All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 43s
196 lines
6.0 KiB
TypeScript
196 lines
6.0 KiB
TypeScript
import {
|
|
type Ref,
|
|
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<React.SetStateAction<State>>;
|
|
setItems: (fn: (prev: Dump[]) => Dump[]) => void;
|
|
sentinelRef: Ref<HTMLDivElement>;
|
|
}
|
|
|
|
/**
|
|
* 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<Dump>(cacheKey, hydrateDump);
|
|
|
|
const [state, setState] = useState<State>({ 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<RawDump>
|
|
: { 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<RawDump>;
|
|
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 };
|
|
}
|