diff options
| author | Adam Soltys <[email protected]> | 2026-10-07 06:46:53 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-07 06:46:53 -0700 |
| commit | 64c0afcb99846528029e06309369aee8b28adff6 (patch) | |
| tree | f337e51d81f109f856786f0e1af3f2d911e1ee05 /src/features | |
| parent | 9b3a440f9e56054d23bf24b9c0b2010c92e98d68 (diff) | |
| download | coinosv3-64c0afcb99846528029e06309369aee8b28adff6.tar.gz coinosv3-64c0afcb99846528029e06309369aee8b28adff6.zip | |
DMs: file messages keep their key across reloads, tabs no longer erase each other's cached messages, one-time re-open of the last two weeks of wraps
The DM cache kept only text, so a kind-15 picture (an encrypted Blossom
blob) came back after a reload as a bare link with no key. Every tab also
rewrote the whole cache from its own memory, so a tab asleep since
yesterday erased messages another tab had received, while the wrap log
still marked them opened: those messages were never fetched again (a
phone showed 2 of 4). Rows now carry kind 15's tags and merge by id with
what's stored; the wrap log merges too. A one-time pass re-opens wraps
from the last 14 days so damaged caches heal.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Diffstat (limited to 'src/features')
| -rw-r--r-- | src/features/messages.js | 104 |
1 files changed, 80 insertions, 24 deletions
diff --git a/src/features/messages.js b/src/features/messages.js index 108af9a..3eb9258 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -564,11 +564,26 @@ export function messagesFeature(ctx) { const WRAPS_MAX = 1500; const wrapKey = (id) => String(id).slice(0, 16); let wrapLog = null, wrapsTimer = 0; + // One pass, once per wallet, over the last two weeks of wraps even though + // the log calls them opened: caches written before rows kept file fields, + // or clobbered by another tab, are missing messages no relay would ever be + // asked for again. Only messages and reactions are re-applied. + const HEAL_VERSION = 1; + let healSince = 0; + const healed = new Set(); function loadSeenWraps() { if (wrapLog) return; - wrapLog = (st().wraps || []).slice(-WRAPS_MAX); + const s = st(); + wrapLog = (s.wraps || []).slice(-WRAPS_MAX); for (const k of wrapLog) seenWraps.add(k); + healSince = 0; + if ((s.dmHeal || 0) < HEAL_VERSION) { + if (wrapLog.length) healSince = Math.floor(Date.now() / 1000) - 14 * 86400; + s.dmHeal = HEAL_VERSION; + save(s); + } } + const healing = (wrap) => !!healSince && wrap.created_at >= healSince && !healed.has(wrap.id) && !!healed.add(wrap.id); function rememberWrap(id) { const k = wrapKey(id); if (!wrapLog) wrapLog = []; @@ -576,7 +591,14 @@ export function messagesFeature(ctx) { seenWraps.add(k); clearTimeout(wrapsTimer); wrapsTimer = setTimeout(() => { - try { const s = st(); s.wraps = wrapLog.slice(-WRAPS_MAX); wrapLog = s.wraps; save(s); } catch {} + try { + // another tab may have logged wraps since this one loaded: keep theirs + const s = st(); + const mine = new Set(wrapLog); + s.wraps = [...(s.wraps || []).filter((k) => !mine.has(k)), ...wrapLog].slice(-WRAPS_MAX); + wrapLog = s.wraps; + save(s); + } catch {} }, 1500); } let dmStarted = false; @@ -2778,11 +2800,12 @@ export function messagesFeature(ctx) { // true: opened. false: not ours. 'later': a remote signer never answered, // so nothing is known yet — the wrap must be tried again, uncounted. - async function openInboxWrap(wrap) { + async function openInboxWrap(wrap, heal = false) { for (const d of dmDecryptors()) { let got; try { got = await unwrapDMAny(wrap, d); } catch { return 'later'; } if (!got) continue; + if (heal && ![7, 14, 15].includes(got.rumor.kind)) return true; if (got.rumor.kind === 14 || got.rumor.kind === 15) { // unwrapDM judges "mine" against the key that DECRYPTED, but this // wallet can hold several identities (wallet key + nostr login). A @@ -2824,12 +2847,16 @@ export function messagesFeature(ctx) { } async function handleInboxWrap(wrap) { - if (seenWraps.has(wrap.id) || seenWraps.has(wrapKey(wrap.id))) return; + let heal = false; + if (seenWraps.has(wrap.id) || seenWraps.has(wrapKey(wrap.id))) { + if (!healing(wrap)) return; + heal = true; + } seenWraps.add(wrap.id); - const r = await openInboxWrap(wrap); + const r = await openInboxWrap(wrap, heal); if (r === true) { rememberWrap(wrap.id); return; } if (pendingWraps.size >= PENDING_MAX) pendingWraps.delete(pendingWraps.keys().next().value); - pendingWraps.set(wrap.id, { wrap, tries: r !== 'later' && decryptorsComplete() ? 1 : 0 }); + pendingWraps.set(wrap.id, { wrap, heal, tries: r !== 'later' && decryptorsComplete() ? 1 : 0 }); scheduleDrain(8000); } @@ -2862,7 +2889,7 @@ export function messagesFeature(ctx) { // full-strength failures would evict messages that were never really // tried. Stop here — the slow retry picks the rest up. if (complete && !decryptorsComplete()) break; - const r = await openInboxWrap(p.wrap); + const r = await openInboxWrap(p.wrap, p.heal); if (r === true) { pendingWraps.delete(id); rememberWrap(id); } else if (r === 'later') break; // the signer went quiet — nothing learned, try the rest later else if (complete && ++p.tries >= PENDING_TRIES) { pendingWraps.delete(id); rememberWrap(id); } @@ -2905,11 +2932,7 @@ export function messagesFeature(ctx) { swept = true; continue; } - for (const m of list) - threadOf(peer).set(m.id, { - rumor: { id: m.id, pubkey: m.from, content: m.text, created_at: m.t, kind: 14, tags: (m.em || []).map(([c, u]) => ['emoji', c, u]) }, - mine: isMe(m.from), - }); + for (const m of list) threadOf(peer).set(m.id, { rumor: rumorFromRow(m), mine: isMe(m.from) }); } bumpMsgRev(); if (swept) save(s); @@ -3033,22 +3056,55 @@ export function messagesFeature(ctx) { } } + // A cached DM row. A file message (kind 15) keeps its kind and tags — the + // url alone is an encrypted blob, and without the key and nonce a reload + // turned the picture into a bare link. + function rowOf(rumor) { + const row = { id: rumor.id, from: rumor.pubkey, text: rumor.content, t: rumor.created_at }; + if (rumor.kind === 15) { + row.k = 15; + row.tg = (rumor.tags || []).filter((x) => x[0] !== 'p'); + } else { + // a message's custom emoji ride along, or they'd be text after a reload + const em = [...emojiTagMap(rumor.tags)]; + if (em.length) row.em = em; + } + return row; + } + function rumorFromRow(m) { + return { + id: m.id, pubkey: m.from, content: m.text, created_at: m.t, kind: m.k || 14, + tags: m.tg || (m.em || []).map(([c, u]) => ['emoji', c, u]), + }; + } + + // The cache is shared by every tab of this wallet, and each tab only holds + // the messages IT has seen: a tab left asleep since yesterday used to + // rewrite the cache from its own memory and erase what another tab had + // received meanwhile — while the wrap log still said those were opened, so + // nothing ever fetched them again. Rows are merged by id with what's + // stored; the fuller row (the one carrying file fields) wins. function persistDms() { const s = st(); - const byRecent = [...threads.entries()] - .map(([peer, m]) => [peer, [...m.values()].sort((a, b) => a.rumor.created_at - b.rumor.created_at)]) - .sort((a, b) => (b[1].at(-1)?.rumor.created_at || 0) - (a[1].at(-1)?.rumor.created_at || 0)) + const merged = new Map(); // peer -> Map(id -> row) + for (const [peer, list] of Object.entries(s.dms || {})) + merged.set(peer, new Map(list.map((r) => [r.id, r]))); + for (const [peer, m] of threads) { + const rows = merged.get(peer) || merged.set(peer, new Map()).get(peer); + for (const e of m.values()) { + if (e.pending || e.wn) continue; + const row = rowOf(e.rumor); + const had = rows.get(row.id); + if (!had || !had.tg || row.tg) rows.set(row.id, row); + } + } + const byRecent = [...merged.entries()] + .map(([peer, rows]) => [peer, [...rows.values()].sort((a, b) => a.t - b.t)]) + .filter(([, list]) => list.length) + .sort((a, b) => (b[1].at(-1)?.t || 0) - (a[1].at(-1)?.t || 0)) .slice(0, 30); s.dms = {}; - for (const [peer, list] of byRecent) - s.dms[peer] = list.filter((m) => !m.pending && !m.wn).slice(-CACHE_MAX) - .map((m) => { - const row = { id: m.rumor.id, from: m.rumor.pubkey, text: m.rumor.content, t: m.rumor.created_at }; - // a message's custom emoji ride along, or they'd be text after a reload - const em = [...emojiTagMap(m.rumor.tags)]; - if (em.length) row.em = em; - return row; - }); + for (const [peer, list] of byRecent) s.dms[peer] = list.slice(-CACHE_MAX); save(s); } |
