diff options
Diffstat (limited to 'src/features/messages.js')
| -rw-r--r-- | src/features/messages.js | 108 |
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); } |
