From 78ab79e7527bdab08c59e5ee8ee5dd701c0856e0 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sun, 2 Aug 2026 19:39:00 -0700 Subject: Punk default avatars, header profile button, owner channel creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Everyone gets a face: profiles without a picture fall back to a coinos punk, picked deterministically by the pubkey's last byte — the same 64 webps and formula the coinos app uses (initials show through if the image can't load). Your own avatar moves from the chat home to the app header next to the wallet selector, opening your editable profile from anywhere. Community owners can add channels from the room's + panel: one owner-signed ChannelMetadata edition (CORD-03), and every member's channel switcher grows when the fold lands. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Wvj4aoeMZwA7VUGdJP9Swi --- src/features/messages.js | 63 ++++++++++++++++++++++++++++++++++++------------ src/i18n.js | 2 ++ src/recipient-search.js | 13 +++++++++- src/style.css | 4 +++ 4 files changed, 66 insertions(+), 16 deletions(-) (limited to 'src') diff --git a/src/features/messages.js b/src/features/messages.js index ef70ccd..c70964b 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -23,7 +23,7 @@ import { communityId, parseInviteLink, makeInviteLink, makeInviteBundleEvent, openInviteBundle, } from '../concord.js'; import { makeDM, unwrapDM, wrapDM } from '../dm.js'; -import { makeSearcher, resultRows } from '../recipient-search.js'; +import { makeSearcher, resultRows, fallbackAvatar } from '../recipient-search.js'; import { hexToBytes, bytesToHex } from '@noble/hashes/utils'; import { t } from '../i18n.js'; @@ -107,7 +107,7 @@ export function messagesFeature(ctx) { if (repaintTimer) return; repaintTimer = setTimeout(() => { repaintTimer = null; - if (ui.screen === 'wallet' && ui.chatOpen) render(); + if (ui.screen === 'wallet') render(); }, 80); }; @@ -360,6 +360,25 @@ export function messagesFeature(ctx) { render(); } + // A new channel is one owner-signed ChannelMetadata edition (CORD-03 §2); + // the control-plane fold picks it up and every member's switcher grows. + async function createChannel(room, name) { + name = (name || '').trim().slice(0, 64); + if (!name) return; + const id = await identity(); + if (!id || id.pubkey !== room.jm.owner) { toast(t('msgOwnerOnly')); return; } + const chId = bytesToHex(crypto.getRandomValues(new Uint8Array(32))); + const edition = { + ...makeEdition({ vsk: 2, eid: chId, version: 1, content: JSON.stringify({ name: name.replace(/^#/, ''), private: false }) }, Date.now()), + pubkey: id.pubkey, + }; + const wrap = await wrapRumor(edition, id.signer, room.control, { plaintext: true }); + const ok = await publishOn(room.relays, wrap); + if (!ok) { toast(t('msgSendFailed')); return; } + ui.msgNewChannel = ''; + toast('#' + name.replace(/^#/, '')); + } + // ---- invites ------------------------------------------------------------ function bundleFor(room, creatorPk) { @@ -746,13 +765,15 @@ export function messagesFeature(ctx) { }; const avatar = (pk, cls = 'chat-avatar', clickable = true) => { - const attrs = clickable - ? { class: cls + ' clickable', onClick: (e) => { e.stopPropagation(); openProfile(pk); } } - : { class: cls }; const p = profileOf(pk); - return p && p.picture - ? h('img', { ...attrs, src: p.picture, alt: '' }) - : h('div', { ...attrs, class: attrs.class + ' fallback' }, displayName(pk).slice(0, 2)); + const node = p && p.picture + ? h('img', { class: cls, src: p.picture, alt: '' }) + : fallbackAvatar(h, pk, p && p.name, cls); + if (clickable) { + node.classList.add('clickable'); + node.addEventListener('click', (e) => { e.stopPropagation(); openProfile(pk); }); + } + return node; }; // ---- profiles: view + own kind-0 editor --------------------------------- @@ -925,12 +946,9 @@ export function messagesFeature(ctx) { const kids = []; // Chat takes the whole screen, so home carries the way back to the wallet. - const me = myPubkeys()[0]; - kids.push(h('div', { class: 'row between', style: 'align-items:center' }, - h('div', { class: 'row gap6', style: 'align-items:center' }, - backBtn(() => { ui.chatOpen = false; render(); }), - h('h3', { style: 'margin:0' }, t('tabMessages'))), - me ? avatar(me) : null)); + kids.push(h('div', { class: 'row gap6', style: 'align-items:center' }, + backBtn(() => { ui.chatOpen = false; render(); }), + h('h3', { style: 'margin:0' }, t('tabMessages')))); if (pendingLink) kids.push(linkInviteCard()); for (const [rid, inv] of pendingDirect) kids.push(directInviteCard(rid, inv)); @@ -1162,6 +1180,15 @@ export function messagesFeature(ctx) { class: 'btn-sm', onClick: () => { sendDirectInvite(room, ui.msgInviteTo); ui.msgInviteTo = ''; render(); }, }, t('msgSend'))), + // owners can add channels (an owner-signed control edition) + myPubkeys().includes(room.jm.owner) + ? h('div', { class: 'row gap6' }, + h('input', { + class: 'grow', type: 'text', placeholder: t('msgChannelPlaceholder'), maxlength: '64', + value: ui.msgNewChannel || '', onInput: (e) => { ui.msgNewChannel = e.target.value; }, + }), + h('button', { class: 'btn-sm', onClick: () => createChannel(room, ui.msgNewChannel) }, t('msgCreate'))) + : null, // leaving discards the keys on this identity — two taps, default community exempt builtin ? null : h('div', { class: 'row' }, h('button', { @@ -1218,10 +1245,16 @@ export function messagesFeature(ctx) { // Chat lives behind a header button and takes over the whole screen — // no balance card, no tabs; each view carries its own way back. headerButtons() { - return [h('button', { + const btns = [h('button', { class: 'btn-sm', title: t('tabMessages'), onClick: () => { ui.chatOpen = true; render(); }, }, h('span', { html: CHAT_ICON }))]; + const me = myPubkeys()[0]; + if (me) btns.push(h('button', { + class: 'header-avatar', title: t('profEdit'), + onClick: () => openProfile(me), + }, avatar(me, 'chat-avatar header-ava', false))); + return btns; }, screenView() { if (ui.screen !== 'wallet') return null; diff --git a/src/i18n.js b/src/i18n.js index 8a39f3d..9f23b14 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -169,6 +169,8 @@ const STR = { msgOpen: 'Open', msgNpubPlaceholder: 'npub…', msgSearchPlaceholder: 'Name or npub…', + msgChannelPlaceholder: 'New channel name', + msgOwnerOnly: 'Only the community owner can do that', profEdit: 'Edit profile', profPay: '⚡ Pay', profName: 'Name', diff --git a/src/recipient-search.js b/src/recipient-search.js index d3e69eb..5cc8315 100644 --- a/src/recipient-search.js +++ b/src/recipient-search.js @@ -10,6 +10,17 @@ import { queryOn, parseNostrPubkey, npubOf, PROFILE_RELAYS } from './nostr.js'; +// coinos punks: the deterministic default avatar — same formula the coinos +// app uses (last byte of the pubkey picks one of 64). Initials show through +// if the image can't load (offline). +export const punkUrl = (pk) => + `https://coinos.io/api/public/punks/${Math.floor((parseInt(pk.slice(-2), 16) / 256) * 64) + 1}.webp`; + +export const fallbackAvatar = (h, pk, name, cls) => + h('div', { class: cls + ' fallback' }, + h('img', { class: 'punk', src: punkUrl(pk), alt: '', onError: (e) => { e.target.style.display = 'none'; } }), + (name || npubOf(pk) || '??').slice(0, 2)); + const REGISTRAR = 'https://names.coinos.io'; const SEARCH_RELAYS = ['wss://search.nos.today', 'wss://relay.nostr.band']; const PRIMAL_CACHE = 'wss://cache2.primal.net/v1'; @@ -144,7 +155,7 @@ export function resultRows(h, rows, onPick) { h('div', { class: 'item chat-thread-row', onClick: () => onPick(r) }, r.picture ? h('img', { class: 'chat-avatar', src: r.picture, alt: '' }) - : h('div', { class: 'chat-avatar fallback' }, (r.name || npubOf(r.pk) || '??').slice(0, 2)), + : fallbackAvatar(h, r.pk, r.name, 'chat-avatar'), h('div', { class: 'col grow', style: 'min-width:0;gap:1px' }, h('div', { class: 'chat-name' }, r.name || (npubOf(r.pk) || '').slice(0, 14) + '…'), h('div', { class: 'muted small chat-preview' }, r.address || r.nip05 || (npubOf(r.pk) || '').slice(0, 24) + '…')))); diff --git a/src/style.css b/src/style.css index e5cc666..2323635 100644 --- a/src/style.css +++ b/src/style.css @@ -391,3 +391,7 @@ hr.divider { margin: 16px 0; } .profile-avatar { width: 64px; height: 64px; font-size: 18px; } .zap-chip { display: inline-flex; gap: 5px; align-items: center; cursor: pointer; background: var(--surface2); border: 1px solid var(--line); border-radius: 999px; padding: 1px 8px 1px 2px; } .chat-avatar.mini { width: 18px; height: 18px; font-size: 8px; flex-shrink: 0; } +.chat-avatar.fallback { position: relative; overflow: hidden; } +.chat-avatar.fallback .punk { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; } +.header-avatar { padding: 0; border: none; background: none; cursor: pointer; display: flex; } +.chat-avatar.header-ava { width: 30px; height: 30px; font-size: 11px; } -- cgit v1.2.3