99f922ea15
- Always use relay.ditto.pub directly for req() streaming (NPool's eoseTimeout: 200 was killing subscriptions prematurely) - Filter out events with created_at in the future - Matches clawchat pattern: fetchInitial + streamNew in parallel - Both query() and req() go to same single relay Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
157 lines
4.4 KiB
TypeScript
157 lines
4.4 KiB
TypeScript
import { useNostr } from '@nostrify/react';
|
|
import { useState, useEffect, useRef } from 'react';
|
|
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
|
|
|
|
interface StreamPostsOptions {
|
|
includeReplies: boolean;
|
|
mediaType: 'all' | 'images' | 'videos' | 'vines' | 'none';
|
|
}
|
|
|
|
/** Extracts image URLs from note content. */
|
|
function extractImages(content: string): string[] {
|
|
const urlRegex = /https?:\/\/[^\s]+\.(jpg|jpeg|png|gif|webp|svg)(\?[^\s]*)?/gi;
|
|
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;
|
|
return content.match(urlRegex) || [];
|
|
}
|
|
|
|
/** Filters an event based on search options and rejects future-dated events. */
|
|
function filterEvent(event: NostrEvent, options: StreamPostsOptions): boolean {
|
|
// Reject events with created_at in the future
|
|
const now = Math.floor(Date.now() / 1000);
|
|
if (event.created_at > now) {
|
|
return false;
|
|
}
|
|
|
|
if (!options.includeReplies) {
|
|
if (event.tags.some(([name]) => name === 'e')) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (options.mediaType !== 'all') {
|
|
const hasImages = extractImages(event.content).length > 0;
|
|
const hasVideos = extractVideos(event.content).length > 0;
|
|
|
|
switch (options.mediaType) {
|
|
case 'images':
|
|
return hasImages;
|
|
case 'videos':
|
|
case 'vines':
|
|
return hasVideos;
|
|
case 'none':
|
|
return !hasImages && !hasVideos;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
// Use relay.ditto.pub for all search streaming (supports NIP-50)
|
|
const SEARCH_RELAY = 'wss://relay.ditto.pub';
|
|
|
|
/**
|
|
* Stream posts in real-time from a single relay.
|
|
* 1. Fetches initial batch via query().
|
|
* 2. Opens a live req() subscription for new posts in parallel.
|
|
*/
|
|
export function useStreamPosts(query: string, options: StreamPostsOptions) {
|
|
const { nostr } = useNostr();
|
|
const [posts, setPosts] = useState<NostrEvent[]>([]);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const abortRef = useRef<AbortController | null>(null);
|
|
|
|
useEffect(() => {
|
|
// Abort any existing stream
|
|
if (abortRef.current) {
|
|
abortRef.current.abort();
|
|
}
|
|
|
|
const ac = new AbortController();
|
|
abortRef.current = ac;
|
|
let isSubscribed = true;
|
|
|
|
setPosts([]);
|
|
setIsLoading(true);
|
|
|
|
const eventMap = new Map<string, NostrEvent>();
|
|
|
|
function addEvent(event: NostrEvent) {
|
|
if (eventMap.has(event.id)) return;
|
|
if (!filterEvent(event, options)) return;
|
|
eventMap.set(event.id, event);
|
|
if (isSubscribed) {
|
|
setPosts(Array.from(eventMap.values()).sort((a, b) => b.created_at - a.created_at));
|
|
}
|
|
}
|
|
|
|
// Always use a single relay for streaming (pool eoseTimeout kills subscriptions)
|
|
const relay = nostr.relay(SEARCH_RELAY);
|
|
|
|
// Build base filter
|
|
const baseFilter: NostrFilter = { kinds: [1] };
|
|
if (query.trim()) {
|
|
baseFilter.search = query.trim();
|
|
}
|
|
|
|
const now = Math.floor(Date.now() / 1000);
|
|
|
|
// 1. Fetch initial batch
|
|
const fetchInitial = async () => {
|
|
try {
|
|
const signal = AbortSignal.any([ac.signal, AbortSignal.timeout(8000)]);
|
|
const events = await relay.query(
|
|
[{ ...baseFilter, limit: 40 }],
|
|
{ signal },
|
|
);
|
|
|
|
for (const event of events) {
|
|
addEvent(event);
|
|
}
|
|
} catch (error) {
|
|
if (error instanceof Error && error.name !== 'AbortError') {
|
|
console.error('Failed to fetch initial posts:', error);
|
|
}
|
|
}
|
|
if (isSubscribed) {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
// 2. Stream new posts in real-time
|
|
const streamNew = async () => {
|
|
try {
|
|
for await (const msg of relay.req([{ ...baseFilter, since: now, limit: 100 }], { signal: ac.signal })) {
|
|
if (!isSubscribed) break;
|
|
|
|
if (msg[0] === 'EVENT') {
|
|
addEvent(msg[2]);
|
|
} else if (msg[0] === 'CLOSED') {
|
|
break;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
if (error instanceof Error && error.name !== 'AbortError') {
|
|
console.error('Failed to maintain stream:', error);
|
|
}
|
|
}
|
|
};
|
|
|
|
// Run both in parallel
|
|
fetchInitial();
|
|
streamNew();
|
|
|
|
return () => {
|
|
isSubscribed = false;
|
|
ac.abort();
|
|
abortRef.current = null;
|
|
};
|
|
}, [query, options.includeReplies, options.mediaType, nostr]);
|
|
|
|
return { posts, isLoading };
|
|
}
|