diff options
| author | Adam Soltys <[email protected]> | 2026-08-30 09:31:25 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-08-30 09:31:25 -0700 |
| commit | d7d5484b76459c068135ebd7fb33ef19adf33bc0 (patch) | |
| tree | 3abb240544cbeb487e80b723d7da27e9b85c3f6d /src/features/messages.js | |
| parent | ce7ebbd5d057d9ef5a04b7d704d26ece2ed00ff7 (diff) | |
| download | coinosv3-d7d5484b76459c068135ebd7fb33ef19adf33bc0.tar.gz coinosv3-d7d5484b76459c068135ebd7fb33ef19adf33bc0.zip | |
DMs stop losing the race with the signer, and your own address stops paying yourself: undecrypted wraps are kept and retried when a signer comes live (the backfill used to be consumed and dropped whenever the login signer hadn't resumed yet — each device kept a different random subset of history), the inbox also reads the relays our kind-10050 actually advertises, and pasting your own ark address or coinos name into Send now opens the board panel from Savings instead of a silent Spending→Spending self-send; a foreign ark address while Savings is selected says to switch instead of quietly spending the other balance
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01MrkLsNScXe9b6BviUSdmMr
Diffstat (limited to 'src/features/messages.js')
| -rw-r--r-- | src/features/messages.js | 91 |
1 files changed, 83 insertions, 8 deletions
diff --git a/src/features/messages.js b/src/features/messages.js index 5f74c46..42446f4 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -1079,14 +1079,38 @@ export function messagesFeature(ctx) { scheduleRepaint(); } - async function handleInboxWrap(wrap) { - if (seenWraps.has(wrap.id)) return; - seenWraps.add(wrap.id); - const decryptors = []; - if (wallet.nostr && wallet.nostr.sk) decryptors.push(wallet.nostr.sk); + // Wraps nothing could open are NOT consumed. The usual reason is the + // remote signer losing the race with the relay backfill on a fresh load + // (the login signer resumes lazily, the stored wraps arrive first), or a + // single bunker round-trip failing — and dropping the wrap there was how + // whole stretches of DM history quietly went missing, differently on every + // device. Keep them and retry as the decryptor set improves; a wrap that + // never opens (a stranger's wrap that merely p-tags us) falls out after a + // few full-strength passes. + const pendingWraps = new Map(); // wrap.id -> { wrap, tries } + const PENDING_MAX = 800; + const PENDING_TRIES = 6; + let drainTimer = 0; + let draining = false; + + function dmDecryptors() { + const out = []; + if (wallet.nostr && wallet.nostr.sk) out.push(wallet.nostr.sk); const login = hook('nostrLoginIdentity'); - if (login && login.signer && login.signer.decryptFrom) decryptors.push(login.signer); - for (const d of decryptors) { + if (login && login.signer && login.signer.decryptFrom) out.push(login.signer); + return out; + } + // A "full-strength" attempt has every key this wallet expects: retries + // only count against a wrap once the login signer (if one is linked) has + // actually had its chance — the wallet key alone can never open a wrap + // sealed to the login npub. + function decryptorsComplete() { + const login = hook('nostrLoginIdentity'); + return !login || !!(login.signer && login.signer.decryptFrom); + } + + async function openInboxWrap(wrap) { + for (const d of dmDecryptors()) { const got = await unwrapDM(wrap, d).catch(() => null); if (!got) continue; if (got.rumor.kind === 14) { @@ -1107,8 +1131,38 @@ export function messagesFeature(ctx) { scheduleRepaint(); } catch {} } - return; + return true; } + return false; + } + + async function handleInboxWrap(wrap) { + if (seenWraps.has(wrap.id)) return; + seenWraps.add(wrap.id); + if (await openInboxWrap(wrap)) return; + if (pendingWraps.size >= PENDING_MAX) pendingWraps.delete(pendingWraps.keys().next().value); + pendingWraps.set(wrap.id, { wrap, tries: decryptorsComplete() ? 1 : 0 }); + scheduleDrain(8000); + } + + function scheduleDrain(ms) { + if (!pendingWraps.size) return; + clearTimeout(drainTimer); + drainTimer = setTimeout(() => { drainPendingWraps().catch(() => {}); }, ms); + } + + async function drainPendingWraps() { + if (draining || !pendingWraps.size || !dmDecryptors().length) return; + draining = true; + const complete = decryptorsComplete(); + try { + for (const [id, p] of [...pendingWraps]) { + if (await openInboxWrap(p.wrap)) pendingWraps.delete(id); + else if (complete && ++p.tries >= PENDING_TRIES) pendingWraps.delete(id); + } + } finally { draining = false; } + // still holding wraps: the signer may connect later — keep a slow retry + if (pendingWraps.size) scheduleDrain(complete ? 30_000 : 60_000); } function openDirectBundle(json) { @@ -1144,6 +1198,22 @@ export function messagesFeature(ctx) { allUnsubs.push(subscribeOn(DM_RELAYS, { kinds: [1059], '#p': pks, limit: 400 }, (wrap) => { handleInboxWrap(wrap).catch(() => {}); })); + // Senders deliver to the relays our kind-10050 advertises — a list other + // clients may have published with relays beyond our defaults. Read those + // too, or a compliant sender's DM lands somewhere we never look (a reply + // went only to damus/primal and no device ever showed it). + (async () => { + try { + const lists = await Promise.all(pks.map((pk) => fetchInboxRelays(pk).catch(() => []))); + const extras = [...new Set(lists.flat().map((r) => String(r || '').trim().replace(/\/$/, '')))] + .filter((r) => /^wss:\/\//i.test(r) && !DM_RELAYS.includes(r)) + .slice(0, 3); + if (extras.length && dmStarted) + allUnsubs.push(subscribeOn(extras, { kinds: [1059], '#p': pks, limit: 400 }, (wrap) => { + handleInboxWrap(wrap).catch(() => {}); + })); + } catch {} + })(); ensureDmRelayList().catch(() => {}); } @@ -2601,6 +2671,9 @@ export function messagesFeature(ctx) { ui.userSearch = { q: '', rows: null }; return true; }, + // nostr-login just connected or resumed a signer: wraps the backfill + // couldn't open a moment ago can be opened now — drain right away. + nostrSignerLive() { scheduleDrain(0); }, // Anyone (ark's history, other features) can open a profile or render a // small clickable identity chip. showProfile(pk) { openProfile(pk); return true; }, @@ -2663,6 +2736,8 @@ export function messagesFeature(ctx) { threads.clear(); pendingDirect.clear(); seenWraps.clear(); // the next account must decrypt wraps this one couldn't + clearTimeout(drainTimer); + pendingWraps.clear(); dmStarted = false; listsSynced = false; }, |
