diff options
| author | Adam Soltys <[email protected]> | 2026-08-08 09:24:17 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-08-08 09:24:17 -0700 |
| commit | a8636d2658aedab96eb545ef2f0ba0db972ca228 (patch) | |
| tree | d35009786b643de94a22f1a259afc3cc9e595708 | |
| parent | 19c14131ce3fc69a06d3c0af8d445b056d703bf9 (diff) | |
| download | coinosv3-a8636d2658aedab96eb545ef2f0ba0db972ca228.tar.gz coinosv3-a8636d2658aedab96eb545ef2f0ba0db972ca228.zip | |
The wizard waits out a signer login; a picked recipient shows its work, and search answers from memory before the network
Co-Authored-By: Claude Fable 5 <[email protected]>
| -rw-r--r-- | src/app.js | 15 | ||||
| -rw-r--r-- | src/features/names.js | 9 | ||||
| -rw-r--r-- | src/i18n.js | 1 | ||||
| -rw-r--r-- | src/recipient-search.js | 113 |
4 files changed, 108 insertions, 30 deletions
@@ -2179,8 +2179,12 @@ function onboardScreen() { // Steps that were waiting on something advance the moment it exists — unless // the user explicitly stepped back from username (heldFor remembers which // wallet they backed away from); only a different wallet re-advances then. + // A nostr login in flight holds the step: the wallet opens mid-login, but + // whether the wizard should end (a restored account) isn't known until the + // login finishes — advancing now flashes "Choose your username" at people + // who chose one long ago. if ((o.step === 'welcome' || o.step === 'signin') && ui.screen === 'wallet' && activeAccount() - && activeAccount().id !== o.heldFor) { + && activeAccount().id !== o.heldFor && !ui.nostrLoginBusy) { o.step = 'username'; o.enterAddr = featureHook('namesAddress') || null; } @@ -2990,7 +2994,7 @@ function parkSendField() { const el = document.activeElement; if (el && el.isConnected && el.tagName === 'INPUT') el.scrollIntoView({ block: 'start', behavior: 'smooth' }); } -const sendSearcher = makeSearcher((q, rows) => { sendSearch.rows = rows; render(); if (rows.length) setTimeout(parkSendField, 50); }); +const sendSearcher = makeSearcher((q, rows) => { sendSearch.rows = rows; render(); if (rows && rows.length) setTimeout(parkSendField, 50); }); // One recipient: address + amount. Max is only offered for a single recipient. function recipientRow(s, r, i) { @@ -3034,11 +3038,12 @@ function recipientRow(s, r, i) { }); const pickRecipient = (cand) => { const v = cand.address || npubOf(cand.pk); - sendSearcher.clear(); - addrInput.value = v; + // State first: clear() re-renders, and that render must already see the + // picked address — the old order repainted the half-typed query and left + // the pick looking like it did nothing. r.address = v; ui.sendError = ''; - syncCheck(); + sendSearcher.clear(); if (!featureMatchSend(v)) render(); }; const row = h( diff --git a/src/features/names.js b/src/features/names.js index afd681c..f386a47 100644 --- a/src/features/names.js +++ b/src/features/names.js @@ -556,6 +556,15 @@ export function namesFeature(ctx) { if (!ui.send || ui.send.recipients.length !== 1) return false; return beginResolve(text); }, + // DNS resolution takes visible time — say so under the recipient field + // instead of leaving a picked name looking ignored. + sendFormNote(a) { + const nr = ui.nameResolve; + if (!nr || nr.text !== a) return null; + return h('div', { class: 'row gap6', style: 'align-items:center;justify-content:center' }, + h('span', { class: 'spinner sm' }), + h('span', { class: 'small muted' }, t('namesResolving', { name: String(a || '').trim() }))); + }, settingsCards() { return [namesCard()]; }, }; } diff --git a/src/i18n.js b/src/i18n.js index 2623a56..1a2652f 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -623,6 +623,7 @@ const STR = { namesCustomHow: 'Swap the generated username for one you like. Your old address stops working.', namesDesc: 'Claim your payment address — like [email protected]. People can pay you just by typing it, no codes to copy or QR to scan.', namesPlaceholder: 'username', + namesResolving: 'Looking up {name}…', namesClaim: 'Claim', namesClaimed: 'You are now {name}', namesCopy: 'Copy address', diff --git a/src/recipient-search.js b/src/recipient-search.js index 2cf52b6..5b7758b 100644 --- a/src/recipient-search.js +++ b/src/recipient-search.js @@ -31,27 +31,71 @@ const MAX_RESULTS = 8; // Primal's public cache API has the best-ranked user search on nostr (it's // what the Primal client itself uses): a custom REQ that answers with plain // kind-0 events. Nonstandard, so the NIP-50 relays stay as fallback. +// One socket serves the whole typing session — the per-query handshake used +// to cost more than the query itself. Idle for a minute, it closes itself. +let pws = null, pwsN = 0, pwsIdle = null; +const pwsSubs = new Map(); // sub id -> { rows, finish } function primalSearch(q, limit = MAX_RESULTS) { return new Promise((resolve) => { + const sid = 'rs' + ++pwsN; const rows = []; - let ws; - const finish = () => { clearTimeout(to); try { ws.close(); } catch {} resolve(rows); }; + const finish = () => { clearTimeout(to); pwsSubs.delete(sid); resolve(rows); }; const to = setTimeout(finish, 3500); - try { ws = new WebSocket(PRIMAL_CACHE); } catch { clearTimeout(to); return resolve(rows); } - ws.onopen = () => ws.send(JSON.stringify(['REQ', 'rs', { cache: ['user_search', { query: q, limit }] }])); - ws.onmessage = (ev) => { - try { - const m = JSON.parse(ev.data); - if (m[0] === 'EVENT' && m[2] && m[2].kind === 0) rows.push(m[2]); - if (m[0] === 'EOSE') finish(); - } catch {} - }; - ws.onerror = finish; + pwsSubs.set(sid, { rows, finish }); + clearTimeout(pwsIdle); + pwsIdle = setTimeout(() => { try { pws?.close(); } catch {} pws = null; }, 60_000); + const send = () => { try { pws.send(JSON.stringify(['REQ', sid, { cache: ['user_search', { query: q, limit }] }])); } catch { finish(); } }; + if (pws && pws.readyState === 1) return send(); + if (!pws || pws.readyState > 1) { + try { pws = new WebSocket(PRIMAL_CACHE); } catch { pws = null; return finish(); } + pws.onmessage = (ev) => { + try { + const m = JSON.parse(ev.data); + const sub = pwsSubs.get(m[1]); + if (!sub) return; + if (m[0] === 'EVENT' && m[2] && m[2].kind === 0) sub.rows.push(m[2]); + if (m[0] === 'EOSE') sub.finish(); + } catch {} + }; + pws.onerror = pws.onclose = () => { + pws = null; + const waiting = [...pwsSubs.values()]; + pwsSubs.clear(); + for (const s of waiting) s.finish(); + }; + } + pws.addEventListener('open', send, { once: true }); }); } +// Caches survive reloads: a name searched yesterday paints instantly today. +// Profiles cap at 200 by recency; whole-query results keep for 12 hours. +const PROF_KEY = 'btc-wallet-search-profiles'; +const QUERY_KEY = 'btc-wallet-search-queries'; +const QUERY_TTL = 12 * 3600_000; const profileCache = new Map(); // pk -> { name, picture } const queryCache = new Map(); // q -> rows +const queryAt = new Map(); // q -> when the rows were fetched +try { + for (const [pk, v] of Object.entries(JSON.parse(localStorage.getItem(PROF_KEY) || '{}'))) profileCache.set(pk, v); + const now = Date.now(); + for (const [q, e] of Object.entries(JSON.parse(localStorage.getItem(QUERY_KEY) || '{}'))) + if (e && Array.isArray(e.rows) && now - (e.t || 0) < QUERY_TTL) { queryCache.set(q, e.rows); queryAt.set(q, e.t); } +} catch {} +let persistTimer = null; +function persistCaches() { + clearTimeout(persistTimer); + persistTimer = setTimeout(() => { + try { + const prof = {}; + for (const [pk, v] of [...profileCache].slice(-200)) prof[pk] = { name: v.name || null, picture: v.picture || null }; + localStorage.setItem(PROF_KEY, JSON.stringify(prof)); + const qs = {}; + for (const [q, rows] of [...queryCache].slice(-60)) qs[q] = { t: queryAt.get(q) || Date.now(), rows }; + localStorage.setItem(QUERY_KEY, JSON.stringify(qs)); + } catch {} + }, 800); +} // Worth searching? A partial username or npub — never an address, invoice, // or key the Send form knows how to handle whole. Known payment prefixes are @@ -84,7 +128,11 @@ async function fillProfiles(rows) { } } -export async function searchRecipients(qRaw) { +// onPartial (optional) receives the current best rows as each source lands, +// so the UI paints the fast sources (registrar, cached profiles) without +// waiting for the slow ones. The returned promise still resolves with the +// final, profile-filled rows. +export async function searchRecipients(qRaw, onPartial = null) { const q = qRaw.trim().toLowerCase(); if (queryCache.has(q)) return queryCache.get(q); const out = new Map(); // pk -> { pk, name, address, picture, pri } @@ -95,13 +143,26 @@ export async function searchRecipients(qRaw) { else out.set(pk, { pk, ...row, pri }); }; + // Within a source, a name that actually contains the query outranks the + // search relay's looser relevance matches. + const score = (r) => ((r.name || '').toLowerCase().includes(q) || (r.address || r.nip05 || '').toLowerCase().includes(q)) ? 0 : 1; + const snapshot = () => { + const rows = [...out.values()].sort((a, b) => a.pri - b.pri || score(a) - score(b)).slice(0, MAX_RESULTS); + for (const r of rows) { + const p = profileCache.get(r.pk); + if (p) { r.picture ||= p.picture; r.name ||= p.name; } + } + return rows; + }; + const emit = () => { if (onPartial && out.size) onPartial(snapshot()); }; + const exact = parseNostrPubkey(q); - if (exact) add(exact, {}, 0); + if (exact) { add(exact, {}, 0); emit(); } await Promise.allSettled([ fetch(`${REGISTRAR}/search?q=${encodeURIComponent(q)}`) .then((r) => r.json()) - .then((j) => (j.results || []).forEach((r) => add(r.pubkey, { name: r.name, address: r.address }, 1))), + .then((j) => { (j.results || []).forEach((r) => add(r.pubkey, { name: r.name, address: r.address }, 1)); emit(); }), (async () => { if (exact) return; // an npub needs no full-text search const noteKind0 = (e, pri) => { @@ -113,19 +174,20 @@ export async function searchRecipients(qRaw) { }; const primal = await primalSearch(q); for (const e of primal) noteKind0(e, 2); - if (primal.length) return; // fallback only when the cache is empty/down - const evs = await queryOn(SEARCH_RELAYS, { kinds: [0], search: q, limit: MAX_RESULTS }, 3500); - for (const e of evs) noteKind0(e, 3); + if (!primal.length) { + const evs = await queryOn(SEARCH_RELAYS, { kinds: [0], search: q, limit: MAX_RESULTS }, 3500); + for (const e of evs) noteKind0(e, 3); + } + emit(); })(), ]); - // Within a source, a name that actually contains the query outranks the - // search relay's looser relevance matches. - const score = (r) => ((r.name || '').toLowerCase().includes(q) || (r.address || r.nip05 || '').toLowerCase().includes(q)) ? 0 : 1; - const rows = [...out.values()].sort((a, b) => a.pri - b.pri || score(a) - score(b)).slice(0, MAX_RESULTS); + const rows = snapshot(); await fillProfiles(rows).catch(() => {}); - if (queryCache.size > 200) queryCache.clear(); + if (queryCache.size > 200) { queryCache.clear(); queryAt.clear(); } queryCache.set(q, rows); + queryAt.set(q, Date.now()); + persistCaches(); return rows; } @@ -145,9 +207,10 @@ export function makeSearcher(onUpdate) { // almost immediately (that's the wait the user actually feels), while // longer ones — mid-word keystrokes — wait long enough to skip the // characters still being typed. Cached prefixes are instant. - const wait = queryCache.has(s) ? 0 : s.length <= 3 ? 120 : 500; + const wait = queryCache.has(s) ? 0 : s.length <= 3 ? 120 : 300; timer = setTimeout(async () => { - const rows = await searchRecipients(q).catch(() => []); + // partial results paint as each source lands; the final call settles it + const rows = await searchRecipients(q, (partial) => { if (my === seq) onUpdate(q, partial); }).catch(() => []); if (my === seq) onUpdate(q, rows); }, wait); }, |
