aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/app.js43
-rw-r--r--src/features/ark.js86
-rw-r--r--src/features/sync.js47
-rw-r--r--src/recipient-search.js24
4 files changed, 137 insertions, 63 deletions
diff --git a/src/app.js b/src/app.js
index 0e5ed9c..16f0595 100644
--- a/src/app.js
+++ b/src/app.js
@@ -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