diff options
Diffstat (limited to 'src/marmot-client.js')
| -rw-r--r-- | src/marmot-client.js | 74 |
1 files changed, 73 insertions, 1 deletions
diff --git a/src/marmot-client.js b/src/marmot-client.js index 4a79b21..b514216 100644 --- a/src/marmot-client.js +++ b/src/marmot-client.js @@ -26,6 +26,7 @@ const DIRECTORY_RELAYS = ['wss://relay.eu.whitenoise.chat', 'wss://relay.us.whit const DAY = 86400; const KP_REFRESH = 30 * DAY; // republish well inside the 84-day lifetime +const RETIRE_CHECK = 6 * 3600; // how often a device asks whether another one took the invitations const REACH_TTL = 10 * 60_000; const now = () => Math.floor(Date.now() / 1000); const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); @@ -98,6 +99,7 @@ export function marmotClient({ scope, pubkey, identity, on, relays = GROUP_RELAY } async function ensure(opts) { await load(); + if (await checkRetired()) return false; // another device took the invitations: ours is not put back const cur = dev && dev.kps && dev.kps.find((k) => k.eventId); if (cur && cur.notAfter - now() > 14 * DAY && now() - cur.at < KP_REFRESH) return true; const s = await signer(opts); @@ -150,6 +152,76 @@ export function marmotClient({ scope, pubkey, identity, on, relays = GROUP_RELAY return true; } + // ---- which device gets the invitations ---- + // Every device publishes its own KeyPackage, and a Marmot inviter (White + // Noise) picks ONE of them: the invitation lands on whichever device it + // chose, and the others never see the group (Adam, 2026-10-14: the + // phone's). "Only here" retires the account's other packages — a kind 5 + // naming each, NIP-09 `a` for the addressable 30443s and `e` for an old + // 443 — and publishes a fresh one of ours; a device whose package was + // retired sees the deletion (newer than its package) and stops putting + // one back, until "only here" is pressed on it in turn. + const tagOf = (e, k) => (e.tags.find((t) => t[0] === k) || [])[1] || ''; + async function devices() { + await load(); + await checkRetired(); + const own = await ownWriteRelays().catch(() => []); + const where = [...new Set([...own.slice(0, 6), ...PROFILE_RELAYS, ...DIRECTORY_RELAYS])]; + const evs = await queryOn(where, { kinds: [M.KIND.KEY_PACKAGE, 443], authors: [pubkey] }, 3000).catch(() => []); + const bySlot = new Map(); + for (const e of evs) { + if (e.pubkey !== pubkey) continue; + if (e.kind === 443) { bySlot.set('e:' + e.id, { id: e.id, slot: null, created_at: e.created_at, client: tagOf(e, 'client'), legacy: true }); continue; } + const d = tagOf(e, 'd'); + if (!d) continue; + const cur = bySlot.get(d); + if (!cur || e.created_at > cur.created_at) bySlot.set(d, { id: e.id, slot: d, created_at: e.created_at, client: tagOf(e, 'client') }); + } + const mine = dev ? dev.slot : null; + const list = [...bySlot.values()]; + const ours = (x) => !!mine && x.slot === mine; // (an old 443 has no slot: never ours) + return { + slot: mine, retired: !!(dev && dev.retired), published: list.some(ours), + others: list.filter((x) => !ours(x)).sort((a, b) => b.created_at - a.created_at), + }; + } + async function onlyHere(opts = {}) { + await load(); + const s = await signer(opts); + if (!s) throw new Error('no signer'); + if (!dev) { dev = { ...(await M.newDevice(s.sign, pubkey)), kps: [] }; await saveDev(); } + const d = await devices(); + if (d.others.length) { + const tags = [['k', String(M.KIND.KEY_PACKAGE)], ['k', '443']]; + for (const o of d.others) { + tags.push(['e', o.id]); + if (o.slot) tags.push(['a', M.KIND.KEY_PACKAGE + ':' + pubkey + ':' + o.slot]); + } + const ev = await s.sign({ kind: M.KIND.DELETE, created_at: now(), tags, content: 'group invitations go to one device' }); + const own = await ownWriteRelays().catch(() => []); + if (!await publishOn([...new Set([...own.slice(0, 6), ...PROFILE_RELAYS, ...DIRECTORY_RELAYS])], ev)) throw new Error('not published'); + } + // ours, fresh: newer than any deletion that ever named this slot + if (dev.retired) { dev.retired = false; await saveDev(); } + dev.retireCheckedAt = now(); + if (!await publishKeyPackage(s)) throw new Error('not published'); + return d.others.length; + } + // once in a while: was our package retired on the relays (onlyHere on another device)? + async function checkRetired() { + const cur = dev && dev.kps && dev.kps.find((k) => k.eventId); + if (!dev || dev.retired || !cur || now() - (dev.retireCheckedAt || 0) <= RETIRE_CHECK) return !!(dev && dev.retired); + dev.retireCheckedAt = now(); await saveDev(); + if (await retiredOnRelays(cur)) { dev.retired = true; await saveDev(); } + return dev.retired === true; + } + async function retiredOnRelays(cur) { + const own = await ownWriteRelays().catch(() => []); + const evs = await queryOn([...new Set([...own.slice(0, 6), ...PROFILE_RELAYS])], + { kinds: [M.KIND.DELETE], authors: [pubkey], '#a': [M.KIND.KEY_PACKAGE + ':' + pubkey + ':' + dev.slot] }, 2500).catch(() => []); + return evs.some((e) => e.pubkey === pubkey && e.created_at > (cur.at || 0)); + } + // Somebody's KeyPackages: one per device (a slot is one installation), the // best first, at most ten. An invitation adds every one of them, so a person // with a phone and a laptop gets the group on both — the rule Marmot's @@ -440,7 +512,7 @@ export function marmotClient({ scope, pubkey, identity, on, relays = GROUP_RELAY } return { - pubkey, groups, load, start, stop, resubscribe, ready, announce, lookup, lookupAll, siblingAdds, welcome, accept, + pubkey, groups, load, start, stop, resubscribe, ready, announce, lookup, lookupAll, siblingAdds, welcome, accept, devices, onlyHere, create, send, add, kick, rename, leave, forget, event: (kind, content, tags) => M.appEvent(pubkey, kind, content, tags), // attachments: seal for the epoch a message is about to go out in, and |
