Browse Source

add paging

imwald
Silberengel 1 week ago
parent
commit
2d5ca743ec
  1. 50
      src/hooks/useLibraryPublications.ts
  2. 1
      src/i18n/locales/de.ts
  3. 1
      src/i18n/locales/en.ts
  4. 47
      src/lib/library-publication-index.test.ts
  5. 101
      src/lib/library-publication-index.ts
  6. 16
      src/pages/primary/LibraryPage/index.tsx

50
src/hooks/useLibraryPublications.ts

@ -3,6 +3,7 @@ import { @@ -3,6 +3,7 @@ import {
filterLibraryPublicationsByUser,
buildLibraryRelayUrls,
libraryPublicationEntriesForUserFromIndexAsync,
libraryDefaultFeedSlice,
loadLibraryPublicationIndex,
peekLibrarySearchResults,
refreshLibraryEngagement,
@ -48,6 +49,8 @@ export function useLibraryPublications(isActive: boolean) { @@ -48,6 +49,8 @@ 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('')
@ -124,12 +127,27 @@ export function useLibraryPublications(isActive: boolean) { @@ -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) { @@ -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) { @@ -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) { @@ -179,7 +197,7 @@ export function useLibraryPublications(isActive: boolean) {
}
}
},
[pubkey, blockedRelays]
[pubkey, blockedRelays, applyDefaultFeedSlice]
)
useEffect(() => {
@ -194,7 +212,7 @@ export function useLibraryPublications(isActive: boolean) { @@ -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) { @@ -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) { @@ -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) { @@ -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) { @@ -386,6 +419,9 @@ export function useLibraryPublications(isActive: boolean) {
topLevelCount,
refresh,
searchOnRelays,
hasIndexData: indexEvents.length > 0
hasIndexData: indexEvents.length > 0,
loadMoreFeed,
defaultFeedHasMore,
feedTotalCount
}
}

1
src/i18n/locales/de.ts

@ -1660,6 +1660,7 @@ export default { @@ -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',

1
src/i18n/locales/en.ts

@ -1683,6 +1683,7 @@ export default { @@ -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',

47
src/lib/library-publication-index.test.ts

@ -5,10 +5,13 @@ import { @@ -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', () => { @@ -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))

101
src/lib/library-publication-index.ts

@ -47,7 +47,9 @@ const ENGAGEMENT_ADDRESS_CHUNK = 36 @@ -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( @@ -692,7 +694,7 @@ export function buildRecentPublicationEntries(
roots: Event[],
indexByAddress: Map<string, Event>,
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( @@ -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<string, Event>,
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<string>()
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<string, Event>,
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<string, Event>,
engagement: PublicationEngagementMaps
): LibraryPublicationEntry[] {
const ordered = computeLibraryFeedRootOrder(roots, indexByAddress, engagement)
return libraryFeedEntriesThroughPage(ordered, indexByAddress, engagement, 0)
}
export function sortLibraryPublications(entries: LibraryPublicationEntry[]): LibraryPublicationEntry[] {

16
src/pages/primary/LibraryPage/index.tsx

@ -1,8 +1,10 @@ @@ -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<TPageRef>((_props, ref) => { @@ -30,7 +32,10 @@ const LibraryPage = forwardRef<TPageRef>((_props, ref) => {
topLevelCount,
refresh,
searchOnRelays,
hasIndexData
hasIndexData,
loadMoreFeed,
defaultFeedHasMore,
feedTotalCount
} = useLibraryPublications(isActive)
useImperativeHandle(
@ -97,6 +102,15 @@ const LibraryPage = forwardRef<TPageRef>((_props, ref) => { @@ -97,6 +102,15 @@ const LibraryPage = forwardRef<TPageRef>((_props, ref) => {
searchQuery.trim() || showOnlyMine ? t('Library empty filtered') : t('Library empty')
}
/>
{defaultFeedHasMore ? (
<div className="mt-6 flex justify-center">
<Button type="button" variant="outline" onClick={loadMoreFeed}>
{t('Library load more', {
count: Math.min(LIBRARY_PAGE_SIZE, feedTotalCount - entries.length)
})}
</Button>
</div>
) : null}
</div>
</PrimaryPageLayout>
)

Loading…
Cancel
Save