aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-02 10:09:42 -0700
committerAdam Soltys <[email protected]>2026-08-02 10:09:42 -0700
commit3205acf157b7f1cfb857a383f374ef4d739d82cc (patch)
treec61dd9e51b230106bc3134a22eef774dc1decd8c
parent6ebae6bce794c8f2db8337cb189b9095975e36ad (diff)
downloadcoinosv3-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
-rw-r--r--build.js2
-rw-r--r--src/app.js2
-rw-r--r--src/concord.js234
-rw-r--r--src/features/index.js3
-rw-r--r--src/features/messages.js415
-rw-r--r--src/i18n.js11
-rw-r--r--src/style.css56
-rw-r--r--tools/concord-genesis.js85
8 files changed, 806 insertions, 2 deletions
diff --git a/build.js b/build.js
index 02eb13d..523d917 100644
--- a/build.js
+++ b/build.js
@@ -181,7 +181,7 @@ export async function buildSwNwc({ minify = true } = {}) {
// feature's code (and its network endpoints) never enters the bundle.
// NB order matters and must match src/features/index.js — nwc sits after ark
// because it drives ark's headless pay/balance seam.
-const ALL_FEATURES = { gifts: 'giftsFeature', ark: 'arkFeature', nostrlogin: 'nostrLoginFeature', names: 'namesFeature', zaps: 'zapsFeature', nwc: 'nwcFeature', sync: 'syncFeature' };
+const ALL_FEATURES = { gifts: 'giftsFeature', ark: 'arkFeature', nostrlogin: 'nostrLoginFeature', names: 'namesFeature', zaps: 'zapsFeature', nwc: 'nwcFeature', sync: 'syncFeature', messages: 'messagesFeature' };
export function enabledFeatures(spec = process.env.HAL_FEATURES) {
if (spec == null) return Object.keys(ALL_FEATURES);
diff --git a/src/app.js b/src/app.js
index 0bce8b6..b31c136 100644
--- a/src/app.js
+++ b/src/app.js
@@ -1954,6 +1954,7 @@ function tabsBar() {
// Watch-only wallets show Send too — it prompts to load the seed to spend.
['send', t('tabSend')],
['history', t('tabHistory')],
+ ...featureAll('tabs'), // feature-provided tabs (e.g. messages) sit before Settings
['settings', t('tabSettings')],
];
return h(
@@ -1982,6 +1983,7 @@ function tabContent() {
case 'send': return sendTab();
case 'history': return historyTab();
case 'settings': return settingsTab();
+ default: return featureHook('tabContent', ui.tab);
}
}
diff --git a/src/concord.js b/src/concord.js
new file mode 100644
index 0000000..909fbd1
--- /dev/null
+++ b/src/concord.js
@@ -0,0 +1,234 @@
+// Concord (CORD-01..06) — private communities on nostr
+// https://github.com/concord-protocol/concord
+//
+// Core derivations and envelope crypto. Everything here is protocol-pure:
+// no DOM, no app state. The messages feature builds on top of this.
+
+import { sha256 } from "@noble/hashes/sha256";
+import { hkdf } from "@noble/hashes/hkdf";
+import { schnorr, secp256k1 } from "@noble/curves/secp256k1";
+import { bytesToHex, hexToBytes } from "@noble/hashes/utils";
+import { finalizeEvent, generateSecretKey, getPublicKey, getEventHash, verifyEvent } from "nostr-tools/pure";
+import { nip44 } from "nostr-tools";
+
+const utf8 = (s) => new TextEncoder().encode(s);
+
+export const PERM = {
+ MANAGE_ROLES: 1n << 0n,
+ MANAGE_CHANNELS: 1n << 1n,
+ MANAGE_METADATA: 1n << 2n,
+ KICK: 1n << 3n,
+ BAN: 1n << 4n,
+ MANAGE_MESSAGES: 1n << 5n,
+ CREATE_INVITE: 1n << 6n,
+ VIEW_AUDIT_LOG: 1n << 8n,
+ MENTION_EVERYONE: 1n << 9n,
+};
+
+// ---- Appendix A derivations (frozen) ----
+
+const be64 = (n) => {
+ let b = new Uint8Array(8);
+ new DataView(b.buffer).setBigUint64(0, BigInt(n));
+ return b;
+};
+
+const concat = (...arrs) => {
+ let out = new Uint8Array(arrs.reduce((n, a) => n + a.length, 0));
+ let o = 0;
+ for (let a of arrs) out.set(a, o), (o += a.length);
+ return out;
+};
+
+// A.1: info = utf8(label) || 0x00 || id[32] || epoch_be[8]? (|| counter byte on scalar retry)
+const hkdf32 = (secret, label, id, epoch, counter) => {
+ let info = concat(utf8(label), new Uint8Array([0]), id);
+ if (epoch !== undefined) info = concat(info, be64(epoch));
+ if (counter !== undefined) info = concat(info, new Uint8Array([counter]));
+ return hkdf(sha256, secret, undefined, info, 32);
+};
+
+const isValidScalar = (b) => {
+ let n = BigInt("0x" + bytesToHex(b));
+ return n > 0n && n < secp256k1.CURVE.n;
+};
+
+// A.2 + A.3: derive a keypair whose x-only pubkey is the stream address
+export const groupKey = (label, secret, id, epoch) => {
+ let sk = hkdf32(secret, label, id, epoch);
+ for (let c = 0; !isValidScalar(sk); c++) sk = hkdf32(secret, label, id, epoch, c);
+ let pk = bytesToHex(schnorr.getPublicKey(sk));
+ return { sk, pk, convKey: nip44.getConversationKey(sk, pk) };
+};
+
+// A.4
+export const communityId = (ownerHex, saltHex) =>
+ bytesToHex(sha256(concat(utf8("concord/community"), hexToBytes(ownerHex), hexToBytes(saltHex))));
+
+const ZERO32 = new Uint8Array(32);
+
+export const controlKey = (root, cid, epoch = 0) =>
+ groupKey("concord/control", root, hexToBytes(cid), epoch);
+export const guestbookKey = (root, cid, epoch = 0) =>
+ groupKey("concord/guestbook", root, hexToBytes(cid), epoch);
+export const channelKey = (secret, channelId, epoch = 0) =>
+ groupKey("concord/channel", secret, hexToBytes(channelId), epoch);
+export const grantEid = (cid, memberHex) =>
+ bytesToHex(hkdf32(hexToBytes(cid), "concord/grant", hexToBytes(memberHex)));
+export const banlistEid = (cid) => bytesToHex(hkdf32(hexToBytes(cid), "concord/banlist", ZERO32));
+
+// ---- CORD-01 envelope ----
+// wrap(1059, signed by stream key) > seal(20013 encrypted | 20014 plaintext, author-signed) > rumor
+
+const rumorWithId = (rumor) => ({ ...rumor, id: getEventHash({ ...rumor, tags: rumor.tags || [] }) });
+
+// Sign a seal around a rumor. `signer` is a raw secret key (Uint8Array) or a
+// { signEvent } adapter (nostr-login extension/bunker). Plaintext seals
+// (control plane) carry the rumor JSON verbatim; encrypted seals
+// nip44-encrypt it under the stream conversation key.
+export const makeSeal = async (rumor, signer, streamConvKey, { plaintext = false } = {}) => {
+ let r = rumorWithId(rumor);
+ let content = plaintext ? JSON.stringify(r) : nip44.encrypt(JSON.stringify(r), streamConvKey);
+ let seal = { kind: plaintext ? 20014 : 20013, content, tags: [], created_at: rumor.created_at };
+ return signer instanceof Uint8Array ? finalizeEvent(seal, signer) : signer.signEvent(seal);
+};
+
+export const makeWrap = (seal, stream, { ephemeral = false } = {}) => {
+ let wrap = {
+ kind: ephemeral ? 21059 : 1059,
+ content: nip44.encrypt(JSON.stringify(seal), stream.convKey),
+ tags: [["p", getPublicKey(generateSecretKey())]],
+ created_at: seal.created_at,
+ };
+ return finalizeEvent(wrap, stream.sk);
+};
+
+export const wrapRumor = async (rumor, signer, stream, opts = {}) =>
+ makeWrap(await makeSeal(rumor, signer, stream.convKey, opts), stream, opts);
+
+// Open a 1059/21059 wrap fetched at a stream address. Returns { rumor, author, seal }
+// or null if any layer fails to parse, decrypt, or verify.
+export const openWrap = (wrap, stream) => {
+ try {
+ let seal = JSON.parse(nip44.decrypt(wrap.content, stream.convKey));
+ if (seal.kind !== 20013 && seal.kind !== 20014) return null;
+ if (!verifyEvent(seal)) return null;
+ let raw = seal.kind === 20014 ? seal.content : nip44.decrypt(seal.content, stream.convKey);
+ let rumor = JSON.parse(raw);
+ if (rumor.pubkey !== seal.pubkey) return null;
+ if (rumor.id !== getEventHash({ ...rumor, tags: rumor.tags || [] })) return null;
+ return { rumor, author: seal.pubkey, seal, raw };
+ } catch {
+ return null;
+ }
+};
+
+// millisecond ordering: created_at * 1000 + ms tag (CORD-02 §4)
+export const eventMs = (rumor) => {
+ let ms = +(rumor.tags?.find((t) => t[0] === "ms")?.[1] ?? 0);
+ if (!(ms >= 0 && ms <= 999)) return null;
+ return rumor.created_at * 1000 + ms;
+};
+
+export const msTags = (nowMs) => {
+ let created_at = Math.floor(nowMs / 1000);
+ return { created_at, ms: ["ms", String(nowMs % 1000)] };
+};
+
+// ---- CORD-04 editions ----
+
+// edition_hash: sha256 over the length-prefixed, domain-separated preimage
+export const editionHash = (eidHex, version, prevHex, contentStr) => {
+ let label = utf8("vector-community/v1/edition");
+ let content = utf8(contentStr);
+ return bytesToHex(
+ sha256(
+ concat(
+ be64(label.length), label,
+ hexToBytes(eidHex),
+ be64(version),
+ prevHex ? concat(new Uint8Array([1]), hexToBytes(prevHex)) : concat(new Uint8Array([0]), ZERO32),
+ be64(content.length), content
+ )
+ )
+ );
+};
+
+export const makeEdition = ({ vsk, eid, version, prev, content, vac }, nowMs) => {
+ let { created_at, ms } = msTags(nowMs);
+ let tags = [["vsk", String(vsk)], ["eid", eid], ["ev", String(version)], ms];
+ if (prev) tags.push(["ep", prev]);
+ if (vac) tags.push(["vac", ...vac]);
+ return { kind: 3308, content, tags, created_at };
+};
+
+// Fold control-plane rumors into per-entity heads.
+// Authority: owner-rooted; we accept owner-signed editions always, and
+// non-owner editions only when a fold of grants/roles authorizes them.
+// Returns { metadata, channels: Map(channelId -> meta), roles, grants, banned:Set }.
+export const foldControl = (entries, { ownerHex, cid }) => {
+ // entries: [{ rumor, author }] — group by (vsk, eid), take highest intact version
+ let byEntity = new Map();
+ for (let e of entries) {
+ let tag = (k) => e.rumor.tags?.find((t) => t[0] === k)?.[1];
+ let vsk = tag("vsk"), eid = tag("eid"), ev = +tag("ev");
+ if (vsk === undefined || !eid || !(ev >= 1)) continue;
+ let key = vsk + ":" + eid;
+ let cur = byEntity.get(key);
+ // Highest version wins; ties: authority first (owner beats non-owner), then lower rumor id.
+ let better =
+ !cur ||
+ ev > cur.ev ||
+ (ev === cur.ev &&
+ ((e.author === ownerHex) > (cur.author === ownerHex) ||
+ (e.author === ownerHex) === (cur.author === ownerHex) && e.rumor.id < cur.rumor.id));
+ if (better) byEntity.set(key, { ...e, vsk, eid, ev });
+ }
+
+ let state = { metadata: null, channels: new Map(), roles: new Map(), grants: new Map(), banned: new Set() };
+ let parse = (e) => { try { return JSON.parse(e.rumor.content); } catch { return null; } };
+
+ // Roles and grants first so later authority checks could use them; for now
+ // hal only honors owner-signed authority (the coinos community's owner is
+ // its sole admin) plus grants the owner signed.
+ for (let e of byEntity.values()) {
+ if (e.author !== ownerHex) continue; // non-owner authority: future work (vac/roster walk)
+ let c = parse(e);
+ if (e.vsk === "1" && c) state.roles.set(e.eid, c);
+ else if (e.vsk === "3" && c) state.grants.set(e.eid, c);
+ }
+ for (let e of byEntity.values()) {
+ if (e.author !== ownerHex) continue;
+ let c = parse(e);
+ if (e.vsk === "0" && c && e.eid === cid) state.metadata = c;
+ else if (e.vsk === "2" && c) state.channels.set(e.eid, c);
+ else if (e.vsk === "4" && Array.isArray(c)) state.banned = new Set(c);
+ }
+ for (let [, ch] of [...state.channels]) if (ch.deleted) state.channels.delete(ch.channel_id);
+ return state;
+};
+
+// ---- CORD-02 §5 guestbook fold ----
+// entries: [{ rumor, author }], returns Map(npub -> { state: 'join'|'leave'|'kick', t })
+export const foldGuestbook = (entries, { nowMs, banned = new Set() } = {}) => {
+ let members = new Map();
+ let consider = (npub, state, t, id) => {
+ let cur = members.get(npub);
+ if (!cur || t > cur.t || (t === cur.t && id < cur.id)) members.set(npub, { state, t, id });
+ };
+ for (let { rumor, author } of entries) {
+ let t = eventMs(rumor);
+ if (t === null || (nowMs && t > nowMs + 3600_000)) continue;
+ if (rumor.kind === 3306 && (rumor.content === "join" || rumor.content === "leave"))
+ consider(author, rumor.content, t, rumor.id);
+ // kicks (3309) and snapshots (3312): honored later with roster ranks; skipped for now
+ }
+ for (let b of banned) members.delete(b);
+ return members;
+};
+
+// Observed presence: any decrypted chat author newer than their latest leave re-enters (CORD-02 §5)
+export const observeAuthor = (members, author, t) => {
+ let cur = members.get(author);
+ if (!cur || (cur.state !== "join" && t > cur.t)) members.set(author, { state: "join", t, id: "" });
+};
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 {} }
+ },
+ };
+}
diff --git a/src/i18n.js b/src/i18n.js
index 5e0138e..bddfeb0 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -157,6 +157,17 @@ const STR = {
tabSend: 'Send',
tabHistory: 'History',
tabSettings: 'Settings',
+ tabMessages: 'Chat',
+ msgSend: 'Send',
+ msgEmpty: 'No messages yet — say hi!',
+ msgPlaceholder: 'Message #{channel}',
+ msgMembers: '{n} members',
+ msgEncrypted: 'End-to-end encrypted',
+ msgAdmin: 'admin',
+ msgEdited: '(edited)',
+ msgDelete: 'Delete message',
+ msgNoIdentity: 'No nostr identity available to sign with',
+ msgSendFailed: 'No relay accepted the message',
copyAddress: 'Copy address',
paymentReceived: 'Payment received!',
tapToProceed: 'Tap anywhere to proceed',
diff --git a/src/style.css b/src/style.css
index f9d2124..998cdd7 100644
--- a/src/style.css
+++ b/src/style.css
@@ -311,3 +311,59 @@ hr.divider { margin: 16px 0; }
.icon-select-item.active { background: var(--accent-soft, rgba(247,147,26,.14)); }
.sel-ico { display: inline-flex; align-items: center; justify-content: center; width: 20px; height: 20px; flex-shrink: 0; font-size: 16px; line-height: 1; }
.sel-ico svg { display: block; }
+
+/* Messages — Concord community chat. */
+.chat-card { padding: 0; overflow: hidden; }
+.chat-head { padding: 14px 16px 10px; border-bottom: 1px solid var(--line); }
+.chat-title { font-weight: 650; }
+.chat-log {
+ height: min(52vh, 460px); overflow-y: auto; padding: 12px 14px;
+ display: flex; flex-direction: column; gap: 10px; background: var(--surface2);
+}
+.chat-row { display: flex; gap: 9px; align-items: flex-start; }
+.chat-row.grouped { margin-top: -6px; }
+.chat-avatar {
+ width: 30px; height: 30px; border-radius: 50%; flex-shrink: 0; object-fit: cover;
+ background: var(--accent-soft);
+}
+.chat-avatar.fallback {
+ display: flex; align-items: center; justify-content: center;
+ font-size: 11px; font-weight: 650; color: var(--muted); text-transform: uppercase;
+}
+.chat-avatar.spacer { background: transparent; height: 0; }
+.chat-body { min-width: 0; flex: 1; }
+.chat-meta { display: flex; align-items: baseline; gap: 7px; margin-bottom: 2px; }
+.chat-name { font-size: 13px; font-weight: 650; }
+.chat-badge {
+ font-size: 10px; font-weight: 650; color: var(--accent-fg); background: var(--accent);
+ border-radius: 5px; padding: 1px 5px; margin-left: 5px; vertical-align: 1px;
+}
+.chat-time { font-size: 11px; color: var(--faint); }
+.chat-bubble {
+ position: relative; display: inline-block; max-width: 100%;
+ background: var(--paper); border: 1px solid var(--line); border-radius: 12px;
+ padding: 7px 11px; font-size: 14px; line-height: 1.45;
+ overflow-wrap: break-word; white-space: pre-wrap;
+}
+.chat-edited { font-size: 11px; color: var(--faint); }
+.chat-del {
+ position: absolute; top: -7px; right: -7px; width: 18px; height: 18px;
+ display: none; align-items: center; justify-content: center;
+ border: 1px solid var(--line-strong); border-radius: 50%;
+ background: var(--paper); color: var(--muted); font-size: 12px; line-height: 1; cursor: pointer;
+}
+.chat-row:hover .chat-del { display: flex; }
+.chat-reacts { display: flex; gap: 5px; margin-top: 3px; }
+.chat-react {
+ font-size: 12px; background: var(--paper); border: 1px solid var(--line);
+ border-radius: 9px; padding: 1px 7px;
+}
+.chat-compose {
+ display: flex; gap: 8px; padding: 10px 12px; border-top: 1px solid var(--line);
+ align-items: center;
+}
+.chat-compose input {
+ flex: 1; min-width: 0; font: inherit; color: var(--ink); background: var(--paper);
+ border: 1px solid var(--line-strong); border-radius: 11px; padding: 10px 13px;
+}
+.chat-compose input:focus-visible { outline: 2px solid var(--accent); outline-offset: -1px; }
diff --git a/tools/concord-genesis.js b/tools/concord-genesis.js
new file mode 100644
index 0000000..797902d
--- /dev/null
+++ b/tools/concord-genesis.js
@@ -0,0 +1,85 @@
+// One-time genesis for a Concord community (CORD-02 §1): publishes the two
+// owner-signed editions (community metadata + #general) and the owner's Join,
+// then prints the join material to embed in the app.
+//
+// bun run tools/concord-genesis.js <owner-privkey-hex> <name> <relay> [relay...]
+
+import { bytesToHex, hexToBytes } from "@noble/hashes/utils";
+import { getPublicKey } from "nostr-tools/pure";
+import { Relay, useWebSocketImplementation } from "nostr-tools/relay";
+import WebSocket from "ws";
+import {
+ communityId,
+ controlKey,
+ guestbookKey,
+ makeEdition,
+ wrapRumor,
+ msTags,
+} from "../src/concord.js";
+
+try { useWebSocketImplementation(WebSocket); } catch {}
+
+let [skHex, name, ...relays] = process.argv.slice(2);
+if (!skHex || !name || !relays.length) {
+ console.error("usage: bun run tools/concord-genesis.js <owner-privkey-hex> <name> <relay...>");
+ process.exit(1);
+}
+
+let ownerSk = hexToBytes(skHex);
+let owner = getPublicKey(ownerSk);
+let ownerSalt = bytesToHex(crypto.getRandomValues(new Uint8Array(32)));
+let communityRoot = crypto.getRandomValues(new Uint8Array(32));
+let cid = communityId(owner, ownerSalt);
+let generalId = bytesToHex(crypto.getRandomValues(new Uint8Array(32)));
+
+let control = controlKey(communityRoot, cid, 0);
+let guestbook = guestbookKey(communityRoot, cid, 0);
+
+let now = Date.now();
+let metadata = makeEdition(
+ { vsk: 0, eid: cid, version: 1, content: JSON.stringify({ name, relays }) },
+ now
+);
+let general = makeEdition(
+ { vsk: 2, eid: generalId, version: 1, content: JSON.stringify({ name: "general", private: false }) },
+ now + 1
+);
+let { created_at, ms } = msTags(now + 2);
+let join = { kind: 3306, content: "join", tags: [ms], created_at };
+
+let events = [
+ await wrapRumor({ ...metadata, pubkey: owner }, ownerSk, control, { plaintext: true }),
+ await wrapRumor({ ...general, pubkey: owner }, ownerSk, control, { plaintext: true }),
+ await wrapRumor({ ...join, pubkey: owner }, ownerSk, guestbook),
+];
+
+for (let url of relays) {
+ try {
+ let r = await Relay.connect(url);
+ for (let e of events) await r.publish(e);
+ r.close();
+ console.error(`published ${events.length} events to ${url}`);
+ } catch (e) {
+ console.error(`FAILED ${url}: ${e.message ?? e}`);
+ }
+}
+
+// Join material (CORD-02 §8) — the bundle's membership subset. Contains the
+// community_root: embedding it in the app makes every app user a member.
+console.log(
+ JSON.stringify(
+ {
+ community_id: cid,
+ owner,
+ owner_salt: ownerSalt,
+ community_root: bytesToHex(communityRoot),
+ root_epoch: 0,
+ channels: [{ id: generalId, name: "general" }],
+ relays,
+ name,
+ },
+ null,
+ 2
+ )
+);
+process.exit(0);