diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/app.js | 43 | ||||
| -rw-r--r-- | src/features/ark.js | 86 | ||||
| -rw-r--r-- | src/features/sync.js | 47 | ||||
| -rw-r--r-- | src/recipient-search.js | 24 |
4 files changed, 137 insertions, 63 deletions
@@ -756,7 +756,7 @@ window.addEventListener('popstate', (e) => { // in the browser's history. Walking Back into one with a wallet open showed // the start page over a perfectly open session — which reads as "I got // logged out". With a wallet open, those entries become the wallet home. - if (snap.screen && snap.screen !== 'wallet' && activeAccount() && ui.screen === 'wallet') { + if (['unlock', 'vault'].includes(snap.screen) && activeAccount() && ui.screen === 'wallet') { snap = Object.fromEntries(NAV_FIELDS.map((f) => [f, null])); snap.screen = 'wallet'; snap.tab = ui.tab ?? null; try { history.replaceState({ nav: snap, i: st && typeof st.i === 'number' ? st.i : navIndex }, ''); } catch {} @@ -1381,6 +1381,7 @@ async function activateAccount(acc, opts = {}) { // reflow when the name lookup proves Spending a beat later. if (opts.spendingHint && !acc.spendingSetup) { acc.spendingSetup = Date.now(); } const netName = acc.network; + const stillActive = () => activeAccount() === acc && getNetwork() === netName; if (netName !== getNetwork()) { setNetwork(netName); for (const f of FEATURES) { try { f.networkChanged && f.networkChanged(netName); } catch {} } @@ -1481,15 +1482,19 @@ async function activateAccount(acc, opts = {}) { // with no synced state); that's what discovers the used addresses. Otherwise // the socket + frontier poll keep us current with no refresh-time burst. const hadNostr = wallet.watchOnly || !wallet.syncFromNostr ? false : await wallet.syncFromNostr(); + if (!stillActive()) return; if (!hadCache && !hadNostr) { await wallet.scan({ silent: false }); } + if (!stillActive()) return; // Re-baseline a fresh open against the final frontier (Nostr or the discovery // scan may have advanced it) so payments that predate the open don't celebrate. if (opts.fresh) { ack = wallet.nextReceiveIndex; wallet.setReceiveAck(ack); ui.receiveSeenIndex = ack; } wallet.retrack(); // re-subscribe to the latest frontier (Nostr/scan may have moved it) } catch { - enterOfflineFallback(); + // A scan can finish after logout or an account switch. Its failure must + // not put the new session (or an empty wallet) into offline mode. + if (stillActive()) enterOfflineFallback(); } } @@ -2267,7 +2272,8 @@ function lock({ offerPassword = false } = {}) { function avatarMenu() { const me = (featureHook('nostrLoginIdentity') || {}).pubkey || (wallet.nostrPubkey && wallet.nostrPubkey()) || (activeAccount() || {}).nostrPk; - // No identity (watch-only wallet): a neutral face with nowhere to go. + // Older watch-only accounts may have no remembered Nostr identity. Keep + // account access available even when there is no profile to open. const node = (me && featureHook('headerAvatar', me)) || h('span', { class: 'chat-avatar header-ava fallback', @@ -2275,8 +2281,15 @@ function avatarMenu() { }); return h('button', { class: 'header-avatar', - title: me ? t('profYourProfile') : undefined, - onClick: me ? () => { featureHook('showProfile', me); render(); } : undefined, + title: me ? t('profYourProfile') : t('accounts'), + 'aria-label': me ? t('profYourProfile') : t('accounts'), + onClick: () => { + clearFeatureNav(); ui.screen = 'wallet'; + if (!me || !featureHook('showProfile', me)) { + ui.screen = 'accounts'; + } + render(); + }, }, node); } @@ -2284,7 +2297,7 @@ function settingsBtn() { return h('button', { class: 'header-msgs', title: t('tabSettings'), 'aria-label': t('tabSettings'), onClick: () => { - ui.chatOpen = false; ui.profilePk = null; ui.userSearch = null; ui.noteThread = null; + clearFeatureNav(); ui.screen = 'wallet'; ui.tab = 'settings'; ui.settingsPage = null; render(); }, }, h('span', { @@ -2371,7 +2384,7 @@ function messagesBtn() { class: 'header-msgs' + (unread ? ' unread' : ''), title: t('msgDmsTitle'), 'aria-label': t('msgDmsTitle'), - onClick: () => { ui.profilePk = null; ui.userSearch = null; ui.noteThread = null; ui.zapSetup = null; ui.chatOpen = true; ui.msgView = 'home'; render(); }, + onClick: () => { clearFeatureNav(); ui.screen = 'wallet'; ui.chatOpen = true; ui.msgView = 'home'; render(); }, }, h('span', { class: 'hm-ico', html: '<svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg>', @@ -2386,6 +2399,7 @@ function searchBtn() { title: t('searchUsers'), 'aria-label': t('searchUsers'), onClick: () => { + clearFeatureNav(); ui.screen = 'wallet'; featureHook('openUserSearch'); render(); setTimeout(() => document.querySelector('.user-search-input')?.focus(), 60); @@ -2800,6 +2814,15 @@ function claimTargets() { // Account switcher: pick a wallet, add another, or lock the session. function accountsScreen() { if (ui.pw) return h('div', { class: 'col', style: 'gap:16px' }, brandHeader(false), pwPromptCard()); + if (ui.logoutConfirm) { + return h('div', { class: 'col', style: 'gap:16px' }, + brandHeader(false), + h('div', { class: 'card col' }, + h('h3', {}, t('logout') + '?'), + h('p', { class: 'small muted' }, t('logoutPopBlurb')), + h('button', { class: 'btn-primary btn-block', onClick: () => { ui.logoutConfirm = null; ctx.logout(); } }, t('logout')), + h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.logoutConfirm = null; render(); } }, t('back')))); + } if (ui.addWallet) { const aw = ui.addWallet; const fulls = accounts.filter((x) => x.type === 'full' && x.mnemonic); @@ -2866,7 +2889,7 @@ function accountsScreen() { // Reached from an open wallet this is a page, not a boot screen — the // header icons (search, messages, settings, lock, avatar) stay put. // Cold boot lands here with nothing loaded yet, and then they hide. - brandHeader(wallet.loaded), + brandHeader(!!activeAccount()), h('div', { class: 'card col' }, h('h3', {}, t('accounts')), h('div', { class: 'col', style: 'gap:0' }, @@ -2909,6 +2932,8 @@ function accountsScreen() { ]) ), h('button', { class: 'btn-block', onClick: () => { ui.addWallet = { kind: 'spending', from: 'new' }; render(); } }, t('addWallet')), + h('button', { class: 'btn-block', onClick: signInAnother }, t('signInAnother')), + activeAccount() ? h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.logoutConfirm = true; render(); } }, t('logout')) : null, hasVault() ? h('button', { class: 'btn-ghost btn-block', onClick: startChangePw }, t('changePassword')) : null, h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.confirmClear = true; render(); } }, t('clearAll')) ), @@ -3447,7 +3472,7 @@ function onboardScreen() { // a relative path; kind 0 needs the absolute URL (a relative one // once replaced a real profile picture with a broken string). const picked = o.avatar && !/^https?:/i.test(o.avatar) ? 'https://v3.coinos.io/' + o.avatar : o.avatar; - const picture = picked || (me ? 'https://v3.coinos.io/' + punkUrl(me) : undefined); + const picture = picked || (me ? new URL(punkUrl(me), 'https://v3.coinos.io/').href : undefined); const fields = { name: addr ? addr.split('@')[0] : undefined, picture }; // Publishing the profile is a nicety — a signer that can't sign // right now (a sleeping phone bunker) must not trap the wizard. diff --git a/src/features/ark.js b/src/features/ark.js index 93578da..22ba5fd 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -144,11 +144,11 @@ export function installArkWallet(wallet) { }); } -// A slim projection of ark state for the sync snapshot — the sync relay caps -// events at 64 KB and the full state (deep-genesis vtxo bytes + done-action -// hex) blew past it, so publishes silently failed and nothing synced. Another +// A slim projection of ark state for the sync snapshot. Oversized domains +// now travel as encrypted Blossom blobs, so preserve the complete history +// and spent-coin stubs instead of rebuilding them from the mailbox. Another // device needs, per vtxo: id/amount/state (+ bytes ONLY for spendable/pending, -// which it might spend/exit); the movement log (capped) for ark history; and +// which it might spend/exit); the movement log for ark history; and // done board/offboard/exit actions (stripped of hex) so on-chain rows still // label. In-flight actions and their signing material stay device-local. export function slimArkForSync(s) { @@ -156,22 +156,20 @@ export function slimArkForSync(s) { const HEAVY = ['bytes', 'destBytes', 'changeBytes', 'vtxoBytes', 'txHex', 'fundingTxHex', 'outputVtxos']; return { v: s.v, serverPubkey: s.serverPubkey, mailboxCheckpoint: s.mailboxCheckpoint, nextKeyIndex: s.nextKeyIndex, receiveAckTs: s.receiveAckTs, - // Spent stubs and done actions are pure history and grow forever — cap - // them (newest kept) so the sync event's size is bounded for life. + // Spent stubs keep old snapshots/mailbox messages from resurrecting coins. vtxos: (() => { // On the wire the bytes stay HEX for now: a device still on a build // that only reads hex would otherwise fail to decode every coin merged // from this one. Flip to base64 once builds before 2026-09-10 are gone. const live = (s.vtxos || []).filter((v) => v.state !== 'spent') .map((v) => (v.bytes ? { ...v, bytes: vtxoBytesToHex(v.bytes) } : v)); - const stubs = (s.vtxos || []).filter((v) => v.state === 'spent').slice(-200) + const stubs = (s.vtxos || []).filter((v) => v.state === 'spent') .map((v) => ({ id: v.id, amountSat: v.amountSat, state: 'spent', keyIndex: v.keyIndex, expiryHeight: v.expiryHeight })); return [...live, ...stubs]; })(), actions: (() => { const done = (s.actions || []).filter((a) => a.step === 'done'); const core = done.filter((a) => ['board', 'offboard', 'exit'].includes(a.type)) - .slice(-50) .map((a) => { const c = { ...a }; for (const k of HEAVY) delete c[k]; return c; }); // Spends travel as SLIVERS — id, net amount, and which coins they // consumed. That last field is what lets ANOTHER device recognize a @@ -179,14 +177,13 @@ export function slimArkForSync(s) { // another device" row beside the synced Sent movement (one spend was // showing twice, at input magnitude the second time). const spends = done.filter((a) => ['send', 'ln-pay'].includes(a.type)) - .slice(-100) .map((a) => ({ id: a.id, type: a.type, step: 'done', amountSat: a.amountSat, - inputIds: (a.parts || []).map((p) => p.inputId).concat(a.inputId ? [a.inputId] : []).filter(Boolean), + inputIds: [...new Set([...(a.inputIds || []), ...(a.parts || []).map((p) => p.inputId), a.inputId].filter(Boolean))], })); return [...core, ...spends]; })(), - movements: (s.movements || []).slice(-200).map((m, i, arr) => + movements: (s.movements || []).map((m, i, arr) => // Older rows only need to render a history line; the bolt11 (~400 chars // each) and preimage are what pushed snapshots past relay size limits. (arr.length - i <= 50 ? m : (({ invoice, preimage, ...rest }) => rest)(m))), @@ -285,7 +282,12 @@ export function mergeArkStates(a, b) { // skips every message this device never processed — balance appears (the // vtxo unions in) but its receive movement and celebration never happen. // Keep the local checkpoint; re-read messages dedupe harmlessly. - out.mailboxCheckpoint = a.mailboxCheckpoint || 0; + const pristine = !a.mailboxCheckpoint && !a.vtxos?.length && !a.movements?.length + && !a.actions?.length && !a.scheduled?.length; + // A manager's empty shell is not local history. Restore the snapshot's + // cursor with its coins/history, just as the !a case above does, rather + // than replaying the entire mailbox on a first login. + out.mailboxCheckpoint = pristine ? b.mailboxCheckpoint || 0 : a.mailboxCheckpoint || 0; out.nextKeyIndex = Math.max(a.nextKeyIndex || 1, b.nextKeyIndex || 1); out.nextLnRecvIndex = Math.max(a.nextLnRecvIndex || 0, b.nextLnRecvIndex || 0); out.receiveAckTs = Math.max(a.receiveAckTs || 0, b.receiveAckTs || 0); @@ -397,6 +399,7 @@ export function arkFeature(ctx) { for (const f of [...arkPersistFlushers]) { try { f(); } catch {} } arkPersistFlushers.clear(); arkInitGen++; + lastAutoInit = 0; if (arkTimer) clearInterval(arkTimer); arkTimer = null; if (ark) ark.stopMailboxStream(); @@ -430,7 +433,6 @@ export function arkFeature(ctx) { function initArk() { stopArk(); ui.arkError = ''; - lastAutoInit = Date.now(); // The ark store is IndexedDB — asynchronous to OPEN, synchronous to read // once it has. A boot that beats it open reads no state at all: no // Spending balance, no Spending history, and nothing here that looks @@ -441,7 +443,7 @@ export function arkFeature(ctx) { store.open().then(() => { ctx.forgetAmountAnim && ctx.forgetAmountAnim(); render(); initArk(); }).catch(() => {}); return; } - if (arkAvailable() && arkWanted()) connectArk().catch(() => {}); + if (arkAvailable() && arkWanted()) { lastAutoInit = Date.now(); connectArk().catch(() => {}); } } // Automatic re-inits (a synced snapshot arriving) must not restart a @@ -453,7 +455,9 @@ export function arkFeature(ctx) { function maybeInitArk() { if (ark || arkConnectPromise) return; if (Date.now() - lastAutoInit < 20000) return; - initArk(); + // A fresh snapshot may be held under its server pubkey, so arkWanted() + // cannot see it until the handshake identifies the server and adopts it. + if (arkAvailable()) { lastAutoInit = Date.now(); connectArk().catch(() => {}); } } // Operator console rescue for a round participation stranded server-side @@ -524,10 +528,13 @@ export function arkFeature(ctx) { const tx = (wallet.txs || []).find((t2) => t2.txid === txid2); return tx && tx.confirmed ? { confirmed: true, block_height: tx.blockHeight || NaN } : null; }, - onUpdate: (m) => { maybeAutoSelectSpending(); scheduleLnTag(m); render(); }, + onUpdate: (m) => { if (gen !== arkInitGen) return; maybeAutoSelectSpending(); scheduleLnTag(m); render(); }, }); ui.arkError = ''; - arkConnectPromise = mgr.init().then(() => { + // Open the server connection while Nostr restores, but do not replay the + // mailbox before the snapshot's history and cursor have arrived. + const restored = Promise.resolve(wallet.nostrRestoreReady || wallet.syncFromNostr?.()).catch(() => false); + arkConnectPromise = Promise.all([mgr.init(), restored]).then(() => { if (gen !== arkInitGen) throw new Error('superseded'); // wallet switched mid-connect ark = mgr; // Sync may have arrived while init awaited the server handshake. @@ -556,20 +563,24 @@ export function arkFeature(ctx) { // A failed mailbox read must not starve the action drives: boards and // refreshes progress on chain state alone, so push them even when the // sync itself errored. - const tick = () => mgr.sync().catch(() => mgr.resumePending().catch(() => {})) + let ticking = null; + const tick = () => ticking || (ticking = mgr.sync().catch(async () => { + // Even when mailbox catch-up fails, independently check stale coins + // and advance recoverable local actions. + await mgr.reconcile().catch(() => {}); + await mgr.resumePending().catch(() => {}); + }) .then(() => driveExits(mgr)).catch(() => {}) .then(() => { if (ark === mgr) return maybeAutoRefresh(mgr); }).catch(() => {}) - .then(() => { if (ark === mgr) return maybeAutoWithdraw(mgr); }).catch(() => {}); - tick(); - // Reconcile once on connect: a vtxo synced in from another device (or - // one this device held while a spend happened elsewhere) is checked - // against the server, so a stale spendable is caught here rather than - // only at send time. - mgr.reconcile().catch(() => {}); + .then(() => { if (ark === mgr) return maybeAutoWithdraw(mgr); }).catch(() => {}) + .finally(() => { ticking = null; })); + // sync() already reconciles on its first pass. Start the live stream + // afterwards, at the caught-up cursor: running both together replayed + // the backlog one message/render at a time alongside the batch read. + tick().finally(() => { if (ark === mgr) mgr.startMailboxStream(); }); mergeBgWorkerState(mgr).catch(() => {}); // absorb what the SW did while we were closed // Receives arrive in real time over the mailbox stream; the poll is the // fallback and what drives in-flight boards/refreshes forward. - mgr.startMailboxStream(); scheduleLnTag(mgr); // receives that landed while this device was closed arkTimer = setInterval(() => { if (ark === mgr) tick(); }, getNetwork() === 'regtest' ? 5000 : 30000); render(); @@ -577,7 +588,7 @@ export function arkFeature(ctx) { }).catch((e) => { if (gen === arkInitGen) { ui.arkError = e.message; render(); } throw e; - }).finally(() => { arkConnectPromise = null; }); + }).finally(() => { if (gen === arkInitGen) arkConnectPromise = null; }); render(); return arkConnectPromise; } @@ -1543,22 +1554,28 @@ export function arkFeature(ctx) { // way this goes, say so, so the chip settles instead of pulsing until it // times out. (A handover to the Lightning flow doesn't report — that flow // is still carrying the same zap, and reports for itself.) - const bail = () => { + const bail = (error) => { if (!z.autoSat) return; // a form zap: the card keeps the screen and its own status ctx.hook('zapSettled', z.eventId, false); - z.autoSat = 0; ctx.showSend(); + z.autoSat = 0; + if (z.eventId) { + if (ui.arkZap === z) ui.arkZap = null; + toast('⚡ ' + (error || t('arkZapNoArk')), 4000); + return true; + } else ctx.showSend(); }; // an instant zap: resolution succeeded — pay the default amount now, no - // form, and report by toast; failures fall back to the classic form + // form. Post zaps settle in place; other payments retain their form. const auto = async (label) => { try { await performArkZap(z, z.autoSat); if (ui.arkZap === z) ui.arkZap = null; ctx.hook('zapSettled', z.eventId, true, z.autoSat); - toast('⚡ ' + t('zapSentShort', { n: fmtAmount(z.autoSat) + ' ' + unitLabel() })); + if (!z.eventId) toast('⚡ ' + t('zapSentShort', { n: fmtAmount(z.autoSat) + ' ' + unitLabel() })); render(); } catch (e) { - bail(); ui.sendError = e.message; render(); + if (!bail(e.message)) ui.sendError = e.message; + render(); } }; (async () => { @@ -1567,7 +1584,7 @@ export function arkFeature(ctx) { const adv = await lookupArkZapTarget(pk).catch(() => ({ status: 'noark' })); if (!live()) return; if (adv.status === 'ready') { Object.assign(z, adv); if (z.autoSat) return auto(); render(); return; } - if (adv.status === 'wrongnet') { Object.assign(z, adv); bail(); render(); return; } + if (adv.status === 'wrongnet') { Object.assign(z, adv); bail(t('arkGiftWrongNet', { net: adv.net })); render(); return; } // 1b. BIP-353 with an ark instruction; remember on-chain as last resort const uris = []; try { @@ -1605,6 +1622,7 @@ export function arkFeature(ctx) { } // 3. on-chain fallback if (onchain) { + if (z.autoSat && z.eventId) { bail(); render(); return; } ui.arkZap = null; ui.send.recipients[0].address = onchain; bail(); @@ -1612,7 +1630,7 @@ export function arkFeature(ctx) { return; } if (live()) { z.status = 'noark'; bail(); render(); } - })().catch((e) => { if (live()) { z.status = 'noark'; bail(); ui.sendError = e.message; render(); } }); + })().catch((e) => { if (live()) { z.status = 'noark'; if (!bail(e.message)) ui.sendError = e.message; render(); } }); } // shared by the ark and lightning zap flows (see zaps.js for the twin) diff --git a/src/features/sync.js b/src/features/sync.js index 415717f..ed22322 100644 --- a/src/features/sync.js +++ b/src/features/sync.js @@ -247,8 +247,25 @@ export function installSyncWallet(wallet, { outbox = syncOutbox() } = {}) { }, }); + // Account activation, Spending connect and the live subscription all need + // the same initial restore. Share it so history is merged newest-first + // before either live source starts delivering an old backlog piecemeal. + const restore = wallet.syncFromNostr.bind(wallet); + let restoring = null; + wallet.syncFromNostr = () => { + const pk = wallet.nostr.pk, net = wallet.netName; + if (restoring && restoring.pk === pk && restoring.net === net) return restoring.promise; + const run = { pk, net, promise: null }; + run.promise = restore().finally(() => { if (restoring === run) restoring = null; }); + restoring = run; + wallet.nostrRestoreReady ||= run.promise; + return run.promise; + }; + // identity follows the open wallet wallet.registerLoadHook(() => { + restoring = null; + wallet.nostrRestoreReady = null; if (wallet.mnemonic) wallet.nostr.load(wallet.mnemonic, wallet.passphrase, wallet.accountIndex || 0); else wallet.nostr.unload(); outbox?.wake(); @@ -305,24 +322,32 @@ export function installSyncWallet(wallet, { outbox = syncOutbox() } = {}) { // vtxos union) applies live; full snapshots stay a load-time affair. With // per-device slots no device overwrites another's, so this only ever adds. let stateUnsub = null; + let stateGeneration = 0; wallet.registerRealtimeHook({ start: () => { const sync = getSyncConfig(); if (!sync.enabled || !wallet.nostr.pk) return; wallet.nostr.setRelays(sync.relays); if (stateUnsub) { try { stateUnsub(); } catch {} } - stateUnsub = wallet.nostr.subscribeStates((v, dtag) => { - if (!v.netName || v.netName !== wallet.netName) return; - if (!isOurDtag(dtag, wallet.netName)) return; - if (isOwnDeviceDtag(dtag, wallet.netName) && (v.savedAt || 0) === (wallet._savedAt || 0)) return; // our own echo - if (!v.blob) { wallet._mergeSnapshotExtensions(v); return; } - const pk = wallet.nostr.pk; - resolveStates([{ state: v, dtag }]).then((r) => { - if (r[0] && wallet.nostr.pk === pk && wallet.netName === r[0].state.netName) wallet._mergeSnapshotExtensions(r[0].state); - }).catch(() => {}); - }); + stateUnsub = null; + const generation = ++stateGeneration; + const identity = wallet.nostr.pk, network = wallet.netName; + Promise.resolve(wallet.nostrRestoreReady || wallet.syncFromNostr()).then(() => { + if (generation !== stateGeneration || wallet.nostr.pk !== identity || wallet.netName !== network) return; + stateUnsub = wallet.nostr.subscribeStates((v, dtag) => { + if (generation !== stateGeneration || wallet.nostr.pk !== identity) return; + if (!v.netName || v.netName !== wallet.netName) return; + if (!isOurDtag(dtag, wallet.netName)) return; + if (isOwnDeviceDtag(dtag, wallet.netName) && (v.savedAt || 0) === (wallet._savedAt || 0)) return; // our own echo + if (!v.blob) { wallet._mergeSnapshotExtensions(v); return; } + const pk = wallet.nostr.pk; + resolveStates([{ state: v, dtag }]).then((r) => { + if (generation === stateGeneration && r[0] && wallet.nostr.pk === pk && wallet.netName === r[0].state.netName) wallet._mergeSnapshotExtensions(r[0].state); + }).catch(() => {}); + }); + }).catch(() => {}); }, - stop: () => { if (stateUnsub) { try { stateUnsub(); } catch {} stateUnsub = null; } }, + stop: () => { stateGeneration++; if (stateUnsub) { try { stateUnsub(); } catch {} stateUnsub = null; } }, }); // Capture and sign changed domains NOW, under the saving wallet's identity. diff --git a/src/recipient-search.js b/src/recipient-search.js index be3c70e..e0339ca 100644 --- a/src/recipient-search.js +++ b/src/recipient-search.js @@ -16,23 +16,28 @@ import { queryOn, parseNostrPubkey, npubOf, PROFILE_RELAYS } from './nostr.js'; // Self-hosted (dist/punks) — coinos.io's own copies 502 for a third of the // set. Same deterministic formula the coinos app uses. const punkN = (pk) => Math.floor((parseInt(pk.slice(-2), 16) / 256) * 64) + 1; -export const punkUrl = (pk) => `punks/${punkN(pk)}.webp`; +export const punkImageUrl = (n, small = false) => + `${typeof location !== 'undefined' && location.protocol === 'file:' ? '' : '/'}punks${small ? '-sm' : ''}/${n}.webp`; +export const punkUrl = (pk) => punkImageUrl(punkN(pk)); // The art is 240px, which is what a punk published as someone's nostr // picture should be — and eight times what a 30px circle needs. We ship a // 128px copy of each (a fifth of the bytes, 20KB → 4KB) and paint the circles // from that, so a brand-new wallet with nothing cached fills its faces in // straight away. Only the 64px profile avatar takes the full-size one. -export const punkSmallUrl = (pk) => `punks-sm/${punkN(pk)}.webp`; -export const punkSmallN = (n) => `punks-sm/${n}.webp`; +export const punkSmallUrl = (pk) => punkImageUrl(punkN(pk), true); +export const punkSmallN = (n) => punkImageUrl(n, true); -export const fallbackAvatar = (h, pk, name, cls) => - h('div', { class: cls + ' fallback' }, - h('img', { +const failedPunks = new Map(); +export const fallbackAvatar = (h, pk, name, cls) => { + const src = cls.includes('profile-avatar') ? punkUrl(pk) : punkSmallUrl(pk); + return h('div', { class: cls + ' fallback' }, + Date.now() - (failedPunks.get(src) || 0) < 60_000 ? null : h('img', { class: 'punk', alt: '', - src: cls.includes('profile-avatar') ? punkUrl(pk) : punkSmallUrl(pk), - onError: (e) => { e.target.style.display = 'none'; }, + src, + onError: (e) => { failedPunks.set(src, Date.now()); e.target.remove(); }, }), (name || npubOf(pk) || '??').slice(0, 2)); +}; const REGISTRAR = 'https://names.coinos.io'; const SEARCH_RELAYS = ['wss://search.nos.today', 'wss://relay.nostr.band']; @@ -103,7 +108,8 @@ export function warmSearch() { // 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'; +// Discard old results that identified migrated accounts by their wallet key. +const QUERY_KEY = 'btc-wallet-search-queries-v2'; const QUERY_TTL = 12 * 3600_000; const profileCache = new Map(); // pk -> { name, picture } const queryCache = new Map(); // q -> rows |
