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. 74
      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. 237
      src/services/note-stats.service.ts

12
package-lock.json generated

@ -1,12 +1,12 @@
{ {
"name": "imwald", "name": "imwald",
"version": "23.4.0", "version": "23.5.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "imwald", "name": "imwald",
"version": "23.4.0", "version": "23.5.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@asciidoctor/core": "^3.0.4", "@asciidoctor/core": "^3.0.4",
@ -7245,13 +7245,13 @@
} }
}, },
"node_modules/axios": { "node_modules/axios": {
"version": "1.15.0", "version": "1.16.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz",
"integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", "integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"follow-redirects": "^1.15.11", "follow-redirects": "^1.16.0",
"form-data": "^4.0.5", "form-data": "^4.0.5",
"proxy-from-env": "^2.1.0" "proxy-from-env": "^2.1.0"
} }

2
package.json

@ -1,6 +1,6 @@
{ {
"name": "imwald", "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", "description": "Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery",
"private": true, "private": true,
"type": "module", "type": "module",

32
src/components/Note/index.tsx

@ -8,6 +8,8 @@ import {
isNip25ReactionKind, isNip25ReactionKind,
isNsfwEvent isNsfwEvent
} from '@/lib/event' } 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 { getCachedThreadContextEvents } from '@/lib/navigation-related-events'
import { toNote } from '@/lib/link' import { toNote } from '@/lib/link'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@ -106,7 +108,8 @@ export default function Note({
showFull = false, showFull = false,
disableClick = false, disableClick = false,
fullCalendarInvite, fullCalendarInvite,
zapPollVoteHighlightOption zapPollVoteHighlightOption,
nip84HighlightEvents
}: { }: {
event: Event event: Event
originalNoteId?: string originalNoteId?: string
@ -119,6 +122,8 @@ export default function Note({
fullCalendarInvite?: { event: Event; naddr: string } fullCalendarInvite?: { event: Event; naddr: string }
/** Profile: highlight option when this row is from a zap vote receipt. */ /** Profile: highlight option when this row is from a zap vote receipt. */
zapPollVoteHighlightOption?: number 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 { t } = useTranslation()
const { navigateToNote } = useSmartNoteNavigationOptional() 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 (
<div
className={cn(
'note-content text-base font-normal whitespace-pre-wrap break-words',
className
)}
>
{renderPlaintextWithNip84MergedMarks(displayEvent.content ?? '', merged)}
</div>
)
}
}
return ( return (
<MarkdownArticle <MarkdownArticle
className={className} className={className}
@ -223,7 +251,7 @@ export default function Note({
/> />
) )
}, },
[displayEvent, fullCalendarInvite, autoLoadMedia] [displayEvent, fullCalendarInvite, autoLoadMedia, nip84HighlightEvents]
) )
let content: React.ReactNode let content: React.ReactNode

9
src/components/NoteCard/MainNoteCard.tsx

@ -1,3 +1,4 @@
import { useNip84HighlightTargetEvents } from '@/hooks'
import { ExtendedKind } from '@/constants' import { ExtendedKind } from '@/constants'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { getCachedThreadContextEvents } from '@/lib/navigation-related-events' import { getCachedThreadContextEvents } from '@/lib/navigation-related-events'
@ -5,9 +6,10 @@ import { toNote } from '@/lib/link'
import { useSmartNoteNavigationOptional } from '@/PageManager' import { useSmartNoteNavigationOptional } from '@/PageManager'
import client from '@/services/client.service' import client from '@/services/client.service'
import { Pin } from 'lucide-react' import { Pin } from 'lucide-react'
import { Event } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import Collapsible from '../Collapsible' import Collapsible from '../Collapsible'
import NoteBoostBadges from '../NoteBoostBadges'
import Note from '../Note' import Note from '../Note'
import NoteStats from '../NoteStats' import NoteStats from '../NoteStats'
import RepostDescription from './RepostDescription' import RepostDescription from './RepostDescription'
@ -39,6 +41,9 @@ export default function MainNoteCard({
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { navigateToNote } = useSmartNoteNavigationOptional() const { navigateToNote } = useSmartNoteNavigationOptional()
const nip84HighlightEvents = useNip84HighlightTargetEvents(
event.kind === kinds.ShortTextNote ? event : null
)
const isZapFeedCard = const isZapFeedCard =
event.kind === ExtendedKind.ZAP_RECEIPT || event.kind === ExtendedKind.ZAP_REQUEST event.kind === ExtendedKind.ZAP_RECEIPT || event.kind === ExtendedKind.ZAP_REQUEST
const showNoteStatsRow = !embedded || isZapFeedCard const showNoteStatsRow = !embedded || isZapFeedCard
@ -99,8 +104,10 @@ export default function MainNoteCard({
hideParentNotePreview={hideParentNotePreview} hideParentNotePreview={hideParentNotePreview}
zapPollVoteHighlightOption={zapPollVoteHighlightOption} zapPollVoteHighlightOption={zapPollVoteHighlightOption}
showFull={showFull} showFull={showFull}
nip84HighlightEvents={nip84HighlightEvents}
/> />
</Collapsible> </Collapsible>
{!embedded ? <NoteBoostBadges event={event} className="mt-2 px-4" /> : null}
{showNoteStatsRow ? ( {showNoteStatsRow ? (
<NoteStats <NoteStats
className={embedded ? 'mt-2 px-2 sm:px-3' : 'mt-3 px-4'} 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'
import { ExtendedKind, FIRST_RELAY_RESULT_GRACE_MS, SINGLE_RELAY_KINDLESS_EOSE_TIMEOUT_MS, SINGLE_RELAY_KINDLESS_REQ_LIMIT } from '@/constants' import { ExtendedKind, FIRST_RELAY_RESULT_GRACE_MS, SINGLE_RELAY_KINDLESS_EOSE_TIMEOUT_MS, SINGLE_RELAY_KINDLESS_REQ_LIMIT } from '@/constants'
import { import {
collectEmbeddedEventPrefetchTargets, collectEmbeddedEventPrefetchTargets,
getNip18RepostTargetId,
getReplaceableCoordinateFromEvent, getReplaceableCoordinateFromEvent,
isMentioningMutedUsers, isMentioningMutedUsers,
isNip18RepostKind,
isReplaceableEvent, isReplaceableEvent,
isReplyNoteEvent isReplyNoteEvent,
normalizeReplaceableCoordinateString
} from '@/lib/event' } from '@/lib/event'
import { shouldFilterEvent } from '@/lib/event-filtering' import { shouldFilterEvent } from '@/lib/event-filtering'
import { import {
@ -25,6 +28,7 @@ import { useNostr } from '@/providers/NostrProvider'
import { useUserTrust } from '@/contexts/user-trust-context' import { useUserTrust } from '@/contexts/user-trust-context'
import { useZap } from '@/providers/ZapProvider' import { useZap } from '@/providers/ZapProvider'
import client from '@/services/client.service' import client from '@/services/client.service'
import noteStatsService from '@/services/note-stats.service'
import indexedDb from '@/services/indexed-db.service' import indexedDb from '@/services/indexed-db.service'
import { import {
getSessionFeedSnapshot, getSessionFeedSnapshot,
@ -190,6 +194,29 @@ const FEED_PROFILE_BATCH_DEBOUNCE_MS = 50
/** Larger chunks + parallel fetches below — sequential 36-pubkey rounds made notification avatars lag. */ /** Larger chunks + parallel fetches below — sequential 36-pubkey rounds made notification avatars lag. */
const FEED_PROFILE_CHUNK = 80 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( function mergeEventBatchesById(
prev: Event[], prev: Event[],
incoming: Event[], incoming: Event[],
@ -2169,15 +2196,29 @@ const NoteList = forwardRef(
} }
if (shouldHideEventRef.current(event)) return if (shouldHideEventRef.current(event)) return
if (pubkey && event.pubkey === pubkey) { if (pubkey && event.pubkey === pubkey) {
// If the new event is from the current user, insert it directly into the feed setEvents((oldEvents) => {
setEvents((oldEvents) => if (oldEvents.some((e) => e.id === event.id)) return oldEvents
oldEvents.some((e) => e.id === event.id) ? oldEvents : [event, ...oldEvents] if (
) isNip18RepostKind(event.kind) &&
feedTimelineAlreadyRepresentsNip18Target(getNip18RepostTargetId(event), oldEvents)
) {
noteStatsService.updateNoteStatsByEvents([event], undefined)
return oldEvents
}
return [event, ...oldEvents]
})
} else { } else {
// Otherwise, buffer it and show the New Notes button setNewEvents((oldEvents) => {
setNewEvents((oldEvents) => const pool = [...eventsRef.current, ...oldEvents]
[event, ...oldEvents].sort((a, b) => b.created_at - a.created_at) 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 (shouldHideEventRef.current(event)) return
if (pubkey && event.pubkey === pubkey) { if (pubkey && event.pubkey === pubkey) {
setEvents((oldEvents) => setEvents((oldEvents) => {
oldEvents.some((e) => e.id === event.id) ? oldEvents : [event, ...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 { } else {
setNewEvents((oldEvents) => setNewEvents((oldEvents) => {
[event, ...oldEvents].sort((a, b) => b.created_at - a.created_at) 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]) }, [events.length, showCount, loading, hasMore, mergePrefetchTargetsFromEvents])
const showNewEvents = () => { 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([]) setNewEvents([])
setTimeout(() => { setTimeout(() => {
scrollToTop('smooth') scrollToTop('smooth')

18
src/components/ReplyNote/index.tsx

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

74
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 { isDiscussionDownvoteEmoji, isDiscussionUpvoteEmoji } from '@/lib/discussion-votes'
import { import {
canonicalizeRssArticleUrl, canonicalizeRssArticleUrl,
@ -43,6 +49,7 @@ import noteStatsService from '@/services/note-stats.service'
import discussionFeedCache from '@/services/discussion-feed-cache.service' import discussionFeedCache from '@/services/discussion-feed-cache.service'
import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey' import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey'
import { buildReplyReadRelayList, relayHintsFromEventTags } from '@/lib/relay-list-builder' import { buildReplyReadRelayList, relayHintsFromEventTags } from '@/lib/relay-list-builder'
import { eventReferencesThreadTarget } from '@/lib/op-reference-tags'
import { replyBelongsToNoteThread } from '@/lib/thread-reply-root-match' import { replyBelongsToNoteThread } from '@/lib/thread-reply-root-match'
import { import {
buildRssArticleUrlThreadInteractionFilters, buildRssArticleUrlThreadInteractionFilters,
@ -70,6 +77,15 @@ type TRootInfo =
const LIMIT = 200 const LIMIT = 200
const SHOW_COUNT = 10 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_BATCH_DEBOUNCE_MS = 50
const THREAD_PROFILE_CHUNK = 80 const THREAD_PROFILE_CHUNK = 80
@ -280,7 +296,16 @@ function replyMatchesThreadForList(
) { ) {
return true 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 { function threadBacklinkRelationLabel(item: NEvent, t: TFunction): string {
@ -350,10 +375,7 @@ function ReplyNoteList({
const { relayUrls: browsingRelayUrls } = useCurrentRelays() const { relayUrls: browsingRelayUrls } = useCurrentRelays()
const [rootInfo, setRootInfo] = useState<TRootInfo | undefined>(undefined) const [rootInfo, setRootInfo] = useState<TRootInfo | undefined>(undefined)
const { repliesMap, addReplies } = useReply() const { repliesMap, addReplies } = useReply()
const { quoteEvents, quoteLoading } = useQuoteEvents( const { quoteEvents, quoteLoading } = useQuoteEvents(event, true)
event,
showQuotes ?? false
)
const filteredQuoteEvents = useMemo( const filteredQuoteEvents = useMemo(
() => () =>
quoteEvents.filter( quoteEvents.filter(
@ -634,6 +656,14 @@ function ReplyNoteList({
return zapsThenTimeSorted(merged, 'desc') return zapsThenTimeSorted(merged, 'desc')
}, [replies, filteredQuoteEvents, showQuotes, sort, replyIdSet, rootInfo]) }, [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 parentNoteFeed = useNoteFeedProfileContext()
const threadProfileLoadedRef = useRef<Set<string>>(new Set()) const threadProfileLoadedRef = useRef<Set<string>>(new Set())
const threadProfileBatchGenRef = useRef(0) const threadProfileBatchGenRef = useRef(0)
@ -1076,6 +1106,16 @@ function ReplyNoteList({
} }
const filters: Filter[] = [] 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') { if (rootInfo.type === 'E') {
// Fetch all reply types for event-based replies (keep ≤4 kinds per filter — some relays // 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). // 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], kinds: [kinds.Reaction],
limit: LIMIT limit: LIMIT
}) })
// Kind-1 notes that quote via #q without e-tags (still part of this thread)
filters.push({ filters.push({
'#q': [rootInfo.id], '#q': [rootInfo.id],
kinds: [kinds.ShortTextNote], kinds: qKindsHex,
limit: LIMIT limit: LIMIT
}) })
// For public messages (kind 24), also look for replies using 'q' tags // For public messages (kind 24), also look for replies using 'q' tags
@ -1109,6 +1148,10 @@ function ReplyNoteList({
limit: LIMIT 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') { } else if (rootInfo.type === 'A') {
// Fetch all reply types for replaceable event-based replies // Fetch all reply types for replaceable event-based replies
filters.push( filters.push(
@ -1140,13 +1183,17 @@ function ReplyNoteList({
if (qVals.length > 0) { if (qVals.length > 0) {
filters.push({ filters.push({
'#q': qVals, '#q': qVals,
kinds: [kinds.ShortTextNote], kinds: qKindsHex,
limit: LIMIT limit: LIMIT
}) })
} }
if (rootInfo.relay) { if (rootInfo.relay) {
finalRelayUrls.push(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') { } else if (rootInfo.type === 'I') {
filters.push(...buildRssArticleUrlThreadInteractionFilters(rootInfo.id, LIMIT)) filters.push(...buildRssArticleUrlThreadInteractionFilters(rootInfo.id, LIMIT))
} }
@ -1204,7 +1251,14 @@ function ReplyNoteList({
logger.warn('[ReplyNoteList] Cache returned null after store, using fetched replies only') logger.warn('[ReplyNoteList] Cache returned null after store, using fetched replies only')
addReplies(regularReplies) addReplies(regularReplies)
} }
const statsBatch = mergedCachedReplies?.length ? mergedCachedReplies : regularReplies
if (statsBatch.length > 0) {
noteStatsService.updateNoteStatsByEvents(statsBatch, event.pubkey, {
statsRootEvent: event
})
}
if (!hasCache) { if (!hasCache) {
// No cache: stop loading after adding replies // No cache: stop loading after adding replies
setLoading(false) setLoading(false)

13
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[] = export const THREAD_BACKLINK_STREAM_KINDS_WITHOUT_HIGHLIGHT: readonly number[] =
THREAD_BACKLINK_STREAM_KINDS.filter((k) => k !== kinds.Highlights) 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 * 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). * 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'
export * from './useSearchProfiles' export * from './useSearchProfiles'
export * from './useMediaExtraction' export * from './useMediaExtraction'
export * from './useEmojiInfosForEvent' export * from './useEmojiInfosForEvent'
export * from './useNip84HighlightTargetEvents'

48
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<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 @@
import { import {
E_TAG_FILTER_BLOCKED_RELAY_URLS, E_TAG_FILTER_BLOCKED_RELAY_URLS,
ExtendedKind,
FAST_READ_RELAY_URLS, FAST_READ_RELAY_URLS,
SEARCHABLE_RELAY_URLS, NOTE_STATS_OP_REFERENCE_KINDS_WITHOUT_HIGHLIGHT,
THREAD_BACKLINK_STREAM_KINDS_WITHOUT_HIGHLIGHT SEARCHABLE_RELAY_URLS
} from '@/constants' } from '@/constants'
import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event' import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event'
import { buildNormalizedBlockedRelaySet } from '@/lib/thread-response-filter' import { buildNormalizedBlockedRelaySet } from '@/lib/thread-response-filter'
@ -17,6 +18,15 @@ import { Event, kinds } from 'nostr-tools'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
const LIMIT = 100 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 const INITIAL_QUOTE_LOAD_TIMEOUT_MS = 12_000
/** Fetches events that quote or reference the given event (#q, #e, #a tags). */ /** 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}` : `${ev.kind}:${ev.pubkey}:${ev.id}`
const highlightKinds = [kinds.Highlights] as const 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 }[] = [ const subRequests: { urls: string[]; filter: TSubRequestFilter }[] = [
{ {
urls: finalRelayUrls, urls: finalRelayUrls,
filter: { '#q': [qeIdForTagFilter], kinds: [kinds.ShortTextNote], limit: LIMIT } filter: {
'#q': isReplaceableEvent(ev.kind) ? qValsReplaceable : [qeIdForTagFilter],
kinds: qKindsBroad,
limit: LIMIT
}
}, },
{ {
urls: finalRelayUrls, urls: finalRelayUrls,
@ -117,15 +146,26 @@ export function useQuoteEvents(event: Event | null, enabled: boolean) {
limit: LIMIT limit: LIMIT
} }
}, },
{ ...opRefKindChunks.map(
urls: finalRelayUrls, (kindsChunk) =>
filter: { ({
'#a': [eventCoordinate], urls: finalRelayUrls,
kinds: otherBacklinkKinds, filter: {
limit: LIMIT '#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. // `#e` tag filters must use 64-hex event ids. For replaceable roots we use `#a`/`#q` only.
if (qeIdIsHexEventId) { if (qeIdIsHexEventId) {
subRequests.push( subRequests.push(
@ -137,14 +177,30 @@ export function useQuoteEvents(event: Event | null, enabled: boolean) {
limit: LIMIT limit: LIMIT
} }
}, },
{ ...opRefKindChunks.map((kindsChunk) => ({
urls: finalRelayUrls, urls: finalRelayUrls,
filter: { filter: {
'#e': [qeIdForTagFilter], '#e': [qeIdForTagFilter],
kinds: otherBacklinkKinds, kinds: kindsChunk,
limit: LIMIT 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 {
return kind === kinds.Repost || kind === ExtendedKind.GENERIC_REPOST 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). */ /** NIP-56: kind 1984 report / flag (`kinds.Report` and {@link ExtendedKind.REPORT} are the same kind). */
export function isNip56ReportEvent(event: Pick<Event, 'kind'>): boolean { export function isNip56ReportEvent(event: Pick<Event, 'kind'>): boolean {
return event.kind === kinds.Report || event.kind === ExtendedKind.REPORT 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). */ /** 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()) const m = /^(\d+):([0-9a-f]{64}):(.*)$/i.exec(coord.trim())
if (!m) return coord.trim().toLowerCase() if (!m) return coord.trim().toLowerCase()
return getReplaceableCoordinate(Number(m[1]), m[2].toLowerCase(), m[3]) return getReplaceableCoordinate(Number(m[1]), m[2].toLowerCase(), m[3])

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

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

@ -12,7 +12,7 @@ import UserAvatar from '@/components/UserAvatar'
import { Card } from '@/components/ui/card' import { Card } from '@/components/ui/card'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { useFetchEvent, useFetchProfile } from '@/hooks' import { useFetchEvent, useFetchProfile, useNip84HighlightTargetEvents } from '@/hooks'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { import {
getParentBech32Id, getParentBech32Id,
@ -116,6 +116,7 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }:
const { event, isFetching, refetch: refetchMain } = useFetchEvent(id, initialEvent) const { event, isFetching, refetch: refetchMain } = useFetchEvent(id, initialEvent)
const [externalEvent, setExternalEvent] = useState<Event | undefined>(undefined) const [externalEvent, setExternalEvent] = useState<Event | undefined>(undefined)
const finalEvent = event || externalEvent const finalEvent = event || externalEvent
const nip84HighlightEvents = useNip84HighlightTargetEvents(finalEvent)
const parentEventId = useMemo(() => { const parentEventId = useMemo(() => {
if (!finalEvent) return undefined if (!finalEvent) return undefined
@ -508,6 +509,7 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }:
hideParentNotePreview hideParentNotePreview
originalNoteId={id} originalNoteId={id}
showFull showFull
nip84HighlightEvents={nip84HighlightEvents}
fullCalendarInvite={ fullCalendarInvite={
calendarInviteEvent && calendarInviteNaddr calendarInviteEvent && calendarInviteNaddr
? { event: calendarInviteEvent, naddr: calendarInviteNaddr } ? { event: calendarInviteEvent, naddr: calendarInviteNaddr }

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

@ -2,15 +2,19 @@ import {
E_TAG_FILTER_BLOCKED_RELAY_URLS, E_TAG_FILTER_BLOCKED_RELAY_URLS,
ExtendedKind, ExtendedKind,
FAST_READ_RELAY_URLS, FAST_READ_RELAY_URLS,
NOTE_STATS_OP_REFERENCE_KINDS_WITHOUT_HIGHLIGHT,
SEARCHABLE_RELAY_URLS SEARCHABLE_RELAY_URLS
} from '@/constants' } from '@/constants'
import { replaceStandardEmojiShortcodesInContent } from '@/lib/emoji-content' import { replaceStandardEmojiShortcodesInContent } from '@/lib/emoji-content'
import { import {
getNip18RepostTargetId,
getParentEventHexId, getParentEventHexId,
getReplaceableCoordinateFromEvent, getReplaceableCoordinateFromEvent,
isNip18RepostKind, isNip18RepostKind,
isReplaceableEvent isReplaceableEvent
} from '@/lib/event' } 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 { getZapInfoFromEvent } from '@/lib/event-metadata'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { import {
@ -353,7 +357,9 @@ class NoteStatsService {
const { queryService } = await import('@/services/client.service') const { queryService } = await import('@/services/client.service')
const onStatsEvent = (evt: Event) => { const onStatsEvent = (evt: Event) => {
this.updateNoteStatsByEvents([evt], resolvedEvent!.pubkey) this.updateNoteStatsByEvents([evt], resolvedEvent!.pubkey, {
statsRootEvent: resolvedEvent!
})
events.push(evt) events.push(evt)
} }
if (nonSocial.length > 0) { if (nonSocial.length > 0) {
@ -511,6 +517,15 @@ class NoteStatsService {
{ '#e': [rootId], kinds: [kinds.Zap], limit: 100 } { '#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[] = [ const social: Filter[] = [
{ {
'#e': [rootId], '#e': [rootId],
@ -523,14 +538,31 @@ class NoteStatsService {
], ],
limit: interactionLimit 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], '#q': [rootId],
kinds: [kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT], kinds: qKindsHex,
limit: 50 limit: 50
} }
] ]
if (replaceableCoordinate) { if (replaceableCoordinate) {
const qValsReplaceable = Array.from(
new Set(
[event.id, replaceableCoordinate]
.map((x) => (typeof x === 'string' ? x.trim() : ''))
.filter(Boolean)
)
)
nonSocial.push( nonSocial.push(
{ '#a': [replaceableCoordinate], kinds: [kinds.Reaction], limit: reactionLimit }, { '#a': [replaceableCoordinate], kinds: [kinds.Reaction], limit: reactionLimit },
{ '#a': [replaceableCoordinate], kinds: [kinds.Zap], limit: 100 } { '#a': [replaceableCoordinate], kinds: [kinds.Zap], limit: 100 }
@ -548,8 +580,18 @@ class NoteStatsService {
limit: interactionLimit limit: interactionLimit
}, },
{ {
'#q': [replaceableCoordinate], '#a': [replaceableCoordinate],
kinds: [kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT], 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 limit: 50
} }
) )
@ -618,22 +660,23 @@ class NoteStatsService {
mergeOpts?: { mergeOpts?: {
interactionTargetNoteId?: string interactionTargetNoteId?: string
replyParentNoteId?: 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>() const updatedEventIdSet = new Set<string>()
// Process events in batches for better performance // Process events in batches for better performance
const batchSize = 50 const batchSize = 50
for (let i = 0; i < events.length; i += batchSize) { for (let i = 0; i < events.length; i += batchSize) {
const batch = events.slice(i, i + batchSize) const batch = events.slice(i, i + batchSize)
batch.forEach((evt) => { batch.forEach((evt) => {
const updatedEventId = this.processEvent(evt, originalEventAuthor, mergeOpts) for (const id of this.processEvent(evt, originalEventAuthor, mergeOpts)) {
if (updatedEventId) { updatedEventIdSet.add(this.statsKey(id))
updatedEventIdSet.add(updatedEventId)
} }
}) })
} }
updatedEventIdSet.forEach((eventId) => { updatedEventIdSet.forEach((eventId) => {
this.notifyNoteStats(this.statsKey(eventId)) this.notifyNoteStats(this.statsKey(eventId))
}) })
@ -642,40 +685,60 @@ class NoteStatsService {
private processEvent( private processEvent(
evt: Event, evt: Event,
originalEventAuthor?: string, originalEventAuthor?: string,
mergeOpts?: { interactionTargetNoteId?: string; replyParentNoteId?: string } mergeOpts?: {
): string | undefined { interactionTargetNoteId?: string
let updatedEventId: string | undefined 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) { 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) { } else if (evt.kind === ExtendedKind.EXTERNAL_REACTION) {
updatedEventId = this.addLikeByExternalWebReactionEvent( push(
evt, this.addLikeByExternalWebReactionEvent(
originalEventAuthor, evt,
mergeOpts?.interactionTargetNoteId originalEventAuthor,
mergeOpts?.interactionTargetNoteId
)
) )
} else if (isNip18RepostKind(evt.kind)) { } 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) { } 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) { } else if (evt.kind === kinds.ShortTextNote || evt.kind === ExtendedKind.COMMENT || evt.kind === ExtendedKind.VOICE_COMMENT) {
const isQuote = this.isQuoteByEvent(evt) const isQuote = this.isQuoteByEvent(evt)
if (isQuote) { if (isQuote) {
updatedEventId = this.addQuoteByEvent(evt, originalEventAuthor) push(this.addQuoteByEvent(evt, originalEventAuthor))
} else if (mergeOpts?.replyParentNoteId) { } else if (mergeOpts?.replyParentNoteId) {
updatedEventId = this.addReplyByEvent(evt, originalEventAuthor, mergeOpts.replyParentNoteId) pushMany(this.addReplyByEvent(evt, originalEventAuthor, mergeOpts.replyParentNoteId))
} else { } 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) { } else if (evt.kind === kinds.Highlights) {
updatedEventId = this.addHighlightByEvent(evt, originalEventAuthor) push(this.addHighlightByEvent(evt, originalEventAuthor))
} else if (evt.kind === ExtendedKind.WEB_BOOKMARK) { } else if (evt.kind === ExtendedKind.WEB_BOOKMARK) {
updatedEventId = this.addWebBookmarkByArticleUrlEvent(evt) push(this.addWebBookmarkByArticleUrlEvent(evt))
} else if (evt.kind === kinds.BookmarkList) { } else if (evt.kind === kinds.BookmarkList) {
this.addBookmarkListRefsByEvent(evt) this.addBookmarkListRefsByEvent(evt)
} }
return updatedEventId return out
} }
private reactionEmojiFromEvent(evt: Event): TEmoji | string { private reactionEmojiFromEvent(evt: Event): TEmoji | string {
@ -785,29 +848,7 @@ class NoteStatsService {
private repostStatsTargetId(evt: Event, forcedTargetEventId?: string): string | undefined { private repostStatsTargetId(evt: Event, forcedTargetEventId?: string): string | undefined {
const forced = forcedTargetEventId?.trim() const forced = forcedTargetEventId?.trim()
if (forced) return forced if (forced) return forced
if (!isNip18RepostKind(evt.kind)) return undefined return getNip18RepostTargetId(evt)
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
} }
private addRepostByEvent(evt: Event, originalEventAuthor?: string, forcedTargetEventId?: string) { 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<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 let originalEventId: string | undefined = forcedOriginalEventId
if (!originalEventId) { if (!originalEventId) {
@ -880,23 +994,16 @@ class NoteStatsService {
} }
} }
if (!originalEventId) return 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 (originalEventAuthor && originalEventAuthor === evt.pubkey) { if (originalEventAuthor && originalEventAuthor === evt.pubkey) {
return return []
} }
replyIdSet.add(evt.id) const replyKey = this.statsKey(originalEventId)
replies.push({ id: evt.id, pubkey: evt.pubkey, created_at: evt.created_at }) const chain = /^[0-9a-f]{64}$/i.test(replyKey)
this.noteStatsMap.set(replyKey, { ...old, replyIdSet, replies }) ? this.replyAncestorChainStartingAt(originalEventId)
return replyKey : [replyKey]
return this.appendReplyAtAncestorChain(evt, chain)
} }
private isQuoteByEvent(evt: Event): boolean { private isQuoteByEvent(evt: Event): boolean {

Loading…
Cancel
Save