From 8b65caee4348073f44c36d379bc654c27f5e9ee5 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sun, 11 Oct 2026 22:30:10 -0700 Subject: Feed: ⚡ on a locked wallet says so and opens the Accounts page. A locked wallet — the watch-only view of a sealed vault, or a nostr identity signed in over one (Adam's debug Chrome: three watch-only accounts, the vault gone, his extension identity on the first) — has no keys to pay with, and the zap button knew it: it got no handler at all, so the tap pressed the icon and nothing more, under an arrow cursor. Now the tap toasts "Your wallet is locked, so it can't zap. Unlock it to send sats." and opens the Accounts page, where the padlock and Add account are; the open feed is remembered for the way back (by URL, or by id for Following, which has none — returnAfterSignin learns the id form). tools/zap-locked-test.js. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- src/features/messages.js | 18 ++++++++++++++- src/i18n.js | 1 + tools/zap-locked-test.js | 59 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 tools/zap-locked-test.js diff --git a/src/features/messages.js b/src/features/messages.js index 3bccd31..64e36c5 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -8385,6 +8385,21 @@ export function messagesFeature(ctx) { // Your own posts and messages too: a zap to yourself is a harmless // round-trip, and one bar that behaves the same everywhere beats a special case. const canZapPk = (pk) => !!pk && !isVisitor() && !!(hook('arkReady') || hook('canLnZap')); // a visitor's tap asks them in instead + // A locked wallet — the watch-only view of a sealed vault, or an identity + // signed in over one — has no keys to pay with. The tap says so and opens + // the Accounts page (the padlock, and the way to add the wallet, are + // there); the feed is remembered for the way back. It used to do nothing + // at all: the button pressed, the cursor an arrow. + function zapLockedAsk() { + toast('\u{1F512} ' + t('zapLocked'), 4000); + // the open feed, for the way back: by its URL when it has one, by its + // id otherwise (Following lives at no address) + const def = ui.chatOpen && ui.msgView === 'feed' ? feedDef() : null; + const back = def ? feedPath(def) || 'feed:' + def.id : null; + try { sessionStorage.setItem(RETURN_KEY, back || location.pathname + location.search); } catch {} + ui.screen = 'accounts'; + render(); + } function zapNote(pk, ev, origin) { const npubStr = npubOf(pk); const def = ctx.zapDefaultSat ? ctx.zapDefaultSat() : 0; @@ -9594,7 +9609,7 @@ export function messagesFeature(ctx) { // always in the bar — the same row of buttons on every post, so nothing // shifts when a wallet comes online; without one it is just the number btn(I_ZAP, zapLabel, zapSats ? fmtSats(zapSats) : 0, zapMine, - (zapHold && zapHold.onClick) || (isVisitor() ? () => signinAsk() : null), + (zapHold && zapHold.onClick) || (isVisitor() ? () => signinAsk() : wallet.watchOnly ? zapLockedAsk : null), 'note-zap' + (zapMine ? ' zapped' : '') + (zapFlying ? ' flying' : ''), zapHold && { ...zapHold, onClick: undefined }), // who did all that: a small chevron, always in place — dimmed until @@ -11068,6 +11083,7 @@ export function messagesFeature(ctx) { if (!back) return; setTimeout(() => { try { + if (back.startsWith('feed:')) { showFeed(back.slice(5)); render(); return; } const [path, q] = back.split('?'); if (/^\/feed(\/|$)/i.test(path)) { openFeedUrl(path.replace(/^\/feed\/?/i, ''), q ? '?' + q : ''); return; } const pk = parseNostrPubkey(path.replace(/^\//, '')); diff --git a/src/i18n.js b/src/i18n.js index 7488821..8d014a6 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -528,6 +528,7 @@ const STR = { msgNoIdentity: "Your nostr signer isn't connected. Reconnect it to send as yourself", msgSendFailed: 'No relay accepted the message', msgLockedChat: 'Your wallet is locked, so it can’t sign messages.', + zapLocked: 'Your wallet is locked, so it can’t zap. Unlock it to send sats.', noteRefLink: 'view note', noteRefLoading: 'Fetching note…', noteRefNotFound: "Couldn't find that note on the relays.", diff --git a/tools/zap-locked-test.js b/tools/zap-locked-test.js new file mode 100644 index 0000000..3077c1f --- /dev/null +++ b/tools/zap-locked-test.js @@ -0,0 +1,59 @@ +// A locked wallet (the watch-only view of a sealed vault, with an identity +// signed in over it) can't pay: tapping ⚡ on a post used to do nothing at +// all — the button pressed, the cursor an arrow. Now it says the wallet is +// locked and opens the Accounts page, remembering the feed for the way back. +// Run: bun tools/zap-locked-test.js +import puppeteer from './puppeteer.js'; +import { generateMnemonic } from '@scure/bip39'; +import { wordlist } from '@scure/bip39/wordlists/english'; +import { accountXpubFor, cacheKeyFor } from '../src/wallet.js'; +import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; +import { buildHtml } from '../build.js'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let ok = true; +const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; + +const xpub = accountXpubFor({ mnemonic: generateMnemonic(wordlist) }); +const SK = generateSecretKey(), AUTHOR = getPublicKey(SK); +const now = Math.floor(Date.now() / 1000); +const seeded = [finalizeEvent({ kind: 1, created_at: now - 100, tags: [], content: 'a sunset worth a zap' }, SK)]; + +const html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 0, fetch: (req) => { + const path = new URL(req.url).pathname; + if (path.startsWith('/punks')) return new Response(Bun.file('dist' + path)); + return new Response(html, { headers: { 'content-type': 'text/html' } }); +} }); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +const page = await browser.newPage(); +const errs = []; +page.on('pageerror', (e) => errs.push(String(e))); +const waitFor = async (fn, arg, ms = 10000) => { for (let i = 0; i < ms / 200; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(200); } return false; }; +try { + await page.setViewport({ width: 390, height: 844 }); + await page.goto(server.url.href, { waitUntil: 'domcontentloaded' }); + const base = cacheKeyFor(xpub); + await page.evaluate(([xpub, k, pk, ns]) => { + // a saved wallet, locked: only its xpub and its nostr identity on this device + localStorage.setItem('btc-wallet-watch', JSON.stringify([{ id: 'locked-watch', label: 'Account 1', xpub, network: 'mainnet', nostrPk: pk }])); + localStorage.setItem('btc-wallet-lockpw', '1'); + localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) })); + localStorage.setItem(k + ':feedNotes', JSON.stringify(ns)); + localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Sunset Poster', t: Date.now() } })); + localStorage.setItem('btc-wallet-feed:' + k, 'following'); + }, [xpub, base, AUTHOR, seeded]); + await page.reload({ waitUntil: 'domcontentloaded' }); + await page.waitForSelector('.header-avatar', { timeout: 20000 }); + await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click()); + check('the post is in the feed', await waitFor(() => !!document.querySelector('.notes-feed .note-zap'), null, 20000)); + const before = await page.evaluate(() => { const b = document.querySelector('.notes-feed .note-zap'); return { disabled: b.getAttribute('aria-disabled'), cursor: getComputedStyle(b).cursor }; }); + check('the ⚡ button is live (not aria-disabled, a pointer cursor)', before.disabled !== 'true' && before.cursor === 'pointer', JSON.stringify(before)); + await page.evaluate(() => document.querySelector('.notes-feed .note-zap').click()); + check('the tap says the wallet is locked', await waitFor(() => /locked, so it can.t zap/i.test(document.body.innerText), null, 4000)); + check('…and opens the Accounts page', await waitFor(() => !document.querySelector('.notes-feed') && /add account/i.test(document.body.innerText), null, 4000), + (await page.evaluate(() => document.body.innerText.slice(0, 160))).replace(/\n+/g, ' | ')); + check('the feed is remembered for the way back', await page.evaluate(() => /^(\/feed|feed:)/.test(sessionStorage.getItem('coinos-after-signin') || ''))); + check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | ')); +} finally { await browser.close(); server.stop(true); } +console.log(ok ? '\n✅ a locked wallet\'s ⚡ says so and opens the Accounts page' : '\n❌ failed'); +process.exit(ok ? 0 : 1); -- cgit v1.2.3