From dcf0650eebc765763f627e5126a52714cd2dbaba Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Tue, 17 Feb 2026 00:51:12 -0600 Subject: [PATCH] Fix streaming: use AsyncIterable API with AbortController, stream by default - Rewrote useStreamPosts to use Nostrify's correct req() API which returns AsyncIterable, not a subscription object with .close() - Uses AbortController to cancel streams on cleanup (fixes sub.close error) - Streams posts by default with no search query (global kind:1 feed) - When search query is provided, uses NIP-50 search on relay.ditto.pub - Properly handles EOSE, CLOSED, and EVENT messages from the stream Co-authored-by: shakespeare.diy --- src/hooks/useStreamPosts.ts | 108 ++++++++++++++++++------------------ 1 file changed, 55 insertions(+), 53 deletions(-) diff --git a/src/hooks/useStreamPosts.ts b/src/hooks/useStreamPosts.ts index ac0954c1..18f44985 100644 --- a/src/hooks/useStreamPosts.ts +++ b/src/hooks/useStreamPosts.ts @@ -1,6 +1,6 @@ import { useNostr } from '@nostrify/react'; -import { useState, useEffect, useRef } from 'react'; -import type { NostrEvent } from '@nostrify/nostrify'; +import { useState, useEffect, useCallback, useRef } from 'react'; +import type { NostrEvent, NostrFilter } from '@nostrify/nostrify'; interface StreamPostsOptions { includeReplies: boolean; @@ -10,32 +10,26 @@ interface StreamPostsOptions { /** Extracts image URLs from note content. */ function extractImages(content: string): string[] { const urlRegex = /https?:\/\/[^\s]+\.(jpg|jpeg|png|gif|webp|svg)(\?[^\s]*)?/gi; - const matches = content.match(urlRegex); - return matches || []; + return content.match(urlRegex) || []; } /** Extracts video URLs from note content. */ function extractVideos(content: string): string[] { const urlRegex = /https?:\/\/[^\s]+\.(mp4|webm|mov|avi|mkv)(\?[^\s]*)?/gi; - const matches = content.match(urlRegex); - return matches || []; + return content.match(urlRegex) || []; } /** Filters an event based on search options. */ function filterEvent(event: NostrEvent, options: StreamPostsOptions): boolean { - // Filter replies if (!options.includeReplies) { if (event.tags.some(([name]) => name === 'e')) { return false; } } - // Filter by media type if (options.mediaType !== 'all') { - const images = extractImages(event.content); - const videos = extractVideos(event.content); - const hasImages = images.length > 0; - const hasVideos = videos.length > 0; + const hasImages = extractImages(event.content).length > 0; + const hasVideos = extractVideos(event.content).length > 0; switch (options.mediaType) { case 'images': @@ -51,64 +45,72 @@ function filterEvent(event: NostrEvent, options: StreamPostsOptions): boolean { return true; } -/** Stream posts in real-time using NIP-50 search on relay.ditto.pub. */ +/** Stream posts in real-time. When a search query is provided, uses NIP-50 on relay.ditto.pub. */ export function useStreamPosts(query: string, options: StreamPostsOptions) { const { nostr } = useNostr(); const [posts, setPosts] = useState([]); - const [isLoading, setIsLoading] = useState(false); - const closeRef = useRef<(() => void) | null>(null); + const [isLoading, setIsLoading] = useState(true); + const abortRef = useRef(null); useEffect(() => { - // Close any existing subscription - if (closeRef.current) { - closeRef.current(); - closeRef.current = null; + // Abort any existing stream + if (abortRef.current) { + abortRef.current.abort(); } - // Reset posts when query changes + const ac = new AbortController(); + abortRef.current = ac; + + // Reset posts when query/filters change setPosts([]); - - // Don't subscribe if query is empty - if (!query.trim()) { - setIsLoading(false); - return; - } - setIsLoading(true); - // Use relay.ditto.pub specifically for NIP-50 post search - const relay = nostr.relay('wss://relay.ditto.pub'); - const eventMap = new Map(); - // Create a subscription for streaming events - const sub = relay.req([{ kinds: [1], search: query.trim(), limit: 0 }], { - onevent(event: NostrEvent) { - // Check if event passes filters - if (!filterEvent(event, options)) { - return; - } + // Build filter + const filter: NostrFilter = { kinds: [1], limit: 0 }; + if (query.trim()) { + filter.search = query.trim(); + } - // Deduplicate and add to map - if (!eventMap.has(event.id)) { - eventMap.set(event.id, event); - - // Update posts state with sorted events - setPosts(Array.from(eventMap.values()).sort((a, b) => b.created_at - a.created_at)); + // Use relay.ditto.pub for NIP-50 search, otherwise default pool + const store = query.trim() + ? nostr.relay('wss://relay.ditto.pub') + : nostr; + + // Run the streaming loop + (async () => { + try { + for await (const msg of store.req([filter], { signal: ac.signal })) { + if (msg[0] === 'EOSE') { + setIsLoading(false); + continue; + } + if (msg[0] === 'CLOSED') { + break; + } + if (msg[0] === 'EVENT') { + const event = msg[2]; + + if (!filterEvent(event, options)) continue; + if (eventMap.has(event.id)) continue; + + eventMap.set(event.id, event); + setPosts( + Array.from(eventMap.values()).sort((a, b) => b.created_at - a.created_at), + ); + } } - }, - oneose() { + } catch { + // AbortError is expected on cleanup + } finally { setIsLoading(false); - }, - }); + } + })(); - // Store close function - closeRef.current = () => sub.close(); - - // Cleanup on unmount or query change return () => { - sub.close(); - closeRef.current = null; + ac.abort(); + abortRef.current = null; }; }, [query, options.includeReplies, options.mediaType, nostr]);