You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
194 lines
5.5 KiB
194 lines
5.5 KiB
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' |
|
import { getPrivateRelayUrls } from '@/lib/private-relays' |
|
|
|
type ProfileNotesTimelineCacheEntry = { |
|
events: Event[] |
|
lastUpdated: number |
|
} |
|
|
|
const timelineCache = new Map<string, ProfileNotesTimelineCacheEntry>() |
|
const CACHE_DURATION = 5 * 60 * 1000 // 5 minutes - cache is considered fresh for this long |
|
|
|
type UseProfileNotesTimelineOptions = { |
|
pubkey: string |
|
cacheKey: string |
|
kinds: number[] |
|
limit?: number |
|
filterPredicate?: (event: Event) => boolean |
|
} |
|
|
|
type UseProfileNotesTimelineResult = { |
|
events: Event[] |
|
isLoading: boolean |
|
refresh: () => void |
|
} |
|
|
|
function postProcessEvents( |
|
rawEvents: Event[], |
|
filterPredicate: ((event: Event) => boolean) | undefined, |
|
limit: number |
|
) { |
|
const dedupMap = new Map<string, Event>() |
|
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 useProfileNotesTimeline({ |
|
pubkey, |
|
cacheKey, |
|
kinds, |
|
limit = 200, |
|
filterPredicate |
|
}: UseProfileNotesTimelineOptions): UseProfileNotesTimelineResult { |
|
const cachedEntry = useMemo(() => timelineCache.get(cacheKey), [cacheKey]) |
|
const [events, setEvents] = useState<Event[]>(cachedEntry?.events ?? []) |
|
const [isLoading, setIsLoading] = useState(!cachedEntry) |
|
const [refreshToken, setRefreshToken] = useState(0) |
|
const subscriptionRef = useRef<() => void>(() => {}) |
|
|
|
useEffect(() => { |
|
let cancelled = false |
|
|
|
const subscribe = async () => { |
|
// Check if we have fresh cached data |
|
const cachedEntry = timelineCache.get(cacheKey) |
|
const cacheAge = cachedEntry ? Date.now() - cachedEntry.lastUpdated : Infinity |
|
const isCacheFresh = cacheAge < CACHE_DURATION |
|
|
|
// If cache is fresh, show it immediately and skip subscribing |
|
if (isCacheFresh && cachedEntry) { |
|
setEvents(cachedEntry.events) |
|
setIsLoading(false) |
|
// Still subscribe in background to get updates, but don't show loading |
|
} else { |
|
// Cache is stale or missing - show loading and fetch |
|
setIsLoading(!cachedEntry) |
|
} |
|
|
|
try { |
|
// Get private relays (outbox + cache relays) for private notes |
|
const privateRelayUrls = await getPrivateRelayUrls(pubkey) |
|
const normalizedPrivateRelays = Array.from( |
|
new Set( |
|
privateRelayUrls |
|
.map((url) => normalizeUrl(url)) |
|
.filter((value): value is string => !!value) |
|
) |
|
) |
|
|
|
// Also include fast read relays as fallback |
|
const fastReadRelays = Array.from( |
|
new Set( |
|
FAST_READ_RELAY_URLS.map((url) => normalizeUrl(url) || url) |
|
) |
|
) |
|
|
|
// Build relay groups: private relays first, then fast read relays |
|
const relayGroups: string[][] = [] |
|
if (normalizedPrivateRelays.length > 0) { |
|
relayGroups.push(normalizedPrivateRelays) |
|
} |
|
if (fastReadRelays.length > 0) { |
|
relayGroups.push(fastReadRelays) |
|
} |
|
|
|
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) |
|
}, [cacheKey]) |
|
|
|
return { |
|
events, |
|
isLoading, |
|
refresh |
|
} |
|
} |
|
|
|
|