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.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
}, []);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user