diff options
| author | Adam Soltys <[email protected]> | 2026-08-02 10:09:42 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-08-02 10:09:42 -0700 |
| commit | 3205acf157b7f1cfb857a383f374ef4d739d82cc (patch) | |
| tree | c61dd9e51b230106bc3134a22eef774dc1decd8c /src/features | |
| parent | 6ebae6bce794c8f2db8337cb189b9095975e36ad (diff) | |
| download | coinosv3-3205acf157b7f1cfb857a383f374ef4d739d82cc.tar.gz coinosv3-3205acf157b7f1cfb857a383f374ef4d739d82cc.zip | |
Chat: Concord encrypted community messaging, with a default coinos room
Implements the Concord protocol (CORD-01..04) — private streams, the
control-plane fold, guestbook membership, owner-rooted authority — and
ships a built-in "coinos" community founded by Adam's npub (owner/admin).
Messages sign with the nostr login identity when a signer is live, else
the wallet's NIP-06 key; all encryption rides derived stream keys so
remote signers only ever sign seals.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Wvj4aoeMZwA7VUGdJP9Swi
Diffstat (limited to 'src/features')
| -rw-r--r-- | src/features/index.js | 3 | ||||
| -rw-r--r-- | src/features/messages.js | 415 |
2 files changed, 417 insertions, 1 deletions
diff --git a/src/features/index.js b/src/features/index.js index 70f8421..6a8bb9f 100644 --- a/src/features/index.js +++ b/src/features/index.js @@ -11,6 +11,7 @@ import { namesFeature } from './names.js'; import { zapsFeature } from './zaps.js'; import { syncFeature } from './sync.js'; import { nwcFeature } from './nwc.js'; +import { messagesFeature } from './messages.js'; // NB order is meaningful where hooks stack: gifts' receive takeover and // balance line come before ark's, matching the pre-plugin layout. zaps sits @@ -20,5 +21,5 @@ import { nwcFeature } from './nwc.js'; // names sits between ark and zaps: a pasted user@domain resolves over DNS // (BIP-353) before the zaps feature treats it as a plain Lightning address. export function buildFeatures(ctx) { - return [giftsFeature(ctx), arkFeature(ctx), nostrLoginFeature(ctx), namesFeature(ctx), zapsFeature(ctx), nwcFeature(ctx), syncFeature(ctx)]; + return [giftsFeature(ctx), arkFeature(ctx), nostrLoginFeature(ctx), namesFeature(ctx), zapsFeature(ctx), nwcFeature(ctx), syncFeature(ctx), messagesFeature(ctx)]; } diff --git a/src/features/messages.js b/src/features/messages.js new file mode 100644 index 0000000..3843b36 --- /dev/null +++ b/src/features/messages.js @@ -0,0 +1,415 @@ +// Messages — encrypted community chat over the Concord protocol (CORD-01..04), +// the successor to Marmot/White Noise for Discord-shaped rooms on nostr. +// +// hal ships with one built-in community: "coinos". Its join material (below) +// includes the community_root, so holding the app is holding membership — +// relays only ever see kind-1059 wraps signed by derived stream keys. The +// community was founded by Adam's npub, which makes him owner (position 0) +// and sole authority for the control plane fold. +// +// Messages are signed by the session's nostr login identity when a signer is +// live (extension, or a pasted key before reload), else by the wallet's +// NIP-06 key — both only ever *sign* the seal; all encryption happens under +// stream keys hal already holds, so remote signers need no nip44 round-trips. + +import { subscribeOn, publishOn, fetchNostrProfile, npubOf } from '../nostr.js'; +import { + channelKey, controlKey, guestbookKey, openWrap, wrapRumor, + foldControl, foldGuestbook, observeAuthor, eventMs, msTags, +} from '../concord.js'; +import { hexToBytes } 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 hal 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; +const CACHE_MAX = 50; // messages kept per channel in feature state + +export function messagesFeature(ctx) { + const { h, ui, render, wallet, toast, hook } = ctx; + + const root = hexToBytes(COMMUNITY.community_root); + const cid = COMMUNITY.community_id; + const control = controlKey(root, cid, EPOCH); + const guestbook = guestbookKey(root, cid, EPOCH); + const chStream = (id) => channelKey(root, id, EPOCH); + + // ---- state ------------------------------------------------------------ + + let folded = null; // control-plane fold: { metadata, channels, banned, ... } + let controlEntries = []; + let guestEntries = []; + let byChannel = new Map(); // channelId -> Map(rumorId -> {rumor, author}) + let edits = new Map(); // target rumor id -> latest edit rumor + let deletes = new Set(); // rumor ids deleted by their author + let reactions = new Map(); // target rumor id -> Map(author -> emoji) + let seenWraps = new Set(); + let unsubs = []; + let started = false; + + const profiles = new Map(); // pubkey -> {name, picture} | null while loading + const st = () => wallet.loadFeatureState('messages', { joined: {}, cache: {} }); + const save = (s) => wallet.saveFeatureState('messages', s); + + const channels = () => { + // The control plane is the authority; the baked-in list covers the gap + // until the first fold lands (offline, cold relays). + if (folded && folded.channels.size) + return [...folded.channels.entries()].map(([id, c]) => ({ id, name: c.name })); + return COMMUNITY.channels; + }; + const currentChannel = () => + channels().find((c) => c.id === ui.msgChannel) || channels()[0]; + + // ---- identity ---------------------------------------------------------- + + async function identity() { + const id = hook('nostrLoginIdentity'); + if (id) { + const signer = id.signer || (await hook('nostrLoginResume')); + if (signer) return { pubkey: id.pubkey, signer }; + } + if (wallet.nostr && wallet.nostr.sk) return { pubkey: wallet.nostr.pk, signer: wallet.nostr.sk }; + return null; + } + + // ---- inbound ----------------------------------------------------------- + + const refold = () => { + folded = foldControl(controlEntries, { ownerHex: COMMUNITY.owner, cid }); + }; + + const onChat = (channelId) => (wrap) => { + if (seenWraps.has(wrap.id)) return; + seenWraps.add(wrap.id); + const opened = openWrap(wrap, chStream(channelId)); + if (!opened) return; + const { rumor, author } = opened; + 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 (folded && folded.banned.has(author)) return; + if (rumor.kind === 9) { + let 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')) { + const m = byChannel.get(channelId)?.get(e[1]); + if (!m || m.author === author) deletes.add(e[1]); + } + } else if (rumor.kind === 3302) { + const target = tag('e')?.[1]; + if (target) { + const cur = edits.get(target); + if (!cur || eventMs(rumor) > eventMs(cur.rumor)) edits.set(target, { rumor, author }); + } + } else if (rumor.kind === 7) { + const target = tag('e')?.[1]; + if (target) { + let r = reactions.get(target) || reactions.set(target, new Map()).get(target); + r.set(author, rumor.content); + } + } + const tms = eventMs(rumor); + if (tms) observeAuthor(members, author, tms); + scheduleRepaint(); + }; + + let members = new Map(); + const refoldGuestbook = () => { + members = foldGuestbook(guestEntries, { nowMs: Date.now(), banned: folded?.banned }); + for (const [, msgs] of byChannel) + for (const { rumor, author } of msgs.values()) { + const tms = eventMs(rumor); + if (tms) observeAuthor(members, author, tms); + } + }; + + // Coalesce repaints: relays replay history in a burst on subscribe. + let repaintTimer = null; + const scheduleRepaint = () => { + if (repaintTimer) return; + repaintTimer = setTimeout(() => { + repaintTimer = null; + if (ui.screen === 'wallet' && ui.tab === 'messages') render(); + }, 80); + }; + + function start() { + if (started) return; + started = true; + const relays = COMMUNITY.relays; + unsubs.push( + subscribeOn(relays, { kinds: [1059], authors: [control.pk], limit: 500 }, (wrap) => { + if (seenWraps.has(wrap.id)) return; + seenWraps.add(wrap.id); + const opened = openWrap(wrap, control); + if (!opened || opened.rumor.kind !== 3308) return; + controlEntries.push(opened); + refold(); + scheduleRepaint(); + }) + ); + unsubs.push( + subscribeOn(relays, { kinds: [1059], authors: [guestbook.pk], limit: 500 }, (wrap) => { + if (seenWraps.has(wrap.id)) return; + seenWraps.add(wrap.id); + const opened = openWrap(wrap, guestbook); + if (!opened) return; + guestEntries.push(opened); + refoldGuestbook(); + scheduleRepaint(); + }) + ); + for (const c of COMMUNITY.channels) subChannel(c.id); + // warm the timeline from the local cache so the page paints instantly + const cached = st().cache; + for (const [chId, list] of Object.entries(cached || {})) + for (const m of list) { + let msgs = byChannel.get(chId) || byChannel.set(chId, new Map()).get(chId); + if (!msgs.has(m.rumor.id)) msgs.set(m.rumor.id, m); + } + } + + const subbed = new Set(); + function subChannel(id) { + if (subbed.has(id)) return; + subbed.add(id); + unsubs.push(subscribeOn(COMMUNITY.relays, { kinds: [1059], authors: [chStream(id).pk], limit: 200 }, onChat(id))); + } + + function persistCache() { + const s = st(); + s.cache = {}; + for (const [chId, msgs] of byChannel) { + const list = [...msgs.values()] + .filter((m) => !deletes.has(m.rumor.id)) + .sort((a, b) => eventMs(a.rumor) - eventMs(b.rumor)) + .slice(-CACHE_MAX); + s.cache[chId] = list; + } + save(s); + } + + // ---- outbound ---------------------------------------------------------- + + async function ensureJoined(id) { + const s = st(); + if (s.joined[id.pubkey]) return; + const { created_at, ms } = msTags(Date.now()); + const rumor = { kind: 3306, pubkey: id.pubkey, content: 'join', tags: [ms], created_at }; + const wrap = await wrapRumor(rumor, id.signer, guestbook); + publishOn(COMMUNITY.relays, wrap); + s.joined[id.pubkey] = true; + save(s); + } + + async function sendMessage() { + const text = (ui.msgDraft || '').trim(); + const ch = currentChannel(); + if (!text || !ch || ui.msgSending) return; + const id = await identity(); + if (!id) { toast(t('msgNoIdentity')); return; } + ui.msgSending = true; + render(); + try { + const { created_at, ms } = msTags(Date.now()); + const rumor = { + kind: 9, pubkey: id.pubkey, content: text, + tags: [['channel', ch.id], ['epoch', String(EPOCH)], ms], created_at, + }; + const wrap = await wrapRumor(rumor, id.signer, chStream(ch.id)); + // optimistic insert (the relay echo will dedupe on wrap id) + const opened = openWrap(wrap, chStream(ch.id)); + if (opened) { + seenWraps.add(wrap.id); + let msgs = byChannel.get(ch.id) || byChannel.set(ch.id, new Map()).get(ch.id); + msgs.set(opened.rumor.id, opened); + } + ui.msgDraft = ''; + const ok = await publishOn(COMMUNITY.relays, wrap); + if (!ok) toast(t('msgSendFailed')); + ensureJoined(id).catch(() => {}); + persistCache(); + } catch (e) { + toast(e.message || String(e)); + } finally { + ui.msgSending = false; + ui.msgStick = true; + render(); + } + } + + async function deleteMessage(m) { + const id = await identity(); + if (!id || id.pubkey !== m.author) return; + const ch = currentChannel(); + const { created_at, ms } = msTags(Date.now()); + const rumor = { + kind: 5, pubkey: id.pubkey, content: '', + tags: [['channel', ch.id], ['epoch', String(EPOCH)], ['e', m.rumor.id], ['k', '9'], ms], created_at, + }; + const wrap = await wrapRumor(rumor, id.signer, chStream(ch.id)); + deletes.add(m.rumor.id); + render(); + publishOn(COMMUNITY.relays, wrap); + persistCache(); + } + + // ---- profiles ---------------------------------------------------------- + + function profileOf(pk) { + if (profiles.has(pk)) return profiles.get(pk); + profiles.set(pk, null); + fetchNostrProfile(pk).then((p) => { + profiles.set(pk, p || {}); + scheduleRepaint(); + }).catch(() => profiles.set(pk, {})); + return null; + } + + const displayName = (pk) => { + const p = profileOf(pk); + if (p && p.name) return p.name; + return npubOf(pk).slice(0, 12) + '…'; + }; + + // ---- view -------------------------------------------------------------- + + const timeLabel = (tms) => { + const d = new Date(tms); + const today = new Date().toDateString() === d.toDateString(); + return today + ? d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }) + : d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + + ' ' + d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); + }; + + function messageRows(chId, myPk) { + const msgs = [...(byChannel.get(chId)?.values() || [])] + .filter((m) => !deletes.has(m.rumor.id)) + .filter((m) => !(folded && folded.banned.has(m.author))) + .sort((a, b) => eventMs(a.rumor) - eventMs(b.rumor)); + if (!msgs.length) + return [h('div', { class: 'muted small', style: 'text-align:center;padding:24px 0' }, t('msgEmpty'))]; + let lastAuthor = null, lastT = 0; + return msgs.map((m) => { + const tms = eventMs(m.rumor); + const mine = m.author === myPk; + const edit = edits.get(m.rumor.id); + const text = edit && edit.author === m.author ? edit.rumor.content : m.rumor.content; + const grouped = m.author === lastAuthor && tms - lastT < 5 * 60_000; + lastAuthor = m.author; lastT = tms; + const p = profileOf(m.author); + const reacts = reactions.get(m.rumor.id); + const counts = new Map(); + if (reacts) for (const emoji of reacts.values()) counts.set(emoji, (counts.get(emoji) || 0) + 1); + return h( + 'div', { class: 'chat-row' + (mine ? ' mine' : '') + (grouped ? ' grouped' : '') }, + grouped + ? h('div', { class: 'chat-avatar spacer' }) + : p && p.picture + ? h('img', { class: 'chat-avatar', src: p.picture, alt: '' }) + : h('div', { class: 'chat-avatar fallback' }, displayName(m.author).slice(0, 2)), + h('div', { class: 'chat-body' }, + grouped ? null : h('div', { class: 'chat-meta' }, + h('span', { class: 'chat-name' + (m.author === COMMUNITY.owner ? ' owner' : '') }, + displayName(m.author), + m.author === COMMUNITY.owner ? h('span', { class: 'chat-badge' }, t('msgAdmin')) : null), + h('span', { class: 'chat-time' }, timeLabel(tms))), + h('div', { class: 'chat-bubble' }, + text, + edit ? h('span', { class: 'chat-edited' }, ' ', t('msgEdited')) : null, + mine + ? h('button', { + class: 'chat-del', title: t('msgDelete'), + onClick: () => deleteMessage(m), + }, '×') + : null), + counts.size + ? h('div', { class: 'chat-reacts' }, + [...counts.entries()].map(([emoji, n]) => + h('span', { class: 'chat-react' }, emoji, n > 1 ? ' ' + n : ''))) + : null) + ); + }); + } + + function messagesTab() { + start(); + const ch = currentChannel(); + const myId = hook('nostrLoginIdentity'); + const myPk = myId ? myId.pubkey : wallet.nostr && wallet.nostr.pk; + const name = (folded && folded.metadata && folded.metadata.name) || COMMUNITY.name; + const memberCount = [...members.values()].filter((m) => m.state === 'join').length; + + // pin to bottom once the burst of history lands, unless the user scrolled up + queueMicrotask(() => { + const log = document.querySelector('.chat-log'); + if (log && ui.msgStick !== false) log.scrollTop = log.scrollHeight; + }); + + return h('div', { class: 'card col chat-card' }, + h('div', { class: 'row between chat-head' }, + h('div', { class: 'col', style: 'gap:2px' }, + h('div', { class: 'chat-title' }, name), + h('div', { class: 'muted small' }, + memberCount ? t('msgMembers', { n: memberCount }) : t('msgEncrypted'))), + channels().length > 1 + ? h('div', { class: 'seg' }, + channels().map((c) => + h('button', { + class: c.id === ch.id ? 'active' : '', + onClick: () => { ui.msgChannel = c.id; ui.msgStick = true; subChannel(c.id); render(); }, + }, '#' + c.name))) + : h('div', { class: 'tag' }, '#' + ch.name)), + h('div', { + class: 'chat-log', + onScroll: (e) => { + const el = e.target; + ui.msgStick = el.scrollHeight - el.scrollTop - el.clientHeight < 60; + }, + }, ...(ch ? messageRows(ch.id, myPk) : [])), + h('div', { class: 'chat-compose' }, + h('input', { + class: 'grow', type: 'text', id: 'msg-draft', + placeholder: t('msgPlaceholder', { channel: ch ? ch.name : '' }), + value: ui.msgDraft || '', + maxlength: '2000', + onInput: (e) => { ui.msgDraft = e.target.value; }, + onKeydown: (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } }, + }), + h('button', { + class: 'btn-primary btn-sm', disabled: ui.msgSending, + onClick: sendMessage, + }, ui.msgSending ? h('span', { class: 'spinner sm' }) : t('msgSend')))); + } + + return { + id: 'messages', + tabs: () => [['messages', t('tabMessages')]], + tabContent(tab) { + if (tab !== 'messages') return null; + return messagesTab(); + }, + stop() { + for (const u of unsubs) { try { u(); } catch {} } + unsubs = []; + started = false; + subbed.clear(); + if (byChannel.size) { try { persistCache(); } catch {} } + }, + }; +} |
