diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/app.js | 363 | ||||
| -rw-r--r-- | src/features/messages.js | 8 | ||||
| -rw-r--r-- | src/features/names.js | 2 | ||||
| -rw-r--r-- | src/features/nostrlogin.js | 130 | ||||
| -rw-r--r-- | src/features/pos.js | 30 | ||||
| -rw-r--r-- | src/i18n.js | 32 | ||||
| -rw-r--r-- | src/nostr-login.js | 12 | ||||
| -rw-r--r-- | src/passkey.js | 6 | ||||
| -rw-r--r-- | src/style.css | 27 |
9 files changed, 507 insertions, 103 deletions
@@ -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 diff --git a/src/features/messages.js b/src/features/messages.js index 318d1cc..d5d286a 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -18104,7 +18104,7 @@ export function messagesFeature(ctx) { // the message's own custom emoji (an edit brings its own set) const em = emojiTagMap((edit && edit.author === m.author ? edit.rumor : m.rumor).tags); return h( - 'div', { class: 'chat-row' + (mine ? ' mine' : '') + (grouped ? ' grouped' : '') }, + 'div', { class: 'chat-row group' + (mine ? ' mine' : '') + (grouped ? ' grouped' : '') }, grouped ? h('div', { class: 'chat-avatar spacer' }) : avatar(m.author), h('div', { class: 'chat-body' }, grouped ? null : h('div', { class: 'chat-meta' }, @@ -18146,8 +18146,7 @@ export function messagesFeature(ctx) { title: t('msgReact'), onClick: (e) => { e.stopPropagation(); sendReaction(room, chId, m, emoji); }, }, reactNode(emoji), n > 1 ? ' ' + n : ''))) - : null)(zapChip(m.rumor.id, { cls: 'chat-react', onClick: canZapPk(m.author) ? () => zapMessage(m.author, m.rumor.id) : null, onHold: () => openZapSettings(m.author, m.rumor.id) }))), - msgZapBtn(m.author, m.rumor.id))) + : null)(zapChip(m.rumor.id, { cls: 'chat-react', onClick: canZapPk(m.author) ? () => zapMessage(m.author, m.rumor.id) : null, onHold: () => openZapSettings(m.author, m.rumor.id) }))))) ); }); } @@ -18767,8 +18766,7 @@ export function messagesFeature(ctx) { ui.msgSheet = ui.msgSheet === m.rumor.id ? null : m.rumor.id; render(); }), - }, dmQuote(m), ...(m.rumor.kind === 15 ? [] : noteBody(m.rumor.content, 0, emojiTagMap(m.rumor.tags))), ...attachmentNodes(m.rumor, m.wn), dmChips(m)), - m.mine ? null : msgZapBtn(m.rumor.pubkey, m.rumor.id)), + }, dmQuote(m), ...(m.rumor.kind === 15 ? [] : noteBody(m.rumor.content, 0, emojiTagMap(m.rumor.tags))), ...attachmentNodes(m.rumor, m.wn), dmChips(m))), h('div', { class: 'chat-time' }, timeLabel(m.rumor.created_at * 1000))))) : [h('div', { class: 'muted small', style: 'text-align:center;padding:24px 0' }, t('msgNoDmsYet'))])), dmReplyBar(), diff --git a/src/features/names.js b/src/features/names.js index 3f23a0c..81b64e6 100644 --- a/src/features/names.js +++ b/src/features/names.js @@ -683,6 +683,8 @@ export function namesFeature(ctx) { onClick: () => copy(addr), html: '<svg width="15" height="15" 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>', }))), + // ask for a sum: the point of sale's amount pad and invoice, simplified + hook('posAvailable') ? h('button', { class: 'btn-block', id: 'recv-amount', onClick: () => hook('openAmountRequest') }, t('recvAmount')) : null, moreSection()); } diff --git a/src/features/nostrlogin.js b/src/features/nostrlogin.js index 4a811fc..6f700b1 100644 --- a/src/features/nostrlogin.js +++ b/src/features/nostrlogin.js @@ -158,6 +158,17 @@ export function nostrLoginFeature(ctx) { // and the payment name too — the Receive pane should open with its QR const nameHintP = ctx.hook('namesLookupByPk', signer.pubkey); const res = await walletForSigner(signer); + // A way back in (added from Settings): the key holds a copy of an + // account's seed, and opens that account as ITSELF — its own name, + // profile and identity. Nothing about the key is adopted or kept. + if (res.mode === 'restored' && res.recovery) { + try { if (signer.kind === 'bunker' && signer.close) signer.close(); } catch {} + ctx.onbNostrLogin(true); + ui.navAnimSkip = true; + await ctx.openMnemonic(res.mnemonic, res.passphrase || '', { spendingHint: !!res.spending, spendingOnly: !!res.spendingOnly }); + busy(false); + return; + } if (res.mode === 'new') { // No wallet on this identity yet — make one and walk straight in. // The interstitial that used to ask first read as an error screen to @@ -681,6 +692,124 @@ export function nostrLoginFeature(ctx) { resumeLogin().then((s) => { if (s) render(); }).catch(() => {}); } + // ---- ways back in ------------------------------------------------------ + // Settings → Wallet: the sign-in methods that can open THIS account on + // another device, added after the fact — a passkey, Google, a nostr key — + // next to the recovery phrase. Each one gets a copy of the seed encrypted + // to its own key (kind 30078, marked `recovery`), so signing in with it + // anywhere opens this account as itself. Unlike linking a login, the + // account's identity doesn't change. + async function addRecovery(makeSigner, via, label = '') { + ui.waysBackError = ''; ui.waysBackBusy = via; ui.nostrLoginBusy = true; render(); + let signer = null; + try { + if (!wallet.mnemonic) throw new Error(t('nlNeedSeed')); + signer = await makeSigner(); + const st = load(); + if (st.pubkey && st.pubkey === signer.pubkey) throw new Error(t('waysBackIsLogin')); + // a key that already opens a DIFFERENT wallet keeps it: never overwrite + const existing = await fetchWalletBackup(signer); + if (existing && existing.mnemonic + && (existing.mnemonic !== wallet.mnemonic || (existing.passphrase || '') !== (wallet.passphrase || ''))) { + throw new Error(t('waysBackTaken')); + } + await publishWalletBackup(signer, { + mnemonic: wallet.mnemonic, passphrase: wallet.passphrase || '', + spending: true, recovery: true, spendingOnly: !!ctx.spendingOnlyAccount(), + }); + ctx.addRecoveryMethod({ via, id: signer.pubkey, label }); + ui.waysBackNostr = false; + toast(t('waysBackAdded')); + } catch (e) { + const msg = String((e && e.message) || e || ''); + // a dismissed passkey or Google dialog is a plain cancel + if (!/NotAllowed|abort/i.test(String((e && e.name) || '') + ' ' + msg)) ui.waysBackError = msg; + } finally { + try { if (signer && signer.kind === 'bunker' && signer.close) signer.close(); } catch {} + ui.waysBackBusy = null; ui.nostrLoginBusy = false; + render(); + } + } + async function passkeyRecovery() { + const pass = await import('../passkey.js'); + const name = ctx.hook('namesAddress') || 'coinos'; + await addRecovery(async () => keySigner(await pass.passkeyCreate({ name, displayName: name })), 'passkey'); + } + async function googleRecovery() { + // opened in the click itself (user activation) — see googleLogin + const popup = window.open('about:blank', 'OAuth', 'width=600,height=600'); + ui.waysBackError = ''; ui.waysBackBusy = 'google'; render(); + try { + const pome = await import('../pomegranate.js'); + const { uri, email } = await pome.loginWithGoogle(popup, {}); + await addRecovery(() => bunkerSigner(uri, { onAuth: (url) => { ui.nostrLoginAuthUrl = url; render(); } }), 'google', email || ''); + } catch (e) { + try { popup && popup.close(); } catch {} + ui.waysBackError = (e && e.message) || String(e); + ui.waysBackBusy = null; + render(); + } + } + // pen over a sheet of paper, in the line style of the passkey mark + const PAPER_MARK = '<svg width="19" height="19" style="display:block" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><path d="M13 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-5"/><path d="M8 9h4"/><path d="M8 13h3"/><path d="M8 17h6"/><path d="M18.4 2.6a2 2 0 0 1 2.8 2.8L14 12.6l-3.6.9.9-3.6z"/></svg>'; + const fmtDay = (ms) => { try { return new Date(ms).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); } catch { return ''; } }; + function waysBackCard() { + if (wallet.watchOnly || !wallet.mnemonic) return null; + const methods = ctx.recoveryMethods() || []; + const st = load(); + const busyVia = ui.waysBackBusy; + const hasPasskey = typeof window !== 'undefined' && !!window.PublicKeyCredential && !!navigator.credentials; + const done = (mark, title, detail, key) => h('div', { class: 'row gap6 ways-back-row', 'data-way': key, style: 'align-items:center;padding:8px 2px' }, + h('span', { style: 'display:flex;flex-shrink:0;width:23px;justify-content:center', html: mark }), + h('span', { class: 'col', style: 'flex:1;gap:1px;min-width:0' }, + h('span', { style: 'font-weight:600' }, title), + detail ? h('span', { class: 'small muted break' }, detail) : null), + h('span', { class: 'ways-back-check', 'aria-label': t('waysBackOn') }, '✓')); + const add = (mark, label, via, onClick) => h('button', { + class: 'btn-block ways-back-add', 'data-way': via, disabled: !!busyVia, onClick, + }, markedLabel(mark, label, busyVia === via)); + const rows = []; + // the sign-in this account came through is a way back in already + if (st.pubkey) { + const via = st.via || 'nostr'; + rows.push(done(via === 'google' ? GOOGLE_MARK : via === 'passkey' ? PASSKEY_MARK : OSTRICH, + t('waysBackLogin', { via: via === 'google' ? 'Google' : via === 'passkey' ? t('doorPasskey') : 'Nostr' }), + via === 'google' || via === 'passkey' ? '' : (npubOf(st.pubkey) || '').slice(0, 20) + '…', 'login')); + } + for (const m of methods) { + if (m.via === 'passkey') rows.push(done(PASSKEY_MARK, t('doorPasskey'), t('waysBackAddedOn', { date: fmtDay(m.at) }), 'passkey')); + else if (m.via === 'google') rows.push(done(GOOGLE_MARK, 'Google', m.label || t('waysBackAddedOn', { date: fmtDay(m.at) }), 'google')); + else rows.push(done(OSTRICH, 'Nostr', (npubOf(m.id) || '').slice(0, 20) + '…', 'nostr')); + } + const seen = ctx.seedSeen(); + const have = (via) => methods.some((m) => m.via === via) || (via !== 'nostr' && st.via === via); + const adds = [ + hasPasskey && !have('passkey') ? add(PASSKEY_MARK, t('waysBackAddPasskey'), 'passkey', () => passkeyRecovery()) : null, + !have('google') ? add(GOOGLE_MARK, t('waysBackAddGoogle'), 'google', () => googleRecovery()) : null, + ui.waysBackNostr + ? h('div', { class: 'col', style: 'gap:8px' }, + h('div', { class: 'row between' }, + h('strong', { class: 'small' }, t('waysBackAddNostr')), + h('span', { class: 'linklike small', onClick: () => { ui.waysBackNostr = false; ui.waysBackError = ''; render(); } }, t('cancel'))), + signerButtons((make) => addRecovery(make, 'nostr'), { nostrOnly: true })) + : add(OSTRICH, t('waysBackAddNostr'), 'nostr', () => { ui.waysBackNostr = true; ui.waysBackError = ''; ui.nostrLoginError = ''; render(); }), + // the recovery phrase, as one more button (Adam, 2026-11-15): it opens + // the masked words below, ticked once they've been seen + h('button', { class: 'btn-block ways-back-add', 'data-way': 'phrase', disabled: !!busyVia, onClick: () => ctx.showRecoveryPhrase() }, + h('span', { style: 'display:flex;align-items:center;justify-content:center;gap:8px' }, + h('span', { style: 'display:flex;flex-shrink:0', html: PAPER_MARK }), + t('waysBackPaper'), + seen ? h('span', { class: 'ways-back-check', 'aria-label': t('waysBackOn') }, '✓') : null)), + ].filter(Boolean); + return h('div', { class: 'card col ways-back', style: 'gap:6px' }, + h('h3', {}, t('waysBackTitle')), + h('p', { class: 'small muted', style: 'margin:0 0 4px' }, t('waysBackDesc')), + h('div', { class: 'col', style: 'gap:0' }, ...rows), + adds.length ? h('div', { class: 'col', style: 'gap:8px;margin-top:6px' }, ...adds) : null, + ui.waysBackError ? h('div', { class: 'notice err' }, ui.waysBackError) : null, + h('p', { class: 'small faint', style: 'margin:4px 0 0' }, t('waysBackNote'))); + } + function settingsCard() { if (wallet.watchOnly || !wallet.mnemonic) return null; const st = load(); @@ -879,5 +1008,6 @@ export function nostrLoginFeature(ctx) { ].filter(Boolean); }, nostrSettingsCards() { return [settingsCard()]; }, + waysBackCard() { return waysBackCard(); }, }; } diff --git a/src/features/pos.js b/src/features/pos.js index 3727862..eb4b0e9 100644 --- a/src/features/pos.js +++ b/src/features/pos.js @@ -44,8 +44,11 @@ export function posFeature(ctx) { // ---- flow ---------------------------------------------------------------- // ui.pos: { step: 'amount'|'tip'|'invoice'|'paid', amount, note, tipMode, tipCustom, sale, error, busy } - function open() { - ui.pos = { step: 'amount', amount: '', note: '', tipMode: null, tipCustom: '', sale: null, error: '' }; + // `request`: Receive → Amount (Adam, 2026-11-15) — the same amount pad + // and invoice for asking someone for a sum, without the till around it: + // no tip prompt, no tip settings or day's sales, and Back is Receive. + function open(opts = {}) { + ui.pos = { step: 'amount', amount: '', note: '', tipMode: null, tipCustom: '', sale: null, error: '', request: !!opts.request }; render(); } // Digits on screen, into the same field a keyboard or a paste would fill. @@ -90,7 +93,7 @@ export function posFeature(ctx) { if (!bill) { p.error = t('posEnterAmount'); render(); return; } p.error = ''; if (!hook('arkReady')) { p.error = t('posNeedsSpending'); render(); return; } - if (st().askTip) { p.step = 'tip'; p.tipMode = null; p.tipCustom = ''; render(); return; } + if (st().askTip && !p.request) { p.step = 'tip'; p.tipMode = null; p.tipCustom = ''; render(); return; } makeSale(bill, 0, 0); } function tipSat() { @@ -107,6 +110,7 @@ export function posFeature(ctx) { id: 's' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6), at: Date.now(), billSat: bill, tipSat: tip, tipPct, note: (p.note || '').trim().slice(0, 80), status: 'open', fiat: rateNow() ? { code: getCurrency(), rate: rateNow() } : null, + ...(p.request ? { request: true } : {}), }; try { const memo = (sale.note ? sale.note + ' · ' : '') + t('posMemo', { bill: fmtAmount(bill) + ' ' + unitLabel(), tip: tip ? fmtAmount(tip) + ' ' + unitLabel() : '0' }); @@ -145,7 +149,7 @@ export function posFeature(ctx) { function newSale() { const p = ui.pos; if (p.sale && p.sale.status === 'open') voidSale(p.sale); - Object.assign(p, { step: 'amount', amount: '', note: '', tipMode: null, tipCustom: '', sale: null, error: '' }); + Object.assign(p, { step: 'amount', amount: '', note: '', tipMode: null, tipCustom: '', sale: null, error: '', request: p.request }); render(); } @@ -194,7 +198,7 @@ export function posFeature(ctx) { return h('div', { class: 'col', style: 'gap:16px' }, ctx.brandHeader(false), h('div', { class: 'card col', style: 'gap:12px' }, - h('h3', { style: 'margin:0' }, t('posTitle')), + h('h3', { style: 'margin:0' }, t(p.request ? 'recvAmountTitle' : 'posTitle')), h('div', { class: 'input-group' }, h('input', { type: 'text', inputmode: 'decimal', class: 'pos-amount', placeholder: '0', value: p.amount, @@ -204,12 +208,12 @@ export function posFeature(ctx) { unitTag()), getUnit() !== 'fiat' && billSat() && fiatLine(billSat()) ? h('div', { class: 'small muted', style: 'text-align:right' }, fiatLine(billSat())) : null, numpad(() => p.amount, (v) => { p.amount = v; }), - h('input', { type: 'text', placeholder: t('posNoteHint'), value: p.note, maxlength: '80', onInput: (e) => { p.note = e.target.value; } }), + h('input', { type: 'text', placeholder: t(p.request ? 'recvAmountNote' : 'posNoteHint'), value: p.note, maxlength: '80', onInput: (e) => { p.note = e.target.value; } }), p.error ? h('div', { class: 'notice error small' }, p.error) : null, - h('button', { class: 'btn-primary btn-block', disabled: !!p.busy, onClick: charge }, p.busy ? h('span', { class: 'spinner sm' }) : t('posCharge')), + h('button', { class: 'btn-primary btn-block', id: 'pos-charge', disabled: !!p.busy, onClick: charge }, p.busy ? h('span', { class: 'spinner sm' }) : t(p.request ? 'recvAmountGo' : 'posCharge')), p.busy ? h('div', { class: 'small muted', style: 'text-align:center' }, t('posMakingInvoice')) : null), - tipSettingsCard(s), - salesCard(s), + p.request ? null : tipSettingsCard(s), + p.request ? null : salesCard(s), back()); } function tipSettingsCard(s) { @@ -231,7 +235,7 @@ export function posFeature(ctx) { } function salesCard(s) { adoptPaid(s); - const sales = [...s.sales].reverse(); + const sales = s.sales.filter((x) => !x.request).reverse(); // Receive → Amount isn't a sale if (!sales.length) return null; const today = dayKey(Date.now()); const paidToday = sales.filter((x) => x.status === 'paid' && dayKey(x.paidAt || x.at) === today); @@ -310,8 +314,8 @@ export function posFeature(ctx) { h('h3', { style: 'margin:0' }, t('posPaid')), money(sale.billSat + sale.tipSat, sale.fiat && sale.fiat.rate), sale.tipSat ? h('div', { class: 'small muted' }, t('posBreakdown', { bill: fmtAmount(sale.billSat), tip: fmtAmount(sale.tipSat), u: unitLabel() })) : null, - h('button', { class: 'btn-primary btn-block', onClick: newSale }, t('posNewSale'))), - back()); + p.request ? null : h('button', { class: 'btn-primary btn-block', onClick: newSale }, t('posNewSale'))), + p.request ? h('button', { class: 'btn-primary btn-block', onClick: close }, t('recvAmountDone')) : back()); } function posScreen() { const p = ui.pos; @@ -346,6 +350,8 @@ export function posFeature(ctx) { h('p', { class: 'small faint', style: 'margin:0' }, t('posLinkHint', { url })))]; }, openPos: open, + openAmountRequest: () => open({ request: true }), + posAvailable: () => !!hook('arkReady'), // The history detail of a till payment says what was the bill and what the tip. arkMoveDetailExtra(m, row) { const sale = saleOfMovement(m); diff --git a/src/i18n.js b/src/i18n.js index 3e42daa..bb82ed2 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -1498,6 +1498,31 @@ const STR = { onbContinue: 'Continue', onbSkipAvatar: 'Continue', onbDoneTitle: "You're all set!", + onbCreateAccount: 'Create an account', + onbNameTitle: 'Claim your name', + onbNameBody: "It's your username and your payment address: people pay you, follow you and message you with just your name.", + waysBackReminder: 'Add a way back into your account, in case you lose this device.', + waysBackPrompt: 'You have bitcoin in here now. Add a way back into your account, in case you lose this device.', + waysBackReminderGo: 'Add one', + addSavings: 'Add a savings account', + addSavingsDesc: 'On-chain bitcoin, for keeping. Same account, a second balance.', + savingsAddedToast: 'Savings added', + waysBackTitle: 'Ways back in', + waysBackDesc: 'Any one of these opens this account on a new device, or if this one is lost.', + waysBackNote: 'Each one can open your account and spend its money, so only add ones you control.', + waysBackOn: 'Set up', + waysBackLogin: 'Signed in with {via}', + waysBackAddedOn: 'Added {date}', + waysBackPhraseSeen: 'Seen. Keep it written down somewhere safe', + waysBackPhraseUnseen: 'Twelve words that open this account in any wallet', + waysBackShow: 'Show', + waysBackAddPasskey: 'Passkey auth', + waysBackAddGoogle: 'Google auth', + waysBackAddNostr: 'Nostr auth', + waysBackPaper: 'Pen and paper', + waysBackAdded: 'Added. You can sign in with it now', + waysBackIsLogin: 'That is the account you signed in with; it already opens this one.', + waysBackTaken: 'That sign-in already opens a different coinos account, so it can’t open this one too.', onbDoneBody: 'Your spending account is set up. Your address is live and the coinos community chat is waiting for you.', onbEnter: 'Enter coinos', spendSetup: 'Set up a spending account', @@ -1652,6 +1677,13 @@ const STR = { settingsNostrDesc: 'Identity and connections', settingsNotifDesc: 'What buzzes this device', settingsAdvancedDesc: 'Network, rarities, rescues', + aboutTitle: 'About', + recvAmount: 'Amount', + recvAmountTitle: 'Request an amount', + recvAmountNote: 'What it’s for (optional)', + recvAmountGo: 'Create invoice', + recvAmountDone: 'Done', + settingsAboutDesc: 'How it works, support, app, theme', settingsPosDesc: 'Till mode, tips and the /pos link', unlockForLabel: 'Stay unlocked', unlockFor5m: '5 minutes', diff --git a/src/nostr-login.js b/src/nostr-login.js index 3357f61..562a5d4 100644 --- a/src/nostr-login.js +++ b/src/nostr-login.js @@ -514,8 +514,14 @@ export async function fetchWalletBackup(signer, relays = BACKUP_RELAYS) { // `spending` rides along as a hint: a restored wallet paints its Spending // side from the first frame instead of reflowing when the name lookup proves // it seconds later. -export async function publishWalletBackup(signer, { mnemonic, passphrase, spending }, relays = BACKUP_RELAYS) { - const content = await signer.encryptSelf(JSON.stringify({ mnemonic, passphrase: passphrase || '', ...(spending ? { spending: true } : {}) })); +// `recovery` marks a copy kept by a WAY BACK IN (a passkey, Google or nostr +// key added from Settings): signing in with that key opens the account as +// itself — its own identity, name and profile — rather than adopting the +// key as the account's nostr identity the way a login does. `spendingOnly` +// carries a sign-up's shape (no Savings side yet) to the next device. +export async function publishWalletBackup(signer, { mnemonic, passphrase, spending, recovery, spendingOnly }, relays = BACKUP_RELAYS) { + const content = await signer.encryptSelf(JSON.stringify({ mnemonic, passphrase: passphrase || '', + ...(spending ? { spending: true } : {}), ...(recovery ? { recovery: true } : {}), ...(spendingOnly ? { spendingOnly: true } : {}) })); const evt = await signer.signEvent({ kind: BACKUP_KIND, created_at: Math.floor(Date.now() / 1000), tags: [['d', SEED_DTAG]], content, @@ -537,7 +543,7 @@ export async function publishWalletBackup(signer, { mnemonic, passphrase, spendi // deriving (key in hand) or generating (signer only). export async function walletForSigner(signer) { const found = await fetchWalletBackup(signer); - if (found) return { mnemonic: found.mnemonic, passphrase: found.passphrase, spending: !!found.spending, mode: 'restored' }; + if (found) return { mnemonic: found.mnemonic, passphrase: found.passphrase, spending: !!found.spending, recovery: !!found.recovery, spendingOnly: !!found.spendingOnly, mode: 'restored' }; if (signer.secret) { // Publishing the derived seed costs nothing: the derivation is public, // so anyone holding this key could compute it anyway. It buys the thing diff --git a/src/passkey.js b/src/passkey.js index 248e1e6..7d5e6e5 100644 --- a/src/passkey.js +++ b/src/passkey.js @@ -54,14 +54,16 @@ export async function passkeySignIn() { // Create a passkey, then derive the key from it. Some platforms evaluate PRF // during creation; the rest need a follow-up assertion against the new // credential — both paths end at the same bytes. -export async function passkeyCreate() { +// `name` labels the credential in the platform's passkey picker (a way back +// into a named account reads "[email protected]", not just "coinos"). +export async function passkeyCreate({ name = 'coinos', displayName = 'Coinos wallet' } = {}) { const cred = await navigator.credentials.create({ publicKey: { challenge: crypto.getRandomValues(new Uint8Array(32)), rp: { name: 'Coinos', id: location.hostname }, user: { id: crypto.getRandomValues(new Uint8Array(16)), - name: 'coinos', displayName: 'Coinos wallet', + name, displayName, }, pubKeyCredParams: [{ type: 'public-key', alg: -7 }, { type: 'public-key', alg: -257 }], authenticatorSelection: { residentKey: 'required', userVerification: 'required' }, diff --git a/src/style.css b/src/style.css index 17412b2..2bbf8fc 100644 --- a/src/style.css +++ b/src/style.css @@ -534,6 +534,13 @@ html:has(#app .chat-card), body:has(#app .chat-card) { overflow: hidden; } } .chat-row { display: flex; gap: 9px; align-items: flex-start; } .chat-row.grouped { margin-top: -6px; } +/* group chats: the avatar sits beside the name and the bubble starts at the + left edge under both, instead of indented a whole avatar column */ +.chat-row.group { display: grid; grid-template-columns: 30px minmax(0, 1fr); column-gap: 9px; row-gap: 3px; align-items: center; } +.chat-row.group > .chat-body { display: contents; } +.chat-row.group .chat-meta { grid-column: 2; margin-bottom: 0; } +.chat-row.group .chat-line { grid-column: 1 / -1; } +.chat-row.group > .chat-avatar.spacer { display: none; } .chat-avatar { width: 30px; height: 30px; border-radius: 50%; flex-shrink: 0; object-fit: cover; background: var(--accent-soft); @@ -623,7 +630,8 @@ html:has(#app .chat-card), body:has(#app .chat-card) { overflow: hidden; } .chat-react.on { border-color: color-mix(in srgb, currentColor 55%, transparent); font-weight: 600; } /* zap tally: a little bolt + sats, amber on both themes; .on = we zapped it */ .zap-tally { display: inline-flex; align-items: center; gap: 2px; font-size: 11px; font-weight: 650; color: #e6a800; white-space: nowrap; flex-shrink: 0; } -.zap-tally.chat-react { font-size: 12px; padding: 1px 7px 1px 5px; } +/* in a bubble the zap total is just the amber bolt + sats: no pill, no ring */ +.zap-tally.chat-react { font-size: 12px; padding: 1px 2px 1px 0; background: none; border: none; } .zap-tally.clickable { cursor: pointer; } /* the one-tap ⚡ beside someone else's chat bubble */ .chat-line { display: flex; align-items: center; gap: 4px; } @@ -2171,3 +2179,20 @@ html:has(.call-overlay:not(.call-min)) { overflow: hidden; scrollbar-gutter: aut /* on: the accent with its own text colour (accent-fg). accent-ink, used before, is light on dark mode's white accent: unreadable (Adam, 2026-11-15) */ .moment-toggle.on { color: var(--accent-fg); background: var(--accent); border: 1px solid var(--accent); } + +/* Settings → Wallet: the ways back into an account */ +.ways-back .ways-back-row + .ways-back-row { border-top: 1px solid var(--line); } +.ways-back .ways-back-check { color: var(--green, #2e9d5b); font-weight: 700; font-size: 16px; padding: 0 4px; } + +/* Settings → Wallet → Balance settings rows */ +.balance-settings-row { display: flex; align-items: center; gap: 14px; width: 100%; text-align: left; + padding: 14px 16px; border: 1px solid var(--line); border-radius: 14px; background: transparent; color: inherit; font: inherit; cursor: pointer; } +@media (hover: hover) { .balance-settings-row:hover { background: rgba(127,127,127,.06); } } +.balance-settings-row .bs-tile { display: flex; align-items: center; justify-content: center; flex-shrink: 0; + width: 38px; height: 38px; border-radius: 11px; } +.balance-settings-row .bs-tile.spend { color: #7c3aed; background: rgba(124, 58, 237, .14); } +.balance-settings-row .bs-tile.save { color: #f7931a; background: rgba(247, 147, 26, .14); } +.balance-settings-row .bs-text { flex: 1; gap: 3px; min-width: 0; } +.balance-settings-row .bs-title { font-weight: 600; } +.balance-settings-row .bs-tail { color: var(--muted); font-size: 20px; line-height: 1; flex-shrink: 0; width: 14px; text-align: center; } +.settings-prefs select { flex: 1 1 0; min-width: 0; } |
