diff --git a/src/components/Feed.tsx b/src/components/Feed.tsx index efd75949..76891ff6 100644 --- a/src/components/Feed.tsx +++ b/src/components/Feed.tsx @@ -10,6 +10,7 @@ import SignupDialog from '@/components/auth/SignupDialog'; import { useFeed } from '@/hooks/useFeed'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useAuthors } from '@/hooks/useAuthors'; +import { useBatchEventStats } from '@/hooks/useTrending'; import { cn } from '@/lib/utils'; import type { FeedItem } from '@/hooks/useFeed'; @@ -73,6 +74,13 @@ export function Feed() { }, [feedItems]); useAuthors(feedPubkeys); + // Batch-prefetch interaction stats for all visible events in a single + // relay query instead of firing 2 queries per NoteCard. + const feedEventIds = useMemo(() => { + return feedItems.map((item) => item.event.id); + }, [feedItems]); + useBatchEventStats(feedEventIds); + const handleLogin = () => { setLoginDialogOpen(false); setSignupDialogOpen(false); diff --git a/src/hooks/useEventInteractions.ts b/src/hooks/useEventInteractions.ts index 1cc54d7e..ef3f3023 100644 --- a/src/hooks/useEventInteractions.ts +++ b/src/hooks/useEventInteractions.ts @@ -126,16 +126,17 @@ export function useEventInteractions(eventId: string | undefined) { const timeout = AbortSignal.timeout(5000); const combined = AbortSignal.any([signal, timeout]); - const [eTagEvents, qTagEvents] = await Promise.all([ - nostr.query( - [{ kinds: [6, 7, 9735], '#e': [eventId], limit: 500 }], - { signal: combined }, - ), - nostr.query( - [{ kinds: [1], '#q': [eventId], limit: 100 }], - { signal: combined }, - ), - ]); + // Single query with two filter objects — relay handles as OR + const allEvents = await nostr.query( + [ + { kinds: [6, 7, 9735], '#e': [eventId], limit: 50 }, + { kinds: [1], '#q': [eventId], limit: 20 }, + ], + { signal: combined }, + ); + + const eTagEvents = allEvents.filter(e => e.kind !== 1 || e.tags.some(([n, v]) => n === 'e' && v === eventId)); + const qTagEvents = allEvents.filter(e => e.kind === 1 && e.tags.some(([n, v]) => n === 'q' && v === eventId)); const reposts: RepostEntry[] = []; const quotes: QuoteEntry[] = []; diff --git a/src/hooks/useProfileFeed.ts b/src/hooks/useProfileFeed.ts index 3c91efef..8a38571d 100644 --- a/src/hooks/useProfileFeed.ts +++ b/src/hooks/useProfileFeed.ts @@ -4,7 +4,7 @@ import { useFeedSettings } from './useFeedSettings'; import { getEnabledFeedKinds } from '@/lib/extraKinds'; import type { NostrEvent } from '@nostrify/nostrify'; -const PAGE_SIZE = 30; +const PAGE_SIZE = 20; export type ProfileTab = 'posts' | 'replies' | 'media' | 'likes'; diff --git a/src/hooks/useReplies.ts b/src/hooks/useReplies.ts index b6aa4b8d..788bb518 100644 --- a/src/hooks/useReplies.ts +++ b/src/hooks/useReplies.ts @@ -12,7 +12,7 @@ export function useReplies(eventId: string | undefined) { if (!eventId) return []; const events = await nostr.query( - [{ kinds: [1], '#e': [eventId], limit: 100 }], + [{ kinds: [1], '#e': [eventId], limit: 50 }], { signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]) }, ); diff --git a/src/hooks/useStreamKind.ts b/src/hooks/useStreamKind.ts index 3cbec9f3..3e294b42 100644 --- a/src/hooks/useStreamKind.ts +++ b/src/hooks/useStreamKind.ts @@ -90,7 +90,7 @@ export function useStreamKind(kind: number | number[]) { try { const now = Math.floor(Date.now() / 1000); for await (const msg of nostr.req( - [{ ...filter, since: now, limit: 100 }], + [{ ...filter, since: now, limit: 0 }], { signal: ac.signal }, )) { if (!alive) break; diff --git a/src/hooks/useStreamPosts.ts b/src/hooks/useStreamPosts.ts index ef41b7d8..6b05f2e5 100644 --- a/src/hooks/useStreamPosts.ts +++ b/src/hooks/useStreamPosts.ts @@ -127,7 +127,7 @@ export function useStreamPosts(query: string, options: StreamPostsOptions) { try { const now = Math.floor(Date.now() / 1000); for await (const msg of nostr.req( - [{ ...baseFilter, since: now, limit: 100 }], + [{ ...baseFilter, since: now, limit: 0 }], { signal: ac.signal }, )) { if (!alive) break; diff --git a/src/hooks/useTrending.ts b/src/hooks/useTrending.ts index a5d3494b..7ac32f7b 100644 --- a/src/hooks/useTrending.ts +++ b/src/hooks/useTrending.ts @@ -1,5 +1,5 @@ import { useNostr } from '@nostrify/react'; -import { useQuery } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import type { NostrEvent } from '@nostrify/nostrify'; export interface TrendingTag { @@ -15,7 +15,7 @@ export function useTrendingTags(enabled = true) { queryKey: ['trending-tags'], queryFn: async ({ signal }) => { const events = await nostr.query( - [{ kinds: [1], limit: 200 }], + [{ kinds: [1], limit: 50 }], { signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]) }, ); @@ -117,6 +117,66 @@ function parseBolt11Amount(bolt11: string): number { } } +/** The stats shape returned by useEventStats and useBatchEventStats. */ +export interface EventStats { + replies: number; + reposts: number; + quotes: number; + reactions: number; + zapAmount: number; + reactionEmojis: string[]; +} + +const EMPTY_STATS: EventStats = { replies: 0, reposts: 0, quotes: 0, reactions: 0, zapAmount: 0, reactionEmojis: [] }; + +/** Computes stats for a single event ID from a flat array of interaction events. */ +function computeStats(eventId: string, events: NostrEvent[]): EventStats { + let replies = 0; + let reposts = 0; + let quotes = 0; + let reactions = 0; + let zapAmount = 0; + const reactionEmojiSet = new Set(); + + for (const e of events) { + // Check if this event references our target via e-tag or q-tag + const refersViaE = e.tags.some(([name, val]) => name === 'e' && val === eventId); + const refersViaQ = e.tags.some(([name, val]) => name === 'q' && val === eventId); + + if (!refersViaE && !refersViaQ) continue; + + switch (e.kind) { + case 1: + if (refersViaQ) { + quotes++; + } else { + replies++; + } + break; + case 6: reposts++; break; + case 7: { + reactions++; + const emoji = e.content.trim(); + if (emoji === '+' || emoji === '') { + reactionEmojiSet.add('👍'); + } else if (emoji !== '-') { + reactionEmojiSet.add(emoji); + } + break; + } + case 9735: { + const msats = extractZapAmount(e); + if (msats > 0) { + zapAmount += Math.floor(msats / 1000); + } + break; + } + } + } + + return { replies, reposts, quotes, reactions, zapAmount, reactionEmojis: Array.from(reactionEmojiSet) }; +} + /** Counts engagement (replies, reposts, quotes, reactions, zaps) for a given event. */ export function useEventStats(eventId: string | undefined) { const { nostr } = useNostr(); @@ -124,60 +184,72 @@ export function useEventStats(eventId: string | undefined) { return useQuery({ queryKey: ['event-stats', eventId ?? ''], queryFn: async ({ signal }) => { - if (!eventId) return { replies: 0, reposts: 0, quotes: 0, reactions: 0, zapAmount: 0, reactionEmojis: [] as string[] }; + if (!eventId) return EMPTY_STATS; - const timeout = AbortSignal.timeout(5000); - const combined = AbortSignal.any([signal, timeout]); + const combined = AbortSignal.any([signal, AbortSignal.timeout(5000)]); - // Two queries: one for e-tag interactions, one for q-tag quotes - const [eTagEvents, qTagEvents] = await Promise.all([ - nostr.query( - [{ kinds: [1, 6, 7, 9735], '#e': [eventId], limit: 200 }], - { signal: combined }, - ), - nostr.query( - [{ kinds: [1], '#q': [eventId], limit: 50 }], - { signal: combined }, - ), - ]); + // Single query with two filter objects — relay handles as OR + const events = await nostr.query( + [ + { kinds: [1, 6, 7, 9735], '#e': [eventId], limit: 50 }, + { kinds: [1], '#q': [eventId], limit: 20 }, + ], + { signal: combined }, + ); - let replies = 0; - let reposts = 0; - let reactions = 0; - let zapAmount = 0; - const reactionEmojiSet = new Set(); - - for (const e of eTagEvents) { - switch (e.kind) { - case 1: replies++; break; - case 6: reposts++; break; - case 7: { - reactions++; - // Extract the emoji from the reaction content (kind 7 events use content for the emoji) - const emoji = e.content.trim(); - if (emoji === '+' || emoji === '') { - reactionEmojiSet.add('👍'); - } else if (emoji !== '-') { - reactionEmojiSet.add(emoji); - } - break; - } - case 9735: { - const msats = extractZapAmount(e); - if (msats > 0) { - zapAmount += Math.floor(msats / 1000); - } - break; - } - } - } - - const quotes = qTagEvents.length; - - return { replies, reposts, quotes, reactions, zapAmount, reactionEmojis: Array.from(reactionEmojiSet) }; + return computeStats(eventId, events); }, enabled: !!eventId, staleTime: 60 * 1000, - placeholderData: (prev) => prev, // Keep showing previous counts while refetching + placeholderData: (prev) => prev, + }); +} + +/** + * Batch-fetch interaction stats for multiple event IDs in a single relay query. + * + * Much more efficient than calling `useEventStats` once per event — one + * round-trip instead of N. Results are also seeded into the individual + * `['event-stats', id]` cache entries so that `useEventStats()` calls + * for the same IDs resolve instantly from cache. + */ +export function useBatchEventStats(eventIds: string[]) { + const { nostr } = useNostr(); + const queryClient = useQueryClient(); + + const uniqueIds = [...new Set(eventIds)].sort(); + + return useQuery>({ + queryKey: ['batch-event-stats', uniqueIds.join(',')], + queryFn: async ({ signal }) => { + if (uniqueIds.length === 0) return new Map(); + + const combined = AbortSignal.any([signal, AbortSignal.timeout(6000)]); + + // Single query covering all event IDs — relay handles as OR + const events = await nostr.query( + [ + { kinds: [1, 6, 7, 9735], '#e': uniqueIds, limit: uniqueIds.length * 10 }, + { kinds: [1], '#q': uniqueIds, limit: uniqueIds.length * 3 }, + ], + { signal: combined }, + ); + + const statsMap = new Map(); + + for (const id of uniqueIds) { + const stats = computeStats(id, events); + statsMap.set(id, stats); + + // Seed individual cache + queryClient.setQueryData(['event-stats', id], stats); + } + + return statsMap; + }, + staleTime: 60 * 1000, + gcTime: 5 * 60 * 1000, + enabled: uniqueIds.length > 0, + placeholderData: (prev) => prev, }); }