Files
eranos/src/hooks/useStreamPosts.ts
T
Chad Curtis eb93d5dd09 Re-enable search filters and add logging for initial query
- Re-enabled language and media search filters for initial query
- Added console logging to track initial batch fetch
- Initial query fetches 40 events with NIP-50 filters applied
- Streaming subscription then continues from relay.ditto.pub for new events
2026-02-19 05:26:03 -06:00

258 lines
9.0 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';
language?: string;
}
/** 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/')),
);
}
/**
* Client-side filtering for streaming events.
* Initial query uses relay-level filters (NIP-50 search), but streaming
* events need client-side filtering since relays don't support streaming search.
*/
function filterEvent(event: NostrEvent, options: StreamPostsOptions, searchQuery: string): boolean {
const now = Math.floor(Date.now() / 1000);
if (event.created_at > now) return false;
// Non-kind-1 events (extra kinds) pass through without filtering
if (event.kind !== 1) return true;
// Filter replies
if (!options.includeReplies) {
if (event.tags.some(([name]) => name === 'e')) return false;
}
// Client-side search (for streaming events only - initial query uses relay search)
if (searchQuery.trim()) {
const lowerContent = event.content.toLowerCase();
const lowerQuery = searchQuery.toLowerCase();
if (!lowerContent.includes(lowerQuery)) return false;
}
// Client-side media filtering (for streaming events only)
if (options.mediaType !== 'all') {
const hasImages = hasImageImeta(event);
const hasVideos = hasVideoImeta(event);
switch (options.mediaType) {
case 'images':
if (!hasImages || hasVideos) return false;
break;
case 'videos':
if (!hasVideos) return false;
break;
case 'vines':
return false; // kind 1 posts aren't vines
case 'none':
if (hasImages || hasVideos) return false;
break;
}
}
// Note: Language filtering is only done at relay-level (NIP-50 language:)
// We can't reliably detect language client-side for streaming events
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(() => {
console.log('[useStreamPosts] Effect running with deps:', {
query,
isVines,
extraKindsKey,
language: options.language,
mediaType: options.mediaType
});
const ac = new AbortController();
let alive = true;
// Log when abort is called
const originalAbort = ac.abort.bind(ac);
ac.abort = () => {
console.log('[useStreamPosts] AbortController.abort() called');
console.trace();
originalAbort();
};
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];
// Base filter for streaming (kinds only - no search)
const streamFilter: NostrFilter = { kinds };
// Search filter for initial query (includes NIP-50 extensions)
const searchParts: string[] = [];
if (query.trim()) {
searchParts.push(query.trim());
}
// Add language filter (NIP-50 extension supported by Ditto)
if (options.language && options.language !== 'global') {
searchParts.push(`language:${options.language}`);
}
// Add media filter (NIP-50 extension supported by Ditto)
// Only apply to non-vines queries (kind 1)
if (!isVines) {
if (options.mediaType === 'images') {
searchParts.push('media:true');
searchParts.push('video:false');
} else if (options.mediaType === 'videos') {
searchParts.push('video:true');
} else if (options.mediaType === 'none') {
searchParts.push('media:false');
}
// 'all' means no media filter
}
const initialFilter: NostrFilter = { ...streamFilter };
if (searchParts.length > 0) {
initialFilter.search = searchParts.join(' ');
}
// 1. Fetch initial batch with search filters (uses pool, reuses existing connections)
(async () => {
try {
console.log('[useStreamPosts] Fetching initial batch with filter:', initialFilter);
const events = await nostr.query(
[{ ...initialFilter, limit: 40 }],
{ signal: ac.signal },
);
console.log('[useStreamPosts] Received', events.length, 'initial events');
for (const event of events) {
addEvent(event);
}
} catch (error) {
if (!ac.signal.aborted) {
console.error('[useStreamPosts] Initial query error:', error);
}
}
if (alive) setIsLoading(false);
})();
// 2. Stream new events WITHOUT search (relays don't support streaming search)
// Client-side filtering is applied via useMemo at the end
//
// CRITICAL: The pool has eoseTimeout: 500 which aborts req() subscriptions 500ms after
// the first EOSE. This kills streaming! Solution: Use relay() directly for one relay
// to avoid the pool's timeout logic.
(async () => {
try {
const now = Math.floor(Date.now() / 1000);
console.log('[useStreamPosts] Starting stream subscription:', { kinds, since: now });
// Use relay.ditto.pub directly for streaming to avoid pool's eoseTimeout
const dittoRelay = nostr.relay('wss://relay.ditto.pub');
for await (const msg of dittoRelay.req(
[{ ...streamFilter, since: now, limit: 0 }],
{ signal: ac.signal }
)) {
if (!alive) {
console.log('[useStreamPosts] Component unmounted, stopping stream');
break;
}
if (msg[0] === 'EVENT') {
console.log('[useStreamPosts] Received streaming event:', msg[2].id);
addEvent(msg[2]);
} else if (msg[0] === 'EOSE') {
console.log('[useStreamPosts] Received EOSE, subscription is now active for new events');
// Don't break - keep listening for new events
} else if (msg[0] === 'CLOSED') {
console.log('[useStreamPosts] Subscription closed by relay');
break;
}
}
console.log('[useStreamPosts] Stream loop ended');
} catch (error) {
if (!ac.signal.aborted) {
console.error('[useStreamPosts] Stream error:', error);
} else {
console.log('[useStreamPosts] Stream aborted (expected on unmount/filter change)');
}
}
})();
return () => {
console.log('[useStreamPosts] Cleanup: stopping stream');
alive = false;
ac.abort(); // This will still abort the initial query
// The stream subscription will stop on its own when alive=false triggers the break
};
}, [nostr, query, isVines, extraKindsKey, options.language, options.mediaType]);
// Apply client-side filters without restarting the stream
const posts = useMemo(() => {
return allEvents.filter((event) => filterEvent(event, options, query));
}, [allEvents, options.includeReplies, options.mediaType, query]);
return { posts, isLoading };
}