Files
gerbeur/src/pages/Index.tsx
khannurien 0d4e344d77 v3: comments say what the code can't — the documentation pass wanted JSDoc on every export and every field of an exported interface, which filled the code with restatements ("The dump.", "Whether it is loading.", "The props of X.", "Renders the X.") and module docs that told a reader nothing ("The hot and new feeds, and the props every main feed of the index takes."); some were wrong as well as vague. Every comment in api/, shared/ and src/ is rewritten under one rule, now stated in the README's "Comments" section: every module and export still has a JSDoc, and it gives context (who uses it and where), usage (what a caller must know: a precondition, what it leaves to the caller, a side effect) or its place in the chain (where its input comes from, where its output goes); a field, parameter, private function or local gets one only when it adds to its parent's, and context about a whole type goes on the type; a type's doc is phrased from the value's side ("Returned to Y by X when Z", "Passed to Y by X"); full sentences, as short as they can be, no label-colon-list; // goes wherever the code isn't obvious and says why, and the section banners and narration go. Every claim was checked against the code, which turned up some forty comments that were wrong: DumpCard's claimed it was only used by the hot and new feeds, the WebSocket welcome claimed to be a socket's first message (presence comes first), UpdateUserRequest said null removes a description (it keeps it), deletedDumpIds leaves out that it also holds dumps made private, THEME_COLOR claimed to mirror index.html's, and the like. The routers' JSDoc now says where they are mounted and what becomes of a thrown APIException. doc-lint.ts lets a field without a doc through; a module or export without one still fails. No code changes; 204 backend and 628 frontend tests pass, and doc:lint, deno check, deno lint and tsc are clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 14:31:14 +00:00

367 lines
12 KiB
TypeScript

