diff --git a/src/components/NostrProvider.tsx b/src/components/NostrProvider.tsx index 0a21d7d8..790329b2 100644 --- a/src/components/NostrProvider.tsx +++ b/src/components/NostrProvider.tsx @@ -44,7 +44,8 @@ const NostrProvider: React.FC = (props) => { reqRouter(filters: NostrFilter[]) { const routes = new Map(); - // Route to all read relays + // Route to all read relays — eoseTimeout races them so the + // first relay to finish wins while the rest are cut off. const readRelays = effectiveRelays.current.relays .filter(r => r.read) .map(r => r.url); @@ -56,14 +57,12 @@ const NostrProvider: React.FC = (props) => { return routes; }, eventRouter(_event: NostrEvent) { - // Get write relays from effective relays + // Publish to all write relays for maximum reach const writeRelays = effectiveRelays.current.relays .filter(r => r.write) .map(r => r.url); - const allRelays = new Set(writeRelays); - - return [...allRelays]; + return [...new Set(writeRelays)]; }, // Resolve queries quickly once any relay sends EOSE, instead of // waiting for every relay to finish. This is the single biggest diff --git a/src/hooks/useAuthor.ts b/src/hooks/useAuthor.ts index 271d4c2b..4e08556a 100644 --- a/src/hooks/useAuthor.ts +++ b/src/hooks/useAuthor.ts @@ -1,34 +1,130 @@ import { type NostrEvent, type NostrMetadata, NSchema as n } from '@nostrify/nostrify'; import { useNostr } from '@nostrify/react'; -import { useQuery } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; + +// --------------------------------------------------------------------------- +// Batching layer — collects individual pubkey requests within a 50 ms window +// and resolves them all with a single relay query. +// --------------------------------------------------------------------------- + +type NostrPool = ReturnType['nostr']; + +interface PendingRequest { + resolve: (data: { event?: NostrEvent; metadata?: NostrMetadata }) => void; + reject: (err: Error) => void; +} + +/** Map of pubkey → pending resolvers, flushed every 50 ms. */ +const pendingBatch = new Map(); +let flushTimer: ReturnType | null = null; +let currentPool: NostrPool | null = null; +let currentQueryClient: ReturnType | null = null; + +function flushBatch() { + flushTimer = null; + const pool = currentPool; + const qc = currentQueryClient; + if (!pool || !qc) return; + + // Snapshot & clear + const batch = new Map(pendingBatch); + pendingBatch.clear(); + + const pubkeys = [...batch.keys()]; + if (pubkeys.length === 0) return; + + (async () => { + try { + const events = await pool.query( + [{ kinds: [0], authors: pubkeys, limit: pubkeys.length }], + { signal: AbortSignal.timeout(5000) }, + ); + + // Index results by pubkey + const byPubkey = new Map(); + for (const event of events) { + const existing = byPubkey.get(event.pubkey); + if (!existing || event.created_at > existing.created_at) { + byPubkey.set(event.pubkey, event); + } + } + + // Resolve each pending request + for (const [pk, resolvers] of batch) { + const event = byPubkey.get(pk); + let result: { event?: NostrEvent; metadata?: NostrMetadata }; + + if (event) { + let metadata: NostrMetadata | undefined; + try { + metadata = n.json().pipe(n.metadata()).parse(event.content); + } catch { + // unparseable + } + result = { event, metadata }; + } else { + result = {}; + } + + // Seed individual cache + qc.setQueryData(['author', pk], result); + + for (const r of resolvers) { + r.resolve(result); + } + } + } catch (err) { + // Reject all pending + for (const resolvers of batch.values()) { + for (const r of resolvers) { + r.reject(err instanceof Error ? err : new Error(String(err))); + } + } + } + })(); +} + +function fetchAuthorBatched( + pubkey: string, + pool: NostrPool, + queryClient: ReturnType, +): Promise<{ event?: NostrEvent; metadata?: NostrMetadata }> { + // Keep refs current so the flush uses the latest pool + currentPool = pool; + currentQueryClient = queryClient; + + return new Promise((resolve, reject) => { + const existing = pendingBatch.get(pubkey); + if (existing) { + existing.push({ resolve, reject }); + } else { + pendingBatch.set(pubkey, [{ resolve, reject }]); + } + + // Schedule a flush if not already pending + if (!flushTimer) { + flushTimer = setTimeout(flushBatch, 50); + } + }); +} + +// --------------------------------------------------------------------------- +// Hook +// --------------------------------------------------------------------------- export function useAuthor(pubkey: string | undefined) { const { nostr } = useNostr(); + const queryClient = useQueryClient(); return useQuery<{ event?: NostrEvent; metadata?: NostrMetadata }>({ queryKey: ['author', pubkey ?? ''], - queryFn: async ({ signal }) => { + queryFn: async () => { if (!pubkey) { return {}; } - - const [event] = await nostr.query( - [{ kinds: [0], authors: [pubkey!], limit: 1 }], - { signal: AbortSignal.any([signal, AbortSignal.timeout(1500)]) }, - ); - - if (!event) { - throw new Error('No event found'); - } - - try { - const metadata = n.json().pipe(n.metadata()).parse(event.content); - return { metadata, event }; - } catch { - return { event }; - } + return fetchAuthorBatched(pubkey, nostr, queryClient); }, - staleTime: 5 * 60 * 1000, // Keep cached data fresh for 5 minutes - retry: 3, + staleTime: 5 * 60 * 1000, + retry: 1, }); } diff --git a/src/hooks/useTrending.ts b/src/hooks/useTrending.ts index a5d3494b..efb7ebad 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 { @@ -117,67 +117,165 @@ function parseBolt11Amount(bolt11: string): number { } } -/** Counts engagement (replies, reposts, quotes, reactions, zaps) for a given event. */ -export function useEventStats(eventId: string | undefined) { - const { nostr } = useNostr(); +// --------------------------------------------------------------------------- +// Batched event stats — collects individual event ID requests within a 50 ms +// window and resolves them all with a single pair of relay queries. +// --------------------------------------------------------------------------- - 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[] }; +type NostrPool = ReturnType['nostr']; - const timeout = AbortSignal.timeout(5000); - const combined = AbortSignal.any([signal, timeout]); +export interface EventStats { + replies: number; + reposts: number; + quotes: number; + reactions: number; + zapAmount: number; + reactionEmojis: string[]; +} - // Two queries: one for e-tag interactions, one for q-tag quotes +const EMPTY_STATS: EventStats = { replies: 0, reposts: 0, quotes: 0, reactions: 0, zapAmount: 0, reactionEmojis: [] }; + +interface PendingStatsRequest { + resolve: (data: EventStats) => void; + reject: (err: Error) => void; +} + +const pendingStatsBatch = new Map(); +let statsFlushTimer: ReturnType | null = null; +let statsPool: NostrPool | null = null; +let statsQueryClient: ReturnType | null = null; + +function flushStatsBatch() { + statsFlushTimer = null; + const pool = statsPool; + const qc = statsQueryClient; + if (!pool || !qc) return; + + const batch = new Map(pendingStatsBatch); + pendingStatsBatch.clear(); + + const eventIds = [...batch.keys()]; + if (eventIds.length === 0) return; + + (async () => { + try { + // Two batched queries for ALL pending event IDs at once const [eTagEvents, qTagEvents] = await Promise.all([ - nostr.query( - [{ kinds: [1, 6, 7, 9735], '#e': [eventId], limit: 200 }], - { signal: combined }, + pool.query( + [{ kinds: [1, 6, 7, 9735], '#e': eventIds, limit: eventIds.length * 50 }], + { signal: AbortSignal.timeout(5000) }, ), - nostr.query( - [{ kinds: [1], '#q': [eventId], limit: 50 }], - { signal: combined }, + pool.query( + [{ kinds: [1], '#q': eventIds, limit: eventIds.length * 10 }], + { signal: AbortSignal.timeout(5000) }, ), ]); - let replies = 0; - let reposts = 0; - let reactions = 0; - let zapAmount = 0; - const reactionEmojiSet = new Set(); + // Group results by referenced event ID + const statsMap = new Map; quotes: number; + }>(); + // Initialize + for (const id of eventIds) { + statsMap.set(id, { replies: 0, reposts: 0, reactions: 0, zapAmount: 0, reactionEmojis: new Set(), quotes: 0 }); + } + + // Process e-tag events for (const e of eTagEvents) { + const refId = e.tags.find(([name]) => name === 'e')?.[1]; + if (!refId) continue; + const entry = statsMap.get(refId); + if (!entry) continue; + switch (e.kind) { - case 1: replies++; break; - case 6: reposts++; break; + case 1: entry.replies++; break; + case 6: entry.reposts++; break; case 7: { - reactions++; - // Extract the emoji from the reaction content (kind 7 events use content for the emoji) + entry.reactions++; const emoji = e.content.trim(); if (emoji === '+' || emoji === '') { - reactionEmojiSet.add('👍'); + entry.reactionEmojis.add('👍'); } else if (emoji !== '-') { - reactionEmojiSet.add(emoji); + entry.reactionEmojis.add(emoji); } break; } case 9735: { const msats = extractZapAmount(e); - if (msats > 0) { - zapAmount += Math.floor(msats / 1000); - } + if (msats > 0) entry.zapAmount += Math.floor(msats / 1000); break; } } } - const quotes = qTagEvents.length; + // Process q-tag events (quotes) + for (const e of qTagEvents) { + const refId = e.tags.find(([name]) => name === 'q')?.[1]; + if (!refId) continue; + const entry = statsMap.get(refId); + if (entry) entry.quotes++; + } - return { replies, reposts, quotes, reactions, zapAmount, reactionEmojis: Array.from(reactionEmojiSet) }; + // Resolve all pending requests + for (const [id, resolvers] of batch) { + const raw = statsMap.get(id); + const result: EventStats = raw + ? { replies: raw.replies, reposts: raw.reposts, quotes: raw.quotes, reactions: raw.reactions, zapAmount: raw.zapAmount, reactionEmojis: [...raw.reactionEmojis] } + : { ...EMPTY_STATS }; + + qc.setQueryData(['event-stats', id], result); + + for (const r of resolvers) { + r.resolve(result); + } + } + } catch (err) { + for (const resolvers of batch.values()) { + for (const r of resolvers) { + r.reject(err instanceof Error ? err : new Error(String(err))); + } + } + } + })(); +} + +function fetchStatsBatched( + eventId: string, + pool: NostrPool, + queryClient: ReturnType, +): Promise { + statsPool = pool; + statsQueryClient = queryClient; + + return new Promise((resolve, reject) => { + const existing = pendingStatsBatch.get(eventId); + if (existing) { + existing.push({ resolve, reject }); + } else { + pendingStatsBatch.set(eventId, [{ resolve, reject }]); + } + + if (!statsFlushTimer) { + statsFlushTimer = setTimeout(flushStatsBatch, 50); + } + }); +} + +/** Counts engagement (replies, reposts, quotes, reactions, zaps) for a given event. */ +export function useEventStats(eventId: string | undefined) { + const { nostr } = useNostr(); + const queryClient = useQueryClient(); + + return useQuery({ + queryKey: ['event-stats', eventId ?? ''], + queryFn: async () => { + if (!eventId) return { ...EMPTY_STATS }; + return fetchStatsBatched(eventId, nostr, queryClient); }, enabled: !!eventId, staleTime: 60 * 1000, - placeholderData: (prev) => prev, // Keep showing previous counts while refetching + placeholderData: (prev) => prev, }); }