perf: batch engagement stats into 2 queries instead of 2×N per card

Replace per-card useEventStats (2 queries per NoteCard = 30 queries for
15 posts) with useBatchedEventStats at the feed level. Fires 2 combined
queries for ALL event IDs at once, processes results, and seeds the
individual ['event-stats', eventId] cache entries. Each NoteCard's
useEventStats() then resolves instantly from cache — same data, same
pattern as useAuthors, no prop changes needed.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy
2026-02-18 01:29:17 -06:00
parent b74430e655
commit 1b3a1806b3
3 changed files with 141 additions and 73 deletions
+6 -6
View File
@@ -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)}
/>
))}
+2 -8
View File
@@ -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);
+133 -59
View File
@@ -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<string, EngagementCounts>();
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<string, EngagementCounts>();
// 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<string, {
replies: number;
reposts: number;
quotes: number;
reactions: number;
zapAmount: number;
reactionEmojis: Set<string>;
}>();
for (const id of uniqueIds) {
statsMap.set(id, {
replies: 0, reposts: 0, quotes: 0, reactions: 0, zapAmount: 0,
reactionEmojis: new Set(),
});
}
const promises: Promise<void>[] = [];
// 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,
});
}