From 73da9de061d2fb3526cef804a3b49e3e62ca5fcb Mon Sep 17 00:00:00 2001 From: Silberengel Date: Wed, 3 Jun 2026 11:21:48 +0200 Subject: [PATCH] bug-fixes --- src/components/NoteList/index.tsx | 24 +- src/components/Profile/ProfileFeed.tsx | 6 + src/components/ReplyNoteList/index.tsx | 190 +++++++++++++--- .../ReplyNoteList/reply-list-utils.ts | 205 ++++++++++++++++-- src/services/note-stats.service.ts | 8 + 5 files changed, 377 insertions(+), 56 deletions(-) diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index fe89ee37..de4daa4f 100644 --- a/src/components/NoteList/index.tsx +++ b/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. */ feedClientFilterTabRowHost, + /** When set with {@link feedClientFilterTabRowHost}, portaled filter panel renders here (e.g. profile: above pins). */ + feedClientFilterPanelHost, onSingleRelayKindlessEmpty, onSingleRelayBrowseEmpty, feedTopNotice, @@ -891,6 +893,7 @@ const NoteList = forwardRef( showFeedClientFilter?: boolean hostPrimaryPageName?: TPrimaryPageName feedClientFilterTabRowHost?: HTMLElement | null + feedClientFilterPanelHost?: HTMLElement | null /** Single-relay kindless: if EOSE with no events, parent switches to explicit kinds in `subRequests`. */ onSingleRelayKindlessEmpty?: () => void /** Relay explore: explicit kinds EOSE empty — parent retries kindless `{ limit }` once. */ @@ -1115,9 +1118,14 @@ const NoteList = forwardRef( const primaryPageCtx = usePrimaryPageOptional() const primaryPageCurrent = primaryPageCtx?.current ?? null 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 = primaryPanelFrozen && + !primaryFeedDisplayed && hostPrimaryPageName != null && 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 = - feedClientFilterPanelPortalMode ? feedClientFilterPanel : null + feedClientFilterPanelPortalMode && !feedClientFilterPanelHost + ? feedClientFilterPanel + : null const feedClientFilterBarEmbedded = (
@@ -5044,6 +5061,7 @@ const NoteList = forwardRef( return (
+ {feedClientFilterPanelPortaled} {supportTouch ? ( (null) const [feedFilterTabRowHost, setFeedFilterTabRowHost] = useState(null) + const [feedFilterPanelHost, setFeedFilterPanelHost] = useState(null) const onFeedFilterTabRowSlotRef = useCallback((node: HTMLDivElement | null) => { 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) @@ -119,6 +123,7 @@ const ProfileFeed = forwardRef< includeFeedSearchSlot />
+
{pinEvents.filter((e) => !isEventDeleted(e)).length > 0 && (
{pinEvents @@ -152,6 +157,7 @@ const ProfileFeed = forwardRef< showKind1111={showKind1111} showFeedClientFilter feedClientFilterTabRowHost={feedFilterTabRowHost} + feedClientFilterPanelHost={feedFilterPanelHost} timelinePublicReadFallback revealBatchSize={48} /> diff --git a/src/components/ReplyNoteList/index.tsx b/src/components/ReplyNoteList/index.tsx index 195b23cd..fadbe582 100644 --- a/src/components/ReplyNoteList/index.tsx +++ b/src/components/ReplyNoteList/index.tsx @@ -62,6 +62,8 @@ import { backlinkRunSectionClass, buildVisibleBacklinkRows, EA_THREAD_TAIL_REFERENCE_KINDS, + buildNoteStatsReplyIdSet, + buildRepliesListAlignedWithNoteStats, collectDisplayedThreadReplies, fetchPaymentAttestationsForRecipient, hydrateThreadRepliesFromStats, @@ -137,13 +139,26 @@ function ReplyNoteList({ return out.length ? out : undefined }, [duplicateWebPreviewCleanedUrlHints, rootInfo]) + const statsReplyIds = useMemo( + () => buildNoteStatsReplyIdSet(noteStats?.replies), + [noteStats?.replies, noteStats?.updatedAt] + ) + const replies: NEvent[] = useMemo(() => { - const replyEvents = collectDisplayedThreadReplies( + const threadDisplayed = collectDisplayedThreadReplies( event, rootInfo, repliesMap, isDiscussionRoot, mutePubkeySet, + hideContentMentioningMutedUsers, + statsReplyIds + ) + const replyEvents = buildRepliesListAlignedWithNoteStats( + noteStats?.replies, + repliesMap, + threadDisplayed, + mutePubkeySet, hideContentMentioningMutedUsers ) const replyIdSet = new Set(replyEvents.map((r) => r.id)) @@ -162,6 +177,7 @@ function ReplyNoteList({ ) { return false } + if (statsReplyIds.has(evt.id)) return true if ( rootInfo && !replyMatchesThreadForList(evt, event, rootInfo, isDiscussionRoot, threadWalkFromRepliesMap) @@ -169,11 +185,16 @@ function ReplyNoteList({ return false } 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 ( - opHex && - rootInfo?.type === 'E' && - rootInfo.id.trim().toLowerCase() !== opHex && - !replyIsInSubtreeBelowOpenNote(evt, opHex, threadWalkFromRepliesMap) + opHexLower && + rootInfo && + !viewingThreadRoot && + !replyIsInSubtreeBelowOpenNote(evt, opHexLower, threadWalkFromRepliesMap) ) { return false } @@ -283,7 +304,10 @@ function ReplyNoteList({ sort, attestedPaymentIds, isDiscussionRoot, - event.kind + event.kind, + statsReplyIds, + noteStats?.replies, + noteStats?.updatedAt ]) const replyIdSet = useMemo(() => new Set(replies.map((r) => r.id)), [replies]) @@ -479,13 +503,19 @@ function ReplyNoteList({ }, [event.id]) useEffect(() => { - if (!rootInfo) return const fromStats = noteStats?.replies 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( (r) => !replyIdPresentInRepliesMap(repliesMap, r.id) && + !client.peekSessionCachedEvent(r.id) && !statsHydratedReplyIdsRef.current.has(r.id) ) if (candidates.length === 0) return @@ -493,25 +523,16 @@ function ReplyNoteList({ let cancelled = false ;(async () => { for (const { id } of candidates) statsHydratedReplyIdsRef.current.add(id) - const batch = await hydrateThreadRepliesFromStats( - candidates, - rootInfo, - event, - isDiscussionRoot - ) + const batch = await hydrateThreadRepliesFromStats(candidates, { + relayUrls: threadRelayUrlsRef.current, + mutePubkeySet, + hideContentMentioningMutedUsers + }) if (cancelled) return for (const { id } of candidates) { if (!batch.some((e) => e.id === id)) statsHydratedReplyIdsRef.current.delete(id) } - const ok = batch.filter( - (e) => - !shouldHideThreadResponseEvent( - e, - mutePubkeySet, - hideContentMentioningMutedUsers - ) - ) - if (ok.length > 0) addReplies(ok) + if (batch.length > 0) addReplies(batch) })() return () => { @@ -519,14 +540,78 @@ function ReplyNoteList({ } }, [ event, - rootInfo, - isDiscussionRoot, + event.id, noteStats?.replies, noteStats?.updatedAt, repliesMap, addReplies, 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( @@ -587,6 +672,9 @@ function ReplyNoteList({ const fetchGeneration = ++replyFetchGenRef.current 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 if (rootInfo.type === 'E' || rootInfo.type === 'A') { const fromSession = eventService.getSessionThreadInteractionEvents( @@ -596,6 +684,12 @@ function ReplyNoteList({ if (fromSession.length > 0) { 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 @@ -698,13 +792,26 @@ function ReplyNoteList({ const recipientPubkey = event.pubkey // 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) => { if (fetchGeneration !== replyFetchGenRef.current) return if (isPollVoteKind(evt)) return if (rootInfo.type === 'I') { 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]) if (!hasCache) setLoading(false) } @@ -760,15 +867,29 @@ function ReplyNoteList({ 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) const regularReplies = allReplies.filter((evt) => { if (isPollVoteKind(evt)) return false - const match = replyMatchesThreadForList(evt, event, rootInfo, isDiscussionRoot, threadWalkFromBatch) - if (!match) return false - return !shouldHideThreadResponseEvent( + if ( + shouldHideThreadResponseEvent( + evt, + mutePubkeySet, + hideContentMentioningMutedUsers + ) + ) { + return false + } + if (statsIdsForFetch.has(evt.id)) return true + return replyMatchesThreadForList( evt, - mutePubkeySet, - hideContentMentioningMutedUsers + event, + rootInfo, + isDiscussionRoot, + threadWalkFromBatch ) }) @@ -1000,6 +1121,13 @@ function ReplyNoteList({ } }, [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) => { if (scrollTo) { const ref = replyRefs.current[eventId] diff --git a/src/components/ReplyNoteList/reply-list-utils.ts b/src/components/ReplyNoteList/reply-list-utils.ts index cde5030c..ecbecb5b 100644 --- a/src/components/ReplyNoteList/reply-list-utils.ts +++ b/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 { buildThreadInteractionFilters } from '@/lib/thread-interaction-req' 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 type { TSubRequestFilter } from '@/types' import { Filter, Event as NEvent, kinds } from 'nostr-tools' @@ -62,6 +62,84 @@ function dedupeEventsFromRepliesMap(repliesMap: TRepliesMap): NEvent[] { return [...byId.values()] } +export function buildNoteStatsReplyIdSet( + replies: ReadonlyArray<{ id: string }> | undefined +): Set { + const out = new Set() + 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() + 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, + hideContentMentioningMutedUsers: boolean | undefined +): NEvent[] { + const statsIds = buildNoteStatsReplyIdSet(statsReplies) + const byId = new Map() + + 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() + 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). */ export function collectDisplayedThreadReplies( opEvent: NEvent, @@ -69,12 +147,22 @@ export function collectDisplayedThreadReplies( repliesMap: TRepliesMap, isDiscussionRoot: boolean, mutePubkeySet: Set, - hideContentMentioningMutedUsers: boolean | undefined + hideContentMentioningMutedUsers: boolean | undefined, + /** Reply ids already counted on this note in note-stats — always show when loaded. */ + statsReplyIds?: ReadonlySet ): NEvent[] { const threadWalk = new Map() for (const evt of dedupeEventsFromRepliesMap(repliesMap)) { 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') { const opHex = openNoteHexId(opEvent) @@ -84,6 +172,11 @@ export function collectDisplayedThreadReplies( if (seen.has(evt.id)) continue if (isPollVoteKind(evt)) 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 ( opHex && @@ -101,8 +194,11 @@ export function collectDisplayedThreadReplies( const opHex = openNoteHexId(opEvent) if (!opHex) return [] + const opHexLower = opHex.toLowerCase() + /** Viewing the thread root itself (kind-1 note or a replaceable article instance). */ 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 seen = new Set() @@ -110,6 +206,11 @@ export function collectDisplayedThreadReplies( if (seen.has(evt.id)) continue if (isPollVoteKind(evt)) 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)) { 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 + 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( candidates: ReadonlyArray<{ id: string }>, - rootInfo: TRootInfo, - opEvent: NEvent, - isDiscussionRoot: boolean + opts?: HydrateThreadRepliesFromStatsOpts ): Promise { 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() - try { - const archived = await indexedDb.getArchivedEventsByIds(ids) - for (const e of archived) byId.set(e.id, e) - } catch { - /* optional */ - } - for (const id of ids) { - if (byId.has(id)) continue + + for (let i = 0; i < ids.length; i += STATS_HYDRATE_ARCHIVE_CHUNK) { + const chunk = ids.slice(i, i + STATS_HYDRATE_ARCHIVE_CHUNK) try { - const ev = await eventService.fetchEvent(id) - if (ev) byId.set(ev.id, ev) + const archived = await indexedDb.getArchivedEventsByIds(chunk) + for (const e of archived) byId.set(e.id, e) } catch { /* optional */ } } + for (const id of ids) { + 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 { + const ev = await eventService.fetchEvent(id) + if (ev) byId.set(ev.id, ev) + } catch { + /* 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[] = [] - for (const ev of byId.values()) { + for (const id of ids) { + const ev = byId.get(id) + if (!ev) continue if (isPollVoteKind(ev)) continue - if (rootInfo.type === 'I') { - if (!isRssArticleUrlThreadInteraction(ev, rootInfo.id)) continue - } else if (!replyMatchesThreadForList(ev, opEvent, rootInfo, isDiscussionRoot)) { + if ( + opts?.mutePubkeySet && + shouldHideThreadResponseEvent(ev, opts.mutePubkeySet, opts.hideContentMentioningMutedUsers) + ) { continue } batch.push(ev) diff --git a/src/services/note-stats.service.ts b/src/services/note-stats.service.ts index ce40c118..26972c48 100644 --- a/src/services/note-stats.service.ts +++ b/src/services/note-stats.service.ts @@ -877,6 +877,14 @@ class NoteStatsService { 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 * always re-renders when counts update (avoids stale UI when the map entry reference is reused or updates race mount).