perf: batch engagement counts with NIP-45 COUNT at feed level

Replace per-card useEventStats queries (2 heavy queries × N posts) with
a single useEngagementCounts hook that fires lightweight NIP-45 COUNT
requests in parallel via relay.ditto.pub. Feed passes counts as props;
NoteCard only falls back to per-card queries when rendered outside a
feed (e.g. PostDetailPage).

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy
2026-02-18 01:26:30 -06:00
parent bec09826ee
commit b74430e655
3 changed files with 99 additions and 2 deletions
+7
View File
@@ -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)}
/>
))}
+8 -2
View File
@@ -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);
+84
View File
@@ -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<string, EngagementCounts>();
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(10000)]);
const relay = nostr.relay('wss://relay.ditto.pub') as NRelay1;
const counts = new Map<string, EngagementCounts>();
// 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<void>[] = [];
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,
});
}