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.
205 lines
5.2 KiB
205 lines
5.2 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' |
|
|
|
type ProfileTimelineCacheEntry = { |
|
events: Event[] |
|
lastUpdated: number |
|
} |
|
|
|
const timelineCache = new Map<string, ProfileTimelineCacheEntry>() |
|
const relayGroupCache = new Map<string, string[][]>() |
|
|
|
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<string[][]> { |
|
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<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 useProfileTimeline({ |
|
pubkey, |
|
cacheKey, |
|
kinds, |
|
limit = 200, |
|
filterPredicate |
|
}: UseProfileTimelineOptions): UseProfileTimelineResult { |
|
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 () => { |
|
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 |
|
} |
|
} |
|
|
|
|