// Bitcoin Wallet — UI controller (vanilla DOM, no framework). // // State lives in `ui` + the singleton `wallet`. Mutating handlers call render(), // which rebuilds the active screen. Text inputs write back into `ui` on `input` // (without re-rendering) so their values survive structural re-renders. import { Wallet, newMnemonic, isValidMnemonic, accountXpubFor, cacheKeyFor, utxoId, parseExtendedKey, xpubToZpub, encryptVault, decryptVault } from './wallet.js'; import { qrSvg } from './qr.js'; import { makeSearcher, resultRows, searchable, punkUrl, warmSearch } from './recipient-search.js'; import { npubOf, seedPubkey } from './nostr.js'; import { nip98Header } from './nip98.js'; import { NOSTR_MARK } from './features/nostrlogin.js'; import { scanQr } from './scan.js'; import { dataSources, getSource, setSource, getNetwork, setNetwork, NETWORKS } from './api.js'; import { STAGING } from './build-flags.js'; import { buildFeatures, loadDeferredFeatures } from './features/index.js'; import { t, LANGS, getLang, setLang, isRTL, loadLocale } from './i18n.js'; import { rateNow, rateAt, refreshRates, haveRate, getCurrency, setCurrency, currencies, fmtFiat, fmtFiatBare } from './rates.js'; import { fmtBtc, fmtSats, parseAmount, shortAddr, shortTxid, timeAgo, SATS, BITCOIN_ICON, } from './format.js'; const wallet = new Wallet(); // ---- optional features ------------------------------------------------ // Swaps and Ark plug into fixed seams (receive modes, send // matchers, history entries, balance lines, settings cards, lifecycle hooks). // FEATURES/ctx are assembled near the bottom of this file, once every helper // they capture exists; the hooks below are only ever called at runtime. const featureHook = (name, ...args) => { for (const f of FEATURES) { if (!f[name]) continue; const v = f[name](...args); if (v) return v; } return null; }; const featureAll = (name, ...args) => FEATURES.flatMap((f) => (f[name] ? f[name](...args) : [])); // `typed` distinguishes keystroke input from paste/scan: some matchers (a // lightning address) match mid-typing and must not yank the form away. const featureMatchSend = (text, typed) => FEATURES.some((f) => f.matchSendText && f.matchSendText(text, typed)); const ui = { screen: 'unlock', // 'unlock' | 'wallet' | 'claim' | 'howItWorks' claimStep: null, // 'welcome' | 'backup' when opening a gift link claimChecking: false, // verifying the gift's funding coin is still unspent claimTaken: null, // { txid } if the gift was already claimed (coin spent) returnScreen: 'unlock', // where 'howItWorks' returns to (Back / logo) unlockTab: 'create', // 'create' | 'import' | 'watch' watchXpub: '', // watch-only xpub/zpub input watchLabel: '', // watch-only account label input fromWallet: false, // unlock screen reached as "add wallet" (show a back button) pw: null, // { purpose, accId, mode, v1, v2, error } — vault password prompt vaultPw: '', // on-open vault unlock input vaultError: '', confirmClear: false, // "Clear all" confirmation shown editId: null, // account being renamed editLabel: '', createStep: 'gen', // 'gen' | 'confirm' draftMnemonic: '', confirm: [], // [{ index, value }] confirmPass: '', // re-entered passphrase (confirm field, shown once one is typed) importText: '', passphrase: '', showPass: false, revealShown: false, // recovery phrase: false | 'masked' (grid, dots) | 'words' pubkeyShown: false, // account public key revealed in Settings giftMode: false, // gift sub-view active on the Send page giftAmount: '', // gift-create amount input viewGift: null, // re-viewing a previously created gift's link/QR { code, locked, amount, claimCode } claimChoose: null, // opening a gift with existing wallets present: { code } — pick a target giftCode: null, // last-created gift PSBT code giftError: '', giftMax: false, // gift the whole spendable balance (no-change sweep) giftSplitOffer: null, // { amt, lock, freed, fee } when offering to split a coin first revokeId: null, // outpoint of a gift being revoked (confirm state) claimCode: null, // gift code being claimed (opened from a #gift= link) claimLocked: null, // npub-locked gift blob opened from a /lg/ link (public fields) claimCodeInput: '', // one-time claim code typed on the locked-gift screen claimedAmount: 0, claimError: '', offlineFallback: false, // auto-entered offline because the network was unreachable unlockError: '', tab: 'receive', // receive | send | history | settings receiveSeenIndex: null, // fresh receive index the user has acknowledged txDetail: null, // txid being viewed in the history detail view txPage: 0, // History: current page of transactions (10 per page) addrScan: false, // Settings: showing the per-address rescan list addrScanPage: 0, // Settings: current page within the address list rescanning: new Set(), // 'chain/index' ids queued/in-flight for rescan send: blankSend(), draft: null, // built tx summary awaiting review broadcastTx: null, // scanned signed tx awaiting broadcast confirmation bump: null, // RBF bump in progress: { prep, feeChoice, customFee } sendError: '', sendResult: null, // { txid } | { signedHex, txid } busy: false, }; function blankSend() { return { recipients: [{ address: '', amount: '' }], unit: 'btc', max: false, feeChoice: 'halfHourFee', customFee: '', manual: false, coins: new Set(), }; } // ---------------------------------------------------------------- DOM helper function h(tag, attrs = {}, ...children) { const e = document.createElement(tag); for (const [k, v] of Object.entries(attrs || {})) { if (v == null || v === false) continue; if (k === 'class') e.className = v; else if (k === 'html') { e.innerHTML = v; e._html = v; } else if (k === 'value') e.value = v; else if (k === 'checked' || k === 'disabled' || k === 'selected') e[k] = !!v; else if (k.startsWith('on') && typeof v === 'function') { // assigned as an on* PROPERTY (not addEventListener) so the morph can // transplant handlers between renders: assignment replaces, and _evs // records which slots this render claimed const n = 'on' + k.slice(2).toLowerCase(); e[n] = v; (e._evs || (e._evs = new Set())).add(n); } else e.setAttribute(k, v); } for (const c of children.flat()) { if (c == null || c === false || c === true) continue; e.append(c.nodeType ? c : document.createTextNode(String(c))); } return e; } // ---------------------------------------------------------------- morphing // render() builds a fresh tree exactly as before, but instead of replacing // the document we PATCH the live DOM into its shape. Nodes keep their // identity across renders, so :hover, focus, text selection, CSS transitions // and scroll positions survive every background repaint — the strobing that // full replacement caused (and that focus/scroll bookkeeping only papered // over) is gone at the root. Escape hatch: a node carrying data-fresh is // swapped wholesale whenever the new render produced a different one (used // where a detached node runs its own animation frames). function morph(a, b) { if (a === b) return; // A subtree frozen for a gesture (the balance carousel mid-drag/mid-glide) // is left exactly as it stands: patching it would strip the drag's inline // snap override and yank the scroll. endCarouselDrag unfreezes and runs a // syncing render once motion stops. if (a.nodeType === 1 && a._skipMorph) return; if (a.nodeType === 3 && b.nodeType === 3) { if (a.data !== b.data) a.data = b.data; return; } if (a.nodeType !== 1 || b.nodeType !== 1 || a.nodeName !== b.nodeName || b.hasAttribute?.('data-fresh')) { a.replaceWith(b); return; } // attributes (className and style strings ride along as attributes) // A reused BUTTON whose class changes is a different control wearing the // same node (screens lining up positionally) — its `transition: background` // would visibly crossfade the old style into the new one (a dark filled // button melting into a text link read as a black flash). Repaints aren't // style animations: snap it, restore the transition after the paint. // A node with a data-key is only ever patched into a node with the SAME // key: siblings line up by position, so a conditional sibling appearing or // vanishing would otherwise hand one section's live state (an expanded //
) to its neighbour. if (a.getAttribute('data-key') !== b.getAttribute('data-key')) { a.replaceWith(b); return; } const snap = a.nodeName === 'BUTTON' && a.getAttribute('class') !== b.getAttribute('class'); //
is the user's doing (the browser toggles the attribute on // click), like a field's value: a render that doesn't set `open` leaves it // alone instead of collapsing the section on every background repaint. const userOwned = (at) => a.nodeName === 'DETAILS' && at.name === 'open'; for (const at of [...a.attributes]) if (!b.hasAttribute(at.name) && !userOwned(at)) a.removeAttribute(at.name); for (const at of [...b.attributes]) if (a.getAttribute(at.name) !== at.value) a.setAttribute(at.name, at.value); if (snap) { a.style.transition = 'none'; requestAnimationFrame(() => requestAnimationFrame(() => { a.style.transition = ''; })); } // event handler slots claimed via h() if (a._evs) for (const n of a._evs) if (!(b._evs && b._evs.has(n))) a[n] = null; if (b._evs) for (const n of b._evs) a[n] = b[n]; a._evs = b._evs; // live form state: value/checked are properties, not attributes — but never // fight the user over a field they're currently editing const tag = a.nodeName; if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') { if (document.activeElement !== a) { if (a.value !== b.value) a.value = b.value; if (a.checked !== b.checked) a.checked = b.checked; } if (a.disabled !== b.disabled) a.disabled = b.disabled; } // innerHTML-authored subtrees (svg icons, QRs): compare source, not nodes if (b._html != null) { if (a._html !== b._html) a.innerHTML = b._html; a._html = b._html; return; } if (a._html != null) { // the live node was innerHTML-authored but this render built children — // clear and fall through so the child loop rebuilds it (returning here // once left a form's field permanently empty) a.textContent = ''; a._html = undefined; } // children, by position; snapshot first (appending b's children moves them) const ac = [...a.childNodes]; const bc = [...b.childNodes]; if (allKeyed(bc)) return morphKeyed(a, ac, bc); for (let i = 0; i < bc.length; i++) { if (!ac[i]) a.append(bc[i]); else if (ac[i] !== bc[i]) morph(ac[i], bc[i]); } for (let i = bc.length; i < ac.length; i++) ac[i].remove(); } function morphChildren(parent, next) { const cur = [...parent.childNodes]; if (allKeyed(next)) return morphKeyed(parent, cur, next); for (let i = 0; i < next.length; i++) { if (!cur[i]) parent.append(next[i]); else if (cur[i] !== next[i]) morph(cur[i], next[i]); } for (let i = next.length; i < cur.length; i++) cur[i].remove(); } // A list whose every child carries a data-key is reconciled BY KEY, not by // position: a post let in at the top of the feed used to be patched onto // the old first row's node, and every row below it onto its neighbour's — // so the node the new post was built as never reached the page (nothing // that animates its own arrival could run), and each row's live state slid // one post down. Keyed, the new row is inserted as itself and the old rows // keep their nodes, moved down. Partially keyed lists keep the positional // rule above, where a keyed node still refuses to patch into another key. const allKeyed = (nodes) => nodes.length > 0 && nodes.every((n) => n.nodeType === 1 && n.hasAttribute('data-key')); function morphKeyed(parent, cur, next) { const byKey = new Map(); for (const n of cur) if (n.nodeType === 1 && n.hasAttribute('data-key')) byKey.set(n.getAttribute('data-key'), n); const kept = new Set(); for (let i = 0; i < next.length; i++) { const b = next[i]; const have = byKey.get(b.getAttribute('data-key')); let node = b; if (have && !kept.has(have)) { morph(have, b); // may itself swap the node out (data-fresh, tag change) node = have.parentNode === parent ? have : b; } kept.add(node); const at = parent.childNodes[i] || null; if (at !== node) parent.insertBefore(node, at); } for (const n of cur) if (!kept.has(n) && n.parentNode === parent) n.remove(); } const root = document.getElementById('app'); function footer() { return h( 'div', { class: 'footer small muted center' }, h( 'div', {}, t('footerMadeBy') + ' ', h('a', { href: 'https://adamsoltys.com', target: '_blank', rel: 'noopener' }, 'Adam Soltys'), h('span', { class: 'faint' }, ' · '), t('footerSourceOn') + ' ', h('a', { href: 'https://github.com/coinos/coinosv3', target: '_blank', rel: 'noopener' }, 'GitHub') ), h( 'div', { style: 'margin-top:4px' }, h('button', { class: 'linklike', style: 'font-weight:400', onClick: openHowItWorks }, t('howItWorks')), // Chrome no longer prompts to install on its own — surface our own link // once it reports the app is installable. We render from a persisted flag // (not the live event) so the link is present on the first paint after a // refresh, avoiding a layout shift when beforeinstallprompt fires late. installable() ? h('span', {}, h('span', { class: 'faint' }, ' · '), h('button', { class: 'linklike', style: 'font-weight:400', onClick: triggerInstall }, t('installApp'))) : null, h('span', { class: 'faint' }, ' · '), h('button', { class: 'linklike', style: 'font-weight:400', onClick: toggleTheme }, resolvedTheme() === 'dark' ? t('lightMode') : t('darkMode')) ), // Display preferences live together: the language this reads in, and the // money amounts are shown in. (The currency sat under Settings → Network // for exactly one day, where nobody found it.) h('div', { class: 'row gap6', style: 'margin-top:8px;justify-content:center' }, languagePicker(), currencyPicker()), // The commit stamp settles "is my PWA fresh?" AND "what code is this?" in // one glance — its own last line, so it never wraps the credits on mobile. h('div', { class: 'faint', style: 'margin-top:6px' }, 'Running git commit ', h('a', { class: 'faint', href: 'https://github.com/coinos/coinosv3/commit/{{COMMIT}}', target: '_blank', rel: 'noopener' }, '{{COMMIT}}')) ); } const THEME_KEY = 'btc-wallet-theme'; function resolvedTheme() { try { const s = localStorage.getItem(THEME_KEY); if (s === 'dark' || s === 'light') return s; } catch {} try { return matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; } catch {} return 'light'; } function applyTheme() { try { document.documentElement.dataset.theme = resolvedTheme(); } catch {} } function toggleTheme() { try { localStorage.setItem(THEME_KEY, resolvedTheme() === 'dark' ? 'light' : 'dark'); } catch {} applyTheme(); render(); } // PWA install. Chrome fires beforeinstallprompt when the app qualifies; we stash // the event and reveal an "Install app" link, then replay it on a user tap (the // browser requires a gesture). The link's visibility is driven by a persisted // flag rather than the live event so it's present on the first paint after a // refresh (no layout shift); the event only supplies the prompt to replay. // We deliberately do NOT call e.preventDefault(): modern Chrome shows no banner // of its own to suppress, and preventDefault-without-prompt() logs a console // warning. The event stays usable for our own e.prompt() on tap. const INSTALLABLE_KEY = 'btc-wallet-installable'; let installPrompt = null; function isStandalone() { try { return matchMedia('(display-mode: standalone)').matches || navigator.standalone === true; } catch { return false; } } function installable() { if (isStandalone()) return false; try { return localStorage.getItem(INSTALLABLE_KEY) === '1'; } catch { return false; } } function setInstallable(v) { try { if (v) localStorage.setItem(INSTALLABLE_KEY, '1'); else localStorage.removeItem(INSTALLABLE_KEY); } catch {} } if (typeof window !== 'undefined') { window.addEventListener('beforeinstallprompt', (e) => { installPrompt = e; // Only re-render if this changes what's on screen; on a refresh the link is // already shown from the persisted flag, so nothing moves. const wasShown = installable(); setInstallable(true); if (!wasShown) render(); }); window.addEventListener('appinstalled', () => { installPrompt = null; setInstallable(false); render(); }); } async function triggerInstall() { const e = installPrompt; // The prompt event may not have fired yet this load even though the link is // shown from the persisted flag; bail quietly if so. if (!e) return; installPrompt = null; e.prompt(); try { await e.userChoice; } catch {} } // Open the How it works page, remembering where to return to. function openHowItWorks() { if (ui.screen === 'howItWorks') return; ui.returnScreen = ui.screen; ui.screen = 'howItWorks'; render(); } // Per-tab session nav (tab + open tx), so a refresh keeps the user's place. const NAV_KEY = 'btc-wallet-nav'; // Index path from #app down to a node, and back — used to re-find the focused // field after a full rebuild (a background re-render produces the same structure). function focusPath(el) { const path = []; for (let n = el; n && n !== root; n = n.parentNode) { const p = n.parentNode; if (!p) return null; path.unshift(Array.prototype.indexOf.call(p.children, n)); } return path; } function nodeAtPath(path) { let n = root; for (const i of path) n = n && n.children[i]; return n || null; } // ---- one-shot animation gating ------------------------------------------- // The renderer rebuilds the whole DOM on every render, so a CSS mount // animation would replay on every background repaint (balance ticks, chat // bursts). These helpers answer "did this change since the last render?" — // things animate only in the render where they actually happened. const _uiSeen = new Map(); function uiChanged(key, val) { const prev = _uiSeen.get(key); _uiSeen.set(key, val); return prev !== val; } // A background render can land mid-animation and rebuild the node without // its one-shot class, killing the motion. animWindow answers "how far into // the animation started by this change are we?" — callers re-apply the class // with a negative animation-delay so a rebuild RESUMES the animation. let _navAt = -1e9; let _swipeDir = null; // set by the swipe handler just before render // tab switches carousel just the content pane, not the whole page let _tabAt = -1e9; let _tabCls = 'anim-tab-left'; let _tabSeen = false; // a tab has rendered before (slides are for CHANGES, not arrivals) let _prevTabIdx = 0; const _animAt = new Map(); function animWindow(key, val, durMs) { if (uiChanged(key, val)) _animAt.set(key, performance.now()); const dt = performance.now() - (_animAt.get(key) ?? -1e9); return dt < durMs ? dt : -1; } function applyAnim(el, cls, dt) { if (dt < 0 || !el || !el.classList) return; el.classList.add(cls); el.style.animationDelay = -Math.round(dt) + 'ms'; } // A balance that counts to its new value instead of teleporting, with a green // breath when money arrived. Keyed so background renders don't re-animate. const _amtLast = new Map(); function animatedAmount(key, sat) { const prev = _amtLast.get(key); _amtLast.set(key, sat); // data-fresh: this node animates itself with its own rAF loop after render, // so the morph must install THIS node, not patch text onto last render's const el = h('span', { 'data-fresh': '1' }, fmtAmount(sat)); if (prev !== undefined && prev !== sat) { const from = prev, to = sat, t0 = performance.now(), dur = 550; const step = (t) => { if (!el.isConnected) return; // a newer render owns the number now const p = Math.min(1, (t - t0) / dur); const e = 1 - Math.pow(1 - p, 3); el.textContent = fmtAmount(Math.round(from + (to - from) * e)); if (p < 1) requestAnimationFrame(step); }; requestAnimationFrame(step); if (to > from) el.classList.add('amt-flash'); } return el; } let _carDragging = false; // a balance-carousel drag is in progress let _renderDeferred = false; // /chat runs the standalone public community view: it owns #app outright, // and every app render (feature init, emitter ticks) must stand down or the // onboarding screen paints over the chat moments after it mounts. let _publicChatMode = false; // True until boot has decided whether a session is restoring. Anything that // renders before then (a deferred feature chunk landing, an emitter tick) is // rendering without knowing whether anyone is signed in — and the signed-out // welcome screen is the one screen that must never be guessed at. The boot // shell holds the frame for those few ms instead; see the dispatch below. let _bootDeciding = true; // Belt and braces: a boot path that returns early (or throws) must not leave // the welcome screen held back forever. Nothing legitimate is still deciding // four seconds in — the store wait alone caps at 800ms. setTimeout(() => { if (_bootDeciding) { _bootDeciding = false; render(); } }, 4000); // A picture, full screen. Posts show images cropped to a few hundred pixels // so a feed stays a feed; this is where you actually look at one. Tap // anywhere (or Escape, or Back) to close — no chrome, nothing to aim at. function imageViewer() { if (!ui.lightbox) return null; const close = () => { ui.lightbox = null; render(); }; return h('div', { class: 'lightbox', onClick: close, role: 'dialog', 'aria-modal': 'true', }, h('img', { src: ui.lightbox, alt: '', onClick: (e) => e.stopPropagation() }), h('button', { class: 'lightbox-x', 'aria-label': t('close'), onClick: close }, '\u00d7')); } if (typeof window !== 'undefined') { window.addEventListener('keydown', (e) => { if (e.key === 'Escape' && ui.lightbox) { ui.lightbox = null; render(); } }); } function render() { if (_publicChatMode) return; // A background render mid carousel-drag rebuilds the strip; the morph then // strips the drag's inline scroll-snap-type:none, so mandatory snap yanks // the card back to its origin — the "jump partway through" stutter (a // position jump, not a dropped frame). Hold renders while the FINGER is // down (pointermove writes scrollLeft on the main thread — a render there // eats frames); after release the strip is morph-frozen (_skipMorph) // instead, so the glide runs undisturbed while the history below is // already switching to the chosen account. if (_carDragging) { _renderDeferred = true; return; } // A render that throws must never brick the page silently: stale DOM keeps // its handlers, every one of them calls render again, and every tap turns // into a no-op. Show the error and offer the way out instead. try { renderInner(); } catch (err) { renderCrash(err); } } function endCarouselDrag() { _carDragging = false; // the strip was frozen against the morph for the glide — unfreeze it and // make sure one render runs so it catches up with any state committed early const strip = document.querySelector('.bal-carousel'); if (strip && strip._skipMorph) { strip._skipMorph = false; _renderDeferred = true; } if (_renderDeferred) { _renderDeferred = false; render(); } } function renderCrash(err) { console.error('render failed:', err); try { const div = document.createElement('div'); div.className = 'card col'; div.style.cssText = 'margin:20px;gap:10px'; const h3 = document.createElement('h3'); h3.textContent = 'Something broke'; const pre = document.createElement('div'); pre.className = 'small muted'; pre.style.cssText = 'word-break:break-word;white-space:pre-wrap'; pre.textContent = String((err && (err.stack || err.message)) || err).slice(0, 600); const btn = document.createElement('button'); btn.className = 'btn-primary btn-block'; btn.textContent = 'Reload'; btn.onclick = () => location.reload(); div.append(h3, pre, btn); root.replaceChildren(div); } catch {} } function renderInner() { // A direct render subsumes any coalesced one queued by a background emit, so // drop the pending one (a user action must not trigger a second rebuild). if (_renderRaf) { clearTimeout(_renderRaf); _renderRaf = 0; } _lastRender = Date.now(); // Preserve focus + caret across the rebuild, so a background update (poll, a // payment push, an SP scan) can't kick the user out of a field they're editing. const a = document.activeElement; let fpath = null, selStart = null, selEnd = null; if (a && root.contains(a) && /^(INPUT|SELECT|TEXTAREA)$/.test(a.tagName)) { fpath = focusPath(a); try { selStart = a.selectionStart; selEnd = a.selectionEnd; } catch {} } // Same idea for scrollable strips (the punks carousel): a rebuild must not // yank a half-scrolled list back to its start. Anything marked with // data-keep-scroll gets its position carried across the swap. const scrolls = {}; for (const el of root.querySelectorAll('[data-keep-scroll]')) if (el.scrollLeft || el.scrollTop) scrolls[el.getAttribute('data-keep-scroll')] = { l: el.scrollLeft, t: el.scrollTop }; // The padlock's "protect this device?" ask is modal: it belongs over // whatever page the tap happened on. A feature screen (your profile, a chat // thread) used to swallow it — walletScreen paints the card, and // screenView outranks walletScreen, so the padlock did nothing visible // until you navigated back to the wallet and the card finally appeared // there. Declining still lands back exactly where the tap happened, // because nothing else moves. Onboarding keeps its precedence. const lockAsk = ui.pw && ui.pw.purpose === 'lock' && !(ui.onb || onbInProgress()); const screen = (lockAsk && h('div', { class: 'col', style: 'gap:16px' }, brandHeader(false), pwPromptCard())) || featureHook('screenView') || (ui.screen === 'wallet' ? walletScreen() : ui.screen === 'accounts' ? accountsScreen() : ui.screen === 'accountSettings' ? accountSettingsScreen() : ui.screen === 'vault' ? vaultScreen() : ui.screen === 'howItWorks' ? howItWorksScreen() : _bootDeciding // Not "nobody is signed in" — "we don't know yet". Painting // Get started here is what flashed the sign-in page over a // wallet that was about to restore. Every other screen is a // deliberate choice and still paints; this one waits. ? h('div', { class: 'col', style: 'gap:16px' }, brandHeader(false)) : shouldOnboard() ? onboardScreen() : unlockScreen()); // Navigation animates; background repaints must not. The key is every // ui field that decides which page is on screen. const navKey = [ui.screen, ui.tab === 'settings', ui.chatOpen, ui.msgView, ui.msgPeer, ui.msgCommunity, ui.profilePk, ui.settingsPage, ui.addrScan, ui.arkExitPage, ui.txDetail, ui.giftMode, ui.claimStep, ui.nameEditOpen, ui.noteThread && ui.noteThread.focusId, !!ui.userSearch, !!ui.zapSetup, !!ui.hatShop].join('|'); // A deep-linked profile landing is ONE page settling, not a chain of // navigations: boot screens shuffle beneath the shell (unlock → restored // wallet), then the resolved profile replaces the shell in place. The key // still updates, but nothing slides or re-animates until the landing ends. if (uiChanged('nav', navKey)) { // ui.navAnimSkip: a one-shot "this navigation must not animate" — set by // sign-in right before the wallet screen takes over, so the handover is // an instant swap instead of a fade over a page that's still settling. if (!(ui.pubProfPending || ui.pubProfHold || ui.navAnimSkip)) _navAt = performance.now(); ui.navAnimSkip = false; } applyAnim(screen, 'anim-page', (performance.now() - _navAt) < 340 ? performance.now() - _navAt : -1); morphChildren(root, [screen, footer(), ...(ui.lightbox ? [imageViewer()] : [])]); try { document.documentElement.classList.toggle('no-scroll', !!ui.lightbox); } catch {} if (fpath) { const el = nodeAtPath(fpath); if (el && el !== a && /^(INPUT|SELECT|TEXTAREA)$/.test(el.tagName)) { try { el.focus({ preventScroll: true }); if (selStart != null && el.setSelectionRange) el.setSelectionRange(selStart, selEnd); } catch {} } } for (const el of root.querySelectorAll('[data-keep-scroll]')) { const s = scrolls[el.getAttribute('data-keep-scroll')]; if (s) { el.scrollLeft = s.l; el.scrollTop = s.t; } } // Fast-poll the receive address only while the user is actually watching for a // payment (wallet screen, Receive tab, online). Idempotent — safe each render. wallet.setWatchReceive(ui.screen === 'wallet' && ui.tab === 'receive' && !wallet.offline); if (ui.screen === 'wallet') { commitAccount(); // entering the wallet (any path) keeps a provisional gift account // Remember where we are so a refresh restores it (only meaningful on the wallet). try { sessionStorage.setItem(NAV_KEY, JSON.stringify({ tab: ui.tab, txDetail: ui.txDetail })); } catch {} } syncHistory(); // mirror the current screen into browser history (Back/Forward) } // Background state changes (a wake scan, cross-device sync merges, WS payment // pushes) can fire a burst of emits — especially when the PWA is refocused // after being backgrounded. Rendering synchronously on each one rebuilds the // whole screen dozens of times in a row and starves user input, so the app // feels frozen for a second or two. Coalesce those into at most one render per // 200ms: one-per-frame still meant a visible strobe during boot bursts (every // rebuild swaps the node under the cursor, dropping its :hover until the next // mouse move — buttons flashed under a resting pointer). Direct user actions // still call render() synchronously and always take priority; render() itself // stamps the throttle, so a user render pushes the next background one out. let _renderRaf = 0; let _lastRender = 0; const BG_RENDER_MIN_MS = 200; function scheduleRender() { if (_renderRaf) return; const wait = Math.max(0, BG_RENDER_MIN_MS - (Date.now() - _lastRender)); _renderRaf = setTimeout(() => { _renderRaf = 0; render(); }, wait); } wallet.subscribe(scheduleRender); // ---- browser-history navigation ------------------------------------------ // Mirror the app's screen position into the browser history so the Back/Forward // buttons (and Android/system back) move between screens we've actually viewed. // We snapshot only the navigation-relevant `ui` fields, so incidental re-renders // (typing, polling, balance updates) don't create history entries. // arkMoveDetail/arkExitDetail/giftDetail are the ark and gift rows' detail // views — most Spending payments open one of these, and leaving them out // meant the phone's native Back skipped past the history list entirely // (it landed on whatever tab minted the previous entry). const NAV_FIELDS = ['screen', 'tab', 'txDetail', 'arkMoveDetail', 'arkReconDetail', 'arkExitDetail', 'giftDetail', 'bump', 'giftMode', 'claimStep', 'chatOpen', 'msgView', 'msgCommunity', 'msgPeer', 'profilePk', 'profEdit', 'profEditFilled', 'profOverThread', 'settingsPage', 'nameEditOpen', 'noteThread', 'userSearch', 'zapSetup', 'hatShop']; function navSnapshot() { const s = {}; for (const f of NAV_FIELDS) s[f] = ui[f] ?? null; // Screens with volatile sub-state (a reply draft, typed amounts, search // results streaming in) snapshot only what IDENTIFIES the screen — a // keystroke must never mint a history entry. if (ui.noteThread) s.noteThread = { rootId: ui.noteThread.rootId, focusId: ui.noteThread.focusId, seed: ui.noteThread.seed }; if (ui.userSearch) s.userSearch = { q: '', rows: null }; if (ui.zapSetup) s.zapSetup = { pk: ui.zapSetup.pk, npub: ui.zapSetup.npub, eventId: ui.zapSetup.eventId, amount: '21' }; // Keep the editable fields in this history entry so a refresh restores the // draft. Copy them: later keystrokes must not mutate earlier snapshots. s.profEdit = ui.profilePk && ui.profEdit ? Object.fromEntries( ['uname', 'name', 'about', 'picture', 'banner'].map(k => [k, ui.profEdit[k] || ''])) : null; if (s.profEdit) s.profEdit.touched = { ...ui.profEdit.touched }; s.profEditFilled = !!s.profEdit && !!ui.profEditFilled; return s; } const navSig = ({ profEdit, profEditFilled, ...s }) => JSON.stringify({ ...s, profEdit: !!profEdit }); let navStack = []; // in-memory mirror of the history entries (to detect an in-app Back) let navIndex = -1; let restoringHistory = false; // true while applying a popstate (suppresses pushing) function syncHistory() { if (restoringHistory) return; try { const snap = navSnapshot(); const sig = navSig(snap); if (navIndex >= 0 && sig === navSig(navStack[navIndex])) { // Typing changes the draft, not the page. Replace the current entry // instead of making Back walk through every version of the form. if (JSON.stringify(snap) !== JSON.stringify(navStack[navIndex])) { history.replaceState({ nav: snap, i: navIndex }, ''); navStack[navIndex] = snap; } return; } // Every screen change is a new history entry. (We deliberately don't try to // detect in-app "back" navigations — an A→B→A pattern is indistinguishable // from a genuine back, so guessing corrupts the stack. An in-app back just // adds an entry; Back/Forward still walk the screens correctly.) navStack = navStack.slice(0, navIndex + 1); // drop any forward entries navStack.push(snap); navIndex++; const entry = { nav: snap, i: navIndex }; if (navIndex === 0) history.replaceState(entry, ''); else history.pushState(entry, ''); } catch {} // history API failures must never break a render } // A refresh must land where the user was — on a profile, a settings page, a // tab — not back home. history.state survives reload, but any boot-time // render runs syncHistory and replaceStates a blank snapshot over it — so // the pre-reload nav is captured HERE, at script load, before any render. const BOOT_NAV = (() => { try { return (history.state && history.state.nav) || null; } catch { return null; } })(); function restoreNavFromHistory() { try { const nav = BOOT_NAV; if (!nav || ui.screen !== 'wallet') return; for (const f of NAV_FIELDS) if (f in nav) ui[f] = f === 'profEdit' && nav[f] ? structuredClone(nav[f]) : nav[f]; navStack = [nav]; navIndex = 0; history.replaceState({ nav, i: 0 }, ''); // undo any boot-render clobber render(); } catch {} } window.addEventListener('popstate', (e) => { const st = e.state; const snap = (st && st.nav) || navSnapshot(); restoringHistory = true; try { for (const f of NAV_FIELDS) ui[f] = f === 'profEdit' && snap[f] ? structuredClone(snap[f]) : f in snap ? snap[f] : null; if (st && typeof st.i === 'number') navIndex = st.i; else { const i = navStack.findIndex((s) => navSig(s) === navSig(snap)); if (i >= 0) navIndex = i; } render(); } finally { restoringHistory = false; } }); // In-app "back" buttons call this instead of mutating ui + render() directly, so // they POP the history entry they're leaving rather than pushing a duplicate. // When there's no in-app entry to pop (e.g. the page was reloaded straight into a // sub-screen), fall back to navigating to the explicit parent. function goBack(toParent) { if (navIndex > 0) history.back(); else { toParent(); render(); } } // ---------------------------------------------------------------- utilities let toastTimer; function toast(msg, ms = 1600) { let t = document.querySelector('.toast'); if (!t) { t = h('div', { class: 'toast' }); document.body.append(t); } t.textContent = msg; requestAnimationFrame(() => t.classList.add('show')); clearTimeout(toastTimer); toastTimer = setTimeout(() => t.classList.remove('show'), ms); } async function copy(text) { try { await navigator.clipboard.writeText(text); } catch { const ta = h('textarea', { value: text }); document.body.append(ta); ta.select(); try { document.execCommand('copy'); } catch {} ta.remove(); } toast(t('copied')); } // Open a URL in a new window/tab. window.open is more reliable than an // inside an installed PWA (standalone mode), where the link // can otherwise navigate the app away instead of opening externally. function openExternal(url) { try { window.open(url, '_blank', 'noopener,noreferrer'); } catch {} } function download(filename, text, mime = 'application/json') { const url = URL.createObjectURL(new Blob([text], { type: mime })); const a = h('a', { href: url, download: filename }); document.body.append(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 1000); } function copyBtn(text, label = t('copy')) { return h('button', { class: 'btn-sm', onClick: () => copy(text) }, label); } // A small "paste from clipboard" button; apply(text) receives the trimmed text. // Returns null where the Clipboard read API isn't available (the catch keeps it // silent if a browser blocks the read). function pasteBtn(apply) { if (typeof navigator === 'undefined' || !navigator.clipboard || !navigator.clipboard.readText) return null; return h('button', { type: 'button', class: 'btn-sm', title: t('paste'), onClick: async () => { try { const txt = await navigator.clipboard.readText(); if (txt) apply(txt.trim()); } catch {} }, html: '', }); } // Paste, for a textarea. Beside a one-line input the button belongs in the // input-group (see Send); above a textarea it floats next to the label as a // box heavier than the label itself. So put it inside the field — and only // while the field is empty, which is the only time it's wanted and the only // time it can't cover what you typed. function pasteInto(ta, apply) { const btn = pasteBtn((text) => { apply(text); sync(); }); if (!btn) return ta; btn.classList.add('paste-in'); btn.append(h('span', {}, t('paste'))); const wrap = h('div', { class: 'paste-wrap' }, ta, btn); // Toggled on the node rather than through render(): re-rendering a textarea // on every keystroke rebuilds it under the caret. const sync = () => wrap.classList.toggle('has-text', !!ta.value); ta.addEventListener('input', sync); sync(); return wrap; } // ---------------------------------------------------------------- display unit // Global BTC/sats preference, persisted in localStorage across refreshes and // logouts. Every unit label on the site is clickable to toggle it. const UNIT_KEY = 'btc-wallet-unit'; let unit = (() => { // Default to sats for first-time users; only an explicit choice sticks. try { const v = localStorage.getItem(UNIT_KEY); return v === 'btc' || v === 'fiat' ? v : 'sats'; } catch { return 'sats'; } })(); // sats → BTC → your money → sats. Fiat is a display unit like the other two // rather than a second number beside them: the balance is one figure, and // which figure is your choice. It's skipped entirely when no price has ever // been fetched — a third position showing blanks is worse than two that work. function toggleUnit() { const ring = haveRate() ? ['sats', 'btc', 'fiat'] : ['sats', 'btc']; unit = ring[(ring.indexOf(unit) + 1) % ring.length]; try { localStorage.setItem(UNIT_KEY, unit); } catch {} render(); } const unitLabel = () => (unit === 'sats' ? 'sats' : unit === 'fiat' ? getCurrency() : 'BTC'); // "Worth X then · Y now" for a payment's detail page — the one place both // numbers earn their keep, because that page is where the question is asked. // Absent when there's no price, and when the two would say the same thing. function worthLine(sats, at, row = null) { const then = rateAt(at); const now = rateNow(); if (!now || !at) return null; const line = row || ((k, v) => h('div', { class: 'line' }, h('span', { class: 'k' }, k), h('span', { class: 'v' }, v))); // A payment we have a price for is shown at that price; one we don't is // shown at today's, without ceremony. (The '~' that used to mark the // difference was more bookkeeping than the beta warrants.) const thenStr = then.rate && !then.stale ? fmtFiat(sats, then.rate) : null; const nowStr = fmtFiat(sats, now); if (!thenStr) return line(t('worthNow'), nowStr); return line(t('worthThen'), thenStr + (thenStr === nowStr ? '' : ' · ' + t('worthNowInline', { v: nowStr }))); } // `at` is when the money moved. In fiat it picks the price recorded at that // moment, so a payment is shown for what it was worth then — the number the // rest of the world remembers it by. Without it (a balance, a form) the live // price is used. A '~' means we had no sample near that time and had to price // it at today's, which is a different claim and shouldn't look like the same // one. const fmtAmount = (sats, at) => { if (unit === 'sats') return fmtSats(sats); if (unit === 'btc') return fmtBtc(sats); const { rate } = rateAt(at); if (!rate) return fmtSats(sats); // bare, like the other two units: whatever labels this number — the unit // tag beside a balance, the toggle everywhere else — says which money it is return fmtFiatBare(sats, rate); }; // A clickable unit label. cls lets callers inherit surrounding sizing. function unitTag(cls = '') { return h('button', { type: 'button', class: 'unit-tag ' + cls, title: t('switchUnit'), onClick: toggleUnit }, unitLabel()); } // ================================================================ UNLOCK // The create/import card on its own — the unlock screen wraps it in the // classic chrome, the onboarding wizard's "Get started" step shows it bare. function unlockCard() { // The entropy page takes the card over entirely — seed words, tabs and // other controls step aside until Submit or Back. if (ui.entropyPage) return h('div', { class: 'card col' }, entropyPage()); return h( 'div', { class: 'card col' }, h( 'div', { class: 'tabs' }, tabBtn(t('createNew'), ui.unlockTab === 'create', () => { ui.unlockTab = 'create'; ui.unlockError = ''; render(); }), tabBtn(t('importExisting'), ui.unlockTab === 'import', () => { ui.unlockTab = 'import'; ui.unlockError = ''; render(); }) ), ui.unlockTab === 'create' ? createPane() : importPane(), ui.unlockError && h('div', { class: 'notice err' }, ui.unlockError) ); } function unlockScreen() { return h( 'div', { class: 'col', style: 'gap:16px' }, brandHeader(false), unlockCard(), featureHook('unlockExtra'), ui.fromWallet && accounts.length && !ui.entropyPage ? h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.fromWallet = false; ui.screen = 'wallet'; ui.unlockError = ''; render(); } }, t('back')) : null ); } // ================================================================ HOW IT WORKS function howItWorksScreen() { const back = () => { ui.screen = ui.returnScreen === 'wallet' ? 'wallet' : 'unlock'; render(); }; const para = (key) => h('p', { class: 'muted', style: 'margin:0' }, ...linkify(t(key))); return h( 'div', { class: 'col', style: 'gap:16px' }, brandHeader(false), h( 'div', { class: 'card col', style: 'gap:14px' }, h('h3', {}, t('hiwBasicsTitle')), para('hiwBasics1'), para('hiwBasics2'), para('hiwBasics3') ), h('div', { class: 'card col', style: 'gap:14px' }, h('h3', {}, '⚡ ' + t('hiwLnTitle')), para('hiwLn1')), h('div', { class: 'card col', style: 'gap:14px' }, h('h3', {}, '🎁 ' + t('hiwGiftsTitle')), para('hiwGifts1')), // The standalone file: the whole app inlined into one HTML — the // save-and-open-offline story the split build gave up. Only offered when // there's a server to download it from (on file:// you already have it). location.protocol.startsWith('http') ? h('div', { class: 'card col', style: 'gap:14px' }, h('h3', {}, '💾 ' + t('hiwStandaloneTitle')), para('hiwStandalone1'), h('a', { href: '/standalone.html', download: 'coinos.html' }, t('hiwStandaloneLink'))) : null, h('button', { class: 'btn-block', onClick: back }, t('back')) ); } // Turn known tokens (e.g. mempool.space) into links within a plain string, // returning an array of text + anchor nodes. Keeps i18n strings link-free. const HIW_LINKS = [ ['mempool.space', 'https://mempool.space'], ]; function linkify(text) { const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const re = new RegExp('(' + HIW_LINKS.map(([tok]) => esc(tok)).join('|') + ')', 'g'); const out = []; let last = 0; let m; while ((m = re.exec(text))) { if (m.index > last) out.push(text.slice(last, m.index)); const href = HIW_LINKS.find(([tok]) => tok === m[0])[1]; out.push(h('a', { href, target: '_blank', rel: 'noopener' }, m[0])); last = m.index + m[0].length; } if (last < text.length) out.push(text.slice(last)); return out; } function tabBtn(label, active, onClick) { // Act on POINTERDOWN, not click: on a phone the click event lands // 100–250ms after the finger does, and a tab switch has no scroll or // long-press ambiguity to wait out — native segmented controls switch on // touch-down for exactly this reason. The click handler stays for // keyboard/AT activation; re-selecting the current tab is a no-op, so the // pair firing together costs nothing. // // And feedback comes FIRST: the full re-render can take 100ms+ on a // phone, and running it synchronously meant even the button's own active // state waited for the whole pane to build. Flip the strip's classes with // a direct DOM write (paints this frame), then run the real render after // that paint — double-rAF is the "after the next paint" idiom. // The down-timestamp lives ON THE NODE, not in this closure: the // pointerdown's own render replaces these handlers (fresh closure, zero // timestamp) before the browser delivers the paired click, and with tabs // that TOGGLE, letting that click through un-did the tap it belonged to. const select = (btn) => { if (btn && btn.parentElement) { for (const b of btn.parentElement.children) b.classList.toggle('active', b === btn); } requestAnimationFrame(() => requestAnimationFrame(onClick)); }; return h('button', { class: active ? 'active' : '', onPointerdown: (e) => { if (e.button !== 0) return; e.currentTarget._downAt = performance.now(); select(e.currentTarget); }, onClick: (e) => { if (performance.now() - (e.currentTarget._downAt || 0) > 500) select(e.currentTarget); }, }, label); } function createPane() { if (ui.createStep === 'gen') { if (!ui.draftMnemonic) { return h( 'div', { class: 'col' }, h('p', { class: 'muted' }, t('genIntro')), h( 'button', { class: 'btn-primary btn-block', onClick: () => { ui.draftRandom = ui.draftMnemonic = newMnemonic(); render(); }, }, t('generateSeed') ) ); } const words = ui.draftMnemonic.split(' '); return h( 'div', { class: 'col' }, h('div', { class: 'warn-box' }, t('writeDownWarn')), h( 'div', { class: 'words' }, words.map((w, i) => h('div', { class: 'w' }, h('span', { class: 'n' }, i + 1), h('span', { class: 't' }, w)) ) ), h( 'div', { class: 'row gap6' }, copyBtn(ui.draftMnemonic, t('copyPhrase')), // With own entropy the phrase is a pure function of the text — // "Regenerate" would hand back the identical words, so it hides. (ui.ownEntropy || '').trim() ? null : h( 'button', { class: 'btn-ghost btn-sm', onClick: () => { ui.draftRandom = ui.draftMnemonic = newMnemonic(); render(); }, }, t('regenerate') ), h( 'button', { class: 'btn-ghost btn-sm', onClick: () => { ui.entropyPage = 'create'; ui.entropyEntry = ui.ownEntropy || ''; render(); }, }, t('entropyToggle') ) ), optionsPanel(), h( 'button', { class: 'btn-primary btn-block', onClick: () => { // catch a mistyped passphrase here, while the field to fix it is // still on screen — the verify step only samples the words if (ui.passphrase && ui.confirmPass !== ui.passphrase) { ui.unlockError = t('passphraseMismatch'); render(); return; } ui.confirm = pickConfirm(words); ui.unlockError = ''; ui.createStep = 'confirm'; render(); }, }, t('verifyBackup') ), h( 'button', { class: 'btn-block', onClick: () => openWallet(ui.draftMnemonic, { generated: true }) }, t('skipVerification') ) ); } // confirm step (optional — reachable via "Verify backup") return h( 'div', { class: 'col' }, h('p', { class: 'muted' }, t('confirmBackupIntro')), ...ui.confirm.map((c, i) => h( 'label', { class: 'field' }, h('span', { class: 'lab' }, t('wordN', { n: c.index + 1 })), h('input', { type: 'text', class: 'mono-input', autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', value: c.value, onInput: (e) => (ui.confirm[i].value = e.target.value.trim()), }) ) ), h('div', { class: 'row gap6' }, h('button', { class: 'btn-ghost', onClick: () => { ui.createStep = 'gen'; render(); } }, t('back')), h('button', { class: 'btn-primary grow', onClick: () => { const words = ui.draftMnemonic.split(' '); const ok = ui.confirm.every((c) => c.value.toLowerCase() === words[c.index]); if (!ok) { ui.unlockError = t('wordsMismatch'); render(); return; } openWallet(ui.draftMnemonic, { generated: true }); }, }, t('openWallet')) ), h('button', { class: 'btn-block', onClick: () => openWallet(ui.draftMnemonic, { generated: true }) }, t('skipVerification')) ); } function pickConfirm(words) { const idx = new Set(); while (idx.size < 3) idx.add(Math.floor(Math.random() * words.length)); return [...idx].sort((a, b) => a - b).map((index) => ({ index, value: '' })); } // The BYO-entropy page, shared by Create (re-derives the draft phrase) and // Import (fills the phrase field): the typed text alone determines the seed, // so the same text recreates the same wallet anywhere. Nothing derives while // typing — Submit does it once, then hands back the phrase view. function entropyPage() { const forCreate = ui.entropyPage === 'create'; return h( 'div', { class: 'col', style: 'gap:10px' }, h('h3', { style: 'margin:0' }, t('entropyToggle')), h('p', { class: 'small muted', style: 'margin:0' }, t('entropyHint')), h('div', { class: 'warn-box small' }, t('entropyWarn')), h('textarea', { rows: '3', style: 'font-family:var(--sans)', placeholder: t('entropyPlaceholder'), autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', value: ui.entropyEntry || '', onInput: (e) => (ui.entropyEntry = e.target.value), }), h( 'div', { class: 'row gap6' }, h('button', { class: 'btn-ghost grow', onClick: () => { ui.entropyPage = null; render(); } }, t('back')), h('button', { class: 'btn-primary grow', onClick: () => { const txt = (ui.entropyEntry || '').trim(); if (forCreate) { ui.ownEntropy = txt; ui.draftMnemonic = txt ? newMnemonic(128, txt) : (ui.draftRandom || ui.draftMnemonic || newMnemonic()); } else { ui.importEntropy = txt; // fills the phrase field with its derived words; clearing the text // clears only what it filled — a pasted phrase is left alone if (txt) ui.importText = ui._entropyFilled = newMnemonic(128, txt); else if (ui.importText === ui._entropyFilled) ui.importText = ''; } ui.entropyPage = null; render(); } }, t('entropySubmit')) ) ); } function importPane() { const ta = h('textarea', { placeholder: t('importPlaceholder'), autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', value: ui.importText, onInput: (e) => (ui.importText = e.target.value), }); return h( 'div', { class: 'col' }, h( 'label', { class: 'field' }, h('span', { class: 'lab' }, t('importLabel')), pasteInto(ta, (text) => { ta.value = text; ui.importText = text; }) ), // A wallet born from typed entropy comes back the same way: the entropy // page fills the phrase field with its derived words, visibly. h( 'button', { class: 'btn-ghost btn-sm', style: 'align-self:flex-start', onClick: () => { ui.entropyPage = 'import'; ui.entropyEntry = ui.importEntropy || ''; render(); }, }, t('entropyToggle') ), optionsPanel(), h('button', { class: 'btn-primary btn-block', onClick: () => openWallet(ui.importText) }, t('openWallet')) ); } function optionsPanel() { const pass = h( 'label', { class: 'field' }, h('span', { class: 'lab' }, t('passphrase'), // People assume a password logs in or encrypts; a BIP39 passphrase does // neither, and getting that wrong loses money — worth a sentence here. h('button', { class: 'linklike small', type: 'button', style: 'margin-left:8px', onClick: (e) => { e.preventDefault(); ui.passInfoOpen = !ui.passInfoOpen; render(); }, }, t('passWhatsThis'))), ui.passInfoOpen ? h('p', { class: 'small muted', style: 'margin:0 0 6px' }, t('passInfo')) : null, h( 'div', { class: 'input-group' }, h('input', { type: ui.showPass ? 'text' : 'password', class: 'mono-input', autocomplete: 'off', value: ui.passphrase, // rendering mid-keystroke is safe (focus + caret are preserved), but // only the empty↔non-empty flip changes what's on screen (the confirm // field below), so only that flip pays for a rebuild onInput: (e) => { const had = !!ui.passphrase; ui.passphrase = e.target.value; if (had !== !!ui.passphrase) render(); }, }), h('button', { class: 'btn-sm', type: 'button', onClick: () => { ui.showPass = !ui.showPass; render(); } }, ui.showPass ? t('hide') : t('show')) ) ); // A typo here IS a different wallet, silently — so once a passphrase is // typed, it's typed twice. (Not needed when it's empty, which is most people.) const confirm = ui.passphrase ? h( 'label', { class: 'field' }, h('span', { class: 'lab' }, t('reenterPassphrase')), h('input', { type: ui.showPass ? 'text' : 'password', class: 'mono-input', autocomplete: 'off', value: ui.confirmPass, onInput: (e) => (ui.confirmPass = e.target.value), }) ) : null; return [pass, confirm]; } // Import accepts a recovery phrase, an xpub/zpub (watch-only), or an xprv/zprv // (full spending). Classify the pasted text and open the right kind of wallet. async function openWallet(input, opts = {}) { ui.unlockError = ''; const raw = (input || '').trim(); const m = raw.replace(/\s+/g, ' '); if (isValidMnemonic(m)) { // last line of defense — the panes with the confirm field on screen also // check before handing off, so this error always lands somewhere fixable if (ui.passphrase && ui.confirmPass !== ui.passphrase) { ui.unlockError = t('passphraseMismatch'); render(); return; } await enterWallet(m, ui.passphrase, { generated: opts.generated }); ui.draftMnemonic = ''; // discard the used draft so the next "Add wallet" generates a fresh seed return; } let pk; try { pk = parseExtendedKey(raw); } catch { ui.unlockError = t('invalidImport'); render(); return; } const acc = pk.kind === 'xpub' ? addOrGetAccount({ type: 'watch', label: defaultLabel('watch'), xpub: pk.key }) : addOrGetAccount({ type: 'full', label: defaultLabel('full'), xprv: pk.key }); ui.fromWallet = false; await activateAccount(acc, { fresh: true }); } // Register a full (seed) wallet as an account and open it. async function enterWallet(mnemonic, passphrase, opts = {}) { const acc = addOrGetAccount({ type: 'full', label: defaultLabel('full'), mnemonic: (mnemonic || '').trim().replace(/\s+/g, ' '), passphrase: passphrase || '', }); // Has this person ever had the recovery phrase in front of them? Creating // a wallet shows it; importing one means they typed it. A sign-in DERIVES // it — Google, a passkey and a nostr key all end up at a seed the user has // never seen, and which nobody can reissue for them. That is the one case // worth asking about, so it's recorded once and never guessed at again. if (acc.seedSeen === undefined) acc.seedSeen = !opts.nostrPubkey; await activateAccount(acc, { ...opts, fresh: true }); } // Load an account into the wallet and start scanning. Full-account seeds are // kept in sessionStorage (ephemeral); a refresh restores the open account. async function activateAccount(acc, opts = {}) { // Tear down the previous account's feature runtime BEFORE the wallet // switches identity: live relay subscriptions and in-memory state (chat // threads, decrypted DMs) belong to the old keys, and surviving into this // account they'd both leak the old account's messages on screen and block // the new account's own subscriptions from ever starting. _featuresInited = false; for (const f of FEATURES) { try { f.stop && f.stop(); } catch {} } activeId = acc.id; // A gift link generates this wallet only to claim into. Keep it provisional // until the user commits (claims, or chooses to keep it / enters the wallet), // so bailing from an already-claimed gift doesn't leave an empty account. if (opts.gift && !opts.existingClaim) acc.provisional = true; // The network is a per-wallet property: activating a wallet activates its // network. Accounts created before this carried none — they adopt the // current global choice once, then keep it. The global setting remains the // default for the NEXT new wallet. if (!acc.network) acc.network = getNetwork(); // The relay backup said this wallet has a Spending side: latch it BEFORE // the first paint, so a restored wallet doesn't open as savings-only and // reflow when the name lookup proves Spending a beat later. if (opts.spendingHint && !acc.spendingSetup) { acc.spendingSetup = Date.now(); } const netName = acc.network; if (netName !== getNetwork()) { setNetwork(netName); for (const f of FEATURES) { try { f.networkChanged && f.networkChanged(netName); } catch {} } } if (acc.type === 'watch') wallet.load({ xpub: acc.xpub, netName, offline: false }); else if (acc.xprv) wallet.load({ xprv: acc.xprv, netName, offline: false }); else wallet.load({ mnemonic: acc.mnemonic, passphrase: acc.passphrase || '', netName, offline: false, spFresh: !!opts.generated, accountIndex: acc.deriveIndex || 0 }); // Record the account-level xpub so this wallet survives a session wipe as a // watch-only entry (see the durable account directory) — you keep seeing your // balance/history and re-enter the seed to spend again. if (acc.type !== 'watch' && !acc.provisional) acc.xpub = wallet.accountXpub(); persistAccounts(); autoSave(acc); // A name fetched during sign-in seeds the names state BEFORE features init // and the first paint — the Receive pane opens showing its QR. if (opts.nameHint) { try { featureHook('namesSeed', opts.nameHint); } catch {} } for (const f of FEATURES) { try { f.init && f.init(); } catch {} } // per-feature wallet lifecycle _featuresInited = true; // a deferred feature landing after this point gets its init() on arrival // A freshly generated identity provably has no profile yet — let features // skip the "is there a kind-0?" wait (avatar would sit blank meanwhile). if (opts.generated) featureHook('identityGenerated'); const hadCache = wallet.restoreCache(); // show last-known balance/history instantly // An opened gift link starts on a claim/back-up screen instead of the wallet. ui.screen = opts.gift ? 'claim' : 'wallet'; ui.claimStep = 'welcome'; ui.claimCode = opts.gift || null; ui.claimError = ''; ui.claimTaken = null; ui.claimNotVisible = false; ui.claimChecking = !!opts.gift; // gate the Claim button until we've checked // Restore the tab / open tx from the last session so a refresh keeps the // user's place. A gift link always opens the claim screen instead. const nav = (() => { try { return JSON.parse(sessionStorage.getItem(NAV_KEY) || 'null'); } catch { return null; } })(); ui.tab = (!opts.gift && nav && nav.tab) || 'history'; ui.txDetail = (!opts.gift && nav && nav.txDetail) || null; // Not baselined yet — stays null until the scan + ack logic below sets it, // so the celebration never fires for payments that were already there at // import (the index only looks "advanced" because the scan hadn't run yet). ui.receiveSeenIndex = null; ui.arkRecvAddr = null; // the ark receive address belongs to one wallet+network ui.send = blankSend(); ui.draft = null; ui.sendResult = null; ui.giftMode = false; ui.offlineFallback = false; render(); if (ui.txDetail) openTx(ui.txDetail); // restored a tx detail — fill in fee/details if missing // No manual offline switch: try to scan, and if the network is unreachable, // fall back to offline mode automatically. if (typeof navigator !== 'undefined' && navigator.onLine === false) { enterOfflineFallback(); return; } try { // Celebration baseline. Opening/importing/switching a wallet (opts.fresh) // baselines to the current frontier, so payments already received before // opening never trigger the "payment received" screen. On a same-session // refresh we restore the persisted ack — which is advanced the moment the // celebration is shown, so a payment celebrates once and never reappears. let ack; if (opts.fresh) { ack = wallet.nextReceiveIndex; wallet.setReceiveAck(ack); } else { ack = wallet.getReceiveAck(); if (ack == null) { ack = wallet.nextReceiveIndex; wallet.setReceiveAck(ack); } } // For a fresh open, leave receiveSeenIndex null until the post-scan baseline // below. Setting it now (to the pre-scan frontier) lets the socket's reconcile // credit a deposit that predates this open and briefly flash the "payment // received" screen before the baseline catches up. A same-session refresh // uses the persisted ack right away. if (!opts.fresh) ui.receiveSeenIndex = ack; // Go Live immediately: the socket must not wait on Nostr (up to 6s) or any // discovery scan. The cache/Nostr state is already on screen. wallet.startRealtime(); // Opened a gift link? Let the gifts feature verify its funding coin is // still unspent before the claim proceeds. if (opts.gift) featureHook('giftOpened', opts.gift); // Cross-device state in the background. State comes from the local cache or // Nostr — both restore the full balance, coins, and history — so a full API // scan runs ONLY when we have neither (e.g. a seed imported on a fresh device // 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 (!hadCache && !hadNostr) { await wallet.scan({ silent: false }); } // 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(); } } // --- accounts ------------------------------------------------------------- // Open the unlock screen in ADDITIVE mode: the sign-in doors (Nostr signer, // passkey, Google, pasted key/phrase) add the identity alongside the current // accounts instead of replacing them, and Back returns to the wallet. Shared // by the Accounts screen and the profile page. function signInAnother() { ui.fromWallet = true; ui.unlockError = ''; ui.unlockTab = 'import'; ui.screen = 'unlock'; ui.profilePk = null; ui.profEdit = null; ui.profEditFilled = false; ui.chatOpen = false; render(); } // The nostr login persisted against an account's OWN wallet state. Feature // state lives in localStorage keyed by the seed, so this answers "who is that // account?" for every account on the device, not just the open one — no // stamping, nothing to go stale. function loginPkOf(a) { try { const base = a.type === 'watch' ? a.xpub : a.xprv || (a.mnemonic ? `${a.mnemonic}\n${a.passphrase || ''}` : null); if (!base) return null; const key = cacheKeyFor(a.deriveIndex ? `${base}\n#${a.deriveIndex}` : base); return (JSON.parse(localStorage.getItem(key + ':nostrlogin') || 'null') || {}).pubkey || null; } catch { return null; } } // Whose wallet this is: the nostr login it carries, else the key its seed // derives, else whatever was last stamped on the record. The accounts screen // and the identity switcher have to agree on this or they describe different // worlds. function identityPkOf(a) { if (!a) return null; let dir = []; try { dir = JSON.parse(localStorage.getItem(WATCH_KEY) || '[]'); } catch {} const stamped = (a.xpub && (dir.find((d) => d.xpub === a.xpub) || {}).nostrPk) || null; return loginPkOf(a) || (a.mnemonic ? seedPubkey(a.mnemonic, a.passphrase || '', a.deriveIndex || 0) : null) || a.nostrPk || stamped; } // The identities on this device — one per seed (a seed's Spending and // Savings faces are the same person), each with the nostr key its header // avatar wears: the nostr login when there is one, else the seed's own key. // Both are read from the account itself; the key remembered on the record // (saveDirectory) and in the durable directory by xpub is only the fallback, // for a watch-only entry whose seed isn't here to ask. function identities() { const pkOf = identityPkOf; const bySeed = new Map(); for (const a of accounts) { if (a.provisional) continue; const key = a.type === 'watch' ? 'w:' + a.xpub : a.mnemonic ? 'm:' + a.mnemonic + '\n' + (a.passphrase || '') + '\n' + (a.deriveIndex || 0) : 'x:' + (a.xprv || a.xpub || a.id); const active = a.id === activeId; const pk = pkOf(a); const cur = bySeed.get(key); if (!cur) bySeed.set(key, { id: a.id, pk, active, label: seedName(a), network: a.network, watch: a.type === 'watch' }); else { if (active) { cur.id = a.id; cur.active = true; } if (!cur.pk && pk) cur.pk = pk; } } const out = [...bySeed.values()]; const me = out.find((x) => x.active); if (me) me.pk = ctx.shownPubkey() || me.pk; return out; } // Page-level navigation owned by the features (a profile, a chat thread, a // search, the zap setup) — dropped whenever the session underneath it // changes, so the next paint is the app's own screen and not the page the // previous identity (or the unlocked wallet) was reading. function clearFeatureNav() { ui.profilePk = null; ui.profEdit = null; ui.profEditFilled = false; ui.chatOpen = false; ui.userSearch = null; ui.noteThread = null; ui.zapSetup = null; } // Switch to another identity already signed in on this device: the wallet // it opens lands on its own history, out of any profile or chat the previous // identity had open. function switchIdentity(id) { const acc = accounts.find((a) => a.id === id); if (!acc) return; clearFeatureNav(); switchToView(acc.id, viewsOf(acc)[0]); } // The working set of wallets you can switch between. Full (seed-bearing) // accounts live only in sessionStorage — ephemeral, wiped when the browser // closes (no seed on disk by default). Watch-only accounts hold just an xpub, // so they're additionally persisted in localStorage and reload across restarts. const ACCOUNTS_KEY = 'btc-wallet-accounts'; // sessionStorage: session list + active const WATCH_KEY = 'btc-wallet-watch'; // localStorage: persisted watch-only accounts let accounts = []; let activeId = null; const genId = () => 'a' + Date.now().toString(36) + Math.random().toString(36).slice(2, 7); const credId = (a) => (a.type === 'watch' ? 'w:' + a.xpub : a.xprv ? 'x:' + a.xprv : 'f:' + a.mnemonic + '|' + (a.passphrase || '') + (a.deriveIndex ? '|#' + a.deriveIndex : '')); const activeAccount = () => accounts.find((a) => a.id === activeId) || null; function defaultLabel(type) { const n = accounts.filter((a) => a.type === type).length + 1; return t(type === 'watch' ? 'watchLabelN' : 'walletLabelN', { n }); } // The durable account directory (localStorage): a view-only mirror of every // account — id, label, and account xpub, never a seed. So a wallet survives a // session wipe (browser closed without "Save to device") as a watch-only entry: // you keep seeing balance/history and re-enter the seed to spend again. function loadWatchAccounts() { try { const dir = JSON.parse(localStorage.getItem(WATCH_KEY) || '[]'); return dir.filter((d) => d.xpub).map((d) => ({ id: d.id, label: d.label, type: 'watch', xpub: d.xpub, autoLock: d.autoLock || 0, network: d.network, nostrPk: d.nostrPk })); } catch { return []; } } function saveDirectory() { try { // Remember each wallet's nostr identity alongside its xpub: a watch-only // reopening still shows WHO this wallet is (avatar, npub, profile) even // though nothing can be signed until the keys are back. const dir = accounts.filter((a) => a.xpub && !a.provisional).map((a) => { if (a.id === activeId && !wallet.watchOnly) { // remember the identity the header actually SHOWS — a nostr login // outranks the seed-derived key, locked or not try { a.nostrPk = (featureHook('nostrLoginIdentity') || {}).pubkey || (wallet.nostrPubkey && wallet.nostrPubkey()) || a.nostrPk; } catch {} } return { id: a.id, label: a.label, xpub: a.xpub, autoLock: a.autoLock || 0, network: a.network, nostrPk: a.nostrPk }; }); localStorage.setItem(WATCH_KEY, JSON.stringify(dir)); } catch {} } function persistAccounts() { try { sessionStorage.setItem(ACCOUNTS_KEY, JSON.stringify({ accounts, activeId })); } catch {} saveDirectory(); } function clearAccounts() { accounts = []; activeId = null; try { sessionStorage.removeItem(ACCOUNTS_KEY); } catch {} } // Add an account (deduped by credential), returning the stored object. function addOrGetAccount(partial) { const cid = credId(partial); let acc = accounts.find((a) => credId(a) === cid); if (!acc) { // every new wallet is born on the currently-selected network acc = { id: genId(), network: getNetwork(), ...partial }; // the Add-wallet chooser fixed this wallet's purpose before the seed flow if (ui.newWalletKind) { acc.kind = ui.newWalletKind; ui.newWalletKind = null; } accounts.push(acc); persistAccounts(); } return acc; } // A wallet on an existing seed: the next unused BIP84 account index under the // parent's phrase. Its coins, ark state, cache and nostr identity are all its // own; the parent's seed phrase backs up both. function createDerivedWallet(parentId, kind) { const parent = accounts.find((a) => a.id === parentId); if (!parent || !parent.mnemonic) return; const siblings = accounts.filter((x) => x.mnemonic === parent.mnemonic && (x.passphrase || '') === (parent.passphrase || '')); const next = 1 + Math.max(0, ...siblings.map((x) => x.deriveIndex || 0)); const acc = { id: genId(), type: 'full', network: parent.network || getNetwork(), label: `${parent.label} +${next}`, viewLabels: { [kind]: `${kind === 'spending' ? t('spendingName') : t('savingsName')} ${next + 1}` }, mnemonic: parent.mnemonic, passphrase: parent.passphrase || '', deriveIndex: next, kind, }; accounts.push(acc); persistAccounts(); if (parent.persisted) { acc.persisted = true; writeVault(); } ui.addWallet = null; activateAccount(acc, { fresh: true }); } // Commit the active account — clear the "provisional" gift flag so it's no // longer discarded on bail. Called once the user claims, keeps, or enters it. function commitAccount() { const a = accounts.find((x) => x.id === activeId); if (a && a.provisional) { delete a.provisional; if (a.type !== 'watch') a.xpub = wallet.accountXpub(); // now eligible for the durable directory persistAccounts(); } autoSave(a); } function removeAccount(id) { const acc = accounts.find((a) => a.id === id); if (!acc) return; accounts = accounts.filter((a) => a.id !== id); persistAccounts(); if (activeId === id) { if (accounts.length) activateAccount(accounts[0], { fresh: true }); else lock(); } else { render(); } } function switchAccount(id) { const acc = accounts.find((a) => a.id === id); if (acc) activateAccount(acc, { fresh: true }); } // Restore accounts after a refresh (sessionStorage); on a fresh session, prompt // to unlock the encrypted vault if there is one, else seed from watch-only // accounts. Returns true if it handled the entry (opened or showed a prompt). function restoreAccountsState() { let sess = null; try { sess = JSON.parse(sessionStorage.getItem(ACCOUNTS_KEY) || 'null'); } catch {} // the "Unlock for" deadline outlives refreshes: past it, the password is // required again no matter what the session cache still holds if (unlockDeadlinePassed()) { try { sessionStorage.removeItem(ACCOUNTS_KEY); localStorage.removeItem(UNLOCK_UNTIL_KEY); } catch {} sess = null; } else { armUnlockDeadline(); } if (sess && Array.isArray(sess.accounts) && sess.accounts.length) { accounts = sess.accounts; const active = accounts.find((a) => a.id === sess.activeId) || accounts[0]; activateAccount(active); restoreNavFromHistory(); return true; } if (hasVault()) { // A blank (optional) vault password unlocks seamlessly with no prompt. if (attemptVaultUnlock('')) { if (accounts.length) activateAccount(accounts[0], { fresh: true }); else { ui.screen = 'unlock'; render(); } return true; } ui.screen = 'vault'; render(); return true; } const watch = loadWatchAccounts(); if (watch.length) { accounts = watch.slice(); activateAccount(accounts[0], { fresh: true }); return true; } return false; } // --- encrypted vault (optional password-persisted full accounts) ---------- const VAULT_KEY = 'btc-wallet-vault'; // Whether we've offered a password on locking. Asked once, ever: someone who // says no has chosen, and a wallet that nags is a wallet people stop reading. const LOCK_PW_KEY = 'btc-wallet-lockpw'; const lockPwAsked = () => { try { return !!localStorage.getItem(LOCK_PW_KEY); } catch { return true; } }; const markLockPwAsked = () => { try { localStorage.setItem(LOCK_PW_KEY, '1'); } catch {} }; let vaultPassword = null; // in memory once unlocked/set this session; cleared on lock function loadVaultBlob() { try { return JSON.parse(localStorage.getItem(VAULT_KEY) || 'null'); } catch { return null; } } function hasVault() { return !!loadVaultBlob(); } // Re-encrypt the vault from the currently-persisted full accounts (needs the // in-memory password). Removes the blob when nothing is persisted. function writeVault() { if (vaultPassword == null) return; const list = accounts.filter((a) => a.type === 'full' && a.persisted) .map((a) => (a.xprv ? { label: a.label, xprv: a.xprv, network: a.network } : { label: a.label, mnemonic: a.mnemonic, passphrase: a.passphrase || '', network: a.network })); try { if (!list.length) localStorage.removeItem(VAULT_KEY); else localStorage.setItem(VAULT_KEY, JSON.stringify(encryptVault(list, vaultPassword))); } catch {} } // Bring vault-saved seeds into the session. Each upgrades the matching watch-only // directory entry (by xpub) to a spendable full account so wallets aren't // duplicated; one with no directory entry is added fresh. function mergeVaultList(list) { for (const v of list) { // Derive on the wallet's OWN network: accountXpubFor defaults to mainnet, // and a testnet wallet derives under a different coin type, so leaving it // to the default produced an xpub that could never match the stored one — // every unlock re-added the same wallet as a new entry. const net = v.network || getNetwork(); const xpub = v.xprv ? accountXpubFor({ xprv: v.xprv }, net) : accountXpubFor({ mnemonic: v.mnemonic, passphrase: v.passphrase || '' }, net); const existing = accounts.find((a) => a.xpub === xpub); if (existing) { existing.type = 'full'; if (v.xprv) { existing.xprv = v.xprv; delete existing.mnemonic; delete existing.passphrase; } else { existing.mnemonic = v.mnemonic; existing.passphrase = v.passphrase || ''; delete existing.xprv; } if (v.label) existing.label = v.label; if (v.network && !existing.network) existing.network = v.network; existing.persisted = true; } else { const acc = addOrGetAccount( v.xprv ? { type: 'full', label: v.label || defaultLabel('full'), xprv: v.xprv, xpub } : { type: 'full', label: v.label || defaultLabel('full'), mnemonic: v.mnemonic, passphrase: v.passphrase || '', xpub } ); if (v.network) acc.network = v.network; acc.persisted = true; } } } // Keeping a wallet is the default: a seed that only lives in this tab is one // refresh away from being gone, and that surprise costs people money. So a // wallet saves itself to the device unless the vault is locked behind a real // password — then the manual Save button (and the padlock's save-first ask) // still covers it. No password is set here; Settings can add one later. function autoSave(acc) { if (!acc || acc.type !== 'full' || acc.persisted || acc.provisional) return; if (vaultPassword == null) { if (hasVault()) { // A blank-password vault opens by itself — treating it as locked left // every wallet created after a plain reload silently unsaved. Fold its // wallets into the session first: writeVault rebuilds the blob from // session accounts, and the saved ones must not fall out of it here. let list; try { list = decryptVault(loadVaultBlob(), ''); } catch { return; } // a real password: manual vaultPassword = ''; mergeVaultList(list); } else { vaultPassword = ''; } } acc.persisted = true; writeVault(); persistAccounts(); } // Toggle persistence on a full account. Prompts for the vault password when it // isn't unlocked this session ('set' the first time, 'enter' if a vault exists). function startSave(id) { const acc = accounts.find((a) => a.id === id); if (!acc || acc.type !== 'full') return; if (vaultPassword != null) { acc.persisted = true; writeVault(); persistAccounts(); render(); return; } ui.pw = { purpose: 'save', accId: id, mode: hasVault() ? 'enter' : 'set', v1: '', v2: '', error: '' }; render(); } // --- load a seed into a watch-only account (make it spendable) -------------- function startLoadSeed(opts = {}) { // accId lets the per-wallet settings page import a seed for a specific // (possibly non-active) watch-only wallet; defaults to the active one. ui.loadSeed = { value: '', passphrase: '', save: !!opts.save, error: '', accId: opts.accId || activeId }; render(); } function cancelLoadSeed() { ui.loadSeed = null; render(); } async function doLoadSeed() { const ls = ui.loadSeed; const acc = (ls && accounts.find((a) => a.id === ls.accId)) || activeAccount(); if (!ls || !acc || !acc.xpub) return; const raw = (ls.value || '').trim().replace(/\s+/g, ' '); if (!raw) { ls.error = t('enterSeedToSpend'); render(); return; } let next = null; try { if (isValidMnemonic(raw)) { if (accountXpubFor({ mnemonic: raw, passphrase: ls.passphrase || '' }, acc.network || getNetwork()) !== acc.xpub) { ls.error = t('seedMismatch'); render(); return; } next = { mnemonic: raw, passphrase: ls.passphrase || '' }; } else { const pk = parseExtendedKey(raw); // throws if not an extended key if (pk.kind !== 'xprv') { ls.error = t('seedNeedsPrivate'); render(); return; } if (accountXpubFor({ xprv: pk.key }, acc.network || getNetwork()) !== acc.xpub) { ls.error = t('seedMismatch'); render(); return; } next = { xprv: pk.key }; } } catch { ls.error = t('seedInvalid'); render(); return; } // Upgrade in place — keep id/label/xpub so the cache, directory entry and // history all carry over; the account is now spendable. acc.type = 'full'; if (next.mnemonic) { acc.mnemonic = next.mnemonic; acc.passphrase = next.passphrase; delete acc.xprv; } else { acc.xprv = next.xprv; delete acc.mnemonic; delete acc.passphrase; } const save = ls.save; ui.loadSeed = null; await activateAccount(acc, { fresh: false }); if (save) startSave(acc.id); // opens the Save-to-device (vault) flow else render(); } function startForget(id) { if (vaultPassword != null) { const acc = accounts.find((a) => a.id === id); if (acc) acc.persisted = false; writeVault(); persistAccounts(); render(); return; } ui.pw = { purpose: 'forget', accId: id, mode: 'enter', v1: '', v2: '', error: '' }; render(); } function cancelPw() { // Declining the lock-time offer is an answer, not a postponement. if (ui.pw && ui.pw.purpose === 'lock') markLockPwAsked(); // Backing out of save-before-lock means "don't lock" — go home, keys intact. if (ui.pw && ui.pw.purpose === 'locksave') ui.screen = 'wallet'; ui.pw = null; render(); } function startChangePw() { ui.pw = { purpose: 'change', mode: 'change', v0: '', v1: '', v2: '', error: '' }; render(); } function submitPw() { const p = ui.pw; if (p.purpose === 'change') { // Re-encrypt the actual vault (decrypt with the current password, encrypt // with the new one) so we never drop wallets that aren't in this session. let list; try { list = decryptVault(loadVaultBlob(), p.v0 || ''); } catch { p.error = t('pwWrong'); render(); return; } if (p.v1 !== p.v2) { p.error = t('pwMismatch'); render(); return; } try { localStorage.setItem(VAULT_KEY, JSON.stringify(encryptVault(list, p.v1 || ''))); } catch {} if (vaultPassword != null) vaultPassword = p.v1 || ''; // keep the unlocked session in sync ui.pw = null; render(); toast(t('pwChanged')); return; } if (p.purpose === 'lock') { if (!p.v1) { p.error = t('pwNeededToLock'); render(); return; } if (p.v1 !== p.v2) { p.error = t('pwMismatch'); render(); return; } // Fold any blank-password vault contents into the session before the // re-encrypt: writeVault rebuilds the blob from session accounts, and a // wallet saved by an earlier session must not fall out of the vault just // because a password is being set now. try { mergeVaultList(decryptVault(loadVaultBlob(), '')); } catch {} vaultPassword = p.v1; writeVault(); persistAccounts(); markLockPwAsked(); ui.pw = null; softLock(); // stay home, watch-only: closed padlock + "Locked" toast return; } if (p.mode === 'set') { // Password is optional — a blank one just persists without protection. if (p.v1 !== p.v2) { p.error = t('pwMismatch'); render(); return; } vaultPassword = p.v1 || ''; } else { let list; try { list = decryptVault(loadVaultBlob(), p.v1); } catch { p.error = t('pwWrong'); render(); return; } vaultPassword = p.v1; mergeVaultList(list); // bring existing persisted accounts into the session } const acc = accounts.find((a) => a.id === p.accId); if (acc) acc.persisted = p.purpose === 'save' || p.purpose === 'locksave'; writeVault(); persistAccounts(); const finishLock = p.purpose === 'locksave'; ui.pw = null; if (finishLock) { softLock(); return; } render(); } // Try to decrypt the vault with the given password; on success, seed the // accounts (watch-only directory + the unlocked full wallets). Returns success. function attemptVaultUnlock(password) { let list; try { list = decryptVault(loadVaultBlob(), password); } catch { return false; } vaultPassword = password; // Start from the durable directory (watch-only views of every known wallet), // then upgrade the vault-saved ones to spendable — so non-saved wallets still // appear (watch-only) instead of vanishing. accounts = loadWatchAccounts(); mergeVaultList(list); // Now that we have the password, finish signing out any saved wallets whose // timer expired while we were away (couldn't re-encrypt the vault until now). if (_bootAwayMs) { const due = accounts.filter((a) => accAutoLock(a) > 0 && _bootAwayMs >= accAutoLock(a)); for (const a of due) { wipeAccountCache(a); accounts = accounts.filter((x) => x.id !== a.id); } if (due.some((a) => a.persisted)) writeVault(); _bootAwayMs = 0; } return true; } // "Unlock for": a session deadline chosen at the password prompt. Past it the // app relocks (back to this prompt) — wallets stay saved, nothing is wiped. const UNLOCK_FOR_KEY = 'btc-wallet-unlock-for'; const UNLOCK_UNTIL_KEY = 'btc-wallet-unlock-until'; const UNLOCK_FOR_OPTIONS = [ { ms: 300_000, label: 'unlockFor5m' }, { ms: 1_800_000, label: 'unlockFor30m' }, { ms: 86_400_000, label: 'unlockFor1d' }, { ms: 0, label: 'unlockForever' }, ]; let _unlockUntilTimer = null; function armUnlockDeadline() { clearTimeout(_unlockUntilTimer); _unlockUntilTimer = null; let until = 0; try { until = Number(localStorage.getItem(UNLOCK_UNTIL_KEY)) || 0; } catch {} if (!until) return; const left = until - Date.now(); if (left <= 0) { try { localStorage.removeItem(UNLOCK_UNTIL_KEY); } catch {} if (activeAccount() && !wallet.watchOnly) softLock(); return; } _unlockUntilTimer = setTimeout(armUnlockDeadline, Math.min(left, 60_000)); } function unlockDeadlinePassed() { try { const u = Number(localStorage.getItem(UNLOCK_UNTIL_KEY)) || 0; return u > 0 && Date.now() >= u; } catch { return false; } } // On-open vault unlock (password prompt). function unlockVault() { if (!attemptVaultUnlock(ui.vaultPw)) { ui.vaultError = t('pwWrong'); render(); return; } ui.vaultPw = ''; ui.vaultError = ''; ui.justLocked = false; const ms = ui.unlockFor != null ? ui.unlockFor : Number(localStorage.getItem(UNLOCK_FOR_KEY)) || 0; try { localStorage.setItem(UNLOCK_FOR_KEY, String(ms)); if (ms > 0) localStorage.setItem(UNLOCK_UNTIL_KEY, String(Date.now() + ms)); else localStorage.removeItem(UNLOCK_UNTIL_KEY); } catch {} armUnlockDeadline(); const cur = accounts.find((a) => a.id === activeId) || accounts[0]; if (cur) activateAccount(cur, { fresh: true }); else lock(); // everything got signed out by the timer } function skipVault() { ui.vaultPw = ''; ui.vaultError = ''; const watch = loadWatchAccounts(); if (watch.length) { accounts = watch.slice(); activateAccount(accounts[0], { fresh: true }); } else { ui.screen = 'unlock'; render(); } } function enterOfflineFallback() { wallet.setOffline(true); wallet.deriveWindow(40); ui.offlineFallback = true; ui.claimChecking = false; // can't verify a gift offline; don't hang on the loader ui.tab = 'settings'; render(); } async function retryOnline() { ui.offlineFallback = false; wallet.setOffline(false); ui.tab = 'history'; render(); try { await wallet.scan(); wallet.startRealtime(); } catch { enterOfflineFallback(); } } // --- auto log-out, per wallet ---------------------------------------------- // Each account can choose how long the app may sit in the background before that // wallet is signed out (removed from this device — session, directory, cache, // and its vault entry). Other wallets are untouched. The countdown only runs // while the app is hidden/unfocused, never while you're looking at it. 0 = never. const AUTOLOCK_OPTIONS = [ { ms: 0, label: 'autolockNever' }, { ms: 60_000, label: 'autolock1m' }, { ms: 300_000, label: 'autolock5m' }, { ms: 3_600_000, label: 'autolock1h' }, { ms: 86_400_000, label: 'autolock1d' }, ]; const accAutoLock = (a) => (a && Number(a.autoLock)) || 0; const AWAY_AT_KEY = 'btc-wallet-bg-at'; let _awayTimer = null; let _bootAwayMs = 0; // away duration carried into the vault-unlock check this session const _awayAt = () => { try { return Number(localStorage.getItem(AWAY_AT_KEY)) || 0; } catch { return 0; } }; const _clearAwayAt = () => { try { localStorage.removeItem(AWAY_AT_KEY); } catch {} }; // Remove one wallet's cached state — the xpub mirror plus its seed-keyed cache // (when the seed is at hand), including the :ack/:gift suffixes. function wipeAccountCache(acc) { if (!acc) return; const ids = []; if (acc.xpub) ids.push(acc.xpub); if (acc.xprv) ids.push(acc.xprv); else if (acc.mnemonic) ids.push(`${acc.mnemonic}\n${acc.passphrase || ''}`); const bases = ids.map(cacheKeyFor); try { const keys = []; for (let i = 0; i < localStorage.length; i++) { const k = localStorage.key(i); if (k && bases.some((b) => k.startsWith(b))) keys.push(k); } keys.forEach((k) => localStorage.removeItem(k)); } catch {} for (const f of FEATURES) { try { f.wipeCache && f.wipeCache(bases); } catch {} } } // Sign out a single wallet: remove it from the device entirely (session, // directory, cache, and the vault if it was saved). Others are left alone. function wipeAccount(id) { const acc = accounts.find((a) => a.id === id); if (!acc) return; const wasActive = activeId === id; wipeAccountCache(acc); const wasPersisted = acc.persisted; accounts = accounts.filter((a) => a.id !== id); if (wasPersisted && vaultPassword != null) writeVault(); // re-encrypt the vault without it persistAccounts(); // refresh the durable directory if (wasActive) { if (accounts.length) activateAccount(accounts[0], { fresh: true }); else lock(); } } // Sign out every loaded wallet whose timer has elapsed for the given away time. function evaluateOverdue(away) { const due = accounts.filter((a) => accAutoLock(a) > 0 && away >= accAutoLock(a)).map((a) => a.id); for (const id of due) wipeAccount(id); } // Arm a timer for the soonest-due wallet so a blurred-but-visible window still // signs out on time (the timestamp covers throttled/backgrounded tabs). function armAwayTimer() { clearTimeout(_awayTimer); _awayTimer = null; const awayAt = _awayAt(); if (!awayAt) return; const locks = accounts.map(accAutoLock).filter((ms) => ms > 0); if (!locks.length) return; const elapsed = Date.now() - awayAt; if (locks.some((ms) => elapsed >= ms)) { evaluateOverdue(elapsed); armAwayTimer(); return; } const next = Math.min(...locks.map((ms) => ms - elapsed)); _awayTimer = setTimeout(() => { evaluateOverdue(Date.now() - awayAt); armAwayTimer(); }, Math.max(0, next)); } function onAppHidden() { try { localStorage.setItem(AWAY_AT_KEY, String(Date.now())); } catch {} armAwayTimer(); } function onAppVisible() { clearTimeout(_awayTimer); _awayTimer = null; const awayAt = _awayAt(); _clearAwayAt(); if (awayAt) evaluateOverdue(Date.now() - awayAt); armUnlockDeadline(); // background tabs throttle timers — recheck on return // A phone freezes a backgrounded tab: timers stop, relay sockets are cut, // and nothing that happened while you were away ever arrives. Features that // hold live connections get told how long the app was gone so they can // catch up rather than sit on a dead subscription. const away = awayAt ? Date.now() - awayAt : 0; for (const f of FEATURES) { try { f.resumed && f.resumed(away); } catch {} } } // At boot the in-memory timer is gone (reload / discarded tab). Drop overdue // wallets from the persisted session and directory before anything is restored; // overdue saved wallets are removed from the vault when it's unlocked. function applyBootAutoLogout() { const awayAt = _awayAt(); _clearAwayAt(); if (!awayAt) return; const away = Date.now() - awayAt; _bootAwayMs = away; const overdue = (a) => { const ms = Number(a.autoLock) || 0; return ms > 0 && away >= ms; }; try { const sess = JSON.parse(sessionStorage.getItem(ACCOUNTS_KEY) || 'null'); if (sess && Array.isArray(sess.accounts)) { const keep = sess.accounts.filter((a) => { if (overdue(a)) { wipeAccountCache(a); return false; } return true; }); sess.accounts = keep; if (!keep.find((a) => a.id === sess.activeId)) sess.activeId = keep[0] ? keep[0].id : null; sessionStorage.setItem(ACCOUNTS_KEY, JSON.stringify(sess)); } } catch {} try { const dir = JSON.parse(localStorage.getItem(WATCH_KEY) || '[]'); const keep = dir.filter((d) => { if (overdue(d)) { wipeAccountCache(d); return false; } return true; }); localStorage.setItem(WATCH_KEY, JSON.stringify(keep)); } catch {} } function lock({ offerPassword = false } = {}) { wallet.stopRealtime(); // A locked session must not keep listening as the account that just left — // and whatever logs in next must not inherit its threads or subscriptions. _featuresInited = false; for (const f of FEATURES) { try { f.stop && f.stop(); } catch {} } clearAccounts(); vaultPassword = null; wallet.load({ mnemonic: '', passphrase: '', netName: getNetwork(), offline: false }); wallet.mnemonic = ''; // Wallets save themselves to this device without asking, under an empty // password unless one is set. Demanding a password on the way out is then // theatre — it protects nothing and asks for something nobody chose. With no // password, logging out means dropping back to the wallet list; with one, it // means what it says. const noPassword = hasVault() && attemptVaultUnlock(''); ui.screen = !hasVault() ? 'unlock' : noPassword ? 'accounts' : 'vault'; ui.unlockTab = 'create'; ui.fromWallet = false; ui.watchXpub = ''; ui.watchLabel = ''; ui.pw = null; clearFeatureNav(); // a profile/chat from the locked session must not outlive it ui.vaultPw = ''; ui.vaultError = ''; ui.confirmClear = false; ui.confirmRemove = null; ui.editId = null; ui.editLabel = ''; ui.createStep = 'gen'; ui.draftMnemonic = ''; ui.importText = ''; ui.entropyPage = null; ui.entropyEntry = ''; ui.ownEntropy = ''; ui.importEntropy = ''; ui.passphrase = ''; ui.confirmPass = ''; ui.confirm = []; ui.revealShown = false; ui.pubkeyShown = false; ui.giftMode = false; ui.giftAmount = ''; ui.giftCode = null; ui.giftError = ''; ui.giftMax = false; ui.giftSplitOffer = null; ui.revokeId = null; ui.receiveSeenIndex = null; ui.txDetail = null; ui.broadcastTx = null; ui.bump = null; // Offer to set a password on the way out — but once, ever: someone who said // "Not now" has answered, and a wallet that nags is a wallet people stop // reading. (The padlock is different: it asks every time via softLock's // in-place prompt, because locking without a password is impossible and // the tap would otherwise be a confusing trip to the wallet list.) if (noPassword && offerPassword && !lockPwAsked()) { ui.pw = { purpose: 'lock', mode: 'set', v0: '', v1: '', v2: '', error: '' }; } render(); } // ================================================================ WALLET // The avatar IS your profile now — one tap opens it (edit lives there). // Settings and Lock stand beside it as their own icons; no popup menu. 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. const node = (me && featureHook('headerAvatar', me)) || h('span', { class: 'chat-avatar header-ava fallback', html: '', }); return h('button', { class: 'header-avatar', title: me ? t('profYourProfile') : undefined, onClick: me ? () => { featureHook('showProfile', me); render(); } : undefined, }, node); } 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; ui.screen = 'wallet'; ui.tab = 'settings'; ui.settingsPage = null; render(); }, }, h('span', { class: 'hm-ico', html: '', })); } // Lock in place: the keys leave, the view stays. The wallet remains on screen // watch-only (balances and history, no spending), the padlock closes, and // tapping it again asks for the password to bring the keys back. function softLock() { const watch = loadWatchAccounts(); let blankPw = false; try { decryptVault(loadVaultBlob(), ''); blankPw = true; } catch {} // No protecting password yet: locking would protect nothing, so ask for one // right here — keys intact, home still on screen. Submitting saves the // vault under it and locks in place; "Not now" just closes the prompt. // (Tearing the session down first meant "Not now" stranded people on the // wallet list, signed out of a wallet they never asked to leave.) if (!hasVault() || blankPw) { ui.pw = { purpose: 'lock', mode: 'set', v0: '', v1: '', v2: '', error: '' }; render(); return; } // Nothing watchable: this session can't stay on screen — classic full lock. if (!watch.length) { lock({ offerPassword: true }); ui.justLocked = true; return; } // A wallet that isn't IN the vault can't come back from a lock — unlocking // decrypts the vault, and this seed isn't there (created while the vault // sat locked, so autoSave couldn't write it). Save it first: with the // session password if we hold it, else ask for the vault password now. const active = activeAccount(); if (active && active.type === 'full' && !active.provisional && !active.persisted) { if (vaultPassword != null) { active.persisted = true; writeVault(); persistAccounts(); } else { ui.pw = { purpose: 'locksave', accId: active.id, mode: 'enter', v1: '', v2: '', error: '' }; ui.screen = 'accounts'; // the pw prompt renders on the accounts screen render(); return; } } const keepId = activeId; // The lock is going through: leave whatever page the padlock was tapped // from, so what you land on is the wallet wearing a closed padlock — from // a profile page the toast was the only sign anything had happened. clearFeatureNav(); _featuresInited = false; for (const f of FEATURES) { try { f.stop && f.stop(); } catch {} } vaultPassword = null; accounts = watch.slice(); persistAccounts(); const cur = accounts.find((a) => a.id === keepId) || accounts[0]; activateAccount(cur, { fresh: true }); toast(t('lockedToast')); } function lockBtn() { const locked = wallet.watchOnly && hasVault(); return h('button', { class: 'header-msgs', title: locked ? t('unlock') : t('lockWallet'), 'aria-label': locked ? t('unlock') : t('lockWallet'), onClick: locked ? () => { if (attemptVaultUnlock('')) { const cur = accounts.find((a) => a.id === activeId) || accounts[0]; if (cur) activateAccount(cur, { fresh: true }); } else { ui.justLocked = false; ui.screen = 'vault'; render(); } } : () => softLock(), }, h('span', { class: 'hm-ico', style: 'font-size:18px;line-height:1' }, locked ? '\u{1F512}' : '\u{1F513}')); } // Messages sit one tap away, left of the wallet selector. The dot is presence, // not a count — it says "someone's waiting", and the chat list says who. function messagesBtn() { const me = (featureHook('nostrLoginIdentity') || {}).pubkey || (wallet.nostrPubkey && wallet.nostrPubkey()); if (!me) return null; const unread = featureHook('unreadMessages') || 0; return h('button', { 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(); }, }, h('span', { class: 'hm-ico', html: '', })); } // Find anyone on nostr from the header — results open their profile. function searchBtn() { if (!featureHook('userSearchAvailable')) return null; return h('button', { class: 'header-msgs', title: t('searchUsers'), 'aria-label': t('searchUsers'), onClick: () => { featureHook('openUserSearch'); render(); setTimeout(() => document.querySelector('.user-search-input')?.focus(), 60); }, }, h('span', { class: 'hm-ico', html: '', })); } function brandHeader(withLock) { const acc = activeAccount(); return h( 'div', { class: 'row between' }, h( 'div', { class: 'brand' + (STAGING ? ' staging' : ' beta'), style: 'cursor:pointer', title: t('home'), onClick: goHome }, STAGING ? h('span', { class: 'staging-badge', title: 'staging build — new wallets start on Mutinynet' }, 'staging') : h('span', { class: 'beta-badge' }, 'v3 beta'), h('div', { class: 'logo-full', 'aria-label': 'coinos', role: 'img', html: '' }) ), withLock ? h('div', { class: 'row gap6', style: 'align-items:center' }, searchBtn(), messagesBtn(), settingsBtn(), lockBtn(), avatarMenu()) : null ); } // Settings tab — view the recovery phrase (+ passphrase) again (important for // users who skipped backup verification) and the offline snapshot transfer. // The phrase is gated: the real words are never put in the DOM until "Reveal", // so the warning is read first. // Rescan a single address on demand — recovers a deposit to a reused old // address without a full wallet rescan. Multiple can be queued at once; the // API layer's global scheduler serializes and spaces the underlying requests // (and backs off on 429), so a flurry of clicks stays within the rate limit. async function doRescanAddress(chain, index) { if (wallet.offline) { toast(t('scanOffline')); return; } const id = chain + '/' + index; if (ui.rescanning.has(id)) return; // already queued ui.rescanning.add(id); render(); try { await wallet.rescanAddress(chain, index); } catch (e) { toast(e.message || t('rescanFailed')); } ui.rescanning.delete(id); render(); } // Paginated list of every known address, each with its own rescan button. function addressScanView() { const addrs = wallet.knownAddresses(); const pages = Math.ceil(addrs.length / PAGE_SIZE) || 1; const page = Math.min(ui.addrScanPage, pages - 1); const slice = addrs.slice(page * PAGE_SIZE, page * PAGE_SIZE + PAGE_SIZE); return h( 'div', { class: 'col', style: 'gap:16px' }, h('div', { class: 'card col', style: 'gap:12px' }, h('div', { class: 'row between' }, h('h3', { style: 'margin:0' }, t('rescanAddresses')), h('button', { class: 'btn-sm', onClick: () => { ui.addrScan = false; render(); } }, t('back')) ), h('p', { class: 'small muted', style: 'margin:0' }, t('rescanAddrDesc')), h('div', { class: 'list' }, ...slice.map((a) => { const id = a.chain + '/' + a.index; const busy = ui.rescanning.has(id); return h('div', { class: 'item' }, h('div', { class: 'grow' }, h('div', { class: 'mono small break' }, shortAddr(a.address, 16, 10), a.used ? null : h('span', { class: 'badge off dot', style: 'font-size:10px;margin-left:6px;padding:1px 7px' }, t('unusedTag'))), h('div', { class: 'path' }, `${a.chain}/${a.index}` + (a.balance ? ' · ' + fmtAmount(a.balance) + ' ' + unitLabel() : '')) ), busy ? h('button', { class: 'btn-sm', disabled: true }, h('span', { class: 'spinner sm' })) : h('button', { class: 'btn-sm', onClick: () => doRescanAddress(a.chain, a.index) }, t('rescanOne')) ); }) ), pager(page, addrs.length, (p) => { ui.addrScanPage = p; render(); }) ) ); } // Settings is a hub: the main page is nothing but doors, each subpage owns // one concern — Wallet (name/pubkey/seed), Payments (address + Spending), // Network (chain + data source), Nostr, Advanced (rarities). function settingsTab() { if (ui.addrScan && !wallet.offline) return addressScanView(); const a = activeAccount(); const page = (kids) => h('div', { class: 'col', style: 'gap:16px' }, ...kids, h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.settingsPage = null; render(); } }, t('back'))); switch (ui.settingsPage) { // the recovery phrase first: it is the one thing on this page that // matters if the phone is lost tonight case 'wallet': return page(a ? [recoveryCard(a), walletNameCard(a), pubkeyCard(a)] : []); case 'payments': return page(featureAll('settingsCards').reverse()); case 'network': return page([networkCard(), explorerCard()]); case 'nostr': return nostrSettingsView(); case 'notifications': return page(featureAll('notifySettingsCards').reverse()); case 'advanced': return advancedSettingsView(); } const I = (d) => '' + d + ''; const TILE_ICONS = { wallet: I(''), payments: I(''), network: I(''), nostr: I(''), notifications: I(''), advanced: I(''), }; const tile = (id, label, desc) => h('button', { class: 'card col settings-tile', style: 'align-items:flex-start;gap:6px;text-align:left;padding:14px;cursor:pointer;margin:0', onClick: () => { ui.settingsPage = id; render(); }, }, h('span', { class: 'st-ico', style: 'color:var(--muted)', html: TILE_ICONS[id] || '' }), h('span', { style: 'font-weight:600' }, label), h('span', { class: 'small faint' }, desc)); const grid = (kids) => h('div', { style: 'display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px' }, ...kids); return h( 'div', { class: 'col', style: 'gap:10px' }, h('div', { class: 'row gap6', style: 'align-items:center;margin:2px 0 6px 4px' }, h('span', { style: 'display:flex;color:var(--muted)', html: '' }), h('h3', { style: 'margin:0' }, t('tabSettings'))), grid([ tile('wallet', t('settingsWallet'), t('settingsWalletDesc')), tile('payments', t('settingsPayments'), t('settingsPaymentsDesc')), tile('network', t('settingsNetwork'), t('settingsNetworkDesc')), tile('nostr', t('nostrSettings'), t('settingsNostrDesc')), tile('notifications', t('settingsNotifications'), t('settingsNotifDesc')), tile('advanced', t('advancedSettings'), t('settingsAdvancedDesc')), ]), h('button', { class: 'btn-ghost btn-block', style: 'margin-top:6px', onClick: () => { ui.tab = wallet.offline ? 'settings' : 'history'; render(); } }, t('back')) ); } // Everything nostr — identity, sync, wallet connect — on its own page. function nostrSettingsView() { return h( 'div', { class: 'col', style: 'gap:16px' }, ...featureAll('nostrSettingsCards'), h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.settingsPage = null; render(); } }, t('back')) ); } // Rarely used, easy to fat-finger: offline transfer, address rescan, auto-logout. function advancedSettingsView() { const a = activeAccount(); return h( 'div', { class: 'col', style: 'gap:16px' }, wallet.watchOnly ? null : h( 'div', { class: 'card col' }, h('h3', {}, t('offlineTransfer')), snapshotActions() ), h( 'div', { class: 'card col' }, h('h3', {}, t('rescan')), h('p', { class: 'small muted', style: 'margin:0' }, t('rescanDesc')), wallet.offline ? null : h('button', { onClick: () => { ui.addrScan = true; ui.addrScanPage = 0; render(); } }, t('rescanAddresses')) ), a ? autolockCard(a) : null, h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.settingsPage = null; render(); } }, t('back')) ); } // Move the ACTIVE wallet to another network (and make that the default for // new wallets). Changes addresses/balances entirely, so the account is // restamped and reloaded under it. function changeNetwork(net) { if (net === getNetwork()) return; setNetwork(net); for (const f of FEATURES) { try { f.networkChanged && f.networkChanged(net); } catch {} } const acc = accounts.find((a) => a.id === activeId); if (acc) { acc.network = net; persistAccounts(); activateAccount(acc); } else render(); } // Network selector. Per-network endpoints (Electrum server / block explorer) are // configured in the Data source card below — no duplicate URL fields here. function networkCard() { const net = getNetwork(); return h( 'div', { class: 'card col' }, h('h3', {}, 'Network'), h('p', { class: 'small muted', style: 'margin:0' }, 'Bitcoin network this wallet operates on. Pick its servers under Data source.'), h('select', { onChange: (e) => changeNetwork(e.target.value) }, NETWORKS.map((n) => h('option', { value: n.id, selected: net === n.id }, n.label))) ); } function explorerCard() { const net = getNetwork(); const src = getSource(); const sources = dataSources(net); const pick = (id) => { setSource({ id, url: '' }); render(); // Switching source can swap the whole backend (electrum⇄esplora), so rebuild // + rescan — except an empty custom (wait for the URL). if (id !== 'custom' && !wallet.offline) wallet.reloadExplorer(); }; const typeDesc = t(src.type === 'electrum' ? 'detectedElectrum' : 'detectedEsplora'); return h( 'div', { class: 'card col' }, h('h3', {}, t('dataSource')), h('p', { class: 'small muted', style: 'margin:0' }, t('dataSourceDesc')), h('select', { onChange: (e) => pick(e.target.value) }, sources.map((o) => h('option', { value: o.id, selected: o.id === src.id }, o.id === 'custom' ? o.label : (o.url || o.base).replace(/^\w+:\/\//, '')))), src.id === 'custom' ? h('label', { class: 'field' }, h('span', { class: 'lab' }, t('sourceUrl')), h('input', { type: 'text', class: 'mono-input', placeholder: 'wss://your-node:50004 · https://your-esplora/api', autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', value: src.url || '', onChange: (e) => { const url = e.target.value.trim(); setSource({ id: 'custom', url }); render(); if (!wallet.offline) wallet.reloadExplorer(); }, }), h('div', { class: 'small faint' }, src.url ? typeDesc : t('sourceUrlHint')) ) : h('div', { class: 'small faint' }, typeDesc) ); } // Which money the third tap on an amount shows. The list is whatever the // rates endpoint carries, so it's every currency coinos knows a price for. // Changing it starts the recorded price history again: the samples behind // "worth then" are denominated in the currency they were taken in, and // relabelling them would be a lie. function currencyPicker() { return h('select', { title: t('currencyLabel'), value: getCurrency(), onChange: (e) => { setCurrency(e.target.value); refreshRates({ force: true }).then(render); render(); }, }, currencies().map((c) => h('option', { value: c, selected: c === getCurrency() }, c))); } // Language selector. Changing it persists the choice, flips text direction for // RTL languages, and re-renders the whole app in the new language. function languagePicker() { return h( 'select', { value: getLang(), onChange: async (e) => { const code = e.target.value; setLang(code); await loadLocale(code); // fetch the locale's strings before re-rendering applyDir(); render(); }, }, LANGS.map(([code, name]) => h('option', { value: code, selected: code === getLang() }, name)) ); } // Reflect the active language's writing direction on (rtl for ar/ur). function applyDir() { try { document.documentElement.dir = isRTL() ? 'rtl' : 'ltr'; document.documentElement.lang = getLang(); } catch {} } function goHome() { // Bailing from an unclaimed gift — discard the provisional wallet it generated. const active = accounts.find((a) => a.id === activeId); if (active && active.provisional) { accounts = accounts.filter((a) => a.id !== active.id); persistAccounts(); activeId = accounts[0] ? accounts[0].id : null; } // A wallet is open → home is its Receive page (not the create/import screen), // so the logo is always a way back to your wallet. if (activeAccount()) { ui.screen = 'wallet'; // Home means home: with a wallet already minted, even the wizard yields — // its remaining steps are niceties, not something to be trapped in. if (ui.onb || onbInProgress()) { ui.onb = null; try { localStorage.removeItem(ONB_STEP_KEY); } catch {} } // Clear every takeover a feature or subpage may hold — // chat, profile, ark exit, settings subpages, the address rescan list. ui.chatOpen = false; ui.profilePk = null; ui.profEdit = null; ui.userSearch = null; ui.noteThread = null; ui.zapSetup = null; ui.arkExitPage = null; ui.settingsPage = null; ui.logoutConfirm = null; ui.nostrReconnect = null; ui.addrScan = false; ui.arkMoveOpen = false; ui.nameEditOpen = null; ui.tab = wallet.offline ? 'settings' : 'history'; ui.draft = null; ui.sendResult = null; ui.sendError = ''; ui.fromWallet = false; } else if ((ui.onb && ui.onb.step !== 'welcome') || ui.draftMnemonic || ui.createStep !== 'gen' || ui.importText) { // Mid-create with no wallet minted yet (wizard step, a generated seed on // screen, the confirm quiz, a pasted import): the logo backs out to the // start page this began from — not the vault prompt, which reads as a // dead end when you were busy creating something new. ui.onb = { step: 'welcome' }; try { localStorage.removeItem(ONB_STEP_KEY); } catch {} ui.nostrLoginOpen = false; ui.screen = 'unlock'; ui.unlockTab = 'create'; ui.createStep = 'gen'; ui.draftMnemonic = ''; ui.importText = ''; ui.entropyPage = null; ui.confirm = []; ui.unlockError = ''; } else if (hasVault()) { // Locked with saved wallets: home is the unlock prompt, not the // create-a-new-wallet flow — the logo must never read as "start over". // A passwordless vault isn't locked, though: open it silently and land // home instead of demanding a password nobody set. if (attemptVaultUnlock('') && accounts.length) { activateAccount(accounts[0], { fresh: true }); return; } ui.screen = 'vault'; } else { ui.screen = 'unlock'; ui.unlockTab = 'create'; ui.createStep = 'gen'; ui.draftMnemonic = ''; ui.confirm = []; ui.unlockError = ''; } render(); } // Wallets a gift could be claimed into: full accounts open this session plus the // durable directory (every wallet's xpub, watch-only), deduped by xpub. Claiming // sends the gift to the wallet's address; a watch-only one then needs the seed // re-entered to spend. Empty for a first-time recipient. function claimTargets() { let sess = null; try { sess = JSON.parse(sessionStorage.getItem(ACCOUNTS_KEY) || 'null'); } catch {} const open = ((sess && Array.isArray(sess.accounts)) ? sess.accounts : []).filter((a) => !a.provisional); const seen = new Set(open.map((a) => a.xpub).filter(Boolean)); return [...open, ...loadWatchAccounts().filter((w) => !seen.has(w.xpub))]; } // 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.addWallet) { const aw = ui.addWallet; const fulls = accounts.filter((x) => x.type === 'full' && x.mnemonic); return h('div', { class: 'col', style: 'gap:16px' }, brandHeader(false), h('div', { class: 'card col', style: 'gap:12px' }, h('h3', {}, t('addWallet')), h('div', { class: 'seg', style: 'display:flex;width:100%' }, ['spending', 'savings'].map((k) => h('button', { type: 'button', class: (aw.kind === k ? 'active ' : '') + 'grow', onClick: () => { aw.kind = k; render(); }, }, k === 'spending' ? t('spendingName') : t('savingsName')))), h('div', { class: 'col gap6' }, h('span', { class: 'small muted' }, t('addWalletSeed')), h('select', { onChange: (e) => { aw.from = e.target.value; render(); } }, h('option', { value: 'new', selected: aw.from === 'new' }, t('addWalletNewSeed')), fulls.map((x) => h('option', { value: x.id, selected: aw.from === x.id }, t('addWalletShareSeed', { name: seedName(x) }))))), aw.from !== 'new' ? h('div', { class: 'small faint' }, t('addWalletShareNote')) : null, h('div', { class: 'row gap6' }, h('button', { class: 'btn-ghost grow', onClick: () => { ui.addWallet = null; render(); } }, t('back')), h('button', { class: 'btn-primary grow', onClick: () => { const kind = aw.kind; if (aw.from === 'new') { ui.newWalletKind = kind; ui.addWallet = null; ui.draftMnemonic = ''; ui.createStep = 'gen'; ui.confirm = []; ui.passphrase = ''; ui.confirmPass = ''; ui.showPass = false; ui.screen = 'unlock'; ui.unlockTab = 'create'; ui.fromWallet = true; ui.unlockError = ''; render(); } else { createDerivedWallet(aw.from, kind); } } }, t('addWalletGo'))))); } if (ui.confirmClear) { return h('div', { class: 'col', style: 'gap:16px' }, brandHeader(false), h('div', { class: 'card col' }, h('h3', {}, t('clearAll')), h('div', { class: 'warn-box' }, t('clearAllWarn')), h('div', { class: 'row gap6' }, h('button', { class: 'btn-ghost grow', onClick: () => { ui.confirmClear = false; render(); } }, t('back')), h('button', { class: 'btn-primary grow', onClick: clearAll }, t('clearAll')) ) ) ); } if (ui.confirmRemove) { const acc = accounts.find((a) => a.id === ui.confirmRemove); return h('div', { class: 'col', style: 'gap:16px' }, brandHeader(false), h('div', { class: 'card col' }, h('h3', {}, t('removeWalletTitle')), h('div', { class: 'warn-box' }, t('removeWalletWarn', { name: acc ? seedName(acc) : '' })), h('div', { class: 'row gap6' }, h('button', { class: 'btn-ghost grow', onClick: () => { ui.confirmRemove = null; render(); } }, t('back')), h('button', { class: 'btn-primary grow', onClick: () => { const id = ui.confirmRemove; ui.confirmRemove = null; removeAccount(id); } }, t('remove')) ) ) ); } return h( 'div', { class: 'col', style: 'gap:16px' }, // 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), h('div', { class: 'card col' }, h('h3', {}, t('accounts')), h('div', { class: 'col', style: 'gap:0' }, // Grouped by whose they are. Three signed-in identities used to give // three rows all called "Savings", indistinguishable from each other // and from duplicates — the wallets are per-identity, so the screen // has to say which identity. ...groupAccounts().flatMap(({ pk, rows }) => [ identityHeading(pk), ...rows.flatMap((a) => viewsOf(a).map((view) => { const isActive = a.id === activeId && accountSel() === view; const netTag = a.network && a.network !== 'mainnet' ? ' · ' + a.network : ''; const seedTag = ''; const tag = (a.type === 'watch' ? ' · ' + t('watchOnlyTag') : '') + netTag + seedTag; if (ui.editId === a.id && ui.editView === view) { return h('div', { class: 'row gap6', style: 'padding:10px 0; border-bottom:1px solid var(--line)' }, h('input', { type: 'text', style: 'flex:1', value: ui.editLabel, autofocus: true, onInput: (e) => (ui.editLabel = e.target.value), onKeyDown: (e) => { if (e.key === 'Enter') renameAccount(a.id); } }), h('button', { class: 'btn-sm', onClick: () => renameAccount(a.id) }, t('save')), h('button', { class: 'btn-sm', onClick: () => { ui.editId = null; render(); } }, t('back')) ); } return h('div', { class: 'col', style: 'gap:4px; padding:10px 0; border-bottom:1px solid var(--line)' }, h('div', { class: 'row between' }, h('button', { class: 'linklike', style: 'text-align:left;flex:1;font-size:15px;' + (isActive ? 'font-weight:600' : ''), onClick: () => switchToView(a.id, view), }, (isActive ? '● ' : '○ ') + viewLabel(a, view) + tag), h('button', { class: 'btn-sm wallet-row-btn', title: t('renameWallet'), onClick: () => { ui.editId = a.id; ui.editView = view; ui.editLabel = viewLabel(a, view); render(); } }, '✎'), h('button', { class: 'btn-sm wallet-row-btn', title: t('walletSettings'), onClick: () => openAccountSettings(a.id), html: '' }), h('button', { class: 'btn-sm wallet-row-btn', title: t('remove'), onClick: () => { ui.confirmRemove = a.id; render(); }, html: '' }) ), a.type === 'full' && view === viewsOf(a)[viewsOf(a).length - 1] ? h('button', { class: 'linklike small', style: 'align-self:flex-start', onClick: () => (a.persisted ? startForget(a.id) : startSave(a.id)) }, a.persisted ? t('forgetDevice') : t('saveDevice')) : null ); })), ]) ), h('button', { class: 'btn-block', onClick: () => { ui.addWallet = { kind: 'spending', from: 'new' }; render(); } }, t('addWallet')), 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')) ), h('button', { class: 'btn-ghost btn-block', onClick: () => goBack(() => { ui.screen = 'wallet'; }) }, t('back')) ); } // The accounts on this device, gathered under the identity each belongs to, // in the order they first appear. function groupAccounts() { const out = []; const byPk = new Map(); for (const a of accounts) { const pk = identityPkOf(a) || 'none'; if (!byPk.has(pk)) { const g = { pk: pk === 'none' ? null : pk, rows: [] }; byPk.set(pk, g); out.push(g); } byPk.get(pk).rows.push(a); } return out; } // Who a group of wallets belongs to: their face and name, and a mark when // it's the identity you're currently using. One identity on the device needs // no heading at all — there's nothing to tell apart. function identityHeading(pk) { if (!pk || groupAccounts().length < 2) return null; const me = ctx.shownPubkey && ctx.shownPubkey(); const chip = featureHook('profileChip', pk); return h('div', { class: 'row gap6', style: 'align-items:center;padding:10px 0 4px;border-bottom:1px solid var(--line)', }, chip || h('span', { class: 'small muted' }, (npubOf(pk) || pk).slice(0, 14) + '…'), pk === me ? h('span', { class: 'tag conf', style: 'margin-left:auto' }, t('accountsThisOne')) : null); } function renameAccount(id) { const acc = accounts.find((a) => a.id === id); if (acc) { const v = (ui.editLabel || '').trim(); if (v) { // names belong to the VIEW now — Spending and Savings rename separately acc.viewLabels = { ...(acc.viewLabels || {}), [ui.editView || accountSel()]: v }; persistAccounts(); if (acc.persisted) writeVault(); } } ui.editId = null; render(); } // Open the per-wallet settings page for an account. function openAccountSettings(id) { ui.settingsId = id; ui.editLabel = null; ui.revealShown = false; ui.pubkeyShown = false; ui.loadSeed = null; ui.screen = 'accountSettings'; render(); } // Per-wallet settings cards, shared by the per-account screen (Accounts → ⚙) // and the main Settings tab (which inlines them for the active wallet). function recoveryCard(a) { const isWatch = a.type === 'watch' || (!a.mnemonic && !a.xprv); const shown = ui.revealShown; const words = a.mnemonic ? a.mnemonic.split(' ') : []; if (isWatch) { return ui.loadSeed && ui.loadSeed.accId === a.id ? loadSeedCard() : h('div', { class: 'card col' }, h('h3', {}, t('recoveryPhrase')), h('p', { class: 'small muted', style: 'margin:0' }, t('watchOnlyNote')), a.xpub ? h('button', { class: 'btn-primary btn-block', style: 'margin-top:4px', onClick: () => startLoadSeed({ accId: a.id }) }, t('loadSeedBtn')) : null ); } if (!a.mnemonic) return h('div', { class: 'card col' }, h('h3', {}, t('importedKey')), h('p', { class: 'small muted', style: 'margin:0' }, t('importedKeyNote'))); // Two steps on purpose. The first opens the card, with the words masked — // so the grid is on screen and you can see how much of it there is (and get // a warning) before anything readable is over your shoulder. The second // actually shows them. if (!shown) return h('div', { class: 'card col' }, h('h3', {}, t('recoveryPhrase')), h('p', { class: 'small muted', style: 'margin:0' }, t('recoveryDesc')), h('button', { class: 'btn-block', onClick: () => { ui.revealShown = 'masked'; render(); } }, t('revealRecovery'))); const unmasked = shown === 'words'; return h('div', { class: 'card col' }, h('h3', {}, t('recoveryPhrase')), h('div', { class: 'warn-box' }, t('recoveryWarn')), h('div', { class: 'words' }, words.map((w, i) => h('div', { class: 'w' + (unmasked ? '' : ' masked') }, h('span', { class: 'n' }, i + 1), h('span', { class: 't' }, unmasked ? w : '••••••')))), unmasked && a.passphrase ? h('div', { class: 'col gap6' }, h('span', { class: 'lab' }, t('bip39Passphrase')), h('div', { class: 'addr-box' }, a.passphrase)) : null, unmasked ? h('div', { class: 'row gap6 wrap' }, copyBtn(a.mnemonic, t('copyPhrase')), a.passphrase ? copyBtn(a.passphrase, t('copyPassphrase')) : null, h('button', { class: 'btn-sm grow', onClick: () => { ui.revealShown = false; render(); } }, t('hide'))) : h('div', { class: 'row gap6' }, h('button', { class: 'btn-primary grow', onClick: () => { ui.revealShown = 'words'; // seeing it here counts: no need to be asked again elsewhere if (!a.seedSeen) { a.seedSeen = true; persistAccounts(); if (a.persisted) writeVault(); } render(); } }, t('revealWords')), // the same size as its neighbour: two halves of one row, not a // big button with a small one tucked beside it h('button', { class: 'btn-ghost grow', onClick: () => { ui.revealShown = false; render(); } }, t('hide'))) ); } function walletNameCard(a) { const saveName = () => { const v = (ui.editLabel || '').trim(); if (v && v !== a.label) { a.label = v; persistAccounts(); if (a.persisted) writeVault(); } ui.editLabel = null; render(); }; return h('div', { class: 'card col' }, h('h3', {}, t('walletName')), h('div', { class: 'row gap6' }, h('input', { type: 'text', style: 'flex:1', value: ui.editLabel != null ? ui.editLabel : a.label, onInput: (e) => { ui.editLabel = e.target.value; }, onKeyDown: (e) => { if (e.key === 'Enter') saveName(); } }), h('button', { class: 'btn-sm', onClick: saveName }, t('save')) ) ); } function pubkeyCard(a) { let zpub = ''; try { if (a.xpub) zpub = xpubToZpub(a.xpub); } catch {} return h('div', { class: 'card col' }, h('h3', {}, t('publicKey')), h('p', { class: 'small muted', style: 'margin:0' }, t('publicKeyDesc')), ui.pubkeyShown ? h('div', { class: 'col gap6' }, h('div', { class: 'addr-box break', style: 'font-size:12px' }, zpub), h('div', { class: 'row gap6 wrap' }, copyBtn(zpub, t('copyKey')), h('button', { class: 'btn-sm grow', onClick: () => { ui.pubkeyShown = false; render(); } }, t('hide')))) : h('button', { class: 'btn-block', onClick: () => { ui.pubkeyShown = true; render(); } }, t('showPublicKey')) ); } function autolockCard(a) { return h('div', { class: 'card col' }, h('h3', {}, t('autolockTitle')), h('p', { class: 'small muted', style: 'margin:0' }, t('autolockDesc')), h('select', { onChange: (e) => { a.autoLock = Number(e.target.value) || 0; persistAccounts(); if (a.persisted) writeVault(); render(); } }, AUTOLOCK_OPTIONS.map((o) => h('option', { value: String(o.ms), selected: o.ms === accAutoLock(a) }, t(o.label)))) ); } // Per-wallet settings screen — reached from Accounts → ⚙ for any account // (not just the active one; full accounts keep their seed in memory). function accountSettingsScreen() { const a = accounts.find((x) => x.id === ui.settingsId) || activeAccount(); if (!a) { ui.screen = 'accounts'; return accountsScreen(); } return h( 'div', { class: 'col', style: 'gap:16px' }, brandHeader(false), recoveryCard(a), walletNameCard(a), pubkeyCard(a), autolockCard(a), h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.editLabel = null; ui.revealShown = false; ui.pubkeyShown = false; ui.loadSeed = null; goBack(() => { ui.screen = 'accounts'; }); } }, t('back')) ); } // Wipe every wallet from this device: session accounts, the encrypted vault, // and saved watch-only accounts. Unbacked-up seeds are unrecoverable after this. function clearAll() { // Forget means forget: cached balances, history and feature state go with // the seeds (best effort — a still-locked vault's mnemonic-keyed caches // can't be found without its password; the xpub-keyed ones still match). for (const a of [...accounts, ...loadWatchAccounts()]) wipeAccountCache(a); try { localStorage.removeItem(VAULT_KEY); } catch {} try { localStorage.removeItem(WATCH_KEY); } catch {} ui.confirmClear = false; lock(); featureHook('forgetAll'); // discard encrypted sync recovery copies too } function pwPromptCard() { const p = ui.pw; const change = p.purpose === 'change'; const atLock = p.purpose === 'lock'; const newish = change || p.mode === 'set'; // entering a (new) password with confirm const atLockSave = p.purpose === 'locksave'; // locking, but the vault needs its password first return h('div', { class: 'card col' }, h('h3', {}, atLock ? t('lockPwTitle') : atLockSave ? t('lockWallet') : change ? t('changePassword') : p.mode === 'set' ? t('setPassword') : t('enterPassword')), h('p', { class: 'small muted', style: 'margin:0' }, atLock ? t('lockPwDesc') : change ? t('changePasswordDesc') : atLockSave ? t('lockSaveDesc') : p.mode === 'set' ? t('setPasswordDesc') : t('enterPasswordDesc')), change ? h('input', { type: 'password', placeholder: t('currentPassword'), value: p.v0, onInput: (e) => (p.v0 = e.target.value) }) : null, h('input', { type: 'password', placeholder: newish ? t('passwordOptional') : t('password'), value: p.v1, onInput: (e) => (p.v1 = e.target.value) }), newish ? h('input', { type: 'password', placeholder: t('confirmPassword'), value: p.v2, onInput: (e) => (p.v2 = e.target.value) }) : null, p.error && h('div', { class: 'notice err' }, p.error), h('div', { class: 'row gap6' }, h('button', { class: 'btn-ghost grow', onClick: cancelPw }, atLock ? t('lockPwSkip') : t('back')), h('button', { class: 'btn-primary grow', onClick: submitPw }, newish ? t('save') : atLockSave ? t('lockAction') : t('unlock')) ) ); } function vaultScreen() { return h( 'div', { class: 'col', style: 'gap:16px' }, brandHeader(false), h('div', { class: 'card col' }, h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { style: 'font-size:20px;line-height:1' }, '\u{1F512}'), h('h3', { style: 'margin:0' }, ui.justLocked ? t('lockedTitle') : t('unlockSaved'))), h('p', { class: 'small muted', style: 'margin:0' }, ui.justLocked ? t('lockedDesc') : t('unlockSavedDesc')), h('input', { type: 'password', placeholder: t('password'), value: ui.vaultPw, onInput: (e) => (ui.vaultPw = e.target.value), onKeyDown: (e) => { if (e.key === 'Enter') unlockVault(); } }), h('div', { class: 'row between', style: 'align-items:center;gap:10px' }, h('span', { class: 'small muted', style: 'white-space:nowrap;flex-shrink:0' }, t('unlockForLabel')), h('select', { onChange: (e) => { ui.unlockFor = Number(e.target.value) || 0; } }, UNLOCK_FOR_OPTIONS.map((o) => h('option', { value: String(o.ms), selected: (ui.unlockFor != null ? ui.unlockFor : Number(localStorage.getItem(UNLOCK_FOR_KEY)) || 0) === o.ms, }, t(o.label))))), ui.vaultError && h('div', { class: 'notice err' }, ui.vaultError), h('button', { class: 'btn-primary btn-block', onClick: unlockVault }, t('unlock')), h('button', { class: 'btn-ghost btn-block', onClick: skipVault }, loadWatchAccounts().length ? t('viewWithoutPassword') : t('useAnotherWallet')) ) ); } // ================================================================ onboarding // First visit on a fresh device: a full-screen wizard — welcome, then either // create/import a seed or sign in with nostr — that ends the moment a wallet // exists, dropping the user into a plain on-chain wallet. Spending is opt-in: // the same wizard machinery serves the "set up a spending account" flow // (username, avatar, confetti), but only when the user asks for it from the // balance card. Shown whenever the device has no wallet — deleting every // wallet brings it back. The current step is persisted so a refresh (or the // legacy-migration round trip) resumes exactly where it left off. const ONB_STEP_KEY = 'btc-wallet-onb-step'; function onbInProgress() { try { return !!localStorage.getItem(ONB_STEP_KEY); } catch { return false; } } function shouldOnboard() { return accounts.length === 0 && !hasVault(); } const PUNK_PICKS = [7, 14, 21, 3, 33, 40, 47, 36, 61, 26, 12, 50]; // The value the wizard publishes is always the full-size art — that URL // becomes this person's picture on every nostr client. The tiles in the // picker are 56px, so they load the small copies. const punkImg = (n) => `punks/${n}.webp`; const punkTile = (n) => `punks-sm/${n}.webp`; async function onbUpload(file) { const fd = new FormData(); fd.append('file', file); const endpoint = 'https://nostr.build/api/v2/upload/files'; // nostr.build no longer takes anonymous uploads — sign with the wallet's // nostr key, the one key that's always available without a signer prompt. const headers = {}; try { if (wallet.nostrPubkey && wallet.nostrPubkey()) { headers.authorization = await nip98Header({ signEvent: (e) => wallet.nostrSign(e) }, endpoint, 'POST'); } } catch {} const r = await fetch(endpoint, { method: 'POST', body: fd, headers }); const j = await r.json(); const url = j?.data?.[0]?.url; if (!url) throw new Error(t('onbUploadFailed')); return url; } function onboardScreen() { if (!ui.onb) { let saved = null; try { saved = localStorage.getItem(ONB_STEP_KEY); } catch {} // a saved step past wallet creation only makes sense if the wallet is here if (saved && !['welcome', 'signin', 'seed'].includes(saved) && !activeAccount()) saved = 'welcome'; // 'legacy-went' survives the coinos.io round trip: the user left for the // migrate page, so coming back must offer "I'm done, continue" — the // in-memory flag died with the navigation and left them looping. ui.onb = saved === 'legacy-went' ? { step: 'legacy', wentToLegacy: true } : { step: saved || 'welcome' }; if (saved === 'signin') ui.nostrLoginOpen = true; // reopen the login card } const o = ui.onb; // The wizard's whole job ends the moment a wallet exists: hand the user // their on-chain wallet, receive page up. A nostr login in flight holds // this: the wallet opens mid-login, but whether the wizard should even // continue (a restored account has onboarded elsewhere) isn't known until // the login finishes. if ((o.step === 'welcome' || o.step === 'seed' || o.step === 'signin') && ui.screen === 'wallet' && activeAccount() && !ui.nostrLoginBusy) { // A wallet that arrived by SIGN-IN has a recovery phrase its owner has // never seen. Show it once, here, while it still reads as part of // setting up — rather than leaving it buried in settings until the day // they lose the Google account and find out it was never a backup. // Handled further down, where page()/title() exist. const arrived = activeAccount(); if (arrived && arrived.mnemonic && !arrived.seedSeen) o.step = 'backup'; else { ui.onb = null; try { localStorage.removeItem(ONB_STEP_KEY); } catch {} // On-chain first: the wizard's promise is "your keys, your coins", and // Spending stays out of sight until the user sets it up. ui.account = 'savings'; try { localStorage.setItem(ACCOUNT_KEY, 'savings'); } catch {} ui.tab = 'history'; return null; // the caller falls through to the wallet itself } } if (o.step === 'spend') { const addr = featureHook('namesAddress'); if (o.enterAddr === undefined) o.enterAddr = addr || null; // Only a real (custom) name advances by itself — the background // auto-claim of the npub-shaped default doesn't count as setting up. if (addr && addr !== o.enterAddr && !/^npub1/.test(addr)) { // The avatar ask is for brand-new profiles. An identity that already // wears a face (a nostr login with a published picture) skips straight // past the punk picker — restyling a real profile helps nobody. const me = (featureHook('nostrLoginIdentity') || {}).pubkey || (wallet.nostrPubkey && wallet.nostrPubkey()); const prof = me ? featureHook('cachedProfile', me) : null; o.step = prof && prof.picture ? nextAfterAvatar() : 'avatar'; } } try { localStorage.setItem(ONB_STEP_KEY, o.step === 'legacy' && o.wentToLegacy ? 'legacy-went' : o.step); } catch {} const page = (kids) => h('div', { class: 'col onb', style: 'gap:20px' }, ...kids); const title = (txt) => h('h2', { class: 'onb-title' }, txt); // After the identity niceties: offer a first top-up when Savings can fund // one and Spending is empty-handed — a spending account with nothing in it // is a demo, not a wallet. // A function declaration, not a const: the spend step above calls this // while advancing past the avatar ask, i.e. BEFORE this line runs — a // const there is in its temporal dead zone and threw "Cannot access // before initialization", crashing onboarding right after a name claim or // a coinos.io migration landed (a profile with a picture took this path). function nextAfterAvatar() { return (featureHook('arkReady') && wallet.spendable > 1000 && (featureHook('spendingSat') || 0) + (featureHook('spendingBoardingSat') || 0) < 1000) ? 'board' : 'success'; } if (o.step === 'legacy') { // The migrate link carries this wallet's payment address, which the // background claim may still be minting. Don't make the user watch that: // show the finished page and wait (on the button) only if they tap // before it lands. const goLegacy = async () => { let addr = featureHook('namesAddress'); if (!addr) { o.legacyBusy = true; render(); for (let i = 0; i < 40 && !addr; i++) { await new Promise((r) => setTimeout(r, 500)); addr = featureHook('namesAddress'); } o.legacyBusy = false; if (!addr) { ui.onbError = t('onbLegacyNoAddr'); render(); return; } } o.wentToLegacy = true; // persist NOW — location.href never comes back through a render try { localStorage.setItem(ONB_STEP_KEY, 'legacy-went'); } catch {} location.href = `https://coinos.io/migrate?to=${encodeURIComponent(addr)}&back=${encodeURIComponent(location.origin + '/')}`; }; // A migrated name mid-claim (the ?migrated= return) paints as progress // here, not as the pristine ask the user just finished answering. const claiming = ui.migrating || pendingMigratedName(); return page([ title(t('onbLegacyTitle')), h('p', { class: 'muted', style: 'margin:0' }, claiming ? t('migrateWorking', { name: claiming }) : t('onbLegacyBody')), claiming ? h('div', { class: 'row gap6', style: 'justify-content:center;padding:8px 0' }, h('span', { class: 'spinner' })) : h('button', { class: 'btn-primary btn-block', style: 'padding:14px', disabled: !!o.legacyBusy, onClick: goLegacy, }, o.legacyBusy ? h('span', { class: 'spinner sm' }) : t('onbLegacyGo')), h('button', { class: 'btn-block', style: 'padding:14px', onClick: () => { o.step = 'spend'; render(); } }, o.wentToLegacy || claiming ? t('onbLegacyDone') : t('back')), ui.onbError ? h('div', { class: 'notice err' }, ui.onbError) : null, ]); } if (o.step === 'welcome') { return page([ h('div', { class: 'onb-hero' }, brandHeader(false)), title(t('onbWelcomeTitle')), // What this wallet IS, before the buttons. Four equally weighted // buttons with only the sign-in ones saying what they do sent people // straight past the seed phrase — which is the wallet — into a login // that is only a convenience wrapped around it. h('p', { class: 'muted', style: 'margin:0' }, t('onbWelcomeBody')), // Making your own keys is the path; a sign-in is the alternative, // below the line. h('button', { // a stable hook for tests, so this button survives its own copy id: 'onb-create', class: 'btn-primary btn-block', style: 'font-size:17px;padding:14px', onClick: () => { ui.unlockTab = 'create'; ui.unlockError = ''; o.step = 'seed'; render(); } }, t('onbStart')), h('p', { class: 'small muted', style: 'margin:-6px 0 0;text-align:center' }, t('onbStartHint')), h('div', { class: 'onb-or' }, h('span', {}, t('onbOrSignIn'))), // All the sign-in doors right on the welcome screen (Google, passkey, // Nostr) — the feature supplies them; the single-button fallback keeps // builds without it working. featureHook('frontDoorSignin') || h('button', { class: 'btn-block', style: 'padding:14px', onClick: () => { o.step = 'signin'; ui.nostrLoginOpen = true; render(); } }, h('span', { style: 'display:flex;align-items:center;justify-content:center;gap:8px' }, h('span', { style: 'display:flex;flex-shrink:0', html: NOSTR_MARK }), t('nlSignIn'))), ui.onbError ? h('div', { class: 'notice err' }, ui.onbError) : null, ]); } if (o.step === 'backup') { const acc = activeAccount(); // the wallet went away (switched, logged out) — nothing to back up if (!acc || !acc.mnemonic) { ui.onb = null; try { localStorage.removeItem(ONB_STEP_KEY); } catch {} return null; } const words = acc.mnemonic.split(' '); const done = () => { acc.seedSeen = true; persistAccounts(); if (acc.persisted) writeVault(); ui.onb = null; try { localStorage.removeItem(ONB_STEP_KEY); } catch {} ui.account = 'savings'; try { localStorage.setItem(ACCOUNT_KEY, 'savings'); } catch {} ui.tab = 'history'; render(); }; return page([ title(t('onbBackupTitle')), h('p', { class: 'muted', style: 'margin:0' }, t('onbBackupBody')), h('div', { class: 'warn-box' }, t('writeDownWarn')), h('div', { class: 'words' }, words.map((w, i) => h('div', { class: 'w' }, h('span', { class: 'n' }, i + 1), h('span', { class: 't' }, w)))), h('div', { class: 'row gap6' }, copyBtn(acc.mnemonic, t('copyPhrase'))), h('button', { class: 'btn-primary btn-block', style: 'padding:14px', onClick: done }, t('onbBackupDone')), // Later means later, not never: seedSeen stays false, so the wallet // keeps a quiet reminder until the phrase has actually been seen. h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.onb = null; try { localStorage.removeItem(ONB_STEP_KEY); } catch {} ui.account = 'savings'; try { localStorage.setItem(ACCOUNT_KEY, 'savings'); } catch {} ui.tab = 'history'; render(); } }, t('onbBackupLater')), ]); } if (o.step === 'seed') { // The classic create/import card, inside the wizard: getting started // means seeing (or bringing) your seed phrase, not a wallet minted // behind your back. Opening one ends the wizard into the wallet + tour. return page([ title(t('onbSeedTitle')), unlockCard(), // the entropy page brings its own Back — one is enough ui.entropyPage ? null : h('button', { class: 'linklike small', onClick: () => { ui.unlockError = ''; o.step = 'welcome'; render(); } }, t('back')), ]); } if (o.step === 'signin') { return page([ h('div', { class: 'onb-mark', html: NOSTR_MARK }), title(t('onbSigninTitle')), featureHook('unlockExtra') || h('div', { class: 'notice err' }, 'nostr login unavailable'), h('button', { class: 'linklike small', onClick: () => { o.step = 'welcome'; render(); } }, t('back')), ]); } if (o.step === 'spend') { return page([ title(t('onbSpendTitle')), h('p', { class: 'muted', style: 'margin:0' }, t('onbSpendBody')), featureHook('namesClaimForm') || h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('onbNameWait'))), // Optional means optional: one tap back to the wallet, no questions. h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.onb = null; try { localStorage.removeItem(ONB_STEP_KEY); } catch {} render(); } }, t('onbNotNow')), ]); } if (o.step === 'avatar') { const me = (featureHook('nostrLoginIdentity') || {}).pubkey || (wallet.nostrPubkey && wallet.nostrPubkey()); const sel = o.avatar; // The big avatar IS the selection: it shows exactly what Continue will // publish — a picked punk, an upload, or the pubkey-derived default. const preview = sel || (me ? punkUrl(me) : null); return page([ title(t('onbAvatarTitle')), h('p', { class: 'muted', style: 'margin:0' }, t('onbAvatarBody')), preview ? h('img', { class: 'onb-avatar', src: preview, alt: '', style: ui.onbBusy ? 'opacity:.5' : '', onError: (e) => { e.target.style.visibility = 'hidden'; }, }) : null, h('div', { class: 'onb-punks', 'data-keep-scroll': 'punks' }, ...PUNK_PICKS.map((n) => h('img', { class: 'onb-punk' + (sel === punkImg(n) ? ' sel' : ''), src: punkTile(n), alt: '', onClick: () => { o.avatar = punkImg(n); render(); }, onError: (e) => { e.target.remove(); }, // a 502'd punk removes itself }))), h('div', { class: 'row gap6' }, h('button', { class: 'btn-block grow', disabled: ui.onbBusy, onClick: () => document.getElementById('onb-file')?.click() }, t('onbUpload')), h('input', { type: 'file', id: 'onb-file', accept: 'image/*', style: 'display:none', onChange: async (e) => { const f = e.target.files && e.target.files[0]; if (!f) return; ui.onbBusy = true; ui.onbError = ''; render(); try { o.avatar = await onbUpload(f); } catch (err) { ui.onbError = err.message; } ui.onbBusy = false; render(); } })), ui.onbError ? h('div', { class: 'notice err' }, ui.onbError) : null, h('button', { class: 'btn-primary btn-block', style: 'padding:14px', disabled: ui.onbBusy, onClick: async () => { ui.onbBusy = true; ui.onbError = ''; render(); try { const addr = featureHook('namesAddress'); // keeping the default still publishes it — their punk should be // their face on every nostr client, not just here. A picked punk is // 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 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. // And it's an OFFER: the address-derived name (and, unless they // actually picked one, the default punk) only fills gaps — it // never renames an identity that already has a profile. if (fields.name || fields.picture) { try { await featureHook('publishProfile', fields, { fillOnly: o.avatar ? ['name'] : ['name', 'picture'] }); } catch (e) { toast(t('onbProfileSkipped')); console.warn('onboarding: profile publish failed —', e.message); } } o.step = nextAfterAvatar(); } catch (e) { ui.onbError = e.message; } ui.onbBusy = false; render(); } }, ui.onbBusy ? h('span', { class: 'spinner sm' }) : (o.avatar ? t('onbContinue') : t('onbSkipAvatar'))), h('button', { class: 'btn-ghost btn-block', disabled: ui.onbBusy, onClick: () => { // re-anchor the spend step's "name changed" detector to the name // as it stands NOW, or a just-claimed name bounces us right back here o.enterAddr = featureHook('namesAddress') || null; o.step = 'spend'; render(); } }, t('back')), ]); } if (o.step === 'board') { return page([ title(t('onbBoardTitle')), h('p', { class: 'muted', style: 'margin:0' }, t('onbBoardBody')), featureHook('arkBoardForm') || h('div', { class: 'row gap6', style: 'align-items:center;justify-content:center' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('arkConnecting'))), h('button', { class: 'btn-primary btn-block', style: 'padding:14px', onClick: () => { o.step = 'success'; render(); } }, t('onbContinue')), ]); } // success return page([ h('div', { class: 'onb-confetti' }, ...Array.from({ length: 18 }, () => h('i'))), h('h2', { class: 'onb-title', style: 'text-align:center' }, t('onbDoneTitle')), h('div', { class: 'check-badge onb-check' }, '✓'), h('p', { class: 'muted', style: 'margin:0;text-align:center' }, t('onbDoneBody')), h('button', { class: 'btn-primary btn-block', style: 'padding:14px', onClick: () => { try { localStorage.removeItem(ONB_STEP_KEY); } catch {} ui.onb = null; // Latch Spending onto the account only when the wizard actually set it // up (a claimed address or funds prove it) — finishing the wizard on a // network without names, or skipping the spend step, must not conjure // a Spending wallet nobody asked for. const acc = activeAccount(); // A name claimed DURING this wizard run (it differs from the address // recorded on entry) is explicit setup on any network — that's the user // opting in, not stale evidence. Otherwise the mainnet-only rule from // spendingActive applies: a stored mainnet name must not vouch for // spending on another network. const addr = featureHook('namesAddress'); const named = !!addr && !/^npub1/.test(addr); const claimedNow = named && addr !== o.enterAddr; const spendReady = claimedNow || (featureHook('spendingSat') || 0) > 0 || (getNetwork() === 'mainnet' && named); if (spendReady) { if (acc && !acc.spendingSetup) { acc.spendingSetup = Date.now(); persistAccounts(); featureHook('spendingEvident'); } ui.account = 'spending'; try { localStorage.setItem(ACCOUNT_KEY, 'spending'); } catch {} } ui.tab = 'history'; render(); } }, t('onbEnter')), h('button', { class: 'btn-ghost btn-block', onClick: () => { o.step = 'avatar'; render(); } }, t('back')), ]); } // Returning from the legacy migration: coinos.io sends ?migrated= // once it has swept the balance and released the name. The param is stashed // SYNCHRONOUSLY at boot (module init below) because the claim itself needs // an open wallet — which can arrive well after any single post-load timer. // The old one-shot ("1.5s after load, if a wallet is active") silently // dropped the whole hand-off whenever the wallet wasn't ready yet, and the // user came back to a migrate page that had no idea they'd just migrated. const MIGRATED_KEY = 'btc-wallet-migrated-name'; try { const _mu = new URL(location.href); const _mn = _mu.searchParams.get('migrated'); if (_mn) { localStorage.setItem(MIGRATED_KEY, _mn); _mu.searchParams.delete('migrated'); history.replaceState(null, '', _mu.pathname + _mu.search + _mu.hash); } } catch {} function pendingMigratedName() { try { return localStorage.getItem(MIGRATED_KEY); } catch { return null; } } function claimMigratedName() { const name = pendingMigratedName(); if (!name || ui.migrating) return; ui.migrating = name; render(); let tries = 0; const attempt = () => { const p = featureHook('namesClaimName', name); if (!p || !p.then) { if (++tries < 12) return setTimeout(attempt, 5000); ui.migrating = null; return; } p.then(async () => { try { localStorage.removeItem(MIGRATED_KEY); } catch {} // Bring the old account's face and cover along — filled into empty // kind-0 slots only. Bounded wait: the wizard's punk-picker skip // checks the cached picture right after this, and losing the race // would ask a migrated user to restyle the profile they already have. await Promise.race([ Promise.resolve(featureHook('adoptLegacyProfile', name)).catch(() => {}), new Promise((r) => setTimeout(r, 5000)), ]); ui.migrating = null; // Mid-onboarding: the migrate step's job is done — move the wizard // along instead of re-offering the page that was just completed. A // null enterAddr lets the spend step see the claimed name as change // and advance on its own. if (ui.onb && ui.onb.step === 'legacy') { ui.onb.step = 'spend'; ui.onb.enterAddr = null; } toast(t('migrateClaimed', { name })); render(); }) .catch(() => { // DNS/registrar caches the legacy lookup briefly; give it a minute if (++tries < 12) return setTimeout(attempt, 5000); try { localStorage.removeItem(MIGRATED_KEY); } catch {} ui.migrating = null; toast(t('migrateClaimFailed', { name })); render(); }); }; attempt(); } function walletScreen() { if (ui.onb || onbInProgress()) { const s = onboardScreen(); if (s) return s; // null = the wizard just handed over; fall through } // A feature can hold the wallet behind a required onboarding step (picking // a username). Imported wallets skip it once their name is recovered. const ob = featureHook('onboardingView'); if (ob) { return h('div', { class: 'col', style: 'gap:0' }, brandHeader(false), h('div', { class: 'mt16' }, ob)); } // Settings is a full page of its own — no balance, no tab strip; the hub's // back button (or the logo / avatar menu) leads out. Offline keeps the // classic layout: settings is the forced tab there, and hiding the balance // and tab strip would wall off the cached balance, history, and receive. if (ui.tab === 'settings' && !wallet.offline) { return h( 'div', { class: 'col', style: 'gap:0' }, brandHeader(true), ui.offlineFallback && wallet.offline ? offlineBanner() : null, h('div', { class: 'mt16' }, tabContent()) ); } // Tab changes slide the content pane sideways like a carousel — swipe // direction when a gesture caused it, index order for taps. The balance // card and tab strip stay planted. const TAB_ORDER = ['history', 'receive', 'send']; if (uiChanged('tabnav', ui.tab)) { const idx = TAB_ORDER.indexOf(ui.tab); // The pane slides only BETWEEN tabs the user has seen: the first // observation after a sign-in (and the handover render itself) is the // wallet simply appearing, not a navigation. if (!ui.navAnimSkip && _tabSeen) { _tabCls = 'anim-tab-' + (_swipeDir || (idx >= 0 && idx < _prevTabIdx ? 'right' : 'left')); _tabAt = performance.now(); } _tabSeen = true; if (idx >= 0) _prevTabIdx = idx; } _swipeDir = null; const pane = h('div', { class: 'tab-pane' }, tabContent()); const tabDt = performance.now() - _tabAt; // skip the pane slide while a full-page entrance is already running if (performance.now() - _navAt > 400) applyAnim(pane, _tabCls, tabDt < 300 ? tabDt : -1); return h( 'div', { class: 'col', style: 'gap:0' }, brandHeader(true), h('div', { class: 'mt16' }, balanceCard()), ui.offlineFallback && wallet.offline ? offlineBanner() : null, backupNotice(), ...featureAll('walletNotices'), tabsBar(), pane ); } // A wallet whose recovery phrase has never been on screen keeps one quiet // line about it. "Later" is a real answer, so this is a row and not a modal — // but it doesn't dismiss, because the risk doesn't go away on its own. It // disappears the moment the phrase is actually seen (here or in settings). function backupNotice() { const a = activeAccount(); if (!a || !a.mnemonic || a.seedSeen || wallet.watchOnly) return null; return h('div', { class: 'small muted', style: 'margin:10px 0 0;text-align:center' }, t('backupReminder'), ' ', h('button', { class: 'linklike small', onClick: () => { ui.tab = 'settings'; ui.settingsPage = 'wallet'; ui.revealShown = 'masked'; render(); } }, t('backupReminderGo'))); } function offlineBanner() { return h( 'div', { class: 'notice info row between', style: 'margin:12px 0 0' }, h('span', {}, t('offlineBanner')), h('button', { class: 'btn-sm', onClick: retryOnline }, t('retry')) ); } // ---- the global account selector ------------------------------------- // The wallet is always "in" one of its two balances: Spending (Ark + // Lightning) or Savings (on-chain). The balance card headline, the Receive // pane, and the gift source all follow it. const ACCOUNT_KEY = 'btc-wallet-account'; // Spending is opt-in: a wallet is born on-chain only, and the second balance // (with its toggle, swipe, and Move money) appears once the user sets it up — // or the moment money is already there (a restored wallet, a claimed gift), // because a balance must never hide. Setting up = claiming a username, so a // name claimed anywhere counts; the account flag latches it for accounts // whose name lookup isn't available right now. function spendingActive() { if (!featureHook('arkReady')) return false; const acc = activeAccount(); // A single-purpose spending wallet IS the opt-in — the user created it by // picking "Spending" in Add wallet; no further evidence required. if (acc?.kind === 'spending') return true; if (acc?.spendingSetup) { // Self-heal a flag latched by the old cross-network name leak: off // mainnet, a LEGACY boolean flag (`true` — the buggy era's value; every // deliberate latch since carries a timestamp) with zero balance and no // ark history was never really set up — drop it. if (acc.spendingSetup === true && getNetwork() !== 'mainnet' && !(featureHook('spendingSat') || 0) && !(featureHook('arkMovements') || []).length) { delete acc.spendingSetup; persistAccounts(); } else { return true; } } // A claimed payment address proves spending on MAINNET only: names feature // state is stored per-wallet without a network, so a mainnet name would // otherwise conjure a Spending wallet on mutinynet nobody set up. const addr = getNetwork() === 'mainnet' ? featureHook('namesAddress') : null; const evident = (featureHook('spendingSat') || 0) > 0 || (!!addr && !/^npub1/.test(addr)); // Latch demonstrated use onto the record, so the Wallets list keeps showing // this seed's Spending side even while it's locked or not the active one. if (evident && acc && !acc.spendingSetup) { acc.spendingSetup = Date.now(); persistAccounts(); featureHook('spendingEvident'); } return evident; } // ---- the flat wallet model ------------------------------------------------- // Each seed record surfaces as up to two TOP-LEVEL wallets: its Spending (Ark) // and Savings (on-chain) sides. A record with a fixed `kind` (single-purpose, // possibly sharing another wallet's seed at a higher BIP84 account) surfaces // once. "Spending" and "Savings" are the default names; renames are per-view. function viewsOf(a) { if (!a) return []; if (a.kind) return [a.kind]; if (a.type === 'watch') return ['savings']; // Spending is opt-in: a seed starts as its on-chain wallet alone, and grows // a Spending side only once the user sets one up (the wizard's spend step, // an explicit Add wallet, or funds/an address arriving — see the latch in // spendingActive). return a.spendingSetup ? ['spending', 'savings'] : ['savings']; } // How a SEED reads to the user: the names of the wallets it carries // ("Spending + Savings") — its internal record label never surfaces. function seedName(a) { return viewsOf(a).map((v) => viewLabel(a, v)).join(' + '); } function viewLabel(a, view) { if (a && a.viewLabels && a.viewLabels[view]) return a.viewLabels[view]; return view === 'spending' ? t('spendingName') : t('savingsName'); } function switchToView(id, view) { ui.accountUserChosen = true; // deliberate pick from the accounts screen ui.account = view; try { localStorage.setItem(ACCOUNT_KEY, view); } catch {} if (id === activeId) { ui.screen = 'wallet'; ui.tab = 'history'; render(); } else { const acc = accounts.find((a) => a.id === id); if (acc) switchAccount(id); } } function accountSel() { const a = activeAccount(); if (a && a.kind) return a.kind; // single-purpose wallet: no other side if (!spendingActive()) return 'savings'; // one balance, no choice if (!ui.account) { let saved = null; try { saved = localStorage.getItem(ACCOUNT_KEY); } catch {} ui.account = saved === 'savings' ? 'savings' : 'spending'; } return ui.account; } let _accDir = null; // which way the balance face should slide in let _accAnim = false; // whether the current face switch animates (decided at the switch) function setAccountSel(a, dir) { if (ui.account === a) return; ui.accountUserChosen = true; // deliberate pick — auto-select must not override _accDir = dir || (a === 'savings' ? 'left' : 'right'); ui.account = a; try { localStorage.setItem(ACCOUNT_KEY, a); } catch {} // Selecting an account shows its history right away — home base — and any // open payment detail (which belongs to the account it was opened from) // closes with it. ui.txDetail = null; ui.arkMoveDetail = null; ui.arkReconDetail = null; ui.arkExitDetail = null; ui.giftDetail = null; ui.tab = 'history'; render(); } const SWAP_ICON = ''; function balanceCard() { // Dim + spin until this network has data (cache, Nostr state, or a scan has // populated balances once). `scanning` alone misses the window between // wallet.load() and the first scan — the cache check and Nostr sync on a // network switch — where a bare 0 reads as a final balance. Background // updates after that happen silently. const firstLoad = !wallet.loaded && !wallet.offline; const pending = wallet.pendingIncoming; const featLines = featureAll('balanceLines'); // Money lives in two places and we name them for what they're FOR, not for // the rails underneath: Spending is the instant off-chain balance, Saving // is the on-chain one. The headline is simply everything you have. const spending = featureHook('spendingSat') || 0; const saving = wallet.spendable; const acc0 = activeAccount(); const kindLocked = !!(acc0 && acc0.kind); // single-purpose top-level wallet const hasSpending = spendingActive(); const sel = kindLocked ? acc0.kind : hasSpending ? accountSel() : 'savings'; const isSpending = sel === 'spending'; // a single-purpose wallet's switch jumps to a same-seed sibling wallet of // the opposite purpose, when one exists const opp = isSpending ? 'savings' : 'spending'; const sibling = kindLocked ? accounts.find((x) => x.id !== acc0.id && x.mnemonic && x.mnemonic === acc0.mnemonic && (x.passphrase || '') === (acc0.passphrase || '') && viewsOf(x).includes(opp)) : null; // The balance area: with both views live it's a CAROUSEL — the other face // peeks at the edge and drags/snaps into place (native scroll-snap), the // way wallet cards behave in BlueWallet. Snapping IS the account switch. // Single-purpose and savings-only wallets keep the lone face. const manageBtn = () => h('button', { class: 'balance-switch', onClick: () => { ui.screen = 'accounts'; render(); }, }, h('span', { html: '' }), h('span', { class: 'bsw-label' }, t('manageAccounts'))); const faceFor = (view) => { const viewSpending = view === 'spending'; return h('div', { class: 'balance-face' }, h('div', { class: 'row between', style: 'align-items:center' }, h('div', { class: 'row', style: 'gap:7px;align-items:center;min-width:0' }, // account identity at a glance (user feedback): spending wears // lightning + the ark mark, savings the bitcoin disc — color says // which rail before the label is read hasSpending && !kindLocked ? h('span', { class: 'face-ico ' + (viewSpending ? 'spend' : 'save'), html: viewSpending ? '\u26a1' : BITCOIN_ICON(14), }) : null, h('div', { class: 'small faint', style: 'text-transform:uppercase;letter-spacing:.06em' }, kindLocked ? viewLabel(acc0, view) : hasSpending ? (viewSpending ? t('spendingLabel') : t('savingLabel')) : t('balance'))), manageBtn()), h('div', { class: 'amt', style: firstLoad ? 'opacity:.3' : '' }, firstLoad ? h('span', { class: 'spinner sm', style: 'margin-right:8px' }) : null, animatedAmount('bal:' + view, viewSpending ? spending : saving), ' ', unitTag('unit')), (pending > 0 && !viewSpending) || (viewSpending && featLines.length) ? h('div', { class: 'split' }, // Pending incoming is an on-chain fact — it belongs to the Saving // face; the feature lines (ark's Moving…) belong to Spending. pending > 0 && !viewSpending ? h('div', {}, h('div', { class: 'k' }, t('pending')), h('div', { class: 'v pending' }, fmtAmount(pending), ' ', unitTag())) : null, ...(viewSpending ? featLines : []).map((l) => h('div', {}, h('div', { class: 'k' }, l.label), h('div', { class: 'v' }, fmtAmount(l.sat), ' ', unitTag())))) : null); }; // Everything below the balance figure — the move button, top-up offer, // spend-setup offer and the inline unfold panel. In carousel mode these // belong to the SPENDING CARD ITSELF, not to whichever card is selected: // the strip's flex row stretches both cards to the tallest, so an extra // that appears only on selection made the whole strip change height right // as a swipe settled. Tied to the card, heights never move mid-swipe. const cardExtras = (view = sel) => [ // "Move money" and friends: the only door between the two balances — // pointless (and confusing) while there's only one. ...(hasSpending ? featureAll('balanceActions') : []).map((a2) => h('button', { class: 'btn-sm', style: 'margin-top:10px', onClick: a2.onClick }, a2.label)), // A seed with only a Savings side is one tap from growing a Spending one: // the offer sits right on the balance card. The tap creates it outright — // same seed, a "Spending" side, latched before any prompt so backing out // of the niceties can't take it away — then walks the username + avatar // steps, since a spending account is best enjoyed with a payment address. // (Explicit latches carry a timestamp; self-heal only eats legacy booleans.) // A Spending face running on fumes while Savings sits comfortable gets a // one-tap top-up into the board panel. hasSpending && view === 'spending' && !ui.arkMoveOpen && wallet.spendable > 1000 && spending + (featureHook('spendingBoardingSat') || 0) < 1000 && featureHook('arkReady') && !wallet.watchOnly ? h('button', { class: 'btn-sm', style: 'margin-top:10px', onClick: () => { // tapped on the peeking card: bring the card in with its panel if (accountSel() !== 'spending') { ui.accountUserChosen = true; ui.account = 'spending'; try { localStorage.setItem(ACCOUNT_KEY, 'spending'); } catch {} } ui.arkMoveDir = 'toSpending'; ui.arkMoveOpen = true; render(); } }, t('topUpFromSavings')) : null, !hasSpending && !kindLocked && featureHook('arkReady') && !wallet.watchOnly && acc0 && acc0.type === 'full' && featureHook('namesSettled') !== 'pending' // a restoring name may prove Spending any second — don't flash the setup ask ? h('button', { class: 'btn-sm', style: 'margin-top:10px', onClick: () => { acc0.spendingSetup = Date.now(); persistAccounts(); featureHook('spendingEvident'); setAccountSel('spending', 'right'); ui.onbError = ''; ui.onb = { step: 'spend' }; render(); } }, t('spendSetup')) : null, // A feature can unfold UI right on the card (ark's inline move panel). (() => { if (!hasSpending) return null; const dt = animWindow('unfold', !!ui.arkMoveOpen, 300); const extra = featureHook('balanceExtra'); if (!extra) return null; const w = h('div', {}, extra); applyAnim(w, 'anim-unfold', dt); return w; })(), ]; const carouselMode = hasSpending && !kindLocked; if (carouselMode) { // Two SEPARATE cards side by side — the neighbor card peeks past the // active one's edge and drags/snaps into place, BlueWallet-style. const ORDER2 = ['spending', 'savings']; // Align the carousel to the selected card after this render lands — but // never fight a finger or live momentum, and NEVER touch layout when it's // already where it belongs. Boot fires many renders (cache, ark connect, // scan, nostr, balance ticks); reading offsetLeft/scrollLeft on each one // forced a synchronous layout mid-scroll — the stutter. `_alignedTo` // short-circuits before any layout read once the card is in place. requestAnimationFrame(() => { const el = document.querySelector('.bal-carousel'); if (!el || el._alignedTo === sel) return; // never yank the card while a finger/mouse is on it, or right after a // scroll — a boot render firing this mid-drag was the "false start" if (el._dragging || Date.now() - (el._lastScroll || 0) < 350) return; const k = el.children[ORDER2.indexOf(sel)]; if (!k) return; const target = k.offsetLeft + k.offsetWidth / 2 - el.clientWidth / 2; if (Math.abs(el.scrollLeft - target) > 6) el.scrollTo({ left: target, behavior: el._inited ? 'smooth' : 'auto' }); el._alignedTo = sel; el._inited = true; }); _accDir = null; // the drag is the animation here // Commit the account switch — shared by settle (the authority, at rest) // and the EARLY commit at release: the moment a flick's outcome is known // the history below starts loading, instead of waiting out the slide. // The strip itself stays morph-frozen until the glide lands, so this // render can't disturb the motion. const commitAccount = (el, view) => { // the scroll rests (or will rest) on this card — mark it aligned so // the post-render pass skips its layout read instead of re-aligning el._alignedTo = view; ui.accountUserChosen = true; // a deliberate pick — no auto-select behind it ui.account = view; try { localStorage.setItem(ACCOUNT_KEY, view); } catch {} // selecting an account lands on its history (home), any open // payment detail belonged to the other account ui.txDetail = null; ui.arkMoveDetail = null; ui.arkReconDetail = null; ui.arkExitDetail = null; ui.giftDetail = null; ui.tab = 'history'; render(); }; // Where will a released touch flick land? Nearest card, pushed one over // when the finger left with real speed — the same physics mandatory snap // applies. A wrong guess only flashes the other history for a beat: // settle corrects it when the snap actually lands. const predictLanding = (el) => { const kids = [...el.children]; if (!kids.length) return null; const mid = el.scrollLeft + el.clientWidth / 2; let idx = 0, d0 = Infinity; kids.forEach((k, i) => { const d = Math.abs(k.offsetLeft + k.offsetWidth / 2 - mid); if (d < d0) { d0 = d; idx = i; } }); const s = el._flick || []; const a = s[0], b = s[s.length - 1]; const vel = a && b && b.t > a.t ? (b.x - a.x) / (b.t - a.t) : 0; // px/ms, + = toward higher index if (Math.abs(vel) > 0.3) idx = Math.max(0, Math.min(kids.length - 1, idx + (vel > 0 ? 1 : -1))); return ORDER2[idx]; }; // The settled-card check, shared by the scroll debounce and drag release. // While a finger or mouse button is still DOWN nothing switches — hovering // a card mid-drag is browsing, not choosing; release is the choice. const settle = (el) => { if (el._dragging) return; endCarouselDrag(); // motion has stopped — unfreeze the strip and sync it const mid = el.scrollLeft + el.clientWidth / 2; let best = 0, bestD = Infinity; [...el.children].forEach((k, i) => { const d = Math.abs(k.offsetLeft + k.offsetWidth / 2 - mid); if (d < bestD) { bestD = d; best = i; } }); const view = ORDER2[best]; if (view && view !== accountSel()) commitAccount(el, view); }; return h('div', { class: 'bal-carousel', // The account switch (and its history-list rebuild) is the heaviest work // the carousel triggers, so it must never run mid-glide (that dropped a // frame and read as a stutter). The switch is driven by the onScroll // debounce below, which naturally trails the LAST scroll event — during a // smooth glide or momentum scroll, scroll events fire continuously until // motion stops, so the debounce lands after the card is already at rest. // (scrollend would be the semantic signal but it doesn't fire reliably // for programmatic scrolls across browsers, so it's not relied on.) onScrollend: (e) => { const el = e.currentTarget; if (!el._dragging) { clearTimeout(el._settle); settle(el); } }, onTouchstart: (e) => { const el = e.currentTarget; el._dragging = true; _carDragging = true; el._skipMorph = true; // where the finger landed, and where it found the strip — a tap // moves neither el._x0 = el.scrollLeft; el._tx = (e.touches[0] || {}).clientX || 0; el._moved = false; clearTimeout(el._settle); }, onTouchmove: (e) => { const el = e.currentTarget; if (!el._moved && Math.abs(((e.touches[0] || {}).clientX || 0) - (el._tx || 0)) > 4) el._moved = true; }, onTouchend: (e) => { const el = e.currentTarget; el._dragging = false; // onScroll's debounce settles it // A TAP, not a drag: the strip never moved. Unfreeze it right now. // Otherwise the freeze stood until a scroll settled — and a tap // produces no scroll events, so it stood until the 700ms safety net, // and the morph refused every render in between. That's why tapping // the unit tag on a phone took most of a second to change the number // while the same tap on a laptop (the mouse path has always had this // guard) was instant. if (!el._moved && Math.abs(el.scrollLeft - (el._x0 || 0)) < 2) { endCarouselDrag(); return; } // The finger is up: lift the render hold (the strip stays frozen via // _skipMorph) and commit the predicted landing NOW, so the history // below loads under the glide instead of after it. _carDragging = false; const view = predictLanding(el); if (view && view !== accountSel()) commitAccount(el, view); // safety: if momentum produces no scroll events, settle may never // fire — unfreeze the strip and flush anyway setTimeout(() => { if (!el._dragging && (el._skipMorph || _renderDeferred)) endCarouselDrag(); }, 700); }, // Desktop: scroll-snap has no mouse drag, so emulate one — grab the // strip and pull. Touch keeps the native pan (pointerType check); // buttons keep their clicks untouched. Mandatory snap fights raw // scrollLeft writes, so it's lifted for the drag and restored once the // release glide lands on a card. onPointerDown: (e) => { if (e.pointerType !== 'mouse' || e.button !== 0) return; if (e.target.closest && e.target.closest('button, a, input')) return; e.preventDefault(); // no text selection while pulling const el = e.currentTarget; // A new grab invalidates any pending snap-restore from the previous // release: bumping the token makes a stale restore a no-op, so it can // never re-apply mandatory snap mid-glide and yank the card back to // where it started. Kill its timer too so they don't pile up. el._dragGen = (el._dragGen || 0) + 1; clearTimeout(el._snapT); const x0 = e.clientX, s0 = el.scrollLeft; const kids0 = [...el.children]; // the card the drag started on — a flick throws relative to it let fromIdx = 0, fromD = Infinity; kids0.forEach((k, i) => { const d = Math.abs(k.offsetLeft + k.offsetWidth / 2 - (s0 + el.clientWidth / 2)); if (d < fromD) { fromD = d; fromIdx = i; } }); let moved = false; const samples = []; // recent pointer positions for release velocity const mv = (ev) => { const dx = ev.clientX - x0; if (!moved && Math.abs(dx) > 4) { moved = true; el._dragging = true; _carDragging = true; // hold renders so nothing eats frames under the pull el._skipMorph = true; // and freeze the strip against any later mid-glide morph el.classList.add('grabbing'); el.style.scrollSnapType = 'none'; } if (moved) { el.scrollLeft = s0 - dx; el._lastScroll = Date.now(); samples.push({ t: performance.now(), x: ev.clientX }); while (samples.length > 8 || (samples.length && samples[0].t < performance.now() - 140)) samples.shift(); } }; const up = () => { window.removeEventListener('pointermove', mv); window.removeEventListener('pointerup', up); el.classList.remove('grabbing'); el._dragging = false; // safety: if the release glide produces no scroll events, settle // never fires to unfreeze the strip — do it after a beat setTimeout(() => { if (!el._dragging && (el._skipMorph || _renderDeferred)) endCarouselDrag(); }, 700); if (!moved) { endCarouselDrag(); return; } el._dragged = Date.now(); // the release's click must not double as a card tap // Commit to a neighbor on a small, deliberate move — not only when // the drag drags PAST the halfway point. A short flick that traveled // ~20% of a card toward the neighbor, or any quick flick, switches; // otherwise it snaps back. (Nearest-point settling made a short pull // spring back to where it started — the "slide then jump back" // glitch.) Direction: a flick uses its velocity sign, a slow drag // uses how far it actually moved from the card it grabbed. const kids = [...el.children]; const last = samples[samples.length - 1]; const past = samples[0]; const vel = last && past && last.t > past.t ? (last.x - past.x) / (last.t - past.t) : 0; const pitch = kids[1] ? (kids[1].offsetLeft - kids[0].offsetLeft) : el.clientWidth || 1; const fromCenter = kids[fromIdx].offsetLeft + kids[fromIdx].offsetWidth / 2; const nowCenter = el.scrollLeft + el.clientWidth / 2; const travelled = (nowCenter - fromCenter) / pitch; // + = toward higher index let dir = 0; if (Math.abs(vel) > 0.12) dir = vel < 0 ? 1 : -1; // quick flick else if (Math.abs(travelled) > 0.15) dir = travelled > 0 ? 1 : -1; // pulled ~a sixth of a card const best = Math.max(0, Math.min(kids.length - 1, fromIdx + dir)); const k = kids[best]; if (k) el.scrollTo({ left: k.offsetLeft + k.offsetWidth / 2 - el.clientWidth / 2, behavior: 'smooth' }); // Restore mandatory snap only once THIS glide has come to rest, and // only if no newer drag has taken over — a fixed timer used to fire // mid-glide and snap the card back to its origin. scrollend marks // the true end; the timer is just a fallback for when the target is // already reached (no scroll, no scrollend). const gen = el._dragGen; const restore = () => { if (el._dragGen === gen && !el._dragging) el.style.scrollSnapType = ''; }; el.addEventListener('scrollend', restore, { once: true }); clearTimeout(el._snapT); el._snapT = setTimeout(restore, 700); // The chosen card is known RIGHT HERE — commit it now so the // history below loads under the glide. The hold lifts (renders may // run again) but the strip stays morph-frozen until settle; settle // then finds the account already switched and just syncs the strip. _carDragging = false; const view = ORDER2[best]; if (view && view !== accountSel()) commitAccount(el, view); }; window.addEventListener('pointermove', mv); window.addEventListener('pointerup', up); }, onScroll: (e) => { const el = e.target; el._lastScroll = Date.now(); // recent positions — predictLanding reads the release velocity off // these when a touch flick lifts (same window the desktop drag keeps) (el._flick ||= []).push({ t: performance.now(), x: el.scrollLeft }); while (el._flick.length > 8 || (el._flick.length && el._flick[0].t < performance.now() - 140)) el._flick.shift(); // trails the LAST scroll event: during a glide/momentum scroll these // fire continuously, so this lands ~130ms after motion stops — after // the card is at rest, never mid-glide. settle bails while _dragging. clearTimeout(el._settle); el._settle = setTimeout(() => settle(el), 130); }, }, ORDER2.map((v) => h('div', { class: 'card balance bal-slide face-' + v, // Tapping the peeking neighbor switches to it outright — the post-render // alignment then glides it into place. Guarded against a desktop drag's // release click and against buttons riding on the card. onClick: v === sel ? undefined : (e) => { if (e.target.closest && e.target.closest('button, a, input')) return; const strip = e.currentTarget.parentNode; if (strip && Date.now() - (strip._dragged || 0) < 300) return; ui.accountUserChosen = true; // deliberate pick ui.account = v; try { localStorage.setItem(ACCOUNT_KEY, v); } catch {} ui.txDetail = null; ui.arkMoveDetail = null; ui.arkReconDetail = null; ui.arkExitDetail = null; ui.giftDetail = null; ui.tab = 'history'; render(); }, }, faceFor(v), ...(v === 'spending' ? cardExtras(v) : [])))); } const dtRaw = animWindow('acct', sel, 300); // Decide AT the change whether this face switch animates: only deliberate // switches (tap/swipe set _accDir) slide — a background flip (arkReady // arriving and revealing Spending right after sign-in) must not. if (dtRaw >= 0 && dtRaw < 16.7) _accAnim = !!_accDir && !ui.navAnimSkip; const dtAcc = _accAnim ? dtRaw : -1; const heroEl = faceFor(sel); applyAnim(heroEl, 'anim-tab-' + (_accDir || 'left'), dtAcc); _accDir = null; return h('div', { class: 'card balance' + (hasSpending && !kindLocked ? ' face-' + sel : '') }, heroEl, ...cardExtras()); } function tabsBar() { // Settings lives in the avatar menu, not the tab strip. History isn't a tab // at all any more — it's the wallet's HOME, under the two tabs: tapping the // lit tab again (or native back) puts it away and the history is what's left. const tabs = [ ['receive', t('tabReceive')], // Watch-only wallets show Send too — it prompts to load the seed to spend. ['send', t('tabSend')], ]; return h( 'div', { class: 'tabs' }, tabs.map(([id, label]) => tabBtn(label, ui.tab === id, () => { ui.tab = ui.tab === id ? 'history' : id; ui.sendError = ''; // a stale send error never survives a tab change ui.revealShown = false; // re-mask the recovery phrase whenever tabs change ui.nostrExportStep = false; // and the exported nostr key ui.txDetail = null; // back to the history list when leaving/returning ui.arkMoveDetail = null; ui.arkReconDetail = null; ui.giftDetail = null; ui.addrScan = false; // and back to the main Settings, not the address list ui.settingsPage = null; ui.bump = null; ui.giftMode = false; render(); }) ) ); } function tabContent() { if (ui.tab === 'settings') return settingsTab(); if (ui.tab === 'history') return historyTab(); // Receive and Send are panes that fold open ABOVE the home view, not // replacements for it: the transaction history stays visible below them, so // getting back to it is scrolling, not hunting for the toggle. A history // DETAIL (a tapped row) still claims the whole area — you're reading one // payment, not browsing — as does the receive celebration takeover. const pane = ui.tab === 'receive' ? receiveTab() : sendTab(); const hist = historyTab(); const detailOpen = ui.bump || ui.txDetail || ui.arkMoveDetail || ui.arkReconDetail || ui.arkExitDetail || ui.giftDetail; if (detailOpen) return hist; return h('div', { class: 'col', style: 'gap:16px' }, pane, h('div', { class: 'small faint', style: 'text-transform:uppercase;letter-spacing:.06em;margin-top:4px' }, t('tabHistory')), hist); } // A payment is "recent" enough to celebrate if it's still pending or confirmed // within the last couple hours. This is a hard guard so an old payment can never // trigger the celebration on import, regardless of receive-index bookkeeping. function hasRecentIncoming() { // "Recent" means "plausibly the payment you're watching for right now". // The open-time baseline can't cover a payment the socket only DISCOVERS // seconds after it was taken — with a 2h window, money that landed a while // ago celebrated on open as if it had just arrived. A payment confirmed // more than 15 minutes ago is news, not an event. const now = Date.now() / 1000; return wallet.txs.some((tx) => tx.net > 0 && (!tx.confirmed || (tx.blockTime && now - tx.blockTime < 900))); } // ---------------------------------------------------------------- Receive // A dropdown that shows an icon beside each option (a native