6b9aeddb06
- Remove orphaned JSDoc and dead isShort code path in VideoContent - Unify vine mute state into shared vineGlobalMute module so mute preference carries between NoteCard feeds and VinesFeedPage - Fix fetchNextPage race cascade by using a ref guard instead of putting isFetchingNextPage in the useCallback dep array - Batch event ingestion in useStreamPosts so the event map is sorted and flushed to state once per page instead of per-event - Don't permanently kill pagination on transient network errors - Move sort modifiers into searchParts before the join for consistency - Wrap full VideoGridCard/ShortThumb content in ContentWarningGuard so metadata doesn't leak when policy is blur
507 lines
20 KiB
TypeScript
507 lines
20 KiB
TypeScript
import { useNostr } from '@nostrify/react';
|
|
import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
|
import { useFeedSettings } from './useFeedSettings';
|
|
import { useMuteList } from './useMuteList';
|
|
import { useContentFilters } from './useContentFilters';
|
|
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_RELAYS } from '@/lib/appRelays';
|
|
import { nip19 } from 'nostr-tools';
|
|
|
|
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[];
|
|
/**
|
|
* When set, overrides the automatic kind selection entirely.
|
|
* The search will only query these specific kind numbers.
|
|
*/
|
|
kindsOverride?: number[];
|
|
/**
|
|
* When set, limits results to events authored by these pubkeys.
|
|
* Each entry accepts raw hex or npub-encoded pubkeys.
|
|
*/
|
|
authorPubkeys?: string[];
|
|
/** NIP-50 sort preference. 'recent' = default (no sort: term). */
|
|
sort?: 'recent' | 'hot' | 'trending';
|
|
}
|
|
|
|
/** 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;
|
|
|
|
// Protocol filter — streaming events carry a 'proxy' tag for bridged protocols.
|
|
// A missing proxy tag = native Nostr. Filter based on selected protocol.
|
|
const protocols = options.protocols ?? ['nostr'];
|
|
if (!protocols.includes('nostr') || protocols.length > 1) {
|
|
const proxyTag = event.tags.find(([name]) => name === 'proxy');
|
|
if (protocols.includes('nostr') && !protocols.some(p => p !== 'nostr')) {
|
|
// nostr only: reject events with a proxy tag
|
|
if (proxyTag) return false;
|
|
} else {
|
|
// bridged protocol selected: only keep events that have a matching proxy tag
|
|
// and optionally native nostr events if 'nostr' is also in protocols
|
|
const hasProxy = !!proxyTag;
|
|
const isNative = !hasProxy;
|
|
if (isNative && !protocols.includes('nostr')) return false;
|
|
if (hasProxy) {
|
|
// proxy tag format: ['proxy', '<uri>', '<protocol>']
|
|
const proxyProtocol = proxyTag?.[2]?.toLowerCase();
|
|
const wantedBridged = protocols.filter(p => p !== 'nostr');
|
|
if (!wantedBridged.some(p => proxyProtocol?.includes(p))) return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Filter replies (kind 1 and 1111 only)
|
|
if (event.kind === 1 || event.kind === 1111) {
|
|
if (!options.includeReplies && isReplyEvent(event)) return false;
|
|
}
|
|
|
|
// Client-side search — applied to all kinds for streamed events.
|
|
// The initial batch uses relay-level NIP-50 search; streamed events have no
|
|
// search filter at the relay, so we must enforce it here.
|
|
if (searchQuery.trim()) {
|
|
const lowerQuery = searchQuery.toLowerCase();
|
|
const lowerContent = event.content.toLowerCase();
|
|
// For non-text events also check the title/summary/subject tags
|
|
const searchableTags = ['title', 'summary', 'subject', 'alt'];
|
|
const tagText = searchableTags
|
|
.flatMap((name) => event.tags.filter(([t]) => t === name).map(([, v]) => v ?? ''))
|
|
.join(' ')
|
|
.toLowerCase();
|
|
if (!lowerContent.includes(lowerQuery) && !tagText.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':
|
|
// Vines are kinds 22/34236; kind 1 posts aren't vines — filter them out
|
|
// (streaming for vines uses kind 22/34236 in streamFilter, so kind 1 events
|
|
// that slip through from cache should be rejected)
|
|
if (event.kind === 1 || event.kind === 1111) return false;
|
|
break;
|
|
case 'none':
|
|
if (hasImages || hasVideos) return false;
|
|
break;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/** Number of events to fetch per page. */
|
|
const PAGE_SIZE = 40;
|
|
|
|
/**
|
|
* 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 { shouldFilterEvent } = useContentFilters();
|
|
const [allEvents, setAllEvents] = useState<NostrEvent[]>([]);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
// Buffer for streamed events — held separately until user scrolls back up
|
|
const streamBufferRef = useRef<NostrEvent[]>([]);
|
|
const [streamBufferCount, setStreamBufferCount] = useState(0);
|
|
// Track whether initial batch has loaded
|
|
const initialLoadDoneRef = useRef(false);
|
|
// Track whether user has scrolled away from the top
|
|
const isScrolledRef = useRef(false);
|
|
// IDs of events that were just flushed from the buffer (for highlight animation)
|
|
const [flushedIds, setFlushedIds] = useState<Set<string>>(new Set());
|
|
const flushedTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
|
|
// Pagination state
|
|
const [isFetchingNextPage, setIsFetchingNextPage] = useState(false);
|
|
const isFetchingRef = useRef(false);
|
|
const [hasNextPage, setHasNextPage] = useState(true);
|
|
const oldestTimestampRef = useRef<number | null>(null);
|
|
|
|
/** Merge buffered events into the main list and mark them as flushed. */
|
|
const doFlush = useCallback(() => {
|
|
if (streamBufferRef.current.length === 0) return;
|
|
const ids = new Set(streamBufferRef.current.map((e) => e.id));
|
|
setAllEvents((prev) => {
|
|
const merged = [...prev, ...streamBufferRef.current];
|
|
merged.sort((a, b) => b.created_at - a.created_at);
|
|
return merged;
|
|
});
|
|
streamBufferRef.current = [];
|
|
setStreamBufferCount(0);
|
|
// Show highlight briefly then clear
|
|
setFlushedIds(ids);
|
|
clearTimeout(flushedTimerRef.current);
|
|
flushedTimerRef.current = setTimeout(() => setFlushedIds(new Set()), 1500);
|
|
}, []);
|
|
|
|
// Clean up timer on unmount
|
|
useEffect(() => () => clearTimeout(flushedTimerRef.current), []);
|
|
|
|
// Monitor scroll position — only buffer when user is scrolled down
|
|
useEffect(() => {
|
|
const threshold = 200; // px from top
|
|
function onScroll() {
|
|
isScrolledRef.current = window.scrollY > threshold;
|
|
// Auto-flush when user scrolls back to the top
|
|
if (!isScrolledRef.current && streamBufferRef.current.length > 0) {
|
|
doFlush();
|
|
}
|
|
}
|
|
window.addEventListener('scroll', onScroll, { passive: true });
|
|
return () => window.removeEventListener('scroll', onScroll);
|
|
}, [doFlush]);
|
|
|
|
// Resolve authorPubkeys: accept hex or npub-encoded entries
|
|
const resolvedAuthorPubkeys = useMemo(() => {
|
|
if (!options.authorPubkeys || options.authorPubkeys.length === 0) return undefined;
|
|
const resolved: string[] = [];
|
|
for (const raw of options.authorPubkeys) {
|
|
const t = raw.trim();
|
|
if (/^[0-9a-f]{64}$/i.test(t)) {
|
|
resolved.push(t);
|
|
} else {
|
|
try {
|
|
const decoded = nip19.decode(t);
|
|
if (decoded.type === 'npub') resolved.push(decoded.data);
|
|
} catch { /* ignore */ }
|
|
}
|
|
}
|
|
return resolved.length > 0 ? resolved : undefined;
|
|
}, [options.authorPubkeys]);
|
|
|
|
// These mediaTypes query dedicated event kinds rather than filtering kind 1
|
|
const isDedicatedKindQuery = !options.kindsOverride && (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(',');
|
|
|
|
// Stable key for kindsOverride
|
|
const kindsOverrideKey = options.kindsOverride ? [...options.kindsOverride].sort().join(',') : '';
|
|
|
|
// Stable key for authorPubkeys (follows list)
|
|
const authorPubkeysKey = options.authorPubkeys ? [...options.authorPubkeys].sort().join(',') : '';
|
|
|
|
// Build the search filter once — reused by initial fetch and pagination.
|
|
const paginationFilter = useMemo(() => {
|
|
// Build the kinds list based on mediaType (or override entirely)
|
|
let kinds: number[];
|
|
if (options.kindsOverride && options.kindsOverride.length > 0) {
|
|
kinds = [...options.kindsOverride];
|
|
} else 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 (kinds only - no search)
|
|
const streamFilter: NostrFilter = { kinds };
|
|
|
|
// Search filter for queries (includes NIP-50 extensions)
|
|
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');
|
|
}
|
|
}
|
|
|
|
// Sort preference (NIP-50 extension)
|
|
if (options.sort === 'hot') {
|
|
searchParts.push('sort:hot');
|
|
} else if (options.sort === 'trending') {
|
|
searchParts.push('sort:trending');
|
|
}
|
|
|
|
const searchFilter: NostrFilter = { ...streamFilter };
|
|
if (searchParts.length > 0) {
|
|
searchFilter.search = searchParts.join(' ');
|
|
}
|
|
|
|
// Author filter
|
|
if (resolvedAuthorPubkeys && resolvedAuthorPubkeys.length > 0) {
|
|
searchFilter.authors = resolvedAuthorPubkeys;
|
|
streamFilter.authors = resolvedAuthorPubkeys;
|
|
}
|
|
|
|
return { searchFilter, streamFilter };
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- enabledKinds is stabilized via kindsKey; options.protocols via protocolsKey; kindsOverride via kindsOverrideKey; authorPubkeys via authorPubkeysKey
|
|
}, [query, isDedicatedKindQuery, kindsKey, options.language, options.mediaType, protocolsKey, kindsOverrideKey, authorPubkeysKey, options.sort]);
|
|
|
|
// Shared ref for the event map and known IDs — persists across initial fetch + pagination
|
|
const eventMapRef = useRef(new Map<string, NostrEvent>());
|
|
const knownIdsRef = useRef(new Set<string>());
|
|
|
|
/** Flush the event map into React state (sorted). Call once after a batch of mutations. */
|
|
const flushEventMap = useCallback(() => {
|
|
setAllEvents(Array.from(eventMapRef.current.values()).sort((a, b) => b.created_at - a.created_at));
|
|
}, []);
|
|
|
|
/** Ingest a single event into the event map / stream buffer. Returns true if the map was mutated. */
|
|
const ingestEvent = useCallback((event: NostrEvent, isStreamed: boolean): boolean => {
|
|
const now = Math.floor(Date.now() / 1000);
|
|
if (event.created_at > now) return false;
|
|
|
|
// Dedupe key
|
|
let dedupeKey: string;
|
|
if (event.kind >= 30000 && event.kind < 40000) {
|
|
const dTag = event.tags.find(([name]) => name === 'd')?.[1] ?? '';
|
|
dedupeKey = `${event.pubkey}:${event.kind}:${dTag}`;
|
|
} else {
|
|
dedupeKey = event.id;
|
|
}
|
|
|
|
// Buffer streamed events only when user is scrolled down to avoid scroll jumps.
|
|
if (isStreamed && initialLoadDoneRef.current && isScrolledRef.current) {
|
|
if (knownIdsRef.current.has(dedupeKey)) return false;
|
|
knownIdsRef.current.add(dedupeKey);
|
|
streamBufferRef.current = [...streamBufferRef.current, event];
|
|
setStreamBufferCount(streamBufferRef.current.length);
|
|
return false;
|
|
}
|
|
|
|
// Addressable events (30000-39999) dedupe by pubkey+kind+d
|
|
if (event.kind >= 30000 && event.kind < 40000) {
|
|
const existing = eventMapRef.current.get(dedupeKey);
|
|
if (existing && existing.created_at >= event.created_at) return false;
|
|
eventMapRef.current.set(dedupeKey, event);
|
|
} else {
|
|
if (eventMapRef.current.has(dedupeKey)) return false;
|
|
eventMapRef.current.set(dedupeKey, event);
|
|
}
|
|
knownIdsRef.current.add(dedupeKey);
|
|
return true;
|
|
}, []);
|
|
|
|
/** Add a single streamed event and flush immediately. */
|
|
const addStreamedEvent = useCallback((event: NostrEvent) => {
|
|
if (ingestEvent(event, true)) {
|
|
flushEventMap();
|
|
}
|
|
}, [ingestEvent, flushEventMap]);
|
|
|
|
useEffect(() => {
|
|
const ac = new AbortController();
|
|
let alive = true;
|
|
|
|
// Reset all state on filter change
|
|
setAllEvents([]);
|
|
setIsLoading(true);
|
|
setHasNextPage(true);
|
|
isFetchingRef.current = false;
|
|
oldestTimestampRef.current = null;
|
|
initialLoadDoneRef.current = false;
|
|
streamBufferRef.current = [];
|
|
setStreamBufferCount(0);
|
|
eventMapRef.current = new Map();
|
|
knownIdsRef.current = new Set();
|
|
|
|
const { searchFilter, streamFilter } = paginationFilter;
|
|
|
|
// 1. Fetch initial batch with search filters (uses pool, reuses existing connections)
|
|
(async () => {
|
|
try {
|
|
const events = await nostr.query(
|
|
[{ ...searchFilter, limit: PAGE_SIZE }],
|
|
{ signal: ac.signal },
|
|
);
|
|
for (const event of events) {
|
|
ingestEvent(event, false);
|
|
}
|
|
flushEventMap();
|
|
// Track oldest timestamp for pagination
|
|
if (events.length > 0) {
|
|
const oldest = Math.min(...events.map((e) => e.created_at));
|
|
oldestTimestampRef.current = oldest;
|
|
}
|
|
// If we got fewer events than PAGE_SIZE, there's no more to fetch
|
|
if (events.length < PAGE_SIZE) {
|
|
setHasNextPage(false);
|
|
}
|
|
} catch {
|
|
// abort expected
|
|
}
|
|
if (alive) {
|
|
initialLoadDoneRef.current = true;
|
|
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 Ditto relays directly for streaming to avoid pool's eoseTimeout
|
|
const dittoRelay = nostr.group(DITTO_RELAYS);
|
|
|
|
for await (const msg of dittoRelay.req(
|
|
[{ ...streamFilter, since: now, limit: 0 }],
|
|
{ signal: ac.signal }
|
|
)) {
|
|
if (!alive) break;
|
|
|
|
if (msg[0] === 'EVENT') {
|
|
addStreamedEvent(msg[2]);
|
|
} else if (msg[0] === 'CLOSED') {
|
|
break;
|
|
}
|
|
}
|
|
} catch {
|
|
// abort expected
|
|
}
|
|
})();
|
|
|
|
return () => {
|
|
alive = false;
|
|
ac.abort();
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- ingestEvent/flushEventMap/addStreamedEvent are stable ref-based callbacks
|
|
}, [nostr, paginationFilter]);
|
|
|
|
/** Fetch the next page of older results. */
|
|
const fetchNextPage = useCallback(async () => {
|
|
if (isFetchingRef.current || !hasNextPage || oldestTimestampRef.current === null) return;
|
|
isFetchingRef.current = true;
|
|
setIsFetchingNextPage(true);
|
|
try {
|
|
const { searchFilter } = paginationFilter;
|
|
const events = await nostr.query(
|
|
[{ ...searchFilter, until: oldestTimestampRef.current - 1, limit: PAGE_SIZE }],
|
|
);
|
|
for (const event of events) {
|
|
ingestEvent(event, false);
|
|
}
|
|
flushEventMap();
|
|
if (events.length > 0) {
|
|
const oldest = Math.min(...events.map((e) => e.created_at));
|
|
oldestTimestampRef.current = oldest;
|
|
}
|
|
if (events.length < PAGE_SIZE) {
|
|
setHasNextPage(false);
|
|
}
|
|
} catch {
|
|
// Transient failure — leave hasNextPage true so the user can retry
|
|
} finally {
|
|
isFetchingRef.current = false;
|
|
setIsFetchingNextPage(false);
|
|
}
|
|
}, [hasNextPage, paginationFilter, nostr, ingestEvent, flushEventMap]);
|
|
|
|
// Flush buffered streamed events into the main list (called by UI when user wants to see new posts)
|
|
const flushStreamBuffer = doFlush;
|
|
|
|
// Shared predicate for client-side filtering (mute, content, search, media, author, etc.)
|
|
const matchesFilters = useCallback((event: NostrEvent) => {
|
|
if (muteItems.length > 0 && isEventMuted(event, muteItems)) return false;
|
|
if (shouldFilterEvent(event)) return false;
|
|
if (resolvedAuthorPubkeys) {
|
|
const authorSet = new Set(resolvedAuthorPubkeys);
|
|
if (!authorSet.has(event.pubkey)) return false;
|
|
}
|
|
return filterEvent(event, options, query);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- using specific options fields instead of the whole object for granular reactivity
|
|
}, [options.includeReplies, options.mediaType, protocolsKey, query, muteItems, resolvedAuthorPubkeys, shouldFilterEvent, authorPubkeysKey]);
|
|
|
|
// Apply client-side filters (including mute filtering and content filters) without restarting the stream
|
|
const posts = useMemo(() => {
|
|
return allEvents.filter(matchesFilters);
|
|
}, [allEvents, matchesFilters]);
|
|
|
|
// Count only buffered events that pass the same filters so the "N new posts"
|
|
// pill reflects the actual number the user will see after flushing.
|
|
const filteredNewPostCount = useMemo(() => {
|
|
return streamBufferRef.current.filter(matchesFilters).length;
|
|
// streamBufferCount is used as a dependency to re-evaluate when the buffer changes
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [streamBufferCount, matchesFilters]);
|
|
|
|
return {
|
|
posts,
|
|
isLoading,
|
|
newPostCount: filteredNewPostCount,
|
|
flushStreamBuffer,
|
|
flushedIds,
|
|
// Pagination
|
|
fetchNextPage,
|
|
hasNextPage,
|
|
isFetchingNextPage,
|
|
};
|
|
}
|