Browse Source

make reply count more comprehensive

remove redundant boosts and expand boost row, instead
expand response catches
imwald
Silberengel 1 month ago
parent
commit
c58af453e0
  1. 12
      package-lock.json
  2. 2
      package.json
  3. 32
      src/components/Note/index.tsx
  4. 9
      src/components/NoteCard/MainNoteCard.tsx
  5. 108
      src/components/NoteList/index.tsx
  6. 18
      src/components/ReplyNote/index.tsx
  7. 72
      src/components/ReplyNoteList/index.tsx
  8. 13
      src/constants.ts
  9. 1
      src/hooks/index.tsx
  10. 48
      src/hooks/useNip84HighlightTargetEvents.ts
  11. 86
      src/hooks/useQuoteEvents.tsx
  12. 32
      src/lib/event.ts
  13. 75
      src/lib/nip84-op-body-marks.tsx
  14. 66
      src/lib/op-reference-tags.ts
  15. 72
      src/lib/translate-client.ts
  16. 4
      src/pages/secondary/NotePage/index.tsx
  17. 229
      src/services/note-stats.service.ts

12
package-lock.json generated

@ -1,12 +1,12 @@ @@ -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 @@ @@ -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"
}

2
package.json

@ -1,6 +1,6 @@ @@ -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",

32
src/components/Note/index.tsx

