aboutsummaryrefslogtreecommitdiffstats
path: root/src/features
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-06 11:22:06 -0700
committerAdam Soltys <[email protected]>2026-10-06 11:22:06 -0700
commitd657d79bc95062a8fc5e128e795e23bcefd0bf04 (patch)
tree04a9d39281ce7784f1a4a966f0a532194ee7ded7 /src/features
parentf571e46937d423d9ba44154a92aeb4136466daaa (diff)
downloadcoinosv3-d657d79bc95062a8fc5e128e795e23bcefd0bf04.tar.gz
coinosv3-d657d79bc95062a8fc5e128e795e23bcefd0bf04.zip
Cordn group chats: MLS groups delivered through a coordinator (Amethyst 1.17 interop)
src/contextvm.js speaks ContextVM (kind 25910 inside ephemeral gift wraps) to a coordinator; src/cordn.js is the protocol (KeyPackages with hex-text identities, the 0xC04D metadata extension, exporter-keyed ChaCha20 sealing, unsigned nostr-shaped envelopes with the sender as MLS AAD, cordn1 group refs, a record engine like marmot.js); src/cordn-client.js mirrors marmot-client.js with the same interface, so the Messages group screens drive both — New group gains a White Noise / Cordn toggle. Verified against @cordn/cli (ts-mls) on the public coordinator both ways, from the engine (tools/cordn-interop-test.js) and from the real app (tools/cordn-ui-test.js). mls.js: the OpenMLS required_capabilities rule for GroupContextExtensions now applies only to groups that carry one. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01YRC6aKhwAud231ot9FPmhi
Diffstat (limited to 'src/features')
-rw-r--r--src/features/messages.js108
1 files changed, 86 insertions, 22 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index 7f71264..108af9a 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -1269,6 +1269,7 @@ export function messagesFeature(ctx) {
if (wasDm) startDMs();
for (const room of wanted) subscribeRoom(room);
if (wn) wn.resubscribe();
+ if (cd) cd.resubscribe();
}
// ---- subscription watchdog ------------------------------------------------
@@ -3007,7 +3008,7 @@ export function messagesFeature(ctx) {
if (!(id.signer instanceof Uint8Array) && !id.signer.encryptTo) { toast(t('msgSignerNoDm')); return; }
ui.msgSheet = null;
// a reaction travels with the message it answers
- if (m.wn) { const g = wn && wn.groups.get(m.wn); if (g) sendWnReaction(g, m, emoji); return; }
+ if (m.wn) { const g = findGroup(m.wn); if (g) sendWnReaction(g, m, emoji); return; }
const rumor = makeDMReaction(id.pubkey, peer, m.rumor.id, emoji, reactEmojiTags(emoji));
const r = dmReacts.get(m.rumor.id) || dmReacts.set(m.rumor.id, new Map()).get(m.rumor.id);
const prev = r.get(id.pubkey);
@@ -3089,6 +3090,42 @@ export function messagesFeature(ctx) {
return p;
}
+ // Cordn groups (cordn-client.js): MLS again, delivered by a coordinator
+ // instead of relays. The same interface as the White Noise client, so the
+ // group screens below drive either; gClient(g) says which one a group is.
+ let cd = null, cdLoading = null, cdReadied = false;
+ function cordnChat() {
+ const pk = identityPk();
+ if (!pk || !wallet._cacheKey) return Promise.resolve(null);
+ if (cd && cd.pubkey === pk) return Promise.resolve(cd);
+ if (cdLoading && cdLoading.pk === pk) return cdLoading.p;
+ if (cd) { try { cd.stop(); } catch {} cd = null; }
+ const p = import('../cordn-client.js').then(async (m) => {
+ if (identityPk() !== pk) return null;
+ let coordinator = null;
+ try { coordinator = JSON.parse(localStorage.getItem('coinos-cordn-coordinator') || 'null'); } catch {}
+ const c = m.cordnClient({
+ scope: wallet._cacheKey() + ':' + pk, pubkey: pk, identity, on: onCd, subClosed: noteSubClosed,
+ ...(coordinator && coordinator.pubkey ? { coordinator } : {}),
+ });
+ cd = c;
+ cdLoading = null;
+ await c.start();
+ return c;
+ }).catch((e) => { console.warn('chat: cordn groups unavailable', e); cdLoading = null; return null; });
+ cdLoading = { pk, p };
+ return p;
+ }
+ function onCd(type, g, data) {
+ if (!cd) return;
+ if (type === 'message') { try { noteEmoji(data); } catch {} }
+ wnFolds.delete(g && g.id);
+ bumpMsgRev();
+ scheduleRepaint();
+ }
+ const gClient = (g) => (g && g.cordn ? cd : wn);
+ const findGroup = (id) => (wn && wn.groups.get(id)) || (cd && cd.groups.get(id)) || null;
+
// One delivered event of a direct group, into that person's thread.
function wnIntoThread(g, peer, m) {
const th = threadOf(peer);
@@ -3232,6 +3269,7 @@ export function messagesFeature(ctx) {
}
async function sendWnReaction(g, m, emoji) {
+ const wn = gClient(g);
const inner = wn.event(7, emoji, [['e', m.rumor.id], ['p', m.rumor.pubkey], ['k', String(m.rumor.kind)], ...reactEmojiTags(emoji)]);
const ok = await wn.send(g, inner).catch(() => false);
if (!ok) { toast(t('msgSendFailed')); return; }
@@ -3242,12 +3280,13 @@ export function messagesFeature(ctx) {
const wnRead = (id) => 'wn:' + id;
// groups with a screen of their own (a direct group lives in its DM thread)
- const wnGroups = () => (wn ? [...wn.groups.values()].filter((g) => !wn.peer(g)) : []);
+ const wnGroups = () => [...(wn ? [...wn.groups.values()].filter((g) => !wn.peer(g)) : []), ...(cd ? cd.groups.values() : [])];
function wnTitle(g) {
- const v = wn.view(g);
+ const c = gClient(g);
+ const v = c.view(g);
if (v.name) return v.name;
- const others = wn.accounts(g).filter((pk) => !isMe(pk));
+ const others = c.accounts(g).filter((pk) => !isMe(pk));
return others.length ? others.slice(0, 3).map(displayName).join(', ') : t('msgGroupUnnamed');
}
@@ -3306,11 +3345,14 @@ export function messagesFeature(ctx) {
// People being gathered for a group (the New group panel, or Add person):
// each is checked for a KeyPackage as they are picked.
const wnReach = new Map(); // pk -> 'checking' | 'ok' | 'no'
- function wnCheck(pk) {
- if (wnReach.get(pk) === 'ok' || wnReach.get(pk) === 'checking') return;
- wnReach.set(pk, 'checking');
- whiteNoise().then((c) => (c ? c.lookup(pk) : null)).then((cand) => { wnReach.set(pk, cand ? 'ok' : 'no'); scheduleRepaint(); })
- .catch(() => { wnReach.set(pk, 'no'); scheduleRepaint(); });
+ const cdReach = new Map(); // the same, on the Cordn coordinator
+ const reachOf = (proto) => (proto === 'cd' ? cdReach : wnReach);
+ function wnCheck(pk, proto = 'wn') {
+ const reach = reachOf(proto);
+ if (reach.get(pk) === 'ok' || reach.get(pk) === 'checking') return;
+ reach.set(pk, 'checking');
+ (proto === 'cd' ? cordnChat() : whiteNoise()).then((c) => (c ? c.lookup(pk) : null)).then((cand) => { reach.set(pk, cand ? 'ok' : 'no'); scheduleRepaint(); })
+ .catch(() => { reach.set(pk, 'no'); scheduleRepaint(); });
}
const wnFail = (e) => {
if (e && e.missing) toast(t('msgGroupSomeCant', { names: e.missing.map(displayName).join(', ') }));
@@ -3325,11 +3367,12 @@ export function messagesFeature(ctx) {
if (ui.msgBusy) return;
ui.msgBusy = true; render();
try {
- const c = await whiteNoise();
+ const cordn = ui.msgGroupProto === 'cd';
+ const c = await (cordn ? cordnChat() : whiteNoise());
if (!c) throw new Error('no signer');
// a group with no name is still a group once it has a third person;
// with two it would be a direct chat, so it takes a name
- const name = (ui.msgGroupName || '').trim() || (members.length === 1 ? t('msgGroupUnnamed') : '');
+ const name = (ui.msgGroupName || '').trim() || (members.length === 1 || cordn ? t('msgGroupUnnamed') : '');
const g = await c.create({ name, members });
ui.msgGroupPicks = []; ui.msgGroupName = ''; ui.msgGroupTo = ''; ui.msgHomePanel = null;
dmSearcher.clear();
@@ -3339,6 +3382,7 @@ export function messagesFeature(ctx) {
async function wnSend(g) {
const text = draftFor('wn:' + g.id).trim();
+ const wn = gClient(g);
if (!text || !wn) return;
const replyTo = ui.msgReplyTo && wnFold(g).rows.some((m) => m.rumor.id === ui.msgReplyTo) ? ui.msgReplyTo : null;
ui.msgReplyTo = null;
@@ -11296,6 +11340,11 @@ export function messagesFeature(ctx) {
if (Date.now() - listsSyncedAt > 60_000) syncLists({ force: true }).catch(() => {});
for (const jm of communities()) ensureRoom(jm);
+ // Cordn groups the same way: a KeyPackage on the coordinator is what
+ // makes us invitable there (a seed wallet signs it unasked; a signer
+ // login is asked when it first opens a Cordn group or invites someone).
+ if (ui.msgView !== 'grp' && cd) cd.focus(null);
+ cordnChat().then((c) => { if (c && !cdReadied) { cdReadied = true; c.ready(); } });
// Group chats ride along: the client loads with the wallet, and standing
// on this screen is what makes us invitable (a KeyPackage on the relays).
whiteNoise().then(async (c) => {
@@ -11851,6 +11900,7 @@ export function messagesFeature(ctx) {
// ---- group chats (White Noise) --------------------------------------------
function wnGroupRow(g) {
+ const wn = gClient(g);
const name = wnTitle(g);
const last = wnChat(g).at(-1);
const unread = wnUnread(g);
@@ -11872,6 +11922,7 @@ export function messagesFeature(ctx) {
}
function wnInviteCard(g) {
+ const wn = gClient(g);
return h('div', { class: 'notice info col', style: 'gap:8px' },
h('div', {}, t('msgGroupInvite', { name: displayName(g.inviter), group: wnTitle(g) })),
h('div', { class: 'row gap6' },
@@ -11887,8 +11938,9 @@ export function messagesFeature(ctx) {
// Search, tap, and the person becomes a chip that says whether they can be
// reached. Shared by New group and a group's Add person.
- function wnPeoplePicker(picks, onPick, onDrop) {
- for (const pk of picks) { profileOf(pk); wnCheck(pk); }
+ function wnPeoplePicker(picks, onPick, onDrop, proto = 'wn') {
+ const wnReach = reachOf(proto);
+ for (const pk of picks) { profileOf(pk); wnCheck(pk, proto); }
const add = (pk) => { if (!isMe(pk) && !picks.includes(pk)) onPick(pk); dmSearcher.clear(); ui.msgGroupTo = ''; render(); };
return [
picks.length ? h('div', { class: 'row gap6', style: 'flex-wrap:wrap' }, picks.map((pk) => {
@@ -11914,24 +11966,32 @@ export function messagesFeature(ctx) {
function wnNewGroupPanel() {
const picks = ui.msgGroupPicks || (ui.msgGroupPicks = []);
- const ready = picks.length && picks.every((pk) => wnReach.get(pk) === 'ok');
+ const proto = ui.msgGroupProto || 'wn';
+ const ready = picks.length && picks.every((pk) => reachOf(proto).get(pk) === 'ok');
return h('div', { class: 'col gap6' },
+ // which protocol the group speaks: White Noise (Marmot) or Cordn
+ h('div', { class: 'seg' },
+ h('button', { class: proto === 'wn' ? 'active' : '', onClick: () => { ui.msgGroupProto = 'wn'; render(); } }, t('msgGroupProtoWn')),
+ h('button', { class: proto === 'cd' ? 'active' : '', onClick: () => { ui.msgGroupProto = 'cd'; render(); } }, t('msgGroupProtoCd'))),
+ proto === 'cd' ? h('div', { class: 'small muted' }, t('msgGroupProtoCdHint')) : null,
h('input', {
type: 'text', placeholder: t('msgGroupName'), maxlength: '64',
value: ui.msgGroupName || '', onInput: (e) => { ui.msgGroupName = e.target.value; },
}),
- ...wnPeoplePicker(picks, (pk) => picks.push(pk), (pk) => picks.splice(picks.indexOf(pk), 1)),
+ ...wnPeoplePicker(picks, (pk) => picks.push(pk), (pk) => picks.splice(picks.indexOf(pk), 1), proto),
h('div', { class: 'row' },
h('button', { class: 'btn-primary btn-sm', disabled: !ready || ui.msgBusy, onClick: wnCreateGroup },
ui.msgBusy ? h('span', { class: 'spinner sm' }) : t('msgGroupCreate'))));
}
function wnMemberPanel(g, admin) {
+ const wn = gClient(g);
+ const proto = g.cordn ? 'cd' : 'wn';
const v = wn.view(g);
const accounts = wn.accounts(g);
const picks = ui.msgGroupAdd || (ui.msgGroupAdd = []);
return h('div', { class: 'col chat-members' },
- h('div', { class: 'muted small', style: 'padding:8px 12px 2px' }, t('msgGroupInfo')),
+ h('div', { class: 'muted small', style: 'padding:8px 12px 2px' }, t(g.cordn ? 'msgCordnInfo' : 'msgGroupInfo')),
...accounts.map((pk) => h('div', { class: 'item chat-thread-row', onClick: () => openProfile(pk) },
avatar(pk),
h('div', { class: 'row gap6 grow', style: 'align-items:center;min-width:0' },
@@ -11944,9 +12004,9 @@ export function messagesFeature(ctx) {
}, t('msgGroupRemove'))
: null)),
admin ? h('div', { class: 'col gap6 chat-invite' },
- ...wnPeoplePicker(picks, (pk) => picks.push(pk), (pk) => picks.splice(picks.indexOf(pk), 1)),
+ ...wnPeoplePicker(picks, (pk) => picks.push(pk), (pk) => picks.splice(picks.indexOf(pk), 1), proto),
picks.length ? h('div', { class: 'row' }, h('button', {
- class: 'btn-primary btn-sm', disabled: ui.msgBusy || !picks.every((pk) => wnReach.get(pk) === 'ok'),
+ class: 'btn-primary btn-sm', disabled: ui.msgBusy || !picks.every((pk) => reachOf(proto).get(pk) === 'ok'),
onClick: () => wnAct(async () => { await wn.add(g, [...picks]); ui.msgGroupAdd = []; }),
}, ui.msgBusy ? h('span', { class: 'spinner sm' }) : t('msgAddPerson'))) : null,
h('div', { class: 'row gap6' },
@@ -11964,6 +12024,7 @@ export function messagesFeature(ctx) {
}
function wnSheet(g) {
+ const wn = gClient(g);
const fold = wnFold(g);
const m = fold.rows.find((x) => x.rumor.id === ui.msgSheet);
if (!m) { ui.msgSheet = null; return null; }
@@ -11990,18 +12051,21 @@ export function messagesFeature(ctx) {
!ui.emojiPick && m.mine && !g.removed ? item('×', t('msgDelete'), async () => {
close();
const del = wn.event(5, '', [['e', m.rumor.id], ['k', '9']]);
- if (await wn.send(g, del).catch(() => false)) onWn('message', g, del);
+ if (await wn.send(g, del).catch(() => false)) (g.cordn ? onCd : onWn)('message', g, del);
}) : null));
}
function wnView() {
- const g = wn && wn.groups.get(ui.msgPeer);
+ const g = findGroup(ui.msgPeer);
if (!g) {
// the groups are still loading (a reload straight into this screen)
- if (!wn || wnLoading) { whiteNoise().then(() => scheduleRepaint()); return h('div', { class: 'card col chat-card chat-page' }, h('span', { class: 'spinner' })); }
+ if (!wn || wnLoading || !cd || cdLoading) { Promise.all([whiteNoise(), cordnChat()]).then(() => scheduleRepaint()); return h('div', { class: 'card col chat-card chat-page' }, h('span', { class: 'spinner' })); }
ui.msgView = 'home';
return homeView();
}
+ const wn = gClient(g);
+ // a Cordn group on screen is read from its coordinator every few seconds
+ if (cd) cd.focus(g.cordn ? g.id : null);
const fold = wnFold(g);
const v = wn.view(g);
const my = myPubkeys();
@@ -12105,7 +12169,7 @@ export function messagesFeature(ctx) {
h('span', { class: 'small muted chat-quote-text' }, ...snippetNodes(reply.rumor))),
h('button', { class: 'chat-del', style: 'position:static;display:flex;flex-shrink:0', onClick: () => { ui.msgReplyTo = null; render(); } }, '×')) : null,
live
- ? composer(t('msgDmPlaceholder'), () => wnSend(g), null, 'wn:' + g.id, (f) => wnSendFile(g, f))
+ ? composer(t('msgDmPlaceholder'), () => wnSend(g), null, 'wn:' + g.id, g.cordn ? null : (f) => wnSendFile(g, f))
: h('div', { class: 'muted small', style: 'text-align:center;padding:12px' }, t('msgGroupGone')),
ui.msgSheet ? wnSheet(g) : null);
}