Browse Source

bug-fixes

imwald
Silberengel 2 weeks ago
parent
commit
73da9de061
  1. 24
      src/components/NoteList/index.tsx
  2. 6
      src/components/Profile/ProfileFeed.tsx
  3. 184
      src/components/ReplyNoteList/index.tsx
  4. 187
      src/components/ReplyNoteList/reply-list-utils.ts
  5. 8
      src/services/note-stats.service.ts

24
src/components/NoteList/index.tsx

@ -816,6 +816,8 @@ const NoteList = forwardRef(
* sits on that row instead of an extra bar above the list. Omitted on spells / standalone NoteList. * sits on that row instead of an extra bar above the list. Omitted on spells / standalone NoteList.
*/ */
feedClientFilterTabRowHost, feedClientFilterTabRowHost,
/** When set with {@link feedClientFilterTabRowHost}, portaled filter panel renders here (e.g. profile: above pins). */
feedClientFilterPanelHost,
onSingleRelayKindlessEmpty, onSingleRelayKindlessEmpty,
onSingleRelayBrowseEmpty, onSingleRelayBrowseEmpty,
feedTopNotice, feedTopNotice,
@ -891,6 +893,7 @@ const NoteList = forwardRef(
showFeedClientFilter?: boolean showFeedClientFilter?: boolean
hostPrimaryPageName?: TPrimaryPageName hostPrimaryPageName?: TPrimaryPageName
feedClientFilterTabRowHost?: HTMLElement | null feedClientFilterTabRowHost?: HTMLElement | null
feedClientFilterPanelHost?: HTMLElement | null
/** Single-relay kindless: if EOSE with no events, parent switches to explicit kinds in `subRequests`. */ /** Single-relay kindless: if EOSE with no events, parent switches to explicit kinds in `subRequests`. */
onSingleRelayKindlessEmpty?: () => void onSingleRelayKindlessEmpty?: () => void
/** Relay explore: explicit kinds EOSE empty — parent retries kindless `{ limit }` once. */ /** Relay explore: explicit kinds EOSE empty — parent retries kindless `{ limit }` once. */
@ -1115,9 +1118,14 @@ const NoteList = forwardRef(
const primaryPageCtx = usePrimaryPageOptional() const primaryPageCtx = usePrimaryPageOptional()
const primaryPageCurrent = primaryPageCtx?.current ?? null const primaryPageCurrent = primaryPageCtx?.current ?? null
const primaryPanelFrozen = primaryPageCtx?.frozen ?? false const primaryPanelFrozen = primaryPageCtx?.frozen ?? false
/** Only pause timelines on the active primary page feed — not secondary-panel profiles, search, etc. */ const primaryFeedDisplayed = primaryPageCtx?.display ?? true
/**
* Pause timelines only when the active primary feed is hidden (e.g. mobile note takeover,
* single-pane sheet). Double-pane and mobile feed overlay keep `display` true keep loading.
*/
const pauseTimelineForPrimaryFreeze = const pauseTimelineForPrimaryFreeze =
primaryPanelFrozen && primaryPanelFrozen &&
!primaryFeedDisplayed &&
hostPrimaryPageName != null && hostPrimaryPageName != null &&
hostPrimaryPageName === primaryPageCurrent hostPrimaryPageName === primaryPageCurrent
@ -4856,9 +4864,18 @@ const NoteList = forwardRef(
</> </>
) )
/** Tab-row portal: toggle lives in the header; panel expands in-flow above the list. */ const feedClientFilterPanelPortaled =
feedClientFilterPanelPortalMode &&
feedClientFilterPanelHost &&
feedClientFilterPanel
? createPortal(feedClientFilterPanel, feedClientFilterPanelHost)
: null
/** Tab-row portal: toggle in header; panel in {@link feedClientFilterPanelHost} or above the list. */
const feedClientFilterPanelInList = const feedClientFilterPanelInList =
feedClientFilterPanelPortalMode ? feedClientFilterPanel : null feedClientFilterPanelPortalMode && !feedClientFilterPanelHost
? feedClientFilterPanel
: null
const feedClientFilterBarEmbedded = ( const feedClientFilterBarEmbedded = (
<div className="sticky top-0 z-20 border-b border-border/80 bg-background/95 px-1 py-1 backdrop-blur supports-[backdrop-filter]:bg-background/80"> <div className="sticky top-0 z-20 border-b border-border/80 bg-background/95 px-1 py-1 backdrop-blur supports-[backdrop-filter]:bg-background/80">
@ -5044,6 +5061,7 @@ const NoteList = forwardRef(
return ( return (
<div ref={feedRootRef} className="relative"> <div ref={feedRootRef} className="relative">
<div ref={topRef} className="scroll-mt-[calc(6rem+1px)]" /> <div ref={topRef} className="scroll-mt-[calc(6rem+1px)]" />
{feedClientFilterPanelPortaled}
<NoteFeedProfileContext.Provider value={noteFeedProfileContextValue}> <NoteFeedProfileContext.Provider value={noteFeedProfileContextValue}>
{supportTouch ? ( {supportTouch ? (
<PullToRefresh <PullToRefresh

6
src/components/Profile/ProfileFeed.tsx

@ -35,9 +35,13 @@ const ProfileFeed = forwardRef<
const [isRefreshing, setIsRefreshing] = useState(false) const [isRefreshing, setIsRefreshing] = useState(false)
const noteListRef = useRef<TNoteListRef>(null) const noteListRef = useRef<TNoteListRef>(null)
const [feedFilterTabRowHost, setFeedFilterTabRowHost] = useState<HTMLDivElement | null>(null) const [feedFilterTabRowHost, setFeedFilterTabRowHost] = useState<HTMLDivElement | null>(null)
const [feedFilterPanelHost, setFeedFilterPanelHost] = useState<HTMLDivElement | null>(null)
const onFeedFilterTabRowSlotRef = useCallback((node: HTMLDivElement | null) => { const onFeedFilterTabRowSlotRef = useCallback((node: HTMLDivElement | null) => {
setFeedFilterTabRowHost((prev) => (Object.is(prev, node) ? prev : node)) setFeedFilterTabRowHost((prev) => (Object.is(prev, node) ? prev : node))
}, []) }, [])
const onFeedFilterPanelHostRef = useCallback((node: HTMLDivElement | null) => {
setFeedFilterPanelHost((prev) => (Object.is(prev, node) ? prev : node))
}, [])
const { pinEvents, loadingPins, refreshPins } = useProfilePins(pubkey) const { pinEvents, loadingPins, refreshPins } = useProfilePins(pubkey)
@ -119,6 +123,7 @@ const ProfileFeed = forwardRef<
includeFeedSearchSlot includeFeedSearchSlot
/> />
</div> </div>
<div ref={onFeedFilterPanelHostRef} className="min-w-0 px-1" />
{pinEvents.filter((e) => !isEventDeleted(e)).length > 0 && ( {pinEvents.filter((e) => !isEventDeleted(e)).length > 0 && (
<div className="mb-3 space-y-2 px-1" aria-label={t('Pinned posts')}> <div className="mb-3 space-y-2 px-1" aria-label={t('Pinned posts')}>
{pinEvents {pinEvents
@ -152,6 +157,7 @@ const ProfileFeed = forwardRef<
showKind1111={showKind1111} showKind1111={showKind1111}
showFeedClientFilter showFeedClientFilter
feedClientFilterTabRowHost={feedFilterTabRowHost} feedClientFilterTabRowHost={feedFilterTabRowHost}
feedClientFilterPanelHost={feedFilterPanelHost}
timelinePublicReadFallback timelinePublicReadFallback
revealBatchSize={48} revealBatchSize={48}
/> />

184
src/components/ReplyNoteList/index.tsx

@ -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,
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 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,16 +867,30 @@ 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, evt,
mutePubkeySet, mutePubkeySet,
hideContentMentioningMutedUsers hideContentMentioningMutedUsers
) )
) {
return false
}
if (statsIdsForFetch.has(evt.id)) return true
return replyMatchesThreadForList(
evt,
event,
rootInfo,
isDiscussionRoot,
threadWalkFromBatch
)
}) })
// Store in cache (this merges with existing cached replies) // Store in cache (this merges with existing cached replies)
@ -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]

187
src/components/ReplyNoteList/reply-list-utils.ts

@ -8,7 +8,7 @@ import { isRssArticleUrlThreadInteraction } from '@/lib/rss-web-feed'
import { shouldHideThreadResponseEvent } from '@/lib/thread-response-filter' import { shouldHideThreadResponseEvent } from '@/lib/thread-response-filter'
import { buildThreadInteractionFilters } from '@/lib/thread-interaction-req' import { buildThreadInteractionFilters } from '@/lib/thread-interaction-req'
import noteStatsService from '@/services/note-stats.service' import noteStatsService from '@/services/note-stats.service'
import client, { eventService } from '@/services/client.service' import client, { eventService, queryService } from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service' import indexedDb from '@/services/indexed-db.service'
import type { TSubRequestFilter } from '@/types' import type { TSubRequestFilter } from '@/types'
import { Filter, Event as NEvent, kinds } from 'nostr-tools' import { Filter, Event as NEvent, kinds } from 'nostr-tools'
@ -62,6 +62,84 @@ function dedupeEventsFromRepliesMap(repliesMap: TRepliesMap): NEvent[] {
return [...byId.values()] return [...byId.values()]
} }
export function buildNoteStatsReplyIdSet(
replies: ReadonlyArray<{ id: string }> | undefined
): Set<string> {
const out = new Set<string>()
if (!replies?.length) return out
for (const r of replies) {
if (r.id) out.add(r.id)
}
return out
}
/** Resolve full events for ids already counted in note-stats (map → session LRU). */
export function resolveEventsForStatsReplyIds(
statsReplies: ReadonlyArray<{ id: string }> | undefined,
repliesMap: TRepliesMap
): NEvent[] {
if (!statsReplies?.length) return []
const fromMap = dedupeEventsFromRepliesMap(repliesMap)
const byId = new Map(fromMap.map((e) => [e.id, e]))
const out: NEvent[] = []
const seen = new Set<string>()
for (const { id } of statsReplies) {
if (seen.has(id)) continue
seen.add(id)
const mapped = byId.get(id)
if (mapped) {
out.push(mapped)
continue
}
const peek = client.peekSessionCachedEvent(id)
if (peek) out.push(peek)
}
return out
}
/**
* Primary reply list for Antworten: same rows as note-stats `replies`, then any extra thread rows.
* Preserves stats ordering so the badge count matches rendered rows when events are resolvable.
*/
export function buildRepliesListAlignedWithNoteStats(
statsReplies: ReadonlyArray<{ id: string; pubkey: string; created_at: number }> | undefined,
repliesMap: TRepliesMap,
threadDisplayed: NEvent[],
mutePubkeySet: Set<string>,
hideContentMentioningMutedUsers: boolean | undefined
): NEvent[] {
const statsIds = buildNoteStatsReplyIdSet(statsReplies)
const byId = new Map<string, NEvent>()
const keep = (evt: NEvent) => {
if (isPollVoteKind(evt)) return false
return !shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers)
}
for (const evt of resolveEventsForStatsReplyIds(statsReplies, repliesMap)) {
if (keep(evt)) byId.set(evt.id, evt)
}
for (const evt of threadDisplayed) {
if (keep(evt)) byId.set(evt.id, evt)
}
const ordered: NEvent[] = []
const seen = new Set<string>()
for (const meta of statsReplies ?? []) {
const evt = byId.get(meta.id)
if (!evt || seen.has(evt.id)) continue
seen.add(evt.id)
ordered.push(evt)
}
for (const evt of threadDisplayed) {
if (seen.has(evt.id) || statsIds.has(evt.id)) continue
if (!keep(evt)) continue
seen.add(evt.id)
ordered.push(evt)
}
return ordered
}
/** Replies to show under “Antworten” for the opened note (direct + nested, not sibling branches). */ /** Replies to show under “Antworten” for the opened note (direct + nested, not sibling branches). */
export function collectDisplayedThreadReplies( export function collectDisplayedThreadReplies(
opEvent: NEvent, opEvent: NEvent,
@ -69,12 +147,22 @@ export function collectDisplayedThreadReplies(
repliesMap: TRepliesMap, repliesMap: TRepliesMap,
isDiscussionRoot: boolean, isDiscussionRoot: boolean,
mutePubkeySet: Set<string>, mutePubkeySet: Set<string>,
hideContentMentioningMutedUsers: boolean | undefined hideContentMentioningMutedUsers: boolean | undefined,
/** Reply ids already counted on this note in note-stats — always show when loaded. */
statsReplyIds?: ReadonlySet<string>
): NEvent[] { ): NEvent[] {
const threadWalk = new Map<string, NEvent>() const threadWalk = new Map<string, NEvent>()
for (const evt of dedupeEventsFromRepliesMap(repliesMap)) { for (const evt of dedupeEventsFromRepliesMap(repliesMap)) {
threadWalk.set(evt.id.toLowerCase(), evt) threadWalk.set(evt.id.toLowerCase(), evt)
} }
if (statsReplyIds) {
for (const id of statsReplyIds) {
const key = id.toLowerCase()
if (threadWalk.has(key)) continue
const peek = client.peekSessionCachedEvent(id)
if (peek) threadWalk.set(key, peek)
}
}
if (rootInfo?.type === 'I') { if (rootInfo?.type === 'I') {
const opHex = openNoteHexId(opEvent) const opHex = openNoteHexId(opEvent)
@ -84,6 +172,11 @@ export function collectDisplayedThreadReplies(
if (seen.has(evt.id)) continue if (seen.has(evt.id)) continue
if (isPollVoteKind(evt)) continue if (isPollVoteKind(evt)) continue
if (shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers)) continue if (shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers)) continue
if (statsReplyIds?.has(evt.id)) {
seen.add(evt.id)
out.push(evt)
continue
}
if (!isRssArticleUrlThreadInteraction(evt, rootInfo.id)) continue if (!isRssArticleUrlThreadInteraction(evt, rootInfo.id)) continue
if ( if (
opHex && opHex &&
@ -101,8 +194,11 @@ export function collectDisplayedThreadReplies(
const opHex = openNoteHexId(opEvent) const opHex = openNoteHexId(opEvent)
if (!opHex) return [] if (!opHex) return []
const opHexLower = opHex.toLowerCase()
/** Viewing the thread root itself (kind-1 note or a replaceable article instance). */
const isThreadRootView = const isThreadRootView =
rootInfo?.type === 'E' && rootInfo.id.trim().toLowerCase() === opHex (rootInfo?.type === 'E' && rootInfo.id.trim().toLowerCase() === opHexLower) ||
(rootInfo?.type === 'A' && rootInfo.eventId.trim().toLowerCase() === opHexLower)
const out: NEvent[] = [] const out: NEvent[] = []
const seen = new Set<string>() const seen = new Set<string>()
@ -110,6 +206,11 @@ export function collectDisplayedThreadReplies(
if (seen.has(evt.id)) continue if (seen.has(evt.id)) continue
if (isPollVoteKind(evt)) continue if (isPollVoteKind(evt)) continue
if (shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers)) continue if (shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers)) continue
if (statsReplyIds?.has(evt.id)) {
seen.add(evt.id)
out.push(evt)
continue
}
if (rootInfo && !replyMatchesThreadForList(evt, opEvent, rootInfo, isDiscussionRoot, threadWalk)) { if (rootInfo && !replyMatchesThreadForList(evt, opEvent, rootInfo, isDiscussionRoot, threadWalk)) {
continue continue
} }
@ -157,39 +258,99 @@ export async function loadThreadRepliesFromLocalStores(
}) })
} }
/** Resolve reply ids from note-stats via archive + session fetch, then thread-match filter. */ const STATS_HYDRATE_ARCHIVE_CHUNK = 80
const STATS_HYDRATE_FETCH_CHUNK = 40
const STATS_HYDRATE_RELAY_IDS_CHUNK = 200
export type HydrateThreadRepliesFromStatsOpts = {
relayUrls?: string[]
mutePubkeySet?: Set<string>
hideContentMentioningMutedUsers?: boolean | undefined
}
/**
* Resolve reply ids already counted in note-stats (archive, session, fetch, relay `ids` REQ).
* Does not re-apply thread-match filters stats and UI counts must stay aligned.
*/
export async function hydrateThreadRepliesFromStats( export async function hydrateThreadRepliesFromStats(
candidates: ReadonlyArray<{ id: string }>, candidates: ReadonlyArray<{ id: string }>,
rootInfo: TRootInfo, opts?: HydrateThreadRepliesFromStatsOpts
opEvent: NEvent,
isDiscussionRoot: boolean
): Promise<NEvent[]> { ): Promise<NEvent[]> {
if (!candidates.length) return [] if (!candidates.length) return []
const ids = candidates.map((c) => c.id) const ids = [
...new Set(
candidates
.map((c) => c.id.trim())
.filter((id) => /^[0-9a-f]{64}$/i.test(id))
)
]
if (!ids.length) return []
const byId = new Map<string, NEvent>() const byId = new Map<string, NEvent>()
for (let i = 0; i < ids.length; i += STATS_HYDRATE_ARCHIVE_CHUNK) {
const chunk = ids.slice(i, i + STATS_HYDRATE_ARCHIVE_CHUNK)
try { try {
const archived = await indexedDb.getArchivedEventsByIds(ids) const archived = await indexedDb.getArchivedEventsByIds(chunk)
for (const e of archived) byId.set(e.id, e) for (const e of archived) byId.set(e.id, e)
} catch { } catch {
/* optional */ /* optional */
} }
}
for (const id of ids) { for (const id of ids) {
if (byId.has(id)) continue if (byId.has(id)) continue
const cached = client.peekSessionCachedEvent(id)
if (cached) byId.set(cached.id, cached)
}
const missingAfterLocal = ids.filter((id) => !byId.has(id))
for (let i = 0; i < missingAfterLocal.length; i += STATS_HYDRATE_FETCH_CHUNK) {
const chunk = missingAfterLocal.slice(i, i + STATS_HYDRATE_FETCH_CHUNK)
await Promise.allSettled(
chunk.map(async (id) => {
try { try {
const ev = await eventService.fetchEvent(id) const ev = await eventService.fetchEvent(id)
if (ev) byId.set(ev.id, ev) if (ev) byId.set(ev.id, ev)
} catch { } catch {
/* optional */ /* optional */
} }
})
)
}
const relayUrls = (opts?.relayUrls ?? []).filter(Boolean)
const missingAfterFetch = ids.filter((id) => !byId.has(id))
if (missingAfterFetch.length > 0 && relayUrls.length > 0) {
for (let i = 0; i < missingAfterFetch.length; i += STATS_HYDRATE_RELAY_IDS_CHUNK) {
const chunk = missingAfterFetch.slice(i, i + STATS_HYDRATE_RELAY_IDS_CHUNK)
try {
const fromRelay = await queryService.fetchEvents(
relayUrls,
[{ ids: chunk, limit: chunk.length }],
{
foreground: true,
globalTimeout: 12_000,
relayOpSource: 'ReplyNoteList.statsHydrate'
}
)
for (const e of fromRelay) byId.set(e.id, e)
} catch {
/* optional */
}
}
} }
const batch: NEvent[] = [] const batch: NEvent[] = []
for (const ev of byId.values()) { for (const id of ids) {
const ev = byId.get(id)
if (!ev) continue
if (isPollVoteKind(ev)) continue if (isPollVoteKind(ev)) continue
if (rootInfo.type === 'I') { if (
if (!isRssArticleUrlThreadInteraction(ev, rootInfo.id)) continue opts?.mutePubkeySet &&
} else if (!replyMatchesThreadForList(ev, opEvent, rootInfo, isDiscussionRoot)) { shouldHideThreadResponseEvent(ev, opts.mutePubkeySet, opts.hideContentMentioningMutedUsers)
) {
continue continue
} }
batch.push(ev) batch.push(ev)

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

@ -877,6 +877,14 @@ class NoteStatsService {
return this.noteStatsMap.get(this.statsKey(id)) return this.noteStatsMap.get(this.statsKey(id))
} }
/** Same social `kinds` / tag filters as {@link fetchNoteStats} — for thread UI to load counted replies. */
getSocialStatsFiltersForEvent(event: Event): Filter[] {
const replaceableCoordinate = isReplaceableEvent(event.kind)
? getReplaceableCoordinateFromEvent(event)
: undefined
return this.buildFilterGroups(event, replaceableCoordinate).social
}
/** /**
* Snapshot for {@link useNoteStatsById} / `useSyncExternalStore`: `epoch` changes on every stats notify so React * Snapshot for {@link useNoteStatsById} / `useSyncExternalStore`: `epoch` changes on every stats notify so React
* always re-renders when counts update (avoids stale UI when the map entry reference is reused or updates race mount). * always re-renders when counts update (avoids stale UI when the map entry reference is reused or updates race mount).

Loading…
Cancel
Save