aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-11-15 14:37:36 -0700
committerAdam Soltys <[email protected]>2026-11-15 14:37:36 -0700
commit5fe7d9a28e19f8fdc563327bfba7a13dc33c1d11 (patch)
treec3a9d13cd9c681900c24d8f5c85e2afb6a80c141
parent5cc640839a14f7a1c821b3630a591bca76717243 (diff)
downloadcoinosv3-5fe7d9a28e19f8fdc563327bfba7a13dc33c1d11.tar.gz
coinosv3-5fe7d9a28e19f8fdc563327bfba7a13dc33c1d11.zip
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
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01VMan6SwkivduFDDkMmnXjZ
-rw-r--r--src/app.js41
-rw-r--r--src/i18n.js1
-rw-r--r--tools/signup-quick-test.js28
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());