// NIP-29 relay-based groups in the app: the fourth way a group chat can // travel, beside White Noise, Cordn and Concord. A group lives on ONE relay, // which keeps its membership and signs its metadata (kinds 39000 name, // 39001 admins, 39002 members); its messages are ordinary signed events // (kind 9 chat, 7 reactions, 5 deletions) carrying an `h` tag with the // group's id. Nothing is encrypted: the relay (and, in an open group, // anyone) can read it. Membership is asked for with kind 9021 and given up // with 9022; the relay decides. // // The same interface as marmot-client.js and cordn-client.js, so the group // screens in features/messages.js drive all three (`g.nip29` says which). // Groups joined here and in other clients are one list: NIP-51 kind 10009 // ("simple groups"), read at start and written back on join and leave — // replaceable, so never written without first reading the newest copy. import { subscribeAuthed, publishOn, queryOn, finalizeEvent } from './nostr.js'; import { decode as nip19decode } from 'nostr-tools/nip19'; import { getEventHash } from 'nostr-tools/pure'; import { marmotStore } from './marmot-store.js'; import { dlog } from './debug.js'; const LIST = 10009; const CHAT_KINDS = [9, 7, 5, 9000, 9001, 9021, 9022]; const META_KINDS = [39000, 39001, 39002]; const LOG_MAX = 600; // events held per group in memory const KEEP = 200; // ...and on disk, so a reload paints at once const now = () => Math.floor(Date.now() / 1000); const GID = /^[A-Za-z0-9_-]{1,128}$/; // The relay's URL, normalized: wss://host[/path], no trailing slash. export function normRelay(u) { let x = String(u || '').trim(); if (!x) return null; if (!/^wss?:\/\//i.test(x)) x = 'wss://' + x.replace(/^\/+/, ''); try { const url = new URL(x); if (!/^wss?:$/.test(url.protocol) || !url.hostname.includes('.') && url.hostname !== 'localhost' && !/^\d/.test(url.hostname)) return null; return (url.protocol + '//' + url.host + url.pathname).replace(/\/+$/, '').toLowerCase(); } catch { return null; } } // A group's address in whatever form someone pastes it: host'id (the // NIP-29 form), wss://host'id, an naddr of its kind 39000, or a web link // from a client (chachi.chat//, flotilla's /spaces//, …). export function parseGroupAddress(input) { let s = String(input || '').trim().replace(/^nostr:/i, ''); if (!s) return null; if (/^naddr1/i.test(s)) { try { const d = nip19decode(s); if (d.type === 'naddr' && d.data.kind === 39000 && GID.test(d.data.identifier) && d.data.relays?.length) { const relay = normRelay(d.data.relays[0]); return relay ? { relay, gid: d.data.identifier } : null; } } catch {} return null; } const q = s.match(/^(wss?:\/\/)?([^'\s/]+(?:\/[^'\s]*)?)'([A-Za-z0-9_-]+)$/); if (q) { const relay = normRelay((q[1] || 'wss://') + q[2]); return relay && GID.test(q[3]) ? { relay, gid: q[3] } : null; } if (/^https?:\/\//i.test(s)) { try { const u = new URL(s); const parts = u.pathname.split('/').filter(Boolean).map(decodeURIComponent); for (let i = 0; i < parts.length - 1; i++) { const host = parts[i].replace(/^wss?:\/\//i, ''); if (host.includes('.') && GID.test(parts[i + 1])) { const relay = normRelay(host); if (relay) return { relay, gid: parts[i + 1] }; } } } catch {} } return null; } const keyOf = (relay, gid) => relay + "'" + gid; // listRelays(deep): where our kind 10009 is read and written. export function nip29Client({ scope, pubkey, identity, on, listRelays, store = marmotStore(scope + '|nip29') }) { const groups = new Map(); // key -> record let loaded = null, stopped = false, list = null; // list: { tags, at } const subs = new Map(); // key -> unsubscribe const leftNow = new Set(); // left this session: a list read still in flight mustn't bring them back const emit = (type, g, data) => { try { on(type, g, data); } catch (e) { dlog('nip29: listener', e); } }; // ---- signing ---- async function sign(tmpl, { interactive = true } = {}) { const id = await identity(); if (!id || !id.signer || id.pubkey !== pubkey) throw new Error('no signer'); if (id.signer instanceof Uint8Array) return finalizeEvent(tmpl, id.signer); if (id.signer.interactive && !interactive) throw new Error('signer is interactive'); return id.signer.signEvent(tmpl); } // NIP-42: a private group shows itself, and any group takes a post, only // from a signed-in member. Never a trip through another app unasked. const onauth = (tmpl) => sign(tmpl, { interactive: false }); // ---- storage ---- function record(relay, gid) { const id = keyOf(relay, gid); let g = groups.get(id); if (!g) { g = { id, nip29: true, relay, gid, log: [], at: now(), meta: {}, admins: [], members: [], metaAt: {} }; groups.set(id, g); } return g; } let flushTimer = 0; const dirty = new Set(); const touch = (g) => { dirty.add(g.id); clearTimeout(flushTimer); flushTimer = setTimeout(flush, 600); }; async function flush() { clearTimeout(flushTimer); const ids = [...dirty]; dirty.clear(); await Promise.all(ids.map((id) => { const g = groups.get(id); if (!g) return store.del('g:' + id); const { log, ...rest } = g; return store.put('g:' + id, { ...rest, log: log.filter((e) => !e.pending).slice(-KEEP) }); })).catch((e) => dlog('nip29: save', e)); } function load() { if (!loaded) loaded = store.all().then((all) => { for (const [k, v] of all) if (k.startsWith('g:') && v && v.relay && v.gid) groups.set(v.id, { ...v, log: v.log || [] }); const l = all.get('list'); if (l && Array.isArray(l.tags)) list = l; }).catch((e) => dlog('nip29: store', e)); return loaded; } // ---- events in ---- // A reply in NIP-29 chat quotes what it answers (`q`); the group screens // read a reply's target from its first `e`, so one is added for display. function display(ev) { if (ev.kind !== 9 || ev.tags.some((x) => x[0] === 'e')) return ev; const q = ev.tags.find((x) => x[0] === 'q' && /^[0-9a-f]{64}$/.test(x[1] || '')); return q ? { ...ev, tags: [...ev.tags, ['e', q[1]]] } : ev; } function take(g, ev) { if (META_KINDS.includes(ev.kind)) { if ((g.metaAt[ev.kind] || 0) >= ev.created_at) return; g.metaAt[ev.kind] = ev.created_at; const tag = (k) => (ev.tags.find((x) => x[0] === k) || [])[1]; if (ev.kind === 39000) { g.meta = { name: tag('name') || '', about: tag('about') || '', picture: tag('picture') || '', closed: ev.tags.some((x) => x[0] === 'closed'), private: ev.tags.some((x) => x[0] === 'private') }; } else { const pks = [...new Set(ev.tags.filter((x) => x[0] === 'p' && /^[0-9a-f]{64}$/.test(x[1] || '')).map((x) => x[1]))]; if (ev.kind === 39001) g.admins = pks; else g.members = pks; } touch(g); emit('meta', g); return; } if (g.log.some((e) => e.id === ev.id && !e.pending)) return; const i = g.log.findIndex((e) => e.id === ev.id); if (i >= 0) g.log.splice(i, 1); // our own, confirmed by the relay const shown = display(ev); g.log.push(shown); g.log.sort((a, b) => a.created_at - b.created_at); if (g.log.length > LOG_MAX) g.log.splice(0, g.log.length - LOG_MAX); if (!ev.pending) g.seen = Math.max(g.seen || 0, ev.created_at); touch(g); emit('message', g, shown); } function subscribe(g) { if (subs.has(g.id) || stopped) return; const since = g.seen ? g.seen - 60 : undefined; const a = subscribeAuthed([g.relay], { kinds: CHAT_KINDS, '#h': [g.gid], limit: 300, ...(since ? { since } : {}) }, (ev) => { if (ev.tags.some((x) => x[0] === 'h' && x[1] === g.gid)) take(g, ev); }, { onauth }); // metadata: only what the group's own relay signed (the copies other // relays carry are read nowhere) const b = subscribeAuthed([g.relay], { kinds: META_KINDS, '#d': [g.gid] }, (ev) => { if (ev.tags.some((x) => x[0] === 'd' && x[1] === g.gid)) take(g, ev); }, { onauth }); subs.set(g.id, () => { a(); b(); }); } function unsubscribe(g) { const u = subs.get(g.id); if (u) { u(); subs.delete(g.id); } } // ---- the joined-groups list (kind 10009) ---- const listGroups = (tags) => (tags || []).filter((x) => x[0] === 'group' && GID.test(x[1] || '') && normRelay(x[2])) .map((x) => ({ gid: x[1], relay: normRelay(x[2]), name: x[3] || '' })); async function readList(deep = false) { const relays = await listRelays(deep); const evs = await queryOn(relays, { kinds: [LIST], authors: [pubkey] }, deep ? 7000 : 5000); const newest = (evs || []).filter((e) => e.pubkey === pubkey).sort((a, b) => b.created_at - a.created_at)[0]; if (newest && (!list || newest.created_at > list.at)) { list = { tags: newest.tags || [], content: newest.content || '', at: newest.created_at }; store.put('list', list).catch(() => {}); } return list; } // Apply one change to the newest list the relays hold (never to a copy // we only assume is current), then publish it. async function writeList(change) { const cur = await readList(!list).catch(() => list); const base = cur || { tags: [], content: '', at: 0 }; const tags = change(base.tags); if (!tags) return; const created_at = Math.max(now(), base.at + 1); const ev = await sign({ kind: LIST, created_at, tags, content: base.content || '' }); const ok = await publishOn(await listRelays(false), ev); if (ok) { list = { tags, content: base.content || '', at: created_at }; store.put('list', list).catch(() => {}); } } async function adoptList() { try { await readList(!list); const l = list; // the newest, a leave's own write included if (!l || stopped) return; for (const x of listGroups(l.tags)) { if (groups.has(keyOf(x.relay, x.gid)) || leftNow.has(keyOf(x.relay, x.gid))) continue; const g = record(x.relay, x.gid); if (x.name && !g.meta.name) g.meta = { ...g.meta, name: x.name }; touch(g); subscribe(g); emit('loaded', g); } } catch (e) { dlog('nip29: list', e); } } // ---- actions ---- // Ask the relay to let us in, and hold the group here either way: an // open group can be read before (or without) the relay's yes. async function join(address, { code = '' } = {}) { const a = typeof address === 'string' ? parseGroupAddress(address) : address; if (!a) throw new Error('bad address'); await load(); leftNow.delete(keyOf(a.relay, a.gid)); const g = record(a.relay, a.gid); touch(g); subscribe(g); emit('joined', g); let refused = ''; const req = await sign({ kind: 9021, created_at: now(), content: '', tags: [['h', a.gid], ...(code ? [['code', code]] : [])] }); const ok = await publishOn([a.relay], req, { onauth, onrefused: (_, why) => { refused = why; } }); // "duplicate"/"already a member" is a yes if (!ok && !/already|duplicate/i.test(refused)) g.joinRefused = refused || 'unreachable'; else g.joinRefused = ''; touch(g); writeList((tags) => tags.some((x) => x[0] === 'group' && x[1] === a.gid && normRelay(x[2]) === a.relay) ? null : [...tags, ['group', a.gid, a.relay, g.meta.name || ''].filter((v, i) => i < 3 || v)]).catch((e) => dlog('nip29: list write', e)); return g; } // The NIP-29 "previous" tags: the first 8 characters of a few recent // events this relay sent us, so it can tell we've seen the timeline. const previous = (g) => g.log.filter((e) => !e.pending && e.pubkey !== pubkey).slice(-3).map((e) => ['previous', e.id.slice(0, 8)]); function event(kind, content, tags = []) { const ev = { kind, content, tags, created_at: now(), pubkey }; return { ...ev, id: getEventHash(ev) }; } // A reply's `e` becomes the `q` NIP-29 chat clients read. function outTags(g, inner) { let tags = inner.tags || []; if (inner.kind === 9) tags = tags.map((x) => { if (x[0] !== 'e') return x; const target = g.log.find((e) => e.id === x[1]); return ['q', x[1], g.relay, ...(target ? [target.pubkey] : [])]; }); return [['h', g.gid], ...tags.filter((x) => x[0] !== 'h'), ...previous(g)]; } async function send(g, inner) { const ev = await sign({ kind: inner.kind, content: inner.content || '', created_at: inner.created_at || now(), tags: outTags(g, inner) }); take(g, { ...ev, pending: true }); let refused = ''; const ok = await publishOn([g.relay], ev, { onauth, onrefused: (_, why) => { refused = why; } }); const i = g.log.findIndex((e) => e.id === ev.id); if (!ok) { if (i >= 0) g.log.splice(i, 1); g.sendRefused = refused; emit('message', g, ev); return false; } if (i >= 0) { const { pending, ...clean } = g.log[i]; g.log[i] = clean; } g.sendRefused = ''; touch(g); emit('message', g, ev); return true; } async function leave(g) { unsubscribe(g); leftNow.add(g.id); groups.delete(g.id); touch(g); emit('left', g); try { const ev = await sign({ kind: 9022, created_at: now(), content: '', tags: [['h', g.gid]] }); publishOn([g.relay], ev, { onauth }).catch(() => {}); } catch {} await writeList((tags) => { const next = tags.filter((x) => !(x[0] === 'group' && x[1] === g.gid && normRelay(x[2]) === g.relay)); return next.length === tags.length ? null : next; }).catch((e) => dlog('nip29: list write', e)); } async function start() { await load(); if (stopped) return; for (const g of groups.values()) subscribe(g); emit('loaded', null); adoptList(); } function resubscribe() { for (const g of groups.values()) { unsubscribe(g); subscribe(g); } } function stop() { stopped = true; for (const g of groups.values()) unsubscribe(g); flush(); } const no = async () => { throw new Error('not supported'); }; return { pubkey, groups, load, start, stop, resubscribe, join, send, leave, forget: leave, accept: () => {}, focus: () => {}, ready: () => {}, poll: () => {}, lookup: async () => null, create: no, add: no, kick: no, rename: no, event, view: (g) => ({ name: g.meta.name || g.gid, about: g.meta.about || '', picture: g.meta.picture || '', admins: g.admins, closed: !!g.meta.closed, private: !!g.meta.private }), peer: () => null, accounts: (g) => g.members.length ? g.members : [...new Set(g.log.filter((e) => e.kind === 9).map((e) => e.pubkey))], isAdmin: () => false, groupRef: (g) => g.relay.replace(/^wss:\/\//, '') + "'" + g.gid, isSetUp: () => true, }; }