From 6cbb1b0c3665191ad486dc43d70dfbd264780f19 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sat, 12 Sep 2026 22:01:14 -0700 Subject: A quoted note shows up as the note, not as "view note". MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A nostr:note1/nevent1 in someone's post IS a post, and making the reader tap a link to see it — losing their place in the feed — was the wrong shape. It renders inside the post now: whose it is, when, and what it said, in a card of its own so the two voices don't run together. Tapping it opens the note properly. Deliberately not a noteRow: a quote is context, not a second post to act on, so it carries no reply, boost or zap of its own. The action row stops at the post that did the quoting. Fetched once per id and remembered, so a feed quoting the same note ten times asks for it once. One level deep only — a quote of a quote of a quote is a rabbit hole, and the inner one stays a link you can follow. tools/quote-card-test.js quotes a real note off the relays and checks it arrives with its author, its text, and none of the outer post's buttons. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx --- tools/quote-card-test.js | 82 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 tools/quote-card-test.js (limited to 'tools') diff --git a/tools/quote-card-test.js b/tools/quote-card-test.js new file mode 100644 index 0000000..5225f4a --- /dev/null +++ b/tools/quote-card-test.js @@ -0,0 +1,82 @@ +// A nostr:note1/nevent1 in someone's post IS a post: show it inside what they +// said about it, the way every other nostr client does, rather than making +// the reader tap "view note" and lose their place. +// +// The quoted event is fetched from real relays here — the note used is +// fiatjaf's, chosen because it will still be there tomorrow. +// +// Run: bun tools/quote-card-test.js +import puppeteer from 'puppeteer-core'; +import { generateMnemonic } from '@scure/bip39'; +import { wordlist } from '@scure/bip39/wordlists/english'; +import { cacheKeyFor } from '../src/wallet.js'; +import { neventOf } from '../src/nostr.js'; +import { buildHtml } from '../build.js'; +import { SimplePool } from 'nostr-tools/pool'; +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; }; + +// something real to quote: a recent note from an author who posts a lot +const pool = new SimplePool(); +const FIATJAF = '3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d'; +const found = await pool.querySync(['wss://relay.primal.net', 'wss://nos.lol'], + { kinds: [1], authors: [FIATJAF], limit: 5 }, { maxWait: 6000 }).catch(() => []); +const target = (found || []).filter((e) => e.content && e.content.length > 20)[0]; +if (!target) { console.log(' ✗ no note to quote (relays quiet)'); process.exit(1); } +const nevent = neventOf(target.id, target.pubkey); + +const html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 5269, fetch: () => 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 click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t); +const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms/250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; }; +try { + await page.setViewport({ width: 390, height: 844 }); + await page.goto('http://localhost:5269/', { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); + await page.reload({ waitUntil: 'domcontentloaded' }); + await sleep(400); + await click('get started'); await sleep(500); + await click('import existing'); await sleep(400); + await page.waitForSelector('textarea'); + const mn = generateMnemonic(wordlist); + await page.type('textarea', mn); + await click('open wallet'); + await waitText('receive', 20000); + const base = cacheKeyFor(mn + '\n'); + const A = 'a'.repeat(63) + '9'; + await page.evaluate(([k, pk, ref]) => { + localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now()/1000) })); + localStorage.setItem(k + ':feedNotes', JSON.stringify([ + { id: 'd'.repeat(64), pubkey: pk, kind: 1, created_at: Math.floor(Date.now()/1000), content: 'what he said nostr:' + ref, tags: [] }, + ])); + }, [base, A, nevent]); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await page.evaluate(() => { const b = [...document.querySelectorAll('button')].find((e) => /message/i.test(e.getAttribute('aria-label') || '')); if (b) b.click(); }); + await sleep(1000); + await page.evaluate(() => { const e = [...document.querySelectorAll('.item')].find((n) => /feed/i.test(n.textContent)); if (e) e.click(); }); + await sleep(7000); + + const card = await page.evaluate(() => { + const q = document.querySelector('.quote-card'); + if (!q) return null; + return { text: q.innerText.replace(/\n+/g, ' | ').slice(0, 90), hasAvatar: !!q.querySelector('.chat-avatar'), inPost: !!q.closest('.notes-feed') }; + }); + check('the quoted note is shown, not linked to', !!card, card ? 'rendered' : 'no card'); + check('...inside the post that quotes it', !!(card && card.inPost)); + check('...with whose note it is', !!(card && card.hasAvatar), card ? card.text.slice(0, 50) : ''); + const body = target.content.replace(/\s+/g, ' ').slice(0, 24); + check('...and what it actually said', !!(card && card.text.replace(/\s+/g, ' ').includes(body.slice(0, 18))), body); + check('no "view note" link left in the post', !(await page.evaluate(() => document.body.innerText)).includes('view note')); + // the outer post keeps its actions; the quote has none of its own + const acts = await page.evaluate(() => ({ + outer: document.querySelectorAll('.notes-feed > .row .note-acts').length, + inside: document.querySelectorAll('.quote-card .note-acts').length, + })); + check('the quote is context, not a second post to act on', acts.outer >= 1 && acts.inside === 0, JSON.stringify(acts)); +} finally { await browser.close(); server.stop(true); } +console.log(ok ? '\n✅ quotes read as quotes' : '\n❌ failed'); +process.exit(ok ? 0 : 1); -- cgit v1.2.3