aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-04 08:55:45 -0700
committerAdam Soltys <[email protected]>2026-09-04 08:55:45 -0700
commita6851ae954d819b116aae6b19b175525f1488928 (patch)
treecc7768cd754b2cb6077b2390e18cdbf3501ca289 /src
parent174ce1027327230154b37b80e44c6b2d45929101 (diff)
downloadcoinosv3-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.js14
-rw-r--r--src/community.js17
-rw-r--r--src/features/messages.js22
-rw-r--r--src/i18n.js5
-rw-r--r--src/public-chat.js249
5 files changed, 292 insertions, 15 deletions
diff --git a/src/app.js b/src/app.js
index 5f43e2c..cf5f9bf 100644
--- a/src/app.js
+++ b/src/app.js
@@ -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();
+}