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,5 @@
|
||||
/**
|
||||
* The feed tab the index opens on, as the user chose it.
|
||||
* Stores, in this browser, which feed tab the index opens on.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -11,13 +11,9 @@ 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.
|
||||
*/
|
||||
/** Returns {@link DEFAULT_FEED_TAB} when the stored value isn't a tab. */
|
||||
function readStored(): FeedTab {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
return stored && (FEED_TABS as readonly string[]).includes(stored)
|
||||
@@ -26,10 +22,11 @@ function readStored(): FeedTab {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Returns the feed tab the index opens on, and a setter that stores a new
|
||||
* choice. The profile's settings set it. The index's redirect, its tab bar and
|
||||
* `Index` read it, and each falls back to {@link DEFAULT_FEED_TAB} when it is
|
||||
* "followed" and nobody is signed in. Each call reads the stored tab once, so
|
||||
* a change shows in other components only once they remount.
|
||||
*/
|
||||
export function useDefaultFeedTab(): [FeedTab, (tab: FeedTab) => void] {
|
||||
const [tab, setTabState] = useState<FeedTab>(readStored);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* The dismissal animation of items leaving a list: a grace period, then a
|
||||
* collapse.
|
||||
* Times the fade-out of dumps leaving an upvoted list, so that a vote withdrawn
|
||||
* by mistake can be cast again before the dump is gone.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -8,25 +8,29 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
/**
|
||||
* Where an item is in its dismissal: `cooldown` is the 2-second grace period
|
||||
* (darkened), `dismissing` the collapse (opacity 0 + grid-row collapse).
|
||||
* The phase of a fading item, which the list maps to a CSS class. `cooldown`
|
||||
* lasts 2 seconds, and `dismissing` lasts the 350ms of the CSS collapse.
|
||||
*/
|
||||
export type FadeState = "cooldown" | "dismissing";
|
||||
|
||||
/** What {@link useFading} returns. */
|
||||
/**
|
||||
* Returned by {@link useFading} to `UserUpvoted` and the profile's upvoted
|
||||
* section. The list keeps showing an item while it is in `fading`, then drops
|
||||
* it.
|
||||
*/
|
||||
export interface UseFadingResult {
|
||||
/** The items being dismissed, by id, and where each is; for CSS classes. */
|
||||
fading: Record<string, FadeState>;
|
||||
/** Starts dismissing item `id`. */
|
||||
/** Does nothing when `id` is already fading. */
|
||||
startFading: (id: string) => void;
|
||||
/** Stops dismissing item `id` (e.g. the action was reversed). */
|
||||
/** Called when the vote is cast again: the item stays. */
|
||||
cancelFading: (id: string) => void;
|
||||
/** Cancels all in-progress animations (e.g. on page navigation). */
|
||||
/** Called when the list changes to another user's. */
|
||||
cancelAll: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a 2-second cooldown + 350ms dismissal animation for a set of items.
|
||||
* Returns the fading state of a list's items and the calls that drive it. The
|
||||
* timers are cleared on unmount.
|
||||
*/
|
||||
export function useFading(): UseFadingResult {
|
||||
const [fading, setFading] = useState<Record<string, FadeState>>({});
|
||||
@@ -38,6 +42,8 @@ export function useFading(): UseFadingResult {
|
||||
|
||||
const startFading = useCallback((id: string) => {
|
||||
if (cancels.current.has(id)) return;
|
||||
// `kill` is replaced as each timer is set, so it always clears the one
|
||||
// running; `dead` stops a timer that fired before it was cleared.
|
||||
let dead = false;
|
||||
let kill = () => {};
|
||||
kill = () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* A feed's pages and scroll position, kept in sessionStorage so going back to
|
||||
* it restores them.
|
||||
* Keeps a feed's loaded pages and scroll position in sessionStorage, so that
|
||||
* going back to a feed page shows it as it was left instead of refetching it.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -8,30 +8,32 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useNavigationType } from "react-router";
|
||||
|
||||
/** How long a saved feed stays good: 10 minutes. */
|
||||
/** Past this age, a saved feed is refetched rather than restored. */
|
||||
const TTL = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
/** A saved feed. */
|
||||
/**
|
||||
* Stored as JSON by {@link useFeedCache}'s `saveState`, and read back by the
|
||||
* hook when the page is next reached by going back.
|
||||
*/
|
||||
export interface FeedCacheEntry<T> {
|
||||
/** Its items, every page loaded so far. */
|
||||
/** Every page loaded so far. JSON turns their dates into strings. */
|
||||
items: T[];
|
||||
/** The last page loaded. */
|
||||
/** The last page loaded, from which loading more carries on. */
|
||||
page: number;
|
||||
/** Whether another page follows. */
|
||||
hasMore: boolean;
|
||||
/** The scroll position. */
|
||||
scrollY: number;
|
||||
/** When it was saved, in epoch ms. */
|
||||
/** In epoch milliseconds. */
|
||||
savedAt: number;
|
||||
}
|
||||
|
||||
/** What {@link useFeedCache} returns. */
|
||||
/** Returned by {@link useFeedCache} to a feed page. */
|
||||
export interface UseFeedCacheResult<T> {
|
||||
/**
|
||||
* The saved feed, when the page was reached by going back and one is fresh.
|
||||
* The saved feed, when the page was reached by going back or forward and the
|
||||
* feed was saved less than 10 minutes ago. It doesn't change after mount.
|
||||
*/
|
||||
cached: Omit<FeedCacheEntry<T>, "savedAt"> | null;
|
||||
/** Saves the feed. */
|
||||
/** Usually called through `useScrollSave`, as the page is left. */
|
||||
saveState: (
|
||||
items: T[],
|
||||
page: number,
|
||||
@@ -41,9 +43,11 @@ export interface UseFeedCacheResult<T> {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Returns the feed saved under `key` and a way to save it. The feed pages and
|
||||
* `useUserDumpFeed` use it. `key` must tell apart every list whose items
|
||||
* differ, the viewer included when they see private items. `hydrateItem`
|
||||
* restores what JSON lost, such as dates. A full or blocked storage is
|
||||
* ignored.
|
||||
*/
|
||||
export function useFeedCache<T>(
|
||||
key: string,
|
||||
@@ -51,7 +55,7 @@ export function useFeedCache<T>(
|
||||
): UseFeedCacheResult<T> {
|
||||
const navType = useNavigationType();
|
||||
|
||||
// useState initializer runs exactly once on mount — correct for a cache read.
|
||||
// Read once: a new navigation to the page must not pick up what it saves.
|
||||
const [cached] = useState<Omit<FeedCacheEntry<T>, "savedAt"> | null>(() => {
|
||||
if (navType !== "POP") return null;
|
||||
try {
|
||||
@@ -79,7 +83,7 @@ export function useFeedCache<T>(
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
// QuotaExceededError or SecurityError — degrade silently
|
||||
// Full or blocked storage: the page just refetches next time.
|
||||
}
|
||||
},
|
||||
[key],
|
||||
|
||||
@@ -1,37 +1,34 @@
|
||||
/**
|
||||
* Loading a feed's next page as its end scrolls into view.
|
||||
* Loads a feed's next page when the end of the feed scrolls into view.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
/** Longest wait between two loads while the sentinel never left the view. */
|
||||
/** The longest wait between two loads while the sentinel stays in view. */
|
||||
const MAX_RETRY_DELAY_MS = 30_000;
|
||||
|
||||
/**
|
||||
* Returns the ref of a sentinel element, and calls `onLoadMore` when it scrolls
|
||||
* into view while `enabled`.
|
||||
* Returns a callback ref for the sentinel element placed after a feed's last
|
||||
* item, and calls `onLoadMore` when the sentinel comes within 200px of the
|
||||
* view while `enabled`. Feed pages pass `enabled` as "loaded, more to come and
|
||||
* not already loading".
|
||||
*
|
||||
* 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.
|
||||
* The ref is a callback so that the observer follows a new sentinel, as when
|
||||
* `Index` switches tabs and another feed mounts its own.
|
||||
*
|
||||
* 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.
|
||||
* A sentinel still in view after a load asks again, which fills a tall screen,
|
||||
* but would also retry a failing request as fast as it fails. So while it
|
||||
* stays in view the waits double, from 1s up to 30s, and they start over once
|
||||
* it leaves the view or new items push it down.
|
||||
*/
|
||||
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.
|
||||
// Loads asked for since the sentinel last left the view or moved down.
|
||||
const attemptsRef = useRef(0);
|
||||
const lastLoadAtRef = useRef(0);
|
||||
const lastTopRef = useRef<number | null>(null);
|
||||
@@ -58,8 +55,8 @@ export function useInfiniteScroll(
|
||||
lastLoadAtRef.current = Date.now();
|
||||
onLoadMore();
|
||||
};
|
||||
// Counted from the last load, not from now: the observer is remade on
|
||||
// every render that changes onLoadMore, which mustn't restart the wait.
|
||||
// Counted from the last load, not from now: the observer is remade
|
||||
// whenever `onLoadMore` changes, which mustn't restart the wait.
|
||||
const wait = attempts === 0 ? 0 : lastLoadAtRef.current +
|
||||
Math.min(1000 * 2 ** (attempts - 1), MAX_RETRY_DELAY_MS) - Date.now();
|
||||
if (wait <= 0) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* Putting items that left a list back where they were.
|
||||
* Puts an item that left a profile's list, by turning private, back at its
|
||||
* old position when it turns public again.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -7,19 +8,17 @@
|
||||
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.
|
||||
* Remembers the index of an item that `lastEvent` removes from `items`, and
|
||||
* puts the item back there when a later event makes it visible again. The
|
||||
* profile pages use it with `WSContext`'s latest dump or playlist edit.
|
||||
* `items` is the loaded list, or `[]` before it loads, and `setItems` the
|
||||
* setter also passed to `useDumpListSync` or `usePlaylistListSync`.
|
||||
* `isRemoving` tells an event that takes the item out, and `shouldReinsert`
|
||||
* one that brings it back.
|
||||
*
|
||||
* `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.
|
||||
* It must be called before the sync hook, so that its effects run first: it
|
||||
* saves the index before the sync hook removes the item. The sync hook must
|
||||
* get `skipReinsert`, or it would add the item a second time.
|
||||
*/
|
||||
export function usePositionAwareSync<T extends { id: string }>(
|
||||
items: T[],
|
||||
@@ -41,7 +40,6 @@ export function usePositionAwareSync<T extends { id: string }>(
|
||||
|
||||
const removedPositionsRef = useRef<Map<string, number>>(new Map());
|
||||
|
||||
// Save position BEFORE the sync hook removes the item.
|
||||
useEffect(() => {
|
||||
if (!lastEvent || !isRemovingRef.current(lastEvent)) return;
|
||||
const idx = itemsRef.current.findIndex((item) => item.id === lastEvent.id);
|
||||
@@ -50,8 +48,6 @@ export function usePositionAwareSync<T extends { id: string }>(
|
||||
}
|
||||
}, [lastEvent]);
|
||||
|
||||
// Re-insert at the saved position. Fires before the sync hook's effect for
|
||||
// the same event; skipReinsert on the sync hook prevents double-insertion.
|
||||
useEffect(() => {
|
||||
if (!lastEvent || !shouldReinsertRef.current(lastEvent)) return;
|
||||
const savedIdx = removedPositionsRef.current.get(lastEvent.id);
|
||||
@@ -61,6 +57,7 @@ export function usePositionAwareSync<T extends { id: string }>(
|
||||
setItemsRef.current((prev) => {
|
||||
if (prev.some((i) => i.id === item.id)) return prev;
|
||||
const next = [...prev];
|
||||
// The list may have shrunk since.
|
||||
next.splice(Math.min(savedIdx, next.length), 0, item);
|
||||
return next;
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Saving a feed as the page is left.
|
||||
* Saves a feed page's state, with its scroll position, as the page is left.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -7,17 +7,18 @@
|
||||
import { useEffect, useLayoutEffect, useRef } from "react";
|
||||
|
||||
/**
|
||||
* Calls `onSave(scrollY)` when the page is left — the component unmounts (a
|
||||
* navigation within the app) or the tab goes away (`pagehide`) — as long as
|
||||
* `enabled` is true (e.g. the page is loaded).
|
||||
* Calls `onSave(scrollY)` when the component unmounts or the tab is hidden
|
||||
* (`pagehide`), while `enabled`. Feed pages pass `enabled` once their feed is
|
||||
* loaded, and an `onSave` that calls `useFeedCache`'s `saveState`.
|
||||
*
|
||||
* Scrolling itself only notes the position: saving serializes the whole feed
|
||||
* into sessionStorage, which on every pause in scrolling cost a long task.
|
||||
* It doesn't save on scroll, since serializing the whole feed there took a long
|
||||
* task at every pause.
|
||||
*/
|
||||
export function useScrollSave(
|
||||
enabled: boolean,
|
||||
onSave: (scrollY: number) => void,
|
||||
): void {
|
||||
// A ref, so a new `onSave` on every render doesn't re-attach the listeners.
|
||||
const onSaveRef = useRef(onSave);
|
||||
useLayoutEffect(() => {
|
||||
onSaveRef.current = onSave;
|
||||
@@ -25,8 +26,8 @@ export function useScrollSave(
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
// Tracked as it happens: by the time the unmount runs, the next page may
|
||||
// already have scrolled the window back to the top.
|
||||
// Tracked on scroll: by the time the unmount runs, the next page may have
|
||||
// scrolled the window back to the top.
|
||||
let y = globalThis.scrollY;
|
||||
const onScroll = () => {
|
||||
y = globalThis.scrollY;
|
||||
|
||||
Reference in New Issue
Block a user