diff --git a/src/components/NoteStats/DiscussionNoteStats.tsx b/src/components/NoteStats/DiscussionNoteStats.tsx index 884e45ab..e907b958 100644 --- a/src/components/NoteStats/DiscussionNoteStats.tsx +++ b/src/components/NoteStats/DiscussionNoteStats.tsx @@ -1,4 +1,5 @@ import { cn } from '@/lib/utils' +import { useNoteStatsRelayHints } from '@/hooks/useNoteStatsRelayHints' import { useNostr } from '@/providers/NostrProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider' import noteStatsService from '@/services/note-stats.service' @@ -21,13 +22,14 @@ export default function DiscussionNoteStats({ }) { const { isSmallScreen } = useScreenSize() const { pubkey } = useNostr() + const { relays: statsRelays, key: statsRelaysKey } = useNoteStatsRelayHints() const [loading, setLoading] = useState(false) useEffect(() => { if (!fetchIfNotExisting) return setLoading(true) - noteStatsService.fetchNoteStats(event, pubkey).finally(() => setLoading(false)) - }, [event, fetchIfNotExisting]) + noteStatsService.fetchNoteStats(event, pubkey, statsRelays).finally(() => setLoading(false)) + }, [event.id, event.kind, event.created_at, event.sig, fetchIfNotExisting, pubkey, statsRelaysKey]) if (isSmallScreen) { return ( diff --git a/src/components/NoteStats/LikeButton.tsx b/src/components/NoteStats/LikeButton.tsx index b9d28a7d..5c5d1701 100644 --- a/src/components/NoteStats/LikeButton.tsx +++ b/src/components/NoteStats/LikeButton.tsx @@ -19,6 +19,7 @@ import { isDiscussionUpvoteEmoji, isDiscussionVoteEmoji } from '@/lib/discussion-votes' +import { useNoteStatsRelayHints } from '@/hooks/useNoteStatsRelayHints' import { useNostr } from '@/providers/NostrProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useUserTrust } from '@/contexts/user-trust-context' @@ -40,6 +41,7 @@ export default function LikeButton({ event, hideCount = false }: { event: Event; const { t } = useTranslation() const { isSmallScreen } = useScreenSize() const { pubkey, publish, checkLogin } = useNostr() + const { relays: statsRelays } = useNoteStatsRelayHints() const { hideUntrustedInteractions, isUserTrusted } = useUserTrust() const [liking, setLiking] = useState(false) const [isEmojiReactionsOpen, setIsEmojiReactionsOpen] = useState(false) @@ -81,7 +83,7 @@ export default function LikeButton({ event, hideCount = false }: { event: Event; try { if (!noteStats?.updatedAt) { - await noteStatsService.fetchNoteStats(event, pubkey) + await noteStatsService.fetchNoteStats(event, pubkey, statsRelays) } const emojiString = typeof emoji === 'string' ? emoji : emoji.shortcode diff --git a/src/components/NoteStats/RepostButton.tsx b/src/components/NoteStats/RepostButton.tsx index 6601c78f..71990e01 100644 --- a/src/components/NoteStats/RepostButton.tsx +++ b/src/components/NoteStats/RepostButton.tsx @@ -11,6 +11,7 @@ import { useNoteStatsById } from '@/hooks/useNoteStatsById' import { createRepostDraftEvent } from '@/lib/draft-event' import { getNoteBech32Id } from '@/lib/event' import { cn } from '@/lib/utils' +import { useNoteStatsRelayHints } from '@/hooks/useNoteStatsRelayHints' import { useNostr } from '@/providers/NostrProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useUserTrust } from '@/contexts/user-trust-context' @@ -29,6 +30,7 @@ export default function RepostButton({ event, hideCount = false }: { event: Even const { isSmallScreen } = useScreenSize() const { hideUntrustedInteractions, isUserTrusted } = useUserTrust() const { publish, checkLogin, pubkey } = useNostr() + const { relays: statsRelays } = useNoteStatsRelayHints() const noteStats = useNoteStatsById(event.id) as import('@/services/note-stats.service').TNoteStats | undefined const [reposting, setReposting] = useState(false) const [isPostDialogOpen, setIsPostDialogOpen] = useState(false) @@ -54,7 +56,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) + await noteStatsService.fetchNoteStats(event, pubkey, statsRelays) // 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/VoteButtons.tsx b/src/components/NoteStats/VoteButtons.tsx index 7e00d55d..9680e8d9 100644 --- a/src/components/NoteStats/VoteButtons.tsx +++ b/src/components/NoteStats/VoteButtons.tsx @@ -6,6 +6,7 @@ import { isDiscussionUpvoteEmoji } from '@/lib/discussion-votes' import { createReactionDraftEvent } from '@/lib/draft-event' +import { useNoteStatsRelayHints } from '@/hooks/useNoteStatsRelayHints' import { useNostr } from '@/providers/NostrProvider' import noteStatsService from '@/services/note-stats.service' import { Event } from 'nostr-tools' @@ -19,6 +20,7 @@ import { useTranslation } from 'react-i18next' export default function VoteButtons({ event }: { event: Event }) { const { t } = useTranslation() const { pubkey, publish, checkLogin } = useNostr() + const { relays: statsRelays } = useNoteStatsRelayHints() const [voting, setVoting] = useState(null) const noteStats = useNoteStatsById(event.id) @@ -59,7 +61,7 @@ export default function VoteButtons({ event }: { event: Event }) { try { if (!noteStats?.updatedAt) { - await noteStatsService.fetchNoteStats(event, pubkey) + await noteStatsService.fetchNoteStats(event, pubkey, statsRelays) } // Create the vote reaction diff --git a/src/components/NoteStats/index.tsx b/src/components/NoteStats/index.tsx index 70da99c2..d8d5f075 100644 --- a/src/components/NoteStats/index.tsx +++ b/src/components/NoteStats/index.tsx @@ -1,7 +1,7 @@ import { cn } from '@/lib/utils' import { useNostr } from '@/providers/NostrProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider' -import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' +import { useNoteStatsRelayHints } from '@/hooks/useNoteStatsRelayHints' import noteStatsService from '@/services/note-stats.service' import { ExtendedKind } from '@/constants' import { useReplyUnderDiscussionRoot } from '@/hooks/useReplyUnderDiscussionRoot' @@ -33,7 +33,7 @@ export default function NoteStats({ }) { const { isSmallScreen } = useScreenSize() const { pubkey } = useNostr() - const { favoriteRelays } = useFavoriteRelays() + const { relays: statsRelays, key: statsRelaysKey } = useNoteStatsRelayHints() const [loading, setLoading] = useState(false) // Hide boost button for discussion events and replies to discussions @@ -52,8 +52,10 @@ export default function NoteStats({ useEffect(() => { if (!fetchIfNotExisting) return setLoading(true) - noteStatsService.fetchNoteStats(event, pubkey, favoriteRelays).finally(() => setLoading(false)) - }, [event, fetchIfNotExisting]) + noteStatsService.fetchNoteStats(event, pubkey, statsRelays).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]) if (isSmallScreen) { return ( diff --git a/src/hooks/useNoteStatsRelayHints.ts b/src/hooks/useNoteStatsRelayHints.ts new file mode 100644 index 00000000..352a913f --- /dev/null +++ b/src/hooks/useNoteStatsRelayHints.ts @@ -0,0 +1,18 @@ +import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' +import { useCurrentRelayUrlsOptional } from '@/providers/CurrentRelaysProvider' +import { useMemo } from 'react' + +/** + * Relays to hint for note-stats REQ construction: user favorites plus any “current” relays + * (e.g. single-relay feed), deduped. + */ +export function useNoteStatsRelayHints(): { relays: string[]; key: string } { + const { favoriteRelays } = useFavoriteRelays() + const currentRelayUrls = useCurrentRelayUrlsOptional() + + return useMemo(() => { + const relays = [...new Set([...(favoriteRelays ?? []), ...currentRelayUrls])] + const key = relays.slice().sort().join('|') + return { relays, key } + }, [favoriteRelays, currentRelayUrls]) +} diff --git a/src/providers/CurrentRelaysProvider.tsx b/src/providers/CurrentRelaysProvider.tsx index 538a13a5..ab723734 100644 --- a/src/providers/CurrentRelaysProvider.tsx +++ b/src/providers/CurrentRelaysProvider.tsx @@ -16,6 +16,11 @@ export const useCurrentRelays = () => { return context } +/** Same URLs as {@link useCurrentRelays} but safe when no provider (e.g. tests, embedded previews). */ +export function useCurrentRelayUrlsOptional(): string[] { + return useContext(CurrentRelaysContext)?.relayUrls ?? [] +} + export function CurrentRelaysProvider({ children }: { children: React.ReactNode }) { const [relayRefCount, setRelayRefCount] = useState>({}) const relayUrls = useMemo(() => Object.keys(relayRefCount), [relayRefCount]) diff --git a/src/services/note-stats.service.ts b/src/services/note-stats.service.ts index b3392456..827701f4 100644 --- a/src/services/note-stats.service.ts +++ b/src/services/note-stats.service.ts @@ -36,15 +36,18 @@ class NoteStatsService { private noteStatsMap: Map> = new Map() private noteStatsSubscribers = new Map void>>() private processingCache = new Set() - private lastProcessedTime = new Map() - + // Batch processing private pendingEvents = 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}. */ + private inFlightDeferredFavoriteRelays = new Map() private batchTimeout: NodeJS.Timeout | null = null - private readonly BATCH_DELAY = 1000 // 1 second batch delay - private readonly MAX_BATCH_SIZE = 10 // Process up to 10 events at once + /** Prevents overlapping processBatch runs (reentrant calls corrupted pendingEvents). */ + private processBatchRunning = false + private readonly BATCH_DELAY = 200 + private readonly MAX_BATCH_SIZE = 24 constructor() { if (!NoteStatsService.instance) { @@ -53,56 +56,84 @@ class NoteStatsService { return NoteStatsService.instance } + /** Merge extra relay URLs into the pending fetch context for this note (deduped). */ + private mergeFavoriteRelaysIntoPending(eventId: string, extra: string[] | null | undefined) { + if (!extra?.length) return + const cur = this.pendingFetchFavoriteRelays.get(eventId) + const merged = new Set([...(cur ?? []), ...extra]) + this.pendingFetchFavoriteRelays.set(eventId, [...merged]) + } + + private mergeFavoriteRelaysIntoDeferred(eventId: string, extra: string[] | null | undefined) { + if (!extra?.length) return + const cur = this.inFlightDeferredFavoriteRelays.get(eventId) + const merged = new Set([...(cur ?? []), ...extra]) + this.inFlightDeferredFavoriteRelays.set(eventId, [...merged]) + } + + private armStatsBatchTimer() { + if (this.batchTimeout) { + clearTimeout(this.batchTimeout) + } + this.batchTimeout = setTimeout(() => { + this.batchTimeout = null + void this.processBatch() + }, this.BATCH_DELAY) + } + async fetchNoteStats(event: Event, _pubkey?: string | null, favoriteRelays?: string[] | null) { const eventId = event.id - - // Rate limiting: Don't process the same event more than once per 10 seconds - const now = Date.now() - const lastProcessed = this.lastProcessedTime.get(eventId) - if (lastProcessed && now - lastProcessed < 10000) { - logger.debug('[NoteStats] Skipping duplicate fetch for event', eventId.substring(0, 8), 'too soon') + + if (this.pendingEvents.has(eventId)) { + this.mergeFavoriteRelaysIntoPending(eventId, favoriteRelays) + return + } + + if (this.processingCache.has(eventId)) { + this.mergeFavoriteRelaysIntoDeferred(eventId, favoriteRelays) return } this.pendingFetchFavoriteRelays.set(eventId, favoriteRelays ?? null) this.pendingEvents.add(eventId) - this.lastProcessedTime.set(eventId, now) - - // Clear existing timeout and set new one - if (this.batchTimeout) { - clearTimeout(this.batchTimeout) - } - - this.batchTimeout = setTimeout(() => { - this.processBatch() - }, this.BATCH_DELAY) - - // If we have enough events or this is urgent, process immediately - if (this.pendingEvents.size >= this.MAX_BATCH_SIZE) { - this.processBatch() + + this.armStatsBatchTimer() + if (this.pendingEvents.size >= this.MAX_BATCH_SIZE && !this.processBatchRunning) { + if (this.batchTimeout) { + clearTimeout(this.batchTimeout) + this.batchTimeout = null + } + void this.processBatch() } } private async processBatch() { - if (this.pendingEvents.size === 0) return - - const eventsToProcess = Array.from(this.pendingEvents).slice(0, this.MAX_BATCH_SIZE) - for (const id of eventsToProcess) { - this.pendingEvents.delete(id) + if (this.processBatchRunning) { + return + } + if (this.pendingEvents.size === 0) { + return } + this.processBatchRunning = true if (this.batchTimeout) { clearTimeout(this.batchTimeout) this.batchTimeout = null } - await Promise.all(eventsToProcess.map((eventId) => this.processSingleEvent(eventId))) - - if (this.pendingEvents.size > 0) { - this.batchTimeout = setTimeout(() => { - this.batchTimeout = null - this.processBatch() - }, this.BATCH_DELAY) + 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) + } + await Promise.all(eventsToProcess.map((eventId) => this.processSingleEvent(eventId))) + } + } finally { + this.processBatchRunning = false + if (this.pendingEvents.size > 0) { + this.armStatsBatchTimer() + } } } @@ -154,6 +185,18 @@ class NoteStatsService { this.notifyNoteStats(event.id) } finally { this.processingCache.delete(eventId) + if (this.inFlightDeferredFavoriteRelays.has(eventId)) { + const deferred = this.inFlightDeferredFavoriteRelays.get(eventId)! + this.inFlightDeferredFavoriteRelays.delete(eventId) + if (deferred.length > 0) { + if (this.pendingEvents.has(eventId)) { + this.mergeFavoriteRelaysIntoPending(eventId, deferred) + } else { + this.pendingFetchFavoriteRelays.set(eventId, deferred) + this.pendingEvents.add(eventId) + } + } + } } }