import { E_TAG_FILTER_BLOCKED_RELAY_URLS, FAST_READ_RELAY_URLS, SEARCHABLE_RELAY_URLS, THREAD_BACKLINK_STREAM_KINDS_WITHOUT_HIGHLIGHT } from '@/constants' import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event' import { buildNormalizedBlockedRelaySet } from '@/lib/thread-response-filter' import { normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url' import { useCurrentRelays } from '@/providers/CurrentRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useNostr } from '@/providers/NostrProvider' import client from '@/services/client.service' import type { TSubRequestFilter } from '@/types' import dayjs from 'dayjs' import { Event, kinds } from 'nostr-tools' import { useEffect, useMemo, useRef, useState } from 'react' const LIMIT = 100 const INITIAL_QUOTE_LOAD_TIMEOUT_MS = 12_000 /** Fetches events that quote or reference the given event (#q, #e, #a tags). */ export function useQuoteEvents(event: Event | null, enabled: boolean) { const { relayList: userRelayList } = useNostr() const { relayUrls: browsingRelayUrls } = useCurrentRelays() const { blockedRelays } = useFavoriteRelays() const userBlockedRelaysNorm = useMemo( () => buildNormalizedBlockedRelaySet(blockedRelays), [blockedRelays] ) const [timelineKey, setTimelineKey] = useState(undefined) const [events, setEvents] = useState([]) const [loading, setLoading] = useState(true) const [hasMore, setHasMore] = useState(true) const receivedAnyQuotesRef = useRef(false) const lastSubscribedEventIdRef = useRef(null) useEffect(() => { if (!event || !enabled) { setEvents([]) setLoading(false) setHasMore(false) lastSubscribedEventIdRef.current = null return } const ev = event let cancelled = false let loadTimeoutId: ReturnType | undefined async function init() { const noteRowId = ev.id const isNewTarget = lastSubscribedEventIdRef.current !== noteRowId lastSubscribedEventIdRef.current = noteRowId setLoading(true) if (isNewTarget) { setEvents([]) receivedAnyQuotesRef.current = false } setHasMore(true) loadTimeoutId = setTimeout(() => { if (cancelled) return setLoading(false) if (!receivedAnyQuotesRef.current) { setHasMore(false) } }, INITIAL_QUOTE_LOAD_TIMEOUT_MS) const userRelays = userRelayList?.read || [] const fromFeed = browsingRelayUrls.map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean) const seenOn = client.getSeenEventRelayUrls(ev.id) const eTagBlockedSet = new Set( E_TAG_FILTER_BLOCKED_RELAY_URLS.map((u) => normalizeUrl(u) || u) ) const finalRelayUrls = Array.from( new Set([ ...fromFeed, ...userRelays.map((url) => normalizeUrl(url) || url), ...seenOn, ...SEARCHABLE_RELAY_URLS.map((url) => normalizeUrl(url) || url), ...FAST_READ_RELAY_URLS.map((url) => normalizeUrl(url) || url) ]) ) .filter(Boolean) .filter((u) => !eTagBlockedSet.has(normalizeUrl(u) || u)) .filter((u) => !userBlockedRelaysNorm.has((normalizeUrl(u) || u).toLowerCase())) const filterQeId = isReplaceableEvent(ev.kind) ? getReplaceableCoordinateFromEvent(ev) : ev.id const qeIdForTagFilter = /^[0-9a-f]{64}$/i.test(filterQeId) ? filterQeId.toLowerCase() : filterQeId const qeIdIsHexEventId = /^[0-9a-f]{64}$/i.test(qeIdForTagFilter) const eventCoordinate = isReplaceableEvent(ev.kind) ? getReplaceableCoordinateFromEvent(ev) : `${ev.kind}:${ev.pubkey}:${ev.id}` const highlightKinds = [kinds.Highlights] as const const otherBacklinkKinds = [...THREAD_BACKLINK_STREAM_KINDS_WITHOUT_HIGHLIGHT] const subRequests: { urls: string[]; filter: TSubRequestFilter }[] = [ { urls: finalRelayUrls, filter: { '#q': [qeIdForTagFilter], kinds: [kinds.ShortTextNote], limit: LIMIT } }, { urls: finalRelayUrls, filter: { '#q': [qeIdForTagFilter], kinds: [...highlightKinds], limit: LIMIT } }, { urls: finalRelayUrls, filter: { '#a': [eventCoordinate], kinds: [...highlightKinds], limit: LIMIT } }, { urls: finalRelayUrls, filter: { '#a': [eventCoordinate], kinds: otherBacklinkKinds, limit: LIMIT } } ] // `#e` tag filters must use 64-hex event ids. For replaceable roots we use `#a`/`#q` only. if (qeIdIsHexEventId) { subRequests.push( { urls: finalRelayUrls, filter: { '#e': [qeIdForTagFilter], kinds: [...highlightKinds], limit: LIMIT } }, { urls: finalRelayUrls, filter: { '#e': [qeIdForTagFilter], kinds: otherBacklinkKinds, limit: LIMIT } } ) } const { closer, timelineKey } = await client.subscribeTimeline( subRequests, { onEvents: (batch, eosed) => { if (cancelled) return if (batch.length > 0) { receivedAnyQuotesRef.current = true setEvents(batch) } if (batch.length > 0 || eosed) { setLoading(false) if (loadTimeoutId) { clearTimeout(loadTimeoutId) loadTimeoutId = undefined } } if (eosed) { setHasMore(batch.length > 0) } }, onNew: (newEvt) => { if (cancelled) return receivedAnyQuotesRef.current = true setLoading(false) if (loadTimeoutId) { clearTimeout(loadTimeoutId) loadTimeoutId = undefined } setHasMore(true) setEvents((oldEvents) => [newEvt, ...oldEvents].sort((a, b) => b.created_at - a.created_at) ) } } ) if (cancelled) { closer() return undefined } setTimelineKey(timelineKey) return closer } const promise = init() return () => { cancelled = true if (loadTimeoutId) clearTimeout(loadTimeoutId) promise.then((closer) => closer?.()) } }, [event, enabled, browsingRelayUrls, userRelayList?.read, userBlockedRelaysNorm]) const loadMore = async () => { if (!timelineKey || loading || !hasMore) return setLoading(true) try { const newEvents = await client.loadMoreTimeline( timelineKey, events.length ? events[events.length - 1].created_at - 1 : dayjs().unix(), LIMIT ) if (newEvents.length === 0) { const until = events.length ? events[events.length - 1].created_at - 1 : dayjs().unix() const hasMoreCached = client.hasMoreTimelineEvents?.(timelineKey, until) ?? false if (!hasMoreCached) setHasMore(false) } else { setEvents((old) => [...old, ...newEvents]) } } catch { setHasMore(false) } finally { setLoading(false) } } return { quoteEvents: events, quoteLoading: loading, quoteHasMore: hasMore, loadMoreQuotes: loadMore } }