aboutsummaryrefslogtreecommitdiffstats
path: root/src/features/messages.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-30 09:31:25 -0700
committerAdam Soltys <[email protected]>2026-08-30 09:31:25 -0700
commitd7d5484b76459c068135ebd7fb33ef19adf33bc0 (patch)
tree3abb240544cbeb487e80b723d7da27e9b85c3f6d /src/features/messages.js
parentce7ebbd5d057d9ef5a04b7d704d26ece2ed00ff7 (diff)
downloadcoinosv3-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.js91
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;
},