v3: src/ is documented like api/ and shared/, and the reference covers the whole repository — the frontend had no module docs, JSDoc on some components and hooks and not on others, props typed inline in half the components and in private interfaces in the rest, // comments on declarations, and no return types on 147 exported functions, so deno doc --lint over src/ found 637 errors. The rule api/ follows is applied to every non-test module of src/: a /** … @module */ block, JSDoc on every export and every field of an exported interface, /** */ for declarations and // for notes, verbs for functions ("Renders…" for a component), parameters named in the prose, {@link} only for symbols in scope, and a page's JSDoc names its route and who may see it (App.tsx's module doc lists the route table). For deno doc to show them, every exported function states its return type: components React.JSX.Element (or | null, or a portal), memo and forwardRef components their exotic types, contexts React.Context<…>, and a hook returning several things a documented Use<Name>Result interface (useAuth, useRequiredAuth, useFading, useEmojiTrigger, useMentionAutocomplete, useDumpCardLink, usePlayRichContent; FeedCacheResult, YouTubePlaylistControls and UseApiFormReturn are renamed to match). Every component that takes props takes an exported, documented <Name>Props interface — the 64 inline prop types are lifted into one, DumpFeed gets DumpFeedProps extending MainFeedProps. React types go through the global React namespace: the four default imports of react are gone, since deno doc can't follow them. Two duplicates go too: useMentionAutocomplete's UserResult was shared/'s UserSuggestion, and JournalCard re-exported JournalShape for no one; i18n.ts no longer re-exports Lingui's i18n, which its three users now import from @lingui/core. deno task doc and doc:lint now take src/ (but its tests and the generated locales), the index page gains a Frontend section linking every module by layer and domain, mod.test.ts walks src/ as well, and the docs workflow runs on changes to src/. deno doc doesn't document npm packages, so it takes a type imported from one — react-hook-form's FieldValues in the form fields, frimousse's SkinTone — for a private one; doc:lint now goes through doc-lint.ts, which fails on every diagnostic but those, 25 of them. The README states the frontend's part of the rule. No behaviour changes; 205 backend and 628 frontend tests pass, and tsc, deno check and deno lint are clean.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 50s
Build and Deploy Static Documentation Site / build-and-push (push) Successful in 22s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-09-28 07:23:03 +00:00
parent b85a71d86e
commit 563daa720c
171 changed files with 3921 additions and 851 deletions

View File

