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>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* The feed page.
|
||||
* Renders the home page, which shows every dump, or one category's, in the
|
||||
* "hot", "new", "journal" and "followed" tabs.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -45,7 +46,6 @@ import { JournalFeed } from "../components/feed/JournalFeed.tsx";
|
||||
import { FollowedFeed } from "../components/feed/FollowedFeed.tsx";
|
||||
import { NotFound } from "./NotFound.tsx";
|
||||
|
||||
/** The feed's state: loading, failed, or loaded with a page of dumps. */
|
||||
type DumpsState =
|
||||
| { status: "loading" }
|
||||
| { status: "error"; error: string }
|
||||
@@ -57,19 +57,23 @@ type DumpsState =
|
||||
loadingMore: boolean;
|
||||
};
|
||||
|
||||
/** The props of {@link Index}. */
|
||||
/**
|
||||
* Passed to {@link Index} by the router, from the `:categorySlug` of the URL.
|
||||
*/
|
||||
export interface IndexProps {
|
||||
/** The category the feed is scoped to; every dump when undefined. */
|
||||
/** Without it, the feed holds every dump. An unknown slug renders 404. */
|
||||
categorySlug?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders `/~/:feedTab` and `/:categorySlug/:feedTab`, for anyone: the shared
|
||||
* feed page, its tabs (hot, new, journal, followed for a signed-in user), who's
|
||||
* online, and new dumps as they arrive. `categorySlug` scopes it to a single
|
||||
* category; when absent it shows every dump. The router keys this component by
|
||||
* scope, so each category gets its own mount/fetch while switching feed tabs
|
||||
* reuses it.
|
||||
* 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,
|
||||
@@ -89,7 +93,6 @@ export function Index(
|
||||
connectionEpoch,
|
||||
} = useWS();
|
||||
|
||||
// Resolve the scope to a real category (for its id, name, and 404 handling).
|
||||
const { categories, isLoaded: categoriesLoaded } = useCategories();
|
||||
const category = categorySlug
|
||||
? categories.find((c) => c.slug === categorySlug)
|
||||
@@ -100,23 +103,19 @@ export function Index(
|
||||
categorySlug ? (category ? category.name : undefined) : null,
|
||||
);
|
||||
|
||||
// ── Active feed tab (from the URL) ──
|
||||
|
||||
const { feedTab: rawTab } = useParams();
|
||||
const [preferredTab] = useDefaultFeedTab();
|
||||
const defaultTab: FeedTab = preferredTab === "followed" && !user
|
||||
? DEFAULT_FEED_TAB
|
||||
: preferredTab;
|
||||
// "followed" needs a session: a guest there (a shared link, or signed out
|
||||
// on it) gets the default feed rather than an empty page.
|
||||
// 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;
|
||||
|
||||
// ── Main feed state ──
|
||||
|
||||
const feedUrl = useCallback(
|
||||
(page: number) =>
|
||||
categorySlug
|
||||
@@ -144,7 +143,9 @@ export function Index(
|
||||
);
|
||||
const mainFetchDone = useRef(false);
|
||||
|
||||
// Web Share Target: Android share sheet navigates to /?share_url=...
|
||||
// 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") ?? "";
|
||||
@@ -154,8 +155,6 @@ export function Index(
|
||||
globalThis.history.replaceState({}, "", location.pathname);
|
||||
}, [shareUrl, location.pathname]);
|
||||
|
||||
// ── Main feed fetch ──
|
||||
|
||||
useEffect(() => {
|
||||
if (mainFetchDone.current || cached) return;
|
||||
mainFetchDone.current = true;
|
||||
@@ -187,8 +186,6 @@ export function Index(
|
||||
};
|
||||
}, [cached, token, feedUrl]);
|
||||
|
||||
// ── WS sync for main feed ──
|
||||
|
||||
const setDumpsItems = useCallback(
|
||||
(fn: (prev: Dump[]) => Dump[]) =>
|
||||
setDumpsState((s) =>
|
||||
@@ -198,11 +195,9 @@ export function Index(
|
||||
);
|
||||
useDumpListSync(setDumpsItems);
|
||||
|
||||
// ── Reconnect backfill ──
|
||||
|
||||
// New dumps that arrived while the socket was down weren't delivered. On
|
||||
// reconnect, re-fetch page 1 and merge anything not already present on top,
|
||||
// preserving loaded pages and scroll position.
|
||||
// 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;
|
||||
@@ -228,8 +223,6 @@ export function Index(
|
||||
return () => controller.abort();
|
||||
}, [connectionEpoch, feedUrl, token]);
|
||||
|
||||
// ── Load more ──
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (
|
||||
dumpsState.status !== "loaded" || !dumpsState.hasMore ||
|
||||
@@ -264,8 +257,6 @@ export function Index(
|
||||
);
|
||||
}, [dumpsState, token, feedUrl]);
|
||||
|
||||
// ── Scroll save / restore ──
|
||||
|
||||
const sentinelRef = useInfiniteScroll(
|
||||
loadMore,
|
||||
dumpsState.status === "loaded" && dumpsState.hasMore &&
|
||||
@@ -289,8 +280,6 @@ export function Index(
|
||||
}
|
||||
}, [dumpsState.status, cached]);
|
||||
|
||||
// ── Derived values ──
|
||||
|
||||
const loading = dumpsState.status === "loading";
|
||||
const error = dumpsState.status === "error" ? dumpsState.error : null;
|
||||
const dumps = useMemo(
|
||||
@@ -300,7 +289,7 @@ export function Index(
|
||||
const loadingMore = dumpsState.status === "loaded" && dumpsState.loadingMore;
|
||||
const hasMore = dumpsState.status === "loaded" ? dumpsState.hasMore : false;
|
||||
|
||||
// New dumps arriving over the WS only belong in this feed if they're in scope.
|
||||
// A category's feed takes only the new dumps filed under it.
|
||||
const categoryId = category?.id;
|
||||
const inScope = useCallback(
|
||||
(d: Dump) =>
|
||||
@@ -311,6 +300,8 @@ export function Index(
|
||||
);
|
||||
|
||||
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]
|
||||
@@ -332,8 +323,6 @@ export function Index(
|
||||
removeVote,
|
||||
};
|
||||
|
||||
// ── Render ──
|
||||
|
||||
if (categoryNotFound) return <NotFound />;
|
||||
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user