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.
503 lines
16 KiB
503 lines
16 KiB
import { |
|
clearAllLibraryIndexCaches, |
|
filterLibraryPublicationsByUser, |
|
buildLibraryRelayUrls, |
|
libraryPublicationEntriesForUserFromIndexAsync, |
|
libraryDefaultFeedSlice, |
|
loadLibraryPublicationIndex, |
|
peekLibrarySearchResults, |
|
refreshLibraryEngagement, |
|
searchLibraryPublications, |
|
searchLibraryPublicationsOnRelays, |
|
type LibraryPublicationEntry, |
|
type LibraryPublicationRelaySearchAxis, |
|
type PublicationEngagementMaps, |
|
type LibraryMineFilterOpts |
|
} from '@/lib/library-publication-index' |
|
import { BOOKLIST_LABEL_UPDATED_EVENT, fetchViewerBooklistTargets } from '@/lib/booklist-label' |
|
import { buildAccountListRelayUrlsForMerge } from '@/lib/account-list-relay-urls' |
|
import { fetchNewestPinListForPubkey } from '@/lib/replaceable-list-latest' |
|
import { getTopLevelIndexEvents } from '@/lib/publication-index' |
|
import logger from '@/lib/logger' |
|
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' |
|
import { useNostr } from '@/providers/NostrProvider' |
|
import type { Event } from 'nostr-tools' |
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' |
|
|
|
const SEARCH_DEBOUNCE_MS = 300 |
|
const RELAY_SEARCH_TIMEOUT_MS = 30_000 |
|
|
|
const EMPTY_ENGAGEMENT: PublicationEngagementMaps = { |
|
labelAddresses: new Set(), |
|
labelEventIds: new Set(), |
|
labelValuesByAddress: new Map(), |
|
labelValuesByEventId: new Map(), |
|
booklistAddresses: new Set(), |
|
booklistEventIds: new Set(), |
|
myBooklistAddresses: new Set(), |
|
myBooklistEventIds: new Set(), |
|
myCommentAddresses: new Set(), |
|
myCommentEventIds: new Set(), |
|
myHighlightAddresses: new Set(), |
|
myHighlightEventIds: new Set(), |
|
commentAddresses: new Set(), |
|
commentEventIds: new Set(), |
|
highlightAddresses: new Set(), |
|
highlightEventIds: new Set(), |
|
bookmarkAddresses: new Set(), |
|
bookmarkEventIds: new Set(), |
|
pinAddresses: new Set(), |
|
pinEventIds: new Set() |
|
} |
|
|
|
const EMPTY_BOOKLIST_TARGETS = { addresses: new Set<string>(), eventIds: new Set<string>() } |
|
|
|
export function useLibraryPublications(isActive: boolean) { |
|
const { pubkey, bookmarkListEvent } = useNostr() |
|
const { favoriteRelays, blockedRelays } = useFavoriteRelays() |
|
const [entries, setEntries] = useState<LibraryPublicationEntry[]>([]) |
|
const [feedPageIndex, setFeedPageIndex] = useState(0) |
|
const [feedTotalCount, setFeedTotalCount] = useState(0) |
|
const [indexEvents, setIndexEvents] = useState<Event[]>([]) |
|
const [engagement, setEngagement] = useState<PublicationEngagementMaps>(EMPTY_ENGAGEMENT) |
|
const [searchQuery, setSearchQuery] = useState('') |
|
const [committedSearch, setCommittedSearch] = useState('') |
|
const [searchAxis, setSearchAxis] = useState<LibraryPublicationRelaySearchAxis | null>(null) |
|
const [debouncedSearch, setDebouncedSearch] = useState('') |
|
const [showOnlyMine, setShowOnlyMine] = useState(false) |
|
const [loading, setLoading] = useState(false) |
|
const [searchLoading, setSearchLoading] = useState(false) |
|
const [relaySearchLoading, setRelaySearchLoading] = useState(false) |
|
const [searchResults, setSearchResults] = useState<LibraryPublicationEntry[] | null>(null) |
|
const [error, setError] = useState<string | null>(null) |
|
const [allIndexCount, setAllIndexCount] = useState(0) |
|
const [topLevelCount, setTopLevelCount] = useState(0) |
|
const [pinListEvent, setPinListEvent] = useState<Event | null>(null) |
|
const [myBooklistTargets, setMyBooklistTargets] = useState(EMPTY_BOOKLIST_TARGETS) |
|
const [booklistTargetsLoading, setBooklistTargetsLoading] = useState(false) |
|
const [reloadNonce, setReloadNonce] = useState(0) |
|
const forceRefreshNextLoadRef = useRef(false) |
|
const indexesReadyRef = useRef(false) |
|
const [mineIndexEntries, setMineIndexEntries] = useState<LibraryPublicationEntry[]>([]) |
|
const [mineFilterComputing, setMineFilterComputing] = useState(false) |
|
const mineIndexCacheRef = useRef<{ |
|
indexEvents: Event[] |
|
engagement: PublicationEngagementMaps |
|
pubkey: string |
|
mineFilterOpts: LibraryMineFilterOpts |
|
entries: LibraryPublicationEntry[] |
|
} | null>(null) |
|
|
|
const loadMyBooklistTargets = useCallback(async () => { |
|
if (!pubkey) { |
|
setMyBooklistTargets(EMPTY_BOOKLIST_TARGETS) |
|
setBooklistTargetsLoading(false) |
|
return |
|
} |
|
setBooklistTargetsLoading(true) |
|
try { |
|
const relays = await buildAccountListRelayUrlsForMerge({ |
|
accountPubkey: pubkey, |
|
favoriteRelays: favoriteRelays ?? [], |
|
blockedRelays: blockedRelays ?? [] |
|
}) |
|
const targets = await fetchViewerBooklistTargets(pubkey, relays) |
|
setMyBooklistTargets(targets) |
|
} finally { |
|
setBooklistTargetsLoading(false) |
|
} |
|
}, [pubkey, favoriteRelays, blockedRelays]) |
|
|
|
useEffect(() => { |
|
if (!isActive || !pubkey) { |
|
setMyBooklistTargets(EMPTY_BOOKLIST_TARGETS) |
|
return |
|
} |
|
void loadMyBooklistTargets() |
|
}, [isActive, pubkey, loadMyBooklistTargets]) |
|
|
|
useEffect(() => { |
|
if (!pubkey) { |
|
setPinListEvent(null) |
|
return |
|
} |
|
let cancelled = false |
|
void (async () => { |
|
const relays = await buildLibraryRelayUrls(pubkey, blockedRelays ?? []) |
|
const pinList = await fetchNewestPinListForPubkey(pubkey, relays) |
|
if (!cancelled) setPinListEvent(pinList ?? null) |
|
})() |
|
return () => { |
|
cancelled = true |
|
} |
|
}, [pubkey]) |
|
|
|
useEffect(() => { |
|
const t = window.setTimeout(() => setDebouncedSearch(committedSearch), SEARCH_DEBOUNCE_MS) |
|
return () => window.clearTimeout(t) |
|
}, [committedSearch]) |
|
|
|
useEffect(() => { |
|
if (!searchQuery.trim()) { |
|
setCommittedSearch('') |
|
setSearchAxis(null) |
|
} |
|
}, [searchQuery]) |
|
|
|
const commitSearch = useCallback( |
|
(query: string, axis: LibraryPublicationRelaySearchAxis | null) => { |
|
const trimmed = query.trim() |
|
if (!trimmed) return |
|
setSearchQuery(trimmed) |
|
setCommittedSearch(trimmed) |
|
setSearchAxis(axis) |
|
}, |
|
[] |
|
) |
|
|
|
useEffect(() => { |
|
setFeedPageIndex(0) |
|
}, [debouncedSearch, showOnlyMine, searchAxis]) |
|
|
|
const applyDefaultFeedSlice = useCallback( |
|
(indexEventsSlice: Event[], engagementMaps: PublicationEngagementMaps, pageIndex: number) => { |
|
const slice = libraryDefaultFeedSlice(indexEventsSlice, engagementMaps, pageIndex) |
|
setEntries(slice.entries) |
|
setFeedTotalCount(slice.totalCount) |
|
return slice |
|
}, |
|
[] |
|
) |
|
|
|
const applyIndexesSnapshot = useCallback( |
|
( |
|
snapshot: { |
|
indexEvents: Event[] |
|
allIndexCount: number |
|
topLevelCount: number |
|
}, |
|
engagementMaps: PublicationEngagementMaps, |
|
pageIndex: number |
|
) => { |
|
setIndexEvents(snapshot.indexEvents) |
|
setAllIndexCount(snapshot.allIndexCount) |
|
setTopLevelCount(snapshot.topLevelCount) |
|
applyDefaultFeedSlice(snapshot.indexEvents, engagementMaps, pageIndex) |
|
}, |
|
[applyDefaultFeedSlice] |
|
) |
|
|
|
useEffect(() => { |
|
if (!isActive) return |
|
let cancelled = false |
|
indexesReadyRef.current = false |
|
setLoading(true) |
|
setError(null) |
|
setFeedPageIndex(0) |
|
const forceRefresh = forceRefreshNextLoadRef.current |
|
forceRefreshNextLoadRef.current = false |
|
if (import.meta.env.DEV) { |
|
logger.info('[Library] page load requested', { forceRefresh, reloadNonce }) |
|
} |
|
|
|
void (async () => { |
|
try { |
|
const relays = await buildLibraryRelayUrls(pubkey || undefined, blockedRelays ?? []) |
|
if (cancelled) return |
|
const result = await loadLibraryPublicationIndex(relays, { |
|
forceRefresh, |
|
viewerPubkey: pubkey || undefined, |
|
onIndexesReady: (snapshot) => { |
|
if (cancelled) return |
|
indexesReadyRef.current = true |
|
if (import.meta.env.DEV && snapshot.indexEvents.length > 0) { |
|
logger.info('[Library] indexes ready (progress)', { |
|
validCount: snapshot.indexEvents.length, |
|
topLevelCount: snapshot.topLevelCount, |
|
entryCount: snapshot.engaged.length |
|
}) |
|
} |
|
applyIndexesSnapshot(snapshot, EMPTY_ENGAGEMENT, 0) |
|
setLoading(false) |
|
} |
|
}) |
|
if (cancelled) return |
|
setEngagement(result.engagement) |
|
applyIndexesSnapshot( |
|
{ |
|
indexEvents: result.indexEvents, |
|
allIndexCount: result.allIndexCount, |
|
topLevelCount: result.topLevelCount |
|
}, |
|
result.engagement, |
|
0 |
|
) |
|
} catch (e) { |
|
if (cancelled) return |
|
if (indexesReadyRef.current) { |
|
if (import.meta.env.DEV) { |
|
logger.warn('[Library] engagement phase failed after indexes loaded', { |
|
message: e instanceof Error ? e.message : String(e) |
|
}) |
|
} |
|
} else { |
|
const message = e instanceof Error ? e.message : 'Failed to load library' |
|
setError(message) |
|
if (import.meta.env.DEV) { |
|
logger.warn('[Library] page load failed', { message }) |
|
} |
|
} |
|
} finally { |
|
if (!cancelled) { |
|
setLoading(false) |
|
} |
|
} |
|
})() |
|
|
|
return () => { |
|
cancelled = true |
|
} |
|
}, [isActive, pubkey, blockedRelays, reloadNonce, applyIndexesSnapshot]) |
|
|
|
const refresh = useCallback(() => { |
|
forceRefreshNextLoadRef.current = true |
|
void clearAllLibraryIndexCaches().then(() => setReloadNonce((n) => n + 1)) |
|
}, []) |
|
|
|
useEffect(() => { |
|
if (!isActive || !pubkey || indexEvents.length === 0) return |
|
let cancelled = false |
|
const onBooklistUpdated = () => { |
|
void (async () => { |
|
await loadMyBooklistTargets() |
|
const relays = await buildLibraryRelayUrls(pubkey, blockedRelays ?? []) |
|
const { engagement: nextEngagement } = await refreshLibraryEngagement( |
|
relays, |
|
indexEvents, |
|
pubkey |
|
) |
|
if (cancelled) return |
|
setEngagement(nextEngagement) |
|
if (!debouncedSearch.trim()) { |
|
setFeedPageIndex(0) |
|
applyDefaultFeedSlice(indexEvents, nextEngagement, 0) |
|
} |
|
})() |
|
} |
|
window.addEventListener(BOOKLIST_LABEL_UPDATED_EVENT, onBooklistUpdated) |
|
return () => { |
|
cancelled = true |
|
window.removeEventListener(BOOKLIST_LABEL_UPDATED_EVENT, onBooklistUpdated) |
|
} |
|
}, [isActive, pubkey, indexEvents, debouncedSearch, loadMyBooklistTargets, blockedRelays, applyDefaultFeedSlice]) |
|
|
|
useEffect(() => { |
|
const q = debouncedSearch.trim() |
|
if (!q) { |
|
setSearchResults(null) |
|
setSearchLoading(false) |
|
return |
|
} |
|
|
|
const cached = peekLibrarySearchResults(q, { indexEvents, engagement }, searchAxis) |
|
if (cached) { |
|
setSearchResults(cached) |
|
setSearchLoading(false) |
|
return |
|
} |
|
|
|
let cancelled = false |
|
setSearchLoading(true) |
|
void searchLibraryPublications(q, { indexEvents, engagement }, searchAxis).then((results) => { |
|
if (cancelled) return |
|
setSearchResults(results) |
|
setSearchLoading(false) |
|
}) |
|
|
|
return () => { |
|
cancelled = true |
|
} |
|
}, [debouncedSearch, indexEvents, engagement, searchAxis]) |
|
|
|
const searchOnRelays = useCallback(async () => { |
|
const q = searchQuery.trim() |
|
if (!q) return |
|
setCommittedSearch(q) |
|
setRelaySearchLoading(true) |
|
setError(null) |
|
try { |
|
const relays = await buildLibraryRelayUrls(pubkey || undefined, blockedRelays ?? []) |
|
let timeoutId: number | undefined |
|
const timeoutPromise = new Promise<never>((_, reject) => { |
|
timeoutId = window.setTimeout( |
|
() => reject(new Error('Relay search timed out')), |
|
RELAY_SEARCH_TIMEOUT_MS |
|
) |
|
}) |
|
let events: Event[] |
|
let mergedIndexEvents: Event[] |
|
let fromCache: boolean |
|
try { |
|
;({ events, mergedIndexEvents, fromCache } = await Promise.race([ |
|
searchLibraryPublicationsOnRelays( |
|
q, |
|
relays, |
|
{ indexEvents, engagement }, |
|
{ axis: searchAxis } |
|
), |
|
timeoutPromise |
|
])) |
|
} finally { |
|
if (timeoutId !== undefined) window.clearTimeout(timeoutId) |
|
} |
|
setIndexEvents(mergedIndexEvents) |
|
setAllIndexCount(mergedIndexEvents.length) |
|
setTopLevelCount(getTopLevelIndexEvents(mergedIndexEvents).length) |
|
if (import.meta.env.DEV) { |
|
logger.info('[Library] relay search merged', { |
|
newEvents: events.length, |
|
fromCache |
|
}) |
|
} |
|
|
|
let nextEngagement = engagement |
|
if (pubkey) { |
|
const refreshed = await refreshLibraryEngagement(relays, mergedIndexEvents, pubkey) |
|
nextEngagement = refreshed.engagement |
|
setEngagement(nextEngagement) |
|
} |
|
|
|
const entries = await searchLibraryPublications(q, { |
|
indexEvents: mergedIndexEvents, |
|
engagement: nextEngagement |
|
}, searchAxis) |
|
setSearchResults(entries) |
|
} catch (e) { |
|
const message = e instanceof Error ? e.message : 'Relay search failed' |
|
setError(message) |
|
if (import.meta.env.DEV) { |
|
logger.warn('[Library] relay search failed', { message }) |
|
} |
|
} finally { |
|
setRelaySearchLoading(false) |
|
} |
|
}, [searchQuery, searchAxis, pubkey, indexEvents, engagement, blockedRelays]) |
|
|
|
const mineFilterOpts = useMemo( |
|
() => ({ |
|
bookmarkListEvent, |
|
pinListEvent, |
|
myBooklistAddresses: myBooklistTargets.addresses, |
|
myBooklistEventIds: myBooklistTargets.eventIds |
|
}), |
|
[bookmarkListEvent, pinListEvent, myBooklistTargets] |
|
) |
|
|
|
useEffect(() => { |
|
if (!showOnlyMine || !pubkey || indexEvents.length === 0 || debouncedSearch.trim()) { |
|
setMineFilterComputing(false) |
|
return |
|
} |
|
|
|
const cached = mineIndexCacheRef.current |
|
if ( |
|
cached && |
|
cached.indexEvents === indexEvents && |
|
cached.engagement === engagement && |
|
cached.pubkey === pubkey && |
|
cached.mineFilterOpts === mineFilterOpts |
|
) { |
|
setMineIndexEntries(cached.entries) |
|
setMineFilterComputing(false) |
|
return |
|
} |
|
|
|
const signal = { cancelled: false } |
|
setMineFilterComputing(true) |
|
|
|
void libraryPublicationEntriesForUserFromIndexAsync( |
|
indexEvents, |
|
engagement, |
|
pubkey, |
|
mineFilterOpts, |
|
signal |
|
).then((computed) => { |
|
if (signal.cancelled) return |
|
mineIndexCacheRef.current = { |
|
indexEvents, |
|
engagement, |
|
pubkey, |
|
mineFilterOpts, |
|
entries: computed |
|
} |
|
setMineIndexEntries(computed) |
|
setMineFilterComputing(false) |
|
}) |
|
|
|
return () => { |
|
signal.cancelled = true |
|
} |
|
}, [showOnlyMine, pubkey, indexEvents, engagement, mineFilterOpts, debouncedSearch]) |
|
|
|
useEffect(() => { |
|
if (debouncedSearch.trim() || showOnlyMine || indexEvents.length === 0) return |
|
applyDefaultFeedSlice(indexEvents, engagement, feedPageIndex) |
|
}, [debouncedSearch, showOnlyMine, indexEvents, engagement, feedPageIndex, applyDefaultFeedSlice]) |
|
|
|
const loadMoreFeed = useCallback(() => { |
|
setFeedPageIndex((page) => page + 1) |
|
}, []) |
|
|
|
const defaultFeedHasMore = useMemo(() => { |
|
if (debouncedSearch.trim() || showOnlyMine) return false |
|
return entries.length < feedTotalCount |
|
}, [debouncedSearch, showOnlyMine, entries.length, feedTotalCount]) |
|
|
|
const filteredEntries = useMemo(() => { |
|
const q = debouncedSearch.trim() |
|
let list: LibraryPublicationEntry[] |
|
if (showOnlyMine && !q) { |
|
list = mineFilterComputing ? [] : mineIndexEntries |
|
} else { |
|
list = q ? (searchResults ?? []) : entries |
|
if (showOnlyMine) { |
|
list = filterLibraryPublicationsByUser(list, pubkey, mineFilterOpts) |
|
} |
|
} |
|
return list |
|
}, [ |
|
entries, |
|
showOnlyMine, |
|
pubkey, |
|
debouncedSearch, |
|
searchResults, |
|
mineIndexEntries, |
|
mineFilterComputing, |
|
mineFilterOpts |
|
]) |
|
|
|
return { |
|
entries: filteredEntries, |
|
searchQuery, |
|
setSearchQuery, |
|
committedSearch, |
|
searchAxis, |
|
commitSearch, |
|
showOnlyMine, |
|
setShowOnlyMine, |
|
mineFilterLoading: |
|
mineFilterComputing || (showOnlyMine && booklistTargetsLoading), |
|
loading, |
|
searchLoading, |
|
relaySearchLoading, |
|
error, |
|
allIndexCount, |
|
topLevelCount, |
|
refresh, |
|
searchOnRelays, |
|
hasIndexData: indexEvents.length > 0, |
|
loadMoreFeed, |
|
defaultFeedHasMore, |
|
feedTotalCount |
|
} |
|
}
|
|
|