diff --git a/src/components/NoteInteractions/index.tsx b/src/components/NoteInteractions/index.tsx index 9af791bc..a1c8ac7d 100644 --- a/src/components/NoteInteractions/index.tsx +++ b/src/components/NoteInteractions/index.tsx @@ -11,12 +11,15 @@ import ReplySort, { ReplySortOption } from './ReplySort' export default function NoteInteractions({ pageIndex, event, - showQuotes: showQuotesProp + showQuotes: showQuotesProp, + statsForeground = false }: { pageIndex?: number event: Event /** When set, overrides the default (quotes hidden for discussions only). */ showQuotes?: boolean + /** Reply row stats use the same priority lane as the open note (`foregroundStats` on `NoteStats`). */ + statsForeground?: boolean }) { const { t } = useTranslation() const [replySort, setReplySort] = useState('oldest') @@ -53,6 +56,7 @@ export default function NoteInteractions({ event={event} sort={replySort} showQuotes={showQuotes} + statsForeground={statsForeground} /> ) diff --git a/src/components/NoteStats/LikeButton.tsx b/src/components/NoteStats/LikeButton.tsx index 765d721a..00d0717c 100644 --- a/src/components/NoteStats/LikeButton.tsx +++ b/src/components/NoteStats/LikeButton.tsx @@ -81,6 +81,9 @@ export default function LikeButton({ event, hideCount = false }: { event: Event; return { myLastEmoji: myLike?.emoji, likeCount: likes?.length, upVoteCount, downVoteCount } }, [noteStats, pubkey, hideUntrustedInteractions, showDiscussionVotes]) + /** Same idea as {@link ReplyButton}: merged likes (thread fetch / publish) can exist before snapshot sets `updatedAt`. */ + const showLikeCount = !hideCount && (statsLoaded || (likeCount ?? 0) > 0) + const like = async (emoji: string | TEmoji) => { checkLogin(async () => { if (liking || !pubkey) return @@ -90,7 +93,7 @@ export default function LikeButton({ event, hideCount = false }: { event: Event; try { if (!noteStats?.updatedAt) { - await noteStatsService.fetchNoteStats(event, pubkey, statsRelays) + await noteStatsService.fetchNoteStats(event, pubkey, statsRelays, { foreground: true }) } const emojiString = typeof emoji === 'string' ? emoji : emoji.shortcode @@ -235,7 +238,7 @@ export default function LikeButton({ event, hideCount = false }: { event: Event; ) : myLastEmoji ? ( <> - {!hideCount && statsLoaded && ( + {showLikeCount && (
{(likeCount ?? 0) >= 100 ? '99+' : String(likeCount ?? 0)}
@@ -244,7 +247,7 @@ export default function LikeButton({ event, hideCount = false }: { event: Event; ) : ( <> - {!hideCount && statsLoaded && ( + {showLikeCount && (
{(likeCount ?? 0) >= 100 ? '99+' : String(likeCount ?? 0)}
@@ -282,7 +285,7 @@ export default function LikeButton({ event, hideCount = false }: { event: Event; {arrow} - {!hideCount && noteStats?.updatedAt != null && ( + {!hideCount && (noteStats?.updatedAt != null || count > 0) && (
{count >= 100 ? '99+' : count}
diff --git a/src/components/NoteStats/RepostButton.tsx b/src/components/NoteStats/RepostButton.tsx index 2013e8b7..f82060c2 100644 --- a/src/components/NoteStats/RepostButton.tsx +++ b/src/components/NoteStats/RepostButton.tsx @@ -57,7 +57,7 @@ export default function RepostButton({ event, hideCount = false }: { event: Even const hasReposted = noteStats?.repostPubkeySet?.has(pubkey) if (hasReposted) return if (!noteStats?.updatedAt) { - await noteStatsService.fetchNoteStats(event, pubkey, statsRelays) + await noteStatsService.fetchNoteStats(event, pubkey, statsRelays, { foreground: true }) // Note: fetchNoteStats doesn't return the stats, it updates them asynchronously // The updated stats will be available through the useNoteStatsById hook } diff --git a/src/components/NoteStats/index.tsx b/src/components/NoteStats/index.tsx index ae870628..639d992f 100644 --- a/src/components/NoteStats/index.tsx +++ b/src/components/NoteStats/index.tsx @@ -23,7 +23,8 @@ export default function NoteStats({ className, classNames, fetchIfNotExisting = false, - displayTopZapsAndLikes = false + displayTopZapsAndLikes = false, + foregroundStats = false }: { event: Event className?: string @@ -32,6 +33,8 @@ export default function NoteStats({ } fetchIfNotExisting?: boolean displayTopZapsAndLikes?: boolean + /** Jump ahead of spell-feed backlog so counts resolve on the open note / article. */ + foregroundStats?: boolean }) { const { isSmallScreen } = useScreenSize() const { pubkey } = useNostr() @@ -64,10 +67,12 @@ export default function NoteStats({ hintRelayCount: statsRelays.length }) setLoading(true) - noteStatsService.fetchNoteStats(event, pubkey, statsRelays).finally(() => setLoading(false)) + noteStatsService + .fetchNoteStats(event, pubkey, statsRelays, { foreground: foregroundStats }) + .finally(() => setLoading(false)) // Intentionally omit `event` object: parent feeds often pass new references each render; // id/sig/kind/created_at identify the note for refetch boundaries. - }, [event.id, event.kind, event.created_at, event.sig, fetchIfNotExisting, pubkey, statsRelaysKey]) + }, [event.id, event.kind, event.created_at, event.sig, fetchIfNotExisting, foregroundStats, pubkey, statsRelaysKey]) if (isSmallScreen) { return ( diff --git a/src/components/ReplyNote/index.tsx b/src/components/ReplyNote/index.tsx index 2149b856..ecf36b96 100644 --- a/src/components/ReplyNote/index.tsx +++ b/src/components/ReplyNote/index.tsx @@ -44,7 +44,8 @@ export default function ReplyNote({ onClickParent = () => {}, onClickReply, highlight = false, - duplicateWebPreviewCleanedUrlHints + duplicateWebPreviewCleanedUrlHints, + foregroundStats = false }: { event: Event parentEventId?: string @@ -52,6 +53,7 @@ export default function ReplyNote({ onClickReply?: (event: Event) => void highlight?: boolean duplicateWebPreviewCleanedUrlHints?: string[] + foregroundStats?: boolean }) { const { t } = useTranslation() const { isSmallScreen } = useScreenSize() @@ -208,6 +210,7 @@ export default function ReplyNote({ event={event} displayTopZapsAndLikes={event.kind !== kinds.Zap} fetchIfNotExisting + foregroundStats={foregroundStats} /> )} diff --git a/src/components/ReplyNoteList/index.tsx b/src/components/ReplyNoteList/index.tsx index 39f9305c..32a7062d 100644 --- a/src/components/ReplyNoteList/index.tsx +++ b/src/components/ReplyNoteList/index.tsx @@ -232,6 +232,38 @@ function replyIdPresentInRepliesMap( return false } +/** NIP-25 reaction: any `e` / `E` tag value equals this hex id (lowercased). */ +function noteReactionEtagEqualsHex(ev: NEvent, hexLower: string): boolean { + const h = hexLower.trim().toLowerCase() + if (!/^[0-9a-f]{64}$/i.test(h)) return false + for (const t of ev.tags) { + if ((t[0] === 'e' || t[0] === 'E') && typeof t[1] === 'string' && t[1].toLowerCase() === h) return true + } + return false +} + +/** + * Thread REQ historically omitted kind 7; {@link replyMatchesThreadForList} also drops reactions from the reply list. + * Reactions still need to merge into {@link noteStatsService} for the root so the note header matches notifications. + */ +function mergeFetchedKind7ReactionsIntoRootNoteStats(all: NEvent[], rootInfo: TRootInfo) { + if (rootInfo.type === 'E') { + const rootHex = rootInfo.id.trim().toLowerCase() + const hits = all.filter((ev) => ev.kind === kinds.Reaction && noteReactionEtagEqualsHex(ev, rootHex)) + if (hits.length > 0) { + noteStatsService.updateNoteStatsByEvents(hits, undefined, { interactionTargetNoteId: rootInfo.id }) + } + } else if (rootInfo.type === 'A') { + const idHex = rootInfo.eventId?.trim().toLowerCase() + if (idHex && /^[0-9a-f]{64}$/i.test(idHex)) { + const hits = all.filter((ev) => ev.kind === kinds.Reaction && noteReactionEtagEqualsHex(ev, idHex)) + if (hits.length > 0) { + noteStatsService.updateNoteStatsByEvents(hits, undefined, { interactionTargetNoteId: rootInfo.eventId }) + } + } + } +} + function replyMatchesThreadForList( evt: NEvent, opEvent: NEvent, @@ -292,7 +324,8 @@ function ReplyNoteList({ event, sort = 'oldest', showQuotes = true, - duplicateWebPreviewCleanedUrlHints + duplicateWebPreviewCleanedUrlHints, + statsForeground = false }: { index?: number event: NEvent @@ -301,6 +334,8 @@ function ReplyNoteList({ showQuotes?: boolean /** Suppress WebPreview for these URLs in replies (e.g. article URL already shown as OP). */ duplicateWebPreviewCleanedUrlHints?: string[] + /** Passed through to reply row `NoteStats` on note & article pages. */ + statsForeground?: boolean }) { const { t } = useTranslation() const { navigateToNote } = useSmartNoteNavigation() @@ -1042,7 +1077,8 @@ function ReplyNoteList({ const filters: Filter[] = [] if (rootInfo.type === 'E') { - // Fetch all reply types for event-based replies + // 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). filters.push({ '#e': [rootInfo.id], kinds: [kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT, kinds.Zap], @@ -1054,6 +1090,11 @@ function ReplyNoteList({ kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT, kinds.Zap], limit: LIMIT }) + filters.push({ + '#e': [rootInfo.id], + kinds: [kinds.Reaction], + limit: LIMIT + }) // Kind-1 notes that quote via #q without e-tags (still part of this thread) filters.push({ '#q': [rootInfo.id], @@ -1082,6 +1123,13 @@ function ReplyNoteList({ limit: LIMIT } ) + if (/^[0-9a-f]{64}$/i.test(rootInfo.eventId)) { + filters.push({ + '#e': [rootInfo.eventId], + kinds: [kinds.Reaction], + limit: LIMIT + }) + } const qVals = Array.from( new Set( [rootInfo.eventId, rootInfo.id] @@ -1126,6 +1174,8 @@ function ReplyNoteList({ if (fetchGeneration !== replyFetchGenRef.current) return + mergeFetchedKind7ReactionsIntoRootNoteStats(allReplies, rootInfo) + // Filter and add replies (URL threads include kind 9802 highlights of this page) const regularReplies = allReplies.filter((evt) => { const match = replyMatchesThreadForList(evt, event, rootInfo, isDiscussionRoot) @@ -1441,6 +1491,7 @@ function ReplyNoteList({ event={reply} parentEventId={event.id !== parentEventHexId ? parentEventId : undefined} duplicateWebPreviewCleanedUrlHints={replyDuplicateWebPreviewHints} + foregroundStats={statsForeground} onClickParent={() => { if (!parentEventHexId) return if (replies.every((r) => r.id !== parentEventHexId)) { diff --git a/src/components/RssUrlThreadStatsBar/index.tsx b/src/components/RssUrlThreadStatsBar/index.tsx index 0a7333e3..f223e134 100644 --- a/src/components/RssUrlThreadStatsBar/index.tsx +++ b/src/components/RssUrlThreadStatsBar/index.tsx @@ -25,7 +25,9 @@ export default function RssUrlThreadStatsBar({ useEffect(() => { setLoading(true) - noteStatsService.fetchNoteStats(event, pubkey, statsRelays).finally(() => setLoading(false)) + noteStatsService + .fetchNoteStats(event, pubkey, statsRelays, { foreground: true }) + .finally(() => setLoading(false)) }, [event.id, event.kind, event.created_at, event.sig, pubkey, statsRelaysKey]) const fmt = (n: number) => (n >= 100 ? '99+' : String(n)) diff --git a/src/pages/secondary/NotePage/index.tsx b/src/pages/secondary/NotePage/index.tsx index 4dac4d06..50d2a2b2 100644 --- a/src/pages/secondary/NotePage/index.tsx +++ b/src/pages/secondary/NotePage/index.tsx @@ -515,11 +515,22 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }: } /> - +
- +
) diff --git a/src/pages/secondary/RssArticlePage/index.tsx b/src/pages/secondary/RssArticlePage/index.tsx index 77143e96..902c2e0a 100644 --- a/src/pages/secondary/RssArticlePage/index.tsx +++ b/src/pages/secondary/RssArticlePage/index.tsx @@ -299,7 +299,13 @@ const RssArticlePage = forwardRef( ) : null} {showNostrThread && syntheticRoot ? (
- +
) : null} {showNostrThread ? : null} @@ -310,6 +316,7 @@ const RssArticlePage = forwardRef( pageIndex={index} event={syntheticRoot} showQuotes={false} + statsForeground /> ) : null} @@ -388,7 +395,13 @@ const RssArticlePage = forwardRef( {showNostrThread && syntheticRoot ? (
- +
) : null} {showNostrThread ? : null} @@ -399,6 +412,7 @@ const RssArticlePage = forwardRef( pageIndex={index} event={syntheticRoot} showQuotes={false} + statsForeground /> ) : null} diff --git a/src/services/note-stats.service.ts b/src/services/note-stats.service.ts index 3c5b3256..60de9243 100644 --- a/src/services/note-stats.service.ts +++ b/src/services/note-stats.service.ts @@ -63,6 +63,10 @@ class NoteStatsService { // Batch processing private pendingEvents = new Set() + /** Open note / explicit UI: drained before {@link pendingEvents} so detail pages are not stuck behind feed cards. */ + private pendingForeground = new Set() + /** If a foreground fetch hit {@link processingCache}, re-queue here so the follow-up run uses the priority lane. */ + private deferredRequeueForeground = new Set() /** Favorite relays passed from the last fetchNoteStats call per note (used in processSingleEvent). */ private pendingFetchFavoriteRelays = new Map() /** Merged favorite URLs requested while this note was already in {@link processingCache}. */ @@ -112,31 +116,87 @@ class NoteStatsService { }, this.BATCH_DELAY) } - async fetchNoteStats(event: Event, _pubkey?: string | null, favoriteRelays?: string[] | null) { + private statsPendingSize() { + return this.pendingForeground.size + this.pendingEvents.size + } + + /** Up to {@link MAX_BATCH_SIZE} ids, foreground queue first (same insertion order within each set). */ + private takeNextStatsSlice(): string[] { + const out: string[] = [] + for (const id of this.pendingForeground) { + if (out.length >= this.MAX_BATCH_SIZE) break + this.pendingForeground.delete(id) + out.push(id) + } + for (const id of this.pendingEvents) { + if (out.length >= this.MAX_BATCH_SIZE) break + this.pendingEvents.delete(id) + out.push(id) + } + return out + } + + /** Coalesce scroll bursts; flush immediately when backlog is large or a foreground note was queued. */ + private maybeFlushStatsBatch(foreground: boolean) { + if (this.processBatchRunning) { + return + } + const backlogLarge = this.pendingEvents.size >= this.MAX_BATCH_SIZE + if (backlogLarge || foreground) { + if (this.batchTimeout) { + clearTimeout(this.batchTimeout) + this.batchTimeout = null + } + void this.processBatch() + } else { + this.armStatsBatchTimer() + } + } + + /** + * Queue relay-backed stats for `event`. Foreground (`opts.foreground`) is for the focused note page / + * article detail so counts are not starved behind spell-feed cards (large pending backlog). + */ + async fetchNoteStats( + event: Event, + _pubkey?: string | null, + favoriteRelays?: string[] | null, + opts?: { foreground?: boolean } + ) { const eventId = this.statsKey(event.id) const idShort = `${eventId.slice(0, 12)}…` + const foreground = opts?.foreground === true - if (this.pendingEvents.has(eventId)) { - this.mergeFavoriteRelaysIntoPending(eventId, favoriteRelays) + const rememberRoot = () => { if (event.kind === ExtendedKind.RSS_THREAD_ROOT) { this.pendingSyntheticRootById.set(eventId, event) } else { this.pendingStatsRootEventById.set(eventId, event) } + } + + if (this.pendingEvents.has(eventId) || this.pendingForeground.has(eventId)) { + this.mergeFavoriteRelaysIntoPending(eventId, favoriteRelays) + rememberRoot() + if (foreground) { + this.pendingEvents.delete(eventId) + this.pendingForeground.add(eventId) + } logger.debug('[NoteStats] fetchNoteStats: merged into existing pending batch', { eventId: idShort, kind: event.kind, - pendingSize: this.pendingEvents.size + pendingForeground: this.pendingForeground.size, + pendingBackground: this.pendingEvents.size }) + this.maybeFlushStatsBatch(foreground) return } if (this.processingCache.has(eventId)) { this.mergeFavoriteRelaysIntoDeferred(eventId, favoriteRelays) - if (event.kind === ExtendedKind.RSS_THREAD_ROOT) { - this.pendingSyntheticRootById.set(eventId, event) - } else { - this.pendingStatsRootEventById.set(eventId, event) + rememberRoot() + if (foreground) { + this.deferredRequeueForeground.add(eventId) } logger.debug('[NoteStats] fetchNoteStats: deferred (already processing same id)', { eventId: idShort, @@ -146,42 +206,48 @@ class NoteStatsService { } this.pendingFetchFavoriteRelays.set(eventId, favoriteRelays ?? null) - this.pendingEvents.add(eventId) - if (event.kind === ExtendedKind.RSS_THREAD_ROOT) { - this.pendingSyntheticRootById.set(eventId, event) + if (foreground) { + this.pendingForeground.add(eventId) } else { - this.pendingStatsRootEventById.set(eventId, event) + this.pendingEvents.add(eventId) } + rememberRoot() logger.debug('[NoteStats] fetchNoteStats: queued new id', { eventId: idShort, kind: event.kind, - pendingSize: this.pendingEvents.size, - immediateBatch: this.pendingEvents.size >= this.MAX_BATCH_SIZE + foreground, + pendingForeground: this.pendingForeground.size, + pendingBackground: this.pendingEvents.size, + immediateBatch: this.statsPendingSize() >= this.MAX_BATCH_SIZE }) - this.armStatsBatchTimer() - if (this.pendingEvents.size >= this.MAX_BATCH_SIZE && !this.processBatchRunning) { - if (this.batchTimeout) { - clearTimeout(this.batchTimeout) - this.batchTimeout = null - } + this.maybeFlushStatsBatch(foreground) + } + + private scheduleStatsBatchContinuation() { + if (this.pendingForeground.size === 0 && this.pendingEvents.size === 0) return + queueMicrotask(() => { void this.processBatch() - } + }) } private async processBatch() { if (this.processBatchRunning) { logger.debug('[NoteStats] processBatch: skipped (already running)', { - pendingSize: this.pendingEvents.size + pendingForeground: this.pendingForeground.size, + pendingBackground: this.pendingEvents.size }) return } - if (this.pendingEvents.size === 0) { + if (this.pendingForeground.size === 0 && this.pendingEvents.size === 0) { return } - logger.info('[NoteStats] processBatch: running', { pendingSize: this.pendingEvents.size }) + logger.info('[NoteStats] processBatch: running', { + pendingForeground: this.pendingForeground.size, + pendingBackground: this.pendingEvents.size + }) this.processBatchRunning = true if (this.batchTimeout) { clearTimeout(this.batchTimeout) @@ -189,26 +255,22 @@ class NoteStatsService { } try { - while (this.pendingEvents.size > 0) { - const eventsToProcess = Array.from(this.pendingEvents).slice(0, this.MAX_BATCH_SIZE) - for (const id of eventsToProcess) { - this.pendingEvents.delete(id) - } - logger.info('[NoteStats] processBatch slice', { - count: eventsToProcess.length, - ids: eventsToProcess.map((id) => `${id.slice(0, 12)}…`), - remainingPending: this.pendingEvents.size, - concurrency: this.STATS_SLICE_CONCURRENCY - }) - for (let i = 0; i < eventsToProcess.length; i += this.STATS_SLICE_CONCURRENCY) { - const chunk = eventsToProcess.slice(i, i + this.STATS_SLICE_CONCURRENCY) - await Promise.all(chunk.map((eventId) => this.processSingleEvent(eventId))) - } + const eventsToProcess = this.takeNextStatsSlice() + logger.info('[NoteStats] processBatch slice', { + count: eventsToProcess.length, + ids: eventsToProcess.map((id) => `${id.slice(0, 12)}…`), + remainingForeground: this.pendingForeground.size, + remainingBackground: this.pendingEvents.size, + concurrency: this.STATS_SLICE_CONCURRENCY + }) + for (let i = 0; i < eventsToProcess.length; i += this.STATS_SLICE_CONCURRENCY) { + const chunk = eventsToProcess.slice(i, i + this.STATS_SLICE_CONCURRENCY) + await Promise.all(chunk.map((eventId) => this.processSingleEvent(eventId))) } } finally { this.processBatchRunning = false - if (this.pendingEvents.size > 0) { - this.armStatsBatchTimer() + if (this.pendingForeground.size > 0 || this.pendingEvents.size > 0) { + this.scheduleStatsBatchContinuation() } } } @@ -327,12 +389,18 @@ class NoteStatsService { if (this.inFlightDeferredFavoriteRelays.has(eventId)) { const deferred = this.inFlightDeferredFavoriteRelays.get(eventId)! this.inFlightDeferredFavoriteRelays.delete(eventId) + const requeueForeground = this.deferredRequeueForeground.has(eventId) + this.deferredRequeueForeground.delete(eventId) if (deferred.length > 0) { - if (this.pendingEvents.has(eventId)) { + if (this.pendingEvents.has(eventId) || this.pendingForeground.has(eventId)) { this.mergeFavoriteRelaysIntoPending(eventId, deferred) } else { this.pendingFetchFavoriteRelays.set(eventId, deferred) - this.pendingEvents.add(eventId) + if (requeueForeground) { + this.pendingForeground.add(eventId) + } else { + this.pendingEvents.add(eventId) + } } } }