Browse Source

fix note stats everywhere

imwald
Silberengel 1 month ago
parent
commit
9a51a927b9
  1. 6
      src/components/NoteStats/DiscussionNoteStats.tsx
  2. 4
      src/components/NoteStats/LikeButton.tsx
  3. 4
      src/components/NoteStats/RepostButton.tsx
  4. 4
      src/components/NoteStats/VoteButtons.tsx
  5. 10
      src/components/NoteStats/index.tsx
  6. 18
      src/hooks/useNoteStatsRelayHints.ts
  7. 5
      src/providers/CurrentRelaysProvider.tsx
  8. 115
      src/services/note-stats.service.ts

6
src/components/NoteStats/DiscussionNoteStats.tsx

@ -1,4 +1,5 @@
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useNoteStatsRelayHints } from '@/hooks/useNoteStatsRelayHints'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import noteStatsService from '@/services/note-stats.service' import noteStatsService from '@/services/note-stats.service'
@ -21,13 +22,14 @@ export default function DiscussionNoteStats({
}) { }) {
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const { pubkey } = useNostr() const { pubkey } = useNostr()
const { relays: statsRelays, key: statsRelaysKey } = useNoteStatsRelayHints()
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
useEffect(() => { useEffect(() => {
if (!fetchIfNotExisting) return if (!fetchIfNotExisting) return
setLoading(true) setLoading(true)
noteStatsService.fetchNoteStats(event, pubkey).finally(() => setLoading(false)) noteStatsService.fetchNoteStats(event, pubkey, statsRelays).finally(() => setLoading(false))
}, [event, fetchIfNotExisting]) }, [event.id, event.kind, event.created_at, event.sig, fetchIfNotExisting, pubkey, statsRelaysKey])
if (isSmallScreen) { if (isSmallScreen) {
return ( return (

4
src/components/NoteStats/LikeButton.tsx

@ -19,6 +19,7 @@ import {
isDiscussionUpvoteEmoji, isDiscussionUpvoteEmoji,
isDiscussionVoteEmoji isDiscussionVoteEmoji
} from '@/lib/discussion-votes' } from '@/lib/discussion-votes'
import { useNoteStatsRelayHints } from '@/hooks/useNoteStatsRelayHints'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useUserTrust } from '@/contexts/user-trust-context' import { useUserTrust } from '@/contexts/user-trust-context'
@ -40,6 +41,7 @@ export default function LikeButton({ event, hideCount = false }: { event: Event;
const { t } = useTranslation() const { t } = useTranslation()
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const { pubkey, publish, checkLogin } = useNostr() const { pubkey, publish, checkLogin } = useNostr()
const { relays: statsRelays } = useNoteStatsRelayHints()
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust() const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
const [liking, setLiking] = useState(false) const [liking, setLiking] = useState(false)
const [isEmojiReactionsOpen, setIsEmojiReactionsOpen] = useState(false) const [isEmojiReactionsOpen, setIsEmojiReactionsOpen] = useState(false)
@ -81,7 +83,7 @@ export default function LikeButton({ event, hideCount = false }: { event: Event;
try { try {
if (!noteStats?.updatedAt) { if (!noteStats?.updatedAt) {
await noteStatsService.fetchNoteStats(event, pubkey) await noteStatsService.fetchNoteStats(event, pubkey, statsRelays)
} }
const emojiString = typeof emoji === 'string' ? emoji : emoji.shortcode const emojiString = typeof emoji === 'string' ? emoji : emoji.shortcode

4
src/components/NoteStats/RepostButton.tsx

@ -11,6 +11,7 @@ import { useNoteStatsById } from '@/hooks/useNoteStatsById'
import { createRepostDraftEvent } from '@/lib/draft-event' import { createRepostDraftEvent } from '@/lib/draft-event'
import { getNoteBech32Id } from '@/lib/event' import { getNoteBech32Id } from '@/lib/event'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useNoteStatsRelayHints } from '@/hooks/useNoteStatsRelayHints'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useUserTrust } from '@/contexts/user-trust-context' import { useUserTrust } from '@/contexts/user-trust-context'
@ -29,6 +30,7 @@ export default function RepostButton({ event, hideCount = false }: { event: Even
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust() const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
const { publish, checkLogin, pubkey } = useNostr() const { publish, checkLogin, pubkey } = useNostr()
const { relays: statsRelays } = useNoteStatsRelayHints()
const noteStats = useNoteStatsById(event.id) as import('@/services/note-stats.service').TNoteStats | undefined const noteStats = useNoteStatsById(event.id) as import('@/services/note-stats.service').TNoteStats | undefined
const [reposting, setReposting] = useState(false) const [reposting, setReposting] = useState(false)
const [isPostDialogOpen, setIsPostDialogOpen] = 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) const hasReposted = noteStats?.repostPubkeySet?.has(pubkey)
if (hasReposted) return if (hasReposted) return
if (!noteStats?.updatedAt) { 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 // Note: fetchNoteStats doesn't return the stats, it updates them asynchronously
// The updated stats will be available through the useNoteStatsById hook // The updated stats will be available through the useNoteStatsById hook
} }

4
src/components/NoteStats/VoteButtons.tsx

@ -6,6 +6,7 @@ import {
isDiscussionUpvoteEmoji isDiscussionUpvoteEmoji
} from '@/lib/discussion-votes' } from '@/lib/discussion-votes'
import { createReactionDraftEvent } from '@/lib/draft-event' import { createReactionDraftEvent } from '@/lib/draft-event'
import { useNoteStatsRelayHints } from '@/hooks/useNoteStatsRelayHints'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import noteStatsService from '@/services/note-stats.service' import noteStatsService from '@/services/note-stats.service'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
@ -19,6 +20,7 @@ import { useTranslation } from 'react-i18next'
export default function VoteButtons({ event }: { event: Event }) { export default function VoteButtons({ event }: { event: Event }) {
const { t } = useTranslation() const { t } = useTranslation()
const { pubkey, publish, checkLogin } = useNostr() const { pubkey, publish, checkLogin } = useNostr()
const { relays: statsRelays } = useNoteStatsRelayHints()
const [voting, setVoting] = useState<string | null>(null) const [voting, setVoting] = useState<string | null>(null)
const noteStats = useNoteStatsById(event.id) const noteStats = useNoteStatsById(event.id)
@ -59,7 +61,7 @@ export default function VoteButtons({ event }: { event: Event }) {
try { try {
if (!noteStats?.updatedAt) { if (!noteStats?.updatedAt) {
await noteStatsService.fetchNoteStats(event, pubkey) await noteStatsService.fetchNoteStats(event, pubkey, statsRelays)
} }
// Create the vote reaction // Create the vote reaction

10
src/components/NoteStats/index.tsx

@ -1,7 +1,7 @@
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useNoteStatsRelayHints } from '@/hooks/useNoteStatsRelayHints'
import noteStatsService from '@/services/note-stats.service' import noteStatsService from '@/services/note-stats.service'
import { ExtendedKind } from '@/constants' import { ExtendedKind } from '@/constants'
import { useReplyUnderDiscussionRoot } from '@/hooks/useReplyUnderDiscussionRoot' import { useReplyUnderDiscussionRoot } from '@/hooks/useReplyUnderDiscussionRoot'
@ -33,7 +33,7 @@ export default function NoteStats({
}) { }) {
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const { pubkey } = useNostr() const { pubkey } = useNostr()
const { favoriteRelays } = useFavoriteRelays() const { relays: statsRelays, key: statsRelaysKey } = useNoteStatsRelayHints()
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
// Hide boost button for discussion events and replies to discussions // Hide boost button for discussion events and replies to discussions
@ -52,8 +52,10 @@ export default function NoteStats({
useEffect(() => { useEffect(() => {
if (!fetchIfNotExisting) return if (!fetchIfNotExisting) return
setLoading(true) setLoading(true)
noteStatsService.fetchNoteStats(event, pubkey, favoriteRelays).finally(() => setLoading(false)) noteStatsService.fetchNoteStats(event, pubkey, statsRelays).finally(() => setLoading(false))
}, [event, fetchIfNotExisting]) // 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) { if (isSmallScreen) {
return ( return (

18
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])
}

5
src/providers/CurrentRelaysProvider.tsx

@ -16,6 +16,11 @@ export const useCurrentRelays = () => {
return context 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 }) { export function CurrentRelaysProvider({ children }: { children: React.ReactNode }) {
const [relayRefCount, setRelayRefCount] = useState<Record<string, number>>({}) const [relayRefCount, setRelayRefCount] = useState<Record<string, number>>({})
const relayUrls = useMemo(() => Object.keys(relayRefCount), [relayRefCount]) const relayUrls = useMemo(() => Object.keys(relayRefCount), [relayRefCount])

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

@ -36,15 +36,18 @@ class NoteStatsService {
private noteStatsMap: Map<string, Partial<TNoteStats>> = new Map() private noteStatsMap: Map<string, Partial<TNoteStats>> = new Map()
private noteStatsSubscribers = new Map<string, Set<() => void>>() private noteStatsSubscribers = new Map<string, Set<() => void>>()
private processingCache = new Set<string>() private processingCache = new Set<string>()
private lastProcessedTime = new Map<string, number>()
// Batch processing // Batch processing
private pendingEvents = new Set<string>() private pendingEvents = new Set<string>()
/** Favorite relays passed from the last fetchNoteStats call per note (used in processSingleEvent). */ /** Favorite relays passed from the last fetchNoteStats call per note (used in processSingleEvent). */
private pendingFetchFavoriteRelays = new Map<string, string[] | null | undefined>() private pendingFetchFavoriteRelays = new Map<string, string[] | null | undefined>()
/** Merged favorite URLs requested while this note was already in {@link processingCache}. */
private inFlightDeferredFavoriteRelays = new Map<string, string[]>()
private batchTimeout: NodeJS.Timeout | null = null private batchTimeout: NodeJS.Timeout | null = null
private readonly BATCH_DELAY = 1000 // 1 second batch delay /** Prevents overlapping processBatch runs (reentrant calls corrupted pendingEvents). */
private readonly MAX_BATCH_SIZE = 10 // Process up to 10 events at once private processBatchRunning = false
private readonly BATCH_DELAY = 200
private readonly MAX_BATCH_SIZE = 24
constructor() { constructor() {
if (!NoteStatsService.instance) { if (!NoteStatsService.instance) {
@ -53,56 +56,84 @@ class NoteStatsService {
return NoteStatsService.instance 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<string>([...(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<string>([...(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) { async fetchNoteStats(event: Event, _pubkey?: string | null, favoriteRelays?: string[] | null) {
const eventId = event.id const eventId = event.id
// Rate limiting: Don't process the same event more than once per 10 seconds if (this.pendingEvents.has(eventId)) {
const now = Date.now() this.mergeFavoriteRelaysIntoPending(eventId, favoriteRelays)
const lastProcessed = this.lastProcessedTime.get(eventId) return
if (lastProcessed && now - lastProcessed < 10000) { }
logger.debug('[NoteStats] Skipping duplicate fetch for event', eventId.substring(0, 8), 'too soon')
if (this.processingCache.has(eventId)) {
this.mergeFavoriteRelaysIntoDeferred(eventId, favoriteRelays)
return return
} }
this.pendingFetchFavoriteRelays.set(eventId, favoriteRelays ?? null) this.pendingFetchFavoriteRelays.set(eventId, favoriteRelays ?? null)
this.pendingEvents.add(eventId) this.pendingEvents.add(eventId)
this.lastProcessedTime.set(eventId, now)
this.armStatsBatchTimer()
// Clear existing timeout and set new one if (this.pendingEvents.size >= this.MAX_BATCH_SIZE && !this.processBatchRunning) {
if (this.batchTimeout) { if (this.batchTimeout) {
clearTimeout(this.batchTimeout) clearTimeout(this.batchTimeout)
} this.batchTimeout = null
}
this.batchTimeout = setTimeout(() => { void this.processBatch()
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()
} }
} }
private async processBatch() { private async processBatch() {
if (this.pendingEvents.size === 0) return if (this.processBatchRunning) {
return
const eventsToProcess = Array.from(this.pendingEvents).slice(0, this.MAX_BATCH_SIZE) }
for (const id of eventsToProcess) { if (this.pendingEvents.size === 0) {
this.pendingEvents.delete(id) return
} }
this.processBatchRunning = true
if (this.batchTimeout) { if (this.batchTimeout) {
clearTimeout(this.batchTimeout) clearTimeout(this.batchTimeout)
this.batchTimeout = null this.batchTimeout = null
} }
await Promise.all(eventsToProcess.map((eventId) => this.processSingleEvent(eventId))) try {
while (this.pendingEvents.size > 0) {
if (this.pendingEvents.size > 0) { const eventsToProcess = Array.from(this.pendingEvents).slice(0, this.MAX_BATCH_SIZE)
this.batchTimeout = setTimeout(() => { for (const id of eventsToProcess) {
this.batchTimeout = null this.pendingEvents.delete(id)
this.processBatch() }
}, this.BATCH_DELAY) 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) this.notifyNoteStats(event.id)
} finally { } finally {
this.processingCache.delete(eventId) 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)
}
}
}
} }
} }

Loading…
Cancel
Save