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 | |
| 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')
| -rw-r--r-- | src/app.js | 14 | ||||
| -rw-r--r-- | src/community.js | 17 | ||||
| -rw-r--r-- | src/features/messages.js | 22 | ||||
| -rw-r--r-- | src/i18n.js | 5 | ||||
| -rw-r--r-- | src/public-chat.js | 249 |
5 files changed, 292 insertions, 15 deletions
@@ -428,7 +428,12 @@ function animatedAmount(key, sat) { let _carDragging = false; // a balance-carousel drag is in progress let _renderDeferred = false; +// /chat runs the standalone public community view: it owns #app outright, +// and every app render (feature init, emitter ticks) must stand down or the +// onboarding screen paints over the chat moments after it mounts. +let _publicChatMode = false; function render() { + if (_publicChatMode) return; // A background render mid carousel-drag rebuilds the strip; the morph then // strips the drag's inline scroll-snap-type:none, so mandatory snap yanks // the card back to its origin — the "jump partway through" stutter (a @@ -4950,6 +4955,15 @@ window.addEventListener('load', () => { }); loadLocale(getLang()).finally(async () => { applyBootAutoLogout(); // clear an overdue session before we read it for claim targets + // Public read-only community chat: no wallet, no login — the join material + // ships in the bundle, so the page decrypts client-side (src/public-chat.js). + // A share-anywhere link; its sign-in button leads back to the normal app. + if (location.pathname === '/chat') { + _publicChatMode = true; // app renders stand down — the page owns #app + const m = await import('./public-chat.js'); + m.mountPublicChat(); + return; + } // A gift link's feature is deferred — wait for it before asking who owns // the URL, or the claim would fall through to a normal boot and be lost. if (/^\/(g|ag|lg)\//.test(location.pathname)) await _deferredReady; diff --git a/src/community.js b/src/community.js new file mode 100644 index 0000000..544cd43 --- /dev/null +++ b/src/community.js @@ -0,0 +1,17 @@ +// The default coinos community's join material (CORD-02 §8) — genesis output +// of tools/concord-genesis.js. The community_root here is deliberately +// public-ish: every coinos user is meant to be a member, and the public +// read-only chat page (/chat) decrypts with it too. Shared by the messages +// feature and src/public-chat.js so the two can never drift. +export const COMMUNITY = { + community_id: 'b517fb4ba04c4c4eac2bd486ee800d1a8644fcdca5c5643098062e7733ee4986', + owner: '98ae4da926c471c23fd12d1ebdd5839ba82917baa618e184e0c9916d93dcf4f7', + owner_salt: '466450cd6cd0e6991a5acea091c5bc9e9a1c1ba27a970e64d2af4860e7f60cb1', + community_root: '58b2ce26eba30fbd19d9a57bce4c61e65838686998f10bba1d3e822fb72b372e', + root_epoch: 0, + channels: [{ id: '56bf8b96c1a3768c85444873df507cdbc3275fcbc21996af09e60003f850f85c', name: 'general' }], + relays: ['wss://relay.coinos.io', 'wss://nos.lol'], + name: 'coinos', +}; + +export const EPOCH = 0; diff --git a/src/features/messages.js b/src/features/messages.js index e246b78..6fc1011 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -29,21 +29,9 @@ import { getNetwork } from '../api.js'; import { hexToBytes, bytesToHex } from '@noble/hashes/utils'; import { t } from '../i18n.js'; -// Genesis output of tools/concord-genesis.js — the coinos community's join -// material (CORD-02 §8). The community_root here is deliberately public-ish: -// every coinos user is meant to be a member of the default community. -const COMMUNITY = { - community_id: 'b517fb4ba04c4c4eac2bd486ee800d1a8644fcdca5c5643098062e7733ee4986', - owner: '98ae4da926c471c23fd12d1ebdd5839ba82917baa618e184e0c9916d93dcf4f7', - owner_salt: '466450cd6cd0e6991a5acea091c5bc9e9a1c1ba27a970e64d2af4860e7f60cb1', - community_root: '58b2ce26eba30fbd19d9a57bce4c61e65838686998f10bba1d3e822fb72b372e', - root_epoch: 0, - channels: [{ id: '56bf8b96c1a3768c85444873df507cdbc3275fcbc21996af09e60003f850f85c', name: 'general' }], - relays: ['wss://relay.coinos.io', 'wss://nos.lol'], - name: 'coinos', -}; - -const EPOCH = 0; +// The coinos community's join material lives in ../community.js — shared +// with the public read-only chat page so the two can never drift. +import { COMMUNITY, EPOCH } from '../community.js'; const DM_RELAYS = ['wss://relay.coinos.io', 'wss://nos.lol']; const NOTIFIER = 'https://nwcpush.coinos.io'; const APP_BASE = 'https://v3.coinos.io'; @@ -811,6 +799,10 @@ export function messagesFeature(ctx) { const urlProfile = (() => { if (typeof location === 'undefined' || urlInvite) return null; const m = location.pathname.match(/^\/([A-Za-z0-9._-]{1,64})\/?$/); + // reserved app routes are never usernames — /chat is the public + // community page (app.js routes it), and eating it here rewrote the URL + // to / before that route ever saw it + if (m && ['chat'].includes(m[1])) return null; return m ? m[1] : null; })(); if (urlProfile) { try { history.replaceState(null, '', '/'); } catch {} } diff --git a/src/i18n.js b/src/i18n.js index 332ee39..1ed6abe 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -179,6 +179,11 @@ const STR = { msgNewChannel: 'New channel', msgAdmin: 'admin', msgEdited: '(edited)', + publicChatTitle: '{name} community', + publicChatDesc: 'Live chat — read along, sign in to join the conversation', + publicChatSignIn: 'Sign in to chat', + publicChatOpenApp: 'Open coinos', + publicChatLoading: 'Loading messages…', msgDelete: 'Delete message', msgNoIdentity: "Your nostr signer isn't connected — reconnect it to send as yourself", msgSendFailed: 'No relay accepted the message', 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(); +} |
