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