diff options
| -rw-r--r-- | src/features/messages.js | 66 | ||||
| -rw-r--r-- | src/style.css | 14 | ||||
| -rw-r--r-- | tools/quote-card-test.js | 82 |
3 files changed, 154 insertions, 8 deletions
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); |
