v3: src/ gets one layout rule and keeps to it — each layer holds one kind of module, and every layer is split into the same domains — the previous pass sorted components into folders but left hooks, helpers and a barrel inside them, kept hooks/, utils/ and contexts/ flat, and still grouped most tests by theme. The rule: components/ holds only components (.tsx), contexts/ holds FooContext.ts + FooProvider.tsx pairs, hooks/ every hook, utils/ every non-React module, pages/ one component per route and nothing else. Each of components/, contexts/, hooks/ and utils/ is split into the same domains — auth, category, chat, comment, dump, editor, feed, form, layout, player, playlist, profile, social, theme, ws — plus shared/ for what belongs to no one domain, so the chat is components/chat, contexts/chat, hooks/chat and utils/chat. components/ui becomes components/shared to match. Out of components/: useApiForm goes to hooks/form, chat's message.ts and time.ts to utils/chat (with chatDraft and chatText, now draft.ts and text.ts), the emoji skin tone to utils/editor, the remembered volume to utils/player; player/icons.tsx is PlayerIcons.tsx. components/form/index.ts is gone: everything is imported from its own file, FormProvider straight from react-hook-form, and expectOk sits beside friendlyFetchError in utils/shared/api.ts (apiError.ts), since pages that have no form used it too. Out of pages/: the index's feed views, tab bar and presence row move to components/feed; HotFeed and NewFeed, identical but for the sort, are one DumpFeed with an order prop, which also takes over MainFeedProps from pages/index/types.ts. App is a named export like everything else. Tests mirror the tree one file per module — test/<layer>/<domain>/<Module>.test.tsx — so the themed files (hooks/misc, data, editor, sync; utils/misc, text, storage, remote, journal; contexts/providers; pages/auth and UserSubpages; layout/Fabs) are split by subject, the setup each piece needs kept and the rest dropped; the floating-button test becomes one test per button, 627 tests now. The README states the rule. No behaviour changes; the catalogues only move their source references.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
195
src/hooks/dump/useUserDumpFeed.ts
Normal file
195
src/hooks/dump/useUserDumpFeed.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import {
|
||||
type RefCallback,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import { API_URL, DEFAULT_PAGE_SIZE } from "../../config/api.ts";
|
||||
import { friendlyFetchError } from "../../utils/shared/api.ts";
|
||||
import type { Dump, PaginatedData, PublicUser, RawDump } from "../../model.ts";
|
||||
import {
|
||||
deserializeDump,
|
||||
deserializePublicUser,
|
||||
hydrateDump,
|
||||
} from "../../model.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";
|
||||
|
||||
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: RefCallback<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 };
|
||||
}
|
||||
Reference in New Issue
Block a user