aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/features/messages.js66
-rw-r--r--src/style.css14
-rw-r--r--tools/quote-card-test.js82
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);