diff options
| -rw-r--r-- | src/features/messages.js | 96 | ||||
| -rw-r--r-- | src/i18n.js | 7 | ||||
| -rw-r--r-- | src/nip29-client.js | 325 | ||||
| -rw-r--r-- | tools/nip29-group-test.js | 102 |
4 files changed, 520 insertions, 10 deletions
diff --git a/src/features/messages.js b/src/features/messages.js index a42396c..85b44f3 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -1607,6 +1607,7 @@ export function messagesFeature(ctx) { for (const room of wanted) subscribeRoom(room); if (wn) wn.resubscribe(); if (cd) cd.resubscribe(); + if (ng) ng.resubscribe(); } // ---- subscription watchdog ------------------------------------------------ @@ -3735,8 +3736,43 @@ export function messagesFeature(ctx) { bumpMsgRev(); scheduleRepaint(); } - const gClient = (g) => (g && g.cordn ? cd : wn); - const findGroup = (id) => (wn && wn.groups.get(id)) || (cd && cd.groups.get(id)) || null; + // NIP-29 groups (nip29-client.js): a group on one relay that keeps its + // members and signs its metadata; messages are plain signed events with + // an `h` tag. Not encrypted. Joined by address (host'id, a client's link), + // and the list of them is the NIP-51 kind 10009 other clients keep too. + let ng = null, ngLoading = null; + function nip29Chat() { + const pk = identityPk(); + if (!pk || !wallet._cacheKey) return Promise.resolve(null); + if (ng && ng.pubkey === pk) return Promise.resolve(ng); + if (ngLoading && ngLoading.pk === pk) return ngLoading.p; + if (ng) { try { ng.stop(); } catch {} ng = null; } + const p = import('../nip29-client.js').then(async (m) => { + if (identityPk() !== pk) return null; + const c = m.nip29Client({ + scope: wallet._cacheKey() + ':' + pk, pubkey: pk, identity, on: onNg, + listRelays: async (deep) => deep + ? [...new Set([...zapRelays(), ...PROFILE_INDEX_RELAYS, ...(await relaysOf(pk).catch(() => []))])] + : zapRelays(), + }); + ng = c; + ngLoading = null; + await c.start(); + return c; + }).catch((e) => { console.warn('chat: relay groups unavailable', e); ngLoading = null; return null; }); + ngLoading = { pk, p }; + return p; + } + function onNg(type, g, data) { + if (!ng) return; + if (type === 'message') { try { noteEmoji(data); } catch {} } + if (g) wnFolds.delete(g.id); + bumpMsgRev(); + scheduleRepaint(); + } + const gClient = (g) => (g && g.nip29 ? ng : g && g.cordn ? cd : wn); + const gOn = (g) => (g.nip29 ? onNg : g.cordn ? onCd : onWn); + const findGroup = (id) => (wn && wn.groups.get(id)) || (cd && cd.groups.get(id)) || (ng && ng.groups.get(id)) || null; // One delivered event of a direct group, into that person's thread. function wnIntoThread(g, peer, m) { @@ -3886,14 +3922,14 @@ export function messagesFeature(ctx) { const inner = wn.event(7, emoji, [['e', m.rumor.id], ['p', m.rumor.pubkey], ['k', String(m.rumor.kind)], ...reactEmojiTags(emoji)]); const ok = await wn.send(g, inner).catch(() => false); if (!ok) { toast(t('msgSendFailed')); return; } - onWn('message', g, inner); + gOn(g)('message', g, inner); } // ---- groups ---- const wnRead = (id) => 'wn:' + id; // groups with a screen of their own (a direct group lives in its DM thread) - const wnGroups = () => [...(wn ? [...wn.groups.values()].filter((g) => !wn.peer(g)) : []), ...(cd ? cd.groups.values() : [])]; + const wnGroups = () => [...(wn ? [...wn.groups.values()].filter((g) => !wn.peer(g)) : []), ...(cd ? cd.groups.values() : []), ...(ng ? ng.groups.values() : [])]; function wnTitle(g) { const c = gClient(g); @@ -4008,7 +4044,7 @@ export function messagesFeature(ctx) { const sent = wn.send(g, inner).catch(() => false); wnFolds.delete(g.id); render(); - if (!(await sent)) { wnFolds.delete(g.id); toast(t('msgSendFailed')); render(); } + if (!(await sent)) { wnFolds.delete(g.id); toast(g.nip29 && g.sendRefused ? t('msgSendFailed') + ': ' + g.sendRefused : t('msgSendFailed')); render(); } } async function wnAct(fn) { @@ -17488,6 +17524,7 @@ export function messagesFeature(ctx) { // login is asked when it first opens a Cordn group or invites someone). if (ui.msgView !== 'grp' && cd) cd.focus(null); cordnChat().then((c) => { if (c && !cdReadied) { cdReadied = true; c.ready(); } }); + nip29Chat(); // Group chats ride along: the client loads with the wallet, and standing // on this screen is what makes us invitable (a KeyPackage on the relays). whiteNoise().then(async (c) => { @@ -18136,7 +18173,42 @@ export function messagesFeature(ctx) { // which protocol the group speaks: White Noise (Marmot) or Cordn h('div', { class: 'seg' }, h('button', { class: proto === 'wn' ? 'active' : '', onClick: () => { ui.msgGroupProto = 'wn'; render(); } }, t('msgGroupProtoWn')), - h('button', { class: proto === 'cd' ? 'active' : '', onClick: () => { ui.msgGroupProto = 'cd'; render(); } }, t('msgGroupProtoCd'))), + h('button', { class: proto === 'cd' ? 'active' : '', onClick: () => { ui.msgGroupProto = 'cd'; render(); } }, t('msgGroupProtoCd')), + h('button', { class: proto === 'n29' ? 'active' : '', onClick: () => { ui.msgGroupProto = 'n29'; render(); } }, t('msgGroupProtoN29'))), + proto === 'n29' ? nip29JoinPanel() : null, + proto === 'n29' ? null : wnNewGroupForm(proto, picks, ready)); + } + // Join a NIP-29 group by its address (they're made in other clients). + async function nip29Join() { + const raw = (ui.msgN29Addr || '').trim(); + if (!raw || ui.msgBusy) return; + const m = await import('../nip29-client.js'); + const a = m.parseGroupAddress(raw); + if (!a) { toast(t('msgNip29BadAddress')); return; } + ui.msgBusy = true; render(); + try { + const c = await nip29Chat(); + if (!c) throw new Error('no signer'); + const g = await c.join(a); + ui.msgN29Addr = ''; ui.msgHomePanel = null; + ui.msgView = 'grp'; ui.msgPeer = g.id; ui.msgStick = true; + if (g.joinRefused) toast(t('msgNip29JoinRefused', { why: g.joinRefused })); + } catch (e) { wnFail(e); } finally { ui.msgBusy = false; render(); } + } + function nip29JoinPanel() { + return h('div', { class: 'col gap6' }, + h('div', { class: 'small muted' }, t('msgGroupProtoN29Hint')), + h('div', { class: 'row gap6' }, + h('input', { + class: 'grow nip29-address', type: 'text', placeholder: t('msgNip29Address'), autocapitalize: 'none', spellcheck: 'false', + value: ui.msgN29Addr || '', onInput: (e) => { ui.msgN29Addr = e.target.value; }, + onKeydown: (e) => { if (e.key === 'Enter') nip29Join(); }, + }), + h('button', { class: 'btn-primary btn-sm', disabled: ui.msgBusy, onClick: nip29Join }, + ui.msgBusy ? h('span', { class: 'spinner sm' }) : t('msgJoin')))); + } + function wnNewGroupForm(proto, picks, ready) { + return h('div', { class: 'col gap6' }, proto === 'cd' ? h('div', { class: 'small muted' }, t('msgGroupProtoCdHint')) : null, h('input', { type: 'text', placeholder: t('msgGroupName'), maxlength: '64', @@ -18155,7 +18227,11 @@ export function messagesFeature(ctx) { const accounts = wn.accounts(g); const picks = ui.msgGroupAdd || (ui.msgGroupAdd = []); return h('div', { class: 'col chat-members' }, - h('div', { class: 'muted small', style: 'padding:8px 12px 2px' }, t(g.cordn ? 'msgCordnInfo' : 'msgGroupInfo')), + h('div', { class: 'muted small', style: 'padding:8px 12px 2px' }, g.nip29 ? t('msgNip29Info', { relay: wn.groupRef(g) }) : t(g.cordn ? 'msgCordnInfo' : 'msgGroupInfo')), + g.nip29 && v.about ? h('div', { class: 'small', style: 'padding:2px 12px 6px' }, v.about) : null, + g.nip29 ? h('div', { class: 'row gap6', style: 'padding:0 12px 6px' }, h('button', { class: 'btn-ghost btn-sm', onClick: async () => { + try { await navigator.clipboard.writeText(wn.groupRef(g)); toast(t('copied')); } catch {} + } }, t('msgNip29CopyAddress'))) : null, ...accounts.map((pk) => h('div', { class: 'item chat-thread-row', onClick: () => openProfile(pk) }, avatar(pk), h('div', { class: 'row gap6 grow', style: 'align-items:center;min-width:0' }, @@ -18215,7 +18291,7 @@ export function messagesFeature(ctx) { !ui.emojiPick && m.mine && !g.removed ? item('×', t('msgDelete'), async () => { close(); const del = wn.event(5, '', [['e', m.rumor.id], ['k', '9']]); - if (await wn.send(g, del).catch(() => false)) (g.cordn ? onCd : onWn)('message', g, del); + if (await wn.send(g, del).catch(() => false)) gOn(g)('message', g, del); }) : null)); } @@ -18223,7 +18299,7 @@ export function messagesFeature(ctx) { const g = findGroup(ui.msgPeer); if (!g) { // the groups are still loading (a reload straight into this screen) - if (!wn || wnLoading || !cd || cdLoading) { Promise.all([whiteNoise(), cordnChat()]).then(() => scheduleRepaint()); return h('div', { class: 'card col chat-card chat-page' }, h('span', { class: 'spinner' })); } + if (!wn || wnLoading || !cd || cdLoading || !ng || ngLoading) { Promise.all([whiteNoise(), cordnChat(), nip29Chat()]).then(() => scheduleRepaint()); return h('div', { class: 'card col chat-card chat-page' }, h('span', { class: 'spinner' })); } ui.msgView = 'home'; return homeView(); } @@ -18332,7 +18408,7 @@ export function messagesFeature(ctx) { h('span', { class: 'small muted chat-quote-text' }, ...snippetNodes(reply.rumor))), h('button', { class: 'chat-del', style: 'position:static;display:flex;flex-shrink:0', onClick: () => { ui.msgReplyTo = null; render(); } }, '×')) : null, live - ? composer(t('msgDmPlaceholder'), () => wnSend(g), null, 'wn:' + g.id, g.cordn ? null : (f) => wnSendFile(g, f)) + ? composer(t('msgDmPlaceholder'), () => wnSend(g), null, 'wn:' + g.id, g.cordn || g.nip29 ? null : (f) => wnSendFile(g, f)) : h('div', { class: 'muted small', style: 'text-align:center;padding:12px' }, t('msgGroupGone')), ui.msgSheet ? wnSheet(g) : null); } diff --git a/src/i18n.js b/src/i18n.js index d1de016..1d14a36 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -790,6 +790,13 @@ const STR = { msgGroupProtoWn: 'Marmot (MLS)', msgGroupProtoCd: 'Cordn', msgGroupProtoCdHint: 'Cordn groups work with Amethyst and the Cordn app; messages travel through a coordinator server that cannot read them.', + msgGroupProtoN29: 'Relay group', + msgGroupProtoN29Hint: 'Join a NIP-29 group made in Chachi, Flotilla, 0xchat or another client: paste its address (relay\'id) or a link to it. Relay groups are not encrypted; the relay keeps the members and can read the messages.', + msgNip29Address: 'relay.example.com\'groupid or a link', + msgNip29BadAddress: 'That isn\'t a group address. It looks like relay.example.com\'groupid', + msgNip29JoinRefused: 'The relay didn\'t let you in yet ({why}). You can still read the group if it\'s open.', + msgNip29Info: 'Relay group {relay} · not encrypted · the relay keeps the members', + msgNip29CopyAddress: 'Copy group address', msgCordnInfo: 'Cordn group · end-to-end encrypted · delivered by a coordinator', msgCordnNoFiles: 'Attachments are not available in Cordn groups yet', msgRouteTitle: 'Send messages by', diff --git a/src/nip29-client.js b/src/nip29-client.js new file mode 100644 index 0000000..040733c --- /dev/null +++ b/src/nip29-client.js @@ -0,0 +1,325 @@ +// 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/<host>/<id>, flotilla's /spaces/<host>/<id>, …). +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, + }; +} diff --git a/tools/nip29-group-test.js b/tools/nip29-group-test.js new file mode 100644 index 0000000..2ff1192 --- /dev/null +++ b/tools/nip29-group-test.js @@ -0,0 +1,102 @@ +// NIP-29 relay groups in Messages: New group → Relay group → paste an +// address → Join. Reads the real "pyramid runners" group off +// pyramid.fiatjaf.com (relays: 'read' — nothing published leaves the page: +// the join request, the kind 10009 list and the test message are answered +// OK in place and checked here). +// Run: bun tools/nip29-group-test.js +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; +import { buildHtml } from '../build.js'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let ok = true; +const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; +const ADDR = process.env.GROUP || "https://chachi.chat/pyramid.fiatjaf.com/Tnq7x2ZTgrPZWFrC"; +const GID = 'Tnq7x2ZTgrPZWFrC'; + +const html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 0, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) }); +const browser = await puppeteer.launch({ relays: 'read', executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +const page = await browser.newPage(); +const errs = []; +page.on('console', (m) => { if (/nip29/.test(m.text())) console.log(' [page]', m.text()); }); +page.on('pageerror', (e) => errs.push(String(e))); +const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase() === x); if (e) { e.click(); return true; } return false; }, t); +const waitText = async (x, ms = 20000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; }; +const published = () => page.evaluate(() => window.__relayGuard.events); + +try { + await page.setViewport({ width: 390, height: 844 }); + await page.goto(server.url.href, { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); + await page.reload({ waitUntil: 'domcontentloaded' }); + await sleep(400); + await page.evaluate(() => [...document.querySelectorAll('button')].find((n) => /create a new wallet/i.test(n.textContent))?.click()); await sleep(600); + await seedWords(page); + await skipSeedCheck(page); + await waitText('receive', 20000); + await page.click('[aria-label="Messages"]'); + await waitText('new group', 10000); + await click('new group'); await sleep(300); + check('New group offers a relay group', await click('relay group')); + await sleep(200); + await page.type('.nip29-address', 'not an address'); + await click('join'); await sleep(500); + check('a bad address is refused', await waitText("isn't a group address", 3000)); + await page.evaluate(() => { const i = document.querySelector('.nip29-address'); i.value = ''; i.dispatchEvent(new Event('input', { bubbles: true })); }); + await page.type('.nip29-address', ADDR); + await click('join'); + check('the group opens, named by its relay', await waitText('pyramid runners', 20000)); + check('its messages arrive', await waitText('paff.club', 20000)); + const evs = await published(); + const join = evs.find((e) => e.kind === 9021); + check('a join request went to the group', !!join && join.tags.some((x) => x[0] === 'h' && x[1] === GID)); + // the 10009 write reads the newest list first, everywhere (a first write) + let list = null; + for (let i = 0; i < 80 && !list; i++) { list = (await published()).filter((e) => e.kind === 10009).pop(); if (!list) await sleep(250); } + check('the group is added to the kind 10009 list', !!list && list.tags.some((x) => x[0] === 'group' && x[1] === GID && /pyramid\.fiatjaf\.com/.test(x[2])), JSON.stringify(list?.tags)); + + // say something (answered OK in place, never sent) + await page.click('#msg-draft'); + await page.type('#msg-draft', 'hello from the nip29 test'); + await page.keyboard.press('Enter'); + await sleep(1500); + const msg = (await published()).find((e) => e.kind === 9 && e.content === 'hello from the nip29 test'); + check('a message is a kind 9 with the group h tag', !!msg && msg.tags.some((x) => x[0] === 'h' && x[1] === GID), JSON.stringify(msg?.tags)); + check('...and previous tags from the timeline', !!msg && msg.tags.some((x) => x[0] === 'previous' && /^[0-9a-f]{8}$/.test(x[1]))); + check('it shows in the group', await waitText('hello from the nip29 test', 3000)); + + // members: the relay's list + await page.evaluate(() => document.querySelector('.chat-head .clickable')?.click()); await sleep(800); + check('the member panel says it is a relay group', await waitText('not encrypted', 3000)); + const n = await page.evaluate(() => document.querySelectorAll('.chat-members .chat-thread-row').length); + check('members come from the relay', n > 10, n + ' members'); + + // back home: a row of its own; reload keeps it + await page.evaluate(() => document.querySelector('.chat-head button')?.click()); await sleep(600); + check('the group is listed in Messages', await page.evaluate(() => [...document.querySelectorAll('.chat-thread-row')].some((r) => r.textContent.includes('pyramid runners')))); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await page.click('[aria-label="Messages"]'); + let kept = false; + for (let i = 0; i < 40 && !kept; i++) { kept = await page.evaluate(() => [...document.querySelectorAll('.chat-thread-row')].some((r) => r.textContent.includes('pyramid runners'))); if (!kept) await sleep(250); } + check('...and still is after a reload', kept); + + // leave: two taps, a 9022 to the relay, and off the list + await page.evaluate(() => [...document.querySelectorAll('.chat-thread-row')].find((r) => r.textContent.includes('pyramid runners'))?.click()); await sleep(800); + await page.click('[aria-label="Leave group"]'); await sleep(200); + await page.click('[aria-label="Leave group"]'); + let left = null, relist = null; + for (let i = 0; i < 80 && !(left && relist); i++) { + const evs = await published(); + left = evs.find((e) => e.kind === 9022); + relist = evs.filter((e) => e.kind === 10009).find((e) => !e.tags.some((x) => x[0] === 'group')); + if (!(left && relist)) await sleep(250); + } + check('leaving tells the relay (9022)', !!left && left.tags.some((x) => x[0] === 'h' && x[1] === GID)); + check('...and takes it off the 10009 list', !!relist); + check('...and out of Messages', !(await page.evaluate(() => [...document.querySelectorAll('.chat-thread-row')].some((r) => r.textContent.includes('pyramid runners'))))); + check('no page errors', !errs.length, errs.join(' | ')); +} catch (e) { console.error(e); ok = false; } finally { + await browser.close(); server.stop(); +} +console.log(ok ? '\nPASS' : '\nFAIL'); +process.exit(ok ? 0 : 1); |
