diff options
Diffstat (limited to 'src/features')
| -rw-r--r-- | src/features/messages.js | 89 |
1 files changed, 79 insertions, 10 deletions
diff --git a/src/features/messages.js b/src/features/messages.js index f7fb492..d874865 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -3056,21 +3056,60 @@ export function messagesFeature(ctx) { // their last message came. A first message goes by White Noise only to // someone whose KeyPackage was published by another app: a coinos wallet // reads NIP-17 on every device it has, where an MLS group reaches just the - // one device whose KeyPackage was picked. + // one device whose KeyPackage was picked. A route pinned by hand in the + // thread header (st().route) overrides all of that. + const wnTheirs = (peer) => { + let theirs = null; + for (const m of threads.get(peer)?.values() || []) + if (!m.mine && (!theirs || m.rumor.created_at > theirs.rumor.created_at)) theirs = m; + return theirs; + }; + const routePref = (peer) => (st().route || {})[peer] || 'auto'; async function wnRoute(peer) { const c = await whiteNoise(); if (!c) return null; await c.load(); + const pref = routePref(peer); + if (pref === 'nip17') return null; const g = wnDirect(peer); - let theirs = null; - for (const m of threads.get(peer)?.values() || []) - if (!m.mine && (!theirs || m.rumor.created_at > theirs.rumor.created_at)) theirs = m; + const lookup = () => Promise.race([c.lookup(peer), new Promise((r) => setTimeout(() => r(null), 3500))]); + if (pref === 'wn') return g || ((await lookup()) ? 'new' : null); + const theirs = wnTheirs(peer); if (g) return theirs && !theirs.wn ? null : g; if (theirs) return null; - const cand = await Promise.race([c.lookup(peer), new Promise((r) => setTimeout(() => r(null), 3500))]); + const cand = await lookup(); return cand && cand.client !== 'coinos' ? 'new' : null; } + // The same decision without waiting, for the thread header: which way the + // next message will go and what was pinned — or null when there is nothing + // to choose (they are not on White Noise, or only through another coinos + // wallet, where NIP-17 is simply the better road). + const wnCands = new Map(); // peer -> { at, cand } : their KeyPackage, as last looked up + function wnKnow(peer) { + const hit = wnCands.get(peer); + if (hit && Date.now() - hit.at < 10 * 60_000) return; + wnCands.set(peer, { at: Date.now(), cand: hit ? hit.cand : undefined }); + whiteNoise().then((c) => (c ? c.lookup(peer) : null)).then((cand) => { + wnCands.set(peer, { at: Date.now(), cand: cand || null }); + scheduleRepaint(); + }).catch(() => {}); + } + function routeChoice(peer) { + if (!wn) return null; + const g = wnDirect(peer), cand = (wnCands.get(peer) || {}).cand; + if (!g && !(cand && cand.client !== 'coinos')) return null; + const pref = routePref(peer), theirs = wnTheirs(peer); + const auto = g ? (theirs && !theirs.wn ? 'nip17' : 'wn') : (theirs ? 'nip17' : 'wn'); + return { pref, now: pref === 'auto' ? auto : pref }; + } + function setRoute(peer, pref) { + const s = st(); + s.route ||= {}; + if (pref === 'auto') delete s.route[peer]; else s.route[peer] = pref; + save(s); + } + async function sendWnDirect(peer, road, text) { const c = wn; const replyTo = ui.msgReplyTo && threadOf(peer).has(ui.msgReplyTo) ? ui.msgReplyTo : null; @@ -10508,6 +10547,28 @@ export function messagesFeature(ctx) { }))); } + // How messages to this person travel: automatic unless pinned. + function routeSheet(peer, choice) { + const close = () => { ui.msgRoutePick = false; render(); }; + const item = (pref, label, hint) => h('button', { + class: 'msg-sheet-item', 'aria-pressed': String(choice.pref === pref), + onClick: () => { setRoute(peer, pref); close(); }, + }, + h('span', { class: 'msg-sheet-ico' }, choice.pref === pref ? '✓' : ''), + h('span', { class: 'col', style: 'gap:1px;text-align:left' }, + h('span', {}, label), + h('span', { class: 'muted small' }, hint))); + return h('div', { + class: 'confirm-pop-backdrop', + onClick: (e) => { if (e.target === e.currentTarget) close(); }, + }, + h('div', { class: 'card col msg-sheet' }, + h('div', { class: 'muted small', style: 'padding:4px 10px 6px' }, t('msgRouteTitle')), + item('auto', t('msgRouteAuto'), t('msgRouteAutoHint')), + item('wn', t('msgRouteWn'), t('msgRouteWnHint')), + item('nip17', t('msgRouteDm'), t('msgRouteDmHint')))); + } + function dmView() { startDMs(); const peer = ui.msgPeer; @@ -10515,7 +10576,8 @@ export function messagesFeature(ctx) { const thread = threads.get(peer) || new Map(); const msgs = [...thread.values()].sort((a, b) => a.rumor.created_at - b.rumor.created_at); // a first message may go by White Noise: have their KeyPackage looked up before Send - if (!msgs.some((m) => !m.mine)) whiteNoise().then((c) => c && c.lookup(peer)).catch(() => {}); + wnKnow(peer); + const choice = routeChoice(peer); // Looking at the thread is reading it — including anything that lands while // it's still open, since every arrival repaints us. markRead(dmRead(peer), newestFrom(msgs, (m) => !m.mine)); @@ -10555,11 +10617,17 @@ export function messagesFeature(ctx) { }; return h('div', { class: 'card col chat-card chat-page' }, h('div', { class: 'row chat-head gap6', style: 'align-items:center' }, - backBtn(() => { ui.msgView = 'home'; ui.msgReplyTo = null; ui.msgSheet = null; render(); }), + backBtn(() => { ui.msgView = 'home'; ui.msgReplyTo = null; ui.msgSheet = null; ui.msgRoutePick = false; render(); }), avatar(peer), h('div', { class: 'col clickable', style: 'gap:2px;min-width:0', onClick: () => openProfile(peer) }, h('div', { class: 'chat-title' }, displayName(peer)), - h('div', { class: 'muted small' }, t('msgDmEncrypted')))), + h('div', { class: 'muted small' }, t('msgDmEncrypted'))), + // Only where there is a choice: this person can be reached two ways. + // The pill says which way the next message goes; tapping it pins one. + choice ? h('button', { + class: 'btn-sm route-pill', style: 'margin-left:auto;flex-shrink:0', title: t('msgRouteTitle'), 'aria-haspopup': 'true', + onClick: () => { ui.msgRoutePick = !ui.msgRoutePick; render(); }, + }, t(choice.now === 'wn' ? 'msgRouteWn' : 'msgRouteDm'), ' ▾') : null), h('div', { class: 'chat-log', onScroll: (e) => { @@ -10586,7 +10654,8 @@ export function messagesFeature(ctx) { : [h('div', { class: 'muted small', style: 'text-align:center;padding:24px 0' }, t('msgNoDmsYet'))])), dmReplyBar(), composer(t('msgDmPlaceholder'), () => sendDM(peer), null, 'dm:' + peer, (f) => sendDMFile(peer, f)), - ui.msgSheet ? dmSheet(peer) : null); + ui.msgSheet ? dmSheet(peer) : null, + choice && ui.msgRoutePick ? routeSheet(peer, choice) : null); } // ---- notifications: what happened to your posts -------------------------- @@ -11226,7 +11295,7 @@ export function messagesFeature(ctx) { pendingDirect.clear(); if (wn) { try { wn.stop(); } catch {} } wn = null; wnLoading = null; wnReadied = false; - wnFolds.clear(); wnReach.clear(); wnReactOf.clear(); + wnFolds.clear(); wnReach.clear(); wnReactOf.clear(); wnCands.clear(); seenWraps.clear(); wrapLog = null; // the next account must decrypt wraps this one couldn't clearTimeout(drainTimer); pendingWraps.clear(); |
