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 <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy
2026-02-17 00:51:12 -06:00
parent 9d3d62f7e6
commit dcf0650eeb
+55 -53
View File
@@ -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<NostrEvent[]>([]);
const [isLoading, setIsLoading] = useState(false);
const closeRef = useRef<(() => void) | null>(null);
const [isLoading, setIsLoading] = useState(true);
const abortRef = useRef<AbortController | null>(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<string, NostrEvent>();
// 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]);