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 {
filterLibraryPublicationsByUser, filterLibraryPublicationsByUser,
buildLibraryRelayUrls, buildLibraryRelayUrls,
libraryPublicationEntriesForUserFromIndexAsync, libraryPublicationEntriesForUserFromIndexAsync,
libraryDefaultFeedSlice,
loadLibraryPublicationIndex, loadLibraryPublicationIndex,
peekLibrarySearchResults, peekLibrarySearchResults,
refreshLibraryEngagement, refreshLibraryEngagement,
@ -48,6 +49,8 @@ export function useLibraryPublications(isActive: boolean) {
const { pubkey, bookmarkListEvent } = useNostr() const { pubkey, bookmarkListEvent } = useNostr()
const { favoriteRelays, blockedRelays } = useFavoriteRelays() const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const [entries, setEntries] = useState<LibraryPublicationEntry[]>([]) const [entries, setEntries] = useState<LibraryPublicationEntry[]>([])
const [feedPageIndex, setFeedPageIndex] = useState(0)
const [feedTotalCount, setFeedTotalCount] = useState(0)
const [indexEvents, setIndexEvents] = useState<Event[]>([]) const [indexEvents, setIndexEvents] = useState<Event[]>([])
const [engagement, setEngagement] = useState<PublicationEngagementMaps>(EMPTY_ENGAGEMENT) const [engagement, setEngagement] = useState<PublicationEngagementMaps>(EMPTY_ENGAGEMENT)
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
@ -124,12 +127,27 @@ export function useLibraryPublications(isActive: boolean) {
return () => window.clearTimeout(t) return () => window.clearTimeout(t)
}, [searchQuery]) }, [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( const load = useCallback(
async (forceRefresh = false) => { async (forceRefresh = false) => {
const gen = ++loadGenRef.current const gen = ++loadGenRef.current
setLoading(true) setLoading(true)
setEngagementLoading(false) setEngagementLoading(false)
setError(null) setError(null)
setFeedPageIndex(0)
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
logger.info('[Library] page load requested', { forceRefresh, gen }) logger.info('[Library] page load requested', { forceRefresh, gen })
} }
@ -146,10 +164,10 @@ export function useLibraryPublications(isActive: boolean) {
viewerPubkey: pubkey || undefined, viewerPubkey: pubkey || undefined,
onIndexesReady: (snapshot) => { onIndexesReady: (snapshot) => {
if (gen !== loadGenRef.current) return if (gen !== loadGenRef.current) return
setEntries(snapshot.engaged)
setIndexEvents(snapshot.indexEvents) setIndexEvents(snapshot.indexEvents)
setAllIndexCount(snapshot.allIndexCount) setAllIndexCount(snapshot.allIndexCount)
setTopLevelCount(snapshot.topLevelCount) setTopLevelCount(snapshot.topLevelCount)
applyDefaultFeedSlice(snapshot.indexEvents, EMPTY_ENGAGEMENT, 0)
setLoading(false) setLoading(false)
setEngagementLoading(true) setEngagementLoading(true)
} }
@ -157,11 +175,11 @@ export function useLibraryPublications(isActive: boolean) {
timeoutPromise timeoutPromise
]) ])
if (gen !== loadGenRef.current) return if (gen !== loadGenRef.current) return
setEntries(result.engaged)
setIndexEvents(result.indexEvents) setIndexEvents(result.indexEvents)
setEngagement(result.engagement) setEngagement(result.engagement)
setAllIndexCount(result.allIndexCount) setAllIndexCount(result.allIndexCount)
setTopLevelCount(result.topLevelCount) setTopLevelCount(result.topLevelCount)
applyDefaultFeedSlice(result.indexEvents, result.engagement, 0)
} finally { } finally {
if (timeoutId != null) window.clearTimeout(timeoutId) if (timeoutId != null) window.clearTimeout(timeoutId)
} }
@ -179,7 +197,7 @@ export function useLibraryPublications(isActive: boolean) {
} }
} }
}, },
[pubkey, blockedRelays] [pubkey, blockedRelays, applyDefaultFeedSlice]
) )
useEffect(() => { useEffect(() => {
@ -194,7 +212,7 @@ export function useLibraryPublications(isActive: boolean) {
void (async () => { void (async () => {
await loadMyBooklistTargets() await loadMyBooklistTargets()
const relays = await buildLibraryRelayUrls(pubkey, blockedRelays ?? []) const relays = await buildLibraryRelayUrls(pubkey, blockedRelays ?? [])
const { engagement: nextEngagement, engaged } = await refreshLibraryEngagement( const { engagement: nextEngagement } = await refreshLibraryEngagement(
relays, relays,
indexEvents, indexEvents,
pubkey pubkey
@ -202,7 +220,8 @@ export function useLibraryPublications(isActive: boolean) {
if (cancelled) return if (cancelled) return
setEngagement(nextEngagement) setEngagement(nextEngagement)
if (!debouncedSearch.trim()) { if (!debouncedSearch.trim()) {
setEntries(engaged) setFeedPageIndex(0)
applyDefaultFeedSlice(indexEvents, nextEngagement, 0)
} }
})() })()
} }
@ -211,7 +230,7 @@ export function useLibraryPublications(isActive: boolean) {
cancelled = true cancelled = true
window.removeEventListener(BOOKLIST_LABEL_UPDATED_EVENT, onBooklistUpdated) window.removeEventListener(BOOKLIST_LABEL_UPDATED_EVENT, onBooklistUpdated)
} }
}, [isActive, pubkey, indexEvents, debouncedSearch, loadMyBooklistTargets, blockedRelays]) }, [isActive, pubkey, indexEvents, debouncedSearch, loadMyBooklistTargets, blockedRelays, applyDefaultFeedSlice])
useEffect(() => { useEffect(() => {
const q = debouncedSearch.trim() const q = debouncedSearch.trim()
@ -346,6 +365,20 @@ export function useLibraryPublications(isActive: boolean) {
} }
}, [showOnlyMine, pubkey, indexEvents, engagement, mineFilterOpts, debouncedSearch]) }, [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 filteredEntries = useMemo(() => {
const q = debouncedSearch.trim() const q = debouncedSearch.trim()
let list: LibraryPublicationEntry[] let list: LibraryPublicationEntry[]
@ -386,6 +419,9 @@ export function useLibraryPublications(isActive: boolean) {
topLevelCount, topLevelCount,
refresh, refresh,
searchOnRelays, searchOnRelays,
hasIndexData: indexEvents.length > 0 hasIndexData: indexEvents.length > 0,
loadMoreFeed,
defaultFeedHasMore,
feedTotalCount
} }
} }

1
src/i18n/locales/de.ts

@ -1660,6 +1660,7 @@ export default {
'Library search relays': 'Relays durchsuchen', 'Library search relays': 'Relays durchsuchen',
'Library relay search loading': 'Dokument-Relays werden durchsucht…', 'Library relay search loading': 'Dokument-Relays werden durchsucht…',
'Library status line': '{{shown}} angezeigt · {{topLevel}} Top-Level · {{total}} Indizes geladen', '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 label': 'Label',
'Library badge comment': 'Kommentar', 'Library badge comment': 'Kommentar',
'Library badge highlight': 'Markierung', 'Library badge highlight': 'Markierung',

1
src/i18n/locales/en.ts

@ -1683,6 +1683,7 @@ export default {
'Library search relays': 'Search the relays', 'Library search relays': 'Search the relays',
'Library relay search loading': 'Searching document relays…', 'Library relay search loading': 'Searching document relays…',
'Library status line': '{{shown}} shown · {{topLevel}} top-level · {{total}} indexes loaded', 'Library status line': '{{shown}} shown · {{topLevel}} top-level · {{total}} indexes loaded',
'Library load more': 'Load next {{count}} books',
'Library badge label': 'Label', 'Library badge label': 'Label',
'Library badge booklist': 'Booklist', 'Library badge booklist': 'Booklist',
'Library badge my booklist': 'On my booklist', 'Library badge my booklist': 'On my booklist',

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

@ -5,10 +5,13 @@ import {
buildLibraryPublicationRelaySearchFilters, buildLibraryPublicationRelaySearchFilters,
buildRecentPublicationEntries, buildRecentPublicationEntries,
clearLibrarySearchSessionCache, clearLibrarySearchSessionCache,
computeLibraryFeedRootOrder,
filterEngagedPublications, filterEngagedPublications,
filterLibraryPublicationsBySearch, filterLibraryPublicationsBySearch,
filterLibraryPublicationsByUser, filterLibraryPublicationsByUser,
libraryDefaultFeedSlice,
libraryPublicationEntriesForUserFromIndex, libraryPublicationEntriesForUserFromIndex,
LIBRARY_PAGE_SIZE,
pickLibraryPublicationEntries, pickLibraryPublicationEntries,
publicationRootBelongsToUser, publicationRootBelongsToUser,
peekLibrarySearchResults, peekLibrarySearchResults,
@ -293,6 +296,50 @@ describe('library-publication-index', () => {
expect(buildRecentPublicationEntries(roots, indexByAddress, engagement, 10)[0].event.created_at).toBe(11) 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', () => { it('filterLibraryPublicationsByUser includes authored, booklist, bookmarked, and commented', () => {
const viewerPk = 'f'.repeat(64) const viewerPk = 'f'.repeat(64)
const authored = indexEvent('mine', [`30041:${PK}:ch`], '1'.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
const ENGAGEMENT_EVENT_ID_CHUNK = 44 const ENGAGEMENT_EVENT_ID_CHUNK = 44
const MAX_TARGET_ADDRESSES = 480 const MAX_TARGET_ADDRESSES = 480
const HYDRATE_MISSING_CAP = 64 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 ENGAGEMENT_FETCH_TIMEOUT_MS = 25_000
const LIBRARY_SEARCH_READING_CACHE_LIMIT = 200 const LIBRARY_SEARCH_READING_CACHE_LIMIT = 200
export const LIBRARY_RELAY_SEARCH_LIMIT = 100 export const LIBRARY_RELAY_SEARCH_LIMIT = 100
@ -692,7 +694,7 @@ export function buildRecentPublicationEntries(
roots: Event[], roots: Event[],
indexByAddress: Map<string, Event>, indexByAddress: Map<string, Event>,
engagement: PublicationEngagementMaps, engagement: PublicationEngagementMaps,
limit = LIBRARY_RECENT_FALLBACK_LIMIT limit = LIBRARY_PAGE_SIZE
): LibraryPublicationEntry[] { ): LibraryPublicationEntry[] {
return [...getTopLevelIndexEvents(roots)] return [...getTopLevelIndexEvents(roots)]
.sort((a, b) => b.created_at - a.created_at) .sort((a, b) => b.created_at - a.created_at)
@ -700,30 +702,87 @@ export function buildRecentPublicationEntries(
.map((event) => buildLibraryPublicationEntry(event, indexByAddress, engagement)) .map((event) => buildLibraryPublicationEntry(event, indexByAddress, engagement))
} }
/** Engaged publications first, then fill with newest top-level indexes up to {@link LIBRARY_RECENT_FALLBACK_LIMIT}. */ /** Full default-feed order: engaged publications first, then newest top-level indexes. */
export function pickLibraryPublicationEntries( export function computeLibraryFeedRootOrder(
roots: Event[], roots: Event[],
indexByAddress: Map<string, Event>, indexByAddress: Map<string, Event>,
engagement: PublicationEngagementMaps engagement: PublicationEngagementMaps
): LibraryPublicationEntry[] { ): Event[] {
const engaged = filterEngagedPublications(roots, indexByAddress, engagement) const topLevel = getTopLevelIndexEvents(roots)
const recent = buildRecentPublicationEntries(roots, indexByAddress, engagement) const engagedRoots: Event[] = []
if (engaged.length === 0) return sortLibraryPublications(recent) 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 seen = new Set<string>()
const merged: LibraryPublicationEntry[] = [] const ordered: Event[] = []
for (const entry of sortLibraryPublications(engaged)) { for (const root of [...sortedEngaged, ...restRoots]) {
if (seen.has(entry.event.id)) continue if (seen.has(root.id)) continue
seen.add(entry.event.id) seen.add(root.id)
merged.push(entry) ordered.push(root)
} }
for (const entry of recent) { return ordered
if (merged.length >= LIBRARY_RECENT_FALLBACK_LIMIT) break }
if (seen.has(entry.event.id)) continue
seen.add(entry.event.id) /** Entries for default library feed from page 0 through {@link pageIndexInclusive} (inclusive). */
merged.push(entry) export function libraryFeedEntriesThroughPage(
} orderedRoots: Event[],
return merged 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[] { export function sortLibraryPublications(entries: LibraryPublicationEntry[]): LibraryPublicationEntry[] {

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

@ -1,8 +1,10 @@
import LibraryPublicationGrid from '@/components/Library/LibraryPublicationGrid' import LibraryPublicationGrid from '@/components/Library/LibraryPublicationGrid'
import LibrarySearchBar from '@/components/Library/LibrarySearchBar' import LibrarySearchBar from '@/components/Library/LibrarySearchBar'
import { RefreshButton } from '@/components/RefreshButton' import { RefreshButton } from '@/components/RefreshButton'
import { Button } from '@/components/ui/button'
import PrimaryPageLayout, { TPrimaryPageLayoutRef } from '@/layouts/PrimaryPageLayout' import PrimaryPageLayout, { TPrimaryPageLayoutRef } from '@/layouts/PrimaryPageLayout'
import { useLibraryPublications } from '@/hooks/useLibraryPublications' import { useLibraryPublications } from '@/hooks/useLibraryPublications'
import { LIBRARY_PAGE_SIZE } from '@/lib/library-publication-index'
import { usePrimaryPage } from '@/contexts/primary-page-context' import { usePrimaryPage } from '@/contexts/primary-page-context'
import { TPageRef } from '@/types' import { TPageRef } from '@/types'
import { BookOpen } from 'lucide-react' import { BookOpen } from 'lucide-react'
@ -30,7 +32,10 @@ const LibraryPage = forwardRef<TPageRef>((_props, ref) => {
topLevelCount, topLevelCount,
refresh, refresh,
searchOnRelays, searchOnRelays,
hasIndexData hasIndexData,
loadMoreFeed,
defaultFeedHasMore,
feedTotalCount
} = useLibraryPublications(isActive) } = useLibraryPublications(isActive)
useImperativeHandle( useImperativeHandle(
@ -97,6 +102,15 @@ const LibraryPage = forwardRef<TPageRef>((_props, ref) => {
searchQuery.trim() || showOnlyMine ? t('Library empty filtered') : t('Library empty') 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> </div>
</PrimaryPageLayout> </PrimaryPageLayout>
) )

Loading…
Cancel
Save