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).