diff options
| author | Adam Soltys <[email protected]> | 2026-10-05 00:27:36 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-05 00:27:36 -0700 |
| commit | 898cb33aba31a37be1e1a5b7e2ded3200dd29180 (patch) | |
| tree | 26045ed4a6f65d994911ed3cf1c48451aa8a94b0 | |
| parent | 609e5d77e7634eb2973b73a677c08f9f2b695951 (diff) | |
| download | coinosv3-898cb33aba31a37be1e1a5b7e2ded3200dd29180.tar.gz coinosv3-898cb33aba31a37be1e1a5b7e2ded3200dd29180.zip | |
Communities wear their icons: Vector's encrypted logo from the metadata, the coinos logo for coinos
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YRC6aKhwAud231ot9FPmhi
| -rw-r--r-- | src/features/messages.js | 40 |
1 files changed, 39 insertions, 1 deletions
diff --git a/src/features/messages.js b/src/features/messages.js index 8001d39..e0c5b4c 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -51,6 +51,7 @@ import { qrSvg } from '../qr.js'; // with the public read-only chat page so the two can never drift. import { COMMUNITY, EPOCH } from '../community.js'; import COMMUNITY_DIRECTORY from '../community-directory.json'; +import { assetUrl } from '../asset-url.js'; const DM_RELAYS = ['wss://relay.coinos.io', 'wss://nos.lol']; const NOTIFIER = 'https://nwcpush.coinos.io'; const APP_BASE = 'https://v3.coinos.io'; @@ -11166,7 +11167,7 @@ export function messagesFeature(ctx) { onPointerdown: () => warmRoom(jm), onClick: () => openRoomWarm(jm, () => { ui.msgView = 'room'; ui.msgCommunity = jm.community_id; ui.msgChannel = null; ui.msgStick = true; }), }, - h('div', { class: 'chat-avatar fallback' }, name.slice(0, 2)), + communityAvatar(jm, room, name), h('div', { class: 'col grow', style: 'min-width:0;gap:1px' }, h('span', { class: 'chat-name' }, name), h('div', { class: 'muted small' }, @@ -11235,6 +11236,42 @@ export function messagesFeature(ctx) { .sort((a, b) => eventMs(b.rumor) - eventMs(a.rumor)); return [...new Set(msgs.map((m) => m.author))].slice(0, 150); } + // A community's face. Vector puts the logo in the vsk-0 metadata as an + // AES-256-GCM blob ({ url, key, nonce, hash }, 16-byte nonce, tag + // appended); a plain URL is taken as is. Decrypted once per session. + // The built-in coinos community wears the coinos logo. + const roomIcons = new Map(); + function communityIcon(jm, room) { + const ic = room?.folded?.metadata?.icon; + if (typeof ic === 'string' && /^(https:|data:image\/)/.test(ic)) return ic; + if (ic && typeof ic === 'object' && /^https:/.test(ic.url || '')) { + const k = ic.url + '#' + ic.key; + if (!roomIcons.has(k)) { + roomIcons.set(k, null); + (async () => { + const key = hexToBytes(ic.key), iv = hexToBytes(ic.nonce); + if (key.length !== 32 || iv.length !== 16) return; + const res = await fetch(ic.url, { signal: AbortSignal.timeout(20_000) }); + if (!res.ok || Number(res.headers.get('content-length') || 0) > 10e6) return; + const ct = await res.arrayBuffer(); + if (ct.byteLength > 10e6) return; + const ck = await crypto.subtle.importKey('raw', key, { name: 'AES-GCM' }, false, ['decrypt']); + const plain = new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, ck, ct)); + if (ic.hash && bytesToHex(new Uint8Array(await crypto.subtle.digest('SHA-256', plain))) !== String(ic.hash).toLowerCase()) return; + roomIcons.set(k, URL.createObjectURL(new Blob([plain]))); + scheduleRepaint(); + })().catch(() => {}); + } + if (roomIcons.get(k)) return roomIcons.get(k); + } + return jm.community_id === COMMUNITY.community_id ? assetUrl('icon-192.png') : null; + } + const communityAvatar = (jm, room, name, cls = '') => { + const src = communityIcon(jm, room); + return src + ? h('img', { class: 'chat-avatar hub-icon' + cls, src, alt: '' }) + : h('div', { class: 'chat-avatar fallback' + cls }, name.slice(0, 2)); + }; // Each face is warmed once per half minute; a later call (the history // still streaming in when the list first warmed) adds whoever is new. function warmRoom(jm) { @@ -11352,6 +11389,7 @@ export function messagesFeature(ctx) { h('div', { class: 'row between chat-head' }, h('div', { class: 'row gap6', style: 'align-items:center;min-width:0' }, backBtn(() => { ui.msgView = 'home'; ui.msgReplyTo = null; ui.msgSheet = null; render(); }), + communityAvatar(jm, room, name, ' header-ava'), h('div', { class: 'col clickable', style: 'gap:2px;min-width:0', onClick: () => { ui.msgMembers = !ui.msgMembers; render(); }, |
