import { type RefCallback, useCallback, useEffect, useLayoutEffect, useRef, useState, } from "react"; import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts"; import { friendlyFetchError } from "../utils/apiError.ts"; import type { Dump, PaginatedData, PublicUser, RawDump } from "../model.ts"; import { deserializeDump, deserializePublicUser, hydrateDump, } from "../model.ts"; import { useAuth } from "./useAuth.ts"; import { useFeedCache } from "./useFeedCache.ts"; import { useInfiniteScroll } from "./useInfiniteScroll.ts"; import { useScrollSave } from "./useScrollSave.ts"; type State = | { status: "loading" } | { status: "error"; error: string } | { status: "loaded"; profileUser: PublicUser; items: Dump[]; hasMore: boolean; page: number; loadingMore: boolean; }; interface UseUserDumpFeedOptions { /** Called with newly appended items whenever a loadMore succeeds. */ onItemsAppended?: (items: Dump[]) => void; } interface UseUserDumpFeedResult { state: State; setState: React.Dispatch>; setItems: (fn: (prev: Dump[]) => Dump[]) => void; sentinelRef: RefCallback; } /** * Shared data-fetching, pagination, and scroll-save logic for profile subpages * that display a paginated list of dumps (e.g. UserDumps, UserUpvoted). * * @param username The route param value (may be undefined during hydration) * @param endpoint Relative path after /api/users/:username, e.g. "dumps" or "votes" * @param cacheKey sessionStorage key for the feed cache * @param options Optional callbacks */ 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((r) => r.json()) .then((body) => { const { items, hasMore } = body.data as PaginatedData; 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 }; }