aboutsummaryrefslogtreecommitdiffstats
path: root/src/app.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/app.js')
-rw-r--r--src/app.js626
1 files changed, 492 insertions, 134 deletions
diff --git a/src/app.js b/src/app.js
index 416fd59..23a1d42 100644
--- a/src/app.js
+++ b/src/app.js
@@ -11,8 +11,9 @@ 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, MediaUploadError } from './media-upload.js';
+import { uploadPublicMedia, uploadStream, mediaUploadSigner, MediaUploadError } from './media-upload.js';
import { NOSTR_MARK } from './features/nostrlogin.js';
import { scanQr } from './scan.js';
import { dataSources, getSource, setSource, getNetwork, setNetwork, NETWORKS } from './api.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,10 +595,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.
@@ -631,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) => {
@@ -850,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) {
@@ -1108,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) {
@@ -1200,31 +1255,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());
}
@@ -1270,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;
@@ -1301,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' },
@@ -1408,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
@@ -1435,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) },
@@ -1446,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
@@ -1563,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 = '';
}
@@ -1601,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' },
@@ -1641,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;
@@ -1852,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();
}
@@ -2100,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 {}
@@ -2151,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;
}
@@ -2306,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();
@@ -2447,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),
@@ -2664,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 });
@@ -2687,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;
@@ -2974,7 +3209,7 @@ 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)] : []);
+ case 'wallet': return page(a ? [recoveryCard(a), walletNameCard(a), pubkeyCard(a), historyExportCard()] : []);
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
@@ -3269,7 +3504,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
@@ -3279,7 +3514,7 @@ function goHome() {
ui.nostrLoginOpen = false;
ui.screen = 'unlock';
ui.unlockTab = 'create';
- ui.createStep = 'gen';
+ ui.createStep = 'intro';
ui.draftMnemonic = '';
clearSeedDrafts();
ui.entropyPage = null;
@@ -3298,7 +3533,7 @@ function goHome() {
} else {
ui.screen = 'unlock';
ui.unlockTab = 'create';
- ui.createStep = 'gen';
+ ui.createStep = 'intro';
ui.draftMnemonic = '';
ui.confirm = [];
ui.unlockError = '';
@@ -3378,8 +3613,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);
@@ -3631,6 +3866,46 @@ function pubkeyCard(a) {
);
}
+// Every payment in both balances as a CSV file, for a spreadsheet or an
+// accountant: Savings' on-chain transactions and Spending's movements (the
+// rows the features list in History, each with its `csv` line), oldest
+// first, priced in the chosen currency at the time each happened.
+function historyCsv() {
+ const cur = getCurrency();
+ const lines = [];
+ for (const tx of wallet.history || []) {
+ const out = tx.net < 0, fee = out ? tx.fee || 0 : 0;
+ lines.push({ ts: tx.confirmed && tx.blockTime ? tx.blockTime * 1000 : Date.now(), account: t('savingLabel'), type: out ? 'send' : 'receive', rail: 'onchain',
+ amount: out ? tx.net + fee : tx.net, fee, net: tx.net, status: tx.confirmed ? 'complete' : 'pending', ref: tx.txid, to: '', invoice: '', note: '' });
+ }
+ for (const [sel, account] of [['savings', t('savingLabel')], ['spending', t('spendingLabel')]])
+ for (const e of featureAll('historyEntries', wallet.history || [], sel)) if (e.csv) lines.push({ ...e.csv, account });
+ lines.sort((a, b) => a.ts - b.ts);
+ const q = (v) => (v == null ? '' : /[",\n\r]/.test(String(v)) ? '"' + String(v).replace(/"/g, '""') + '"' : String(v));
+ const head = ['Date (UTC)', 'Account', 'Type', 'Rail', 'Amount (sats)', 'Fee (sats)', 'Net (sats)', `Net (${cur} at the time)`, 'Status', 'Transaction / payment hash', 'To', 'Invoice', 'Note'];
+ const rows = lines.map((l) => {
+ const { rate, stale } = rateAt(l.ts / 1000);
+ const fiat = rate && !stale ? ((l.net / 1e8) * rate).toFixed(2) : '';
+ return [new Date(l.ts).toISOString().replace('T', ' ').slice(0, 19), l.account, l.type, l.rail, l.amount, l.fee || 0, l.net, fiat, l.status, l.ref, l.to, l.invoice, l.note];
+ });
+ return [head, ...rows].map((r) => r.map(q).join(',')).join('\r\n') + '\r\n';
+}
+function exportHistory() {
+ const name = `coinos-history-${new Date().toISOString().slice(0, 10)}.csv`;
+ const url = URL.createObjectURL(new Blob([historyCsv()], { type: 'text/csv;charset=utf-8' }));
+ const a = h('a', { href: url, download: name, style: 'display:none' });
+ document.body.append(a);
+ a.click();
+ a.remove();
+ setTimeout(() => URL.revokeObjectURL(url), 60_000);
+}
+function historyExportCard() {
+ return h('div', { class: 'card col' },
+ h('h3', {}, t('historyExportTitle')),
+ h('p', { class: 'small muted', style: 'margin:0' }, t('historyExportDesc')),
+ h('button', { class: 'btn-block history-export', disabled: !wallet.loaded, onClick: exportHistory }, t('historyExportBtn')));
+}
+
function autolockCard(a) {
return h('div', { class: 'card col' },
h('h3', {}, t('autolockTitle')),
@@ -3855,6 +4130,7 @@ function mediaProgress(stage, fraction) {
}
async function publicMediaUpload(file) {
try {
+ const signEvent = await mediaUploadSigner(wallet, featureHook);
// a phone video is shrunk on the device first (see video-compress.js);
// a long one becomes an HLS stream, its master playlist the address
if (/^video\//.test(file?.type || '')) {
@@ -3863,18 +4139,24 @@ async function publicMediaUpload(file) {
const stream = await streamVideo(file, (p) => mediaProgress('shrink', p));
if (stream) {
mediaProgress('upload', 0);
- const url = await uploadStream(stream, (event) => wallet.nostrSign(event), { onProgress: (p) => mediaProgress('upload', p) });
+ const url = await uploadStream(stream, signEvent, { onProgress: (p) => mediaProgress('upload', p) });
return { url, m: 'application/vnd.apple.mpegurl', dim: stream.dim, duration: Math.round(stream.seconds) };
}
mediaProgress('shrink', 0);
file = await compressVideo(file, (p) => mediaProgress('shrink', p));
}
mediaProgress('upload', 0);
- return await uploadPublicMedia(file, (event) => wallet.nostrSign(event), { onProgress: (p) => mediaProgress('upload', p) });
+ return await uploadPublicMedia(file, signEvent, { onProgress: (p) => mediaProgress('upload', p) });
} catch (error) {
+ if (error instanceof MediaUploadError && error.code === 'no-identity') throw new Error(t('msgNoIdentity'));
+ 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;
}
@@ -4027,14 +4309,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;
@@ -4043,29 +4325,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,
]);
}
@@ -4081,8 +4391,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,
]);
}
@@ -4101,27 +4411,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') {
@@ -4129,10 +4448,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') {
@@ -4140,21 +4459,23 @@ 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') {
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')),
@@ -4374,6 +4695,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 {}
@@ -4435,6 +4765,7 @@ function walletScreen() {
brandHeader(true),
h('div', { class: 'mt16' }, balanceCard()),
ui.offlineFallback && wallet.offline ? offlineBanner() : null,
+ safeBootNotice(),
backupNotice(),
...featureAll('walletNotices'),
tabsBar(),
@@ -4493,6 +4824,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',
@@ -6370,6 +6719,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: () => {
@@ -6396,6 +6747,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
@@ -6781,6 +7133,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).
@@ -6806,16 +7159,21 @@ 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
- 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
// nothing to answer with, so say so now.