Files
eranos/src/hooks/useStreamPosts.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

158 lines
4.9 KiB
TypeScript

import { useNostr } from '@nostrify/react';
import { useState, useEffect, useMemo } from 'react';
import { useFeedSettings } from './useFeedSettings';
import { getEnabledFeedKinds } from '@/lib/extraKinds';
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
interface StreamPostsOptions {
includeReplies: boolean;
mediaType: 'all' | 'images' | 'videos' | 'vines' | 'none';
}
/** Check if an event has imeta tags with image MIME types. */
function hasImageImeta(event: NostrEvent): boolean {
return event.tags.some(
(tag) => tag[0] === 'imeta' && tag.slice(1).some((part) => part.startsWith('m ') && part.split(' ')[1]?.startsWith('image/')),
);
}
/** Check if an event has imeta tags with video MIME types. */
function hasVideoImeta(event: NostrEvent): boolean {
return event.tags.some(
(tag) => tag[0] === 'imeta' && tag.slice(1).some((part) => part.startsWith('m ') && part.split(' ')[1]?.startsWith('video/')),
);
}
function filterEvent(event: NostrEvent, options: StreamPostsOptions): boolean {
const now = Math.floor(Date.now() / 1000);
if (event.created_at > now) return false;
// Non-kind-1 events (extra kinds) pass through — media filters only apply to kind 1
if (event.kind !== 1) return true;
if (!options.includeReplies) {
if (event.tags.some(([name]) => name === 'e')) return false;
}
if (options.mediaType !== 'all') {
const hasImages = hasImageImeta(event);
const hasVideos = hasVideoImeta(event);
switch (options.mediaType) {
case 'images': return hasImages;
case 'videos': return hasVideos;
case 'vines': return false; // kind 1 posts aren't vines
case 'none': return !hasImages && !hasVideos;
}
}
return true;
}
/**
* Stream posts using a direct relay connection.
* When mediaType is 'vines', streams kind 34236 events instead of kind 1.
* Includes extra kinds the user has enabled in feed settings.
* Other filters are applied client-side via useMemo.
*/
export function useStreamPosts(query: string, options: StreamPostsOptions) {
const { nostr } = useNostr();
const { feedSettings } = useFeedSettings();
const [allEvents, setAllEvents] = useState<NostrEvent[]>([]);
const [isLoading, setIsLoading] = useState(true);
// Vines filter changes the kind queried, so it must restart the stream
const isVines = options.mediaType === 'vines';
const extraKinds = getEnabledFeedKinds(feedSettings);
const extraKindsKey = extraKinds.sort().join(',');
useEffect(() => {
const ac = new AbortController();
let alive = true;
setAllEvents([]);
setIsLoading(true);
const eventMap = new Map<string, NostrEvent>();
function addEvent(event: NostrEvent) {
if (!alive) return;
const now = Math.floor(Date.now() / 1000);
if (event.created_at > now) return;
// Addressable events (30000-39999) dedupe by pubkey+kind+d
if (event.kind >= 30000 && event.kind < 40000) {
const dTag = event.tags.find(([name]) => name === 'd')?.[1] ?? '';
const key = `${event.pubkey}:${event.kind}:${dTag}`;
const existing = eventMap.get(key);
if (existing && existing.created_at >= event.created_at) return;
eventMap.set(key, event);
} else {
if (eventMap.has(event.id)) return;
eventMap.set(event.id, event);
}
setAllEvents(Array.from(eventMap.values()).sort((a, b) => b.created_at - a.created_at));
}
// Build the kinds list: either vines-only or kind 1 + enabled extras
const kinds: number[] = isVines
? [34236]
: [1, ...extraKinds];
const baseFilter: NostrFilter = { kinds };
if (query.trim()) {
baseFilter.search = query.trim();
}
// 1. Fetch initial batch (uses pool, reuses existing connections)
(async () => {
try {
const events = await nostr.query(
[{ ...baseFilter, limit: 40 }],
{ signal: ac.signal },
);
for (const event of events) {
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(
[{ ...baseFilter, 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, query, isVines, extraKindsKey]);
// Apply client-side filters without restarting the stream
const posts = useMemo(() => {
return allEvents.filter((event) => filterEvent(event, options));
}, [allEvents, options.includeReplies, options.mediaType]);
return { posts, isLoading };
}