From 2d5ca743ec2c30f67c2a9820cdc1f887fc0e00a9 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sun, 7 Jun 2026 18:29:16 +0200 Subject: [PATCH] add paging --- src/hooks/useLibraryPublications.ts | 50 +++++++++-- src/i18n/locales/de.ts | 1 + src/i18n/locales/en.ts | 1 + src/lib/library-publication-index.test.ts | 47 ++++++++++ src/lib/library-publication-index.ts | 101 +++++++++++++++++----- src/pages/primary/LibraryPage/index.tsx | 16 +++- 6 files changed, 187 insertions(+), 29 deletions(-) diff --git a/src/hooks/useLibraryPublications.ts b/src/hooks/useLibraryPublications.ts index d1af0885..09c70b35 100644 --- a/src/hooks/useLibraryPublications.ts +++ b/src/hooks/useLibraryPublications.ts @@ -3,6 +3,7 @@ import { filterLibraryPublicationsByUser, buildLibraryRelayUrls, libraryPublicationEntriesForUserFromIndexAsync, + libraryDefaultFeedSlice, loadLibraryPublicationIndex, peekLibrarySearchResults, refreshLibraryEngagement, @@ -48,6 +49,8 @@ export function useLibraryPublications(isActive: boolean) { const { pubkey, bookmarkListEvent } = useNostr() const { favoriteRelays, blockedRelays } = useFavoriteRelays() const [entries, setEntries] = useState([]) + const [feedPageIndex, setFeedPageIndex] = useState(0) + const [feedTotalCount, setFeedTotalCount] = useState(0) const [indexEvents, setIndexEvents] = useState([]) const [engagement, setEngagement] = useState(EMPTY_ENGAGEMENT) const [searchQuery, setSearchQuery] = useState('') @@ -124,12 +127,27 @@ export function useLibraryPublications(isActive: boolean) { return () => window.clearTimeout(t) }, [searchQuery]) + useEffect(() => { + setFeedPageIndex(0) + }, [debouncedSearch, showOnlyMine]) + + const applyDefaultFeedSlice = useCallback( + (indexEventsSlice: Event[], engagementMaps: PublicationEngagementMaps, pageIndex: number) => { + const slice = libraryDefaultFeedSlice(indexEventsSlice, engagementMaps, pageIndex) + setEntries(slice.entries) + setFeedTotalCount(slice.totalCount) + return slice + }, + [] + ) + const load = useCallback( async (forceRefresh = false) => { const gen = ++loadGenRef.current setLoading(true) setEngagementLoading(false) setError(null) + setFeedPageIndex(0) if (import.meta.env.DEV) { logger.info('[Library] page load requested', { forceRefresh, gen }) } @@ -146,10 +164,10 @@ export function useLibraryPublications(isActive: boolean) { viewerPubkey: pubkey || undefined, onIndexesReady: (snapshot) => { if (gen !== loadGenRef.current) return - setEntries(snapshot.engaged) setIndexEvents(snapshot.indexEvents) setAllIndexCount(snapshot.allIndexCount) setTopLevelCount(snapshot.topLevelCount) + applyDefaultFeedSlice(snapshot.indexEvents, EMPTY_ENGAGEMENT, 0) setLoading(false) setEngagementLoading(true) } @@ -157,11 +175,11 @@ export function useLibraryPublications(isActive: boolean) { timeoutPromise ]) if (gen !== loadGenRef.current) return - setEntries(result.engaged) setIndexEvents(result.indexEvents) setEngagement(result.engagement) setAllIndexCount(result.allIndexCount) setTopLevelCount(result.topLevelCount) + applyDefaultFeedSlice(result.indexEvents, result.engagement, 0) } finally { if (timeoutId != null) window.clearTimeout(timeoutId) } @@ -179,7 +197,7 @@ export function useLibraryPublications(isActive: boolean) { } } }, - [pubkey, blockedRelays] + [pubkey, blockedRelays, applyDefaultFeedSlice] ) useEffect(() => { @@ -194,7 +212,7 @@ export function useLibraryPublications(isActive: boolean) { void (async () => { await loadMyBooklistTargets() const relays = await buildLibraryRelayUrls(pubkey, blockedRelays ?? []) - const { engagement: nextEngagement, engaged } = await refreshLibraryEngagement( + const { engagement: nextEngagement } = await refreshLibraryEngagement( relays, indexEvents, pubkey @@ -202,7 +220,8 @@ export function useLibraryPublications(isActive: boolean) { if (cancelled) return setEngagement(nextEngagement) if (!debouncedSearch.trim()) { - setEntries(engaged) + setFeedPageIndex(0) + applyDefaultFeedSlice(indexEvents, nextEngagement, 0) } })() } @@ -211,7 +230,7 @@ export function useLibraryPublications(isActive: boolean) { cancelled = true window.removeEventListener(BOOKLIST_LABEL_UPDATED_EVENT, onBooklistUpdated) } - }, [isActive, pubkey, indexEvents, debouncedSearch, loadMyBooklistTargets, blockedRelays]) + }, [isActive, pubkey, indexEvents, debouncedSearch, loadMyBooklistTargets, blockedRelays, applyDefaultFeedSlice]) useEffect(() => { const q = debouncedSearch.trim() @@ -346,6 +365,20 @@ export function useLibraryPublications(isActive: boolean) { } }, [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[] @@ -386,6 +419,9 @@ export function useLibraryPublications(isActive: boolean) { topLevelCount, refresh, searchOnRelays, - hasIndexData: indexEvents.length > 0 + hasIndexData: indexEvents.length > 0, + loadMoreFeed, + defaultFeedHasMore, + feedTotalCount } } diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 893b6869..129717fb 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -1660,6 +1660,7 @@ export default { 'Library search relays': 'Relays durchsuchen', 'Library relay search loading': 'Dokument-Relays werden durchsucht…', 'Library status line': '{{shown}} angezeigt · {{topLevel}} Top-Level · {{total}} Indizes geladen', + 'Library load more': 'Nächste {{count}} Bücher laden', 'Library badge label': 'Label', 'Library badge comment': 'Kommentar', 'Library badge highlight': 'Markierung', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 68e502ae..0f532117 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -1683,6 +1683,7 @@ export default { 'Library search relays': 'Search the relays', 'Library relay search loading': 'Searching document relays…', 'Library status line': '{{shown}} shown · {{topLevel}} top-level · {{total}} indexes loaded', + 'Library load more': 'Load next {{count}} books', 'Library badge label': 'Label', 'Library badge booklist': 'Booklist', 'Library badge my booklist': 'On my booklist', diff --git a/src/lib/library-publication-index.test.ts b/src/lib/library-publication-index.test.ts index 00675a66..e0c7fdc7 100644 --- a/src/lib/library-publication-index.test.ts +++ b/src/lib/library-publication-index.test.ts @@ -5,10 +5,13 @@ import { buildLibraryPublicationRelaySearchFilters, buildRecentPublicationEntries, clearLibrarySearchSessionCache, + computeLibraryFeedRootOrder, filterEngagedPublications, filterLibraryPublicationsBySearch, filterLibraryPublicationsByUser, + libraryDefaultFeedSlice, libraryPublicationEntriesForUserFromIndex, + LIBRARY_PAGE_SIZE, pickLibraryPublicationEntries, publicationRootBelongsToUser, peekLibrarySearchResults, @@ -293,6 +296,50 @@ describe('library-publication-index', () => { expect(buildRecentPublicationEntries(roots, indexByAddress, engagement, 10)[0].event.created_at).toBe(11) }) + it('libraryDefaultFeedSlice pages through the feed in chunks of LIBRARY_PAGE_SIZE', () => { + const roots = Array.from({ length: 250 }, (_, i) => { + const ev = indexEvent(`book-${i}`, [`30041:${PK}:ch-${i}`], `${String(i).padStart(64, '0')}`) + ev.created_at = i + return ev + }) + const engagement = buildEngagementMapsFromEvents([], [], []) + const topLevelCount = roots.length + + const page0 = libraryDefaultFeedSlice(roots, engagement, 0) + expect(page0.entries).toHaveLength(LIBRARY_PAGE_SIZE) + expect(page0.totalCount).toBe(topLevelCount) + expect(page0.hasMore).toBe(topLevelCount > LIBRARY_PAGE_SIZE) + + const page1 = libraryDefaultFeedSlice(roots, engagement, 1) + expect(page1.entries).toHaveLength(Math.min(LIBRARY_PAGE_SIZE * 2, topLevelCount)) + expect(page1.hasMore).toBe(topLevelCount > LIBRARY_PAGE_SIZE * 2) + + const lastPageIndex = Math.ceil(topLevelCount / LIBRARY_PAGE_SIZE) - 1 + const lastPage = libraryDefaultFeedSlice(roots, engagement, lastPageIndex) + expect(lastPage.entries).toHaveLength(topLevelCount) + expect(lastPage.hasMore).toBe(false) + }) + + it('computeLibraryFeedRootOrder keeps engaged roots before recent ones', () => { + const engagedRoot = indexEvent('engaged', [`30041:${PK}:a`], '1'.repeat(64)) + engagedRoot.created_at = 1 + const recentRoot = indexEvent('recent', [`30041:${PK}:b`], '2'.repeat(64)) + recentRoot.created_at = 100 + const indexByAddress = buildIndexByAddress([engagedRoot, recentRoot]) + const label: Event = { + id: '4'.repeat(64), + kind: ExtendedKind.LABEL, + pubkey: 'f'.repeat(64), + created_at: 50, + content: '', + tags: [['L', 'ugc'], ['l', 'booklist', 'ugc'], ['e', engagedRoot.id]], + sig: 'e'.repeat(128) + } + const engagement = buildEngagementMapsFromEvents([label], [], []) + const ordered = computeLibraryFeedRootOrder([engagedRoot, recentRoot], indexByAddress, engagement) + expect(ordered.map((e) => e.id)).toEqual([engagedRoot.id, recentRoot.id]) + }) + it('filterLibraryPublicationsByUser includes authored, booklist, bookmarked, and commented', () => { const viewerPk = 'f'.repeat(64) const authored = indexEvent('mine', [`30041:${PK}:ch`], '1'.repeat(64)) diff --git a/src/lib/library-publication-index.ts b/src/lib/library-publication-index.ts index f30dfa57..42ee7ea2 100644 --- a/src/lib/library-publication-index.ts +++ b/src/lib/library-publication-index.ts @@ -47,7 +47,9 @@ const ENGAGEMENT_ADDRESS_CHUNK = 36 const ENGAGEMENT_EVENT_ID_CHUNK = 44 const MAX_TARGET_ADDRESSES = 480 const HYDRATE_MISSING_CAP = 64 -export const LIBRARY_RECENT_FALLBACK_LIMIT = 120 +export const LIBRARY_PAGE_SIZE = 120 +/** @deprecated Use {@link LIBRARY_PAGE_SIZE} */ +export const LIBRARY_RECENT_FALLBACK_LIMIT = LIBRARY_PAGE_SIZE const ENGAGEMENT_FETCH_TIMEOUT_MS = 25_000 const LIBRARY_SEARCH_READING_CACHE_LIMIT = 200 export const LIBRARY_RELAY_SEARCH_LIMIT = 100 @@ -692,7 +694,7 @@ export function buildRecentPublicationEntries( roots: Event[], indexByAddress: Map, engagement: PublicationEngagementMaps, - limit = LIBRARY_RECENT_FALLBACK_LIMIT + limit = LIBRARY_PAGE_SIZE ): LibraryPublicationEntry[] { return [...getTopLevelIndexEvents(roots)] .sort((a, b) => b.created_at - a.created_at) @@ -700,30 +702,87 @@ export function buildRecentPublicationEntries( .map((event) => buildLibraryPublicationEntry(event, indexByAddress, engagement)) } -/** Engaged publications first, then fill with newest top-level indexes up to {@link LIBRARY_RECENT_FALLBACK_LIMIT}. */ -export function pickLibraryPublicationEntries( +/** Full default-feed order: engaged publications first, then newest top-level indexes. */ +export function computeLibraryFeedRootOrder( roots: Event[], indexByAddress: Map, engagement: PublicationEngagementMaps -): LibraryPublicationEntry[] { - const engaged = filterEngagedPublications(roots, indexByAddress, engagement) - const recent = buildRecentPublicationEntries(roots, indexByAddress, engagement) - if (engaged.length === 0) return sortLibraryPublications(recent) +): Event[] { + const topLevel = getTopLevelIndexEvents(roots) + const engagedRoots: Event[] = [] + const restRoots: Event[] = [] + for (const root of topLevel) { + const entry = buildLibraryPublicationEntry(root, indexByAddress, engagement) + if (entry.hasLabel || entry.hasComment || entry.hasHighlight) { + engagedRoots.push(root) + } else { + restRoots.push(root) + } + } + const sortedEngaged = sortLibraryPublications( + engagedRoots.map((root) => buildLibraryPublicationEntry(root, indexByAddress, engagement)) + ).map((entry) => entry.event) + restRoots.sort((a, b) => b.created_at - a.created_at) const seen = new Set() - const merged: LibraryPublicationEntry[] = [] - for (const entry of sortLibraryPublications(engaged)) { - if (seen.has(entry.event.id)) continue - seen.add(entry.event.id) - merged.push(entry) - } - for (const entry of recent) { - if (merged.length >= LIBRARY_RECENT_FALLBACK_LIMIT) break - if (seen.has(entry.event.id)) continue - seen.add(entry.event.id) - merged.push(entry) - } - return merged + const ordered: Event[] = [] + for (const root of [...sortedEngaged, ...restRoots]) { + if (seen.has(root.id)) continue + seen.add(root.id) + ordered.push(root) + } + return ordered +} + +/** Entries for default library feed from page 0 through {@link pageIndexInclusive} (inclusive). */ +export function libraryFeedEntriesThroughPage( + orderedRoots: Event[], + indexByAddress: Map, + engagement: PublicationEngagementMaps, + pageIndexInclusive: number, + pageSize = LIBRARY_PAGE_SIZE +): LibraryPublicationEntry[] { + const end = Math.min(orderedRoots.length, (pageIndexInclusive + 1) * pageSize) + return orderedRoots + .slice(0, end) + .map((root) => buildLibraryPublicationEntry(root, indexByAddress, engagement)) +} + +export function libraryDefaultFeedSlice( + indexEvents: Event[], + engagement: PublicationEngagementMaps, + pageIndexInclusive: number +): { + entries: LibraryPublicationEntry[] + totalCount: number + hasMore: boolean +} { + if (indexEvents.length === 0) { + return { entries: [], totalCount: 0, hasMore: false } + } + const indexByAddress = buildIndexByAddress(indexEvents) + const ordered = computeLibraryFeedRootOrder(indexEvents, indexByAddress, engagement) + const entries = libraryFeedEntriesThroughPage( + ordered, + indexByAddress, + engagement, + pageIndexInclusive + ) + return { + entries, + totalCount: ordered.length, + hasMore: entries.length < ordered.length + } +} + +/** First page of the default library feed (engaged first, then recent). */ +export function pickLibraryPublicationEntries( + roots: Event[], + indexByAddress: Map, + engagement: PublicationEngagementMaps +): LibraryPublicationEntry[] { + const ordered = computeLibraryFeedRootOrder(roots, indexByAddress, engagement) + return libraryFeedEntriesThroughPage(ordered, indexByAddress, engagement, 0) } export function sortLibraryPublications(entries: LibraryPublicationEntry[]): LibraryPublicationEntry[] { diff --git a/src/pages/primary/LibraryPage/index.tsx b/src/pages/primary/LibraryPage/index.tsx index c3abb04f..0b964575 100644 --- a/src/pages/primary/LibraryPage/index.tsx +++ b/src/pages/primary/LibraryPage/index.tsx @@ -1,8 +1,10 @@ import LibraryPublicationGrid from '@/components/Library/LibraryPublicationGrid' import LibrarySearchBar from '@/components/Library/LibrarySearchBar' import { RefreshButton } from '@/components/RefreshButton' +import { Button } from '@/components/ui/button' import PrimaryPageLayout, { TPrimaryPageLayoutRef } from '@/layouts/PrimaryPageLayout' import { useLibraryPublications } from '@/hooks/useLibraryPublications' +import { LIBRARY_PAGE_SIZE } from '@/lib/library-publication-index' import { usePrimaryPage } from '@/contexts/primary-page-context' import { TPageRef } from '@/types' import { BookOpen } from 'lucide-react' @@ -30,7 +32,10 @@ const LibraryPage = forwardRef((_props, ref) => { topLevelCount, refresh, searchOnRelays, - hasIndexData + hasIndexData, + loadMoreFeed, + defaultFeedHasMore, + feedTotalCount } = useLibraryPublications(isActive) useImperativeHandle( @@ -97,6 +102,15 @@ const LibraryPage = forwardRef((_props, ref) => { searchQuery.trim() || showOnlyMine ? t('Library empty filtered') : t('Library empty') } /> + {defaultFeedHasMore ? ( +
+ +
+ ) : null} )