107 lines
3.4 KiB
TypeScript
107 lines
3.4 KiB
TypeScript
/**
|
|
* 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<T> = (
|
|
page: number,
|
|
signal?: AbortSignal,
|
|
) => Promise<PaginatedData<T>>;
|
|
|
|
/**
|
|
* 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<T>(
|
|
list: PagedList<T> | null,
|
|
setList: (fn: (list: PagedList<T>) => PagedList<T>) => void,
|
|
fetchPage: FetchPage<T>,
|
|
): () => 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<T> {
|
|
state: FetchState<PagedList<T>>;
|
|
/** 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<T>(
|
|
key: string | undefined,
|
|
fetchPage: FetchPage<T>,
|
|
cached?: Omit<FeedCacheEntry<T>, "savedAt"> | null,
|
|
): UsePagedListResult<T> {
|
|
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 };
|
|
}
|