aboutsummaryrefslogtreecommitdiffstats
path: root/src/app.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/app.js')
-rw-r--r--src/app.js363
1 files changed, 283 insertions, 80 deletions
diff --git a/src/app.js b/src/app.js
index 3f1daf3..99cf565 100644
--- a/src/app.js
+++ b/src/app.js
@@ -16,7 +16,7 @@ import { nip98Header } from './nip98.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';
+import { dataSources, getSource, setSource, getNetwork, setNetwork, NETWORKS, getArkConfig } from './api.js';
import { STAGING } from './build-flags.js';
import { buildFeatures, loadDeferredFeatures } from './features/index.js';
import { cacheReady, cacheStore } from './cache-store.js';
@@ -375,49 +375,42 @@ function setupFooter() {
return h('div', { class: 'footer small muted center' },
h('div', { class: 'row gap6', style: 'justify-content:center' }, lang));
}
+// The footer is the display preferences alone: the language this reads in,
+// and the currency amounts are shown in. Everything else that used to sit
+// here (credits, How it works, support, install, the theme, the commit) is
+// on Settings → About (Adam, 2026-11-15).
function footer() {
- return h(
- 'div',
- { class: 'footer small muted center' },
- h(
- 'div',
- {},
- t('footerMadeBy') + ' ',
- h('a', { href: 'https://adamsoltys.com', target: '_blank', rel: 'noopener' }, 'Adam Soltys'),
- h('span', { class: 'faint' }, ' · '),
- t('footerSourceOn') + ' ',
- h('a', { href: 'https://git.coinos.io/coinosv3.git/', target: '_blank', rel: 'noopener' }, 'git.coinos.io')
- ),
- h(
- 'div',
- { style: 'margin-top:4px' },
- h('button', { class: 'linklike', style: 'font-weight:400', onClick: openHowItWorks }, t('howItWorks')),
- // the hat shop: needs a wallet to pay from, so only once one is open
+ // with a wallet open they're in Settings (Adam, 2026-11-15); a visitor
+ // reading the public feed still gets them here
+ if (activeAccount()) return h('div', { class: 'footer', style: 'display:none' });
+ return h('div', { class: 'footer small muted center' },
+ h('div', { class: 'row gap6', style: 'justify-content:center' }, languagePicker(), currencyPicker()));
+}
+
+function aboutSettingsView() {
+ const row = (label, onClick, attrs = {}) => h('button', { class: 'btn-block', onClick, ...attrs }, label);
+ return h('div', { class: 'col', style: 'gap:16px' },
+ h('div', { class: 'card col about-card', style: 'gap:10px' },
+ h('h3', {}, t('aboutTitle')),
+ h('p', { class: 'small muted', style: 'margin:0' },
+ t('footerMadeBy').replace(/^./, (c) => c.toUpperCase()) + ' ',
+ h('a', { href: 'https://adamsoltys.com', target: '_blank', rel: 'noopener' }, 'Adam Soltys'),
+ '. ' + t('footerSourceOn').replace(/^./, (c) => c.toUpperCase()) + ' ',
+ h('a', { href: 'https://git.coinos.io/coinosv3.git/', target: '_blank', rel: 'noopener' }, 'git.coinos.io'), '.'),
+ row(t('howItWorks'), openHowItWorks),
+ // the hat shop pays from the wallet, so only once one is open
activeAccount() && FEATURES.some((f) => f.openHatShop)
- ? h('span', {}, h('span', { class: 'faint' }, ' · '),
- h('button', { class: 'linklike', style: 'font-weight:400', onClick: () => { clearFeatureNav(); ui.screen = 'wallet'; featureHook('openHatShop'); } }, t('footerSupport')))
+ ? row(t('footerSupport'), () => { clearFeatureNav(); ui.screen = 'wallet'; featureHook('openHatShop'); }, { id: 'about-support' })
: null,
- // Chrome no longer prompts to install on its own — surface our own link
- // once it reports the app is installable. We render from a persisted flag
- // (not the live event) so the link is present on the first paint after a
- // refresh, avoiding a layout shift when beforeinstallprompt fires late.
- installable()
- ? h('span', {}, h('span', { class: 'faint' }, ' · '),
- h('button', { class: 'linklike', style: 'font-weight:400', onClick: triggerInstall }, t('installApp')))
- : null,
- h('span', { class: 'faint' }, ' · '),
- h('button', { class: 'linklike', style: 'font-weight:400', onClick: toggleTheme }, resolvedTheme() === 'dark' ? t('lightMode') : t('darkMode'))
- ),
- // Display preferences live together: the language this reads in, and the
- // money amounts are shown in. (The currency sat under Settings → Network
- // for exactly one day, where nobody found it.)
- h('div', { class: 'row gap6', style: 'margin-top:8px;justify-content:center' }, languagePicker(), currencyPicker()),
- // The commit stamp settles "is my PWA fresh?" AND "what code is this?" in
- // one glance — its own last line, so it never wraps the credits on mobile.
- h('div', { class: 'faint', style: 'margin-top:6px' },
- 'Running git commit ',
- h('a', { class: 'faint', href: 'https://git.coinos.io/coinosv3.git/commit/?id={{COMMIT}}', target: '_blank', rel: 'noopener' }, '{{COMMIT}}'))
- );
+ // Chrome no longer prompts to install by itself: our own button, once
+ // it has said the app is installable (a persisted flag, see installable)
+ installable() ? row(t('installApp'), triggerInstall) : null,
+ row(resolvedTheme() === 'dark' ? t('lightMode') : t('darkMode'), toggleTheme, { id: 'about-theme' }),
+ // "is my PWA fresh?" and "what code is this?" in one line
+ h('div', { class: 'small faint', style: 'margin-top:2px' },
+ 'Running git commit ',
+ h('a', { class: 'faint', href: 'https://git.coinos.io/coinosv3.git/commit/?id={{COMMIT}}', target: '_blank', rel: 'noopener' }, '{{COMMIT}}'))),
+ h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.settingsPage = null; render(); } }, t('back')));
}
const THEME_KEY = 'btc-wallet-theme';
@@ -1883,7 +1876,16 @@ async function enterWallet(mnemonic, passphrase, opts = {}) {
// it — Google, a passkey and a nostr key all end up at a seed the user has
// never seen, and which nobody can reissue for them. That is the one case
// worth asking about, so it's recorded once and never guessed at again.
- if (acc.seedSeen === undefined) acc.seedSeen = !opts.nostrPubkey;
+ // A sign-up (Create an account) shows no phrase either: it stays one tap
+ // away in Settings, next to the other ways back in (passkey, Google, nostr).
+ if (acc.seedSeen === undefined) acc.seedSeen = !opts.nostrPubkey && !opts.unseen;
+ if (opts.signup && !acc.signupAt) acc.signupAt = Date.now();
+ // Spending first: an account made by signing up has no Savings side until
+ // its owner adds one (or on-chain money turns up — see savingsEvident). A
+ // recovery sign-in carries the same shape over from the account's backup.
+ if (opts.spendingOnly && !acc.kind && !acc.deriveIndex && !acc.savingsAdded && acc.type === 'full') {
+ acc.kind = 'spending'; acc.savingsLater = true;
+ } else if (opts.signup && acc.kind === 'spending') acc.savingsLater = true;
await activateAccount(acc, { ...opts, fresh: true });
}
@@ -2193,7 +2195,12 @@ function defaultLabel(type) {
function loadWatchAccounts() {
try {
const dir = JSON.parse(localStorage.getItem(WATCH_KEY) || '[]');
- return dir.filter((d) => d.xpub).map((d) => ({ id: d.id, label: d.label, type: 'watch', xpub: d.xpub, autoLock: d.autoLock || 0, network: d.network, nostrPk: d.nostrPk }));
+ return dir.filter((d) => d.xpub).map((d) => ({ id: d.id, label: d.label, type: 'watch', xpub: d.xpub, autoLock: d.autoLock || 0, network: d.network, nostrPk: d.nostrPk,
+ // a sign-up's Spending-only shape and its ways back in outlive the session
+ ...(d.spendingOnly ? { kind: 'spending', savingsLater: true } : {}),
+ ...(d.signupAt ? { signupAt: d.signupAt } : {}),
+ ...(d.waysBackPrompted ? { waysBackPrompted: d.waysBackPrompted } : {}),
+ ...(Array.isArray(d.recovery) && d.recovery.length ? { recovery: d.recovery } : {}) }));
} catch { return []; }
}
function saveDirectory() {
@@ -2210,7 +2217,11 @@ function saveDirectory() {
|| (wallet.nostrPubkey && wallet.nostrPubkey()) || a.nostrPk;
} catch {}
}
- return { id: a.id, label: a.label, xpub: a.xpub, autoLock: a.autoLock || 0, network: a.network, nostrPk: a.nostrPk, seedSeen: !!a.seedSeen };
+ return { id: a.id, label: a.label, xpub: a.xpub, autoLock: a.autoLock || 0, network: a.network, nostrPk: a.nostrPk, seedSeen: !!a.seedSeen,
+ ...(a.kind === 'spending' && a.savingsLater ? { spendingOnly: true } : {}),
+ ...(a.signupAt ? { signupAt: a.signupAt } : {}),
+ ...(a.waysBackPrompted ? { waysBackPrompted: a.waysBackPrompted } : {}),
+ ...(a.recovery && a.recovery.length ? { recovery: a.recovery } : {}) };
});
localStorage.setItem(WATCH_KEY, JSON.stringify(dir));
} catch {}
@@ -3231,13 +3242,17 @@ function settingsTab() {
// matters if the phone is lost tonight
case 'wallet':
if (ui.delAcct && a) return deleteAccountView(a);
- return page(a ? [recoveryCard(a), balanceSettingsCard(a), walletNameCard(a), pubkeyCard(a), historyExportCard(), deleteAccountCard(a)] : []);
+ // the phrase is one of the ways back in: its card shows only once
+ // Pen and paper asks for it (accounts without the card keep it always)
+ { const ways = a ? featureHook('waysBackCard') : null;
+ return page(a ? [ways, ways && !ui.revealShown ? null : 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
case 'nostr': return nostrSettingsView();
case 'notifications': return page(featureAll('notifySettingsCards').reverse());
case 'advanced': return advancedSettingsView();
+ case 'about': return aboutSettingsView();
}
const I = (d) => '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">' + d + '</svg>';
const TILE_ICONS = {
@@ -3248,6 +3263,7 @@ function settingsTab() {
// a till receipt: paper with a torn foot and two lines of figures
pos: I('<path d="M6 3h12v18l-2-1.5-2 1.5-2-1.5-2 1.5-2-1.5L6 21z"/><line x1="9" y1="8" x2="15" y2="8"/><line x1="9" y1="12" x2="15" y2="12"/><line x1="12" y1="16" x2="15" y2="16"/>'),
notifications: I('<path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/>'),
+ about: I('<circle cx="12" cy="12" r="9"/><line x1="12" y1="11" x2="12" y2="16.5"/><circle cx="12" cy="7.8" r=".6" fill="currentColor"/>'),
advanced: I('<line x1="4" y1="8" x2="20" y2="8"/><line x1="4" y1="16" x2="20" y2="16"/><circle cx="9" cy="8" r="2.2" fill="var(--surface,#fff)"/><circle cx="15" cy="16" r="2.2" fill="var(--surface,#fff)"/>'),
};
const tile = (id, label, desc) =>
@@ -3266,6 +3282,8 @@ function settingsTab() {
h('div', { class: 'row gap6', style: 'align-items:center;margin:2px 0 6px 4px' },
h('span', { style: 'display:flex;color:var(--muted)', html: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>' }),
h('h3', { style: 'margin:0' }, t('tabSettings'))),
+ // display preferences, right at the top: the language and the currency
+ h('div', { class: 'row gap6 settings-prefs', style: 'margin:0 0 4px' }, languagePicker(), currencyPicker()),
grid([
tile('wallet', t('settingsWallet'), t('settingsWalletDesc')),
tile('payments', t('settingsPayments'), t('settingsPaymentsDesc')),
@@ -3273,6 +3291,7 @@ function settingsTab() {
tile('nostr', t('nostrSettings'), t('settingsNostrDesc')),
tile('notifications', t('settingsNotifications'), t('settingsNotifDesc')),
tile('advanced', t('advancedSettings'), t('settingsAdvancedDesc')),
+ tile('about', t('aboutTitle'), t('settingsAboutDesc')),
]),
h('button', { class: 'btn-ghost btn-block', style: 'margin-top:6px', onClick: () => { ui.tab = wallet.offline ? 'settings' : 'history'; render(); } }, t('back'))
);
@@ -3891,12 +3910,12 @@ function recoveryCard(a) {
// a warning) before anything readable is over your shoulder. The second
// actually shows them.
if (!shown)
- return h('div', { class: 'card col' },
+ return h('div', { class: 'card col recovery-card' },
h('h3', {}, t('recoveryPhrase')),
h('p', { class: 'small muted', style: 'margin:0' }, t('recoveryDesc')),
h('button', { class: 'btn-block', onClick: () => { ui.revealShown = 'masked'; render(); } }, t('revealRecovery')));
const unmasked = shown === 'words';
- return h('div', { class: 'card col' },
+ return h('div', { class: 'card col recovery-card' },
h('h3', {}, t('recoveryPhrase')),
h('div', { class: 'warn-box' }, t('recoveryWarn')),
h('div', { class: 'words', style: '--rows-3:' + Math.ceil(words.length / 3) + ';--rows-2:' + Math.ceil(words.length / 2) }, words.map((w, i) =>
@@ -3929,17 +3948,25 @@ function recoveryCard(a) {
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));
+ // Each balance wears its own mark in a tinted tile — the bolt and the
+ // bitcoin disc the balance card uses — rather than a gear beside one and a
+ // coin beside the other (Adam, 2026-11-15: "Icons and padding need work").
+ const BOLT = '<svg width="21" height="21" viewBox="0 0 24 24" fill="currentColor" style="display:block"><path d="M13.5 2 4 13.5h6.5L9.5 22 20 9.5h-6.6z"/></svg>';
+ const tile = (view) => h('span', { class: 'bs-tile ' + (view === 'spending' ? 'spend' : 'save'), html: view === 'spending' ? BOLT : BITCOIN_ICON(22) });
+ const rowOf = (attrs, view, title, desc, tail) => h('button', { class: 'balance-settings-row', ...attrs },
+ tile(view),
+ h('span', { class: 'col bs-text' },
+ h('span', { class: 'bs-title' }, title),
+ h('span', { class: 'small muted' }, desc)),
+ h('span', { class: 'bs-tail', 'aria-hidden': 'true' }, tail));
+ const row = (view) => rowOf({ 'data-view': view, onClick: () => openWalletSettings(view) }, view,
+ viewLabel(a, view), t(view === 'spending' ? 'spendingSettingsDesc' : 'savingsSettingsDesc'), '›');
+ // a sign-up's Spending-only account: Savings is one tap away
+ const addRow = a.kind === 'spending' && a.savingsLater && !a.deriveIndex
+ ? rowOf({ id: 'add-savings', onClick: () => { addSavings(a); toast(t('savingsAddedToast')); } }, 'savings',
+ t('addSavings'), t('addSavingsDesc'), '+')
+ : null;
+ return h('div', { class: 'card col', style: 'gap:8px' }, h('h3', {}, t('balanceSettingsTitle')), ...views.map(row), addRow);
}
function walletNameCard(a) {
const saveName = () => {
@@ -4274,6 +4301,21 @@ async function publicMediaUpload(file) {
// Whether the wizard can offer Spending at all: an Ark-capable build on a
// network with the names registrar, for a full (signing) account. Elsewhere
// the offer would be a spinner that never resolves.
+// Signing up (Adam, 2026-11-15): "we should just have them claim a name and
+// pick their avatar/cover and they should be in." Where a name can be
+// claimed — an Ark server and the names registrar on this network — Create
+// makes the account straight away: no twelve words to copy or check, no
+// Savings side. The phrase waits in Settings with the other ways back in
+// (passkey, Google, nostr), and a quiet line on the wallet asks for one.
+// Elsewhere (regtest, a build without Ark) there's no name to claim, the
+// wallet IS its seed, and Create keeps the classic phrase flow.
+function quickSignupOk() {
+ if (!['mainnet', 'mutinynet'].includes(getNetwork())) return false;
+ try {
+ if (!FEATURES.some((f) => f.id === 'ark') || !FEATURES.some((f) => f.id === 'names')) return false;
+ return !!getArkConfig();
+ } catch { return false; }
+}
function canOfferSpending() {
const acc = activeAccount();
if (!acc || acc.type !== 'full' || wallet.watchOnly) return false;
@@ -4310,7 +4352,8 @@ function onboardScreen() {
// they lose the Google account and find out it was never a backup.
// Handled further down, where page()/title() exist.
const arrived = activeAccount();
- if (arrived && arrived.mnemonic && !arrived.seedSeen) o.step = 'backup';
+ // (a sign-up was never meant to see it: straight on to the name)
+ if (arrived && arrived.mnemonic && !arrived.seedSeen && !arrived.signupAt) o.step = 'backup';
else if (canOfferSpending()) {
// Before the home screen: one offer to set up Spending (a name and a
// payment address), with Not now a single tap away.
@@ -4318,10 +4361,11 @@ function onboardScreen() {
} else {
ui.onb = null;
try { localStorage.removeItem(ONB_STEP_KEY); } catch {}
- // On-chain first: the wizard's promise is "your keys, your coins", and
- // Spending stays out of sight until the user sets it up.
- ui.account = 'savings';
- try { localStorage.setItem(ACCOUNT_KEY, 'savings'); } catch {}
+ // On-chain first for a seed wallet: the wizard's promise is "your
+ // keys, your coins". A sign-up is Spending alone.
+ const home = arrived && arrived.kind ? arrived.kind : 'savings';
+ ui.account = home;
+ try { localStorage.setItem(ACCOUNT_KEY, home); } catch {}
ui.tab = 'receive';
return null; // the caller falls through to the wallet itself
}
@@ -4415,6 +4459,24 @@ function onboardScreen() {
}
// Making your own keys: the words are the wallet, so show them at once,
// no Generate to tap.
+ // Create an account: a fresh seed opens at once as a Spending-only
+ // account; the wizard carries on to the name and the look.
+ const startQuick = async () => {
+ if (o.creating) return;
+ o.creating = true; ui.onbError = ''; render();
+ try {
+ clearSeedDrafts();
+ ui.passphrase = ''; ui.confirmPass = ''; ui.draftMnemonic = '';
+ ui.newWalletKind = 'spending';
+ await enterWallet(newMnemonic(), '', { generated: true, unseen: true, signup: true });
+ } catch (e) {
+ ui.onbError = e.message || String(e);
+ } finally {
+ ui.newWalletKind = null;
+ o.creating = false;
+ render();
+ }
+ };
const startCreate = () => {
ui.unlockTab = 'create';
ui.unlockError = '';
@@ -4443,11 +4505,16 @@ function onboardScreen() {
title(t('onbWelcomeTitle')),
// 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')),
+ quickSignupOk()
+ ? h('button', {
+ // a stable hook for tests, so this button survives its own copy
+ id: 'onb-create', 'data-quick': '1',
+ class: 'btn-primary btn-block', style: 'font-size:17px;padding:14px', disabled: !!o.creating, onClick: startQuick,
+ }, o.creating ? h('span', { class: 'spinner sm' }) : t('onbCreateAccount'))
+ : h('button', {
+ 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',
@@ -4572,14 +4639,18 @@ function onboardScreen() {
]);
}
if (o.step === 'spend') {
+ // A sign-up's name is the account itself — what people pay, follow and
+ // message — so it's claimed here, not skipped.
+ const signupAcc = activeAccount();
+ const signup = !o.fromGift && !!(signupAcc && signupAcc.signupAt && signupAcc.kind === 'spending');
return page([
- title(t(o.fromGift ? 'onbGiftNameTitle' : 'onbSpendTitle')),
- h('p', { class: 'muted', style: 'margin:0' }, t(o.fromGift ? 'onbGiftNameBody' : 'onbSpendBody')),
+ title(t(o.fromGift ? 'onbGiftNameTitle' : signup ? 'onbNameTitle' : 'onbSpendTitle')),
+ h('p', { class: 'muted', style: 'margin:0' }, t(o.fromGift ? 'onbGiftNameBody' : signup ? 'onbNameBody' : '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 (or, after a
// gift, the account the gift arrived in).
- h('button', { class: 'btn-ghost btn-block', onClick: () => {
+ signup ? null : h('button', { class: 'btn-ghost btn-block', onClick: () => {
const home = o.home || 'savings';
ui.onb = null;
try { localStorage.removeItem(ONB_STEP_KEY); } catch {}
@@ -4737,7 +4808,8 @@ function onboardScreen() {
ui.tab = 'receive';
render();
} }, t('onbEnter')),
- h('button', { class: 'btn-ghost btn-block', onClick: () => { o.step = 'avatar'; render(); } }, t('back')),
+ // no Back: the name and the look are done, and both change anytime from
+ // the profile (Adam, 2026-11-15)
]);
}
@@ -4915,22 +4987,140 @@ wallet.registerCacheExtension({
},
});
+// ---- Savings, later --------------------------------------------------------
+// An account made by signing up is Spending alone (kind 'spending', marked
+// savingsLater): no on-chain face, no carousel, no Move money. Its owner adds
+// Savings from Settings → Wallet whenever they like — the same seed, account
+// 0, so it is the on-chain wallet that was always underneath. A balance must
+// never hide, so on-chain money or history arriving adds it by itself.
+function addSavings(acc, { quiet = false } = {}) {
+ if (!acc || acc.kind !== 'spending' || !acc.savingsLater) return false;
+ delete acc.kind; delete acc.savingsLater;
+ if (!acc.spendingSetup) acc.spendingSetup = Date.now();
+ acc.savingsAdded = Date.now();
+ persistAccounts();
+ if (acc.persisted) writeVault();
+ if (acc.id === activeId) { try { wallet.saveCache(); } catch {} }
+ if (!quiet) render();
+ return true;
+}
+function savingsEvident() {
+ const a = activeAccount();
+ if (!a || a.kind !== 'spending' || !a.savingsLater || !wallet.loaded) return;
+ if ((wallet.spendable || 0) > 0 || (wallet.pendingIncoming || 0) > 0 || (wallet.txs || []).length) addSavings(a, { quiet: true });
+}
+// The shape rides the synced snapshot, so the phone opens the account the
+// way the laptop has it: Spending-only until Savings is added on either.
+// Commutative: savingsAdded only ever turns on and wins over spendingOnly.
+wallet.registerCacheExtension({
+ mergeAlways: true,
+ save: () => {
+ const a = activeAccount();
+ if (!a) return {};
+ if (a.savingsAdded) return { savingsAdded: a.savingsAdded };
+ return a.kind === 'spending' && a.savingsLater ? { spendingOnly: true } : {};
+ },
+ load: (d) => {
+ const a = activeAccount();
+ if (!d || !a || a.type !== 'full' || a.deriveIndex) return;
+ if (d.savingsAdded) { if (addSavings(a, { quiet: true }) === false && !a.savingsAdded) { a.savingsAdded = d.savingsAdded; persistAccounts(); } return; }
+ if (d.spendingOnly && !a.kind && !a.savingsAdded && !(wallet.txs || []).length && !(wallet.spendable || 0)) {
+ a.kind = 'spending'; a.savingsLater = true;
+ persistAccounts();
+ }
+ },
+});
+
+// ---- ways back in ----------------------------------------------------------
+// The sign-in methods that can open this account somewhere else, besides its
+// recovery phrase: a passkey, a Google account, a nostr identity — each holds
+// a copy of the seed encrypted to its own key on the relays (see the
+// nostrlogin feature's addRecovery). Listed on the account so Settings can
+// say what's set up; the list rides the synced snapshot, union by key.
+function recoveryMethods(a = activeAccount()) {
+ return (a && Array.isArray(a.recovery)) ? a.recovery : [];
+}
+function sameSeed(a, x) {
+ return x.mnemonic && x.mnemonic === a.mnemonic && (x.passphrase || '') === (a.passphrase || '');
+}
+function mergeRecovery(list) {
+ const a = activeAccount();
+ if (!a || !a.mnemonic || !Array.isArray(list) || !list.length) return false;
+ let changed = false;
+ for (const x of accounts) {
+ if (x !== a && !sameSeed(a, x)) continue;
+ const have = new Map(recoveryMethods(x).map((m) => [m.id, m]));
+ for (const m of list) if (m && m.id && !have.has(m.id)) { have.set(m.id, m); changed = true; }
+ x.recovery = [...have.values()].sort((p, q) => (p.at || 0) - (q.at || 0));
+ }
+ if (changed) { persistAccounts(); if (a.persisted) writeVault(); }
+ return changed;
+}
+function addRecoveryMethod(m) {
+ if (!mergeRecovery([{ ...m, at: m.at || Date.now() }])) return;
+ try { wallet.saveCache(); } catch {}
+ render();
+}
+wallet.registerCacheExtension({
+ mergeAlways: true,
+ save: () => {
+ const a = activeAccount(), r = recoveryMethods();
+ return { ...(r.length ? { recovery: r } : {}), ...(a && a.waysBackPrompted ? { waysBackPrompted: a.waysBackPrompted } : {}) };
+ },
+ load: (d) => {
+ if (!d) return;
+ if (d.recovery) mergeRecovery(d.recovery);
+ // asked on another device: asked (only ever turns on)
+ const a = activeAccount();
+ if (d.waysBackPrompted && a && !a.waysBackPrompted) { a.waysBackPrompted = d.waysBackPrompted; persistAccounts(); }
+ },
+});
+
+// A sign-up is asked for a way back in ONCE, and only when there's something
+// to lose (Adam, 2026-11-15: "only prompt to add a way back in after there's
+// some balance and only if we haven't prompted before"). The ask is recorded
+// the moment it first shows — on the account, the directory and the synced
+// snapshot — and stays up for that visit until Add one or Not now.
+function waysBackPrompt(a) {
+ if (recoveryMethods(a).length) { if (ui.waysBackPromptFor === a.id) ui.waysBackPromptFor = null; return null; }
+ if (ui.waysBackPromptFor !== a.id) {
+ if (a.waysBackPrompted || !wallet.loaded) return null;
+ const bal = (featureHook('spendingSat') || 0) + (wallet.spendable || 0) + (wallet.pendingIncoming || 0);
+ if (bal <= 0) return null;
+ a.waysBackPrompted = Date.now();
+ persistAccounts();
+ try { wallet.saveCache(); } catch {}
+ ui.waysBackPromptFor = a.id;
+ }
+ const done = () => { ui.waysBackPromptFor = null; };
+ return h('div', { class: 'notice info col ways-back-prompt', style: 'margin:12px 0 0;gap:10px' },
+ h('span', {}, t('waysBackPrompt')),
+ h('div', { class: 'row gap6' },
+ h('button', { class: 'btn-sm btn-primary', onClick: () => { done(); ui.tab = 'settings'; ui.settingsPage = 'wallet'; render(); } }, t('waysBackReminderGo')),
+ h('button', { class: 'btn-sm btn-ghost', onClick: () => { done(); render(); } }, t('onbNotNow'))));
+}
+
// A wallet whose recovery phrase has never been on screen keeps one quiet
// line about it. "Later" is a real answer, so this is a row and not a modal —
// but it doesn't dismiss, because the risk doesn't go away on its own. It
// disappears the moment the phrase is actually seen (here or in settings).
function backupNotice() {
+ savingsEvident();
const a = activeAccount();
if (!a || !a.mnemonic || a.seedSeen || wallet.watchOnly) return null;
+ // A sign-up never saw a phrase and wasn't asked to: what it needs is A way
+ // back in, any one — and once it has one, nothing to nag about.
+ const signup = !!a.signupAt;
+ if (signup) return waysBackPrompt(a);
return h('div', { class: 'small muted', style: 'margin:10px 0 0;text-align:center' },
- t('backupReminder'),
+ t(signup ? 'waysBackReminder' : 'backupReminder'),
' ',
h('button', { class: 'linklike small', onClick: () => {
ui.tab = 'settings';
ui.settingsPage = 'wallet';
- ui.revealShown = 'masked';
+ if (!signup) ui.revealShown = 'masked';
render();
- } }, t('backupReminderGo')));
+ } }, t(signup ? 'waysBackReminderGo' : 'backupReminderGo')));
}
// Safe mode (see BOOT_GUARD in build.js): the app crashed on open twice in a
@@ -5154,7 +5344,9 @@ function balanceCard() {
})
: null,
h('div', { class: 'small faint', style: 'text-transform:uppercase;letter-spacing:.06em' },
- kindLocked ? viewLabel(acc0, view)
+ // a lone balance needs no name: blank unless its owner gave it
+ // one (Adam, 2026-11-15); the two-face carousel keeps its labels
+ kindLocked ? ((acc0.viewLabels && acc0.viewLabels[view]) || '')
: hasSpending ? (viewSpending ? t('spendingLabel') : t('savingLabel')) : t('balance'))),
manageBtn(view)),
h('div', { class: 'amt', style: firstLoad ? 'opacity:.3' : '' },
@@ -5587,7 +5779,8 @@ function tabContent() {
if (ui.tab === 'send' && sendFocused) return pane;
return h('div', { class: 'col', style: 'gap:16px' },
pane,
- h('div', { class: 'small faint', style: 'text-transform:uppercase;letter-spacing:.06em;margin-top:4px' }, t('tabHistory')),
+ // no heading over nothing: it comes with the first payment
+ hist ? h('div', { class: 'small faint', style: 'text-transform:uppercase;letter-spacing:.06em;margin-top:4px' }, t('tabHistory')) : null,
hist);
}
@@ -5675,7 +5868,8 @@ function receiveTab() {
{ class: 'card col', style: 'align-items:center;gap:14px' },
h('div', { html: qrSvg(ui.arkRecvAddr) }),
h('div', { class: 'addr-box break', style: 'width:100%' }, ui.arkRecvAddr),
- copyBtn(ui.arkRecvAddr, t('copyAddress'))
+ copyBtn(ui.arkRecvAddr, t('copyAddress')),
+ featureHook('posAvailable') ? h('button', { class: 'btn-block', id: 'recv-amount', onClick: () => featureHook('openAmountRequest') }, t('recvAmount')) : null
);
}
}
@@ -6620,8 +6814,9 @@ function historyTab() {
wallet.historyLoading ? h('p', { class: 'small muted', style: 'margin:0' }, t('loadingHistory')) : null
);
const featEntries = featureAll('historyEntries', txs, histSel);
- if (!txs.length && !featEntries.length)
- return h('div', { class: 'card' }, h('p', { class: 'muted center', style: 'margin:0' }, t('noTxYet')));
+ // nothing yet: no card at all, it appears with the first payment
+ // (Adam, 2026-11-15: "let's not show an empty history section")
+ if (!txs.length && !featEntries.length) return null;
// Transactions and feature entries merge into one timeline.
const entries = [
@@ -6857,6 +7052,14 @@ const ctx = {
getAccount: () => accountSel(),
setAccount: (a, dir) => setAccountSel(a, dir),
offerProfileSetup, pickConfirm,
+ // the ways back in (Settings → Wallet): what's set up, and adding one
+ recoveryMethods: () => recoveryMethods(), addRecoveryMethod,
+ spendingOnlyAccount: () => { const a = activeAccount(); return !!(a && a.kind === 'spending' && a.savingsLater); },
+ seedSeen: () => !!(activeAccount() || {}).seedSeen,
+ showRecoveryPhrase: () => {
+ ui.tab = 'settings'; ui.settingsPage = 'wallet'; ui.revealShown = ui.revealShown || 'masked'; render();
+ requestAnimationFrame(() => { try { document.querySelector('.recovery-card')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); } catch {} });
+ },
// 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