/** * Loads a user's dumps or upvoted dumps, a page at a time, for the profile * subpages that list them in full. * * @module */ import { type RefCallback, useCallback, useEffect, useLayoutEffect, useRef, useState, } from "react"; import { API_URL, DEFAULT_PAGE_SIZE } from "../../config/api.ts"; import { expectOk, friendlyFetchError } from "../../utils/shared/api.ts"; import type { Dump } from "../../../shared/dump.ts"; import type { PaginatedData, Wire } from "../../../shared/api.ts"; import type { PublicUser } from "../../../shared/user.ts"; import { deserializeDump, deserializePublicUser, hydrateDump, } from "../../utils/shared/deserialize.ts"; import { useAuth } from "../auth/useAuth.ts"; import { useFeedCache } from "../feed/useFeedCache.ts"; import { useInfiniteScroll } from "../feed/useInfiniteScroll.ts"; import { useScrollSave } from "../feed/useScrollSave.ts"; /** * Held by {@link useUserDumpFeed} for `UserDumps` and `UserUpvoted`. It fails * when the user doesn't exist, and holds an empty list when only the dumps * failed to load. */ export type UserDumpFeedState = | { status: "loading" } | { status: "error"; error: string } | { status: "loaded"; profileUser: PublicUser; items: Dump[]; hasMore: boolean; page: number; loadingMore: boolean; }; /** Passed to {@link useUserDumpFeed} by `UserUpvoted`. */ export interface UseUserDumpFeedOptions { /** Called with the dumps of each page after the first, once appended. */ onItemsAppended?: (items: Dump[]) => void; } /** Returned by {@link useUserDumpFeed} to `UserDumps` and `UserUpvoted`. */ export interface UseUserDumpFeedResult { state: UserDumpFeedState; /** Lets `UserUpvoted` prepend a dump the user just upvoted. */ setState: React.Dispatch>; /** Does nothing until loaded. Passed to `useDumpListSync`. */ setItems: (fn: (prev: Dump[]) => Dump[]) => void; /** From `useInfiniteScroll`, for an element after the last card. */ sentinelRef: RefCallback; } /** * Returns the profile of `username`, from `GET /api/users/:username`, with its * dumps from `GET /api/users/:username/`, `endpoint` being "dumps" * or "votes". The dumps are fetched signed in, so the user sees their own * private ones. The next page loads as the sentinel scrolls into view. The * list and its scroll position are saved under `cacheKey` as the page is * left, and going back restores them, refetching only the profile. */ export function useUserDumpFeed( username: string | undefined, endpoint: string, cacheKey: string, options?: UseUserDumpFeedOptions, ): UseUserDumpFeedResult { const { token } = useAuth(); const { cached, saveState } = useFeedCache(cacheKey, hydrateDump); const [state, setState] = useState({ status: "loading" }); const [prevUsername, setPrevUsername] = useState(username); if (prevUsername !== username) { setPrevUsername(username); setState({ status: "loading" }); } const setItems = useCallback((fn: (prev: Dump[]) => Dump[]) => { setState((s) => s.status !== "loaded" ? s : { ...s, items: fn(s.items) }); }, []); useEffect(() => { if (!username) return; const controller = new AbortController(); if (cached) { fetch(`${API_URL}/api/users/${username}`, { signal: controller.signal }) .then((r) => r.json()) .then((body) => { if (!body.success) throw new Error("User not found"); setState({ status: "loaded", profileUser: deserializePublicUser(body.data), items: cached.items, hasMore: cached.hasMore, page: cached.page, loadingMore: false, }); }) .catch((err) => { if (err.name === "AbortError") return; setState({ status: "error", error: friendlyFetchError(err) }); }); return () => controller.abort(); } const authHeaders: HeadersInit = token ? { Authorization: `Bearer ${token}` } : {}; Promise.all([ fetch(`${API_URL}/api/users/${username}`, { signal: controller.signal }), fetch( `${API_URL}/api/users/${username}/${endpoint}?page=1&limit=${DEFAULT_PAGE_SIZE}`, { headers: authHeaders, signal: controller.signal }, ), ]) .then(([userRes, itemsRes]) => Promise.all([userRes.json(), itemsRes.json()]) ) .then(([userBody, itemsBody]) => { if (!userBody.success) throw new Error("User not found"); const { items, hasMore } = itemsBody.success ? itemsBody.data as PaginatedData> : { items: [], hasMore: false }; setState({ status: "loaded", profileUser: deserializePublicUser(userBody.data), items: items.map(deserializeDump), hasMore, page: 1, loadingMore: false, }); }) .catch((err) => { if (err.name === "AbortError") return; setState({ status: "error", error: friendlyFetchError(err) }); }); return () => controller.abort(); }, [username, endpoint, cached, token]); const { onItemsAppended } = options ?? {}; const loadMore = useCallback(() => { if ( state.status !== "loaded" || !state.hasMore || state.loadingMore || !username ) return; const nextPage = state.page + 1; setState((s) => s.status === "loaded" ? { ...s, loadingMore: true } : s); fetch( `${API_URL}/api/users/${username}/${endpoint}?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`, { headers: token ? { Authorization: `Bearer ${token}` } : {} }, ) .then((res) => expectOk>>(res)) .then(({ items, hasMore }) => { const newItems = items.map(deserializeDump); setState((s) => s.status === "loaded" ? { ...s, items: [...s.items, ...newItems], hasMore, page: nextPage, loadingMore: false, } : s ); onItemsAppended?.(newItems); }) .catch(() => setState((s) => s.status === "loaded" ? { ...s, loadingMore: false } : s ) ); }, [state, username, token, endpoint, onItemsAppended]); const sentinelRef = useInfiniteScroll( loadMore, state.status === "loaded" && state.hasMore && !state.loadingMore, ); useScrollSave( state.status === "loaded", useCallback((y) => { if (state.status !== "loaded") return; saveState(state.items, state.page, state.hasMore, y); }, [state, saveState]), ); const scrollRestored = useRef(false); useLayoutEffect(() => { if (cached?.scrollY == null || scrollRestored.current) return; if (state.status === "loaded") { globalThis.scrollTo(0, cached.scrollY); scrollRestored.current = true; } }, [state.status, cached]); return { state, setState, setItems, sentinelRef }; }