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 {