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.
488 lines
16 KiB
488 lines
16 KiB
import { useDeletedEvent } 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 { buildProfilePageReadRelayUrls } from '@/lib/favorites-feed-relays' |
|
import { hexPubkeysEqual, normalizeHexPubkey } from '@/lib/pubkey' |
|
import { normalizeAnyRelayUrl, subtractNormalizedRelayUrls } from '@/lib/url' |
|
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 |
|
|
|
type UseProfileTimelineOptions = { |
|
pubkey: string |
|
cacheKey: string |
|
kinds: number[] |
|
limit?: number |
|
filterPredicate?: (event: Event) => boolean |
|
} |
|
|
|
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)) |
|
|
|
// Parameterized replaceable events (kinds 30000-39999) should be unique by pubkey+kind+d. |
|
// Keep only the latest version so profile feeds don't show multiple revisions of one article. |
|
const latestAddressableByKey = new Map<string, Event>() |
|
const nonAddressableEvents: Event[] = [] |
|
events.forEach((evt) => { |
|
const isAddressable = evt.kind >= 30000 && evt.kind < 40000 |
|
if (!isAddressable) { |
|
nonAddressableEvents.push(evt) |
|
return |
|
} |
|
const d = evt.tags.find((t) => t[0] === 'd')?.[1]?.trim() |
|
if (!d) { |
|
nonAddressableEvents.push(evt) |
|
return |
|
} |
|
const key = `${evt.pubkey}:${evt.kind}:${d}` |
|
const existing = latestAddressableByKey.get(key) |
|
if ( |
|
!existing || |
|
evt.created_at > existing.created_at || |
|
(evt.created_at === existing.created_at && evt.id > existing.id) |
|
) { |
|
latestAddressableByKey.set(key, evt) |
|
} |
|
}) |
|
events = [...nonAddressableEvents, ...latestAddressableByKey.values()] |
|
|
|
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 |
|
}: UseProfileTimelineOptions): UseProfileTimelineResult { |
|
const nostr = useNostrOptional() |
|
const { favoriteRelays, blockedRelays } = useFavoriteRelays() |
|
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]) |
|
const relayListsKey = useMemo( |
|
() => relayListsContentKey(favoriteRelays, blockedRelays), |
|
[favoriteRelays, blockedRelays] |
|
) |
|
const { isEventDeleted, tombstoneEpoch } = useDeletedEvent() |
|
const isEventDeletedRef = useRef(isEventDeleted) |
|
isEventDeletedRef.current = isEventDeleted |
|
|
|
const filterPredicateRef = useRef(filterPredicate) |
|
filterPredicateRef.current = filterPredicate |
|
const limitRef = useRef(limit) |
|
limitRef.current = limit |
|
|
|
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 socialKinds = kinds.some(isSocialKindBlockedKind) |
|
const emptyAuthor = { read: [] as string[], write: [] as string[], httpRead: [] as string[], httpWrite: [] as string[] } |
|
const idbDocKinds = kinds.filter((k) => isDocumentRelayKind(k)) |
|
/** |
|
* Author NIP-65 read/write relays must feed the **first** REQ for every profile tab. Favorites-only |
|
* misses most people’s kind-1 notes; we previously only prefetched relays for document tabs. |
|
*/ |
|
let prefetchedAuthorRelays: typeof emptyAuthor = emptyAuthor |
|
|
|
if (idbDocKinds.length > 0) { |
|
try { |
|
const pkNorm = normalizeHexPubkey(pubkey) |
|
const fromSession = eventService.listSessionEventsAuthoredBy(pkNorm, { |
|
kinds: idbDocKinds, |
|
limit |
|
}) |
|
if (!cancelled) { |
|
for (const e of fromSession) { |
|
pool.set(e.id, e as Event) |
|
} |
|
if (fromSession.length) flushPool() |
|
} |
|
const [authorRl, fromPubStore, fromArchive] = await Promise.all([ |
|
client.fetchRelayList(pubkey).catch(() => ({ |
|
read: [] as string[], |
|
write: [] as string[], |
|
httpRead: [] as string[], |
|
httpWrite: [] as string[] |
|
})), |
|
indexedDb.getCachedPublicationStoreEventsForProfileAuthor(pkNorm, idbDocKinds, limit), |
|
indexedDb.scanEventArchiveByAuthorPubkey(pkNorm, { |
|
kinds: idbDocKinds, |
|
maxRowsScanned: 18_000, |
|
maxMatches: limit |
|
}) |
|
]) |
|
if (!cancelled) { |
|
prefetchedAuthorRelays = authorRl |
|
for (const e of fromPubStore) { |
|
pool.set(e.id, e) |
|
} |
|
for (const e of fromArchive) { |
|
pool.set(e.id, e) |
|
} |
|
const hadDisk = fromPubStore.length > 0 || fromArchive.length > 0 |
|
if (hadDisk) flushPool() |
|
else if (!isCacheFresh && !mem?.events?.length && fromSession.length === 0) { |
|
setIsLoading(true) |
|
} |
|
} |
|
} catch { |
|
if (!cancelled) { |
|
prefetchedAuthorRelays = await client.fetchRelayList(pubkey).catch(() => emptyAuthor) |
|
} |
|
if (!cancelled && !isCacheFresh && !mem?.events?.length) { |
|
setIsLoading(true) |
|
} |
|
} |
|
} else { |
|
try { |
|
const pkNorm = normalizeHexPubkey(pubkey) |
|
const fromSession = eventService.listSessionEventsAuthoredBy(pkNorm, { kinds, limit }) |
|
if (!cancelled) { |
|
for (const e of fromSession) { |
|
pool.set(e.id, e as Event) |
|
} |
|
if (fromSession.length) flushPool() |
|
} |
|
const [authorRl, fromArchiveSocial] = await Promise.all([ |
|
client.fetchRelayList(pubkey).catch(() => emptyAuthor), |
|
indexedDb.scanEventArchiveByAuthorPubkey(pkNorm, { |
|
kinds, |
|
maxRowsScanned: 16_000, |
|
maxMatches: limit |
|
}) |
|
]) |
|
if (!cancelled) { |
|
prefetchedAuthorRelays = authorRl |
|
for (const e of fromArchiveSocial) { |
|
pool.set(e.id, e) |
|
} |
|
if (fromArchiveSocial.length) flushPool() |
|
else if (!isCacheFresh && !mem?.events?.length && fromSession.length === 0) { |
|
setIsLoading(true) |
|
} |
|
} |
|
} catch { |
|
if (!cancelled) { |
|
prefetchedAuthorRelays = await client.fetchRelayList(pubkey).catch(() => emptyAuthor) |
|
} |
|
if (!cancelled && !isCacheFresh && !mem?.events?.length) { |
|
setIsLoading(true) |
|
} |
|
} |
|
} |
|
|
|
const provisionalFeedUrls = buildProfilePageReadRelayUrls( |
|
favoriteRelays, |
|
blockedRelays, |
|
prefetchedAuthorRelays, |
|
socialKinds, |
|
includeAuthorLocalRelays, |
|
kinds |
|
) |
|
|
|
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.getTimelineDiskSnapshotEvents( |
|
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 = prefetchedAuthorRelays |
|
if (cancelled) return |
|
const fullFeedUrls = buildProfilePageReadRelayUrls( |
|
favoriteRelays, |
|
blockedRelays, |
|
authorRl, |
|
socialKinds, |
|
includeAuthorLocalRelays, |
|
kinds |
|
) |
|
const deltaUrls = subtractNormalizedRelayUrls(fullFeedUrls, provisionalFeedUrls) |
|
if (cancelled || deltaUrls.length === 0) return |
|
const deltaSubs = buildSubRequests([deltaUrls], pubkey, kinds, limit, hasCalendarKinds) |
|
try { |
|
const diskDelta = await client.getTimelineDiskSnapshotEvents( |
|
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]) |
|
|
|
const refresh = useCallback(() => { |
|
subscriptionRef.current() |
|
subscriptionRef.current = () => {} |
|
memoryTimelineByKey.delete(cacheKey) |
|
setIsLoading(true) |
|
setRefreshToken((token) => token + 1) |
|
}, [cacheKey]) |
|
|
|
return { |
|
events, |
|
isLoading, |
|
refresh |
|
} |
|
}
|
|
|