/** * Makes every list the API serves a page at a time work the same way. Each is a * {@link PagedList}, whose next page `useLoadMore` appends, and this module * reads its pages. * * @module */ import { DEFAULT_PAGE_SIZE } from "../../config/api.ts"; import type { PaginatedData, Wire } from "../../../shared/api.ts"; import { dataOr, expectOk } from "../shared/api.ts"; /** * Held for a list by `usePagedList`, or by a page that loads it along with * more, as the search page does. `useLoadMore` loads the pages after `page`. */ export interface PagedList { items: T[]; hasMore: boolean; /** Loading more carries on from this page, the last loaded. */ page: number; loadingMore: boolean; } /** Returns the list holding only its first page, `items`. */ export function firstPage(items: T[], hasMore: boolean): PagedList { return { items, hasMore, page: 1, loadingMore: false }; } /** Returns the list as `useFeedCache`'s `cached` restores it. */ export function restoredList( { items, hasMore, page }: { items: T[]; hasMore: boolean; page: number }, ): PagedList { return { items, hasMore, page, loadingMore: false }; } /** * Checks whether `list` has a next page to load now, which `useLoadMore` * requires and `useInfiniteScroll` takes as `enabled`. A list not loaded yet * is null. */ export function canLoadMore(list: PagedList | null): boolean { return !!list && list.hasMore && !list.loadingMore; } /** * Returns a setter of a list's items as a setter of the list, for the sync * hooks, which edit items only. */ export function mapItems( fn: (items: T[]) => T[], ): (list: PagedList) => PagedList { return (list) => ({ ...list, items: fn(list.items) }); } /** Returns `url` asking for page `page`, `limit` items long. */ export function pageUrl( url: string, page: number, limit = DEFAULT_PAGE_SIZE, ): string { return `${url}${url.includes("?") ? "&" : "?"}page=${page}&limit=${limit}`; } /** * Returns the page `res` answers, its items deserialized. * * @throws {Error} With the server's message when it refuses, and when the * body isn't an envelope. */ export async function readPage( res: Response, deserialize: (wire: Wire) => T, ): Promise> { const { items, hasMore } = await expectOk>>(res); return { items: items.map(deserialize), hasMore }; } const EMPTY_PAGE: PaginatedData = { items: [], hasMore: false }; /** * Returns the first page `res` answers as a list, or an empty list when the * server refuses it. The profile pages read their lists with it beside the * user, whose failure alone fails the page. * * @throws {Error} When the body isn't an envelope. */ export async function readFirstPage( res: Response, deserialize: (wire: Wire) => T, ): Promise> { const { items, hasMore } = await dataOr>>( res, EMPTY_PAGE, ); return firstPage(items.map(deserialize), hasMore); } /** * Returns `items` with `item` first, or `items` as they are when it is already * there, as when a list's own echo of a new item arrives after it. */ export function withFirst(items: T[], item: T): T[] { return items.some((i) => i.id === item.id) ? items : [item, ...items]; } /** * Returns `items`, in the order of `compare` as the API pages them, with * `item` where that order puts it: before the first item that doesn't come * before it. An item that comes after every one loaded is left to a later * page, and `items` returned as they are, as they are when `item` is already * there. `usePlaylistListSync` puts a new, newly visible or risen playlist in * a list with it, whatever its order. */ export function withInOrder( items: T[], item: T, compare: (a: T, b: T) => number, ): T[] { if (items.some((i) => i.id === item.id)) return items; if (items.length === 0) return [item]; const idx = items.findIndex((i) => compare(i, item) >= 0); if (idx === -1) return items; return [...items.slice(0, idx), item, ...items.slice(idx)]; } /** * Returns `items`, newest first, with `item` where its `createdAt` puts it, * as {@link withInOrder} does. `useDumpListSync` puts a new or newly visible * dump in a list with it. */ export function withByDate( items: T[], item: T, ): T[] { return withInOrder( items, item, (a, b) => b.createdAt.getTime() - a.createdAt.getTime(), ); } /** * Fetches pages 1 to `pages` of a list again, and resolves to their items, * each once, and whether more follow. An item added between two pages' * fetches shifts the later page by one, which would list an item twice. A * live list calls it after a reconnection, through `usePagedList`'s `resync` * or, for the search's dumps, directly; it rejects when a page fails. */ export async function refetchPages( pages: number, fetchPage: (page: number) => Promise>, ): Promise> { const results = await Promise.all( Array.from({ length: pages }, (_, i) => fetchPage(i + 1)), ); const byId = new Map(); for (const item of results.flatMap((r) => r.items)) { if (!byId.has(item.id)) byId.set(item.id, item); } return { items: [...byId.values()], hasMore: results[results.length - 1].hasMore, }; }