From 5fe7d9a28e19f8fdc563327bfba7a13dc33c1d11 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sun, 15 Nov 2026 14:37:36 -0700 Subject: Ask for a way back in once, and only when there's money to lose. "Let's only prompt to add a way back in after there's some balance and only if we haven't prompted before" (Adam, 2026-11-15). A sign-up's wallet no longer carries the reminder line from its first minute. The first time it holds anything (Spending, Savings or an incoming payment) and has no way back in yet, it shows one notice: 'You have bitcoin in here now. Add a way back into your account, in case you lose this device.' with Add one (→ Settings → Wallet) and Not now. That's recorded on the account as soon as it shows (directory and synced snapshot too), so it stays for that visit and never comes back, here or on another device. Seed wallets opened by sign-in keep their old phrase reminder. signup-quick-test: nothing on an empty account; given a Spending balance it asks; Not now; not again after a reload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VMan6SwkivduFDDkMmnXjZ --- src/app.js | 41 ++++++++++++++++++++++++++++++++++++++--- src/i18n.js | 1 + tools/signup-quick-test.js | 28 ++++++++++++++++++++++++++-- 3 files changed, 65 insertions(+), 5 deletions(-) diff --git a/src/app.js b/src/app.js index 3144e9b..99cf565 100644 --- a/src/app.js +++ b/src/app.js @@ -2199,6 +2199,7 @@ function loadWatchAccounts() { // 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 []; } } @@ -2219,6 +2220,7 @@ function saveDirectory() { 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)); @@ -5061,10 +5063,43 @@ function addRecoveryMethod(m) { } wallet.registerCacheExtension({ mergeAlways: true, - save: () => { const r = recoveryMethods(); return r.length ? { recovery: r } : {}; }, - load: (d) => { if (d && d.recovery) mergeRecovery(d.recovery); }, + 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 @@ -5076,7 +5111,7 @@ function backupNotice() { // 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 && recoveryMethods(a).length) return null; + if (signup) return waysBackPrompt(a); return h('div', { class: 'small muted', style: 'margin:10px 0 0;text-align:center' }, t(signup ? 'waysBackReminder' : 'backupReminder'), ' ', diff --git a/src/i18n.js b/src/i18n.js index 97bb066..bb82ed2 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -1502,6 +1502,7 @@ const STR = { 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.', diff --git a/tools/signup-quick-test.js b/tools/signup-quick-test.js index d184457..a269faf 100644 --- a/tools/signup-quick-test.js +++ b/tools/signup-quick-test.js @@ -82,7 +82,32 @@ try { })); let s = await shape(); check('in, on Spending alone (no Savings face, no name on it)', s.faces === 1 && !s.carousel && !s.label, JSON.stringify({ faces: s.faces, carousel: s.carousel, label: s.label })); - check('a quiet line asks for a way back in', /add a way back into your account/i.test(s.body)); + // asked once, and only with money in the account (2026-11-15): not on an empty one + check('no way-back-in prompt on an empty account', !/add a way back into your account/i.test(s.body) && !(await page.$('.ways-back-prompt'))); + // money arrives in Spending (the ark feature, found on the heap, made to + // report a balance; then a repaint) + const giveBalance = async () => { + const protoH = await page.evaluateHandle(() => Object.prototype); + const all = await page.queryObjects(protoH); + const found = await page.evaluate((objs) => { + const f = objs.find((o) => { try { return o && o.id === 'ark' && typeof o.spendingSat === 'function'; } catch { return false; } }); + if (!f) return false; + f.spendingSat = () => 5000; + window.dispatchEvent(new Event('resize')); + document.querySelector('.tabs button')?.click(); document.querySelector('.tabs button')?.click(); + return true; + }, all); + await all.dispose(); await protoH.dispose(); + await sleep(800); + return found; + }; + check('(test: found the ark feature)', await giveBalance()); + check('with a balance, it asks once for a way back in', !!(await page.$('.ways-back-prompt')), (await text()).slice(0, 300).replace(/\n/g, ' | ')); + await page.evaluate(() => [...document.querySelectorAll('.ways-back-prompt button')].find((b) => /not now/i.test(b.textContent)).click()); await sleep(400); + check('Not now puts it away', !(await page.$('.ways-back-prompt'))); + await page.reload({ waitUntil: 'domcontentloaded' }); await page.waitForSelector('.balance-face', { timeout: 20000 }); await sleep(1500); + await giveBalance(); + check('...and never again, not after a reload', !(await page.$('.ways-back-prompt'))); for (let i = 0; i < 60 && !(await page.$('#recv-amount')); i++) await sleep(250); check('Receive has an Amount button', !!(await page.$('#recv-amount'))); check('no History heading on an empty wallet', !/\bHISTORY\b/.test(await page.evaluate(() => document.body.innerText))); @@ -110,7 +135,6 @@ try { const pk = await page.evaluate(() => ({ rows: [...document.querySelectorAll('.ways-back-row')].map((e) => e.dataset.way), err: document.querySelector('.ways-back .notice.err')?.textContent || '' })); check('adding a passkey lists it', pk.rows.includes('passkey') && !pk.err, JSON.stringify(pk)); const backups = await page.evaluate(() => (window.__relayGuard.events || []).filter((e) => e.kind === 30078 && e.tags.some((t) => t[0] === 'd' && t[1] === 'coinos:wallet:v1'))); - check('the reminder line is gone once there is a way back in', await (async () => { await page.goBack(); await page.goBack(); await sleep(600); const gone = !/add a way back into your account/i.test(await text()); await openWalletPage(); return gone; })()); // the list and the reminder, as the app sees them (record a method directly) check('the phrase card waits behind Pen and paper', !(await page.$('.recovery-card'))); await page.evaluate(() => document.querySelector('.ways-back-add[data-way="phrase"]')?.click()); -- cgit v1.2.3