import { useEffect, useMemo, useRef, useState, useCallback } from 'react' import { Event } from 'nostr-tools' import client from '@/services/client.service' import { FAST_READ_RELAY_URLS } from '@/constants' import { normalizeUrl } from '@/lib/url' type ProfileTimelineCacheEntry = { events: Event[] lastUpdated: number } const timelineCache = new Map() const relayGroupCache = new Map() type UseProfileTimelineOptions = { pubkey: string cacheKey: string kinds: number[] limit?: number filterPredicate?: (event: Event) => boolean } type UseProfileTimelineResult = { events: Event[] isLoading: boolean refresh: () => void } async function getRelayGroups(pubkey: string): Promise { const cached = relayGroupCache.get(pubkey) if (cached) { return cached } const [relayList, favoriteRelays] = await Promise.all([ client.fetchRelayList(pubkey).catch(() => ({ read: [], write: [] })), client.fetchFavoriteRelays(pubkey).catch(() => []) ]) const groups: string[][] = [] const normalizeList = (urls?: string[]) => Array.from( new Set( (urls || []) .map((url) => normalizeUrl(url)) .filter((value): value is string => !!value) ) ) const readRelays = normalizeList(relayList.read) if (readRelays.length) { groups.push(readRelays) } const writeRelays = normalizeList(relayList.write) if (writeRelays.length) { groups.push(writeRelays) } const favoriteRelayList = normalizeList(favoriteRelays) if (favoriteRelayList.length) { groups.push(favoriteRelayList) } const fastReadRelays = normalizeList(FAST_READ_RELAY_URLS) if (fastReadRelays.length) { groups.push(fastReadRelays) } if (!groups.length) { relayGroupCache.set(pubkey, [fastReadRelays]) return [fastReadRelays] } relayGroupCache.set(pubkey, groups) return groups } function postProcessEvents( rawEvents: Event[], filterPredicate: ((event: Event) => boolean) | undefined, limit: number ) { const dedupMap = new Map() rawEvents.forEach((evt) => { if (!dedupMap.has(evt.id)) { dedupMap.set(evt.id, evt) } }) let events = Array.from(dedupMap.values()) if (filterPredicate) { events = events.filter(filterPredicate) } events.sort((a, b) => b.created_at - a.created_at) return events.slice(0, limit) } export function useProfileTimeline({ pubkey, cacheKey, kinds, limit = 200, filterPredicate }: UseProfileTimelineOptions): UseProfileTimelineResult { const cachedEntry = useMemo(() => timelineCache.get(cacheKey), [cacheKey]) const [events, setEvents] = useState(cachedEntry?.events ?? []) const [isLoading, setIsLoading] = useState(!cachedEntry) const [refreshToken, setRefreshToken] = useState(0) const subscriptionRef = useRef<() => void>(() => {}) useEffect(() => { let cancelled = false const subscribe = async () => { setIsLoading(!timelineCache.has(cacheKey)) try { const relayGroups = await getRelayGroups(pubkey) if (cancelled) { return } const subRequests = relayGroups .map((urls) => ({ urls, filter: { authors: [pubkey], kinds, limit } as any })) .filter((request) => request.urls.length) if (!subRequests.length) { timelineCache.set(cacheKey, { events: [], lastUpdated: Date.now() }) setEvents([]) setIsLoading(false) return } const { closer } = await client.subscribeTimeline( subRequests, { onEvents: (fetchedEvents) => { if (cancelled) return const processed = postProcessEvents(fetchedEvents as Event[], filterPredicate, limit) timelineCache.set(cacheKey, { events: processed, lastUpdated: Date.now() }) setEvents(processed) setIsLoading(false) }, onNew: (evt) => { if (cancelled) return setEvents((prevEvents) => { const combined = [evt as Event, ...prevEvents] const processed = postProcessEvents(combined, filterPredicate, limit) timelineCache.set(cacheKey, { events: processed, lastUpdated: Date.now() }) return processed }) } }, { needSort: true } ) subscriptionRef.current = () => closer() } catch (error) { if (!cancelled) { setIsLoading(false) } } } subscribe() return () => { cancelled = true subscriptionRef.current() subscriptionRef.current = () => {} } }, [pubkey, cacheKey, JSON.stringify(kinds), limit, filterPredicate, refreshToken]) const refresh = useCallback(() => { subscriptionRef.current() subscriptionRef.current = () => {} timelineCache.delete(cacheKey) setIsLoading(true) setRefreshToken((token) => token + 1) }, []) return { events, isLoading, refresh } }