aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/features/messages.js66
-rw-r--r--src/style.css14
2 files changed, 72 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. */