aboutsummaryrefslogtreecommitdiffstats
path: root/src/app.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/app.js')
-rw-r--r--src/app.js585
1 files changed, 465 insertions, 120 deletions
diff --git a/src/app.js b/src/app.js
index 9789401..e6e6843 100644
--- a/src/app.js
+++ b/src/app.js
@@ -11,6 +11,7 @@ import './rich-text.js'; // <coinos-text>, the composers' field
import { makeSearcher, resultRows, searchable, punkUrl, warmSearch } from './recipient-search.js';
import { assetUrl } from './asset-url.js';
import { npubOf, seedPubkey, neventOf, parseNostrPubkey } from './nostr.js';
+import { warmSeed, forgetSeeds } from './seed.js';
import { nip98Header } from './nip98.js';
import { uploadPublicMedia, uploadStream, mediaUploadSigner, MediaUploadError } from './media-upload.js';
import { NOSTR_MARK } from './features/nostrlogin.js';
@@ -20,6 +21,7 @@ import { STAGING } from './build-flags.js';
import { buildFeatures, loadDeferredFeatures } from './features/index.js';
import { cacheReady, cacheStore } from './cache-store.js';
import { t, LANGS, getLang, setLang, isRTL, loadLocale } from './i18n.js';
+import { initPullRefresh } from './pull-refresh.js';
import { rateNow, rateAt, refreshRates, haveRate, getCurrency, setCurrency, currencies, fmtFiat, fmtFiatBare } from './rates.js';
import {
fmtBtc,
@@ -75,7 +77,7 @@ const ui = {
confirmClear: false, // "Clear all" confirmation shown
editId: null, // account being renamed
editLabel: '',
- createStep: 'gen', // 'gen' | 'confirm'
+ createStep: 'intro', // 'intro' (the heads-up) | 'gen' (the words) | 'confirm' (the one-word check)
draftMnemonic: '',
confirm: [], // [{ index, value }]
confirmPass: '', // re-entered passphrase (confirm field, shown once one is typed)
@@ -365,6 +367,14 @@ function morphKeyed(parent, cur, next) {
}
const root = document.getElementById('app');
+// The footer while setting up: the language, nothing else.
+function setupFooter() {
+ const lang = languagePicker();
+ lang.style.width = 'auto'; // as wide as the language's name, not the page
+ lang.style.flex = '0 0 auto';
+ return h('div', { class: 'footer small muted center' },
+ h('div', { class: 'row gap6', style: 'justify-content:center' }, lang));
+}
function footer() {
return h(
'div',
@@ -585,6 +595,14 @@ 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
@@ -597,7 +615,7 @@ 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.
@@ -640,12 +658,22 @@ function attachZoom(box, img, { wheel = true } = {}) {
const mx = Math.max(0, (img.offsetWidth * s - window.innerWidth) / 2), my = Math.max(0, (img.offsetHeight * s - window.innerHeight) / 2);
x = Math.min(mx, Math.max(-mx, x)); y = Math.min(my, Math.max(-my, y));
};
+ // Chrome keeps a will-change: transform layer rastered at 1× and scales
+ // the bitmap: zoomed in, the raster's tiles show as a faint grid of
+ // seams across the picture ("very faint grid lines", Adam, 2026-11-02).
+ // The hint is on while a gesture moves the picture (no re-raster per
+ // frame) and off once it settles zoomed, so the picture is drawn again
+ // at its real scale: sharp, and seamless.
+ let settle = 0;
const apply = (animate) => {
+ clearTimeout(settle);
img.style.transition = animate ? 'transform .2s ease' : 'none';
img.style.transform = s === 1 && !x && !y ? '' : `translate(${x}px, ${y}px) scale(${s})`;
img.style.cursor = s > 1 ? 'grab' : '';
// zoomed, the finger pans the picture; at 1× the touch is the page's (a pager's swipe)
img.style.touchAction = s > 1 ? 'none' : '';
+ img.style.willChange = 'transform';
+ settle = setTimeout(() => { img.style.willChange = s > 1 ? 'auto' : ''; }, animate ? 250 : 120);
};
// scale to ns keeping the picture point under (cx, cy) where it is
const zoomAt = (ns, cx, cy) => {
@@ -859,13 +887,24 @@ 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] : [])]);
+ // What a feature floats over its page (the feed's post button and
+ // new-posts pill): fixed-position nodes, so they live beside the page,
+ // not inside it — the page's entrance animation transforms it, and a
+ // transform makes fixed descendants position within the page.
+ const floaters = h('div', { class: 'page-floaters', style: 'display:contents' }, ...featureAll('pageFloaters'));
+ // The front door and every setup step keep only the language picker under
+ // them: no credits, theme toggle, currency or build stamp (Adam,
+ // 2026-11-06). Setup = the unlock screen, or a page the wizard drew (.onb).
+ const setup = ui.screen === 'unlock' || !!(screen && (screen.classList?.contains('onb') || screen.querySelector?.('.onb')));
+ morphChildren(root, [screen, floaters, setup ? setupFooter() : footer(), ...(bottomNav ? [bottomNav] : []), ...(side ? [side] : []), ...(ui.lightbox ? [imageViewer()] : []), ...(callOv ? [callOv] : [])]);
promoteLazySrc(root); // sources on the nodes that actually made it into the page
try { document.documentElement.classList.toggle('no-scroll', !!(ui.lightbox || ui.mediaPager)); } catch {}
if (fpath) {
@@ -1117,9 +1156,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) {
@@ -1292,19 +1338,36 @@ function unitTag(cls = '') {
// ================================================================ 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.
+// The front door's seed pages (first run, or "Sign into another account"):
+// no tabs, and the page's own Back steps through the create flow.
+function seedPages() {
+ return ui.screen === 'unlock' && !!((ui.onb && ui.onb.step === 'seed') || (ui.fromWallet && ui.addFlow));
+}
+// Back within the create flow: the check → the passphrase → the words → the
+// heads-up. False when there's no earlier step (the caller leaves the flow).
+const CREATE_PREV = { gen: 'intro', pass: 'gen', confirm: 'pass' };
+function createStepBack() {
+ if (ui.unlockTab !== 'create' || ui.entropyPage || !CREATE_PREV[ui.createStep]) return false;
+ ui.createStep = CREATE_PREV[ui.createStep];
+ ui.unlockError = '';
+ render();
+ return true;
+}
+
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());
// Inside the first-run wizard the words are already on screen: no tabs,
// just a line underneath for whoever brought their own seed (and one back).
- const wizard = ui.screen === 'unlock' && ui.onb && ui.onb.step === 'seed';
- const swap = wizard ? h('button', {
+ const wizard = seedPages();
+ // only before the words are up: once they're shown, it was already offered
+ const swap = wizard && (ui.unlockTab === 'import' || ui.createStep === 'intro') ? h('button', {
class: 'linklike small', style: 'align-self:center',
onClick: () => {
clearSeedDrafts(); ui.unlockError = '';
if (ui.unlockTab === 'create') ui.unlockTab = 'import';
- else { ui.unlockTab = 'create'; ui.createStep = 'gen'; ui.draftRandom = ui.draftMnemonic = newMnemonic(); }
+ else { ui.unlockTab = 'create'; ui.createStep = 'intro'; ui.draftRandom = ui.draftMnemonic = newMnemonic(); }
render();
},
}, ui.unlockTab === 'create' ? t('onbHaveSeed') : t('onbCreateInstead')) : null;
@@ -1323,7 +1386,77 @@ function unlockCard() {
);
}
+// "Sign in to another account", laid out as the front door is (Adam,
+// 2026-11-07): two choices, then the sign-in list with the seed phrase
+// first. Everything here adds an account beside the open ones; Back returns
+// to the wallet.
+function addAccountScreen() {
+ const page = (kids) => h('div', { class: 'col onb', style: 'gap:20px' }, ...kids);
+ const title = (txt) => h('h2', { class: 'onb-title' }, txt);
+ const toWallet = () => {
+ ui.fromWallet = false; ui.addFlow = false; ui.addStep = null; ui.nostrLoginOpen = false;
+ ui.screen = 'wallet'; ui.unlockError = ''; ui.nostrLoginError = ''; render();
+ };
+ if (ui.addStep === 'doors') {
+ if (ui.nostrLoginOpen) {
+ 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: () => { ui.nostrLoginOpen = false; ui.nostrLoginError = ''; render(); } }, t('back')),
+ ]);
+ }
+ return page([
+ h('div', { class: 'onb-hero' }, brandHeader(false)),
+ title(t('onbSigninTitle')),
+ h('div', { class: 'col', style: 'gap:10px' },
+ h('button', { id: 'add-restore', class: 'btn-block', style: 'padding:14px', onClick: () => {
+ clearSeedDrafts();
+ ui.unlockTab = 'import'; ui.unlockError = ''; ui.addStep = null;
+ render();
+ } }, t('onbRestoreDoor')),
+ featureHook('frontDoorSignin', { short: true })),
+ h('button', { class: 'linklike small', onClick: () => { ui.nostrLoginError = ''; ui.addStep = 'welcome'; render(); } }, t('back')),
+ ]);
+ }
+ return page([
+ h('div', { class: 'onb-hero' }, brandHeader(false)),
+ title(t('addAccountTitle')),
+ h('div', { class: 'col', style: 'gap:10px' },
+ h('button', {
+ id: 'add-create',
+ class: 'btn-primary btn-block', style: 'font-size:17px;padding:14px', onClick: () => {
+ clearSeedDrafts();
+ ui.unlockTab = 'create'; ui.unlockError = ''; ui.createStep = 'intro'; ui.confirm = [];
+ ui.draftRandom = ui.draftMnemonic = newMnemonic();
+ ui.addStep = null;
+ render();
+ },
+ }, t('onbStart')),
+ h('button', {
+ id: 'add-signin',
+ class: 'btn-block', style: 'font-size:17px;padding:14px',
+ onClick: () => { ui.nostrLoginError = ''; ui.addStep = 'doors'; render(); },
+ }, t('onbSignInExisting'))),
+ accounts.length ? h('button', { class: 'linklike small', onClick: toWallet }, t('back')) : null,
+ ]);
+}
+
function unlockScreen() {
+ if (ui.fromWallet && ui.addFlow) {
+ if (ui.addStep && !ui.entropyPage) return addAccountScreen();
+ // a choice made there: the same seed pages as the front door, whose
+ // Back steps back to where it was made
+ return h('div', { class: 'col onb', style: 'gap:20px' },
+ h('h2', { class: 'onb-title' }, t(ui.unlockTab === 'import' ? 'onbRestoreTitle' : 'onbSeedTitle')),
+ unlockCard(),
+ ui.entropyPage ? null : h('button', { class: 'linklike small', onClick: () => {
+ if (createStepBack()) return;
+ const from = ui.unlockTab === 'import' ? 'doors' : 'welcome';
+ clearSeedDrafts(); ui.draftMnemonic = ''; ui.createStep = 'intro'; ui.confirm = []; ui.unlockError = '';
+ ui.addStep = from; render();
+ } }, t('back')));
+ }
return h(
'div',
{ class: 'col', style: 'gap:16px' },
@@ -1430,6 +1563,27 @@ function tabBtn(label, active, onClick) {
}
function createPane() {
+ // A heads-up before the words appear: they are secret, and the screen is
+ // about to show them (Adam, 2026-11-07). No warning icon — a plain note.
+ if (ui.createStep === 'intro' || !ui.createStep) {
+ const n = ui.draftMnemonic ? ui.draftMnemonic.split(' ').length : 12;
+ return h(
+ 'div',
+ { class: 'col seed-intro' },
+ h('p', { style: 'margin:0' }, t('seedIntroBody', { n })),
+ h('p', { class: 'muted', style: 'margin:0' }, t('seedIntroPrivate')),
+ h('button', {
+ id: 'seed-intro-next',
+ class: 'btn-primary btn-block', style: 'padding:14px',
+ onClick: () => {
+ if (!ui.draftMnemonic) ui.draftRandom = ui.draftMnemonic = newMnemonic();
+ ui.unlockError = '';
+ ui.createStep = 'gen';
+ render();
+ },
+ }, t('seedIntroShow')),
+ );
+ }
if (ui.createStep === 'gen') {
// The wizard shows words, never a Generate button: a refresh restores
// the step but not the draft (drafts live in memory only), so draw a
@@ -1457,7 +1611,6 @@ function createPane() {
return h(
'div',
{ class: 'col' },
- h('div', { class: 'warn-box' }, t('writeDownWarn')),
h(
'div',
{ class: 'words', style: '--rows-3:' + Math.ceil(words.length / 3) + ';--rows-2:' + Math.ceil(words.length / 2) },
@@ -1468,74 +1621,78 @@ function createPane() {
h('button', { class: 'btn-block', style: 'display:flex;align-items:center;justify-content:center;gap:8px', onClick: () => copy(ui.draftMnemonic) },
h('span', { style: 'display:flex', html: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>' }),
t('copy')),
- optionsPanel(),
h(
'button',
{
+ id: 'seed-next',
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();
- },
+ onClick: () => { ui.unlockError = ''; ui.createStep = 'pass'; render(); },
},
- t('verifyBackup')
- ),
- h(
- 'button',
- { class: 'btn-block', onClick: () => openWallet(ui.draftMnemonic, { generated: true }) },
- t('skipVerification')
+ t('next')
)
);
}
- // confirm step (optional — reachable via "Verify backup")
+ // The passphrase, on a page of its own (Adam, 2026-11-07): what it is,
+ // that it's optional but recommended, and the field — a small "Add
+ // passphrase" button under the words was easy to miss.
+ if (ui.createStep === 'pass') {
+ const words = ui.draftMnemonic.split(' ');
+ const next = () => {
+ // a typo here IS a different wallet: caught while the fields are up
+ if (ui.passphrase && ui.confirmPass !== ui.passphrase) {
+ ui.unlockError = t('passphraseMismatch'); render(); return;
+ }
+ ui.confirm = pickConfirm(words);
+ ui.unlockError = '';
+ ui.createStep = 'confirm';
+ render();
+ };
+ return h(
+ 'div',
+ { class: 'col' },
+ h('h3', { style: 'margin:0' }, t('passStepTitle')),
+ h('p', { style: 'margin:0' }, t('passStepBody', { n: words.length })),
+ h('p', { class: 'muted', style: 'margin:0' }, t('passStepNote')),
+ ...passFields(),
+ h('button', { id: 'pass-next', class: 'btn-primary btn-block', onClick: next }, t(ui.passphrase ? 'next' : 'passStepNone')),
+ );
+ }
+ // The check: which word is #5? One tap from a few of the phrase's own
+ // words, or Skip (Adam, 2026-11-07) — no typing.
+ const q = ui.confirm;
+ if (!q || !q.options) { ui.createStep = 'pass'; return createPane(); }
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('p', { style: 'margin:0' }, t('quizPrompt', { n: q.index + 1 })),
+ h('div', { class: 'quiz-opts' }, q.options.map((w) =>
h('button', {
- class: 'btn-primary grow',
+ type: 'button',
+ class: 'quiz-opt' + (q.wrong && q.wrong.includes(w) ? ' wrong' : ''),
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 });
+ if (w === words[q.index]) { ui.unlockError = ''; openWallet(ui.draftMnemonic, { generated: true }); return; }
+ (q.wrong ||= []).includes(w) || q.wrong.push(w);
+ render();
},
- }, t('openWallet'))
- ),
- h('button', { class: 'btn-block', onClick: () => openWallet(ui.draftMnemonic, { generated: true }) }, t('skipVerification'))
+ }, w))),
+ q.wrong && q.wrong.length ? h('div', { class: 'notice err' }, t('quizWrong')) : null,
+ h('button', { id: 'seed-skip', class: 'btn-ghost btn-block', onClick: () => { ui.unlockError = ''; openWallet(ui.draftMnemonic, { generated: true }); } }, t('skip')),
+ // outside the seed pages (no Back of their own), a way back to the words
+ seedPages() ? null : h('button', { class: 'linklike small', style: 'align-self:center', onClick: () => { ui.createStep = 'pass'; ui.unlockError = ''; render(); } }, t('back')),
);
}
-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 5th word and three others from the same phrase, shuffled: knowing the
+// words isn't enough, you need them in order.
+function pickConfirm(words, index = Math.min(4, words.length - 1)) {
+ const right = words[index];
+ const others = [...new Set(words.filter((w) => w !== right))];
+ for (let i = others.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [others[i], others[j]] = [others[j], others[i]]; }
+ const options = [right, ...others.slice(0, 3)];
+ for (let i = options.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [options[i], options[j]] = [options[j], options[i]]; }
+ return { index, options, wrong: [] };
}
// The BYO-entropy page, shared by Create (re-derives the draft phrase) and
@@ -1585,7 +1742,7 @@ function entropyPage() {
// tabs, backing out of the entropy page, going home. Whatever you come back
// to is empty. (An opened wallet clears these too, in openWallet/lock.)
function clearSeedDrafts() {
- ui.importText = ''; ui._entropyFilled = null;
+ ui.importText = ''; ui._entropyFilled = null; ui.passOpen = false;
ui.entropyEntry = ''; ui.ownEntropy = ''; ui.importEntropy = '';
}
@@ -1623,18 +1780,34 @@ function importPane() {
}
function optionsPanel() {
+ // Most people want no passphrase, and an empty field labelled "Passphrase"
+ // under the seed reads as a required password. It waits behind a button,
+ // and once opened says it's optional (Adam, 2026-11-06). A passphrase
+ // already typed keeps it open.
+ if (!ui.passOpen && !ui.passphrase) {
+ return [h('button', {
+ class: 'btn-block', type: 'button',
+ onClick: (e) => { e.preventDefault(); ui.passOpen = true; render(); },
+ }, t('addPassphrase'))];
+ }
+ return passFields({ label: true });
+}
+
+// The passphrase field (+ its confirmation once something is typed). The
+// create flow's own page explains it above; the import pane labels it here.
+function passFields({ label = false } = {}) {
const pass = h(
'label',
{ class: 'field' },
h('span', { class: 'lab' },
- t('passphrase'),
+ t(label ? 'passphraseOptional' : '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,
+ ui.passInfoOpen ? h('p', { class: 'small muted', style: 'margin:0 0 6px' }, t('passInfo')) : label ? h('p', { class: 'small muted', style: 'margin:0 0 6px' }, t('passOptionalHint')) : null,
h(
'div',
{ class: 'input-group' },
@@ -1663,7 +1836,7 @@ function optionsPanel() {
class: 'mono-input',
autocomplete: 'off',
value: ui.confirmPass,
- onInput: (e) => (ui.confirmPass = e.target.value),
+ onInput: (e) => { ui.confirmPass = e.target.value; if (ui.unlockError && ui.confirmPass === ui.passphrase) { ui.unlockError = ''; render(); } },
})
)
: null;
@@ -1874,8 +2047,9 @@ async function activateAccount(acc, opts = {}) {
function signInAnother() {
clearSeedDrafts();
ui.fromWallet = true; ui.unlockError = ''; ui.screen = 'unlock';
- // Starts on Create new (its Generate button), not the seed-import box.
- ui.unlockTab = 'create'; ui.createStep = 'gen'; ui.draftMnemonic = ''; ui.confirm = [];
+ ui.addFlow = true; ui.addStep = 'welcome'; ui.nostrLoginOpen = false; ui.nostrLoginError = '';
+ // Opens on the front door's two choices (addAccountScreen).
+ ui.unlockTab = 'create'; ui.createStep = 'intro'; ui.draftMnemonic = ''; ui.confirm = [];
ui.profilePk = null; ui.profEdit = null; ui.profEditFilled = false; ui.chatOpen = false;
render();
}
@@ -2122,6 +2296,42 @@ 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();
+
+// The phrases the restore below is about to open, stretched into their seeds
+// natively (seed.js) while the stores open: the restore and the wallet then
+// read them instead of stretching each in JavaScript, once per use.
+async function warmBootSeeds() {
+ let list = [];
+ try {
+ if (!unlockDeadlinePassed()) {
+ const s = JSON.parse(sessionStorage.getItem(ACCOUNTS_KEY) || 'null');
+ if (s && Array.isArray(s.accounts)) list = s.accounts;
+ }
+ } catch {}
+ if (!list.length && vaultOpensBlank()) {
+ try { list = decryptVault(loadVaultBlob(), ''); } catch {}
+ }
+ await Promise.all((Array.isArray(list) ? list : [])
+ .filter((a) => a && a.mnemonic && !a.xprv)
+ .map((a) => warmSeed(a.mnemonic, a.passphrase || '')));
+}
+
function restoreAccountsState() {
let sess = null;
try { sess = JSON.parse(sessionStorage.getItem(ACCOUNTS_KEY) || 'null'); } catch {}
@@ -2173,6 +2383,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;
}
@@ -2328,7 +2539,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();
@@ -2469,6 +2680,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),
@@ -2686,6 +2898,7 @@ function lock({ offerPassword = false } = {}) {
_featuresInited = false;
for (const f of FEATURES) { try { f.stop && f.stop(); } catch {} }
clearAccounts();
+ forgetSeeds();
vaultPassword = null;
vaultPasskeyId = null;
wallet.load({ mnemonic: '', passphrase: '', netName: getNetwork(), offline: false });
@@ -2709,14 +2922,14 @@ function lock({ offerPassword = false } = {}) {
ui.confirmRemove = null;
ui.editId = null;
ui.editLabel = '';
- ui.createStep = 'gen';
+ ui.createStep = 'intro';
ui.draftMnemonic = '';
ui.importText = '';
ui.entropyPage = null;
ui.entropyEntry = '';
ui.ownEntropy = '';
ui.importEntropy = '';
- ui.passphrase = '';
+ ui.passphrase = ''; ui.passOpen = false;
ui.confirmPass = '';
ui.confirm = [];
ui.revealShown = false;
@@ -2865,6 +3078,8 @@ function headerSearch() {
type: 'text', class: 'header-search', placeholder: t('searchUsers') + '…', 'aria-label': t('searchUsers'),
value: (ui.userSearch && ui.userSearch.q) || '', autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false',
onInput: (e) => { clearFeatureNavKeepSearch(); featureHook('userSearchQuery', e.target.value); },
+ // Enter without picking a suggestion: full-text search over notes
+ onKeydown: (e) => { if (e.key === 'Enter' && !e.isComposing && e.target.value.trim()) { e.preventDefault(); featureHook('userSearchSubmit', e.target.value); e.target.blur(); } },
});
}
function clearFeatureNavKeepSearch() {
@@ -2996,7 +3211,9 @@ function settingsTab() {
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), historyExportCard()] : []);
+ case 'wallet':
+ if (ui.delAcct && a) return deleteAccountView(a);
+ return page(a ? [recoveryCard(a), balanceSettingsCard(a), walletNameCard(a), pubkeyCard(a), historyExportCard(), deleteAccountCard(a)] : []);
case 'payments': return page(featureAll('settingsCards').reverse());
case 'pos': return page(featureAll('posSettingsCards').reverse()); // the till's own card first, then the terminal's
case 'network': return page([networkCard(), explorerCard()]); // an old bookmark of the tile; the cards live under Advanced now
@@ -3291,7 +3508,7 @@ function goHome() {
ui.sendError = '';
ui.fromWallet = false;
} else if ((ui.onb && ui.onb.step !== 'welcome')
- || ui.draftMnemonic || ui.createStep !== 'gen' || ui.importText) {
+ || ui.draftMnemonic || !['intro', 'gen'].includes(ui.createStep) || 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
@@ -3301,7 +3518,7 @@ function goHome() {
ui.nostrLoginOpen = false;
ui.screen = 'unlock';
ui.unlockTab = 'create';
- ui.createStep = 'gen';
+ ui.createStep = 'intro';
ui.draftMnemonic = '';
clearSeedDrafts();
ui.entropyPage = null;
@@ -3320,7 +3537,7 @@ function goHome() {
} else {
ui.screen = 'unlock';
ui.unlockTab = 'create';
- ui.createStep = 'gen';
+ ui.createStep = 'intro';
ui.draftMnemonic = '';
ui.confirm = [];
ui.unlockError = '';
@@ -3400,8 +3617,8 @@ function accountsScreen() {
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 = '';
+ ui.draftMnemonic = ''; ui.createStep = 'intro'; ui.confirm = []; ui.passphrase = ''; ui.confirmPass = ''; ui.passOpen = false;
+ ui.showPass = false; ui.screen = 'unlock'; ui.unlockTab = 'create'; ui.fromWallet = true; ui.unlockError = ''; ui.addFlow = false;
render();
} else {
createDerivedWallet(aw.from, kind);
@@ -3564,6 +3781,76 @@ function openAccountSettings(id) {
// Per-wallet settings cards, shared by the per-account screen (Accounts → ⚙)
// and the main Settings tab (which inlines them for the active wallet).
+// Delete account: the server forgets this wallet (its name is freed, its
+// records go) and the wallet leaves this device. "someone's asking for a way
+// to delete their v3 account. we allowed that in v2" (Adam, 2026-11-10). The
+// coins are the user's own and no server can delete them: they stay
+// spendable from the recovery phrase, which is why the page says how much is
+// still here before anything happens.
+// The saved copy on this device has to go too, and the vault can only be
+// rewritten with its password in hand: a blank one is tried here, a real one
+// is known once the app has been unlocked with it this session.
+function vaultWritable() {
+ if (vaultPassword != null || !hasVault()) return true;
+ try { const list = decryptVault(loadVaultBlob(), ''); vaultPassword = ''; mergeVaultList(list); return true; } catch { return false; }
+}
+function deleteAccountCard(a) {
+ if (a.type === 'watch' || wallet.watchOnly || !wallet.nostrSign) return null;
+ return h('div', { class: 'card col', 'data-key': 'delete-account' },
+ h('h3', {}, t('delAcctTitle')),
+ h('p', { class: 'small muted', style: 'margin:0' }, t('delAcctBlurb')),
+ h('button', { class: 'btn-danger btn-block', onClick: () => {
+ if (!vaultWritable()) { toast(t('delAcctUnlockFirst')); return; }
+ const d = ui.delAcct = { accId: a.id, info: null, sure: false, busy: false, error: '' };
+ render();
+ Promise.resolve(featureHook('accountDelete', true)).then((info) => { if (ui.delAcct === d) { d.info = info || {}; render(); } })
+ .catch((e) => { if (ui.delAcct === d) { d.info = {}; d.error = e.message; render(); } });
+ } }, t('delAcctTitle')));
+}
+function deleteAccountView(a) {
+ const d = ui.delAcct;
+ const heldSat = (wallet.spendable || 0) + (featureHook('arkHeldSat') || 0);
+ const info = d.info;
+ const names = (info && info.names) || [];
+ const owedSat = info ? (info.pendingSat || 0) + (info.withdrawSat || 0) : 0;
+ const blocked = !!(info && (info.blocked || owedSat > 0 || info.paying));
+ const back = () => { ui.delAcct = null; render(); };
+ const go = async () => {
+ if (d.busy || !d.sure || blocked) return;
+ d.busy = true; d.error = ''; render();
+ try {
+ const r = await featureHook('accountDelete', false);
+ if (r && r.blocked) { d.info = { ...d.info, ...r, blocked: true }; d.sure = false; return; }
+ ui.delAcct = null; ui.settingsPage = null;
+ wipeAccountCache(a);
+ // out of the saved vault first, or the next unlock brings it back
+ if (a.persisted && vaultWritable()) { a.persisted = false; writeVault(); }
+ removeAccount(a.id);
+ toast(t('delAcctDone'));
+ } catch (e) { d.error = e.message; }
+ finally { d.busy = false; render(); }
+ };
+ return h('div', { class: 'col', style: 'gap:16px' },
+ h('div', { class: 'card col', style: 'gap:10px' },
+ h('h3', {}, t('delAcctTitle')),
+ !info ? h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('delAcctChecking'))) : null,
+ h('ul', { class: 'small', style: 'margin:0;padding-left:18px;display:flex;flex-direction:column;gap:6px' },
+ names.length ? h('li', {}, t('delAcctName', { name: names.join(', ') })) : null,
+ h('li', {}, t('delAcctRecords')),
+ h('li', {}, t('delAcctDevice', { name: seedName(a) })),
+ h('li', { class: 'muted' }, t('delAcctStays'))),
+ blocked ? h('div', { class: 'warn-box' }, t('delAcctOwed', { sat: fmtAmount(owedSat) })) : null,
+ heldSat > 0 ? h('div', { class: 'warn-box' }, t('delAcctFunds', { sat: fmtAmount(heldSat) })) : null,
+ d.error ? h('div', { class: 'warn-box' }, d.error) : null,
+ h('label', { class: 'row gap6 small', style: 'align-items:flex-start;cursor:pointer' },
+ h('input', { type: 'checkbox', checked: d.sure, disabled: blocked || d.busy, style: 'margin-top:2px', onChange: (e) => { d.sure = e.target.checked; render(); } }),
+ h('span', {}, t(heldSat > 0 ? 'delAcctSureFunds' : 'delAcctSure'))),
+ h('div', { class: 'row gap6' },
+ h('button', { class: 'btn-ghost grow', disabled: d.busy, onClick: back }, t('back')),
+ h('button', { class: 'btn-danger grow', disabled: !info || !d.sure || blocked || d.busy, onClick: go },
+ d.busy ? h('span', { class: 'spinner sm' }) : t('delAcctGo')))));
+}
+
function recoveryCard(a) {
const isWatch = a.type === 'watch' || (!a.mnemonic && !a.xprv);
const shown = ui.revealShown;
@@ -3619,6 +3906,23 @@ function recoveryCard(a) {
);
}
+// The balance cards' gears, reachable from Settings → Wallet too: "I simply
+// cannot find this option" (Nils, 2026-11-12) about the Spending card's gear.
+function balanceSettingsCard(a) {
+ if (a.id !== activeId) return null;
+ const views = a.kind ? [a.kind] : spendingActive() ? ['spending', 'savings'] : ['savings'];
+ const row = (view) => h('button', {
+ class: 'btn-ghost btn-block balance-settings-row', 'data-view': view,
+ style: 'display:flex;align-items:center;gap:10px;text-align:left;padding:10px 4px',
+ onClick: () => openWalletSettings(view),
+ },
+ h('span', { style: 'display:flex;color:var(--muted)', html: GEAR_ICON(18) }),
+ h('span', { class: 'col', style: 'flex:1;gap:2px' },
+ h('span', { style: 'font-weight:600' }, viewLabel(a, view)),
+ h('span', { class: 'small muted' }, t(view === 'spending' ? 'spendingSettingsDesc' : 'savingsSettingsDesc'))),
+ h('span', { class: 'muted' }, '›'));
+ return h('div', { class: 'card col', style: 'gap:4px' }, h('h3', {}, t('balanceSettingsTitle')), ...views.map(row));
+}
function walletNameCard(a) {
const saveName = () => {
const v = (ui.editLabel || '').trim();
@@ -4096,14 +4400,14 @@ function onboardScreen() {
const startCreate = () => {
ui.unlockTab = 'create';
ui.unlockError = '';
- ui.createStep = 'gen';
+ ui.createStep = 'intro';
if (!ui.draftMnemonic) ui.draftRandom = ui.draftMnemonic = newMnemonic();
o.step = 'seed';
render();
};
// All the sign-in doors (Google, passkey, Nostr) — the feature supplies
// them; the single-button fallback keeps builds without it working.
- const signinDoors = () => featureHook('frontDoorSignin')
+ const signinDoors = (opts) => featureHook('frontDoorSignin', opts)
|| h('button', { class: 'btn-block', style: 'padding:14px', onClick: () => {
o.step = 'signin';
ui.nostrLoginOpen = true;
@@ -4112,29 +4416,57 @@ function onboardScreen() {
h('span', { style: 'display:flex;flex-shrink:0', html: NOSTR_MARK }),
t('nlSignIn')));
if (o.step === 'welcome') {
+ // Two ways in, nothing else: a new wallet, or one you already have. The
+ // front page had grown a door per sign-in method (Google, passkey, Nostr,
+ // twelve words, coinos.io) and read as a form; they now wait one step
+ // further in, behind the choice that leads to them.
return page([
h('div', { class: 'onb-hero' }, brandHeader(false)),
title(t('onbWelcomeTitle')),
- // 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: startCreate,
- }, t('onbStart')),
- h('div', { class: 'onb-or' }, h('span', {}, t('onbOrSignIn'))),
- signinDoors(),
- // The move from the original coinos.io used to wait behind the
- // Spending offer, where nobody looking for it found it. Its own door
- // on the front page: it stashes the intent the /migrate link stashes
- // (the move needs a wallet and a payment address first) and explains
- // that before any twelve words appear.
- h('button', { id: 'onb-migrate', class: 'btn-block', style: 'padding:14px', onClick: () => {
- try { localStorage.setItem(MIGRATE_LINK_KEY, String(Date.now())); } catch {}
- ui.onbError = '';
- o.step = 'migrate';
- render();
- } }, t('onbMigrateDoor')),
+ // spaced as the sign-in list is (10px)
+ h('div', { class: 'col', style: 'gap:10px' },
+ 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: startCreate,
+ }, t('onbStart')),
+ h('button', {
+ id: 'onb-signin',
+ class: 'btn-block', style: 'font-size:17px;padding:14px',
+ onClick: () => { ui.onbError = ''; o.step = 'doors'; render(); },
+ }, t('onbSignInExisting'))),
+ ui.onbError ? h('div', { class: 'notice err' }, ui.onbError) : null,
+ ]);
+ }
+ if (o.step === 'doors') {
+ return page([
+ h('div', { class: 'onb-hero' }, brandHeader(false)),
+ title(t('onbSigninTitle')),
+ // The seed phrase first: it IS the wallet, and works anywhere. It
+ // used to hide as a line under the freshly minted words behind
+ // "Create a new wallet", where a person signing in on their second
+ // phone found it only by chance (user feedback, 2026-11-02).
+ // one evenly spaced list: seed phrase, passkey, Google, Nostr, coinos.io
+ h('div', { class: 'col', style: 'gap:10px' },
+ h('button', { id: 'onb-restore', class: 'btn-block', style: 'padding:14px', onClick: () => {
+ clearSeedDrafts();
+ ui.unlockTab = 'import';
+ ui.unlockError = '';
+ ui.onbError = '';
+ o.step = 'seed';
+ render();
+ } }, t('onbRestoreDoor')),
+ signinDoors({ short: true }),
+ // The move from the original coinos.io: it stashes the intent the
+ // /migrate link stashes (the move needs a wallet and a payment address
+ // first) and explains that before any twelve words appear.
+ h('button', { id: 'onb-migrate', class: 'btn-block', style: 'padding:14px', onClick: () => {
+ try { localStorage.setItem(MIGRATE_LINK_KEY, String(Date.now())); } catch {}
+ ui.onbError = '';
+ o.step = 'migrate';
+ render();
+ } }, t('onbMigrateDoor'))),
+ h('button', { class: 'linklike small', onClick: () => { ui.onbError = ''; o.step = 'welcome'; render(); } }, t('back')),
ui.onbError ? h('div', { class: 'notice err' }, ui.onbError) : null,
]);
}
@@ -4150,8 +4482,8 @@ function onboardScreen() {
class: 'btn-primary btn-block', style: 'font-size:17px;padding:14px', onClick: startCreate,
}, t('onbMigrateCreate')),
h('div', { class: 'onb-or' }, h('span', {}, t('onbOrSignIn'))),
- signinDoors(),
- h('button', { class: 'linklike small', onClick: () => { endMigrateLink(); ui.onbError = ''; o.step = 'welcome'; render(); } }, t('back')),
+ h('div', { class: 'col', style: 'gap:10px' }, signinDoors()),
+ h('button', { class: 'linklike small', onClick: () => { endMigrateLink(); ui.onbError = ''; o.step = 'doors'; render(); } }, t('back')),
ui.onbError ? h('div', { class: 'notice err' }, ui.onbError) : null,
]);
}
@@ -4170,27 +4502,36 @@ function onboardScreen() {
ui.tab = 'receive';
render();
};
+ // Later means later, not never: seedSeen stays false, so the wallet
+ // keeps a quiet reminder until the phrase has actually been seen.
+ // Except on the way to a coinos.io move: the balance about to arrive
+ // must have its twelve words written down first, so Later isn't offered.
+ const later = migrateLinkPending() ? null : h('button', { class: 'btn-ghost btn-block', onClick: () => {
+ if (canOfferSpending()) { o.step = 'spend'; o.enterAddr = undefined; render(); return; }
+ ui.onb = null;
+ try { localStorage.removeItem(ONB_STEP_KEY); } catch {}
+ ui.account = 'savings';
+ try { localStorage.setItem(ACCOUNT_KEY, 'savings'); } catch {}
+ ui.tab = 'receive';
+ render();
+ } }, t('onbBackupLater'));
+ // a heads-up first: the next screen shows the secret (Adam, 2026-11-07)
+ if (!o.backupShown) {
+ return page([
+ title(t('onbBackupTitle')),
+ h('p', { class: 'muted', style: 'margin:0' }, t('onbBackupBody')),
+ h('p', { class: 'muted', style: 'margin:0' }, t('seedIntroPrivate')),
+ h('button', { id: 'onb-backup-show', class: 'btn-primary btn-block', style: 'padding:14px', onClick: () => { o.backupShown = true; render(); } }, t('onbBackupShow')),
+ later,
+ ]);
+ }
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', style: '--rows-3:' + Math.ceil(words.length / 3) + ';--rows-2:' + Math.ceil(words.length / 2) }, 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.
- // Except on the way to a coinos.io move: the balance about to arrive
- // must have its twelve words written down first, so Later isn't offered.
- migrateLinkPending() ? null : h('button', { class: 'btn-ghost btn-block', onClick: () => {
- if (canOfferSpending()) { o.step = 'spend'; o.enterAddr = undefined; render(); return; }
- ui.onb = null;
- try { localStorage.removeItem(ONB_STEP_KEY); } catch {}
- ui.account = 'savings';
- try { localStorage.setItem(ACCOUNT_KEY, 'savings'); } catch {}
- ui.tab = 'receive';
- render();
- } }, t('onbBackupLater')),
+ later,
]);
}
if (o.step === 'seed') {
@@ -4198,10 +4539,10 @@ function onboardScreen() {
// 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')),
+ title(t(ui.unlockTab === 'import' ? 'onbRestoreTitle' : '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')),
+ ui.entropyPage ? null : h('button', { class: 'linklike small', onClick: () => { if (createStepBack()) return; ui.unlockError = ''; o.step = ui.unlockTab === 'import' ? 'doors' : 'welcome'; render(); } }, t('back')),
]);
}
if (o.step === 'signin') {
@@ -4209,7 +4550,7 @@ function onboardScreen() {
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')),
+ h('button', { class: 'linklike small', onClick: () => { o.step = 'doors'; render(); } }, t('back')),
]);
}
if (o.step === 'spend') {
@@ -6469,6 +6810,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: () => {
@@ -6911,14 +7254,16 @@ loadLocale(getLang()).finally(async () => {
const storeReady = featureHook('arkStoreReady');
// the IndexedDB caches (profiles, reports, …) open alongside: a wallet's
// caches are read as soon as it opens
- await Promise.all([storeReady, cacheReady()].filter(Boolean)).catch(() => {});
+ await Promise.all([storeReady, cacheReady(), warmBootSeeds()].filter(Boolean)).catch(() => {});
// Ask the browser not to evict this site's storage under disk pressure.
// Chrome answers without a prompt (yes for an installed or busy site).
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');
+ initPullRefresh(); // the app and the Beta have no browser pull-to-refresh
setTimeout(bootSettled, 8000);
if (WAKE_PAYLOAD != null) {
// Nothing opened — a locked vault, or no wallet on this device. There is