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:
@@ -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)}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user