@@ -1,3 +1,9 @@
/**
* The feed tab the index opens on, as the user chose it.
*
* @module
*/
import { useCallback, useState } from "react";
import {
DEFAULT_FEED_TAB,
@@ -5,8 +11,13 @@ import {
type FeedTab,
} from "../../config/feedTabs.ts";
/** The localStorage key of the chosen tab. */
const STORAGE_KEY = "default-feed-tab";
/**
* Returns the stored tab, or {@link DEFAULT_FEED_TAB} when none valid is
* stored.
*/
function readStored(): FeedTab {
const stored = localStorage.getItem(STORAGE_KEY);
return stored && (FEED_TABS as readonly string[]).includes(stored)
@@ -15,10 +26,10 @@ function readStored(): FeedTab {
}
/**
* User preference for which feed tab the index opens on, persisted to
* localStorage (defaults to DEFAULT_FEED_TAB). Returns the stored tab and a setter that
* persists the choice. Consumers that only need to honour the preference (e.g.
* the index default) can ignore the setter.
* Returns the user's preference for which feed tab the index opens on,
* persisted to localStorage (defaults to {@link DEFAULT_FEED_TAB}), and a
* setter that persists the choice. Consumers that only need to honour the
* preference (e.g. the index default) can ignore the setter.
*/
export function useDefaultFeedTab(): [FeedTab, (tab: FeedTab) => void] {
const [tab, setTabState] = useState<FeedTab>(readStored);

View File

@@ -1,18 +1,35 @@
/**
* The dismissal animation of items leaving a list: a grace period, then a
* collapse.
*
* @module
*/
import { useCallback, useEffect, useRef, useState } from "react";
/**
* Manages a 2-second cooldown + 350ms dismissal animation for a set of items.
*
* Call `startFading(id)` when an item should begin fading out.
* Call `cancelFading(id)` to abort the animation (e.g. the action was reversed).
* Use the returned `fading` record to apply CSS classes:
* - `"cooldown"` → item is in the 2-second grace period (darkened)
* - `"dismissing"` → item is collapsing (opacity 0 + grid-row collapse)
* Where an item is in its dismissal: `cooldown` is the 2-second grace period
* (darkened), `dismissing` the collapse (opacity 0 + grid-row collapse).
*/
export function useFading() {
const [fading, setFading] = useState<
Record<string, "cooldown" | "dismissing">
>({});
export type FadeState = "cooldown" | "dismissing";
/** What {@link useFading} returns. */
export interface UseFadingResult {
/** The items being dismissed, by id, and where each is; for CSS classes. */
fading: Record<string, FadeState>;
/** Starts dismissing item `id`. */
startFading: (id: string) => void;
/** Stops dismissing item `id` (e.g. the action was reversed). */
cancelFading: (id: string) => void;
/** Cancels all in-progress animations (e.g. on page navigation). */
cancelAll: () => void;
}
/**
* Returns a 2-second cooldown + 350ms dismissal animation for a set of items.
*/
export function useFading(): UseFadingResult {
const [fading, setFading] = useState<Record<string, FadeState>>({});
const cancels = useRef<Map<string, () => void>>(new Map());
useEffect(() => () => {
@@ -69,7 +86,6 @@ export function useFading() {
cancels.current.get(id)?.();
}, []);
/** Cancel all in-progress animations (e.g. on page navigation). */
const cancelAll = useCallback(() => {
cancels.current.forEach((c) => c());
}, []);

View File

@@ -1,18 +1,37 @@
/**
* A feed's pages and scroll position, kept in sessionStorage so going back to
* it restores them.
*
* @module
*/
import { useCallback, useState } from "react";
import { useNavigationType } from "react-router";
/** How long a saved feed stays good: 10 minutes. */
const TTL = 10 * 60 * 1000; // 10 minutes
interface FeedCacheEntry<T> {
/** A saved feed. */
export interface FeedCacheEntry<T> {
/** Its items, every page loaded so far. */
items: T[];
/** The last page loaded. */
page: number;
/** Whether another page follows. */
hasMore: boolean;
/** The scroll position. */
scrollY: number;
/** When it was saved, in epoch ms. */
savedAt: number;
}
export interface FeedCacheResult<T> {
/** What {@link useFeedCache} returns. */
export interface UseFeedCacheResult<T> {
/**
* The saved feed, when the page was reached by going back and one is fresh.
*/
cached: Omit<FeedCacheEntry<T>, "savedAt"> | null;
/** Saves the feed. */
saveState: (
items: T[],
page: number,
@@ -21,10 +40,15 @@ export interface FeedCacheResult<T> {
) => void;
}
/**
* Returns the feed saved under `key`, read once on mount and only on a back or
* forward navigation, its items passed through `hydrateItem`; and a way to save
* it. A full or blocked storage is ignored.
*/
export function useFeedCache<T>(
key: string,
hydrateItem: (raw: T) => T,
): FeedCacheResult<T> {
): UseFeedCacheResult<T> {
const navType = useNavigationType();
// useState initializer runs exactly once on mount — correct for a cache read.

View File

@@ -1,25 +1,35 @@
/**
* Loading a feed's next page as its end scrolls into view.
*
* @module
*/
import { useCallback, useEffect, useRef, useState } from "react";
/** Longest wait between two loads while the sentinel never left the view. */
const MAX_RETRY_DELAY_MS = 30_000;
/**
* Observe a sentinel element and call `onLoadMore` when it scrolls into view.
* Returns the ref of a sentinel element, and calls `onLoadMore` when it scrolls
* into view while `enabled`.
*
* The sentinel is tracked via a state-backed callback ref rather than a plain
* `useRef` so the observer re-attaches whenever the underlying DOM node
* changes — e.g. when the index page swaps the active feed tab and a fresh
* sentinel `<div>` mounts in place of the old one. A static ref would keep
* observing the now-detached node and silently stop loading more.
* `useRef` so the observer re-attaches whenever the underlying DOM node changes
* — e.g. when the index page swaps the active feed tab and a fresh sentinel
* `<div>` mounts in place of the old one. A static ref would keep observing the
* now-detached node and silently stop loading more.
*
* Each change of `enabled` makes a new observer, which reports the sentinel
* at once if it's still in view. After a load that added items that's what
* fills a tall screen; after one that failed it would ask again straight
* away, and again, as fast as the request can fail. So a sentinel that stays
* put in view waits longer each time — 1s, 2s, 4s… up to 30s — and goes back
* to asking at once when it leaves the view or is pushed down by new items.
* Each change of `enabled` makes a new observer, which reports the sentinel at
* once if it's still in view. After a load that added items that's what fills a
* tall screen; after one that failed it would ask again straight away, and
* again, as fast as the request can fail. So a sentinel that stays put in view
* waits longer each time — 1s, 2s, 4s… up to 30s — and goes back to asking at
* once when it leaves the view or is pushed down by new items.
*/
export function useInfiniteScroll(onLoadMore: () => void, enabled: boolean) {
export function useInfiniteScroll(
onLoadMore: () => void,
enabled: boolean,
): (node: HTMLDivElement | null) => void {
const [sentinel, setSentinel] = useState<HTMLDivElement | null>(null);
// Loads asked for since the sentinel last left the view or moved.
const attemptsRef = useRef(0);

View File

@@ -1,20 +1,25 @@
/**
* Putting items that left a list back where they were.
*
* @module
*/
import { useEffect, useLayoutEffect, useRef } from "react";
/**
* Keeps track of item positions as items leave a list and re-inserts them
* at their original positions when they become visible again.
* Keeps track of item positions as items leave a list and re-inserts them at
* their original positions when they become visible again.
*
* `items` is the current list (pass [] when state is not loaded), `setItems`
* its setter (the same one passed to the sync hook), and `lastEvent` the latest
* changed item. `isRemoving` returns true when the item is about to leave the
* list (e.g. it just became private), so its index is saved; `shouldReinsert`
* returns true when it should come back at its original position (e.g. it just
* became public again).
*
* MUST be called BEFORE the corresponding sync hook (useDumpListSync /
* usePlaylistListSync) so the save-position effect fires before the hook's
* removal effect in the same render cycle.
*
* @param items The current list (pass [] when state is not loaded).
* @param setItems The list setter (same one passed to the sync hook).
* @param lastEvent The latest changed item (Dump | Playlist | null).
* @param isRemoving Return true when the item is about to leave the list
* (e.g. it just became private). Used to save its index.
* @param shouldReinsert Return true when the item should come back at its
* original position (e.g. it just became public again).
*/
export function usePositionAwareSync<T extends { id: string }>(
items: T[],

View File

@@ -1,3 +1,9 @@
/**
* Saving a feed as the page is left.
*
* @module
*/
import { useEffect, useLayoutEffect, useRef } from "react";
/**
@@ -11,7 +17,7 @@ import { useEffect, useLayoutEffect, useRef } from "react";
export function useScrollSave(
enabled: boolean,
onSave: (scrollY: number) => void,
) {
): void {
const onSaveRef = useRef(onSave);
useLayoutEffect(() => {
onSaveRef.current = onSave;