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. 99
      src/services/note-stats.service.ts

6
src/components/NoteStats/DiscussionNoteStats.tsx

@ -1,4 +1,5 @@ @@ -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({ @@ -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 (

4
src/components/NoteStats/LikeButton.tsx

@ -19,6 +19,7 @@ import { @@ -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; @@ -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; @@ -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

4
src/components/NoteStats/RepostButton.tsx

@ -11,6 +11,7 @@ import { useNoteStatsById } from '@/hooks/useNoteStatsById' @@ -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 @@ -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 @@ -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
}

4
src/components/NoteStats/VoteButtons.tsx

@ -6,6 +6,7 @@ import { @@ -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' @@ -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<string | null>(null)
const noteStats = useNoteStatsById(event.id)
@ -59,7 +61,7 @@ export default function VoteButtons({ event }: { event: Event }) { @@ -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

10
src/components/NoteStats/index.tsx

@ -1,7 +1,7 @@ @@ -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({ @@ -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({ @@ -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 (

18
src/hooks/useNoteStatsRelayHints.ts

@ -0,0 +1,18 @@ @@ -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 = () => { @@ -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<Record<string, number>>({})
const relayUrls = useMemo(() => Object.keys(relayRefCount), [relayRefCount])

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

@ -36,15 +36,18 @@ class NoteStatsService { @@ -36,15 +36,18 @@ class NoteStatsService {
private noteStatsMap: Map<string, Partial<TNoteStats>> = new Map()
private noteStatsSubscribers = new Map<string, Set<() => void>>()
private processingCache = new Set<string>()
private lastProcessedTime = new Map<string, number>()
// Batch processing
private pendingEvents = new Set<string>()
/** Favorite relays passed from the last fetchNoteStats call per note (used in processSingleEvent). */
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 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 { @@ -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<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) {
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
this.armStatsBatchTimer()
if (this.pendingEvents.size >= this.MAX_BATCH_SIZE && !this.processBatchRunning) {
if (this.batchTimeout) {
clearTimeout(this.batchTimeout)
this.batchTimeout = null
}
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()
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
}
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.batchTimeout = setTimeout(() => {
this.batchTimeout = null
this.processBatch()
}, this.BATCH_DELAY)
this.armStatsBatchTimer()
}
}
}
@ -154,6 +185,18 @@ class NoteStatsService { @@ -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)
}
}
}
}
}

Loading…
Cancel
Save