diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/app.js | 291 | ||||
| -rw-r--r-- | src/features/gifts.js | 72 | ||||
| -rw-r--r-- | src/features/messages.js | 89 | ||||
| -rw-r--r-- | src/features/withdraw-links.js | 195 | ||||
| -rw-r--r-- | src/i18n.js | 43 | ||||
| -rw-r--r-- | src/style.css | 4 |
6 files changed, 559 insertions, 135 deletions
@@ -77,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) @@ -1338,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; @@ -1369,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' }, @@ -1476,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 @@ -1513,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 @@ -1674,22 +1786,28 @@ function optionsPanel() { // already typed keeps it open. if (!ui.passOpen && !ui.passphrase) { return [h('button', { - class: 'btn-ghost btn-sm', type: 'button', style: 'align-self:flex-start', + 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('passphraseOptional'), + 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')) : h('p', { class: 'small muted', style: 'margin:0 0 6px' }, t('passOptionalHint')), + 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' }, @@ -1718,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; @@ -1929,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(); } @@ -2803,7 +2922,7 @@ 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; @@ -3385,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 @@ -3395,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; @@ -3414,7 +3533,7 @@ function goHome() { } else { ui.screen = 'unlock'; ui.unlockTab = 'create'; - ui.createStep = 'gen'; + ui.createStep = 'intro'; ui.draftMnemonic = ''; ui.confirm = []; ui.unlockError = ''; @@ -3494,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.passOpen = false; - 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); @@ -4190,7 +4309,7 @@ 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(); @@ -4292,26 +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: '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') { @@ -4322,7 +4451,7 @@ function onboardScreen() { 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 = ui.unlockTab === 'import' ? 'doors' : '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') { diff --git a/src/features/gifts.js b/src/features/gifts.js index 42e794d..c6ccbd9 100644 --- a/src/features/gifts.js +++ b/src/features/gifts.js @@ -8,6 +8,7 @@ import { decryptWithCode, npubOf } from '../nostr.js'; import { installGiftWallet, previewGift, giftOutpoints, giftClaimTxs, isRetiredGift, giftMinimum, lockGift, previewLockedGift } from './gifts-wallet.js'; import { t } from '../i18n.js'; import { qrSvg } from '../qr.js'; +import { withdrawLinks } from './withdraw-links.js'; import { fmtBtc, timeAgo, shortAddr } from '../format.js'; import { getEventHash, verifyEvent } from 'nostr-tools/pure'; import { sha256 } from '@noble/hashes/sha256'; @@ -26,6 +27,7 @@ export function giftsFeature(ctx) { // every call degrades to null in an on-chain-only build. const hook = ctx.hook || (() => null); const arkGiftOf = (code) => hook('arkGiftDecode', code); + const wl = withdrawLinks(ctx); // prepaid LNURL-withdraw links, the gift link's sibling on Send // ================================================================ GIFT / CLAIM // Read a gift link, returning the code (and scrubbing it from the URL so the @@ -288,51 +290,47 @@ export function giftsFeature(ctx) { h('h2', { style: 'margin:0' }, claimed ? t('claimedTitle') : t('newWalletTitle')), h('p', { class: 'muted', style: 'margin:0' }, claimed ? t('claimedBody') : t('newWalletBody')) ), - h('div', { class: 'card col' }, - h('h3', {}, t('recoveryPhrase')), - 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(wallet.mnemonic, t('copyPhrase'))), - h('button', { class: 'btn-primary btn-block', onClick: () => { ui.confirm = pickConfirm(words); ui.claimError = ''; ui.claimStep = 'verify'; render(); } }, t('verifyBackup')), - h('button', { class: 'btn-block', onClick: finishClaim }, t('skipVerification')) - ) + // a heads-up before the words, then the words, then one word to + // check (Adam, 2026-11-07) — the same three steps as a new wallet + !ui.claimShown + ? h('div', { class: 'card col' }, + h('h3', { style: 'margin-top:0' }, t('recoveryPhrase')), + h('p', { style: 'margin:0' }, t('seedIntroBody', { n: words.length })), + h('p', { class: 'muted', style: 'margin:0' }, t('seedIntroPrivate')), + h('button', { id: 'claim-seed-show', class: 'btn-primary btn-block', onClick: () => { ui.claimShown = true; render(); } }, t('seedIntroShow'))) + : h('div', { class: 'card col' }, + h('h3', { style: 'margin-top:0' }, t('recoveryPhrase')), + 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(wallet.mnemonic, t('copyPhrase'))), + h('button', { id: 'claim-seed-next', class: 'btn-primary btn-block', onClick: () => { ui.confirm = pickConfirm(words); ui.claimError = ''; ui.claimStep = 'verify'; render(); } }, t('next'))) ); } if (ui.claimStep === 'verify') { - // Same word-confirmation as new-wallet creation: prove the recipient wrote - // the phrase down before sending them off into their freshly funded wallet. const words = wallet.mnemonic.split(' '); + const q = ui.confirm; + if (!q || !q.options) { ui.claimStep = 'backup'; ui.claimShown = true; return claimScreen(); } return h( 'div', { class: 'col', style: 'gap:16px' }, brandHeader(false), h('div', { class: 'card col' }, h('h3', { style: 'margin-top:0' }, t('recoveryPhrase')), - h('p', { class: 'muted', style: 'margin:0' }, 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()), - }) - ) - ), - ui.claimError && h('div', { class: 'notice err' }, ui.claimError), - h('div', { class: 'row gap6' }, - h('button', { class: 'btn-ghost', onClick: () => { ui.claimStep = 'backup'; ui.claimError = ''; 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.includes(w) ? ' wrong' : ''), onClick: () => { - const ok = ui.confirm.every((c) => c.value.toLowerCase() === words[c.index]); - if (!ok) { ui.claimError = t('wordsMismatch'); render(); return; } - finishClaim(); + if (w === words[q.index]) { ui.claimError = ''; finishClaim(); return; } + if (!q.wrong.includes(w)) q.wrong.push(w); + ui.claimError = t('quizWrong'); render(); }, - }, t('openWallet')) - ), - h('button', { class: 'btn-block', onClick: finishClaim }, t('skipVerification')) + }, w))), + ui.claimError && h('div', { class: 'notice err' }, ui.claimError), + h('button', { id: 'claim-seed-skip', class: 'btn-ghost btn-block', onClick: () => { ui.claimError = ''; finishClaim(); } }, t('skip')), + h('button', { class: 'linklike small', style: 'align-self:center', onClick: () => { ui.claimStep = 'backup'; ui.claimShown = true; ui.claimError = ''; render(); } }, t('quizBack')), ) ); } @@ -366,7 +364,7 @@ export function giftsFeature(ctx) { // view the claim on a block explorer, or head back to the home screen. h('button', { class: 'btn-primary btn-block', onClick: () => { ui.claimTaken = null; ui.claimedAmount = 0; - if (activeAccount() && activeAccount().provisional) { ui.claimStep = 'backup'; commitAccount(); } + if (activeAccount() && activeAccount().provisional) { ui.claimStep = 'backup'; ui.claimShown = false; commitAccount(); } else { ui.screen = 'wallet'; ui.claimStep = null; landAccount(); } render(); } }, activeAccount() && activeAccount().provisional ? t('createWalletAnyway') : t('goToWallet')), @@ -488,7 +486,7 @@ export function giftsFeature(ctx) { commitAccount(); // claimed — keep a fresh gift wallet (no-op for an existing one) if (ui.claimFrom) hook('followGiftSender', ui.claimFrom); if (wasProvisional) { - ui.claimStep = 'backup'; // fresh wallet — show the new seed to back up + ui.claimStep = 'backup'; ui.claimShown = false; // fresh wallet — show the new seed to back up } else { ui.screen = 'wallet'; // existing wallet — straight into it, no new seed to back up ui.claimStep = null; @@ -1089,9 +1087,11 @@ export function giftsFeature(ctx) { return { blob, claimCode, url: `${location.origin}/lg/${blob}` }; }, receiveTakeover() { return claimCelebration(); }, - sendView() { return ui.giftMode ? giftView() : null; }, + sendView() { return ui.giftMode === 'lnurl' ? wl.view() : ui.giftMode ? giftView() : null; }, sendFormExtras() { - return h('button', { class: 'linklike small', style: 'align-self:center;margin-top:2px', onClick: () => { ui.giftMode = true; ui.sendError = ''; render(); } }, '🎁 ' + t('giftLink')); + return h('div', { class: 'row gap6 wrap', style: 'justify-content:center;column-gap:18px' }, + h('button', { class: 'linklike small', style: 'margin-top:2px', onClick: () => { ui.giftMode = true; ui.sendError = ''; render(); } }, '🎁 ' + t('giftLink')), + wl.entry()); }, balanceLines() { const locked = wallet.giftLockedValue(); diff --git a/src/features/messages.js b/src/features/messages.js index 90bfaaf..6c4f5f8 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -760,14 +760,38 @@ export function messagesFeature(ctx) { let allUnsubs = []; let dmUnsubs = []; // the DM wrap subscriptions alone, so they can be rebuilt + // Is the page under the reader's finger, or still coasting from a flick? + // Background work (a repaint for a face that arrived, writing the profile + // cache) waits for it to settle: on Adam's Pixel a flick through the feed + // spent 1.7 s of 9 on the main thread doing exactly that, and taps queued + // behind it (2026-11-07, "flick to scroll … not getting up to speed … + // clicking around feels sluggish"). Never longer than `most` ms, though: + // a slow, steady read must still see what arrived. + let pageMovedAt = 0, fingerDown = false; + if (typeof window !== 'undefined') { + window.addEventListener('scroll', () => { pageMovedAt = Date.now(); }, { passive: true, capture: true }); + window.addEventListener('touchstart', () => { fingerDown = true; }, { passive: true, capture: true }); + for (const n of ['touchend', 'touchcancel']) window.addEventListener(n, (e) => { if (!e.touches || !e.touches.length) fingerDown = false; }, { passive: true, capture: true }); + } + const pageMoving = () => fingerDown || Date.now() - pageMovedAt < 200; + const whenSettled = (fn, most = 1500, step = 150) => { + const until = Date.now() + most; + const go = () => { if (pageMoving() && Date.now() < until) { setTimeout(go, step); return; } fn(); }; + go(); + }; + let repaintTimer = null; const scheduleRepaint = () => { if (repaintTimer) return; + const since = Date.now(); const run = () => { // not under a pager in motion: the page beneath is out of sight, and // its repaint (40–55 ms on Adam's phone) dropped frames from flights // through the pictures (2026-10-17) — it waits for the landing if (pagerMoving()) { repaintTimer = setTimeout(run, 120); return; } + // nor mid-flick on the page itself (up to 1.5 s): a whole-page morph + // in the middle of a fling is a dropped frame and a late tap + if (pageMoving() && Date.now() - since < 1500) { repaintTimer = setTimeout(run, 150); return; } repaintTimer = null; // a visitor's public page (a profile, a thread, a feed) repaints too if (ui.screen === 'wallet' || ui.pubProf) renderThreadStable(); @@ -826,11 +850,14 @@ export function messagesFeature(ctx) { // memory and is written back once per burst: a batch of thirty profiles // landing during a scroll used to parse and re-serialize the whole blob // thirty times over, on the main thread, mid-scroll. - let profBlob = null, profFlush = null; + let profBlob = null, profFlush = null, profFlushSince = 0; const PROFILE_ROWS = 1500; // a row without a thumbnail is ~430 chars; IndexedDB has room const profilesDisk = () => (profBlob ||= wallet.loadFeatureState('profiles', {})); - function flushProfiles() { - profFlush = null; + function flushProfiles(force) { + // up to 5 s for the page to settle: stringifying this blob (thumbnails + // and all, up to ~2 MB) is a long main-thread task + if (!force && pageMoving() && Date.now() - (profFlushSince || 0) < 5000) { profFlush = setTimeout(flushProfiles, 300); return; } + profFlush = null; profFlushSince = 0; const s = profBlob; if (!s) return; // Who stays when space runs out: your own faces, the people you follow, @@ -848,7 +875,10 @@ export function messagesFeature(ctx) { // Thumbnails are the bulk of this blob, so they live on a budget: the // least recently seen strangers give theirs up first. The row itself // stays — that face just paints the way it used to. - let size = JSON.stringify(s).length; + // (an estimate, not a JSON.stringify of the lot just to measure it: + // a row is ~430 chars plus its thumbnail) + let size = 0; + for (const k in s) size += 430 + ((s[k] && s[k].thumb) || '').length; if (size > THUMB_BUDGET) { // never our own: the header face is the one that must paint offline for (const k of evictFirst(Object.keys(s).filter((k) => s[k].thumb && !mine.has(k)))) { @@ -875,7 +905,7 @@ export function messagesFeature(ctx) { ? { thumb: p.thumb, thumbFor: p.thumbFor, thumbPx: p.thumbPx || 0 } : {}), ...(p.thumbFail ? { thumbFail: p.thumbFail, thumbFailAt: p.thumbFailAt || 0, thumbFails: p.thumbFails || 1, thumbFailVersion: p.thumbFailVersion || 0 } : {}) }; - if (!profFlush) profFlush = setTimeout(flushProfiles, 500); + if (!profFlush) { profFlushSince = Date.now(); profFlush = setTimeout(flushProfiles, 500); } } // A punk picture is OUR OWN art. Every coinos user who keeps the default // publishes https://v3.coinos.io/punks/N.webp as their nostr picture, and @@ -1081,6 +1111,39 @@ export function messagesFeature(ctx) { blobProbing.set(url, task); return task; } + // Centre-crop to a square (the way background-size:cover paints it), + // shrink, and encode — off the main thread where the browser allows: the + // crop and resize through createImageBitmap, the encode through an + // OffscreenCanvas's convertToBlob. The synchronous canvas.toDataURL it + // replaces cost ~30 ms a face on a phone, mid-scroll (2026-11-07). + async function encodeThumb(bmp) { + const side = Math.min(bmp.width, bmp.height); + if (!side) throw new Error('empty'); + const sx = (bmp.width - side) / 2, sy = (bmp.height - side) / 2; + if (typeof OffscreenCanvas !== 'undefined' && OffscreenCanvas.prototype.convertToBlob) { + let small = null; + try { + small = await createImageBitmap(bmp, sx, sy, side, side, { resizeWidth: THUMB_PX, resizeHeight: THUMB_PX, resizeQuality: 'high' }); + const oc = new OffscreenCanvas(THUMB_PX, THUMB_PX); + oc.getContext('2d').drawImage(small, 0, 0); + const asUrl = (b) => new Promise((ok, bad) => { const r = new FileReader(); r.onload = () => ok(r.result); r.onerror = () => bad(r.error); r.readAsDataURL(b); }); + let blob = await oc.convertToBlob({ type: 'image/webp', quality: 0.75 }); + if (blob.type !== 'image/webp') blob = await oc.convertToBlob({ type: 'image/jpeg', quality: 0.7 }); + if (blob.size * 4 / 3 > THUMB_MAX) blob = await oc.convertToBlob({ type: 'image/webp', quality: 0.5 }); // a busy picture, leaned on harder + const data = await asUrl(blob); + if (data.length > THUMB_MAX) throw new Error('too big'); + return data; + } finally { try { small && small.close(); } catch {} } + } + const c = document.createElement('canvas'); + c.width = c.height = THUMB_PX; + c.getContext('2d').drawImage(bmp, sx, sy, side, side, 0, 0, THUMB_PX, THUMB_PX); + let data = c.toDataURL('image/webp', 0.75); + if (!data.startsWith('data:image/webp')) data = c.toDataURL('image/jpeg', 0.7); + if (data.length > THUMB_MAX) data = c.toDataURL('image/webp', 0.5); + if (data.length > THUMB_MAX) throw new Error('too big'); + return data; + } function makeThumb(pk, p) { if (!p || !p.picture || typeof document === 'undefined') return; if (noCorsHost(p.picture)) return; // a host that won't hand over bytes; the <img> shows it anyway @@ -1152,18 +1215,8 @@ export function messagesFeature(ctx) { scheduleRepaint(); // the big face, where one is up, can paint from the copy now } bmp = await createImageBitmap(await res.blob()); - // centre-crop to a square, the way background-size:cover paints it - const side = Math.min(bmp.width, bmp.height); - if (!side) throw new Error('empty'); - const c = document.createElement('canvas'); - c.width = c.height = THUMB_PX; - c.getContext('2d').drawImage(bmp, (bmp.width - side) / 2, (bmp.height - side) / 2, - side, side, 0, 0, THUMB_PX, THUMB_PX); - let data = c.toDataURL('image/webp', 0.75); - if (!data.startsWith('data:image/webp')) data = c.toDataURL('image/jpeg', 0.7); - if (data.length > THUMB_MAX) data = c.toDataURL('image/webp', 0.5); // a busy picture, leaned on harder - if (data.length > THUMB_MAX) throw new Error('too big'); - done({ thumb: data, thumbFor: url, thumbPx: THUMB_PX }); + const data = await encodeThumb(bmp); + whenSettled(() => done({ thumb: data, thumbFor: url, thumbPx: THUMB_PX })); } catch (e) { // a quick TypeError is the CORS refusal (a dead host takes longer) // only a refusal corsFetch confirmed (a body cut off mid-download @@ -19062,7 +19115,7 @@ export function messagesFeature(ctx) { follows = null; followsAt = 0; feed = null; relayLists = null; mutes = null; mutesAt = 0; stCache = null; stAt = 0; // the next account's state, not this one's - if (profFlush) { clearTimeout(profFlush); flushProfiles(); } // this account's faces, written before the blob is dropped + if (profFlush) { clearTimeout(profFlush); flushProfiles(true); } // this account's faces, written before the blob is dropped profBlob = null; timedActivity.clear(); diskHeld.clear(); reacts.clear(); boosts.clear(); seenNoteEv.clear(); myReactEv.clear(); quoted.clear(); noteCountsReady.clear(); diff --git a/src/features/withdraw-links.js b/src/features/withdraw-links.js new file mode 100644 index 0000000..a63d180 --- /dev/null +++ b/src/features/withdraw-links.js @@ -0,0 +1,195 @@ +// Prepaid Lightning withdraw links (LNURL-withdraw): make a link or QR that +// whoever holds it can pull sats from — a voucher, a faucet, a printed card — +// even while this wallet is asleep. "Do we have lnurl withdraw links +// somewhere? can we add them?" → "Prepaid" (Adam, 2026-11-07). +// +// The money is paid up front, from Spending over Lightning, to the coinos +// registrar (coinosv3-server names/withdraw.js), which pays each claim out of +// it; until claimed it sits there, and the creator can take back whatever is +// left at any time ("Take back"), which closes the link. Links belong to the +// wallet's own nostr key, so every device with the seed sees them. +// +// It lives on the Send page as a sibling of the gift link: ui.giftMode === +// 'lnurl' (giftMode is already a navigation field with its resets). +import { nip98Header } from '../nip98.js'; +import { encodeLnurl } from '../lnurl.js'; +import { qrSvg } from '../qr.js'; +import { getNetwork } from '../api.js'; +import { t } from '../i18n.js'; + +const REGISTRAR = 'https://names.coinos.io'; +const NOTE_MAX = 120; +const net = () => (getNetwork() === 'mutinynet' ? 'mutinynet' : 'mainnet'); +const available = () => ['mainnet', 'mutinynet'].includes(getNetwork()); + +export function withdrawLinks(ctx) { + const { h, ui, render, wallet, toast, copyBtn, goBack, fmtAmount, unitLabel, unitTag, parseAmount, getUnit } = ctx; + const hook = ctx.hook || (() => null); + const base = () => ctx.withdrawRegistrar || REGISTRAR; + // ui.wl: { list: [...]|null, listError, amount, uses, note, busy, error, open: id|null, made: id|null, taking: id|null } + const st = () => (ui.wl ||= { list: null, amount: '', uses: '1', note: '' }); + + const signer = { signEvent: (e) => wallet.nostrSign(e) }; + async function call(method, path, payload) { + const url = base() + path; + const body = payload ? JSON.stringify(payload) : ''; + const auth = await nip98Header(signer, url, method, body); + const r = await fetch(url, { method, headers: { authorization: auth, ...(body ? { 'content-type': 'application/json' } : {}) }, ...(body ? { body } : {}) }); + const j = await r.json().catch(() => ({})); + if (!r.ok || j.error) throw new Error(j.error || `registrar refused (${r.status})`); + return j; + } + + async function load() { + const s = st(); + try { + const j = await call('GET', '/withdraw/mine'); + s.list = (j.links || []).filter((l) => l.net === net()); + s.listError = ''; + } catch (e) { s.listError = e.message; s.list ||= []; } + render(); + } + + function open() { + ui.wl = null; + ui.giftMode = 'lnurl'; + ui.sendError = ''; + st(); + render(); + load(); + } + + const lnurlOf = (l) => encodeLnurl(l.url).toUpperCase(); + const amt = (sat) => fmtAmount(sat) + ' ' + unitLabel(); + + async function create() { + const s = st(); + const sat = parseAmount(s.amount, getUnit()) || 0; + const uses = Math.floor(Number(s.uses) || 1); + s.error = ''; + if (!(sat >= 1)) { s.error = t('wlAmountInvalid'); render(); return; } + if (!(uses >= 1 && uses <= 1000)) { s.error = t('wlUsesInvalid'); render(); return; } + if (wallet.watchOnly) { s.error = t('lnWithdrawWatchOnly'); render(); return; } + if (!hook('arkReady')) { s.error = t('hatNeedSpending'); render(); return; } + const total = sat * uses; + if ((hook('arkSpendableSat') || 0) < total + Math.max(10, Math.ceil(total * 0.002))) { s.error = t('wlNoFunds', { amount: amt(total) }); render(); return; } + s.busy = true; render(); + try { + const inv = await call('POST', '/withdraw/new', { sat, uses, note: String(s.note || '').trim().slice(0, NOTE_MAX), net: net() }); + await hook('arkPayInvoice', inv.invoice, { maxAmountSat: inv.totalSat }); + let link = null; + for (let i = 0; i < 10 && !link; i++) { + try { link = await call('POST', '/withdraw/funded', { id: inv.id }); } + catch (e) { if (!/not paid/i.test(e.message)) throw e; await new Promise((r) => setTimeout(r, 1500)); } + } + if (!link) throw new Error(t('wlNotConfirmed')); + s.list = [link, ...(s.list || []).filter((l) => l.id !== link.id)]; + s.made = link.id; s.open = link.id; + s.amount = ''; s.uses = '1'; s.note = ''; + } catch (e) { + s.error = e.message || String(e); + } finally { s.busy = false; render(); } + } + + async function takeBack(l) { + const s = st(); + s.taking = l.id; s.error = ''; render(); + let lnId = null; + try { + if (l.remainingSat > 0) { + const inv = await hook('arkLnInvoice', l.remainingSat, t('wlTakeBackMemo')); + if (!inv || !inv.invoice) throw new Error(t('lnWithdrawNoInvoice')); + lnId = inv.id; + // the wallet has to claim the held payment while the registrar pays it + hook('arkLnWatch', inv.id, () => {}); + const r = await call('POST', '/withdraw/reclaim', { id: l.id, invoice: inv.invoice }); + Object.assign(l, r); + toast(r.paid === false ? t('wlTakeBackFailed') : t('wlTakenBack', { amount: amt(inv.amountSat) })); + } else { + Object.assign(l, await call('POST', '/withdraw/reclaim', { id: l.id })); + } + } catch (e) { + if (lnId) hook('arkLnCancel', lnId); + s.error = e.message || String(e); + } finally { s.taking = null; render(); load(); } + } + + function linkCard(l) { + const s = st(); + const lnurl = lnurlOf(l); + let svg = null; + try { svg = qrSvg(lnurl, { ec: 'M', mode: 'Alphanumeric' }); } catch {} + const live = l.status === 'open' && l.remainingSat > 0 && l.used < l.uses; + return h('div', { class: 'card col', style: 'align-items:center;gap:10px' }, + h('h3', { style: 'align-self:stretch' }, s.made === l.id ? t('wlReady') : t('wlTitle')), + live && svg ? h('div', { html: svg }) : null, + l.note ? h('blockquote', { class: 'gift-note', style: 'width:100%' }, l.note) : null, + h('div', { class: 'small muted', style: 'text-align:center' }, + l.uses > 1 ? t('wlTermsMany', { amount: amt(l.sat), n: l.uses - l.used, total: l.uses }) : t('wlTermsOne', { amount: amt(l.sat) }), + ' · ', t('wlLeft', { amount: amt(l.remainingSat) })), + live ? h('div', { class: 'addr-box break mono', style: 'width:100%;font-size:11px' }, lnurl) : h('div', { class: 'notice small' }, l.status === 'closed' ? t('wlClosed') : t('wlUsedUp')), + live ? h('div', { class: 'row gap6 wrap', style: 'width:100%' }, copyBtn(lnurl, t('wlCopy'))) : null, + live ? h('div', { class: 'small faint', style: 'text-align:center' }, t('wlBearer')) : null, + s.error && h('div', { class: 'notice err', style: 'width:100%' }, s.error), + live || (l.status === 'open' && l.remainingSat > 0) + ? (s.taking === l.id + ? h('button', { class: 'btn-block', disabled: true }, h('span', { class: 'spinner sm' })) + : h('button', { class: 'btn-ghost btn-block', onClick: () => takeBack(l) }, t('wlTakeBack', { amount: amt(l.remainingSat) }))) + : null, + h('button', { class: 'btn-ghost btn-block', onClick: () => { s.open = null; s.made = null; s.error = ''; render(); } }, t('back'))); + } + + function formCard() { + const s = st(); + const sat = parseAmount(s.amount, getUnit()) || 0; + const uses = Math.max(1, Math.floor(Number(s.uses) || 1)); + return h('div', { class: 'card col gap6' }, + h('h3', {}, t('wlTitle')), + h('div', { class: 'small muted' }, t('wlDesc')), + h('div', { class: 'input-group' }, + h('input', { id: 'wl-amount', type: 'number', min: '0', inputmode: 'decimal', step: getUnit() === 'sats' ? '1' : '0.00000001', + placeholder: t('wlAmount'), value: s.amount, onInput: (e) => { s.amount = e.target.value; render(); } }), + h('div', { style: 'display:flex;align-items:center' }, unitTag())), + h('label', { class: 'row gap6', style: 'align-items:center' }, + h('span', { class: 'small grow' }, t('wlUses')), + h('input', { id: 'wl-uses', type: 'number', min: '1', max: '1000', step: '1', inputmode: 'numeric', style: 'width:90px', + value: s.uses, onInput: (e) => { s.uses = e.target.value; render(); } })), + h('textarea', { class: 'gift-note-input', rows: 2, maxlength: NOTE_MAX, placeholder: t('wlNote'), value: s.note, onInput: (e) => { s.note = e.target.value; } }), + h('div', { class: 'small faint' }, sat >= 1 && uses > 1 ? t('wlPayNowMany', { total: amt(sat * uses), n: uses, amount: amt(sat) }) : t('wlPayNow')), + s.error && !s.open ? h('div', { class: 'notice err' }, s.error) : null, + s.busy + ? h('button', { class: 'btn-primary btn-block', disabled: true }, h('span', { class: 'spinner sm' })) + : h('button', { id: 'wl-create', class: 'btn-primary btn-block', onClick: create }, sat >= 1 ? t('wlCreateFor', { amount: amt(sat * uses) }) : t('wlCreate'))); + } + + function listCard() { + const s = st(); + if (s.list == null) return h('div', { class: 'card', style: 'text-align:center' }, h('span', { class: 'spinner sm' })); + if (!s.list.length) return s.listError ? h('div', { class: 'notice err small' }, s.listError) : null; + return h('div', { class: 'card col gap6' }, + h('span', { class: 'small muted' }, t('wlYours')), + ...s.list.map((l) => h('button', { class: 'row between wl-row', style: 'text-align:left', onClick: () => { s.open = l.id; s.made = null; s.error = ''; render(); } }, + h('span', { class: 'small' }, amt(l.sat), l.uses > 1 ? ` × ${l.uses}` : '', l.note ? h('span', { class: 'faint' }, ' · ' + l.note) : null), + h('span', { class: 'small faint' }, + l.status === 'closed' ? t('wlClosedTag') : l.remainingSat < 1 || l.used >= l.uses ? t('wlUsedUpTag') : t('wlLeft', { amount: amt(l.remainingSat) }))))); + } + + function view() { + const s = st(); + if (!available()) { + return h('div', { class: 'col', style: 'gap:12px' }, + h('div', { class: 'card col' }, h('h3', {}, t('wlTitle')), h('div', { class: 'small muted' }, t('wlMainnetOnly'))), + h('button', { class: 'btn-ghost btn-block', onClick: () => goBack(() => { ui.giftMode = false; }) }, t('back'))); + } + const l = s.open && (s.list || []).find((x) => x.id === s.open); + if (l) return h('div', { class: 'col', style: 'gap:12px' }, linkCard(l)); + return h('div', { class: 'col', style: 'gap:12px' }, + formCard(), + listCard(), + h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.wl = null; goBack(() => { ui.giftMode = false; }); } }, t('back'))); + } + + const entry = () => h('button', { id: 'wl-open', class: 'linklike small', style: 'align-self:center;margin-top:2px', onClick: open }, '⚡ ' + t('wlTitle')); + + return { view, entry, open }; +} diff --git a/src/i18n.js b/src/i18n.js index e45fa49..96f4548 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -197,6 +197,19 @@ const STR = { skipVerification: 'Skip verification', confirmBackupIntro: 'Confirm your backup by entering the requested words.', wordN: 'Word #{n}', + seedIntroBody: 'Next you’ll see your seed phrase: {n} words that are your wallet. Write them down on paper, in order, and keep them somewhere safe. They’re the only way to get your coins back if you lose this device.', + seedIntroPrivate: 'Anyone who sees them can take your coins, so make sure no one is looking at your screen.', + seedIntroShow: 'Show seed phrase', + next: 'Next', + passStepTitle: 'Add a passphrase?', + passStepBody: 'A passphrase is an extra secret you choose yourself, on top of your {n} words. Anyone who finds your seed phrase still can’t open your wallet without it.', + passStepNote: 'It’s optional, but recommended. Write it down with your seed phrase: you’ll need both to recover your wallet, exactly as written, and nobody can reset it for you.', + passStepNone: 'Continue without one', + skip: 'Skip', + quizPrompt: 'Which one is word #{n}?', + quizWrong: 'Not that one. Check what you wrote down.', + quizBack: 'Show the words again', + onbBackupShow: 'Show seed phrase', reenterPassphrase: 'Re-enter passphrase', back: '← Back', cancel: 'Cancel', @@ -1409,6 +1422,7 @@ const STR = { doorPasskey: 'Passkey', doorGoogle: 'Google', doorNostr: 'Nostr', + addAccountTitle: 'Add an account', onbSignInExisting: 'Sign in to an existing account', onbSigninTitle: 'Sign in', onbLegacyTitle: 'Move your coinos.io account', @@ -1729,6 +1743,35 @@ const STR = { lnWithdrawPaid: 'Received {amount}', lnWithdrawNotPaid: 'They took the invoice but the payment didn’t arrive. Check with them before trying again.', lnWithdrawNoInvoice: 'Couldn’t make an invoice to be paid to.', + wlTitle: 'Withdraw link', + wlReady: 'Your withdraw link is ready', + wlDesc: 'A Lightning link or QR code that anyone can withdraw sats from, like a voucher. It works with most Lightning wallets, even while your phone is off. You pay for it up front; Coinos holds the sats until they’re claimed, and you can take back whatever is left at any time.', + wlAmount: 'Amount per withdrawal', + wlUses: 'How many withdrawals', + wlNote: 'Description (optional)', + wlPayNow: 'Paid now from Spending.', + wlPayNowMany: '{total} paid now from Spending: {n} withdrawals of up to {amount}.', + wlCreate: 'Create withdraw link', + wlCreateFor: 'Create link for {amount}', + wlAmountInvalid: 'Enter an amount.', + wlUsesInvalid: 'Between 1 and 1000 withdrawals.', + wlNoFunds: 'Not enough in Spending for {amount}.', + wlNotConfirmed: 'Paid, but the link isn’t confirmed yet. It will appear under your links in a moment.', + wlTermsOne: 'Up to {amount}, once', + wlTermsMany: 'Up to {amount} each, {n} of {total} withdrawals left', + wlLeft: '{amount} left', + wlCopy: 'Copy LNURL', + wlBearer: 'Anyone with this link or QR code can withdraw from it. Share it only with whoever it’s for.', + wlTakeBack: 'Take back {amount}', + wlTakeBackMemo: 'Withdraw link taken back', + wlTakenBack: '{amount} is back in Spending', + wlTakeBackFailed: 'Couldn’t pay it back just now. The link stays open; try again in a moment.', + wlClosed: 'This link is closed.', + wlUsedUp: 'This link has been used up.', + wlClosedTag: 'closed', + wlUsedUpTag: 'used up', + wlYours: 'Your withdraw links', + wlMainnetOnly: 'Withdraw links are only available on mainnet.', lnWithdrawWatchOnly: 'This device can’t receive: the wallet is watch-only here.', lnWithdrawMemo: 'Withdrawal from {domain}', lnPayAmount: 'Amount', diff --git a/src/style.css b/src/style.css index dfe9b9d..d753604 100644 --- a/src/style.css +++ b/src/style.css @@ -344,6 +344,10 @@ textarea.send-dest::placeholder { font-family: var(--sans); } eight-letter word ("resemble") pushed the third column out past the card's padding. The cells are a touch tighter so every BIP-39 word (eight letters at most) still fits whole in a third of a narrow card. */ +/* The one-word check after the phrase: four of its words, tap the right one. */ +.quiz-opts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } +.quiz-opt { font-family: var(--mono); font-size: 16px; padding: 14px 8px; } +.quiz-opt.wrong { border-color: var(--red); color: var(--red); background: var(--red-soft); } .words { display: grid; grid-auto-flow: row; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; } .words .w { display: flex; align-items: baseline; gap: 5px; background: var(--surface2); min-width: 0; |
