diff options
| author | Adam Soltys <[email protected]> | 2026-09-04 08:55:45 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-09-04 08:55:45 -0700 |
| commit | a6851ae954d819b116aae6b19b175525f1488928 (patch) | |
| tree | cc7768cd754b2cb6077b2390e18cdbf3501ca289 /src/public-chat.js | |
| parent | 174ce1027327230154b37b80e44c6b2d45929101 (diff) | |
| download | coinosv3-a6851ae954d819b116aae6b19b175525f1488928.tar.gz coinosv3-a6851ae954d819b116aae6b19b175525f1488928.zip | |
Public read-only coinos community chat at /chat — a share-anywhere link that shows the live chat history to anyone, logged in or not, with a "Sign in to chat" button ("Open coinos" when the device already has an account). No server-side decoder needed: the community's join material was already public by construction (baked into the client bundle — every coinos user is meant to be a member), so the page derives the stream keys and decrypts wraps client-side exactly like the app's Chat tab, worker-offloaded with the same sliced fallback. Renders messages + edits + deletes + reactions and honors the folded control state's ban list; presence/typing/guestbook deliberately omitted. Profiles come from ONE batched kind-0 query per burst (a REQ per author tripped relay concurrency limits). Plumbing: the join material moved to src/community.js (shared with messages.js so they can never drift), /chat is excluded from the single-segment profile deep-link (which was eating the path and rewriting the URL before the route ran), and app renders stand down in public-chat mode so a feature-init render can't paint onboarding over the page. Verified headless against the live relays: 59 messages decrypt, ban-fold applies, names resolve
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01MGzU12QrAA4G6BYGzGVdXz
Diffstat (limited to 'src/public-chat.js')
| -rw-r--r-- | src/public-chat.js | 249 |
1 files changed, 249 insertions, 0 deletions
diff --git a/src/public-chat.js b/src/public-chat.js new file mode 100644 index 0000000..21b372a --- /dev/null +++ b/src/public-chat.js @@ -0,0 +1,249 @@ +// Public read-only view of the default coinos community — served at /chat. +// +// No wallet, no login, no server-side decoder: the community's join material +// (community.js) ships in this bundle — the root is deliberately public-ish, +// every coinos user is meant to be a member — so this page derives the +// stream keys and decrypts wraps right in the visitor's browser, exactly +// like the app's Chat tab does. Reading is open; POSTING still requires a +// signed seal from a real identity, which is what the sign-in button is for. +// +// Deliberately lean: plain DOM (append/rebuild, no morphing), messages + +// edits + deletes + reactions only (no presence, typing, or guestbook), and +// the moderation that matters publicly — the folded control state's ban list +// hides banned members' messages here just as it does in the app. + +import { subscribeOn, queryOn, npubOf, openWrapsOffthread, PROFILE_RELAYS } from './nostr.js'; +import { channelKey, controlKey, openWrap, foldControl, eventMs } from './concord.js'; +import { COMMUNITY, EPOCH } from './community.js'; +import { hexToBytes } from '@noble/hashes/utils'; +import { timeAgo } from './format.js'; +import { t } from './i18n.js'; + +// Same shape as the app's h() for the attributes this page uses. +function h(tag, attrs = {}, ...children) { + const e = document.createElement(tag); + for (const [k, v] of Object.entries(attrs || {})) { + if (v == null || v === false) continue; + if (k === 'class') e.className = v; + else if (k.startsWith('on') && typeof v === 'function') e[k.toLowerCase()] = v; + else e.setAttribute(k, v); + } + for (const c of children.flat()) { + if (c == null || c === false || c === true) continue; + e.append(c.nodeType ? c : document.createTextNode(String(c))); + } + return e; +} + +export function mountPublicChat() { + const root = document.getElementById('app'); + const rootBytes = hexToBytes(COMMUNITY.community_root); + const cid = COMMUNITY.community_id; + + // ---- state --------------------------------------------------------------- + const byChannel = new Map(); // channelId -> Map(rumorId -> { rumor, author }) + const edits = new Map(); // rumorId -> { rumor, author } + const deletes = new Map(); // rumorId -> Set(author) + const reactions = new Map(); // rumorId -> Map(author -> emoji) + const controlEntries = []; + let folded = null; // foldControl result: banned set + channel renames + let foldT = 0; // fold-once-per-burst debounce, same as the app's rooms + const profiles = new Map(); // pk -> { name, picture } | null while loading + const seen = new Set(); + let activeChannel = (COMMUNITY.channels[0] || {}).id; + + // ---- decrypt (worker first, sliced main-thread fallback) ----------------- + function openBg(wrap, stream, cb) { + const job = openWrapsOffthread([wrap], stream.convKey); + if (!job) { cb(openWrap(wrap, stream)); return; } + job.then((r) => cb(r ? r[0] : openWrap(wrap, stream))); + } + + // ---- repaint (debounced full rebuild of the log) -------------------------- + let paintT = 0; + const schedule = () => { clearTimeout(paintT); paintT = setTimeout(paint, 120); }; + + // Profiles fetched in one batched kind-0 query per burst of new authors — + // a REQ per author tripped relay concurrency limits with a full backlog. + const pendingPks = new Set(); + let profT = 0; + function profileOf(pk) { + if (profiles.has(pk)) return profiles.get(pk); + profiles.set(pk, null); + pendingPks.add(pk); + clearTimeout(profT); + profT = setTimeout(async () => { + const batch = [...pendingPks]; + pendingPks.clear(); + try { + const evs = await queryOn([...new Set([...PROFILE_RELAYS, ...COMMUNITY.relays])], { kinds: [0], authors: batch }, 3500); + for (const ev of evs.sort((a, b) => a.created_at - b.created_at)) { // newest overwrites + try { + const p = JSON.parse(ev.content); + profiles.set(ev.pubkey, { name: p.display_name || p.name, picture: p.picture }); + } catch {} + } + for (const pk2 of batch) if (!profiles.get(pk2)) profiles.set(pk2, {}); + } catch {} + schedule(); + }, 300); + return null; + } + const nameOf = (pk) => { + const p = profileOf(pk); + return (p && p.name) || (npubOf(pk) || pk).slice(0, 12); + }; + const avatar = (pk) => { + const p = profileOf(pk); + if (p && p.picture) return h('img', { class: 'chat-avatar', src: p.picture, alt: '' }); + return h('div', { class: 'chat-avatar fallback' }, nameOf(pk).slice(0, 2)); + }; + + function onRumor(channelId, { rumor, author }) { + const tag = (k) => rumor.tags?.find((x) => x[0] === k); + // CORD-03 §3: the rumor must commit to the channel/epoch that decrypted it + if (tag('channel')?.[1] !== channelId || tag('epoch')?.[1] !== String(EPOCH)) return; + if (rumor.kind === 9) { + const msgs = byChannel.get(channelId) || byChannel.set(channelId, new Map()).get(channelId); + msgs.set(rumor.id, { rumor, author }); + } else if (rumor.kind === 5) { + for (const e of rumor.tags.filter((x) => x[0] === 'e')) { + (deletes.get(e[1]) || deletes.set(e[1], new Set()).get(e[1])).add(author); + } + } else if (rumor.kind === 3302) { + const target2 = tag('e')?.[1]; + if (target2) { + const cur = edits.get(target2); + if (!cur || eventMs(rumor) > eventMs(cur.rumor)) edits.set(target2, { rumor, author }); + } + } else if (rumor.kind === 7) { + const target2 = tag('e')?.[1]; + if (target2) (reactions.get(target2) || reactions.set(target2, new Map()).get(target2)).set(author, rumor.content); + } else { + return; + } + schedule(); + } + + // ---- subscriptions --------------------------------------------------------- + const control = controlKey(rootBytes, cid, COMMUNITY.root_epoch || EPOCH); + subscribeOn(COMMUNITY.relays, { kinds: [1059], authors: [control.pk], limit: 500 }, (wrap) => { + if (seen.has(wrap.id)) return; + seen.add(wrap.id); + openBg(wrap, control, (opened) => { + if (!opened || opened.rumor.kind !== 3308) return; + controlEntries.push(opened); + clearTimeout(foldT); + foldT = setTimeout(() => { + folded = foldControl(controlEntries, { ownerHex: COMMUNITY.owner, cid }); + schedule(); + }, 250); + }); + }); + const streams = new Map(); // channelId -> derived stream key + for (const c of COMMUNITY.channels) { + const stream = channelKey(rootBytes, c.id, EPOCH); + streams.set(c.id, stream); + subscribeOn(COMMUNITY.relays, { kinds: [1059], authors: [stream.pk], limit: 200 }, (wrap) => { + if (seen.has(wrap.id)) return; + seen.add(wrap.id); + openBg(wrap, stream, (opened) => { if (opened) onRumor(c.id, opened); }); + }); + } + + // ---- view ------------------------------------------------------------------ + const channelList = () => { + // control-plane renames/additions win over the baked-in genesis list + if (folded && folded.channels && folded.channels.size) { + return [...folded.channels.entries()].map(([id, c]) => ({ id, name: c.name })); + } + return COMMUNITY.channels; + }; + + const hasAccount = (() => { + try { + return !!(sessionStorage.getItem('btc-wallet-accounts') || (JSON.parse(localStorage.getItem('btc-wallet-watch') || '[]')).length); + } catch { return false; } + })(); + + const log = h('div', { class: 'chat-log', style: 'flex:1 1 auto' }); + const tabs = h('div', { class: 'row gap6', style: 'flex-wrap:wrap' }); + + function paint() { + // channel tabs (only when there's more than one) + tabs.replaceChildren(); + const chans = channelList(); + if (chans.length > 1) { + for (const c of chans) { + tabs.append(h('button', { + class: 'btn-sm' + (c.id === activeChannel ? ' btn-primary' : ''), + onClick: () => { + if (!streams.has(c.id)) { + const stream = channelKey(rootBytes, c.id, EPOCH); + streams.set(c.id, stream); + subscribeOn(COMMUNITY.relays, { kinds: [1059], authors: [stream.pk], limit: 200 }, (wrap) => { + if (seen.has(wrap.id)) return; + seen.add(wrap.id); + openBg(wrap, stream, (opened) => { if (opened) onRumor(c.id, opened); }); + }); + } + activeChannel = c.id; + paint(); + }, + }, '#' + c.name)); + } + } + const stick = log.scrollHeight - log.scrollTop - log.clientHeight < 60 || !log.childElementCount; + const banned = (folded && folded.banned) || new Set(); + const rows = [...(byChannel.get(activeChannel) || new Map()).values()] + .filter(({ rumor, author }) => !banned.has(author) + && !(deletes.get(rumor.id) && deletes.get(rumor.id).has(author))) + .sort((a, b) => (eventMs(a.rumor) || 0) - (eventMs(b.rumor) || 0)); + log.replaceChildren(); + if (!rows.length) { + log.append(h('div', { class: 'small muted center', style: 'padding:30px 0' }, + h('span', { class: 'spinner sm', style: 'margin-right:8px' }), t('publicChatLoading'))); + } + let prev = null; + for (const m of rows) { + const ms = eventMs(m.rumor) || m.rumor.created_at * 1000; + const grouped = prev && prev.author === m.author + && ms - (eventMs(prev.rumor) || 0) < 5 * 60_000; + prev = m; + const edit = edits.get(m.rumor.id); + const content = edit && edit.author === m.author ? edit.rumor.content : m.rumor.content; + const reacts = reactions.get(m.rumor.id); + log.append(h('div', { class: 'chat-row' + (grouped ? ' grouped' : '') }, + grouped ? h('div', { class: 'chat-avatar spacer' }) : avatar(m.author), + h('div', { class: 'chat-body' }, + grouped ? null : h('div', { class: 'chat-meta' }, + h('span', { class: 'chat-name' + (m.author === COMMUNITY.owner ? ' owner' : '') }, nameOf(m.author)), + m.author === COMMUNITY.owner ? h('span', { class: 'chat-badge' }, t('msgAdmin')) : null, + h('span', { class: 'chat-time' }, timeAgo(ms / 1000))), + h('div', { class: 'chat-bubble' }, content, + edit && edit.author === m.author ? h('span', { class: 'chat-edited' }, ' ' + t('msgEdited')) : null), + reacts && reacts.size ? h('div', { class: 'chat-reacts' }, + ...[...[...reacts.values()].reduce((m2, e) => m2.set(e, (m2.get(e) || 0) + 1), new Map()).entries()] + .map(([emoji, n]) => h('span', { class: 'chat-react' }, emoji + (n > 1 ? ' ' + n : '')))) : null))); + } + if (stick) log.scrollTop = log.scrollHeight; + } + + // ---- shell ------------------------------------------------------------------- + // #app is already the centered 560px column; .chat-card manages its own + // viewport height and .chat-log scrolls — the same shell the app's Chat + // tab uses, so this page inherits its look for free. + document.title = 'coinos chat'; + root.replaceChildren( + h('div', { class: 'chat-card card' }, + h('div', { class: 'chat-head' }, + h('div', { class: 'row between', style: 'align-items:center;gap:10px' }, + h('div', { class: 'col', style: 'gap:2px;min-width:0' }, + h('div', { class: 'chat-title' }, t('publicChatTitle', { name: COMMUNITY.name })), + h('div', { class: 'small muted' }, t('publicChatDesc'))), + h('button', { class: 'btn-primary btn-sm', style: 'flex-shrink:0', onClick: () => { location.href = '/'; } }, + hasAccount ? t('publicChatOpenApp') : t('publicChatSignIn'))), + tabs), + log)); + paint(); +} |
