Files
eranos/src/hooks/useStreamKind.ts
T
shakespeare.diy 0fa3e58a83 Drastically reduce concurrent relay queries to prevent rate limiting
The app was firing an excessive number of concurrent REQs, causing relays
to return "too many concurrent REQs" errors. Root causes and fixes:

1. **Batch event stats** (biggest win): Created `useBatchEventStats` hook
   that fetches interaction stats for ALL visible feed items in a single
   relay query instead of 2 queries per NoteCard. For a 15-item feed this
   reduces ~30 concurrent REQs to just 1. Results are seeded into the
   individual `['event-stats', id]` cache for instant resolution.

2. **Merged dual queries**: Both `useEventStats` and `useEventInteractions`
   were firing 2 parallel queries (e-tag + q-tag). Merged each into a
   single query using multiple filter objects (relay handles as OR).

3. **Reduced limits across the board**:
   - useEventStats: limit 200+50 → 50+20 (single query)
   - useEventInteractions: limit 500+100 → 50+20 (single query)
   - useTrendingTags: limit 200 → 50
   - useReplies: limit 100 → 50
   - useProfileFeed PAGE_SIZE: 30 → 20
   - useStreamPosts/useStreamKind subscription limit: 100 → 0

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-02-18 01:48:27 -06:00

116 lines
3.1 KiB
TypeScript

import { useNostr } from '@nostrify/react';
import { useState, useEffect, useMemo } from 'react';
import type { NostrEvent } from '@nostrify/nostrify';
/**
* Generic streaming hook that fetches an initial batch of events for the given
* kind(s) and then streams new ones in real-time.
*
* Handles deduplication for both regular events (by id) and addressable
* events (by pubkey+kind+d).
*
* Accepts a single kind number or an array of kinds.
*/
export function useStreamKind(kind: number | number[]) {
const { nostr } = useNostr();
const [events, setEvents] = useState<NostrEvent[]>([]);
const [isLoading, setIsLoading] = useState(true);
// Normalise to a stable array
const kinds = useMemo(
() => (Array.isArray(kind) ? kind : [kind]),
// eslint-disable-next-line react-hooks/exhaustive-deps
[JSON.stringify(Array.isArray(kind) ? kind.slice().sort() : [kind])],
);
const kindsSet = useMemo(() => new Set(kinds), [kinds]);
useEffect(() => {
if (kinds.length === 0) {
setEvents([]);
setIsLoading(false);
return;
}
const ac = new AbortController();
let alive = true;
setEvents([]);
setIsLoading(true);
const eventMap = new Map<string, NostrEvent>();
function isAddressable(k: number): boolean {
return k >= 30000 && k < 40000;
}
function dedupeKey(event: NostrEvent): string {
if (isAddressable(event.kind)) {
const dTag = event.tags.find(([name]) => name === 'd')?.[1] ?? '';
return `${event.pubkey}:${event.kind}:${dTag}`;
}
return event.id;
}
function addEvent(event: NostrEvent) {
if (!alive) return;
if (!kindsSet.has(event.kind)) return;
const now = Math.floor(Date.now() / 1000);
if (event.created_at > now) return;
const key = dedupeKey(event);
const existing = eventMap.get(key);
if (existing && existing.created_at >= event.created_at) return;
eventMap.set(key, event);
setEvents(Array.from(eventMap.values()).sort((a, b) => b.created_at - a.created_at));
}
const filter = { kinds };
// 1. Fetch initial batch (uses pool, reuses existing connections)
(async () => {
try {
const results = await nostr.query(
[{ ...filter, limit: 40 }],
{ signal: ac.signal },
);
for (const event of results) {
addEvent(event);
}
} catch {
// abort expected
}
if (alive) setIsLoading(false);
})();
// 2. Stream new events (uses pool, reuses existing connections)
(async () => {
try {
const now = Math.floor(Date.now() / 1000);
for await (const msg of nostr.req(
[{ ...filter, since: now, limit: 0 }],
{ signal: ac.signal },
)) {
if (!alive) break;
if (msg[0] === 'EVENT') {
addEvent(msg[2]);
} else if (msg[0] === 'CLOSED') {
break;
}
}
} catch {
// abort expected
}
})();
return () => {
alive = false;
ac.abort();
};
}, [nostr, kinds, kindsSet]);
return { events, isLoading };
}