diff --git a/package-lock.json b/package-lock.json index 3b9f5588..ce700e94 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "imwald", - "version": "23.4.0", + "version": "23.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "imwald", - "version": "23.4.0", + "version": "23.5.0", "license": "MIT", "dependencies": { "@asciidoctor/core": "^3.0.4", @@ -7245,13 +7245,13 @@ } }, "node_modules/axios": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", - "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz", + "integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==", "dev": true, "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.11", + "follow-redirects": "^1.16.0", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } diff --git a/package.json b/package.json index 0034e793..a3ff9f99 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "imwald", - "version": "23.4.0", + "version": "23.5.0", "description": "Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery", "private": true, "type": "module", diff --git a/src/components/Note/index.tsx b/src/components/Note/index.tsx index b51034b8..00aba1d1 100644 --- a/src/components/Note/index.tsx +++ b/src/components/Note/index.tsx @@ -8,6 +8,8 @@ import { isNip25ReactionKind, isNsfwEvent } from '@/lib/event' +import { shouldHideInteractions } from '@/lib/event-filtering' +import { mergeNip84MarkedIntervals, renderPlaintextWithNip84MergedMarks } from '@/lib/nip84-op-body-marks' import { getCachedThreadContextEvents } from '@/lib/navigation-related-events' import { toNote } from '@/lib/link' import { cn } from '@/lib/utils' @@ -106,7 +108,8 @@ export default function Note({ showFull = false, disableClick = false, fullCalendarInvite, - zapPollVoteHighlightOption + zapPollVoteHighlightOption, + nip84HighlightEvents }: { event: Event originalNoteId?: string @@ -119,6 +122,8 @@ export default function Note({ fullCalendarInvite?: { event: Event; naddr: string } /** Profile: highlight option when this row is from a zap vote receipt. */ zapPollVoteHighlightOption?: number + /** Kind-9802 events that cite this note; when spans match {@link displayEvent.content}, render green marks (note page OP). */ + nip84HighlightEvents?: Event[] }) { const { t } = useTranslation() const { navigateToNote } = useSmartNoteNavigationOptional() @@ -213,6 +218,29 @@ export default function Note({ /> ) } + if ( + nip84HighlightEvents?.length && + displayEvent.kind === kinds.ShortTextNote && + !shouldHideInteractions(displayEvent) + ) { + const merged = mergeNip84MarkedIntervals( + displayEvent.content ?? '', + nip84HighlightEvents, + displayEvent.id + ) + if (merged.length > 0) { + return ( +
+ {renderPlaintextWithNip84MergedMarks(displayEvent.content ?? '', merged)} +
+ ) + } + } return ( ) }, - [displayEvent, fullCalendarInvite, autoLoadMedia] + [displayEvent, fullCalendarInvite, autoLoadMedia, nip84HighlightEvents] ) let content: React.ReactNode diff --git a/src/components/NoteCard/MainNoteCard.tsx b/src/components/NoteCard/MainNoteCard.tsx index 97c01683..8a43e5e3 100644 --- a/src/components/NoteCard/MainNoteCard.tsx +++ b/src/components/NoteCard/MainNoteCard.tsx @@ -1,3 +1,4 @@ +import { useNip84HighlightTargetEvents } from '@/hooks' import { ExtendedKind } from '@/constants' import { Separator } from '@/components/ui/separator' import { getCachedThreadContextEvents } from '@/lib/navigation-related-events' @@ -5,9 +6,10 @@ import { toNote } from '@/lib/link' import { useSmartNoteNavigationOptional } from '@/PageManager' import client from '@/services/client.service' import { Pin } from 'lucide-react' -import { Event } from 'nostr-tools' +import { Event, kinds } from 'nostr-tools' import { useTranslation } from 'react-i18next' import Collapsible from '../Collapsible' +import NoteBoostBadges from '../NoteBoostBadges' import Note from '../Note' import NoteStats from '../NoteStats' import RepostDescription from './RepostDescription' @@ -39,6 +41,9 @@ export default function MainNoteCard({ }) { const { t } = useTranslation() const { navigateToNote } = useSmartNoteNavigationOptional() + const nip84HighlightEvents = useNip84HighlightTargetEvents( + event.kind === kinds.ShortTextNote ? event : null + ) const isZapFeedCard = event.kind === ExtendedKind.ZAP_RECEIPT || event.kind === ExtendedKind.ZAP_REQUEST const showNoteStatsRow = !embedded || isZapFeedCard @@ -99,8 +104,10 @@ export default function MainNoteCard({ hideParentNotePreview={hideParentNotePreview} zapPollVoteHighlightOption={zapPollVoteHighlightOption} showFull={showFull} + nip84HighlightEvents={nip84HighlightEvents} /> + {!embedded ? : null} {showNoteStatsRow ? ( - oldEvents.some((e) => e.id === event.id) ? oldEvents : [event, ...oldEvents] - ) + setEvents((oldEvents) => { + if (oldEvents.some((e) => e.id === event.id)) return oldEvents + if ( + isNip18RepostKind(event.kind) && + feedTimelineAlreadyRepresentsNip18Target(getNip18RepostTargetId(event), oldEvents) + ) { + noteStatsService.updateNoteStatsByEvents([event], undefined) + return oldEvents + } + return [event, ...oldEvents] + }) } else { - // Otherwise, buffer it and show the New Notes button - setNewEvents((oldEvents) => - [event, ...oldEvents].sort((a, b) => b.created_at - a.created_at) - ) + setNewEvents((oldEvents) => { + const pool = [...eventsRef.current, ...oldEvents] + if ( + isNip18RepostKind(event.kind) && + feedTimelineAlreadyRepresentsNip18Target(getNip18RepostTargetId(event), pool) + ) { + noteStatsService.updateNoteStatsByEvents([event], undefined) + return oldEvents + } + return [event, ...oldEvents].sort((a, b) => b.created_at - a.created_at) + }) } }, }, @@ -2445,13 +2486,29 @@ const NoteList = forwardRef( } if (shouldHideEventRef.current(event)) return if (pubkey && event.pubkey === pubkey) { - setEvents((oldEvents) => - oldEvents.some((e) => e.id === event.id) ? oldEvents : [event, ...oldEvents] - ) + setEvents((oldEvents) => { + if (oldEvents.some((e) => e.id === event.id)) return oldEvents + if ( + isNip18RepostKind(event.kind) && + feedTimelineAlreadyRepresentsNip18Target(getNip18RepostTargetId(event), oldEvents) + ) { + noteStatsService.updateNoteStatsByEvents([event], undefined) + return oldEvents + } + return [event, ...oldEvents] + }) } else { - setNewEvents((oldEvents) => - [event, ...oldEvents].sort((a, b) => b.created_at - a.created_at) - ) + setNewEvents((oldEvents) => { + const pool = [...eventsRef.current, ...oldEvents] + if ( + isNip18RepostKind(event.kind) && + feedTimelineAlreadyRepresentsNip18Target(getNip18RepostTargetId(event), pool) + ) { + noteStatsService.updateNoteStatsByEvents([event], undefined) + return oldEvents + } + return [event, ...oldEvents].sort((a, b) => b.created_at - a.created_at) + }) } } }, @@ -3120,7 +3177,26 @@ const NoteList = forwardRef( }, [events.length, showCount, loading, hasMore, mergePrefetchTargetsFromEvents]) const showNewEvents = () => { - setEvents((oldEvents) => [...newEvents, ...oldEvents]) + setEvents((oldEvents) => { + const pool: Event[] = [...oldEvents] + const statsOnly: Event[] = [] + const kept: Event[] = [] + for (const ev of newEvents) { + if ( + isNip18RepostKind(ev.kind) && + feedTimelineAlreadyRepresentsNip18Target(getNip18RepostTargetId(ev), pool) + ) { + statsOnly.push(ev) + continue + } + kept.push(ev) + pool.push(ev) + } + if (statsOnly.length > 0) { + noteStatsService.updateNoteStatsByEvents(statsOnly, undefined) + } + return [...kept, ...oldEvents] + }) setNewEvents([]) setTimeout(() => { scrollToTop('smooth') diff --git a/src/components/ReplyNote/index.tsx b/src/components/ReplyNote/index.tsx index ecf36b96..f7097860 100644 --- a/src/components/ReplyNote/index.tsx +++ b/src/components/ReplyNote/index.tsx @@ -30,6 +30,7 @@ import ReactionEmojiDisplay from '../Note/ReactionEmojiDisplay' import { FormattedTimestamp } from '../FormattedTimestamp' import Nip05 from '../Nip05' import NoteOptions from '../NoteOptions' +import NoteBoostBadges from '../NoteBoostBadges' import NoteStats from '../NoteStats' import ParentNotePreview from '../ParentNotePreview' import WebPreview from '../WebPreview' @@ -205,13 +206,16 @@ export default function ReplyNote({ {show && !isNip25ReactionKind(event.kind) && ( - + <> + + + )} ) diff --git a/src/components/ReplyNoteList/index.tsx b/src/components/ReplyNoteList/index.tsx index 32a7062d..13032cfa 100644 --- a/src/components/ReplyNoteList/index.tsx +++ b/src/components/ReplyNoteList/index.tsx @@ -1,4 +1,10 @@ -import { E_TAG_FILTER_BLOCKED_RELAY_URLS, ExtendedKind, THREAD_BACKLINK_STREAM_KINDS } from '@/constants' +import { + E_TAG_FILTER_BLOCKED_RELAY_URLS, + ExtendedKind, + NOTE_STATS_OP_REFERENCE_KINDS, + NOTE_STATS_OP_REFERENCE_KINDS_WITHOUT_HIGHLIGHT, + THREAD_BACKLINK_STREAM_KINDS +} from '@/constants' import { isDiscussionDownvoteEmoji, isDiscussionUpvoteEmoji } from '@/lib/discussion-votes' import { canonicalizeRssArticleUrl, @@ -43,6 +49,7 @@ import noteStatsService from '@/services/note-stats.service' import discussionFeedCache from '@/services/discussion-feed-cache.service' import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey' import { buildReplyReadRelayList, relayHintsFromEventTags } from '@/lib/relay-list-builder' +import { eventReferencesThreadTarget } from '@/lib/op-reference-tags' import { replyBelongsToNoteThread } from '@/lib/thread-reply-root-match' import { buildRssArticleUrlThreadInteractionFilters, @@ -70,6 +77,15 @@ type TRootInfo = const LIMIT = 200 const SHOW_COUNT = 10 +const MAX_KINDS_PER_THREAD_REQ_FILTER = 4 + +function chunkKindsForThreadReq(list: readonly number[], size = MAX_KINDS_PER_THREAD_REQ_FILTER): number[][] { + const out: number[][] = [] + for (let i = 0; i < list.length; i += size) { + out.push([...list.slice(i, i + size)]) + } + return out +} const THREAD_PROFILE_BATCH_DEBOUNCE_MS = 50 const THREAD_PROFILE_CHUNK = 80 @@ -280,7 +296,16 @@ function replyMatchesThreadForList( ) { return true } - return replyBelongsToNoteThread(evt, opEvent, rootInfo) + if (replyBelongsToNoteThread(evt, opEvent, rootInfo)) return true + if ( + (rootInfo.type === 'E' || rootInfo.type === 'A') && + evt.kind !== kinds.ShortTextNote && + NOTE_STATS_OP_REFERENCE_KINDS.includes(evt.kind) && + eventReferencesThreadTarget(evt, rootInfo) + ) { + return true + } + return false } function threadBacklinkRelationLabel(item: NEvent, t: TFunction): string { @@ -350,10 +375,7 @@ function ReplyNoteList({ const { relayUrls: browsingRelayUrls } = useCurrentRelays() const [rootInfo, setRootInfo] = useState(undefined) const { repliesMap, addReplies } = useReply() - const { quoteEvents, quoteLoading } = useQuoteEvents( - event, - showQuotes ?? false - ) + const { quoteEvents, quoteLoading } = useQuoteEvents(event, true) const filteredQuoteEvents = useMemo( () => quoteEvents.filter( @@ -634,6 +656,14 @@ function ReplyNoteList({ return zapsThenTimeSorted(merged, 'desc') }, [replies, filteredQuoteEvents, showQuotes, sort, replyIdSet, rootInfo]) + useEffect(() => { + if (!rootInfo) return + const toAdd = filteredQuoteEvents.filter((evt) => + replyMatchesThreadForList(evt, event, rootInfo, isDiscussionRoot) + ) + if (toAdd.length > 0) addReplies(toAdd) + }, [filteredQuoteEvents, rootInfo, event, isDiscussionRoot, addReplies]) + const parentNoteFeed = useNoteFeedProfileContext() const threadProfileLoadedRef = useRef>(new Set()) const threadProfileBatchGenRef = useRef(0) @@ -1076,6 +1106,16 @@ function ReplyNoteList({ } const filters: Filter[] = [] + const qKindsHex = Array.from( + new Set([ + kinds.ShortTextNote, + ExtendedKind.COMMENT, + ExtendedKind.VOICE_COMMENT, + ...NOTE_STATS_OP_REFERENCE_KINDS_WITHOUT_HIGHLIGHT + ]) + ).sort((a, b) => a - b) + const opRefChunks = chunkKindsForThreadReq(NOTE_STATS_OP_REFERENCE_KINDS_WITHOUT_HIGHLIGHT) + if (rootInfo.type === 'E') { // Fetch all reply types for event-based replies (keep ≤4 kinds per filter — some relays // NOTICE "too many kinds N" and drop the whole REQ if kind 7 is bundled with four others). @@ -1095,10 +1135,9 @@ function ReplyNoteList({ kinds: [kinds.Reaction], limit: LIMIT }) - // Kind-1 notes that quote via #q without e-tags (still part of this thread) filters.push({ '#q': [rootInfo.id], - kinds: [kinds.ShortTextNote], + kinds: qKindsHex, limit: LIMIT }) // For public messages (kind 24), also look for replies using 'q' tags @@ -1109,6 +1148,10 @@ function ReplyNoteList({ limit: LIMIT }) } + for (const chunk of opRefChunks) { + filters.push({ '#e': [rootInfo.id], kinds: chunk, limit: LIMIT }) + filters.push({ '#E': [rootInfo.id], kinds: chunk, limit: LIMIT }) + } } else if (rootInfo.type === 'A') { // Fetch all reply types for replaceable event-based replies filters.push( @@ -1140,13 +1183,17 @@ function ReplyNoteList({ if (qVals.length > 0) { filters.push({ '#q': qVals, - kinds: [kinds.ShortTextNote], + kinds: qKindsHex, limit: LIMIT }) } if (rootInfo.relay) { finalRelayUrls.push(rootInfo.relay) } + for (const chunk of opRefChunks) { + filters.push({ '#a': [rootInfo.id], kinds: chunk, limit: LIMIT }) + filters.push({ '#A': [rootInfo.id], kinds: chunk, limit: LIMIT }) + } } else if (rootInfo.type === 'I') { filters.push(...buildRssArticleUrlThreadInteractionFilters(rootInfo.id, LIMIT)) } @@ -1204,7 +1251,14 @@ function ReplyNoteList({ logger.warn('[ReplyNoteList] Cache returned null after store, using fetched replies only') addReplies(regularReplies) } - + + const statsBatch = mergedCachedReplies?.length ? mergedCachedReplies : regularReplies + if (statsBatch.length > 0) { + noteStatsService.updateNoteStatsByEvents(statsBatch, event.pubkey, { + statsRootEvent: event + }) + } + if (!hasCache) { // No cache: stop loading after adding replies setLoading(false) diff --git a/src/constants.ts b/src/constants.ts index 5bd858ed..8e4a1c30 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -593,6 +593,19 @@ export const THREAD_BACKLINK_STREAM_KINDS: readonly number[] = [ export const THREAD_BACKLINK_STREAM_KINDS_WITHOUT_HIGHLIGHT: readonly number[] = THREAD_BACKLINK_STREAM_KINDS.filter((k) => k !== kinds.Highlights) +/** + * Kinds that reference an OP via `#e` / `#E` / `#a` / `#A` / `#q` in note-stats and thread REQ filters. + * Extends {@link THREAD_BACKLINK_STREAM_KINDS} with publication headers (30040) that may tag notes without using 30041. + * REQ tag keys: `e`, `E`, `a`, `A`, `q` only (no `#Q`). + */ +export const NOTE_STATS_OP_REFERENCE_KINDS: readonly number[] = Array.from( + new Set([...THREAD_BACKLINK_STREAM_KINDS, ExtendedKind.PUBLICATION]) +).sort((a, b) => a - b) + +/** {@link NOTE_STATS_OP_REFERENCE_KINDS} without kind 9802 — pair with a small highlights-only filter on relays that cap `kinds`. */ +export const NOTE_STATS_OP_REFERENCE_KINDS_WITHOUT_HIGHLIGHT: readonly number[] = + NOTE_STATS_OP_REFERENCE_KINDS.filter((k) => k !== kinds.Highlights) + /** * When a filter touches these kinds (or omits `kinds`), omit {@link SOCIAL_KIND_BLOCKED_RELAY_URLS} from the relay * stack — those relays do not carry this note/comment surface (kinds **1** / **1111** / **11** per relay policy). diff --git a/src/hooks/index.tsx b/src/hooks/index.tsx index 926142ab..b68a2e17 100644 --- a/src/hooks/index.tsx +++ b/src/hooks/index.tsx @@ -9,3 +9,4 @@ export * from './useFetchRelayList' export * from './useSearchProfiles' export * from './useMediaExtraction' export * from './useEmojiInfosForEvent' +export * from './useNip84HighlightTargetEvents' diff --git a/src/hooks/useNip84HighlightTargetEvents.ts b/src/hooks/useNip84HighlightTargetEvents.ts new file mode 100644 index 00000000..2fd0e352 --- /dev/null +++ b/src/hooks/useNip84HighlightTargetEvents.ts @@ -0,0 +1,48 @@ +import client from '@/services/client.service' +import { useNoteStatsById } from '@/hooks/useNoteStatsById' +import { Event, kinds } from 'nostr-tools' +import { useEffect, useMemo, useState } from 'react' + +/** + * Fetches full kind-9802 events for {@link useNoteStatsById}(noteId).highlights so the note body can paint NIP-84 marks. + */ +export function useNip84HighlightTargetEvents(note: Event | null | undefined): Event[] { + const id = note?.id ?? '' + const noteStats = useNoteStatsById(id) + const [events, setEvents] = useState([]) + const highlightIdsKey = useMemo( + () => (noteStats?.highlights ?? []).map((h) => h.id).join(','), + [noteStats?.highlights] + ) + + useEffect(() => { + if (!note || note.kind !== kinds.ShortTextNote) { + setEvents([]) + return + } + const ids = highlightIdsKey.split(',').filter(Boolean) + if (ids.length === 0) { + setEvents([]) + return + } + let cancelled = false + void (async () => { + const loaded: Event[] = [] + for (const hid of ids) { + try { + const ev = await client.fetchEvent(hid) + if (ev && ev.kind === kinds.Highlights) loaded.push(ev) + } catch { + /* ignore */ + } + if (cancelled) return + } + if (!cancelled) setEvents(loaded) + })() + return () => { + cancelled = true + } + }, [note?.id, note?.kind, highlightIdsKey]) + + return events +} diff --git a/src/hooks/useQuoteEvents.tsx b/src/hooks/useQuoteEvents.tsx index 88ca4c3b..1f0f9fc5 100644 --- a/src/hooks/useQuoteEvents.tsx +++ b/src/hooks/useQuoteEvents.tsx @@ -1,8 +1,9 @@ import { E_TAG_FILTER_BLOCKED_RELAY_URLS, + ExtendedKind, FAST_READ_RELAY_URLS, - SEARCHABLE_RELAY_URLS, - THREAD_BACKLINK_STREAM_KINDS_WITHOUT_HIGHLIGHT + NOTE_STATS_OP_REFERENCE_KINDS_WITHOUT_HIGHLIGHT, + SEARCHABLE_RELAY_URLS } from '@/constants' import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event' import { buildNormalizedBlockedRelaySet } from '@/lib/thread-response-filter' @@ -17,6 +18,15 @@ import { Event, kinds } from 'nostr-tools' import { useEffect, useMemo, useRef, useState } from 'react' const LIMIT = 100 +const MAX_KINDS_PER_RELAY_FILTER = 4 + +function chunkKinds(list: readonly number[], size = MAX_KINDS_PER_RELAY_FILTER): number[][] { + const out: number[][] = [] + for (let i = 0; i < list.length; i += size) { + out.push([...list.slice(i, i + size)]) + } + return out +} const INITIAL_QUOTE_LOAD_TIMEOUT_MS = 12_000 /** Fetches events that quote or reference the given event (#q, #e, #a tags). */ @@ -98,12 +108,31 @@ export function useQuoteEvents(event: Event | null, enabled: boolean) { : `${ev.kind}:${ev.pubkey}:${ev.id}` const highlightKinds = [kinds.Highlights] as const - const otherBacklinkKinds = [...THREAD_BACKLINK_STREAM_KINDS_WITHOUT_HIGHLIGHT] + const opRefKindChunks = chunkKinds(NOTE_STATS_OP_REFERENCE_KINDS_WITHOUT_HIGHLIGHT) + const qKindsBroad = Array.from( + new Set([ + kinds.ShortTextNote, + ExtendedKind.COMMENT, + ExtendedKind.VOICE_COMMENT, + ...NOTE_STATS_OP_REFERENCE_KINDS_WITHOUT_HIGHLIGHT + ]) + ).sort((a, b) => a - b) + const qValsReplaceable = Array.from( + new Set( + [ev.id, eventCoordinate] + .map((x) => (typeof x === 'string' ? x.trim() : '')) + .filter(Boolean) + ) + ) const subRequests: { urls: string[]; filter: TSubRequestFilter }[] = [ { urls: finalRelayUrls, - filter: { '#q': [qeIdForTagFilter], kinds: [kinds.ShortTextNote], limit: LIMIT } + filter: { + '#q': isReplaceableEvent(ev.kind) ? qValsReplaceable : [qeIdForTagFilter], + kinds: qKindsBroad, + limit: LIMIT + } }, { urls: finalRelayUrls, @@ -117,15 +146,26 @@ export function useQuoteEvents(event: Event | null, enabled: boolean) { limit: LIMIT } }, - { - urls: finalRelayUrls, - filter: { - '#a': [eventCoordinate], - kinds: otherBacklinkKinds, - limit: LIMIT - } - } + ...opRefKindChunks.map( + (kindsChunk) => + ({ + urls: finalRelayUrls, + filter: { + '#a': [eventCoordinate], + kinds: kindsChunk, + limit: LIMIT + } + }) as { urls: string[]; filter: TSubRequestFilter } + ) ] + if (isReplaceableEvent(ev.kind)) { + for (const kindsChunk of opRefKindChunks) { + subRequests.push({ + urls: finalRelayUrls, + filter: { '#A': [eventCoordinate], kinds: kindsChunk, limit: LIMIT } + }) + } + } // `#e` tag filters must use 64-hex event ids. For replaceable roots we use `#a`/`#q` only. if (qeIdIsHexEventId) { subRequests.push( @@ -137,14 +177,30 @@ export function useQuoteEvents(event: Event | null, enabled: boolean) { limit: LIMIT } }, - { + ...opRefKindChunks.map((kindsChunk) => ({ urls: finalRelayUrls, filter: { '#e': [qeIdForTagFilter], - kinds: otherBacklinkKinds, + kinds: kindsChunk, limit: LIMIT } - } + })), + { + urls: finalRelayUrls, + filter: { + '#E': [qeIdForTagFilter], + kinds: [...highlightKinds], + limit: LIMIT + } + }, + ...opRefKindChunks.map((kindsChunk) => ({ + urls: finalRelayUrls, + filter: { + '#E': [qeIdForTagFilter], + kinds: kindsChunk, + limit: LIMIT + } + })) ) } diff --git a/src/lib/event.ts b/src/lib/event.ts index 64fa89c7..47b30818 100644 --- a/src/lib/event.ts +++ b/src/lib/event.ts @@ -26,6 +26,36 @@ export function isNip18RepostKind(kind: number): boolean { return kind === kinds.Repost || kind === ExtendedKind.GENERIC_REPOST } +/** + * Target id for NIP-18 repost rows (stats + feed dedupe): `e` first, then embedded JSON `id`, then `a` on generic repost. + * Mirrors {@link NoteStatsService} repost classification so boost strips and “skip duplicate row” agree with stats. + */ +export function getNip18RepostTargetId(evt: Event): string | undefined { + if (!isNip18RepostKind(evt.kind)) return undefined + + const hex = getFirstHexEventIdFromETags(evt.tags) + if (hex) return hex.toLowerCase() + + const raw = evt.content?.trim() + if (raw) { + try { + const embedded = JSON.parse(raw) as { id?: string } + if (embedded.id && /^[0-9a-f]{64}$/i.test(embedded.id)) { + return embedded.id.toLowerCase() + } + } catch { + /* ignore */ + } + } + + if (evt.kind === ExtendedKind.GENERIC_REPOST) { + const aTag = evt.tags.find(tagNameEquals('a')) ?? evt.tags.find(tagNameEquals('A')) + const coord = aTag?.[1]?.trim() + if (coord) return coord + } + return undefined +} + /** NIP-56: kind 1984 report / flag (`kinds.Report` and {@link ExtendedKind.REPORT} are the same kind). */ export function isNip56ReportEvent(event: Pick): boolean { return event.kind === kinds.Report || event.kind === ExtendedKind.REPORT @@ -370,7 +400,7 @@ export function replaceableEventDedupeKey(event: Event): string { } /** Normalize `kind:pubkey:d` for comparisons (lowercase pubkey; preserve d). */ -function normalizeReplaceableCoordinateString(coord: string): string { +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]) diff --git a/src/lib/nip84-op-body-marks.tsx b/src/lib/nip84-op-body-marks.tsx new file mode 100644 index 00000000..0ecc5b6c --- /dev/null +++ b/src/lib/nip84-op-body-marks.tsx @@ -0,0 +1,75 @@ +import { Fragment, type ReactNode } from 'react' +import { kinds, type Event } from 'nostr-tools' +import { resolveNip84HighlightDisplay } from '@/lib/nip84-highlight-display' + +/** Same classes as {@link Highlight} NIP-84 span marks (keep in sync manually). */ +export const NIP84_HIGHLIGHT_MARK_CLASSNAME = + 'bg-green-200 dark:bg-green-600 dark:text-white px-1 rounded font-medium' + +function stripOuterQuotes(s: string): string { + let t = s.trim() + if (t.startsWith('"') && t.endsWith('"')) { + t = t.slice(1, -1).trim() + } + return t +} + +function highlightTargetsNoteHex(h: Event, opHex: string): boolean { + const want = opHex.trim().toLowerCase() + if (!/^[0-9a-f]{64}$/i.test(want)) return false + return h.tags.some( + (t) => (t[0] === 'e' || t[0] === 'E') && typeof t[1] === 'string' && t[1].trim().toLowerCase() === want + ) +} + +/** Non-overlapping merged intervals for first occurrence of each highlight’s marked span in `baseText`. */ +export function mergeNip84MarkedIntervals( + baseText: string, + highlightEvents: Event[], + opEventHexId: string +): { start: number; end: number }[] { + const intervals: { start: number; end: number }[] = [] + for (const ev of highlightEvents) { + if (ev.kind !== kinds.Highlights) continue + if (!highlightTargetsNoteHex(ev, opEventHexId)) continue + const { markedSpan } = resolveNip84HighlightDisplay(ev) + const needle = stripOuterQuotes(markedSpan) + if (!needle) continue + const idx = baseText.indexOf(needle) + if (idx >= 0) intervals.push({ start: idx, end: idx + needle.length }) + } + if (intervals.length === 0) return [] + intervals.sort((a, b) => a.start - b.start) + const merged: { start: number; end: number }[] = [] + for (const cur of intervals) { + const prev = merged[merged.length - 1] + if (!prev || cur.start > prev.end) merged.push({ ...cur }) + else prev.end = Math.max(prev.end, cur.end) + } + return merged +} + +export function renderPlaintextWithNip84MergedMarks( + baseText: string, + merged: { start: number; end: number }[] +): ReactNode { + if (merged.length === 0) return baseText + const nodes: ReactNode[] = [] + let cursor = 0 + let i = 0 + for (const m of merged) { + if (cursor < m.start) nodes.push({baseText.slice(cursor, m.start)}) + nodes.push( + + {baseText.slice(m.start, m.end)} + + ) + cursor = m.end + } + if (cursor < baseText.length) nodes.push({baseText.slice(cursor)}) + return <>{nodes} +} diff --git a/src/lib/op-reference-tags.ts b/src/lib/op-reference-tags.ts new file mode 100644 index 00000000..b72bb210 --- /dev/null +++ b/src/lib/op-reference-tags.ts @@ -0,0 +1,66 @@ +import { ExtendedKind } from '@/constants' +import { + getReplaceableCoordinateFromEvent, + isReplaceableEvent, + normalizeReplaceableCoordinateString +} from '@/lib/event' +import { isRssArticleUrlThreadInteraction } from '@/lib/rss-web-feed' +import { canonicalizeRssArticleUrl, getArticleUrlFromCommentITags } from '@/lib/rss-article' +import type { TThreadRootRef } from '@/lib/thread-reply-root-match' +import type { Event } from 'nostr-tools' + +const REF_TAG_NAMES = new Set(['e', 'E', 'a', 'A', 'q', 'Q']) + +/** + * True if any `e` / `E` / `a` / `A` / `q` / `Q` tag on `evt` references the thread root described by `root` + * (hex id, replaceable coordinate, or canonical article URL). Used for broad “references OP” discovery + * where {@link getRootEventHexId} does not apply (e.g. long-form on the OP). + */ +export function eventReferencesThreadTarget(evt: Event, root: TThreadRootRef): boolean { + if (root.type === 'I') { + return isRssArticleUrlThreadInteraction(evt, root.id) + } + if (root.type === 'A') { + const coordNorm = normalizeReplaceableCoordinateString(root.id) + const eventHex = root.eventId.trim().toLowerCase() + for (const t of evt.tags) { + const name = t[0] + if (!REF_TAG_NAMES.has(name)) continue + const v = typeof t[1] === 'string' ? t[1].trim() : '' + if (!v) continue + if (/^[0-9a-f]{64}$/i.test(v) && v.toLowerCase() === eventHex) return true + if (name === 'a' || name === 'A') { + if (normalizeReplaceableCoordinateString(v) === coordNorm) return true + } + } + return false + } + const hex = root.id.trim().toLowerCase() + if (!/^[0-9a-f]{64}$/i.test(hex)) return false + for (const t of evt.tags) { + const name = t[0] + if (!REF_TAG_NAMES.has(name)) continue + const v = typeof t[1] === 'string' ? t[1].trim() : '' + if (!v) continue + if (/^[0-9a-f]{64}$/i.test(v) && v.toLowerCase() === hex) return true + } + return false +} + +/** Build thread root ref from the note/article stats root (same shapes as {@link ReplyNoteList} `rootInfo`). */ +export function threadRootRefFromStatsRootEvent(event: Event): TThreadRootRef | undefined { + if (event.kind === ExtendedKind.RSS_THREAD_ROOT) { + const url = getArticleUrlFromCommentITags(event) + if (!url) return undefined + return { type: 'I', id: canonicalizeRssArticleUrl(url) } + } + if (isReplaceableEvent(event.kind)) { + return { + type: 'A', + id: getReplaceableCoordinateFromEvent(event), + eventId: event.id, + pubkey: event.pubkey + } + } + return { type: 'E', id: event.id.trim().toLowerCase(), pubkey: event.pubkey } +} diff --git a/src/lib/translate-client.ts b/src/lib/translate-client.ts index 5f6c20a2..9c93e752 100644 --- a/src/lib/translate-client.ts +++ b/src/lib/translate-client.ts @@ -82,8 +82,13 @@ export function translateServerSupportsLogicalTarget(targetCode: string): boolea return advertisedTranslateApiCodes.has(advertisedApiCodeKey(targetCode)) } -let languagesCache: { list: TranslateLanguageOption[]; at: number } | null = null +let languagesCache: { list: TranslateLanguageOption[]; at: number; fromFailure?: boolean } | null = null const LANGUAGES_CACHE_TTL_MS = 60_000 +/** After HTTP/parse failure, cache empty so each {@link useMenuActions} mount does not re-request. */ +const LANGUAGES_FAILURE_CACHE_TTL_MS = 120_000 + +let languagesFetchInFlight: Promise | null = null +let lastLanguagesFailureLogAt = 0 function parseLanguagesResponse(data: unknown): TranslateLanguageOption[] { if (!Array.isArray(data)) return [] @@ -113,30 +118,51 @@ export async function fetchTranslateLanguages(): Promise => { + const res = await electronAwareFetch(url) + if (!res.ok) { + const t = Date.now() + if (t - lastLanguagesFailureLogAt > 10_000) { + lastLanguagesFailureLogAt = t + logger.warn('[Translate] /languages failed', { status: res.status }) + } + languagesCache = { list: [], at: t, fromFailure: true } + recordAdvertisedTranslateCodesFromServer([]) + return [] + } + try { + const data = (await res.json()) as unknown + const list = parseLanguagesResponse(data) + languagesCache = { list, at: Date.now() } + recordAdvertisedTranslateCodesFromServer(list) + return list + } catch (e) { + const t = Date.now() + if (t - lastLanguagesFailureLogAt > 10_000) { + lastLanguagesFailureLogAt = t + logger.warn('[Translate] /languages parse error', { e }) + } + languagesCache = { list: [], at: t, fromFailure: true } + recordAdvertisedTranslateCodesFromServer([]) + return [] + } + })().finally(() => { + languagesFetchInFlight = null + }) + + return languagesFetchInFlight } export function clearTranslateLanguagesCache(): void { diff --git a/src/pages/secondary/NotePage/index.tsx b/src/pages/secondary/NotePage/index.tsx index 50d2a2b2..230626eb 100644 --- a/src/pages/secondary/NotePage/index.tsx +++ b/src/pages/secondary/NotePage/index.tsx @@ -12,7 +12,7 @@ import UserAvatar from '@/components/UserAvatar' import { Card } from '@/components/ui/card' import { Separator } from '@/components/ui/separator' import { Skeleton } from '@/components/ui/skeleton' -import { useFetchEvent, useFetchProfile } from '@/hooks' +import { useFetchEvent, useFetchProfile, useNip84HighlightTargetEvents } from '@/hooks' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import { getParentBech32Id, @@ -116,6 +116,7 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }: const { event, isFetching, refetch: refetchMain } = useFetchEvent(id, initialEvent) const [externalEvent, setExternalEvent] = useState(undefined) const finalEvent = event || externalEvent + const nip84HighlightEvents = useNip84HighlightTargetEvents(finalEvent) const parentEventId = useMemo(() => { if (!finalEvent) return undefined @@ -508,6 +509,7 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }: hideParentNotePreview originalNoteId={id} showFull + nip84HighlightEvents={nip84HighlightEvents} fullCalendarInvite={ calendarInviteEvent && calendarInviteNaddr ? { event: calendarInviteEvent, naddr: calendarInviteNaddr } diff --git a/src/services/note-stats.service.ts b/src/services/note-stats.service.ts index 60de9243..ad96c73f 100644 --- a/src/services/note-stats.service.ts +++ b/src/services/note-stats.service.ts @@ -2,15 +2,19 @@ import { E_TAG_FILTER_BLOCKED_RELAY_URLS, ExtendedKind, FAST_READ_RELAY_URLS, + NOTE_STATS_OP_REFERENCE_KINDS_WITHOUT_HIGHLIGHT, SEARCHABLE_RELAY_URLS } from '@/constants' import { replaceStandardEmojiShortcodesInContent } from '@/lib/emoji-content' import { + getNip18RepostTargetId, getParentEventHexId, getReplaceableCoordinateFromEvent, isNip18RepostKind, isReplaceableEvent } from '@/lib/event' +import { eventReferencesThreadTarget, threadRootRefFromStatsRootEvent } from '@/lib/op-reference-tags' +import type { TThreadRootRef } from '@/lib/thread-reply-root-match' import { getZapInfoFromEvent } from '@/lib/event-metadata' import logger from '@/lib/logger' import { @@ -353,7 +357,9 @@ class NoteStatsService { const { queryService } = await import('@/services/client.service') const onStatsEvent = (evt: Event) => { - this.updateNoteStatsByEvents([evt], resolvedEvent!.pubkey) + this.updateNoteStatsByEvents([evt], resolvedEvent!.pubkey, { + statsRootEvent: resolvedEvent! + }) events.push(evt) } if (nonSocial.length > 0) { @@ -511,6 +517,15 @@ class NoteStatsService { { '#e': [rootId], kinds: [kinds.Zap], limit: 100 } ] + const qKindsHex = Array.from( + new Set([ + kinds.ShortTextNote, + ExtendedKind.COMMENT, + ExtendedKind.VOICE_COMMENT, + ...NOTE_STATS_OP_REFERENCE_KINDS_WITHOUT_HIGHLIGHT + ]) + ).sort((a, b) => a - b) + const social: Filter[] = [ { '#e': [rootId], @@ -523,14 +538,31 @@ class NoteStatsService { ], limit: interactionLimit }, + { + '#e': [rootId], + kinds: [...NOTE_STATS_OP_REFERENCE_KINDS_WITHOUT_HIGHLIGHT], + limit: interactionLimit + }, + { + '#E': [rootId], + kinds: [...NOTE_STATS_OP_REFERENCE_KINDS_WITHOUT_HIGHLIGHT], + limit: interactionLimit + }, { '#q': [rootId], - kinds: [kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT], + kinds: qKindsHex, limit: 50 } ] if (replaceableCoordinate) { + const qValsReplaceable = Array.from( + new Set( + [event.id, replaceableCoordinate] + .map((x) => (typeof x === 'string' ? x.trim() : '')) + .filter(Boolean) + ) + ) nonSocial.push( { '#a': [replaceableCoordinate], kinds: [kinds.Reaction], limit: reactionLimit }, { '#a': [replaceableCoordinate], kinds: [kinds.Zap], limit: 100 } @@ -548,8 +580,18 @@ class NoteStatsService { limit: interactionLimit }, { - '#q': [replaceableCoordinate], - kinds: [kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT], + '#a': [replaceableCoordinate], + kinds: [...NOTE_STATS_OP_REFERENCE_KINDS_WITHOUT_HIGHLIGHT], + limit: interactionLimit + }, + { + '#A': [replaceableCoordinate], + kinds: [...NOTE_STATS_OP_REFERENCE_KINDS_WITHOUT_HIGHLIGHT], + limit: interactionLimit + }, + { + '#q': qValsReplaceable, + kinds: qKindsHex, limit: 50 } ) @@ -618,22 +660,23 @@ class NoteStatsService { mergeOpts?: { interactionTargetNoteId?: string replyParentNoteId?: string + /** Stats root from {@link fetchNoteStats} / relay batch — enables OP-reference kinds to count toward replies. */ + statsRootEvent?: Event } ) { const updatedEventIdSet = new Set() - + // Process events in batches for better performance const batchSize = 50 for (let i = 0; i < events.length; i += batchSize) { const batch = events.slice(i, i + batchSize) batch.forEach((evt) => { - const updatedEventId = this.processEvent(evt, originalEventAuthor, mergeOpts) - if (updatedEventId) { - updatedEventIdSet.add(updatedEventId) + for (const id of this.processEvent(evt, originalEventAuthor, mergeOpts)) { + updatedEventIdSet.add(this.statsKey(id)) } }) } - + updatedEventIdSet.forEach((eventId) => { this.notifyNoteStats(this.statsKey(eventId)) }) @@ -642,40 +685,60 @@ class NoteStatsService { private processEvent( evt: Event, originalEventAuthor?: string, - mergeOpts?: { interactionTargetNoteId?: string; replyParentNoteId?: string } - ): string | undefined { - let updatedEventId: string | undefined - + mergeOpts?: { + interactionTargetNoteId?: string + replyParentNoteId?: string + statsRootEvent?: Event + } + ): string[] { + const out: string[] = [] + const push = (k: string | undefined) => { + if (!k) return + const s = this.statsKey(k) + if (!out.includes(s)) out.push(s) + } + const pushMany = (ks: string[]) => { + for (const k of ks) push(k) + } + if (evt.kind === kinds.Reaction) { - updatedEventId = this.addLikeByEvent(evt, originalEventAuthor, mergeOpts?.interactionTargetNoteId) + push(this.addLikeByEvent(evt, originalEventAuthor, mergeOpts?.interactionTargetNoteId)) } else if (evt.kind === ExtendedKind.EXTERNAL_REACTION) { - updatedEventId = this.addLikeByExternalWebReactionEvent( - evt, - originalEventAuthor, - mergeOpts?.interactionTargetNoteId + push( + this.addLikeByExternalWebReactionEvent( + evt, + originalEventAuthor, + mergeOpts?.interactionTargetNoteId + ) ) } else if (isNip18RepostKind(evt.kind)) { - updatedEventId = this.addRepostByEvent(evt, originalEventAuthor, mergeOpts?.interactionTargetNoteId) + push(this.addRepostByEvent(evt, originalEventAuthor, mergeOpts?.interactionTargetNoteId)) } else if (evt.kind === kinds.Zap) { - updatedEventId = this.addZapByEvent(evt, originalEventAuthor) + push(this.addZapByEvent(evt, originalEventAuthor)) } else if (evt.kind === kinds.ShortTextNote || evt.kind === ExtendedKind.COMMENT || evt.kind === ExtendedKind.VOICE_COMMENT) { const isQuote = this.isQuoteByEvent(evt) if (isQuote) { - updatedEventId = this.addQuoteByEvent(evt, originalEventAuthor) + push(this.addQuoteByEvent(evt, originalEventAuthor)) } else if (mergeOpts?.replyParentNoteId) { - updatedEventId = this.addReplyByEvent(evt, originalEventAuthor, mergeOpts.replyParentNoteId) + pushMany(this.addReplyByEvent(evt, originalEventAuthor, mergeOpts.replyParentNoteId)) } else { - updatedEventId = this.addReplyByEvent(evt, originalEventAuthor) + pushMany(this.addReplyByEvent(evt, originalEventAuthor)) } + } else if ( + mergeOpts?.statsRootEvent && + evt.kind !== kinds.Highlights && + NOTE_STATS_OP_REFERENCE_KINDS_WITHOUT_HIGHLIGHT.includes(evt.kind) + ) { + pushMany(this.addOpReferenceAsThreadResponse(evt, originalEventAuthor, mergeOpts.statsRootEvent)) } else if (evt.kind === kinds.Highlights) { - updatedEventId = this.addHighlightByEvent(evt, originalEventAuthor) + push(this.addHighlightByEvent(evt, originalEventAuthor)) } else if (evt.kind === ExtendedKind.WEB_BOOKMARK) { - updatedEventId = this.addWebBookmarkByArticleUrlEvent(evt) + push(this.addWebBookmarkByArticleUrlEvent(evt)) } else if (evt.kind === kinds.BookmarkList) { this.addBookmarkListRefsByEvent(evt) } - - return updatedEventId + + return out } private reactionEmojiFromEvent(evt: Event): TEmoji | string { @@ -785,29 +848,7 @@ class NoteStatsService { private repostStatsTargetId(evt: Event, forcedTargetEventId?: string): string | undefined { const forced = forcedTargetEventId?.trim() if (forced) return forced - if (!isNip18RepostKind(evt.kind)) return undefined - - const hex = getFirstHexEventIdFromETags(evt.tags) - if (hex) return hex.toLowerCase() - - const raw = evt.content?.trim() - if (raw) { - try { - const embedded = JSON.parse(raw) as { id?: string } - if (embedded.id && /^[0-9a-f]{64}$/i.test(embedded.id)) { - return embedded.id.toLowerCase() - } - } catch { - /* ignore */ - } - } - - if (evt.kind === ExtendedKind.GENERIC_REPOST) { - const aTag = evt.tags.find(tagNameEquals('a')) ?? evt.tags.find(tagNameEquals('A')) - const coord = aTag?.[1]?.trim() - if (coord) return coord - } - return undefined + return getNip18RepostTargetId(evt) } private addRepostByEvent(evt: Event, originalEventAuthor?: string, forcedTargetEventId?: string) { @@ -852,7 +893,80 @@ class NoteStatsService { ) } - private addReplyByEvent(evt: Event, originalEventAuthor?: string, forcedOriginalEventId?: string) { + /** Walk parent notes (session cache) so subtree reply counts include all ancestors up the chain. */ + private replyAncestorChainStartingAt(immediateParentId: string): string[] { + const chain: string[] = [] + const seen = new Set() + let cur: string | undefined = this.statsKey(immediateParentId) + for (let hop = 0; hop < 14; hop++) { + if (!cur) break + if (seen.has(cur)) break + seen.add(cur) + chain.push(cur) + if (!/^[0-9a-f]{64}$/i.test(cur)) break + const parentEv = client.peekSessionCachedEvent(cur) + if (!parentEv) break + const p = getParentEventHexId(parentEv) + if (!p || !/^[0-9a-f]{64}$/i.test(p)) break + cur = this.statsKey(p) + } + return chain + } + + /** Append `evt` to `replies` for each note id in `chain` (dedupe per note). */ + private appendReplyAtAncestorChain(evt: Event, chain: string[]): string[] { + const affected: string[] = [] + for (const rawKey of chain) { + const replyKey = this.statsKey(rawKey) + const old = this.noteStatsMap.get(replyKey) || {} + const replyIdSet = old.replyIdSet || new Set() + const replies = old.replies || [] + if (replyIdSet.has(evt.id)) continue + replyIdSet.add(evt.id) + replies.push({ id: evt.id, pubkey: evt.pubkey, created_at: evt.created_at }) + this.noteStatsMap.set(replyKey, { ...old, replyIdSet, replies }) + affected.push(replyKey) + } + return affected + } + + private addOpReferenceAsThreadResponse( + evt: Event, + originalEventAuthor: string | undefined, + statsRoot: Event + ): string[] { + if (originalEventAuthor && originalEventAuthor === evt.pubkey) { + return [] + } + const rootRef = threadRootRefFromStatsRootEvent(statsRoot) + if (!rootRef || !eventReferencesThreadTarget(evt, rootRef)) { + return [] + } + const anchor = this.anchorNoteIdForOpReferenceStats(evt, rootRef) + if (!anchor) return [] + const chain = /^[0-9a-f]{64}$/i.test(this.statsKey(anchor)) + ? this.replyAncestorChainStartingAt(anchor) + : [this.statsKey(anchor)] + return this.appendReplyAtAncestorChain(evt, chain) + } + + private anchorNoteIdForOpReferenceStats(evt: Event, root: TThreadRootRef): string | undefined { + const p = getParentEventHexId(evt) + if (p && /^[0-9a-f]{64}$/i.test(p)) { + return p.toLowerCase() + } + if (root.type === 'E') return root.id + if (root.type === 'A') { + const h = root.eventId.trim().toLowerCase() + return /^[0-9a-f]{64}$/i.test(h) ? h : root.id + } + if (root.type === 'I') { + return rssArticleStableEventId(canonicalizeRssArticleUrl(root.id)) + } + return undefined + } + + private addReplyByEvent(evt: Event, originalEventAuthor?: string, forcedOriginalEventId?: string): string[] { let originalEventId: string | undefined = forcedOriginalEventId if (!originalEventId) { @@ -880,23 +994,16 @@ class NoteStatsService { } } - if (!originalEventId) return - const replyKey = this.statsKey(originalEventId) - - const old = this.noteStatsMap.get(replyKey) || {} - const replyIdSet = old.replyIdSet || new Set() - const replies = old.replies || [] - - if (replyIdSet.has(evt.id)) return - + if (!originalEventId) return [] if (originalEventAuthor && originalEventAuthor === evt.pubkey) { - return + return [] } - replyIdSet.add(evt.id) - replies.push({ id: evt.id, pubkey: evt.pubkey, created_at: evt.created_at }) - this.noteStatsMap.set(replyKey, { ...old, replyIdSet, replies }) - return replyKey + const replyKey = this.statsKey(originalEventId) + const chain = /^[0-9a-f]{64}$/i.test(replyKey) + ? this.replyAncestorChainStartingAt(originalEventId) + : [replyKey] + return this.appendReplyAtAncestorChain(evt, chain) } private isQuoteByEvent(evt: Event): boolean {