aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-14 23:32:33 -0700
committerAdam Soltys <[email protected]>2026-10-14 23:32:33 -0700
commit14de2121fa1c806048689010cf85d4f65b6e28c1 (patch)
treec02c4beaf6a1eaf63c93547217a72367308a2564 /src
parentc5d42cd305f2f96e84a7526fef1cffcc6aeaac02 (diff)
downloadcoinosv3-14de2121fa1c806048689010cf85d4f65b6e28c1.tar.gz
coinosv3-14de2121fa1c806048689010cf85d4f65b6e28c1.zip
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).
Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01J1Kon8bxJoHRpJdSzshCkp
Diffstat (limited to 'src')
-rw-r--r--src/features/messages.js41
-rw-r--r--src/i18n.js11
-rw-r--r--src/marmot-client.js74
3 files changed, 124 insertions, 2 deletions
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