/** * Gives every paged list of the app the same loading. Every list the API pages * goes through {@link useLoadMore} for its next pages, and a list that loads on * its own through {@link usePagedList}, from its first page on. * * @module */ import { useCallback, useLayoutEffect, useRef } from "react"; import type { PaginatedData } from "../../../shared/api.ts"; import { canLoadMore, firstPage, mapItems, type PagedList, restoredList, } from "../../utils/feed/pagedList.ts"; import type { FeedCacheEntry } from "./useFeedCache.ts"; import { type FetchState, useFetchState } from "../shared/useFetchState.ts"; /** * Passed to {@link useLoadMore} and {@link usePagedList} by a list's page or * hook. It resolves to page `page` of the list, its items deserialized, as * `fetch` with `pageUrl` and `readPage` gives it, and rejects when the page * can't be had. */ export type FetchPage = ( page: number, signal?: AbortSignal, ) => Promise>; /** * Returns the call that loads the page after `list`'s last one and appends it * through `setList`. It does nothing unless `canLoadMore(list)`; a page that * fails leaves the list as it was, for the next call to try again. A page * that loads its list along with more, as the search page does, uses it * directly, and `usePagedList` for the others. */ export function useLoadMore( list: PagedList | null, setList: (fn: (list: PagedList) => PagedList) => void, fetchPage: FetchPage, ): () => void { // A ref, so that a new `fetchPage` on every render keeps the call stable. const fetchPageRef = useRef(fetchPage); useLayoutEffect(() => { fetchPageRef.current = fetchPage; }); return useCallback(() => { if (!list || !canLoadMore(list)) return; const page = list.page + 1; setList((l) => ({ ...l, loadingMore: true })); fetchPageRef.current(page) .then(({ items, hasMore }) => setList((l) => ({ items: [...l.items, ...items], hasMore, page, loadingMore: false, })) ) .catch(() => setList((l) => ({ ...l, loadingMore: false }))); }, [list, setList]); } /** Returned by {@link usePagedList}. */ export interface UsePagedListResult { state: FetchState>; /** Does nothing until loaded. Passed to the sync hooks. */ setItems: (fn: (items: T[]) => T[]) => void; /** It goes to `useInfiniteScroll` or to a `LoadMoreButton`. */ loadMore: () => void; } /** * Returns the list `fetchPage` serves for `key`, from its first page, and the * call that loads the next. `key` changes as `useFetchState`'s does, and * `cached`, `useFeedCache`'s restored list for it, if any, is shown without * loading. The index's feeds, the profile's lists, the notifications and the * popover of voters and likers use it. */ export function usePagedList( key: string | undefined, fetchPage: FetchPage, cached?: Omit, "savedAt"> | null, ): UsePagedListResult { const { state, setData } = useFetchState( key, (signal) => fetchPage(1, signal).then(({ items, hasMore }) => firstPage(items, hasMore) ), { initial: cached ? restoredList(cached) : undefined }, ); const setItems = useCallback( (fn: (items: T[]) => T[]) => setData(mapItems(fn)), [setData], ); const loadMore = useLoadMore( state.status === "loaded" ? state.data : null, setData, fetchPage, ); return { state, setItems, loadMore }; }