diff --git a/src/components/Feed.tsx b/src/components/Feed.tsx index efd75949..7e37ae27 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 { useEngagementCounts } from '@/hooks/useEngagementCounts'; import { cn } from '@/lib/utils'; import type { FeedItem } from '@/hooks/useFeed'; @@ -73,6 +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); + const handleLogin = () => { setLoginDialogOpen(false); setSignupDialogOpen(false); @@ -128,6 +134,7 @@ 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 fe1437cf..f366d3ae 100644 --- a/src/components/NoteCard.tsx +++ b/src/components/NoteCard.tsx @@ -14,6 +14,7 @@ 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'; @@ -31,6 +32,8 @@ 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. */ @@ -118,7 +121,7 @@ function encodeEventId(event: NostrEvent): string { return nip19.neventEncode({ id: event.id, author: event.pubkey }); } -export function NoteCard({ event, className, repostedBy, compact }: NoteCardProps) { +export function NoteCard({ event, className, repostedBy, compact, stats: propStats }: NoteCardProps) { const navigate = useNavigate(); const author = useAuthor(event.pubkey); const metadata = author.data?.metadata; @@ -126,7 +129,10 @@ export function NoteCard({ event, className, repostedBy, compact }: NoteCardProp const nip05 = metadata?.nip05; const npub = useMemo(() => nip19.npubEncode(event.pubkey), [event.pubkey]); const encodedId = useMemo(() => encodeEventId(event), [event]); - const { data: stats } = useEventStats(event.id); + // 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 [moreMenuOpen, setMoreMenuOpen] = useState(false); const [replyOpen, setReplyOpen] = useState(false); diff --git a/src/hooks/useEngagementCounts.ts b/src/hooks/useEngagementCounts.ts new file mode 100644 index 00000000..4597f65e --- /dev/null +++ b/src/hooks/useEngagementCounts.ts @@ -0,0 +1,84 @@ +import { useQuery } from '@tanstack/react-query'; +import { useNostr } from '@nostrify/react'; +import type { NRelay1, NostrEvent } from '@nostrify/nostrify'; + +export interface EngagementCounts { + replies: number; + reposts: number; + quotes?: number; + reactions: number; + zapAmount: number; + reactionEmojis?: string[]; +} + +/** + * Batch-fetch engagement counts for multiple events using NIP-45 COUNT queries. + * + * 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. + * + * Call this once at the feed level, then pass counts down as props. + */ +export function useEngagementCounts(events: NostrEvent[]) { + const { nostr } = useNostr(); + + // Stable query key from sorted event IDs + const eventIds = events.map((e) => e.id).sort(); + + return useQuery({ + queryKey: ['engagement-counts', ...eventIds], + queryFn: async (c) => { + if (events.length === 0) return new Map(); + + const signal = AbortSignal.any([c.signal, AbortSignal.timeout(10000)]); + const relay = nostr.relay('wss://relay.ditto.pub') as NRelay1; + + const counts = new Map(); + + // Initialize all events with zero counts + for (const e of events) { + counts.set(e.id, { replies: 0, reposts: 0, reactions: 0, zapAmount: 0 }); + } + + const promises: Promise[] = []; + + 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 */ }), + ); + } + + await Promise.all(promises); + + return counts; + }, + enabled: events.length > 0, + staleTime: 30_000, + placeholderData: (prev) => prev, + }); +}