aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-02 16:07:48 -0700
committerAdam Soltys <[email protected]>2026-08-02 16:07:48 -0700
commit5a444e260b0b6aa36953b22678e9b98448cd28f0 (patch)
treec2d9b00a4b8a29627f798a429bbda8e8b68d72cd /src
parent95263c02a13a825a8d02dbe760f2a74de8f93f77 (diff)
downloadcoinosv3-5a444e260b0b6aa36953b22678e9b98448cd28f0.tar.gz
coinosv3-5a444e260b0b6aa36953b22678e9b98448cd28f0.zip
Chat: device sync via Community List (13302) and Invite List (13303)
Memberships and minted invite-link keys now follow the user across devices: both lists are NIP-44 self-encrypted replaceables (CORD-02 §8, CORD-05 §4), published under the wallet key (the cross-device constant) and the login npub when a signer is live. Merging validates the self-certifying community_id before adopting keys, keeps tombstones permanent, and lets the newest of added_at/removed_at win, so a stale device can never resurrect a community you left. Leaving is a two-tap control in the room's invite panel (the built-in coinos community is exempt) and publishes a guestbook Leave alongside the tombstone. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Wvj4aoeMZwA7VUGdJP9Swi
Diffstat (limited to 'src')
-rw-r--r--src/features/messages.js185
-rw-r--r--src/i18n.js2
2 files changed, 183 insertions, 4 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index c46bb70..2484b46 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -14,7 +14,7 @@
import {
subscribeOn, publishOn, queryOn, fetchNostrProfile, fetchInboxRelays,
- npubOf, parseNostrPubkey, generateSecretKey, getPublicKey, finalizeEvent,
+ npubOf, parseNostrPubkey, generateSecretKey, getPublicKey, finalizeEvent, nip44,
} from '../nostr.js';
import {
channelKey, controlKey, guestbookKey, openWrap, wrapRumor,
@@ -57,6 +57,8 @@ export function messagesFeature(ctx) {
s.invites ||= {}; // { [cid]: { sk, token, url } } — minted links
s.dms ||= {}; // { [peerPk]: [{ id, from, text, t }] }
s.declined ||= {}; // direct-invite rumor ids dismissed
+ s.tombstones ||= {}; // { [cid]: removed_at ms } — left communities (CORD-02 §8)
+ for (const c of s.communities) c.added_at ||= Date.now();
// pre-multi-community shape: joined was { [pubkey]: true } for coinos
for (const k of Object.keys(s.joined))
if (s.joined[k] === true) { (s.joined[COMMUNITY.community_id] ||= {})[k] = true; delete s.joined[k]; }
@@ -339,11 +341,13 @@ export function messagesFeature(ctx) {
community_id: cid, owner: id.pubkey, owner_salt: ownerSalt,
community_root: bytesToHex(rootBytes), root_epoch: 0,
channels: [{ id: generalId, name: 'general' }], relays, name,
+ added_at: Date.now(),
};
const s = st();
s.communities.push(jm);
(s.joined[cid] ||= {})[id.pubkey] = true;
save(s);
+ publishLists();
ensureRoom(jm);
ui.msgView = 'room';
ui.msgCommunity = cid;
@@ -377,8 +381,9 @@ export function messagesFeature(ctx) {
const ok = await publishOn(room.relays, evt);
if (!ok) { toast(t('msgSendFailed')); return null; }
const url = makeInviteLink(APP_BASE, getPublicKey(linkSk), room.relays, token);
- s.invites[room.jm.community_id] = { sk: bytesToHex(linkSk), token: bytesToHex(token), url };
+ s.invites[room.jm.community_id] = { sk: bytesToHex(linkSk), token: bytesToHex(token), url, created_at: Math.floor(Date.now() / 1000) };
save(s);
+ publishLists();
return url;
}
@@ -407,10 +412,11 @@ export function messagesFeature(ctx) {
community_root: b.community_root, root_epoch: b.root_epoch || 0,
channels: (b.channels || []).map((c) => ({ id: c.id, name: c.name })),
relays: (b.relays || []).slice(0, 5), name: b.name || 'community',
- invitedBy, inviteLabel,
+ invitedBy, inviteLabel, added_at: Date.now(),
};
s.communities.push(jm);
save(s);
+ publishLists();
}
const room = ensureRoom(communityById(b.community_id));
identity().then((id) => id && ensureJoined(room, id)).catch(() => {});
@@ -451,6 +457,165 @@ export function messagesFeature(ctx) {
const urlInvite = typeof location !== 'undefined' ? parseInviteLink(location.href) : null;
if (urlInvite) { try { history.replaceState(null, '', '/'); } catch {} }
+ // ---- device sync: Community List (13302) + Invite List (13303) ----------
+ // Self-encrypted replaceables (CORD-02 §8 / CORD-05 §4) so memberships and
+ // minted link keys follow the user to any device or client. Published under
+ // every identity we can encrypt for: the wallet key is the cross-device
+ // constant (same seed on every device), the login npub makes the list
+ // readable by other Concord clients serving the same identity.
+
+ async function selfCryptors() {
+ const out = [];
+ if (wallet.nostr && wallet.nostr.sk && wallet.nostr.ck) out.push({
+ pk: wallet.nostr.pk,
+ sign: async (e) => finalizeEvent(e, wallet.nostr.sk),
+ enc: async (txt) => nip44.encrypt(txt, wallet.nostr.ck),
+ dec: async (ct) => nip44.decrypt(ct, wallet.nostr.ck),
+ });
+ const login = hook('nostrLoginIdentity');
+ if (login && login.signer && login.signer.encryptSelf) out.push({
+ pk: login.pubkey,
+ sign: (e) => login.signer.signEvent(e),
+ enc: (txt) => login.signer.encryptSelf(txt),
+ dec: (ct) => login.signer.decryptSelf(ct),
+ });
+ return out;
+ }
+
+ // Join material subset (never the icon, never link fields). We only run
+ // epoch 0 today, so seed and current coincide.
+ const jmSubset = (jm) => ({
+ community_id: jm.community_id, owner: jm.owner, owner_salt: jm.owner_salt,
+ community_root: jm.community_root, root_epoch: jm.root_epoch || 0,
+ channels: (jm.channels || []).map((c) => ({ id: c.id, name: c.name })),
+ relays: jm.relays, name: jm.name,
+ });
+
+ const buildCommunityList = (s) => JSON.stringify({
+ entries: s.communities.slice(0, 50).map((jm) => ({
+ community_id: jm.community_id, seed: jmSubset(jm), current: jmSubset(jm), added_at: jm.added_at || 0,
+ })),
+ tombstones: Object.entries(s.tombstones).map(([community_id, removed_at]) => ({ community_id, removed_at })),
+ });
+
+ const buildInviteList = (s) => JSON.stringify({
+ entries: Object.entries(s.invites).map(([community_id, inv]) => ({
+ token: inv.token, signer_sk: inv.sk, community_id, url: inv.url, created_at: inv.created_at || 0,
+ })),
+ tombstones: [],
+ });
+
+ function mergeCommunityList(doc) {
+ const s = st();
+ let changed = false;
+ for (const tb of doc.tombstones || []) {
+ if ((tb.removed_at || 0) > (s.tombstones[tb.community_id] || 0)) {
+ s.tombstones[tb.community_id] = tb.removed_at;
+ changed = true;
+ }
+ }
+ for (const e of (doc.entries || []).slice(0, 50)) {
+ const jm = e.current || e.seed;
+ if (!jm || jm.community_id !== e.community_id) continue;
+ if (jm.community_id === COMMUNITY.community_id) continue; // built-in
+ if ((e.added_at || 0) <= (s.tombstones[e.community_id] || 0)) continue; // tombstone wins
+ if (s.communities.some((c) => c.community_id === e.community_id)) continue;
+ if (communityId(jm.owner, jm.owner_salt) !== jm.community_id) continue; // self-certify before adopting keys
+ if (!/^[0-9a-f]{64}$/.test(jm.community_root || '')) continue;
+ s.communities.push({ ...jmSubset(jm), added_at: e.added_at || Date.now() });
+ changed = true;
+ }
+ // a tombstone newer than an entry's added_at removes it locally too
+ const keep = s.communities.filter((c) => (s.tombstones[c.community_id] || 0) <= (c.added_at || 0));
+ if (keep.length !== s.communities.length) { s.communities = keep; changed = true; }
+ if (changed) save(s);
+ return changed;
+ }
+
+ function mergeInviteList(doc) {
+ const s = st();
+ let changed = false;
+ for (const e of doc.entries || []) {
+ if (!e.token || !e.community_id || !e.signer_sk) continue;
+ if (!s.invites[e.community_id]) {
+ s.invites[e.community_id] = { sk: e.signer_sk, token: e.token, url: e.url, created_at: e.created_at };
+ changed = true;
+ }
+ }
+ if (changed) save(s);
+ return changed;
+ }
+
+ async function publishLists() {
+ const ids = await selfCryptors();
+ const s = st();
+ const docs = [[13302, buildCommunityList(s)], [13303, buildInviteList(s)]];
+ for (const id of ids)
+ for (const [kind, doc] of docs) {
+ try {
+ const evt = await id.sign({
+ kind, content: await id.enc(doc), tags: [], created_at: Math.floor(Date.now() / 1000),
+ });
+ publishOn(DM_RELAYS, evt);
+ } catch {}
+ }
+ }
+
+ let listsSynced = false;
+ async function syncLists() {
+ if (listsSynced) return;
+ const ids = await selfCryptors();
+ if (!ids.length) return;
+ listsSynced = true;
+ const evs = await queryOn(DM_RELAYS, { kinds: [13302, 13303], authors: ids.map((i) => i.pk) }, 3500);
+ let changed = false;
+ const remoteDocs = new Set();
+ for (const kind of [13302, 13303])
+ for (const id of ids) {
+ const newest = evs.filter((e) => e.kind === kind && e.pubkey === id.pk).sort((a, b) => b.created_at - a.created_at)[0];
+ if (!newest) continue;
+ try {
+ const raw = await id.dec(newest.content);
+ remoteDocs.add(kind + ':' + raw);
+ const doc = JSON.parse(raw);
+ if (kind === 13302) changed = mergeCommunityList(doc) || changed;
+ else changed = mergeInviteList(doc) || changed;
+ } catch {}
+ }
+ if (changed) {
+ for (const jm of communities()) ensureRoom(jm);
+ scheduleRepaint();
+ }
+ // republish when any identity's copy is missing or stale
+ const s = st();
+ const current = [[13302, buildCommunityList(s)], [13303, buildInviteList(s)]];
+ const anyMissing = ids.length * 2 > remoteDocs.size
+ || current.some(([kind, doc]) => !remoteDocs.has(kind + ':' + doc));
+ if (anyMissing && (s.communities.length || Object.keys(s.invites).length || Object.keys(s.tombstones).length))
+ publishLists();
+ }
+
+ async function leaveCommunity(room) {
+ const cid = room.jm.community_id;
+ const id = await identity();
+ const s = st();
+ s.tombstones[cid] = Date.now();
+ s.communities = s.communities.filter((c) => c.community_id !== cid);
+ delete s.joined[cid];
+ save(s);
+ if (id) {
+ const { created_at, ms } = msTags(Date.now());
+ wrapRumor({ kind: 3306, pubkey: id.pubkey, content: 'leave', tags: [ms], created_at }, id.signer, room.guestbook)
+ .then((w) => publishOn(room.relays, w)).catch(() => {});
+ }
+ rooms.delete(cid);
+ publishLists();
+ ui.msgView = 'home';
+ ui.msgLeaveArm = false;
+ ui.msgInvitePanel = false;
+ render();
+ }
+
// ---- DMs ----------------------------------------------------------------
const threadOf = (peer) => threads.get(peer) || threads.set(peer, new Map()).get(peer);
@@ -813,6 +978,7 @@ export function messagesFeature(ctx) {
}
function invitePanel(room) {
+ const builtin = room.jm.community_id === COMMUNITY.community_id;
return h('div', { class: 'col chat-invite', style: 'gap:8px' },
h('div', { class: 'row gap6' },
h('button', {
@@ -832,7 +998,16 @@ export function messagesFeature(ctx) {
h('button', {
class: 'btn-sm',
onClick: () => { sendDirectInvite(room, ui.msgInviteTo); ui.msgInviteTo = ''; render(); },
- }, t('msgSend'))));
+ }, t('msgSend'))),
+ // leaving discards the keys on this identity — two taps, default community exempt
+ builtin ? null : h('div', { class: 'row' },
+ h('button', {
+ class: 'btn-ghost btn-sm ' + (ui.msgLeaveArm ? 'btn-danger' : ''),
+ onClick: () => {
+ if (ui.msgLeaveArm) leaveCommunity(room);
+ else { ui.msgLeaveArm = true; render(); }
+ },
+ }, ui.msgLeaveArm ? t('msgLeaveConfirm') : t('msgLeave'))));
}
// ---- dm thread ----------------------------------------------------------
@@ -888,6 +1063,7 @@ export function messagesFeature(ctx) {
setTimeout(() => { ui.tab = 'messages'; ui.msgView = 'home'; render(); }, 0);
}
startDMs();
+ syncLists().catch(() => {});
},
stop() {
for (const u of allUnsubs) { try { u(); } catch {} }
@@ -896,6 +1072,7 @@ export function messagesFeature(ctx) {
threads.clear();
pendingDirect.clear();
dmStarted = false;
+ listsSynced = false;
},
};
}
diff --git a/src/i18n.js b/src/i18n.js
index e87a49d..474a37b 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -196,6 +196,8 @@ const STR = {
msgCopyInvite: 'Copy invite link',
msgLinkCopied: 'Invite link copied',
msgInviteSent: 'Invite sent',
+ msgLeave: 'Leave community',
+ msgLeaveConfirm: 'Really leave? Rejoining needs a new invite',
copyAddress: 'Copy address',
paymentReceived: 'Payment received!',
tapToProceed: 'Tap anywhere to proceed',