diff --git a/src/components/ContentPreview/index.tsx b/src/components/ContentPreview/index.tsx index 43019645..97544862 100644 --- a/src/components/ContentPreview/index.tsx +++ b/src/components/ContentPreview/index.tsx @@ -106,7 +106,7 @@ export default function ContentPreview({ const withKindRow = (node: React.ReactNode) => (
- +
{node}
) @@ -127,7 +127,7 @@ export default function ContentPreview({ if (event.kind === ExtendedKind.DISCUSSION) { return (
- +
diff --git a/src/components/Note/NoteKindLabel.tsx b/src/components/Note/NoteKindLabel.tsx index 9193772b..b602e181 100644 --- a/src/components/Note/NoteKindLabel.tsx +++ b/src/components/Note/NoteKindLabel.tsx @@ -1,18 +1,22 @@ import { cn } from '@/lib/utils' import { getKindDescription } from '@/lib/kind-description' +import type { Event } from 'nostr-tools' import { useTranslation } from 'react-i18next' export default function NoteKindLabel({ kind, + event, className, size = 'normal' }: { kind: number + /** When set, kind 1 can show “Quote Note” for NIP-18 `q`-only notes. */ + event?: Event className?: string size?: 'normal' | 'small' }) { const { t } = useTranslation() - const { description } = getKindDescription(kind) + const { description } = getKindDescription(kind, event) return (

extractElevatedTags(event.tags), [event.tags]) @@ -226,7 +226,9 @@ export default function UnknownNote({

{headline}

- {!omitKindLabel ? : null} + {!omitKindLabel ? ( + + ) : null} {elevated.title?.trim() && !omitKindLabel ? (

{kindLabel.description} diff --git a/src/components/Note/index.tsx b/src/components/Note/index.tsx index 96f4b797..e65eabb7 100644 --- a/src/components/Note/index.tsx +++ b/src/components/Note/index.tsx @@ -450,7 +450,7 @@ export default function Note({ )}

- + {webReactionParentUrl ? (
diff --git a/src/components/ReplyNote/index.tsx b/src/components/ReplyNote/index.tsx index 64621073..08f3952a 100644 --- a/src/components/ReplyNote/index.tsx +++ b/src/components/ReplyNote/index.tsx @@ -125,7 +125,7 @@ export default function ReplyNote({
- + {webReactionParentUrl ? (
diff --git a/src/components/ReplyNoteList/index.tsx b/src/components/ReplyNoteList/index.tsx index 3680c8a5..c5f8eb52 100644 --- a/src/components/ReplyNoteList/index.tsx +++ b/src/components/ReplyNoteList/index.tsx @@ -7,6 +7,7 @@ import { } from '@/lib/rss-article' import { eventReferencesEventId, + getParentATag, getParentETag, getReplaceableCoordinateFromEvent, getRootATag, @@ -15,7 +16,8 @@ import { isMentioningMutedUsers, isNip25ReactionKind, isReplaceableEvent, - isReplyNoteEvent + isReplyNoteEvent, + kind1QuotesThreadRoot } from '@/lib/event' import logger from '@/lib/logger' import { getZapInfoFromEvent } from '@/lib/event-metadata' @@ -81,6 +83,29 @@ function replyFeedZapsFirst(sortedNonZapReplies: NEvent[], zaps: NEvent[]) { return [...sortZapReceiptsBySatsDesc(zaps), ...sortedNonZapReplies] } +/** Shown after thread replies for E/A roots (quote stream + kind 1 #q-only). */ +const EA_THREAD_TAIL_REFERENCE_KINDS = new Set([ + kinds.Highlights, + kinds.LongFormArticle, + ExtendedKind.WIKI_ARTICLE, + ExtendedKind.WIKI_ARTICLE_MARKDOWN, + ExtendedKind.PUBLICATION_CONTENT +]) + +/** Web (NIP-22) thread: tail = reference-style rows + URL-scoped reactions (same block order as E/A). */ +const WEB_THREAD_EXTRA_TAIL_KINDS = new Set([kinds.Reaction, ExtendedKind.EXTERNAL_REACTION]) + +function isWebThreadTailKind(kind: number): boolean { + return EA_THREAD_TAIL_REFERENCE_KINDS.has(kind) || WEB_THREAD_EXTRA_TAIL_KINDS.has(kind) +} + +function isKind1QuoteOnlyOfEaRoot(evt: NEvent, root: TRootInfo): boolean { + if (root.type === 'I') return false + if (evt.kind !== kinds.ShortTextNote) return false + if (getParentETag(evt) || getParentATag(evt)) return false + return kind1QuotesThreadRoot(evt, root) +} + function ReplyNoteList({ index, event, @@ -296,8 +321,21 @@ function ReplyNoteList({ ]) const replyIdSet = useMemo(() => new Set(replies.map((r) => r.id)), [replies]) - /** Events that quote the note (from useQuoteEvents) — render with quote styling and without embedded quote. */ - const quoteIdSet = useMemo(() => new Set(quoteEvents.map((e) => e.id)), [quoteEvents]) + /** Render with quote card chrome (tail stream + kind 1 #q-only of E/A root). */ + const quoteUiIdSet = useMemo(() => { + const s = new Set(quoteEvents.map((e) => e.id)) + if (rootInfo?.type === 'E' || rootInfo?.type === 'A') { + for (const r of replies) { + if (isKind1QuoteOnlyOfEaRoot(r, rootInfo)) s.add(r.id) + } + } + if (rootInfo?.type === 'I') { + for (const r of replies) { + if (EA_THREAD_TAIL_REFERENCE_KINDS.has(r.kind)) s.add(r.id) + } + } + return s + }, [quoteEvents, replies, rootInfo]) const mergedFeed = useMemo(() => { /** Quotes + time-sorted feeds must not interleave zap receipts chronologically */ const zapsThenTimeSorted = (merged: NEvent[], direction: 'asc' | 'desc') => { @@ -309,7 +347,45 @@ function ReplyNoteList({ } if (!showQuotes) return replies + const quoteOnly = quoteEvents.filter((e) => !replyIdSet.has(e.id)) + + // E/A: zaps (sats desc) → thread replies (1 / 1111 / 1244, excluding #q-only) → tail (quotes, highlights, long-form refs) + if (rootInfo?.type === 'E' || rootInfo?.type === 'A') { + const { zaps, nonZaps } = partitionZapReceipts(replies) + const middle = nonZaps.filter((e) => !isKind1QuoteOnlyOfEaRoot(e, rootInfo)) + const qOnlyFromReplies = nonZaps.filter((e) => isKind1QuoteOnlyOfEaRoot(e, rootInfo)) + const tailSeen = new Set() + const tail: NEvent[] = [] + const pushTail = (e: NEvent) => { + if (tailSeen.has(e.id)) return + tailSeen.add(e.id) + tail.push(e) + } + for (const e of qOnlyFromReplies) pushTail(e) + for (const e of quoteOnly) pushTail(e) + tail.sort((a, b) => b.created_at - a.created_at) + return [...replyFeedZapsFirst(middle, zaps), ...tail] + } + + // Web article / URL thread (NIP-22): same zaps → middle → tail layout as E/A + if (rootInfo?.type === 'I') { + const { zaps, nonZaps } = partitionZapReceipts(replies) + const middle = nonZaps.filter((e) => !isWebThreadTailKind(e.kind)) + const tailFromReplies = nonZaps.filter((e) => isWebThreadTailKind(e.kind)) + const tailSeen = new Set() + const tail: NEvent[] = [] + const pushTail = (e: NEvent) => { + if (tailSeen.has(e.id)) return + tailSeen.add(e.id) + tail.push(e) + } + for (const e of tailFromReplies) pushTail(e) + for (const e of quoteOnly) pushTail(e) + tail.sort((a, b) => b.created_at - a.created_at) + return [...replyFeedZapsFirst(middle, zaps), ...tail] + } + const merged = [...replies, ...quoteOnly] if (sort === 'oldest') return zapsThenTimeSorted(merged, 'asc') if (sort === 'newest') return zapsThenTimeSorted(merged, 'desc') @@ -321,7 +397,7 @@ function ReplyNoteList({ return [...sortedReplies, ...sortedQuotes] } return zapsThenTimeSorted(merged, 'desc') - }, [replies, quoteEvents, showQuotes, sort, replyIdSet]) + }, [replies, quoteEvents, showQuotes, sort, replyIdSet, rootInfo]) const [timelineKey] = useState(undefined) const [until, setUntil] = useState(undefined) @@ -468,10 +544,8 @@ function ReplyNoteList({ const handleEventPublished = (data: Event) => { const ce = data as CustomEvent const evt = ce.detail - if (!evt || !isReplyNoteEvent(evt)) return - if (eventReplyMatchesThreadRoot(evt, rootInfo)) { - onNewReply(evt) - } + if (!evt || !eventReplyMatchesThreadRoot(evt, rootInfo)) return + onNewReply(evt) } client.addEventListener('newEvent', handleEventPublished) @@ -577,6 +651,20 @@ function ReplyNoteList({ limit: LIMIT } ) + const qVals = Array.from( + new Set( + [rootInfo.eventId, rootInfo.id] + .map((x) => (typeof x === 'string' ? x.trim() : '')) + .filter(Boolean) + ) + ) + if (qVals.length > 0) { + filters.push({ + '#q': qVals, + kinds: [kinds.ShortTextNote], + limit: LIMIT + }) + } if (rootInfo.relay) { finalRelayUrls.push(rootInfo.relay) } @@ -593,7 +681,9 @@ function ReplyNoteList({ const regularReplies = allReplies.filter((evt) => rootInfo.type === 'I' ? isRssArticleUrlThreadInteraction(evt, rootInfo.id) - : isReplyNoteEvent(evt) + : isReplyNoteEvent(evt) || + ((rootInfo.type === 'E' || rootInfo.type === 'A') && + kind1QuotesThreadRoot(evt, rootInfo)) ) // Store in cache (this merges with existing cached replies) @@ -679,13 +769,19 @@ function ReplyNoteList({ setLoading(true) const events = await client.loadMoreTimeline(timelineKey, until, LIMIT) - const olderEvents = events.filter((evt) => isReplyNoteEvent(evt)) + const olderEvents = events.filter( + (evt) => + isReplyNoteEvent(evt) || + ((rootInfo?.type === 'E' || rootInfo?.type === 'A') && + rootInfo && + kind1QuotesThreadRoot(evt, rootInfo)) + ) if (olderEvents.length > 0) { addReplies(olderEvents) } setUntil(events.length ? events[events.length - 1].created_at - 1 : undefined) setLoading(false) - }, [loading, until, timelineKey]) + }, [loading, until, timelineKey, rootInfo?.type, rootInfo?.id]) const highlightReply = useCallback((eventId: string, scrollTo = true) => { if (scrollTo) { @@ -716,7 +812,7 @@ function ReplyNoteList({ )}
{mergedFeed.slice(0, showCount).map((item) => { - const isQuote = quoteIdSet.has(item.id) + const isQuote = quoteUiIdSet.has(item.id) // Don't filter by trust until trust data is loaded - prevents replies from // vanishing when wotSet is still empty (all non-self appear untrusted) if (isTrustLoaded && hideUntrustedInteractions && !isUserTrusted(item.pubkey)) { @@ -739,9 +835,11 @@ function ReplyNoteList({ const quoteLabel = item.kind === kinds.Highlights ? t('highlighted this note') - : item.kind === kinds.LongFormArticle - ? t('cited in article') - : t('quoted this note') + : item.kind === kinds.ShortTextNote + ? t('quoted this note') + : EA_THREAD_TAIL_REFERENCE_KINDS.has(item.kind) + ? t('cited in article') + : t('quoted this note') const hideQuotedNote = eventReferencesEventId(item, event) return ( ({ max: 10000 } const EVENT_EMBEDDED_PUBKEYS_CACHE = new LRUCache({ max: 10000 }) const EVENT_IS_REPLY_NOTE_CACHE = new LRUCache({ max: 10000 }) /** Bump when isReplyNoteEvent logic changes so cached booleans are not stale. */ -const IS_REPLY_NOTE_CACHE_KEY_SUFFIX = ':v2' +const IS_REPLY_NOTE_CACHE_KEY_SUFFIX = ':v3' export function isNsfwEvent(event: Event) { return event.tags.some( @@ -49,22 +49,12 @@ export function isReplyNoteEvent(event: Event) { const cache = EVENT_IS_REPLY_NOTE_CACHE.get(cacheKey) if (cache !== undefined) return cache - // Include #q (quote) — many clients omit e-tags on quote-only notes; they still belong in the thread. - const isReply = - !!getParentETag(event) || - !!getParentATag(event) || - !!getQuotedEventHexIdFromQTags(event) + // NIP-18 `q` without `e`/`a` is a quote note (top-level for OP vs reply filters), not a thread reply. + const isReply = !!getParentETag(event) || !!getParentATag(event) EVENT_IS_REPLY_NOTE_CACHE.set(cacheKey, isReply) return isReply } -/** First hex event id from `q` / `Q` tags (NIP-18 quote). */ -export function getQuotedEventHexIdFromQTags(event: Event): string | undefined { - const q = event.tags.find((t) => t[0] === 'q' || t[0] === 'Q')?.[1] - if (q && /^[0-9a-f]{64}$/i.test(q)) return q.toLowerCase() - return undefined -} - export function isReplaceableEvent(kind: number) { return ( kinds.isReplaceableKind(kind) || @@ -248,20 +238,28 @@ export function eventReferencesEventId( ? getReplaceableCoordinateFromEvent(targetEvent) : undefined + const qRef = getQuotedReferenceFromQTags(event) + if (targetHexId) { const rootId = getRootETag(event)?.[1]?.toLowerCase() if (rootId === targetHexId) return true const parentId = getParentETag(event)?.[1]?.toLowerCase() if (parentId === targetHexId) return true - const qTag = event.tags.find((t) => t[0] === 'q' || t[0] === 'Q')?.[1]?.toLowerCase() - if (qTag === targetHexId) return true + if (qRef?.hexId === targetHexId) return true const eTags = event.tags.filter((t) => t[0] === 'e' || t[0] === 'E') if (eTags.some((t) => t[1]?.toLowerCase() === targetHexId)) return true } if (targetCoordinate) { + const targetCoordNorm = normalizeReplaceableCoordinateString(targetCoordinate) const aTags = event.tags.filter((t) => t[0] === 'a' || t[0] === 'A') - if (aTags.some((t) => t[1]?.toLowerCase() === targetCoordinate.toLowerCase())) return true + if (aTags.some((t) => normalizeReplaceableCoordinateString(t[1] ?? '') === targetCoordNorm)) return true + if ( + qRef?.coordinate && + normalizeReplaceableCoordinateString(qRef.coordinate) === targetCoordNorm + ) { + return true + } } return false @@ -288,6 +286,113 @@ export function getReplaceableCoordinateFromEvent(event: Event) { return getReplaceableCoordinate(event.kind, event.pubkey, d) } +/** Normalize `kind:pubkey:d` for comparisons (lowercase pubkey; preserve d). */ +export function normalizeReplaceableCoordinateString(coord: string): string { + const m = /^(\d+):([0-9a-f]{64}):(.*)$/i.exec(coord.trim()) + if (!m) return coord.trim().toLowerCase() + return getReplaceableCoordinate(Number(m[1]), m[2].toLowerCase(), m[3]) +} + +function stripNostrUriScheme(s: string): string { + const t = s.trim() + if (t.toLowerCase().startsWith('nostr:')) return t.slice(6).trim() + return t +} + +/** + * NIP-10 / NIP-18: `q` tag value is `` or `` (coordinate), or NIP-19 bech32. + */ +export function parseQTagReferenceValue( + raw: string | undefined | null +): { hexId?: string; coordinate?: string } | undefined { + if (raw == null) return undefined + const s0 = stripNostrUriScheme(raw) + if (!s0) return undefined + + if (/^[0-9a-f]{64}$/i.test(s0)) { + return { hexId: s0.toLowerCase() } + } + + const coordMatch = /^(\d+):([0-9a-f]{64}):(.*)$/i.exec(s0) + if (coordMatch) { + return { + coordinate: getReplaceableCoordinate( + Number(coordMatch[1]), + coordMatch[2].toLowerCase(), + coordMatch[3] + ) + } + } + + if (/^n(?:ote|event|addr)1/i.test(s0)) { + try { + const { type, data } = nip19.decode(s0) + if (type === 'note') { + const id = typeof data === 'string' ? data : (data as { id?: string }).id + if (id && /^[0-9a-f]{64}$/i.test(id)) return { hexId: id.toLowerCase() } + } + if (type === 'nevent') { + const id = (data as { id: string }).id + if (id && /^[0-9a-f]{64}$/i.test(id)) return { hexId: id.toLowerCase() } + } + if (type === 'naddr') { + const d = data as { kind: number; pubkey: string; identifier: string } + return { + coordinate: getReplaceableCoordinate( + d.kind, + d.pubkey.toLowerCase(), + d.identifier ?? '' + ) + } + } + } catch { + /* invalid bech32 */ + } + } + + return undefined +} + +/** Parsed first `q` / `Q` tag on the event (NIP-10). */ +export function getQuotedReferenceFromQTags(event: Event): { + hexId?: string + coordinate?: string +} | undefined { + const q = event.tags.find((t) => t[0] === 'q' || t[0] === 'Q')?.[1] + return parseQTagReferenceValue(q) +} + +/** Hex id from `q` when the reference resolves to a fixed id (not coordinate-only). */ +export function getQuotedEventHexIdFromQTags(event: Event): string | undefined { + return getQuotedReferenceFromQTags(event)?.hexId +} + +/** Kind 1 whose `q` points at this hex id (legacy helper). */ +export function kind1QuotesEventHexId(event: Event, hexId: string): boolean { + if (event.kind !== kinds.ShortTextNote) return false + const ref = getQuotedReferenceFromQTags(event) + return !!ref?.hexId && ref.hexId === hexId.trim().toLowerCase() +} + +/** Kind 1 quote-of-root: match `q` hex and/or replaceable coordinate (and bech32 decoding). */ +export function kind1QuotesThreadRoot( + event: Event, + root: { type: 'E'; id: string } | { type: 'A'; id: string; eventId: string } +): boolean { + if (event.kind !== kinds.ShortTextNote) return false + const ref = getQuotedReferenceFromQTags(event) + if (!ref || (!ref.hexId && !ref.coordinate)) return false + if (root.type === 'E') { + const rid = root.id.trim().toLowerCase() + return !!ref.hexId && ref.hexId === rid + } + const eid = root.eventId.trim().toLowerCase() + const coordNorm = normalizeReplaceableCoordinateString(root.id) + if (ref.hexId && ref.hexId === eid) return true + if (ref.coordinate && normalizeReplaceableCoordinateString(ref.coordinate) === coordNorm) return true + return false +} + /** Whether an event matches a tombstone key from IndexedDB (e-tag id, a-tag coordinate, or k-tag kind:pubkey). */ export function isTombstoneKeyForEvent(event: Event, tombstones: Set): boolean { if (tombstones.has(event.id)) return true diff --git a/src/lib/kind-description.ts b/src/lib/kind-description.ts index e7f68f19..195ed9c0 100644 --- a/src/lib/kind-description.ts +++ b/src/lib/kind-description.ts @@ -1,14 +1,28 @@ import { ExtendedKind } from '@/constants' +import { getParentATag, getParentETag, getQuotedReferenceFromQTags } from '@/lib/event' +import type { Event } from 'nostr-tools' import { kinds } from 'nostr-tools' /** * Get the description for a given kind number * @param kind - The kind number + * @param event - When set, refines kind 1 (e.g. NIP-18 quote without thread parent → "Quote Note") * @returns An object with the kind number and description */ -export function getKindDescription(kind: number): { number: number; description: string } { +export function getKindDescription( + kind: number, + event?: Event +): { number: number; description: string } { switch (kind) { case kinds.ShortTextNote: + if ( + event && + getQuotedReferenceFromQTags(event) && + !getParentETag(event) && + !getParentATag(event) + ) { + return { number: 1, description: 'Quote Note' } + } return { number: 1, description: 'Short Text Note' } case ExtendedKind.COMMENT: return { number: 1111, description: 'Comment' } diff --git a/src/lib/thread-reply-root-match.ts b/src/lib/thread-reply-root-match.ts index 4042c164..ea667cb1 100644 --- a/src/lib/thread-reply-root-match.ts +++ b/src/lib/thread-reply-root-match.ts @@ -1,4 +1,4 @@ -import { getRootATag, getRootEventHexId } from '@/lib/event' +import { getRootATag, getRootEventHexId, kind1QuotesThreadRoot } from '@/lib/event' import { canonicalizeRssArticleUrl, getArticleUrlFromCommentITags, @@ -29,7 +29,8 @@ export function eventReplyMatchesThreadRoot(evt: Event, root: TThreadRootRef): b if (coord === root.id) return true const rootHex = getRootEventHexId(evt) if (rootHex && (rootHex === root.eventId || rootHex === root.id)) return true - return false + return kind1QuotesThreadRoot(evt, root) } - return getRootEventHexId(evt) === root.id + if (getRootEventHexId(evt) === root.id) return true + return kind1QuotesThreadRoot(evt, root) } diff --git a/src/providers/ReplyProvider.tsx b/src/providers/ReplyProvider.tsx index 769234a4..c0892a8d 100644 --- a/src/providers/ReplyProvider.tsx +++ b/src/providers/ReplyProvider.tsx @@ -6,7 +6,7 @@ import { import { getParentATag, getParentETag, - getQuotedEventHexIdFromQTags, + getQuotedReferenceFromQTags, getRootATag, getRootETag, isNip25ReactionKind @@ -80,11 +80,12 @@ export function ReplyProvider({ children }: { children: React.ReactNode }) { newReplyEventMap.set(parentId, [...(newReplyEventMap.get(parentId) || []), reply]) } - // Quote-only notes (#q, no e-tags): still index under the quoted event id. + // Quote-only notes (#q, no e-tags): index under quoted hex id and/or replaceable coordinate. if (!rootId && !parentId) { - const qid = getQuotedEventHexIdFromQTags(reply) - if (qid) { - newReplyEventMap.set(qid, [...(newReplyEventMap.get(qid) || []), reply]) + const qref = getQuotedReferenceFromQTags(reply) + const keys = new Set([qref?.hexId, qref?.coordinate].filter(Boolean) as string[]) + for (const key of keys) { + newReplyEventMap.set(key, [...(newReplyEventMap.get(key) || []), reply]) } } }) diff --git a/src/services/client-events.service.ts b/src/services/client-events.service.ts index bdf184a3..f6722d0b 100644 --- a/src/services/client-events.service.ts +++ b/src/services/client-events.service.ts @@ -3,12 +3,13 @@ import logger from '@/lib/logger' import { getParentATag, getParentETag, - getQuotedEventHexIdFromQTags, + getQuotedReferenceFromQTags, getRootATag, getRootETag, isNip25ReactionKind, isReplyNoteEvent, - isReplaceableEvent + isReplaceableEvent, + kind1QuotesThreadRoot } from '@/lib/event' import { getFirstHexEventIdFromETags } from '@/lib/tag' import type { Event as NEvent, Filter } from 'nostr-tools' @@ -443,7 +444,9 @@ export class EventService { * Reply-shaped events already in the session LRU for this thread (notes, kind 1111, voice comments, zaps), * found by BFS over e/E/q and (for `a`-root threads) a-tag links. Merges with relay fetches via ReplyProvider. */ - getSessionThreadInteractionEvents(root: { type: 'E' | 'A' | 'I'; id: string }): NEvent[] { + getSessionThreadInteractionEvents( + root: { type: 'E'; id: string } | { type: 'A'; id: string; eventId: string } | { type: 'I'; id: string } + ): NEvent[] { if (root.type === 'I') return [] const threadKeys = new Set() @@ -453,6 +456,8 @@ export class EventService { threadKeys.add(id) } else { threadKeys.add(root.id.trim().toLowerCase()) + const aid = root.eventId.trim().toLowerCase() + if (/^[0-9a-f]{64}$/.test(aid)) threadKeys.add(aid) } const linkRefs = (ev: NEvent): string[] => { @@ -463,7 +468,9 @@ export class EventService { } add(getParentETag(ev)?.[1]) add(getRootETag(ev)?.[1]) - add(getQuotedEventHexIdFromQTags(ev)) + const qref = getQuotedReferenceFromQTags(ev) + add(qref?.hexId) + add(qref?.coordinate) if (ev.kind === kinds.Zap) { add(getFirstHexEventIdFromETags(ev.tags)) } @@ -493,7 +500,9 @@ export class EventService { let added = 0 for (const [, ev] of this.sessionEventCache.entries()) { if (shouldDropEventOnIngest(ev)) continue - if (!isReplyNoteEvent(ev)) continue + const threadishKind1Quote = + (root.type === 'E' || root.type === 'A') && kind1QuotesThreadRoot(ev, root) + if (!isReplyNoteEvent(ev) && !threadishKind1Quote) continue if (isNip25ReactionKind(ev.kind)) continue if (seen.has(ev.id)) continue if (!linkRefs(ev).some((id) => threadKeys.has(id))) continue