aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-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());