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.
486 lines
16 KiB
486 lines
16 KiB
import { useDeletedEventSafe } from '@/providers/DeletedEventProvider' |
|
import client, { eventService } from '@/services/client.service' |
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' |
|
import { Event, kinds as nostrKinds, type Filter } from 'nostr-tools' |
|
import { CALENDAR_EVENT_KINDS, ExtendedKind, isDocumentRelayKind, isSocialKindBlockedKind } from '@/constants' |
|
import { useGlobalRelayBootstrapDefaults } from '@/hooks/use-global-relay-bootstrap-defaults' |
|
import { buildProfilePageReadRelayUrls } from '@/lib/favorites-feed-relays' |
|
import type { ProfileReportsRelayList } from '@/lib/profile-reports-relays' |
|
import { hexPubkeysEqual, normalizeHexPubkey } from '@/lib/pubkey' |
|
import { normalizeAnyRelayUrl, subtractNormalizedRelayUrls } from '@/lib/url' |
|
import { dedupeLatestAddressableEvents } from '@/lib/replaceable-revision' |
|
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' |
|
import { useNostrOptional } from '@/providers/nostr-context' |
|
import indexedDb from '@/services/indexed-db.service' |
|
import type { TSubRequestFilter } from '@/types' |
|
|
|
type ProfileTimelineMemoryEntry = { |
|
events: Event[] |
|
lastUpdated: number |
|
} |
|
|
|
/** 5-minute in-memory cache for this hook only — not IndexedDB, not client timeline refs. */ |
|
const memoryTimelineByKey = new Map<string, ProfileTimelineMemoryEntry>() |
|
const CACHE_DURATION = 5 * 60 * 1000 |
|
|
|
export type ProfileTimelineRelayUrlsBuilder = ( |
|
favoriteRelays: string[], |
|
blockedRelays: string[], |
|
authorRelayList: ProfileReportsRelayList, |
|
includeAuthorLocalRelays: boolean |
|
) => string[] |
|
|
|
type UseProfileTimelineOptions = { |
|
pubkey: string |
|
cacheKey: string |
|
kinds: number[] |
|
limit?: number |
|
filterPredicate?: (event: Event) => boolean |
|
/** When set, replaces {@link buildProfilePageReadRelayUrls} (e.g. profile Reports tab inboxes only). */ |
|
relayUrlsBuilder?: ProfileTimelineRelayUrlsBuilder |
|
} |
|
|
|
type UseProfileTimelineResult = { |
|
events: Event[] |
|
isLoading: boolean |
|
refresh: () => void |
|
} |
|
|
|
function buildSubRequests( |
|
groups: string[][], |
|
pubkey: string, |
|
kindsArg: number[], |
|
limit: number, |
|
hasCalendarKinds: boolean |
|
) { |
|
const authorRequests = groups |
|
.map((urls) => ({ |
|
urls, |
|
filter: { |
|
authors: [pubkey], |
|
kinds: kindsArg, |
|
limit |
|
} as any |
|
})) |
|
.filter((request) => request.urls.length) |
|
const calendarInviteRequests = hasCalendarKinds |
|
? groups |
|
.map((urls) => ({ |
|
urls, |
|
filter: { |
|
kinds: [ExtendedKind.CALENDAR_EVENT_DATE, ExtendedKind.CALENDAR_EVENT_TIME], |
|
'#p': [pubkey], |
|
limit: 100 |
|
} as any |
|
})) |
|
.filter((request) => request.urls.length) |
|
: [] |
|
return [...authorRequests, ...calendarInviteRequests] |
|
} |
|
|
|
function postProcessEvents( |
|
rawEvents: Event[], |
|
filterPredicate: ((event: Event) => boolean) | undefined, |
|
limit: number, |
|
isEventDeleted: (event: Event) => boolean |
|
) { |
|
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()).filter((e) => !isEventDeleted(e)) |
|
|
|
events = dedupeLatestAddressableEvents(events) |
|
|
|
if (filterPredicate) { |
|
events = events.filter(filterPredicate) |
|
} |
|
events.sort((a, b) => b.created_at - a.created_at) |
|
return events.slice(0, limit) |
|
} |
|
|
|
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}` |
|
} |
|
|
|
export function useProfileTimeline({ |
|
pubkey, |
|
cacheKey, |
|
kinds, |
|
limit = 200, |
|
filterPredicate, |
|
relayUrlsBuilder |
|
}: UseProfileTimelineOptions): UseProfileTimelineResult { |
|
const nostr = useNostrOptional() |
|
const { favoriteRelays, blockedRelays } = useFavoriteRelays() |
|
const viewerUsesGlobalBootstrap = useGlobalRelayBootstrapDefaults() |
|
const includeAuthorLocalRelays = useMemo(() => { |
|
const me = nostr?.pubkey?.trim() |
|
if (!me) return false |
|
try { |
|
return hexPubkeysEqual(normalizeHexPubkey(me), normalizeHexPubkey(pubkey)) |
|
} catch { |
|
return false |
|
} |
|
}, [nostr?.pubkey, pubkey]) |
|
/** Own profile: honor viewer relay prefs. Other profiles: always widen with FAST_READ / profile index relays. */ |
|
const useGlobalRelayBootstrap = viewerUsesGlobalBootstrap || !includeAuthorLocalRelays |
|
const relayListsKey = useMemo( |
|
() => relayListsContentKey(favoriteRelays, blockedRelays), |
|
[favoriteRelays, blockedRelays] |
|
) |
|
const { isEventDeleted, tombstoneEpoch } = useDeletedEventSafe() |
|
const isEventDeletedRef = useRef(isEventDeleted) |
|
isEventDeletedRef.current = isEventDeleted |
|
|
|
const filterPredicateRef = useRef(filterPredicate) |
|
filterPredicateRef.current = filterPredicate |
|
const relayUrlsBuilderRef = useRef(relayUrlsBuilder) |
|
relayUrlsBuilderRef.current = relayUrlsBuilder |
|
const limitRef = useRef(limit) |
|
limitRef.current = limit |
|
|
|
const resolveFeedUrls = useCallback( |
|
( |
|
favoriteRelaysArg: string[], |
|
blockedRelaysArg: string[], |
|
authorRelayList: ProfileReportsRelayList, |
|
includeAuthorLocalRelaysArg: boolean, |
|
kindsArg: number[], |
|
useGlobalRelayBootstrapArg: boolean |
|
) => { |
|
const custom = relayUrlsBuilderRef.current |
|
if (custom) { |
|
return custom(favoriteRelaysArg, blockedRelaysArg, authorRelayList, includeAuthorLocalRelaysArg) |
|
} |
|
const socialKinds = kindsArg.some(isSocialKindBlockedKind) |
|
return buildProfilePageReadRelayUrls( |
|
favoriteRelaysArg, |
|
blockedRelaysArg, |
|
authorRelayList as { read: string[]; write: string[]; httpRead?: string[]; httpWrite?: string[] }, |
|
socialKinds, |
|
includeAuthorLocalRelaysArg, |
|
kindsArg, |
|
useGlobalRelayBootstrapArg |
|
) |
|
}, |
|
[] |
|
) |
|
|
|
const cachedEntry = useMemo(() => memoryTimelineByKey.get(cacheKey), [cacheKey]) |
|
const [events, setEvents] = useState<Event[]>(cachedEntry?.events ?? []) |
|
const [isLoading, setIsLoading] = useState(!cachedEntry) |
|
/** Last painted rows — re-seed merge pool after `refresh()` clears memory so relay hiccups do not wipe the list. */ |
|
const latestEventsRef = useRef<Event[]>(events) |
|
latestEventsRef.current = events |
|
const [refreshToken, setRefreshToken] = useState(0) |
|
const subscriptionRef = useRef<() => void>(() => {}) |
|
|
|
useEffect(() => { |
|
setEvents((prev) => { |
|
const next = prev.filter((e) => !isEventDeletedRef.current(e)) |
|
if (next.length === prev.length) return prev |
|
const cached = memoryTimelineByKey.get(cacheKey) |
|
if (cached) { |
|
memoryTimelineByKey.set(cacheKey, { events: next, lastUpdated: cached.lastUpdated }) |
|
} |
|
return next |
|
}) |
|
}, [tombstoneEpoch, cacheKey]) |
|
|
|
useEffect(() => { |
|
let cancelled = false |
|
const closers: (() => void)[] = [] |
|
const pool = new Map<string, Event>() |
|
|
|
const flushPool = () => { |
|
if (cancelled) return |
|
const processed = postProcessEvents( |
|
Array.from(pool.values()), |
|
filterPredicateRef.current, |
|
limitRef.current, |
|
isEventDeletedRef.current |
|
) |
|
memoryTimelineByKey.set(cacheKey, { events: processed, lastUpdated: Date.now() }) |
|
setEvents(processed) |
|
setIsLoading(false) |
|
} |
|
|
|
subscriptionRef.current = () => { |
|
closers.forEach((c) => c()) |
|
closers.length = 0 |
|
} |
|
|
|
const registerCloser = (closer: () => void) => { |
|
if (cancelled) { |
|
closer() |
|
return |
|
} |
|
closers.push(closer) |
|
} |
|
|
|
const subscribe = async () => { |
|
const mem = memoryTimelineByKey.get(cacheKey) |
|
const cacheAge = mem ? Date.now() - mem.lastUpdated : Infinity |
|
const isCacheFresh = cacheAge < CACHE_DURATION |
|
|
|
pool.clear() |
|
if (isCacheFresh && mem) { |
|
setEvents(mem.events) |
|
setIsLoading(false) |
|
mem.events.forEach((e) => pool.set(e.id, e)) |
|
} else { |
|
/** |
|
* Stale memory: keep showing last rows while revalidating (SWR). Previously we set `isLoading` false |
|
* whenever `mem` existed (`!mem` is false), which hid the refresh banner and skipped priming the pool — |
|
* relay failures then left the UI “frozen” on an empty pool with no new merge. |
|
*/ |
|
if (mem?.events?.length) { |
|
mem.events.forEach((e) => pool.set(e.id, e)) |
|
setEvents(mem.events) |
|
setIsLoading(true) |
|
} else { |
|
try { |
|
const pk = normalizeHexPubkey(pubkey) |
|
const primeKinds = new Set(kinds) |
|
for (const e of latestEventsRef.current) { |
|
if (!primeKinds.has(e.kind)) continue |
|
if (normalizeHexPubkey(e.pubkey) === pk) pool.set(e.id, e) |
|
} |
|
if (!cancelled && pool.size > 0) flushPool() |
|
} catch { |
|
/* ignore malformed pubkeys */ |
|
} |
|
} |
|
} |
|
|
|
const hasCalendarKinds = kinds.some((k) => CALENDAR_EVENT_KINDS.includes(k)) |
|
const emptyAuthor = { read: [] as string[], write: [] as string[], httpRead: [] as string[], httpWrite: [] as string[] } |
|
const idbDocKinds = kinds.filter((k) => isDocumentRelayKind(k)) |
|
|
|
let pkNorm: string | null = null |
|
try { |
|
pkNorm = normalizeHexPubkey(pubkey) |
|
} catch { |
|
pkNorm = null |
|
} |
|
|
|
let hadSessionHits = false |
|
if (pkNorm) { |
|
const pkForDisk = pkNorm |
|
try { |
|
const sessionKindList = idbDocKinds.length > 0 ? idbDocKinds : kinds |
|
const fromSession = eventService.listSessionEventsAuthoredBy(pkForDisk, { |
|
kinds: sessionKindList, |
|
limit |
|
}) |
|
hadSessionHits = fromSession.length > 0 |
|
if (!cancelled) { |
|
for (const e of fromSession) { |
|
pool.set(e.id, e as Event) |
|
} |
|
if (fromSession.length) flushPool() |
|
} |
|
} catch { |
|
/* ignore malformed pubkeys */ |
|
} |
|
|
|
void (async () => { |
|
try { |
|
const idbKindsForScan = idbDocKinds.length > 0 ? idbDocKinds : kinds |
|
const maxScan = idbDocKinds.length > 0 ? 18_000 : 16_000 |
|
const pubStorePromise = |
|
idbDocKinds.length > 0 |
|
? indexedDb.getCachedPublicationStoreEventsForProfileAuthor(pkForDisk, idbDocKinds, limit) |
|
: Promise.resolve([] as Event[]) |
|
const [fromPubStore, fromArchive] = await Promise.all([ |
|
pubStorePromise, |
|
indexedDb.scanEventArchiveByAuthorPubkey(pkForDisk, { |
|
kinds: idbKindsForScan, |
|
maxRowsScanned: maxScan, |
|
maxMatches: limit |
|
}) |
|
]) |
|
if (cancelled) return |
|
for (const e of fromPubStore) pool.set(e.id, e) |
|
for (const e of fromArchive) pool.set(e.id, e) |
|
const hadDisk = fromPubStore.length + fromArchive.length > 0 |
|
if (hadDisk) flushPool() |
|
else if (!isCacheFresh && !mem?.events?.length && !hadSessionHits) { |
|
setIsLoading(true) |
|
} |
|
} catch { |
|
/* best-effort */ |
|
} |
|
})() |
|
} else if (!isCacheFresh && !mem?.events?.length) { |
|
setIsLoading(true) |
|
} |
|
|
|
const authorRelayPromise = client.fetchRelayList(pubkey).catch(() => emptyAuthor) |
|
|
|
const provisionalFeedUrls = resolveFeedUrls( |
|
favoriteRelays, |
|
blockedRelays, |
|
emptyAuthor, |
|
includeAuthorLocalRelays, |
|
kinds, |
|
useGlobalRelayBootstrap |
|
) |
|
|
|
const startWave = async (subRequests: ReturnType<typeof buildSubRequests>) => { |
|
if (cancelled || subRequests.length === 0) return |
|
try { |
|
const { closer } = await client.subscribeTimeline( |
|
subRequests, |
|
{ |
|
onEvents: (fetched) => { |
|
if (cancelled) return |
|
for (const e of fetched as Event[]) { |
|
pool.set(e.id, e) |
|
} |
|
flushPool() |
|
}, |
|
onNew: (evt) => { |
|
if (cancelled) return |
|
pool.set((evt as Event).id, evt as Event) |
|
flushPool() |
|
} |
|
}, |
|
{ needSort: true } |
|
) |
|
registerCloser(closer) |
|
} catch { |
|
if (!cancelled) setIsLoading(false) |
|
} |
|
} |
|
|
|
if (provisionalFeedUrls.length === 0) { |
|
if (!cancelled) setIsLoading(false) |
|
return |
|
} |
|
|
|
const provisionalSubs = buildSubRequests([provisionalFeedUrls], pubkey, kinds, limit, hasCalendarKinds) |
|
void (async () => { |
|
let pkForReq = pubkey |
|
try { |
|
pkForReq = normalizeHexPubkey(pubkey) |
|
} catch { |
|
/* use raw pubkey */ |
|
} |
|
const longFormPrefetch = |
|
idbDocKinds.includes(nostrKinds.LongFormArticle) && provisionalFeedUrls.length > 0 |
|
? client.fetchEvents( |
|
provisionalFeedUrls, |
|
{ |
|
authors: [pkForReq], |
|
kinds: [nostrKinds.LongFormArticle], |
|
limit |
|
} as Filter, |
|
{ cache: true, eoseTimeout: 4500, globalTimeout: 14_000, replaceableRace: true } |
|
).catch(() => [] as Event[]) |
|
: Promise.resolve([] as Event[]) |
|
|
|
try { |
|
const [disk, longFormRows] = await Promise.all([ |
|
client.getLocalFeedEvents( |
|
provisionalSubs as Array<{ urls: string[]; filter: TSubRequestFilter }> |
|
), |
|
longFormPrefetch |
|
]) |
|
if (!cancelled && disk.length > 0) { |
|
for (const e of disk) { |
|
pool.set(e.id, e) |
|
} |
|
flushPool() |
|
} |
|
if (!cancelled && longFormRows.length > 0) { |
|
for (const e of longFormRows) { |
|
pool.set(e.id, e) |
|
} |
|
flushPool() |
|
} |
|
} catch { |
|
/* disk snapshot is best-effort */ |
|
} |
|
try { |
|
await startWave(provisionalSubs) |
|
} finally { |
|
/** Subscriptions are live; sync UI even if the merged layer was slow to emit (empty feed is valid). */ |
|
if (!cancelled) flushPool() |
|
} |
|
})() |
|
|
|
void (async () => { |
|
const authorRl = await authorRelayPromise |
|
if (cancelled) return |
|
const fullFeedUrls = resolveFeedUrls( |
|
favoriteRelays, |
|
blockedRelays, |
|
authorRl, |
|
includeAuthorLocalRelays, |
|
kinds, |
|
useGlobalRelayBootstrap |
|
) |
|
const deltaUrls = subtractNormalizedRelayUrls(fullFeedUrls, provisionalFeedUrls) |
|
if (cancelled || deltaUrls.length === 0) return |
|
const deltaSubs = buildSubRequests([deltaUrls], pubkey, kinds, limit, hasCalendarKinds) |
|
try { |
|
const diskDelta = await client.getLocalFeedEvents( |
|
deltaSubs as Array<{ urls: string[]; filter: TSubRequestFilter }> |
|
) |
|
if (!cancelled && diskDelta.length > 0) { |
|
for (const e of diskDelta) { |
|
pool.set(e.id, e) |
|
} |
|
flushPool() |
|
} |
|
} catch { |
|
/* optional */ |
|
} |
|
try { |
|
await startWave(deltaSubs) |
|
} finally { |
|
if (!cancelled) flushPool() |
|
} |
|
})() |
|
} |
|
|
|
void subscribe() |
|
|
|
return () => { |
|
cancelled = true |
|
subscriptionRef.current() |
|
subscriptionRef.current = () => {} |
|
} |
|
}, [ |
|
pubkey, |
|
cacheKey, |
|
JSON.stringify(kinds), |
|
limit, |
|
refreshToken, |
|
relayListsKey, |
|
includeAuthorLocalRelays, |
|
useGlobalRelayBootstrap, |
|
resolveFeedUrls |
|
]) |
|
|
|
const refresh = useCallback(() => { |
|
subscriptionRef.current() |
|
subscriptionRef.current = () => {} |
|
memoryTimelineByKey.delete(cacheKey) |
|
setIsLoading(true) |
|
setRefreshToken((token) => token + 1) |
|
}, [cacheKey]) |
|
|
|
return { |
|
events, |
|
isLoading, |
|
refresh |
|
} |
|
}
|
|
|