From 14de2121fa1c806048689010cf85d4f65b6e28c1 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Wed, 14 Oct 2026 23:32:33 -0700 Subject: Group invitations go to one device of yours, by your choice; group reactions learn their emoji pictures. Max added Adam to the 155-member White Noise community and no device showed it: a Marmot inviter picks ONE of a person's published KeyPackages, the Welcome lands on that device alone, and nothing said which (it was the phone's — and the phone, signing through a bunker, couldn't open a 218 KB Welcome until relay.coinos.io's maxEventSize went from 256 KB to 1 MB: the NIP-46 decrypt request carrying it was over the cap). Settings → Nostr gains "Group invitations" (groupDevicesCard): this device's standing, the account's other invitation keys on the relays (by app and date), and "Invite this device only" — a kind 5 naming each other key (NIP-09 `a` for the addressable 30443s, `e` for an old 443) and a fresh key of this device (marmot-client devices/onlyHere). A device whose key was retired by another sees the deletion, newer than its key, when it next checks (checkRetired, a few times a day, before any republish or the card) and stops putting one back, until "only here" is pressed on it in turn. And a group's reactions and messages now teach the app a :code:'s picture from their NIP-30 tag (noteEmoji on the group paths), the way feed reactions do: a :marmot: reaction in the community showed as text. tools/wn-only-device-test.js (the card lists three other keys newest first; the deletion names the two slots and all three events and not ours; the card then shows no others; a retirement by another device is honoured and taken back). MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J1Kon8bxJoHRpJdSzshCkp --- src/features/messages.js | 41 ++++++++++++++++++++++++++- src/i18n.js | 11 +++++++ src/marmot-client.js | 74 +++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 124 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/features/messages.js b/src/features/messages.js index c568ae0..c273d32 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -3416,6 +3416,7 @@ export function messagesFeature(ctx) { function wnIntoThread(g, peer, m) { const th = threadOf(peer); const target = (m.tags.find((x) => x[0] === 'e') || [])[1]; + if (m.kind === 7 || m.kind === 9) noteEmoji(m); // a :code: with its picture (NIP-30 tag): learned, so the chip draws it if (m.kind === 9) { if (!th.has(m.id) || th.get(m.id).pending) th.set(m.id, { rumor: m, mine: isMe(m.pubkey), wn: g.id }); } else if (m.kind === 7 && target) { @@ -3587,6 +3588,7 @@ export function messagesFeature(ctx) { for (const m of sorted) if (m.kind === 9 || m.kind === 1210) byId.set(m.id, { rumor: m, mine: isMe(m.pubkey), wn: g.id }); for (const m of sorted) { const target = (m.tags.find((x) => x[0] === 'e') || [])[1]; + if (m.kind === 7 || m.kind === 9) noteEmoji(m); // (a group's :marmot: came as text: its tag was never read) if (m.kind === 7 && target) { (reacts.get(target) || reacts.set(target, new Map()).get(target)).set(m.pubkey, m.content); reactOf.set(m.id, { target, pk: m.pubkey }); @@ -9579,6 +9581,43 @@ export function messagesFeature(ctx) { } // Settings → Nostr: whether clips over the budget play by themselves. + // Settings → Nostr: which device of yours gets group invitations. A Marmot + // inviter (White Noise) picks one of your published keys; the group lands + // on that device alone, and nothing told you which (Adam's invitation sat + // on the phone while he looked on the laptop). + function groupDevicesCard() { + const pk = identityPk(); + let d = ui.wnDevices; + if (!d || d.pk !== pk) { + d = ui.wnDevices = { pk, loading: true }; + whiteNoise().then((c) => (c ? c.devices() : null)).then((r) => { + if (!ui.wnDevices || ui.wnDevices.pk !== pk) return; + ui.wnDevices = r ? { pk, ...r } : { pk, none: true }; + render(); + }).catch(() => { if (ui.wnDevices && ui.wnDevices.pk === pk) { ui.wnDevices = { pk, none: true }; render(); } }); + } + const busy = !!ui.wnOnlyHere; + const state = d.loading ? null : d.none ? t('wnDeviceNone') : d.retired ? t('wnDeviceRetired') : d.published ? t('wnDeviceHere') : t('wnDeviceNone'); + return h('div', { class: 'card col wn-devices-card', style: 'gap:8px' }, + h('h3', {}, t('wnDevicesTitle')), + h('div', { class: 'small faint' }, t('wnDevicesHelp')), + state ? h('div', { class: 'small wn-device-state' }, state) : h('div', { class: 'row gap6' }, h('span', { class: 'spinner sm' })), + !d.loading && d.others ? (d.others.length + ? h('div', { class: 'col small', style: 'gap:2px' }, h('div', {}, t('wnDevicesOthers', { n: d.others.length })), + ...d.others.map((o) => h('div', { class: 'faint wn-device' }, (o.client || t('wnDeviceUnknown')) + ' \u00b7 ' + timeLabel(o.created_at * 1000)))) + : h('div', { class: 'small faint wn-no-others' }, t('wnDevicesNoOthers'))) : null, + h('button', { + class: 'btn-sm wn-only-here', type: 'button', disabled: busy || d.loading || undefined, + onClick: () => { + ui.wnOnlyHere = true; render(); + whiteNoise().then((c) => (c ? c.onlyHere({ interactive: true }) : Promise.reject(new Error('no client')))) + .then((n) => { toast(t('wnOnlyHereDone', { n })); ui.wnDevices = null; }) + .catch(() => toast(t('wnOnlyHereFail'))) + .finally(() => { ui.wnOnlyHere = false; render(); }); + }, + }, t('wnOnlyHere'))); + } + function videosCard() { const on = largeVideosOk(); return h('div', { class: 'card col videos-card', style: 'gap:8px' }, @@ -15068,7 +15107,7 @@ export function messagesFeature(ctx) { // group-chat state is device state in IndexedDB: it goes with the account wipeCache(bases) { import('../marmot-store.js').then((m) => { for (const b of bases) m.wipeMarmot(b); }).catch(() => {}); }, forgetAll() { wipeMediaIndex(); mediaIdx.clear(); try { caches.delete(VIDEO_THUMB_CACHE); } catch {} }, - nostrSettingsCards() { return [relaysCard(), followHistoryCard(), moderationCard(), translateCard(), videosCard()]; }, + nostrSettingsCards() { return [relaysCard(), followHistoryCard(), groupDevicesCard(), moderationCard(), translateCard(), videosCard()]; }, // The app came back after being backgrounded. A phone freezes a hidden // tab: the relay sockets are cut and every post made in the meantime is // simply missing, which is why the feed used to sit there looking stale diff --git a/src/i18n.js b/src/i18n.js index fd192a1..278d136 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -280,6 +280,17 @@ const STR = { pagerNextPic: 'Next picture', videoNoPicture: "This browser can’t show this video’s picture", videoOpenFile: "Open the file", + wnDevicesTitle: 'Group invitations', + wnDevicesHelp: 'White Noise and other Marmot apps invite one of your devices, and only that device gets the group. Keep the invitations coming to the device you actually use.', + wnDeviceHere: 'This device can be invited.', + wnDeviceRetired: 'This device no longer takes invitations; another one of yours does.', + wnDeviceNone: 'This device has no invitation key published yet.', + wnDevicesOthers: '{n} other invitation key(s) of yours are published:', + wnDevicesNoOthers: 'No other device of yours is published.', + wnDeviceUnknown: 'another app', + wnOnlyHere: 'Invite this device only', + wnOnlyHereDone: 'Done. {n} other invitation key(s) retired.', + wnOnlyHereFail: 'Couldn’t retire the other keys. Try again.', videoLargeTitle: "Videos", videoLargeToggle: "Autoplay large videos", videoLargeHelp: "Videos over {mb} MB or above 1080p wait for a tap, so the feed doesn’t eat your data. The limit is lower on a slow or data-saving connection.", 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 -- cgit v1.2.3