diff options
Diffstat (limited to 'src/app.js')
| -rw-r--r-- | src/app.js | 138 |
1 files changed, 119 insertions, 19 deletions
@@ -585,10 +585,27 @@ let _publicChatMode = false; // 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; +// True until boot's restore has run. The feed is a public page: opened by +// URL it paints before the wallet restores, and it painted as a visitor's +// (no post button, chips or nav) until the restore landed a beat later — +// synchronous work (the vault's scrypt, the wallet's state, every feature's +// init), ~0.5 s on a laptop and more on a phone (Adam, 2026-10-31). When +// boot can already tell a wallet is about to open (restorePending), the +// page paints as that wallet's from its first frame. +let _restorePending = true; +// Boot phases for the crash-loop guard (build.js BOOT_GUARD counts page +// starts): the app notes the phase it reaches, and clears the count once it +// has lived eight seconds or leaves cleanly. A page iOS kills while opening +// does neither, so the third start in a row opens in safe mode. +function bootPhase(p) { try { localStorage.setItem('coinos-boot-phase', p); } catch {} } +function bootSettled() { try { localStorage.removeItem('coinos-boot'); } catch {} } +const safeBoot = () => !!(typeof window !== 'undefined' && window.__safeBoot); +document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') bootSettled(); }); +addEventListener('pagehide', bootSettled); // 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); +setTimeout(() => { _restorePending = false; 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. @@ -850,10 +867,12 @@ function renderInner() { // The bottom nav rides every page of a signed-in wallet and a visitor's // public pages — never over sign-in, onboarding or the lock prompt. const navPage = ui.pubProf || (activeAccount() && ['wallet', 'accounts', 'accountSettings'].includes(ui.screen) && !(ui.onb || onbInProgress())); - const bottomNav = navPage && !lockAsk && !_bootDeciding ? featureHook('bottomNav') : null; + // (a wallet about to restore gets its nav with the page, not after) + const bottomNav = navPage && !lockAsk && (!_bootDeciding || restorePending()) ? featureHook('bottomNav') : null; if (bottomNav) bottomNav.prepend(h('div', { class: 'app-nav-wide app-nav-brand' }, brandLogo())); - // the wallet beside the feed and chats on a wide screen (its own page needs no twin) - const side = bottomNav && featScreen && WIDE_MQ && WIDE_MQ.matches ? walletSide() : null; + // the wallet beside the feed and chats on a wide screen (its own page needs + // no twin; a wallet still restoring has no numbers to show yet) + const side = bottomNav && featScreen && !restorePending() && WIDE_MQ && WIDE_MQ.matches ? walletSide() : null; // a call, over whatever page is up (the messages feature's) const callOv = activeAccount() ? featureHook('callOverlay') : null; morphChildren(root, [screen, footer(), ...(bottomNav ? [bottomNav] : []), ...(side ? [side] : []), ...(ui.lightbox ? [imageViewer()] : []), ...(callOv ? [callOv] : [])]); @@ -1108,9 +1127,16 @@ function toast(msg, ms = 1600) { document.body.append(t); } t.textContent = msg; - requestAnimationFrame(() => t.classList.add('show')); clearTimeout(toastTimer); - toastTimer = setTimeout(() => t.classList.remove('show'), ms); + // The hide timer starts when the toast shows: a hidden tab pauses + // requestAnimationFrame but not setTimeout, so a toast raised in the + // background (a zap failing after you switched apps) used to be hidden + // first and shown on return, then stay up for good. + requestAnimationFrame(() => { + t.classList.add('show'); + clearTimeout(toastTimer); + toastTimer = setTimeout(() => t.classList.remove('show'), ms); + }); } async function copy(text) { @@ -1200,31 +1226,44 @@ let unit = (() => { // been fetched — a third position showing blanks is worse than two that work. function toggleUnit() { const ring = haveRate() ? ['sats', 'btc', 'fiat'] : ['sats', 'btc']; + const was = unit; unit = ring[(ring.indexOf(unit) + 1) % ring.length]; try { localStorage.setItem(UNIT_KEY, unit); } catch {} + // An amount already typed into the send form is the same money in the new + // unit, not the same digits: 1000 sats does not become 1000 BTC. Whatever + // doesn't parse is left as typed. + for (const r of (ui.send && ui.send.recipients) || []) { + if (!r.amount || !String(r.amount).trim()) continue; + const sat = parseField(r.amount, was); + if (sat) r.amount = amountField(sat); + } render(); } // A sats figure as the amount field types it in the current unit — what a // Max button or a fixed-amount invoice fills in. Fiat is floored to the // cent so a filled-in maximum never parses back to more than was offered. -// The figure it filled is remembered with its exact sats: a fiat maximum +// The figures it filled are remembered with their exact sats: a fiat maximum // rounded to the cent would otherwise parse back a few sats short and leave -// them behind. Typing anything else parses as typed. -let lastFill = null; +// them behind. Typing anything else parses as typed. Several are kept so a +// form with more than one amount round-trips each of them. +const fills = new Map(); function amountField(sat) { let s; const r = unit === 'fiat' && rateNow(); if (unit === 'sats') s = String(sat); else if (r) s = (Math.floor((sat / 1e8) * r * 100) / 100).toFixed(2); else s = (sat / 1e8).toFixed(8); - lastFill = { s, unit, sat }; + fills.delete(unit + '|' + s); + fills.set(unit + '|' + s, sat); + while (fills.size > 16) fills.delete(fills.keys().next().value); return s; } function parseField(v, u = unit) { - if (lastFill && lastFill.unit === u && String(v).trim() === lastFill.s) return lastFill.sat; + const k = u + '|' + String(v).trim(); + if (fills.has(k)) return fills.get(k); return parseAmount(v, u, rateNow()); } @@ -2100,6 +2139,23 @@ function switchAccount(id) { // 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). +// Whether restoreAccountsState() is about to open a wallet, read from what +// it reads but without its work: a session list in this tab, a vault the +// last unlock opened without a password, or watch-only accounts. The vault +// alone can't say whether a blank password opens it short of the scrypt +// that is the cost being avoided, so the last seal or unlock leaves a note. +const VAULT_BLANK_KEY = 'btc-wallet-vault-blank'; +const noteVaultBlank = (password) => { try { if (password === '') localStorage.setItem(VAULT_BLANK_KEY, '1'); else localStorage.removeItem(VAULT_BLANK_KEY); } catch {} }; +const vaultOpensBlank = () => { try { return localStorage.getItem(VAULT_BLANK_KEY) === '1'; } catch { return false; } }; +function bootWillRestore() { + if (!unlockDeadlinePassed()) { + try { const s = JSON.parse(sessionStorage.getItem(ACCOUNTS_KEY) || 'null'); if (s && Array.isArray(s.accounts) && s.accounts.length) return true; } catch {} + } + if (hasVault()) return vaultOpensBlank(); + return loadWatchAccounts().length > 0; +} +const restorePending = () => _restorePending && bootWillRestore(); + function restoreAccountsState() { let sess = null; try { sess = JSON.parse(sessionStorage.getItem(ACCOUNTS_KEY) || 'null'); } catch {} @@ -2151,6 +2207,7 @@ const passkeySecret = (pw) => typeof pw === 'string' && pw.startsWith('passkey:' const vaultPasskey = () => (loadVaultBlob() || {}).passkey || null; function sealVault(list, password) { const b = encryptVault(list, password); + noteVaultBlank(password); if (passkeySecret(password) && vaultPasskeyId) b.passkey = vaultPasskeyId; return b; } @@ -2306,7 +2363,7 @@ function submitPw() { 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 {} + try { localStorage.setItem(VAULT_KEY, JSON.stringify(encryptVault(list, p.v1 || ''))); noteVaultBlank(p.v1 || ''); } catch {} if (vaultPassword != null) vaultPassword = p.v1 || ''; // keep the unlocked session in sync ui.pw = null; render(); @@ -2447,6 +2504,7 @@ async function submitPwWithPasskey() { function attemptVaultUnlock(password) { let list; try { list = decryptVault(loadVaultBlob(), password); } catch { return false; } + noteVaultBlank(password); vaultPassword = password; vaultPasskeyId = passkeySecret(password) ? vaultPasskey() : null; // Start from the durable directory (watch-only views of every known wallet), @@ -3917,7 +3975,11 @@ async function publicMediaUpload(file) { if (error instanceof MediaUploadError && error.code === 'unauthorized') throw new Error(t('mediaUploadUnauthorized')); if (error instanceof MediaUploadError && error.code === 'too-big') throw new Error(t('mediaUploadTooBig')); if (error instanceof MediaUploadError && error.code === 'unsupported') throw new Error(t('mediaUploadUnsupported')); - throw new Error(t('msgUploadFailed')); + // what went wrong, said: "check your connection" alone hid a signer + // that never answered and hosts that refused the file + const why = String((error instanceof MediaUploadError ? error.details : '') || error?.message || '').slice(0, 200); + console.warn('media upload failed', error, why); + throw new Error(why && why !== 'failed' ? `${t('msgUploadFailed')} (${why})` : t('msgUploadFailed')); } finally { ui.mediaProgress = null; } @@ -4188,16 +4250,18 @@ function onboardScreen() { } if (o.step === 'spend') { return page([ - title(t('onbSpendTitle')), - h('p', { class: 'muted', style: 'margin:0' }, t('onbSpendBody')), + title(t(o.fromGift ? 'onbGiftNameTitle' : 'onbSpendTitle')), + h('p', { class: 'muted', style: 'margin:0' }, t(o.fromGift ? 'onbGiftNameBody' : '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 to the wallet, no questions — on - // Savings, the account that exists without any setup. + // Savings, the account that exists without any setup (or, after a + // gift, the account the gift arrived in). h('button', { class: 'btn-ghost btn-block', onClick: () => { + const home = o.home || 'savings'; ui.onb = null; try { localStorage.removeItem(ONB_STEP_KEY); } catch {} - ui.account = 'savings'; - try { localStorage.setItem(ACCOUNT_KEY, 'savings'); } catch {} + ui.account = home; + try { localStorage.setItem(ACCOUNT_KEY, home); } catch {} ui.tab = 'receive'; render(); } }, t('onbNotNow')), @@ -4417,6 +4481,15 @@ function claimMigratedName() { attempt(); } +// After a gift is claimed into a fresh wallet (and its seed written down): +// the wizard's name → look → done steps, the rest of a first run. `home` is +// the account a skip lands on — the one the gift arrived in. +function offerProfileSetup({ home = 'savings' } = {}) { + if (!canOfferSpending()) return false; + ui.onb = { step: 'spend', enterAddr: undefined, fromGift: true, home }; + return true; +} + function leaveWizard() { ui.onb = null; try { localStorage.removeItem(ONB_STEP_KEY); } catch {} @@ -4478,6 +4551,7 @@ function walletScreen() { brandHeader(true), h('div', { class: 'mt16' }, balanceCard()), ui.offlineFallback && wallet.offline ? offlineBanner() : null, + safeBootNotice(), backupNotice(), ...featureAll('walletNotices'), tabsBar(), @@ -4536,6 +4610,24 @@ function backupNotice() { } }, t('backupReminderGo'))); } +// Safe mode (see BOOT_GUARD in build.js): the app crashed on open twice in a +// row, so this boot made no automatic connections. Say so, name the phase +// the last boot reached, and offer the way back. +function safeBootNotice() { + if (!safeBoot()) return null; + const phase = (typeof window !== 'undefined' && window.__bootPhase) || '?'; + const report = () => ['Coinos safe mode', 'build ' + (typeof window._v === 'string' ? window._v : '?'), + 'starts without a clean boot: ' + window.__safeBoot, 'last phase: ' + phase, navigator.userAgent].join('\n'); + return h('div', { class: 'notice info col', style: 'margin:12px 0 0;gap:8px' }, + h('b', {}, t('safeBootTitle')), + h('span', { class: 'small' }, t('safeBootBody', { n: window.__safeBoot - 1, phase })), + h('div', { class: 'row', style: 'gap:8px' }, + h('button', { class: 'btn-sm', onClick: () => { bootSettled(); location.reload(); } }, t('safeBootOpen')), + h('button', { class: 'btn-sm', onClick: (e) => { + navigator.clipboard?.writeText(report()).then(() => { e.target.textContent = t('safeBootCopied'); }).catch(() => {}); + } }, t('safeBootCopy')))); +} + function offlineBanner() { return h( 'div', @@ -6413,6 +6505,8 @@ const ctx = { // support) — lazy so it resolves against the final FEATURES list, and a // build without the target feature simply gets null back hook: (name, ...args) => featureHook(name, ...args), + // boot is about to open a wallet (a page may paint as that wallet's already) + restorePending, // full sign-out (distinct from the padlock's lock-in-place): clear the // session and leave — offering a password if none protects the vault yet logout: () => { @@ -6439,6 +6533,7 @@ const ctx = { || (wallet.nostrPubkey && wallet.nostrPubkey()) || (activeAccount() || {}).nostrPk || null, getAccount: () => accountSel(), setAccount: (a, dir) => setAccountSel(a, dir), + offerProfileSetup, pickConfirm, // Open (or create) a wallet from a mnemonic — used by nostr login. openMnemonic: async (mnemonic, passphrase, opts) => enterWallet(mnemonic, passphrase, opts), // Profile pictures retain the metadata-scrubbing image uploader. Public @@ -6824,6 +6919,7 @@ window.addEventListener('load', () => { }, 1000); }); loadLocale(getLang()).finally(async () => { + bootPhase('boot'); applyBootAutoLogout(); // clear an overdue session before we read it for claim targets // Public read-only community chat: no wallet, no login — the join material // ships in the bundle, so the page decrypts client-side (src/public-chat.js). @@ -6849,6 +6945,7 @@ loadLocale(getLang()).finally(async () => { // and a wallet opening in fiat should open with a number in it. refreshRates().then(() => { if (unit === 'fiat') render(); }).catch(() => {}); setInterval(() => { refreshRates().then(() => { if (unit === 'fiat') render(); }).catch(() => {}); }, 10 * 60_000); + bootPhase('store'); const storeReady = featureHook('arkStoreReady'); // the IndexedDB caches (profiles, reports, …) open alongside: a wallet's // caches are read as soon as it opens @@ -6858,7 +6955,10 @@ loadLocale(getLang()).finally(async () => { try { navigator.storage?.persist?.().catch(() => {}); } catch {} _bootDeciding = false; // from here on the screen is a decision, not a guess const opened = restoreAccountsState(); - if (!opened) render(); + _restorePending = false; + if (!opened || !activeAccount()) render(); + bootPhase('ui'); + setTimeout(bootSettled, 8000); if (WAKE_PAYLOAD != null) { // Nothing opened — a locked vault, or no wallet on this device. There is // nothing to answer with, so say so now. |
