import { ExtendedKind, FAST_READ_RELAY_URLS, PROFILE_RELAY_URLS } from '@/constants' import { useGlobalRelayBootstrapDefaults } from '@/hooks/use-global-relay-bootstrap-defaults' import type { ProfileTimelineRelayUrlsBuilder } from '@/hooks/useProfileTimeline' import { buildProfilePageReadRelayUrls, mergeRelayUrlLayers } from '@/lib/favorites-feed-relays' import { isNip56ReportEvent } from '@/lib/event' import { isReportAuthoredBy, reportTargetsPubkey } from '@/lib/nip56-reports' import { normalizeHexPubkey } from '@/lib/pubkey' import { dedupeNormalizeRelayUrlsOrdered } from '@/lib/relay-url-priority' import { normalizeAnyRelayUrl, subtractNormalizedRelayUrls } from '@/lib/url' import { useDeletedEvent } from '@/providers/DeletedEventProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useNostrOptional } from '@/providers/nostr-context' import client from '@/services/client.service' import type { TSubRequestFilter } from '@/types' import { useCallback, useEffect, useMemo, useRef, useState, type Dispatch, type SetStateAction } from 'react' import { Event, kinds } from 'nostr-tools' const REPORT_KINDS = [kinds.Report, ExtendedKind.REPORT] as const const CACHE_DURATION = 5 * 60 * 1000 type CacheEntry = { events: Event[]; lastUpdated: number } const memoryByKey = new Map() function relayListsContentKey(favoriteRelays: string[], blockedRelays: string[]): string { const fav = [...favoriteRelays].map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean).sort().join('\u0001') const blk = [...blockedRelays].map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean).sort().join('\u0001') return `${fav}\u0000${blk}` } function mergeReportEvents( raw: Event[], limit: number, isEventDeleted: (e: Event) => boolean, extraFilter?: (e: Event) => boolean ): Event[] { const dedup = new Map() for (const e of raw) { if (!isNip56ReportEvent(e)) continue if (extraFilter && !extraFilter(e)) continue if (isEventDeleted(e)) continue dedup.set(e.id, e) } return [...dedup.values()].sort((a, b) => b.created_at - a.created_at).slice(0, limit) } function eventsEqualById(a: Event[], b: Event[]): boolean { if (a.length !== b.length) return false for (let i = 0; i < a.length; i++) { if (a[i].id !== b[i].id) return false } return true } type FetchMode = 'received' | 'made' function buildFilter(pubkey: string, mode: FetchMode, limit: number): TSubRequestFilter { if (mode === 'made') { return { authors: [pubkey], kinds: [...REPORT_KINDS], limit } } return { kinds: [...REPORT_KINDS], '#p': [pubkey], limit } } function postFilter(pubkey: string, mode: FetchMode) { return mode === 'made' ? (e: Event) => isReportAuthoredBy(e, pubkey) : (e: Event) => reportTargetsPubkey(e, pubkey) } type UseProfileReportsEventsOptions = { pubkey: string relayUrlsBuilder?: ProfileTimelineRelayUrlsBuilder limit?: number } export function useProfileReportsEvents({ pubkey, relayUrlsBuilder, limit = 200 }: UseProfileReportsEventsOptions) { const { favoriteRelays, blockedRelays } = useFavoriteRelays() const useGlobalRelayBootstrap = useGlobalRelayBootstrapDefaults() const nostr = useNostrOptional() const { isEventDeleted, tombstoneEpoch } = useDeletedEvent() const isEventDeletedRef = useRef(isEventDeleted) isEventDeletedRef.current = isEventDeleted const receivedCacheKey = useMemo(() => `${pubkey}-profile-reports-received-v1`, [pubkey]) const madeCacheKey = useMemo(() => `${pubkey}-profile-reports-made-v1`, [pubkey]) const receivedCached = memoryByKey.get(receivedCacheKey) const madeCached = memoryByKey.get(madeCacheKey) const reportsCacheHasRows = (receivedCached?.events.length ?? 0) + (madeCached?.events.length ?? 0) > 0 const reportsCacheFresh = !!receivedCached && !!madeCached && Date.now() - receivedCached.lastUpdated < CACHE_DURATION && Date.now() - madeCached.lastUpdated < CACHE_DURATION const hasUsefulReportsCache = reportsCacheHasRows && reportsCacheFresh const [received, setReceived] = useState( hasUsefulReportsCache ? receivedCached!.events : [] ) const [made, setMade] = useState(hasUsefulReportsCache ? madeCached!.events : []) const [isLoading, setIsLoading] = useState(!hasUsefulReportsCache) const [refreshToken, setRefreshToken] = useState(0) const includeAuthorLocalRelays = useMemo(() => { const me = nostr?.pubkey?.trim() if (!me) return false try { return normalizeHexPubkey(me) === normalizeHexPubkey(pubkey) } catch { return false } }, [nostr?.pubkey, pubkey]) const relayListsKey = useMemo( () => relayListsContentKey(favoriteRelays, blockedRelays), [favoriteRelays, blockedRelays] ) const relayUrlsBuilderRef = useRef(relayUrlsBuilder) relayUrlsBuilderRef.current = relayUrlsBuilder const favoriteRelaysRef = useRef(favoriteRelays) const blockedRelaysRef = useRef(blockedRelays) favoriteRelaysRef.current = favoriteRelays blockedRelaysRef.current = blockedRelays const useGlobalRelayBootstrapRef = useRef(useGlobalRelayBootstrap) useGlobalRelayBootstrapRef.current = useGlobalRelayBootstrap const runGenRef = useRef(0) const resolveFeedUrls = useCallback( ( authorRelayList: { read: string[]; write: string[]; httpRead?: string[]; httpWrite?: string[] }, includeAuthorLocal: boolean ) => { const blocked = blockedRelaysRef.current const profileRead = buildProfilePageReadRelayUrls( favoriteRelaysRef.current, blocked, authorRelayList, false, includeAuthorLocal, [...REPORT_KINDS], useGlobalRelayBootstrapRef.current ) const custom = relayUrlsBuilderRef.current const fromCustom = custom ? custom(favoriteRelaysRef.current, blocked, authorRelayList, includeAuthorLocal) : [] const merged = mergeRelayUrlLayers( custom ? [fromCustom, profileRead] : [profileRead], blocked ) if (merged.length > 0) return merged // NIP-65 still loading: favorites + fast-read only (same as profile feed). return buildProfilePageReadRelayUrls( favoriteRelaysRef.current, blocked, { read: [], write: [], httpRead: [], httpWrite: [] }, false, includeAuthorLocal, [...REPORT_KINDS], useGlobalRelayBootstrapRef.current ) }, [] ) useEffect(() => { setReceived((prev) => { const next = prev.filter((e) => !isEventDeletedRef.current(e)) const c = memoryByKey.get(receivedCacheKey) if (c) memoryByKey.set(receivedCacheKey, { events: next, lastUpdated: c.lastUpdated }) return next }) setMade((prev) => { const next = prev.filter((e) => !isEventDeletedRef.current(e)) const c = memoryByKey.get(madeCacheKey) if (c) memoryByKey.set(madeCacheKey, { events: next, lastUpdated: c.lastUpdated }) return next }) }, [tombstoneEpoch, receivedCacheKey, madeCacheKey]) useEffect(() => { let cancelled = false const runGen = ++runGenRef.current const loadMode = async ( mode: FetchMode, cacheKey: string, setEvents: Dispatch> ) => { const mem = memoryByKey.get(cacheKey) const cacheAge = mem ? Date.now() - mem.lastUpdated : Infinity const isCacheFresh = cacheAge < CACHE_DURATION const pool = new Map() if (isCacheFresh && mem) { mem.events.forEach((e) => pool.set(e.id, e)) } const flush = () => { if (cancelled) return const processed = mergeReportEvents( Array.from(pool.values()), limit, isEventDeletedRef.current, postFilter(pubkey, mode) ) if (processed.length > 0) { memoryByKey.set(cacheKey, { events: processed, lastUpdated: Date.now() }) } else { memoryByKey.delete(cacheKey) } setEvents((prev) => (eventsEqualById(prev, processed) ? prev : processed)) } let pkNorm = pubkey try { pkNorm = normalizeHexPubkey(pubkey) } catch { /* use raw */ } const emptyAuthor = { read: [] as string[], write: [] as string[], httpRead: [] as string[], httpWrite: [] as string[] } const authorPeek = await client.peekRelayListFromStorage(pubkey).catch(() => emptyAuthor) if (cancelled) return let provisionalUrls = resolveFeedUrls(authorPeek, includeAuthorLocalRelays) if (provisionalUrls.length === 0) { provisionalUrls = dedupeNormalizeRelayUrlsOrdered([ ...PROFILE_RELAY_URLS, ...FAST_READ_RELAY_URLS ]).slice(0, 24) } if (provisionalUrls.length === 0) return const filter = buildFilter(pkNorm, mode, limit) const subRequests = [{ urls: provisionalUrls, filter }] try { const disk = await client.getLocalFeedEvents(subRequests) if (!cancelled) { for (const e of disk) pool.set(e.id, e) flush() } } catch { /* best-effort */ } try { const fetched = await client.fetchEvents(provisionalUrls, filter, { cache: true, eoseTimeout: 4500, globalTimeout: 14_000, foreground: true }) if (!cancelled) { for (const e of fetched) pool.set(e.id, e) flush() } } catch { /* ignore */ } const authorRl = await client.fetchRelayList(pubkey).catch(() => emptyAuthor) if (cancelled) return const fullUrls = resolveFeedUrls(authorRl, includeAuthorLocalRelays) const deltaUrls = subtractNormalizedRelayUrls(fullUrls, provisionalUrls) if (deltaUrls.length === 0) return const deltaRequests = [{ urls: deltaUrls, filter }] try { const diskDelta = await client.getLocalFeedEvents(deltaRequests) if (!cancelled) { for (const e of diskDelta) pool.set(e.id, e) flush() } } catch { /* ignore */ } try { const fetchedDelta = await client.fetchEvents(deltaUrls, filter, { cache: true, eoseTimeout: 4500, globalTimeout: 14_000, foreground: true }) if (!cancelled) { for (const e of fetchedDelta) pool.set(e.id, e) flush() } } catch { /* ignore */ } } const run = async () => { const recvMem = memoryByKey.get(receivedCacheKey) const madeMem = memoryByKey.get(madeCacheKey) const recvFresh = recvMem && Date.now() - recvMem.lastUpdated < CACHE_DURATION const madeFresh = madeMem && Date.now() - madeMem.lastUpdated < CACHE_DURATION const cachedAny = (recvMem?.events.length ?? 0) + (madeMem?.events.length ?? 0) > 0 if (recvFresh && recvMem) { setReceived(recvMem.events) } else if (recvMem?.events.length === 0) { memoryByKey.delete(receivedCacheKey) } if (madeFresh && madeMem) { setMade(madeMem.events) } else if (madeMem?.events.length === 0) { memoryByKey.delete(madeCacheKey) } if (recvFresh && madeFresh && refreshToken === 0 && cachedAny) { if (runGen === runGenRef.current) setIsLoading(false) return } setIsLoading(true) try { await Promise.all([ loadMode('received', receivedCacheKey, setReceived), loadMode('made', madeCacheKey, setMade) ]) } finally { if (!cancelled && runGen === runGenRef.current) setIsLoading(false) } } void run() return () => { cancelled = true } }, [ pubkey, receivedCacheKey, madeCacheKey, limit, refreshToken, relayListsKey, includeAuthorLocalRelays, resolveFeedUrls ]) const refresh = useCallback(() => { memoryByKey.delete(receivedCacheKey) memoryByKey.delete(madeCacheKey) setIsLoading(true) setRefreshToken((t) => t + 1) }, [receivedCacheKey, madeCacheKey]) return { received, made, isLoading, refresh } }