aboutsummaryrefslogtreecommitdiffstats
path: root/src/nip29-client.js
blob: 040733c9c0c2a5382a710b491806a07e7fe7fdf3 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
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,
  };
}