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 --- tools/zap-locked-test.js | 59 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 tools/zap-locked-test.js (limited to 'tools') 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