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 --- src/features/messages.js | 66 +++++++++++++++++++++++++++++++++----- src/style.css | 14 +++++++++ tools/quote-card-test.js | 82 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 154 insertions(+), 8 deletions(-) create mode 100644 tools/quote-card-test.js diff --git a/src/features/messages.js b/src/features/messages.js index 491ebcc..aa74387 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -2706,7 +2706,56 @@ export function messagesFeature(ctx) { return frame; } - function noteBody(text) { + // ---- quoted notes --------------------------------------------------------- + // A nostr:note1/nevent1 in someone's post IS a post, so show it: the thing + // they're talking about, inside what they said about it. Fetched once per + // id and remembered, so a feed that quotes the same note ten times asks for + // it once. + const quoted = new Map(); // id -> { status, ev } + function quotedNote(ref) { + let c = quoted.get(ref.id); + if (c) return c; + c = { status: 'loading', ev: null }; + quoted.set(ref.id, c); + (async () => { + const relays = [...new Set([...(ref.relays || []), ...zapRelays()])]; + const evs = await queryOn(relays, { ids: [ref.id] }, 4500).catch(() => []); + c.ev = (evs || [])[0] || null; + c.status = c.ev ? 'ready' : 'missing'; + scheduleRepaint(); + })(); + return c; + } + + // The card. Deliberately not a noteRow: a quote is context, not another + // post to act on — no reply, no boost, no zap of its own. Tapping it opens + // the note properly, which is where those live. + function quoteCard(ref, depth) { + const c = quotedNote(ref); + if (c.status === 'loading') { + return h('div', { class: 'quote-card quote-loading' }, + h('span', { class: 'spinner sm' }), h('span', { class: 'small faint' }, t('noteRefLoading'))); + } + if (!c.ev) { + return h('div', { class: 'quote-card' }, + h('span', { class: 'small faint' }, t('noteRefNotFound'))); + } + const ev = c.ev; + return h('div', { + class: 'quote-card clickable', + onClick: (e) => { e.stopPropagation(); openNoteThread(ev); }, + }, + h('div', { class: 'row gap6', style: 'align-items:center;min-width:0' }, + avatar(ev.pubkey, 'chat-avatar mini', false), + h('span', { class: 'quote-name' }, displayName(ev.pubkey)), + h('span', { class: 'small faint', style: 'white-space:nowrap' }, timeLabel(ev.created_at * 1000))), + h('div', { class: 'small', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, + // 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 + ...noteBody(ev.content, depth + 1))); + } + + function noteBody(text, depth = 0) { const out = []; for (const part of String(text || '').split(NOTE_SPLIT)) { if (!part) continue; @@ -2736,13 +2785,14 @@ export function messagesFeature(ctx) { if (ref && ref.type === 'pubkey') out.push(h('a', { href: '#', onClick: (e) => { e.preventDefault(); openProfile(ref.pk); } }, '@' + displayName(ref.pk))); else out.push(h('span', { class: 'faint' }, part.slice(6, 18) + '…')); } else if (/^nostr:(note|nevent)1/i.test(part)) { - // a referenced note opens right here as a thread — fetched by id - // (with the reference's relay hints) when tapped const ref = parseNostrRef(part.slice(6)); - if (ref && ref.type === 'event') out.push(h('a', { - href: '#', onClick: (e) => { e.preventDefault(); openNoteRef(ref); }, - }, t('noteRefLink'))); - else out.push(h('span', { class: 'faint' }, part.slice(6, 18) + '…')); + if (ref && ref.type === 'event') { + // the note they're quoting, shown inside what they said about it — + // except one level down, where it goes back to being a link + out.push(depth ? h('a', { + href: '#', onClick: (e) => { e.preventDefault(); e.stopPropagation(); openNoteRef(ref); }, + }, t('noteRefLink')) : quoteCard(ref, depth)); + } else out.push(h('span', { class: 'faint' }, part.slice(6, 18) + '…')); } else if (/^nostr:/i.test(part)) { out.push(h('span', { class: 'faint' }, part.slice(6, 18) + '…')); } else out.push(part); @@ -4812,7 +4862,7 @@ export function messagesFeature(ctx) { stopFeedWatch(); follows = null; followsAt = 0; feed = null; feedAt = 0; relayLists = null; mutes = null; mutesAt = 0; - reacts.clear(); boosts.clear(); seenNoteEv.clear(); myReactEv.clear(); + reacts.clear(); boosts.clear(); seenNoteEv.clear(); myReactEv.clear(); quoted.clear(); clearTimeout(zapSaveT); zapSaveT = null; zapSeed = null; zapTotals.clear(); zapAsked.clear(); zapPending.clear(); // 'mine' is per identity — refetch under the next }, diff --git a/src/style.css b/src/style.css index a03d25e..32e30d9 100644 --- a/src/style.css +++ b/src/style.css @@ -581,6 +581,20 @@ html.no-scroll { overflow: hidden; scrollbar-gutter: auto; } .lightbox-x:hover { background: rgba(255, 255, 255, .24); } @keyframes lightbox-in { from { opacity: 0; } to { opacity: 1; } } .note-video { display: block; max-width: 100%; max-height: 360px; border-radius: 10px; margin-top: 6px; background: #000; } +/* A quoted note, inside the post that quotes it: its own little card, so the + two voices don't run together. Context, not another post to act on — the + action row stops at the outer post. */ +.quote-card { + margin-top: 8px; padding: 8px 10px; border: 1px solid var(--line); border-radius: 10px; + background: var(--surface2); display: flex; flex-direction: column; gap: 4px; +} +.quote-card.clickable { cursor: pointer; } +.quote-card.clickable:hover { border-color: var(--line-strong); } +.quote-loading { flex-direction: row; align-items: center; gap: 8px; } +.quote-name { font-weight: 600; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.quote-card .note-img { max-height: 220px; } +.quote-card .yt-embed { margin-top: 4px; } + /* A YouTube link, shown as the video it is: the still, a play button, and a corner link out. The player replaces all of it on the first tap — see youtubeEmbed for why it isn't there from the start. */ 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