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:
+55
-53
@@ -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]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user