Files
eranos/src/hooks/useStreamPosts.ts
T

252 lines
9.0 KiB
TypeScript

import { useNostr } from '@nostrify/react';
import { useState, useEffect, useMemo } from 'react';
import { useFeedSettings } from './useFeedSettings';
import { useMuteList } from './useMuteList';
import { getEnabledFeedKinds } from '@/lib/extraKinds';
import { isRepostKind } from '@/lib/feedUtils';
import { isReplyEvent } from '@/lib/nostrEvents';
import { isEventMuted } from '@/lib/muteHelpers';
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
import { DITTO_RELAY } from '@/lib/appRelays';
interface StreamPostsOptions {
includeReplies: boolean;
mediaType: 'all' | 'images' | 'videos' | 'vines' | 'none';
language?: string;
/** Protocol strings to pass as protocol: search terms. Defaults to ['nostr']. */
protocols?: 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-text events (extra kinds) pass through without filtering
// Kind 1111 (NIP-22 comments) are treated like kind 1 for filtering purposes
if (event.kind !== 1 && event.kind !== 1111) return true;
// Filter replies
if (!options.includeReplies) {
if (isReplyEvent(event)) 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 { muteItems } = useMuteList();
const [allEvents, setAllEvents] = useState<NostrEvent[]>([]);
const [isLoading, setIsLoading] = useState(true);
// These mediaTypes query dedicated event kinds rather than filtering kind 1
const isDedicatedKindQuery = options.mediaType === 'vines' || options.mediaType === 'images' || options.mediaType === 'videos';
const enabledKinds = getEnabledFeedKinds(feedSettings);
const kindsKey = [...enabledKinds].sort().join(',');
// Stable key for protocols so it can be a useEffect dependency
const protocolsKey = [...(options.protocols ?? ['nostr'])].sort().join(',');
useEffect(() => {
const ac = new AbortController();
let alive = true;
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 based on mediaType
let kinds: number[];
if (options.mediaType === 'vines') {
kinds = [22, 34236]; // shorts + vines
} else if (options.mediaType === 'videos') {
kinds = [21, 22, ...enabledKinds.filter((k) => !isRepostKind(k))];
} else if (options.mediaType === 'images') {
kinds = [20, ...enabledKinds.filter((k) => !isRepostKind(k))];
} else {
kinds = enabledKinds.filter((k) => !isRepostKind(k));
}
// Deduplicate
kinds = [...new Set(kinds)];
// Base filter for streaming (kinds only - no search)
const streamFilter: NostrFilter = { kinds };
// Search filter for initial query (includes NIP-50 extensions)
// protocol:nostr = native Nostr only (no bridged events).
// When bridged protocols are selected, omit protocol:nostr so the relay
// returns both native and bridged events matching the selected protocols.
const protocols = options.protocols ?? ['nostr'];
const bridged = protocols.filter(p => p !== 'nostr');
const searchParts: string[] = bridged.length > 0
? bridged.map(p => `protocol:${p}`)
: ['protocol:nostr'];
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)
// Skip for dedicated-kind queries — kind selection already scopes them
if (!isDedicatedKindQuery) {
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 {
const events = await nostr.query(
[{ ...initialFilter, limit: 40 }],
{ signal: ac.signal },
);
for (const event of events) {
addEvent(event);
}
} catch {
// abort expected
}
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);
// Use relay.ditto.pub directly for streaming to avoid pool's eoseTimeout
const dittoRelay = nostr.relay(DITTO_RELAY);
for await (const msg of dittoRelay.req(
[{ ...streamFilter, since: now, limit: 0 }],
{ signal: ac.signal }
)) {
if (!alive) break;
if (msg[0] === 'EVENT') {
addEvent(msg[2]);
} else if (msg[0] === 'CLOSED') {
break;
}
}
} catch {
// abort expected
}
})();
return () => {
alive = false;
ac.abort();
};
}, [nostr, query, isDedicatedKindQuery, kindsKey, options.language, options.mediaType, protocolsKey]);
// Apply client-side filters (including mute filtering) without restarting the stream
const posts = useMemo(() => {
return allEvents.filter((event) => {
if (muteItems.length > 0 && isEventMuted(event, muteItems)) return false;
return filterEvent(event, options, query);
});
}, [allEvents, options.includeReplies, options.mediaType, protocolsKey, query, muteItems]);
return { posts, isLoading };
}