/**
* Renders the home page, which shows every dump, or one category's, in the
* "hot", "new", "journal" and "followed" tabs.
*
* @module
*/
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { useLocation, useParams } from "react-router";
import { AppHeader } from "../components/layout/AppHeader.tsx";
import { PresenceRow } from "../components/feed/PresenceRow.tsx";
import { FeedTabBar } from "../components/feed/FeedTabBar.tsx";
import {
DEFAULT_FEED_TAB,
FEED_TABS,
type FeedTab,
} from "../config/feedTabs.ts";
import { useDefaultFeedTab } from "../hooks/feed/useDefaultFeedTab.ts";
import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts";
import { deserializeDump, hydrateDump } from "../utils/shared/deserialize.ts";
import type { Dump } from "../../shared/dump.ts";
import type { PaginatedData, Wire } from "../../shared/api.ts";
import { friendlyFetchError } from "../utils/shared/api.ts";
import { useFeedCache } from "../hooks/feed/useFeedCache.ts";
import { useScrollSave } from "../hooks/feed/useScrollSave.ts";
import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts";
import { useAuth } from "../hooks/auth/useAuth.ts";
import { useWS } from "../hooks/ws/useWS.ts";
import { useCategories } from "../hooks/category/useCategories.ts";
import { useDumpListSync } from "../hooks/dump/useDumpListSync.ts";
import { useInfiniteScroll } from "../hooks/feed/useInfiniteScroll.ts";
import { DumpFeed, type MainFeedProps } from "../components/feed/DumpFeed.tsx";
import { JournalFeed } from "../components/feed/JournalFeed.tsx";
import { FollowedFeed } from "../components/feed/FollowedFeed.tsx";
import { NotFound } from "./NotFound.tsx";
type DumpsState =
| { status: "loading" }
| { status: "error"; error: string }
| {
status: "loaded";
dumps: Dump[];
hasMore: boolean;
page: number;
loadingMore: boolean;
};
/**
* Passed to {@link Index} by the router, from the `:categorySlug` of the URL.
*/
export interface IndexProps {
/** Without it, the feed holds every dump. An unknown slug renders 404. */
categorySlug?: string;
}
/**
* Renders `/~/:feedTab` and `/:categorySlug/:feedTab`, for anyone, the
* "followed" tab for a signed-in user only. The feed comes from
* `GET /api/dumps/`, or `GET /api/categories/:slug/dumps`, a page at a time,
* and the "hot", "new" and "journal" tabs share it. `WSContext` adds the dumps
* posted since, the live vote counts, and who's online. The router keys the
* page by category, so switching tabs keeps the feed and switching categories
* refetches it. It also opens the dump creation modal on a link shared from
* Android (`?share_url=`).
*/
export function Index(
{ categorySlug }: IndexProps,
): React.JSX.Element {
const location = useLocation();
const justDeletedId = (location.state as { deletedDumpId?: string } | null)
?.deletedDumpId;
const { user, token } = useAuth();
const {
voteCounts,
myVotes,
recentDumps,
deletedDumpIds,
castVote,
removeVote,
connectionEpoch,
} = useWS();
const { categories, isLoaded: categoriesLoaded } = useCategories();
const category = categorySlug
? categories.find((c) => c.slug === categorySlug)
: undefined;
const categoryNotFound = !!categorySlug && categoriesLoaded && !category;
useDocumentTitle(
categorySlug ? (category ? category.name : undefined) : null,
);
const { feedTab: rawTab } = useParams();
const [preferredTab] = useDefaultFeedTab();
const defaultTab: FeedTab = preferredTab === "followed" && !user
? DEFAULT_FEED_TAB
: preferredTab;
// A guest on "followed", from a shared link or after signing out, gets the
// default tab rather than an empty page.
const tab: FeedTab =
(FEED_TABS as readonly string[]).includes(rawTab ?? "") &&
(rawTab !== "followed" || !!user)
? (rawTab as FeedTab)
: defaultTab;
const feedUrl = useCallback(
(page: number) =>
categorySlug
? `${API_URL}/api/categories/${
encodeURIComponent(categorySlug)
}/dumps?page=${page}&limit=${DEFAULT_PAGE_SIZE}`
: `${API_URL}/api/dumps/?page=${page}&limit=${DEFAULT_PAGE_SIZE}`,
[categorySlug],
);
const { cached, saveState } = useFeedCache<Dump>(
`feed:index:${categorySlug ?? "all"}:${user?.id ?? "guest"}`,
hydrateDump,
);
const [dumpsState, setDumpsState] = useState<DumpsState>(() =>
cached
? {
status: "loaded",
dumps: cached.items,
hasMore: cached.hasMore,
page: cached.page,
loadingMore: false,
}
: { status: "loading" }
);
const mainFetchDone = useRef(false);
// The Web Share Target: Android's share sheet opens `/?share_url=…`, which
// the redirect from `/` keeps. The query is dropped from the URL at once, so
// a reload doesn't share the link a second time.
const searchParams = new URLSearchParams(location.search);
const shareUrl = searchParams.get("share_url") ??
searchParams.get("share_text") ?? "";
useEffect(() => {
if (!shareUrl) return;
globalThis.history.replaceState({}, "", location.pathname);
}, [shareUrl, location.pathname]);
useEffect(() => {
if (mainFetchDone.current || cached) return;
mainFetchDone.current = true;
const controller = new AbortController();
(async () => {
try {
const res = await fetch(feedUrl(1), {
signal: controller.signal,
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = await res.json();
const { items, hasMore } = body.data as PaginatedData<Wire<Dump>>;
setDumpsState({
status: "loaded",
dumps: items.map(deserializeDump),
hasMore,
page: 1,
loadingMore: false,
});
} catch (err) {
if ((err as Error).name === "AbortError") return;
setDumpsState({ status: "error", error: friendlyFetchError(err) });
}
})();
return () => {
mainFetchDone.current = false;
controller.abort();
};
}, [cached, token, feedUrl]);
const setDumpsItems = useCallback(
(fn: (prev: Dump[]) => Dump[]) =>
setDumpsState((s) =>
s.status !== "loaded" ? s : { ...s, dumps: fn(s.dumps) }
),
[],
);
useDumpListSync(setDumpsItems);
// Dumps posted while the socket was down never arrived. On reconnect, the
// first page is fetched again and whatever is new goes on top, keeping the
// pages loaded and the scroll position.
const backfillEpochRef = useRef(connectionEpoch);
useEffect(() => {
if (connectionEpoch === backfillEpochRef.current) return;
backfillEpochRef.current = connectionEpoch;
const controller = new AbortController();
fetch(feedUrl(1), {
signal: controller.signal,
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
.then((r) => r.json())
.then((body) => {
const { items } = body.data as PaginatedData<Wire<Dump>>;
const fresh = items.map(deserializeDump);
setDumpsState((s) => {
if (s.status !== "loaded") return s;
const known = new Set(s.dumps.map((d) => d.id));
const newOnes = fresh.filter((d) => !known.has(d.id));
if (newOnes.length === 0) return s;
return { ...s, dumps: [...newOnes, ...s.dumps] };
});
})
.catch(() => {});
return () => controller.abort();
}, [connectionEpoch, feedUrl, token]);
const loadMore = useCallback(() => {
if (
dumpsState.status !== "loaded" || !dumpsState.hasMore ||
dumpsState.loadingMore
) return;
const nextPage = dumpsState.page + 1;
setDumpsState((s) =>
s.status === "loaded" ? { ...s, loadingMore: true } : s
);
fetch(feedUrl(nextPage), {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
.then((r) => r.json())
.then((body) => {
const { items, hasMore } = body.data as PaginatedData<Wire<Dump>>;
setDumpsState((s) =>
s.status === "loaded"
? {
...s,
dumps: [...s.dumps, ...items.map(deserializeDump)],
hasMore,
page: nextPage,
loadingMore: false,
}
: s
);
})
.catch(() =>
setDumpsState((s) =>
s.status === "loaded" ? { ...s, loadingMore: false } : s
)
);
}, [dumpsState, token, feedUrl]);
const sentinelRef = useInfiniteScroll(
loadMore,
dumpsState.status === "loaded" && dumpsState.hasMore &&
!dumpsState.loadingMore,
);
useScrollSave(
dumpsState.status === "loaded",
useCallback((y) => {
if (dumpsState.status !== "loaded") return;
saveState(dumpsState.dumps, dumpsState.page, dumpsState.hasMore, y);
}, [dumpsState, saveState]),
);
const scrollRestored = useRef(false);
useLayoutEffect(() => {
if (cached?.scrollY == null || scrollRestored.current) return;
if (dumpsState.status === "loaded") {
globalThis.scrollTo(0, cached.scrollY);
scrollRestored.current = true;
}
}, [dumpsState.status, cached]);
const loading = dumpsState.status === "loading";
const error = dumpsState.status === "error" ? dumpsState.error : null;
const dumps = useMemo(
() => dumpsState.status === "loaded" ? dumpsState.dumps : [],
[dumpsState],
);
const loadingMore = dumpsState.status === "loaded" && dumpsState.loadingMore;
const hasMore = dumpsState.status === "loaded" ? dumpsState.hasMore : false;
// A category's feed takes only the new dumps filed under it.
const categoryId = category?.id;
const inScope = useCallback(
(d: Dump) =>
categorySlug
? !!categoryId && !!d.categoryIds?.includes(categoryId)
: true,
[categorySlug, categoryId],
);
const restIds = useMemo(() => new Set(dumps.map((d) => d.id)), [dumps]);
// Dumps deleted since loading are dropped. `justDeletedId` never matches,
// though: DumpEdit navigates to `/`, whose redirect drops the state.
const combined = useMemo(
() =>
[...recentDumps.filter((d) => inScope(d) && !restIds.has(d.id)), ...dumps]
.filter((d) => !deletedDumpIds.has(d.id) && d.id !== justDeletedId),
[recentDumps, restIds, dumps, deletedDumpIds, justDeletedId, inScope],
);
const mainFeedProps: MainFeedProps = {
dumps: combined,
loading,
error,
hasMore,
loadingMore,
sentinelRef,
voteCounts,
myVotes,
user,
castVote,
removeVote,
};
if (categoryNotFound) return <NotFound />;
return (
<div className="index-page">
<AppHeader
centerSlot={
<div className="header-center-slot">
<PresenceRow
fitWithin=".app-header-center"
fitBeside=".feed-sort"
/>
<FeedTabBar />
</div>
}
disableNew={dumpsState.status === "error"}
initialDumpUrl={shareUrl || undefined}
/>
<div className="index-below-header">
<FeedTabBar />
<PresenceRow fitWithin=".index-below-header" fitBeside=".feed-sort" />
</div>
{(tab === "hot" || tab === "new") && (
<DumpFeed order={tab} {...mainFeedProps} />
)}
{tab === "journal" && <JournalFeed {...mainFeedProps} />}
{tab === "followed" && user && (
<FollowedFeed
categorySlug={categorySlug}
voteCounts={voteCounts}
myVotes={myVotes}
user={user}
castVote={castVote}
removeVote={removeVote}
deletedDumpIds={deletedDumpIds}
/>
)}
</div>
);
}