@ -8,6 +8,8 @@ import { @@ -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({ @@ -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({ @@ -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({ @@ -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 (
<div
className={cn(
'note-content text-base font-normal whitespace-pre-wrap break-words',
className
)}
>
{renderPlaintextWithNip84MergedMarks(displayEvent.content ?? '', merged)}
</div>
)
}
}
return (
<MarkdownArticle
className={className}
@ -223,7 +251,7 @@ export default function Note({ @@ -223,7 +251,7 @@ export default function Note({
/>
)
},
[displayEvent, fullCalendarInvite, autoLoadMedia]
[displayEvent, fullCalendarInvite, autoLoadMedia, nip84HighlightEvents]
)
let content: React.ReactNode

9
src/components/NoteCard/MainNoteCard.tsx

@ -1,3 +1,4 @@ @@ -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' @@ -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({ @@ -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({ @@ -99,8 +104,10 @@ export default function MainNoteCard({
hideParentNotePreview={hideParentNotePreview}
zapPollVoteHighlightOption={zapPollVoteHighlightOption}
showFull={showFull}
nip84HighlightEvents={nip84HighlightEvents}
/>
</Collapsible>
{!embedded ? <NoteBoostBadges event={event} className="mt-2 px-4" /> : null}
{showNoteStatsRow ? (
<NoteStats
className={embedded ? 'mt-2 px-2 sm:px-3' : 'mt-3 px-4'}

108
src/components/NoteList/index.tsx

@ -2,10 +2,13 @@ import NewNotesButton from '@/components/NewNotesButton' @@ -2,10 +2,13 @@ import NewNotesButton from '@/components/NewNotesButton'
import { ExtendedKind, FIRST_RELAY_RESULT_GRACE_MS, SINGLE_RELAY_KINDLESS_EOSE_TIMEOUT_MS, SINGLE_RELAY_KINDLESS_REQ_LIMIT } from '@/constants'
import {
collectEmbeddedEventPrefetchTargets,
getNip18RepostTargetId,
getReplaceableCoordinateFromEvent,
isMentioningMutedUsers,
isNip18RepostKind,
isReplaceableEvent,
isReplyNoteEvent
isReplyNoteEvent,
normalizeReplaceableCoordinateString
} from '@/lib/event'
import { shouldFilterEvent } from '@/lib/event-filtering'
import {
@ -25,6 +28,7 @@ import { useNostr } from '@/providers/NostrProvider' @@ -25,6 +28,7 @@ import { useNostr } from '@/providers/NostrProvider'
import { useUserTrust } from '@/contexts/user-trust-context'
import { useZap } from '@/providers/ZapProvider'
import client from '@/services/client.service'
import noteStatsService from '@/services/note-stats.service'
import indexedDb from '@/services/indexed-db.service'
import {
getSessionFeedSnapshot,
@ -190,6 +194,29 @@ const FEED_PROFILE_BATCH_DEBOUNCE_MS = 50 @@ -190,6 +194,29 @@ const FEED_PROFILE_BATCH_DEBOUNCE_MS = 50
/** Larger chunks + parallel fetches below — sequential 36-pubkey rounds made notification avatars lag. */
const FEED_PROFILE_CHUNK = 80
function normalizeFeedRepostTargetKey(id: string): string {
const t = id.trim()
if (/^[0-9a-f]{64}$/i.test(t)) return t.toLowerCase()
return normalizeReplaceableCoordinateString(t)
}
function feedTimelineAlreadyRepresentsNip18Target(targetId: string | undefined, rows: Event[]): boolean {
if (!targetId) return false
const want = normalizeFeedRepostTargetKey(targetId)
for (const e of rows) {
if (normalizeFeedRepostTargetKey(e.id) === want) return true
if (isNip18RepostKind(e.kind)) {
const rt = getNip18RepostTargetId(e)
if (rt && normalizeFeedRepostTargetKey(rt) === want) return true
}
if (isReplaceableEvent(e.kind)) {
const c = getReplaceableCoordinateFromEvent(e)
if (normalizeFeedRepostTargetKey(c) === want) return true
}
}
return false
}
function mergeEventBatchesById(
prev: Event[],
incoming: Event[],
@ -2169,15 +2196,29 @@ const NoteList = forwardRef( @@ -2169,15 +2196,29 @@ const NoteList = forwardRef(
}
if (shouldHideEventRef.current(event)) return
if (pubkey && event.pubkey === pubkey) {
// If the new event is from the current user, insert it directly into the feed
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 {
// 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( @@ -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( @@ -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')

18
src/components/ReplyNote/index.tsx

@ -30,6 +30,7 @@ import ReactionEmojiDisplay from '../Note/ReactionEmojiDisplay' @@ -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({ @@ -205,13 +206,16 @@ export default function ReplyNote({
</div>
</Collapsible>
{show && !isNip25ReactionKind(event.kind) && (
<NoteStats
className="ml-14 pl-1 mr-4 mt-2"
event={event}
displayTopZapsAndLikes={event.kind !== kinds.Zap}
fetchIfNotExisting
foregroundStats={foregroundStats}
/>
<>
<NoteBoostBadges event={event} className="ml-14 pl-1 mr-4 mt-2" />
<NoteStats
className="ml-14 pl-1 mr-4 mt-2"
event={event}
displayTopZapsAndLikes={event.kind !== kinds.Zap}
fetchIfNotExisting
foregroundStats={foregroundStats}
/>
</>
)}
</div>
)

72
src/components/ReplyNoteList/index.tsx

@ -1,4 +1,10 @@ @@ -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' @@ -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 = @@ -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( @@ -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({ @@ -350,10 +375,7 @@ function ReplyNoteList({
const { relayUrls: browsingRelayUrls } = useCurrentRelays()
const [rootInfo, setRootInfo] = useState<TRootInfo | undefined>(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({ @@ -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<Set<string>>(new Set())
const threadProfileBatchGenRef = useRef(0)
@ -1076,6 +1106,16 @@ function ReplyNoteList({ @@ -1076,6 +1106,16 @@ function ReplyNoteList({
}
const filters: Filter[] = []
const qKindsHex = Array.from(
new Set<number>([
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({ @@ -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({ @@ -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({ @@ -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))
}
@ -1205,6 +1252,13 @@ function ReplyNoteList({ @@ -1205,6 +1252,13 @@ function ReplyNoteList({
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)

13
src/constants.ts

@ -593,6 +593,19 @@ export const THREAD_BACKLINK_STREAM_KINDS: readonly number[] = [ @@ -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<number>([...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).

1
src/hooks/index.tsx

@ -9,3 +9,4 @@ export * from './useFetchRelayList' @@ -9,3 +9,4 @@ export * from './useFetchRelayList'
export * from './useSearchProfiles'
export * from './useMediaExtraction'
export * from './useEmojiInfosForEvent'
export * from './useNip84HighlightTargetEvents'

48
src/hooks/useNip84HighlightTargetEvents.ts

@ -0,0 +1,48 @@ @@ -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<Event[]>([])
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
}

86
src/hooks/useQuoteEvents.tsx

@ -1,8 +1,9 @@ @@ -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' @@ -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) { @@ -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<number>([
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) { @@ -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) { @@ -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
}
}))
)
}

32
src/lib/event.ts

@ -26,6 +26,36 @@ export function isNip18RepostKind(kind: number): boolean { @@ -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<Event, 'kind'>): boolean {
return event.kind === kinds.Report || event.kind === ExtendedKind.REPORT
@ -370,7 +400,7 @@ export function replaceableEventDedupeKey(event: Event): string { @@ -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])

75
src/lib/nip84-op-body-marks.tsx

@ -0,0 +1,75 @@ @@ -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(<Fragment key={`t-${i++}`}>{baseText.slice(cursor, m.start)}</Fragment>)
nodes.push(
<mark
key={`m-${m.start}-${m.end}`}
className={NIP84_HIGHLIGHT_MARK_CLASSNAME}
data-nip84-highlight="span"
>
{baseText.slice(m.start, m.end)}
</mark>
)
cursor = m.end
}
if (cursor < baseText.length) nodes.push(<Fragment key={`t-${i++}`}>{baseText.slice(cursor)}</Fragment>)
return <>{nodes}</>
}

66
src/lib/op-reference-tags.ts

@ -0,0 +1,66 @@ @@ -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 }
}

72
src/lib/translate-client.ts

@ -82,8 +82,13 @@ export function translateServerSupportsLogicalTarget(targetCode: string): boolea @@ -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<TranslateLanguageOption[]> | null = null
let lastLanguagesFailureLogAt = 0
function parseLanguagesResponse(data: unknown): TranslateLanguageOption[] {
if (!Array.isArray(data)) return []
@ -113,30 +118,51 @@ export async function fetchTranslateLanguages(): Promise<TranslateLanguageOption @@ -113,30 +118,51 @@ export async function fetchTranslateLanguages(): Promise<TranslateLanguageOption
const base = TRANSLATE_URL.trim().replace(/\/$/u, '')
if (!base) return []
const now = Date.now()
if (languagesCache && now - languagesCache.at < LANGUAGES_CACHE_TTL_MS) {
recordAdvertisedTranslateCodesFromServer(languagesCache.list)
return languagesCache.list
}
const url = `${base}/languages`
const res = await electronAwareFetch(url)
if (!res.ok) {
logger.warn('[Translate] /languages failed', { status: res.status })
languagesCache = null
advertisedTranslateApiCodes = null
return []
if (languagesCache) {
const ttl = languagesCache.fromFailure ? LANGUAGES_FAILURE_CACHE_TTL_MS : LANGUAGES_CACHE_TTL_MS
if (now - languagesCache.at < ttl) {
recordAdvertisedTranslateCodesFromServer(languagesCache.list)
return languagesCache.list
}
}
try {
const data = (await res.json()) as unknown
const list = parseLanguagesResponse(data)
languagesCache = { list, at: now }
recordAdvertisedTranslateCodesFromServer(list)
return list
} catch (e) {
logger.warn('[Translate] /languages parse error', { e })
languagesCache = null
advertisedTranslateApiCodes = null
return []
if (languagesFetchInFlight) {
return languagesFetchInFlight
}
const url = `${base}/languages`
languagesFetchInFlight = (async (): Promise<TranslateLanguageOption[]> => {
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 {

4
src/pages/secondary/NotePage/index.tsx

@ -12,7 +12,7 @@ import UserAvatar from '@/components/UserAvatar' @@ -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 }: @@ -116,6 +116,7 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }:
const { event, isFetching, refetch: refetchMain } = useFetchEvent(id, initialEvent)
const [externalEvent, setExternalEvent] = useState<Event | undefined>(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 }: @@ -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 }

229
src/services/note-stats.service.ts

@ -2,15 +2,19 @@ import { @@ -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 { @@ -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 { @@ -511,6 +517,15 @@ class NoteStatsService {
{ '#e': [rootId], kinds: [kinds.Zap], limit: 100 }
]
const qKindsHex = Array.from(
new Set<number>([
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 { @@ -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 { @@ -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,6 +660,8 @@ class NoteStatsService { @@ -618,6 +660,8 @@ 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<string>()
@ -627,9 +671,8 @@ class NoteStatsService { @@ -627,9 +671,8 @@ class NoteStatsService {
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))
}
})
}
@ -642,40 +685,60 @@ class NoteStatsService { @@ -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 { @@ -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 { @@ -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<string>()
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 { @@ -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 {

Loading…
Cancel
Save