|
|
|
@ -62,6 +62,8 @@ import { |
|
|
|
backlinkRunSectionClass, |
|
|
|
backlinkRunSectionClass, |
|
|
|
buildVisibleBacklinkRows, |
|
|
|
buildVisibleBacklinkRows, |
|
|
|
EA_THREAD_TAIL_REFERENCE_KINDS, |
|
|
|
EA_THREAD_TAIL_REFERENCE_KINDS, |
|
|
|
|
|
|
|
buildNoteStatsReplyIdSet, |
|
|
|
|
|
|
|
buildRepliesListAlignedWithNoteStats, |
|
|
|
collectDisplayedThreadReplies, |
|
|
|
collectDisplayedThreadReplies, |
|
|
|
fetchPaymentAttestationsForRecipient, |
|
|
|
fetchPaymentAttestationsForRecipient, |
|
|
|
hydrateThreadRepliesFromStats, |
|
|
|
hydrateThreadRepliesFromStats, |
|
|
|
@ -137,13 +139,26 @@ function ReplyNoteList({ |
|
|
|
return out.length ? out : undefined |
|
|
|
return out.length ? out : undefined |
|
|
|
}, [duplicateWebPreviewCleanedUrlHints, rootInfo]) |
|
|
|
}, [duplicateWebPreviewCleanedUrlHints, rootInfo]) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const statsReplyIds = useMemo( |
|
|
|
|
|
|
|
() => buildNoteStatsReplyIdSet(noteStats?.replies), |
|
|
|
|
|
|
|
[noteStats?.replies, noteStats?.updatedAt] |
|
|
|
|
|
|
|
) |
|
|
|
|
|
|
|
|
|
|
|
const replies: NEvent[] = useMemo(() => { |
|
|
|
const replies: NEvent[] = useMemo(() => { |
|
|
|
const replyEvents = collectDisplayedThreadReplies( |
|
|
|
const threadDisplayed = collectDisplayedThreadReplies( |
|
|
|
event, |
|
|
|
event, |
|
|
|
rootInfo, |
|
|
|
rootInfo, |
|
|
|
repliesMap, |
|
|
|
repliesMap, |
|
|
|
isDiscussionRoot, |
|
|
|
isDiscussionRoot, |
|
|
|
mutePubkeySet, |
|
|
|
mutePubkeySet, |
|
|
|
|
|
|
|
hideContentMentioningMutedUsers, |
|
|
|
|
|
|
|
statsReplyIds |
|
|
|
|
|
|
|
) |
|
|
|
|
|
|
|
const replyEvents = buildRepliesListAlignedWithNoteStats( |
|
|
|
|
|
|
|
noteStats?.replies, |
|
|
|
|
|
|
|
repliesMap, |
|
|
|
|
|
|
|
threadDisplayed, |
|
|
|
|
|
|
|
mutePubkeySet, |
|
|
|
hideContentMentioningMutedUsers |
|
|
|
hideContentMentioningMutedUsers |
|
|
|
) |
|
|
|
) |
|
|
|
const replyIdSet = new Set(replyEvents.map((r) => r.id)) |
|
|
|
const replyIdSet = new Set(replyEvents.map((r) => r.id)) |
|
|
|
@ -162,6 +177,7 @@ function ReplyNoteList({ |
|
|
|
) { |
|
|
|
) { |
|
|
|
return false |
|
|
|
return false |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
if (statsReplyIds.has(evt.id)) return true |
|
|
|
if ( |
|
|
|
if ( |
|
|
|
rootInfo && |
|
|
|
rootInfo && |
|
|
|
!replyMatchesThreadForList(evt, event, rootInfo, isDiscussionRoot, threadWalkFromRepliesMap) |
|
|
|
!replyMatchesThreadForList(evt, event, rootInfo, isDiscussionRoot, threadWalkFromRepliesMap) |
|
|
|
@ -169,11 +185,16 @@ function ReplyNoteList({ |
|
|
|
return false |
|
|
|
return false |
|
|
|
} |
|
|
|
} |
|
|
|
const opHex = openNoteHexId(event) |
|
|
|
const opHex = openNoteHexId(event) |
|
|
|
|
|
|
|
const opHexLower = opHex?.toLowerCase() |
|
|
|
|
|
|
|
const viewingThreadRoot = |
|
|
|
|
|
|
|
opHexLower && |
|
|
|
|
|
|
|
((rootInfo?.type === 'E' && rootInfo.id.trim().toLowerCase() === opHexLower) || |
|
|
|
|
|
|
|
(rootInfo?.type === 'A' && rootInfo.eventId.trim().toLowerCase() === opHexLower)) |
|
|
|
if ( |
|
|
|
if ( |
|
|
|
opHex && |
|
|
|
opHexLower && |
|
|
|
rootInfo?.type === 'E' && |
|
|
|
rootInfo && |
|
|
|
rootInfo.id.trim().toLowerCase() !== opHex && |
|
|
|
!viewingThreadRoot && |
|
|
|
!replyIsInSubtreeBelowOpenNote(evt, opHex, threadWalkFromRepliesMap) |
|
|
|
!replyIsInSubtreeBelowOpenNote(evt, opHexLower, threadWalkFromRepliesMap) |
|
|
|
) { |
|
|
|
) { |
|
|
|
return false |
|
|
|
return false |
|
|
|
} |
|
|
|
} |
|
|
|
@ -283,7 +304,10 @@ function ReplyNoteList({ |
|
|
|
sort, |
|
|
|
sort, |
|
|
|
attestedPaymentIds, |
|
|
|
attestedPaymentIds, |
|
|
|
isDiscussionRoot, |
|
|
|
isDiscussionRoot, |
|
|
|
event.kind |
|
|
|
event.kind, |
|
|
|
|
|
|
|
statsReplyIds, |
|
|
|
|
|
|
|
noteStats?.replies, |
|
|
|
|
|
|
|
noteStats?.updatedAt |
|
|
|
]) |
|
|
|
]) |
|
|
|
|
|
|
|
|
|
|
|
const replyIdSet = useMemo(() => new Set(replies.map((r) => r.id)), [replies]) |
|
|
|
const replyIdSet = useMemo(() => new Set(replies.map((r) => r.id)), [replies]) |
|
|
|
@ -479,13 +503,19 @@ function ReplyNoteList({ |
|
|
|
}, [event.id]) |
|
|
|
}, [event.id]) |
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
|
useEffect(() => { |
|
|
|
if (!rootInfo) return |
|
|
|
|
|
|
|
const fromStats = noteStats?.replies |
|
|
|
const fromStats = noteStats?.replies |
|
|
|
if (!fromStats?.length) return |
|
|
|
if (!fromStats?.length) return |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const statsIdSet = buildNoteStatsReplyIdSet(fromStats) |
|
|
|
|
|
|
|
const sessionHits = eventService |
|
|
|
|
|
|
|
.getSessionEventsForNoteStatsTarget(event, { maxScan: 40_000 }) |
|
|
|
|
|
|
|
.filter((e) => statsIdSet.has(e.id)) |
|
|
|
|
|
|
|
if (sessionHits.length > 0) addReplies(sessionHits) |
|
|
|
|
|
|
|
|
|
|
|
const candidates = fromStats.filter( |
|
|
|
const candidates = fromStats.filter( |
|
|
|
(r) => |
|
|
|
(r) => |
|
|
|
!replyIdPresentInRepliesMap(repliesMap, r.id) && |
|
|
|
!replyIdPresentInRepliesMap(repliesMap, r.id) && |
|
|
|
|
|
|
|
!client.peekSessionCachedEvent(r.id) && |
|
|
|
!statsHydratedReplyIdsRef.current.has(r.id) |
|
|
|
!statsHydratedReplyIdsRef.current.has(r.id) |
|
|
|
) |
|
|
|
) |
|
|
|
if (candidates.length === 0) return |
|
|
|
if (candidates.length === 0) return |
|
|
|
@ -493,25 +523,16 @@ function ReplyNoteList({ |
|
|
|
let cancelled = false |
|
|
|
let cancelled = false |
|
|
|
;(async () => { |
|
|
|
;(async () => { |
|
|
|
for (const { id } of candidates) statsHydratedReplyIdsRef.current.add(id) |
|
|
|
for (const { id } of candidates) statsHydratedReplyIdsRef.current.add(id) |
|
|
|
const batch = await hydrateThreadRepliesFromStats( |
|
|
|
const batch = await hydrateThreadRepliesFromStats(candidates, { |
|
|
|
candidates, |
|
|
|
relayUrls: threadRelayUrlsRef.current, |
|
|
|
rootInfo, |
|
|
|
mutePubkeySet, |
|
|
|
event, |
|
|
|
hideContentMentioningMutedUsers |
|
|
|
isDiscussionRoot |
|
|
|
}) |
|
|
|
) |
|
|
|
|
|
|
|
if (cancelled) return |
|
|
|
if (cancelled) return |
|
|
|
for (const { id } of candidates) { |
|
|
|
for (const { id } of candidates) { |
|
|
|
if (!batch.some((e) => e.id === id)) statsHydratedReplyIdsRef.current.delete(id) |
|
|
|
if (!batch.some((e) => e.id === id)) statsHydratedReplyIdsRef.current.delete(id) |
|
|
|
} |
|
|
|
} |
|
|
|
const ok = batch.filter( |
|
|
|
if (batch.length > 0) addReplies(batch) |
|
|
|
(e) => |
|
|
|
|
|
|
|
!shouldHideThreadResponseEvent( |
|
|
|
|
|
|
|
e, |
|
|
|
|
|
|
|
mutePubkeySet, |
|
|
|
|
|
|
|
hideContentMentioningMutedUsers |
|
|
|
|
|
|
|
) |
|
|
|
|
|
|
|
) |
|
|
|
|
|
|
|
if (ok.length > 0) addReplies(ok) |
|
|
|
|
|
|
|
})() |
|
|
|
})() |
|
|
|
|
|
|
|
|
|
|
|
return () => { |
|
|
|
return () => { |
|
|
|
@ -519,14 +540,78 @@ function ReplyNoteList({ |
|
|
|
} |
|
|
|
} |
|
|
|
}, [ |
|
|
|
}, [ |
|
|
|
event, |
|
|
|
event, |
|
|
|
rootInfo, |
|
|
|
event.id, |
|
|
|
isDiscussionRoot, |
|
|
|
|
|
|
|
noteStats?.replies, |
|
|
|
noteStats?.replies, |
|
|
|
noteStats?.updatedAt, |
|
|
|
noteStats?.updatedAt, |
|
|
|
repliesMap, |
|
|
|
repliesMap, |
|
|
|
addReplies, |
|
|
|
addReplies, |
|
|
|
mutePubkeySet, |
|
|
|
mutePubkeySet, |
|
|
|
hideContentMentioningMutedUsers |
|
|
|
hideContentMentioningMutedUsers, |
|
|
|
|
|
|
|
refreshToken |
|
|
|
|
|
|
|
]) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/** When stats counted many replies but the thread REQ returned few, run the same social filters as note-stats. */ |
|
|
|
|
|
|
|
const statsRelaySyncGenRef = useRef(0) |
|
|
|
|
|
|
|
useEffect(() => { |
|
|
|
|
|
|
|
const statsLen = noteStats?.replies?.length ?? 0 |
|
|
|
|
|
|
|
if (statsLen < 3) return |
|
|
|
|
|
|
|
const resolved = buildRepliesListAlignedWithNoteStats( |
|
|
|
|
|
|
|
noteStats?.replies, |
|
|
|
|
|
|
|
repliesMap, |
|
|
|
|
|
|
|
[], |
|
|
|
|
|
|
|
mutePubkeySet, |
|
|
|
|
|
|
|
hideContentMentioningMutedUsers |
|
|
|
|
|
|
|
) |
|
|
|
|
|
|
|
if (resolved.length >= statsLen) return |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const relayUrls = threadRelayUrlsRef.current |
|
|
|
|
|
|
|
if (!relayUrls.length) return |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const socialFilters = noteStatsService.getSocialStatsFiltersForEvent(event) |
|
|
|
|
|
|
|
if (!socialFilters.length) return |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const gen = ++statsRelaySyncGenRef.current |
|
|
|
|
|
|
|
void queryService |
|
|
|
|
|
|
|
.fetchEvents(relayUrls, socialFilters, { |
|
|
|
|
|
|
|
foreground: true, |
|
|
|
|
|
|
|
globalTimeout: 14_000, |
|
|
|
|
|
|
|
firstRelayResultGraceMs: 900, |
|
|
|
|
|
|
|
relayOpSource: 'ReplyNoteList.statsSocialSync', |
|
|
|
|
|
|
|
onevent: (evt: NEvent) => { |
|
|
|
|
|
|
|
if (gen !== statsRelaySyncGenRef.current) return |
|
|
|
|
|
|
|
if (isPollVoteKind(evt)) return |
|
|
|
|
|
|
|
if (!statsReplyIds.has(evt.id)) return |
|
|
|
|
|
|
|
if ( |
|
|
|
|
|
|
|
shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers) |
|
|
|
|
|
|
|
) { |
|
|
|
|
|
|
|
return |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
addReplies([evt]) |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
}) |
|
|
|
|
|
|
|
.then((batch) => { |
|
|
|
|
|
|
|
if (gen !== statsRelaySyncGenRef.current) return |
|
|
|
|
|
|
|
const ok = batch.filter( |
|
|
|
|
|
|
|
(evt) => |
|
|
|
|
|
|
|
statsReplyIds.has(evt.id) && |
|
|
|
|
|
|
|
!isPollVoteKind(evt) && |
|
|
|
|
|
|
|
!shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers) |
|
|
|
|
|
|
|
) |
|
|
|
|
|
|
|
if (ok.length > 0) addReplies(ok) |
|
|
|
|
|
|
|
}) |
|
|
|
|
|
|
|
.catch(() => { |
|
|
|
|
|
|
|
/* optional */ |
|
|
|
|
|
|
|
}) |
|
|
|
|
|
|
|
}, [ |
|
|
|
|
|
|
|
event, |
|
|
|
|
|
|
|
noteStats?.replies?.length, |
|
|
|
|
|
|
|
noteStats?.updatedAt, |
|
|
|
|
|
|
|
repliesMap, |
|
|
|
|
|
|
|
statsReplyIds, |
|
|
|
|
|
|
|
addReplies, |
|
|
|
|
|
|
|
mutePubkeySet, |
|
|
|
|
|
|
|
hideContentMentioningMutedUsers, |
|
|
|
|
|
|
|
refreshToken |
|
|
|
]) |
|
|
|
]) |
|
|
|
|
|
|
|
|
|
|
|
const onNewReply = useCallback( |
|
|
|
const onNewReply = useCallback( |
|
|
|
@ -587,6 +672,9 @@ function ReplyNoteList({ |
|
|
|
const fetchGeneration = ++replyFetchGenRef.current |
|
|
|
const fetchGeneration = ++replyFetchGenRef.current |
|
|
|
|
|
|
|
|
|
|
|
const init = async () => { |
|
|
|
const init = async () => { |
|
|
|
|
|
|
|
const cachedStatsReplies = noteStatsService.getNoteStats(event.id)?.replies |
|
|
|
|
|
|
|
const statsIdSetInit = buildNoteStatsReplyIdSet(cachedStatsReplies) |
|
|
|
|
|
|
|
|
|
|
|
// Session LRU (timeline / note-stats / prior panels): thread replies before relay round-trip
|
|
|
|
// Session LRU (timeline / note-stats / prior panels): thread replies before relay round-trip
|
|
|
|
if (rootInfo.type === 'E' || rootInfo.type === 'A') { |
|
|
|
if (rootInfo.type === 'E' || rootInfo.type === 'A') { |
|
|
|
const fromSession = eventService.getSessionThreadInteractionEvents( |
|
|
|
const fromSession = eventService.getSessionThreadInteractionEvents( |
|
|
|
@ -596,6 +684,12 @@ function ReplyNoteList({ |
|
|
|
if (fromSession.length > 0) { |
|
|
|
if (fromSession.length > 0) { |
|
|
|
addReplies(fromSession) |
|
|
|
addReplies(fromSession) |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
if (statsIdSetInit.size > 0) { |
|
|
|
|
|
|
|
const statsSessionHits = eventService |
|
|
|
|
|
|
|
.getSessionEventsForNoteStatsTarget(event, { maxScan: 40_000 }) |
|
|
|
|
|
|
|
.filter((e) => statsIdSetInit.has(e.id)) |
|
|
|
|
|
|
|
if (statsSessionHits.length > 0) addReplies(statsSessionHits) |
|
|
|
|
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// Check cache next — discussion cache merges with relay results
|
|
|
|
// Check cache next — discussion cache merges with relay results
|
|
|
|
@ -698,13 +792,26 @@ function ReplyNoteList({ |
|
|
|
const recipientPubkey = event.pubkey |
|
|
|
const recipientPubkey = event.pubkey |
|
|
|
|
|
|
|
|
|
|
|
// Stream replies as relays return them (aggr is first in the list) instead of waiting for full EOSE.
|
|
|
|
// Stream replies as relays return them (aggr is first in the list) instead of waiting for full EOSE.
|
|
|
|
|
|
|
|
const statsIdsStream = buildNoteStatsReplyIdSet( |
|
|
|
|
|
|
|
noteStatsService.getNoteStats(event.id)?.replies |
|
|
|
|
|
|
|
) |
|
|
|
|
|
|
|
|
|
|
|
const streamThreadReply = (evt: NEvent) => { |
|
|
|
const streamThreadReply = (evt: NEvent) => { |
|
|
|
if (fetchGeneration !== replyFetchGenRef.current) return |
|
|
|
if (fetchGeneration !== replyFetchGenRef.current) return |
|
|
|
if (isPollVoteKind(evt)) return |
|
|
|
if (isPollVoteKind(evt)) return |
|
|
|
if (rootInfo.type === 'I') { |
|
|
|
if (rootInfo.type === 'I') { |
|
|
|
if (!isRssArticleUrlThreadInteraction(evt, rootInfo.id)) return |
|
|
|
if (!isRssArticleUrlThreadInteraction(evt, rootInfo.id)) return |
|
|
|
} |
|
|
|
} |
|
|
|
if (shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers)) return |
|
|
|
if (shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers)) |
|
|
|
|
|
|
|
return |
|
|
|
|
|
|
|
if (statsIdsStream.size > 0) { |
|
|
|
|
|
|
|
if ( |
|
|
|
|
|
|
|
!statsIdsStream.has(evt.id) && |
|
|
|
|
|
|
|
!replyMatchesThreadForList(evt, event, rootInfo, isDiscussionRoot) |
|
|
|
|
|
|
|
) { |
|
|
|
|
|
|
|
return |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
} |
|
|
|
addReplies([evt]) |
|
|
|
addReplies([evt]) |
|
|
|
if (!hasCache) setLoading(false) |
|
|
|
if (!hasCache) setLoading(false) |
|
|
|
} |
|
|
|
} |
|
|
|
@ -760,15 +867,29 @@ function ReplyNoteList({ |
|
|
|
allReplies.map((e) => [e.id.toLowerCase(), e] as const) |
|
|
|
allReplies.map((e) => [e.id.toLowerCase(), e] as const) |
|
|
|
) |
|
|
|
) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const statsIdsForFetch = buildNoteStatsReplyIdSet( |
|
|
|
|
|
|
|
noteStatsService.getNoteStats(event.id)?.replies |
|
|
|
|
|
|
|
) |
|
|
|
|
|
|
|
|
|
|
|
// Filter and add replies (URL threads include kind 9802 highlights of this page)
|
|
|
|
// Filter and add replies (URL threads include kind 9802 highlights of this page)
|
|
|
|
const regularReplies = allReplies.filter((evt) => { |
|
|
|
const regularReplies = allReplies.filter((evt) => { |
|
|
|
if (isPollVoteKind(evt)) return false |
|
|
|
if (isPollVoteKind(evt)) return false |
|
|
|
const match = replyMatchesThreadForList(evt, event, rootInfo, isDiscussionRoot, threadWalkFromBatch) |
|
|
|
if ( |
|
|
|
if (!match) return false |
|
|
|
shouldHideThreadResponseEvent( |
|
|
|
return !shouldHideThreadResponseEvent( |
|
|
|
evt, |
|
|
|
|
|
|
|
mutePubkeySet, |
|
|
|
|
|
|
|
hideContentMentioningMutedUsers |
|
|
|
|
|
|
|
) |
|
|
|
|
|
|
|
) { |
|
|
|
|
|
|
|
return false |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
if (statsIdsForFetch.has(evt.id)) return true |
|
|
|
|
|
|
|
return replyMatchesThreadForList( |
|
|
|
evt, |
|
|
|
evt, |
|
|
|
mutePubkeySet, |
|
|
|
event, |
|
|
|
hideContentMentioningMutedUsers |
|
|
|
rootInfo, |
|
|
|
|
|
|
|
isDiscussionRoot, |
|
|
|
|
|
|
|
threadWalkFromBatch |
|
|
|
) |
|
|
|
) |
|
|
|
}) |
|
|
|
}) |
|
|
|
|
|
|
|
|
|
|
|
@ -1000,6 +1121,13 @@ function ReplyNoteList({ |
|
|
|
} |
|
|
|
} |
|
|
|
}, [mergedFeed.length, showCount]) |
|
|
|
}, [mergedFeed.length, showCount]) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/** Expand the visible window as stats-backed replies hydrate (count badge can be 99+). */ |
|
|
|
|
|
|
|
useEffect(() => { |
|
|
|
|
|
|
|
const statsLen = noteStats?.replies?.length ?? 0 |
|
|
|
|
|
|
|
if (statsLen === 0) return |
|
|
|
|
|
|
|
setShowCount((prev) => Math.max(prev, Math.min(mergedFeed.length, statsLen))) |
|
|
|
|
|
|
|
}, [mergedFeed.length, noteStats?.replies?.length]) |
|
|
|
|
|
|
|
|
|
|
|
const highlightReply = useCallback((eventId: string, scrollTo = true) => { |
|
|
|
const highlightReply = useCallback((eventId: string, scrollTo = true) => { |
|
|
|
if (scrollTo) { |
|
|
|
if (scrollTo) { |
|
|
|
const ref = replyRefs.current[eventId] |
|
|
|
const ref = replyRefs.current[eventId] |
|
|
|
|