diff --git a/src/components/Feed.tsx b/src/components/Feed.tsx index 7e37ae27..37c3dc11 100644 --- a/src/components/Feed.tsx +++ b/src/components/Feed.tsx @@ -10,7 +10,7 @@ import SignupDialog from '@/components/auth/SignupDialog'; import { useFeed } from '@/hooks/useFeed'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useAuthors } from '@/hooks/useAuthors'; -import { useEngagementCounts } from '@/hooks/useEngagementCounts'; +import { useBatchedEventStats } from '@/hooks/useEngagementCounts'; import { cn } from '@/lib/utils'; import type { FeedItem } from '@/hooks/useFeed'; @@ -74,10 +74,11 @@ export function Feed() { }, [feedItems]); useAuthors(feedPubkeys); - // Batch-fetch engagement counts for visible posts using NIP-45 COUNT - // queries instead of downloading full interaction events per card. - const feedEvents = useMemo(() => feedItems.map((item) => item.event), [feedItems]); - const { data: engagementCounts } = useEngagementCounts(feedEvents); + // Batch-prefetch engagement stats for all visible posts in 2 queries + // instead of 2 per card. Results are seeded into the per-event cache + // so each NoteCard's useEventStats() resolves instantly. + const feedEventIds = useMemo(() => feedItems.map((item) => item.event.id), [feedItems]); + useBatchedEventStats(feedEventIds); const handleLogin = () => { setLoginDialogOpen(false); @@ -134,7 +135,6 @@ export function Feed() { key={item.repostedBy ? `repost-${item.repostedBy}-${item.event.id}` : item.event.id} event={item.event} repostedBy={item.repostedBy} - stats={engagementCounts?.get(item.event.id)} /> ))} diff --git a/src/components/NoteCard.tsx b/src/components/NoteCard.tsx index f366d3ae..fe1437cf 100644 --- a/src/components/NoteCard.tsx +++ b/src/components/NoteCard.tsx @@ -14,7 +14,6 @@ import { FollowPackContent } from '@/components/FollowPackContent'; import { ChestIcon } from '@/components/icons/ChestIcon'; import { useAuthor } from '@/hooks/useAuthor'; import { useEventStats } from '@/hooks/useTrending'; -import type { EngagementCounts } from '@/hooks/useEngagementCounts'; import { genUserName } from '@/lib/genUserName'; import { timeAgo } from '@/lib/timeAgo'; import { cn } from '@/lib/utils'; @@ -32,8 +31,6 @@ interface NoteCardProps { repostedBy?: string; /** If true, hide action buttons (used for embeds). */ compact?: boolean; - /** Pre-fetched engagement counts from feed-level batch query. When provided, skips per-card useEventStats. */ - stats?: EngagementCounts; } /** Formats a sats amount into a compact human-readable string. */ @@ -121,7 +118,7 @@ function encodeEventId(event: NostrEvent): string { return nip19.neventEncode({ id: event.id, author: event.pubkey }); } -export function NoteCard({ event, className, repostedBy, compact, stats: propStats }: NoteCardProps) { +export function NoteCard({ event, className, repostedBy, compact }: NoteCardProps) { const navigate = useNavigate(); const author = useAuthor(event.pubkey); const metadata = author.data?.metadata; @@ -129,10 +126,7 @@ export function NoteCard({ event, className, repostedBy, compact, stats: propSta const nip05 = metadata?.nip05; const npub = useMemo(() => nip19.npubEncode(event.pubkey), [event.pubkey]); const encodedId = useMemo(() => encodeEventId(event), [event]); - // Use pre-fetched stats from feed-level batch when available; - // only fall back to per-card query when rendered outside a feed (e.g. PostDetailPage). - const { data: fetchedStats } = useEventStats(!propStats ? event.id : undefined); - const stats = propStats ?? fetchedStats; + const { data: stats } = useEventStats(event.id); const [moreMenuOpen, setMoreMenuOpen] = useState(false); const [replyOpen, setReplyOpen] = useState(false); diff --git a/src/hooks/useEngagementCounts.ts b/src/hooks/useEngagementCounts.ts index 4597f65e..7eb8c26d 100644 --- a/src/hooks/useEngagementCounts.ts +++ b/src/hooks/useEngagementCounts.ts @@ -1,84 +1,158 @@ -import { useQuery } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useNostr } from '@nostrify/react'; -import type { NRelay1, NostrEvent } from '@nostrify/nostrify'; +import type { NostrEvent } from '@nostrify/nostrify'; -export interface EngagementCounts { - replies: number; - reposts: number; - quotes?: number; - reactions: number; - zapAmount: number; - reactionEmojis?: string[]; +/** Extracts the zap amount in millisatoshis from a kind 9735 zap receipt. */ +function extractZapAmount(event: NostrEvent): number { + const amountTag = event.tags.find(([name]) => name === 'amount'); + if (amountTag?.[1]) { + const msats = parseInt(amountTag[1], 10); + if (!isNaN(msats) && msats > 0) return msats; + } + + const descTag = event.tags.find(([name]) => name === 'description'); + if (descTag?.[1]) { + try { + const zapRequest = JSON.parse(descTag[1]); + const reqAmountTag = zapRequest.tags?.find(([name]: [string]) => name === 'amount'); + if (reqAmountTag?.[1]) { + const msats = parseInt(reqAmountTag[1], 10); + if (!isNaN(msats) && msats > 0) return msats; + } + } catch { + // Invalid JSON + } + } + + const bolt11Tag = event.tags.find(([name]) => name === 'bolt11'); + if (bolt11Tag?.[1]) { + const msats = parseBolt11Amount(bolt11Tag[1]); + if (msats > 0) return msats; + } + + return 0; +} + +function parseBolt11Amount(bolt11: string): number { + const match = bolt11.toLowerCase().match(/^ln\w+?(\d+)([munp]?)1/); + if (!match) return 0; + const value = parseInt(match[1], 10); + if (isNaN(value)) return 0; + const multiplier = match[2]; + switch (multiplier) { + case 'm': return value * 100_000_000; + case 'u': return value * 100_000; + case 'n': return value * 100; + case 'p': return value / 10; + default: return value * 100_000_000_000; + } } /** - * Batch-fetch engagement counts for multiple events using NIP-45 COUNT queries. + * Batch-prefetch engagement stats for all visible feed events in two + * combined queries (one for #e interactions, one for #q quotes) instead + * of 2 queries per card. * - * Instead of downloading hundreds of full events per post just to count them, - * this fires lightweight COUNT requests to relay.ditto.pub (which supports NIP-45). - * Each COUNT returns a single number. All requests run in parallel. + * Results are seeded into the individual `['event-stats', eventId]` + * cache entries so that each NoteCard's own `useEventStats()` resolves + * instantly from cache — no prop-drilling needed. * - * Call this once at the feed level, then pass counts down as props. + * Call this at the feed level right after `useAuthors`. */ -export function useEngagementCounts(events: NostrEvent[]) { +export function useBatchedEventStats(eventIds: string[]) { const { nostr } = useNostr(); + const queryClient = useQueryClient(); - // Stable query key from sorted event IDs - const eventIds = events.map((e) => e.id).sort(); + const uniqueIds = [...new Set(eventIds)].sort(); return useQuery({ - queryKey: ['engagement-counts', ...eventIds], - queryFn: async (c) => { - if (events.length === 0) return new Map(); + queryKey: ['batched-event-stats', uniqueIds.join(',')], + queryFn: async ({ signal }) => { + if (uniqueIds.length === 0) return; - const signal = AbortSignal.any([c.signal, AbortSignal.timeout(10000)]); - const relay = nostr.relay('wss://relay.ditto.pub') as NRelay1; + const combined = AbortSignal.any([signal, AbortSignal.timeout(8000)]); - const counts = new Map(); + // Two batched queries covering ALL event IDs at once + const [eTagEvents, qTagEvents] = await Promise.all([ + nostr.query( + [{ kinds: [1, 6, 7, 9735], '#e': uniqueIds, limit: uniqueIds.length * 30 }], + { signal: combined }, + ), + nostr.query( + [{ kinds: [1], '#q': uniqueIds, limit: uniqueIds.length * 5 }], + { signal: combined }, + ), + ]); - // Initialize all events with zero counts - for (const e of events) { - counts.set(e.id, { replies: 0, reposts: 0, reactions: 0, zapAmount: 0 }); + // Build per-event stats accumulators + const statsMap = new Map; + }>(); + + for (const id of uniqueIds) { + statsMap.set(id, { + replies: 0, reposts: 0, quotes: 0, reactions: 0, zapAmount: 0, + reactionEmojis: new Set(), + }); } - const promises: Promise[] = []; + // Process #e tag events — figure out which target event each belongs to + for (const e of eTagEvents) { + const targetId = e.tags.find(([name]) => name === 'e')?.[1]; + if (!targetId) continue; + const entry = statsMap.get(targetId); + if (!entry) continue; - for (const event of events) { - // Replies (kind 1 referencing this event) - promises.push( - relay.count([{ kinds: [1], '#e': [event.id] }], { signal }) - .then((result) => { counts.get(event.id)!.replies = result.count; }) - .catch(() => { /* leave as 0 */ }), - ); - - // Reposts - promises.push( - relay.count([{ kinds: [6], '#e': [event.id] }], { signal }) - .then((result) => { counts.get(event.id)!.reposts = result.count; }) - .catch(() => { /* leave as 0 */ }), - ); - - // Reactions - promises.push( - relay.count([{ kinds: [7], '#e': [event.id] }], { signal }) - .then((result) => { counts.get(event.id)!.reactions = result.count; }) - .catch(() => { /* leave as 0 */ }), - ); - - // Zap receipts (count only — no amount without full events) - promises.push( - relay.count([{ kinds: [9735], '#e': [event.id] }], { signal }) - .then((result) => { counts.get(event.id)!.zapAmount = result.count; }) - .catch(() => { /* leave as 0 */ }), - ); + switch (e.kind) { + case 1: entry.replies++; break; + case 6: entry.reposts++; break; + case 7: { + entry.reactions++; + const emoji = e.content.trim(); + if (emoji === '+' || emoji === '') { + entry.reactionEmojis.add('👍'); + } else if (emoji !== '-') { + entry.reactionEmojis.add(emoji); + } + break; + } + case 9735: { + const msats = extractZapAmount(e); + if (msats > 0) { + entry.zapAmount += Math.floor(msats / 1000); + } + break; + } + } } - await Promise.all(promises); + // Process #q tag events (quotes) + for (const e of qTagEvents) { + const targetId = e.tags.find(([name]) => name === 'q')?.[1]; + if (!targetId) continue; + const entry = statsMap.get(targetId); + if (entry) entry.quotes++; + } - return counts; + // Seed individual ['event-stats', eventId] cache entries + for (const [id, entry] of statsMap) { + queryClient.setQueryData(['event-stats', id], { + replies: entry.replies, + reposts: entry.reposts, + quotes: entry.quotes, + reactions: entry.reactions, + zapAmount: entry.zapAmount, + reactionEmojis: Array.from(entry.reactionEmojis), + }); + } }, - enabled: events.length > 0, - staleTime: 30_000, + enabled: uniqueIds.length > 0, + staleTime: 60_000, placeholderData: (prev) => prev, }); }