v3: every doc opens with the role, then the mechanism, then the details — the docs were written bottom-up: what a thing does first ("Declares the three contexts WSProvider fills…"), details that only make sense once its purpose is known next, and the reason it exists last ("…so that a feed doesn't re-render"), so a reader had nothing to hang the first sentences on. The README's rule caused it, since a module doc "opened with what the module does"; it now opens with the module's role in the app, and the value-side phrasing of a type ("Returned to Y by X") comes once the role is said, with WSContext as the before and after. WSContext.ts is rewritten that way throughout, and so are the docs elsewhere that buried their purpose in a trailing "so that" (the migrations, og, image-proxy, public-address, useFading, useFeedCache, the drafts), opened on a generic verb that hid it ("Holds", "Declares", "Loads", "Defines", "Types": the contexts and providers, the shared types, the models, the hooks), or were label fragments ("The page of a dump…", "A row of tabs."). A function whose verb is its action ("Stores session"), a props or result type, and a protocol message ("Sent by X when Y") already say their role and are left as they are. Only comments changed, and the catalogs only move their references; deno fmt, deno lint and doc:lint are clean.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 49s
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-29 21:42:08 +00:00
parent 2af64721c7
commit f3f07ed1e3
105 changed files with 459 additions and 371 deletions

View File

@@ -1,6 +1,6 @@
/**
* Declares the session's React context. `AuthProvider` fills it and `useAuth`
* reads it; nothing else should touch it.
* Shares the session with every component. `AuthProvider` fills this context
* and `useAuth` reads it; nothing else should touch it.
*
* @module
*/

View File

@@ -1,5 +1,5 @@
/**
* Holds the session for the whole app and keeps it in step with the other tabs
* Keeps the user signed in across the whole app, in step with the other tabs
* and with the server. `App` mounts it near the root.
*
* @module

View File

@@ -1,6 +1,6 @@
/**
* Declares the categories' React context. `CategoriesProvider` fills it and
* `useCategories` reads it.
* Shares the site's categories with every component. `CategoriesProvider` fills
* this context and `useCategories` reads it.
*
* @module
*/

View File

@@ -1,6 +1,6 @@
/**
* Loads the site's categories once for the whole app, and again when the
* category manager changes them. `App` mounts it.
* Spares every page fetching the site's categories: it loads them once for the
* whole app, and again when the category manager changes them. `App` mounts it.
*
* @module
*/

View File

@@ -1,7 +1,6 @@
/**
* Declares the context through which the header's chat button and the floating
* chat button open the one chat modal, which `ChatProvider` owns. Read it with
* `useChat`.
* Lets the header's chat button and the floating one open the same chat modal,
* which `ChatProvider` owns. Read it with `useChat`.
*
* @module
*/

View File

@@ -1,6 +1,7 @@
/**
* Provides `ChatContext` and renders the chat modal while it is open. `App`
* mounts it inside the router, around every page.
* Owns the one chat modal: it renders it while it is open, and provides
* `ChatContext` to the buttons that open it. `App` mounts it inside the router,
* around every page.
*
* @module
*/

View File

@@ -1,8 +1,9 @@
/**
* Defines the player's two contexts, one for the queue and the controls and one
* for the playhead alone. PlayerProvider provides both around the whole app;
* GlobalPlayer draws the player, and the cards and previews that start playback
* read and drive it.
* Shares the player docked at the bottom of every page with the whole app. The
* cards and previews that start playback read and drive it, and GlobalPlayer
* draws it. It is two contexts, one for the queue and the controls and one for
* the playhead alone, which changes several times a second. PlayerProvider
* provides both around the whole app.
*
* @module
*/

View File

@@ -1,7 +1,8 @@
/**
* Holds the player's queue, playback state and controls, saves them across
* reloads and keeps streams' signed URLs fresh. App mounts it around the whole
* app, and GlobalPlayer draws what it holds.
* Keeps playback going from page to page and across reloads. It holds the
* player's queue, playback state and controls, saves them as they change and
* keeps streams' signed URLs fresh. App mounts it around the whole app, and
* GlobalPlayer draws what it holds.
*
* @module
*/

View File

@@ -1,7 +1,6 @@
/**
* Declares the context that holds the users and playlists the signed-in user
* follows, for the Follow buttons. `FollowProvider` provides it and
* `useFollows` reads it.
* Tells the Follow buttons what the signed-in user follows. `FollowProvider`
* provides this context and `useFollows` reads it.
*
* @module
*/
@@ -42,7 +41,10 @@ export const defaultFollowValue: FollowContextValue = {
isLoaded: false,
};
/** Holds the signed-in user's follows. Read it with `useFollows`. */
/**
* Tells the Follow buttons what the signed-in user follows. Read it with
* `useFollows`.
*/
export const FollowContext: React.Context<FollowContextValue> = createContext<
FollowContextValue
>(

View File

@@ -1,6 +1,7 @@
/**
* Provides `FollowContext`, from `GET /api/follows/status`. `App` mounts it
* around every page.
* Keeps what the signed-in user follows for the Follow buttons, loaded from
* `GET /api/follows/status` and again when another tab changes it. `App` mounts
* it around every page.
*
* @module
*/

View File

@@ -1,6 +1,6 @@
/**
* Declares the theme's React context. `ThemeProvider` fills it and `useTheme`
* reads it.
* Shares the chosen theme with every component. `ThemeProvider` fills this
* context and `useTheme` reads it.
*
* @module
*/

View File

@@ -1,6 +1,7 @@
/**
* Holds the chosen style and color scheme, stores them in localStorage and
* applies them to the document. `App` mounts it outermost.
* Lets each browser pick the site's style and color scheme. It holds the
* choice, stores it in localStorage and applies it to the document. `App`
* mounts it outermost.
*
* @module
*/

View File

@@ -1,6 +1,7 @@
/**
* Declares the context through which `Avatar` learns of avatars changed since
* the page loaded. `WSProvider` fills it from `user_updated` messages.
* Shows a user's new avatar everywhere as soon as they change it, without a
* reload. `WSProvider` fills this context from `user_updated` messages, and
* `Avatar` reads it.
*
* @module
*/
@@ -21,8 +22,9 @@ export interface AvatarOverride {
}
/**
* Holds an {@link AvatarOverride} by user id. It is apart from `WSContext`, so
* the many avatars of a page don't re-render on every vote or comment.
* Shows changed avatars without a reload, as an {@link AvatarOverride} by user
* id. It is apart from `WSContext`, so the many avatars of a page don't
* re-render on every vote or comment.
*/
export const AvatarOverrideContext: React.Context<
Record<string, AvatarOverride>

View File

@@ -1,7 +1,10 @@
/**
* Declares the three contexts `WSProvider` fills from the WebSocket, and the
* types they carry. They are split by how often each part changes, so that a
* feed doesn't re-render with every chat message or presence change.
* Live updates from the server reach the pages through the three contexts
* declared here: votes and likes, dumps, playlists, comments, profiles and
* notifications in one, the chat in another, who is online in the third. They
* are split so that a feed doesn't re-render with every chat message or
* presence change. `WSProvider` fills them from its WebSocket, and components
* read them with `useWS`, `useWSChat` and `useWSPresence`.
*
* @module
*/
@@ -16,8 +19,9 @@ import type { Playlist } from "../../../shared/playlist.ts";
import type { PublicUser } from "../../../shared/user.ts";
/**
* Set as `lastVoteEvent` by `WSProvider` on a `votes_update`. The profile
* pages read it to add or drop a dump from a user's upvoted list.
* Keeps a user's upvoted list live, on their profile and on `UserUpvoted`.
* `WSProvider` sets it as `lastVoteEvent` on each `votes_update`, and
* `useUpvotedList` adds or drops the dump.
*/
export interface VoteEvent {
dumpId: string;
@@ -26,9 +30,11 @@ export interface VoteEvent {
}
/**
* Set as `lastPlaylistEvent` by `WSProvider` on each `playlist_*` message.
* The pages that show playlists apply it, some through `usePlaylistListSync`.
* Which fields are set depends on `type`.
* Keeps the playlists on screen up to date, in lists, on a playlist's page and
* on a dump's page. `WSProvider` sets it as `lastPlaylistEvent` on each
* `playlist_*` message; lists apply it through `usePlaylistListSync`, a
* playlist's page through `usePlaylist`. Which fields are set depends on
* `type`.
*/
export interface PlaylistEvent {
type: "created" | "updated" | "deleted" | "dumps_updated";
@@ -47,9 +53,9 @@ export interface PlaylistEvent {
}
/**
* Set as `lastCommentEvent` by `WSProvider` on each `comment_*` message. The
* `Dump` page applies it to its thread, and `useDumpListSync` to its comment
* counts.
* Keeps comment threads and comment counts live. `WSProvider` sets it as
* `lastCommentEvent` on each `comment_*` message; the `Dump` page applies it
* to its thread, and `useDumpListSync` to the counts on dump cards.
*/
export interface CommentEvent {
type: "created" | "deleted" | "updated";
@@ -61,16 +67,18 @@ export interface CommentEvent {
}
/**
* Set as `lastUserEvent` by `WSProvider` on a `user_updated`, which the
* server sends on any change to a profile. `useProfile` applies it.
* Keeps a profile page live while its user edits their profile. `WSProvider`
* sets it as `lastUserEvent` on each `user_updated`, which the server sends on
* any change to a profile, and `useProfile` applies it.
*/
export interface UserEvent {
user: PublicUser;
}
/**
* Chosen in `ChatModal` for the reply bar, and kept on the pending message so
* it shows the reply before the server's echo. Only `id` goes to the server.
* Quotes the message a chat message replies to. `ChatModal` shows it in the
* reply bar once the user picks it, and it stays on the pending message so
* the quote shows before the server's echo. Only `id` goes to the server.
*/
export interface ChatReplyTarget {
id: string;
@@ -79,9 +87,9 @@ export interface ChatReplyTarget {
}
/**
* Held by `WSProvider` for a chat message this client sent and the server
* hasn't echoed back yet. `ChatModal` shows it, as `PendingItem`, after the
* messages the server has.
* Shows a chat message the moment the user sends it, and lets them retry it if
* it fails. `WSProvider` holds it until the server echoes the message back, and
* `ChatModal` shows it, as `PendingItem`, after the messages the server has.
*/
export interface PendingChatMessage {
/**
@@ -102,8 +110,8 @@ export interface PendingChatMessage {
}
/**
* Set as `chatError` by `WSProvider` when the server refuses a chat edit or
* delete. `ChatModal` shows it for a few seconds.
* Tells the user the server refused their chat edit or delete. `WSProvider`
* sets it as `chatError`, and `ChatModal` shows it for a few seconds.
*/
export interface ChatError {
/** Tells apart two errors with the same message. */
@@ -112,60 +120,75 @@ export interface ChatError {
}
/**
* Provided by `WSProvider` through {@link WSContext}, read with `useWS`. The
* `last*Event` fields hold only the latest event of their kind: a consumer
* reacts in an effect when one changes, and an event that another replaces
* within the same render is missed. After a reconnect, `connectionEpoch` tells
* pages to fetch what they missed.
* Carries everything live but the chat and presence, for pages to lay over
* what they fetched. `WSProvider` provides it through {@link WSContext}, and
* components read it with `useWS`. Each `last*Event` field holds only the
* latest event of its kind: a consumer reacts in an effect when one changes,
* and an event that another replaces within the same render is missed. After a
* reconnect, `connectionEpoch` tells pages to fetch what they missed.
*/
export interface WSContextValue {
wsStatus: "connecting" | "connected" | "disconnected";
/** Set while the socket is down, for the banner that says so. */
wsErrorMessage: string | null;
/**
* Counts known to the socket, by dump id: pushed by the server, or changed
* optimistically. A missing dump shows its fetched `voteCount`.
* Shows vote counts live. It holds, by dump id, the counts the server pushed
* or this tab changed optimistically; a dump missing from it shows its
* fetched `voteCount`.
*/
voteCounts: Record<string, number>;
/** Reset from the server's `welcome` on every connection. */
/**
* Marks the dumps this user upvoted, for their vote buttons. The server's
* `welcome` resets it on every connection.
*/
myVotes: Set<string>;
/** As `voteCounts`, for comments. */
likeCounts: Record<string, number>;
/** As `myVotes`, for comments. */
myLikes: Set<string>;
/**
* Dumps that arrived this session, newest first and at most 100: the ones
* the server announced, which are public, and the ones this user posted,
* which may be private. `Index` puts them above the feed it fetched.
* Shows new dumps on the index as they are posted, above the feed `Index`
* fetched. It holds, newest first and at most 100, the ones the server
* announced this session, which are public, and the ones this user posted,
* which may be private.
*/
recentDumps: Dump[];
/**
* It holds the dumps deleted this session, or made private by someone else,
* for the lists to filter out. A dump made public again leaves it.
* Takes deleted dumps off every list at once. It holds the dumps deleted this
* session, or made private by someone else; a dump made public again leaves
* it.
*/
deletedDumpIds: Set<string>;
lastVoteEvent: VoteEvent | null;
/**
* It holds the dump as its latest `dump_created` or `dump_updated` has it,
* for the lists to update it in place, or put it back where it was.
* Keeps a dump up to date in every list that shows it. It holds the dump as
* its latest `dump_created` or `dump_updated` has it, and a list updates it
* in place, or puts it back where it was.
*/
lastDumpEvent: Dump | null;
lastPlaylistEvent: PlaylistEvent | null;
deletedPlaylistIds: Set<string>;
lastCommentEvent: CommentEvent | null;
lastUserEvent: UserEvent | null;
/** Read from the `welcome`, then counted up by each new notification. */
/**
* Drives the bell's badge. It comes from the `welcome`, then each new
* notification counts it up.
*/
unreadNotificationCount: number;
/** Read by the bell, which rings, and by the notifications page. */
/**
* Makes the bell ring, and puts the notification on the notifications page
* as it arrives.
*/
lastNotification: Notification | null;
/**
* Incremented on each reconnect, never on the first connection, so pages
* fetch again what they missed while the socket was down.
* Tells pages to fetch again what they missed while the socket was down. It
* goes up on each reconnect, never on the first connection.
*/
connectionEpoch: number;
/**
* Incremented when the user follows or unfollows something in another tab
* (or this one), so `FollowProvider` fetches the follow status again.
* Keeps the Follow buttons right when the user follows or unfollows in
* another tab. It goes up on every follow change, in any tab, and
* `FollowProvider` fetches the follow status again.
*/
followsVersion: number;
/**
@@ -182,8 +205,9 @@ export interface WSContextValue {
/** Withdraws a like, as {@link WSContextValue.castVote} casts a vote. */
removeCommentLike: (commentId: string, shownCount?: number) => void;
/**
* Adds a dump this user just posted to `recentDumps`, without waiting for
* its broadcast, which a private dump never gets. `DumpCreateModal` calls it.
* Shows a dump this user just posted on the index without waiting for its
* broadcast, which a private dump never gets. `DumpCreateModal` calls it,
* and it adds the dump to `recentDumps`.
*/
injectDump: (dump: Dump) => void;
/**
@@ -194,36 +218,43 @@ export interface WSContextValue {
}
/**
* Provided by `WSProvider` through {@link WSChatContext}, read with
* `useWSChat`. The socket sends chat messages only to signed-in users, so for
* a visitor it stays empty.
* Carries the chat's live side, which `ChatModal` lays over the history it
* fetches and the chat buttons show as a badge. `WSProvider` provides it
* through {@link WSChatContext}, and components read it with `useWSChat`. The
* socket sends chat only to signed-in users, so for a visitor it stays empty.
*/
export interface WSChatContextValue {
/**
* The messages received this session, at most 200, for `ChatModal` to merge
* with the history it fetches. A reconnect empties it while the chat is
* closed, since `ChatModal` fetches everything again on open.
* Shows chat messages as they arrive. It holds the ones received this
* session, at most 200, which `ChatModal` merges with the history it
* fetches. A reconnect empties it while the chat is closed, since `ChatModal`
* fetches everything again on open.
*/
chatMessages: ChatMessage[];
/**
* Messages deleted this session, which `ChatModal` filters out of its
* fetched history.
* Takes the messages deleted this session out of the history `ChatModal`
* fetched.
*/
deletedChatIds: Set<string>;
/**
* Messages from others after the read marker. The server keeps the marker,
* so the count agrees across tabs and devices.
* Drives the chat buttons' unread badge. It counts the messages from others
* after the read marker, which the server keeps, so the count agrees across
* tabs and devices.
*/
unreadChatCount: number;
/**
* The time of the newest message the user has seen, which the server keeps.
* Null until the `welcome`, and for a visitor.
* Places the "new" divider when the chat opens. It holds the time of the
* newest message the user has seen, which the server keeps, and is null
* until the `welcome`, and for a visitor.
*/
chatReadAt: Date | null;
pendingChatMessages: PendingChatMessage[];
/** Only for edits and deletes. A failed send shows on its pending message. */
chatError: ChatError | null;
/** Includes the user when they are typing: `ChatModal` filters them out. */
/**
* Shows who is typing. It includes the user themselves, whom `ChatModal`
* filters out.
*/
typingUsers: OnlineUser[];
/** Trims `body` and sends it as a pending message. Ignores a blank body. */
sendChatMessage: (body: string, replyTo?: ChatReplyTarget) => void;
@@ -244,10 +275,10 @@ export interface WSChatContextValue {
*/
markChatRead: (at: Date) => void;
/**
* Replaces the live buffer's messages between `from` and `to` with
* `messages`, which `ChatModal` fetched after a reconnect. A message missing
* from them was deleted while the socket was down. A failed send found among
* them did reach the server, and leaves the pending list.
* Repairs the live buffer after a reconnect. It replaces its messages between
* `from` and `to` with `messages`, which `ChatModal` fetched. A message
* missing from them was deleted while the socket was down. A failed send
* found among them did reach the server, and leaves the pending list.
*/
reconcileChat: (messages: ChatMessage[], from: Date, to: Date) => void;
/**
@@ -257,23 +288,27 @@ export interface WSChatContextValue {
*/
setChatTyping: (typing: boolean) => void;
/**
* Tells the server whether the user is reading the chat: open, in a visible
* tab. The server sends no notification for a mention the user reads live,
* and this client doesn't count the message unread.
* Spares the user a notification for a mention they read live. It tells the
* server whether the chat is open in a visible tab; while it is, the server
* sends no notification for a mention, and this client doesn't count the
* message unread.
*/
setChatOpen: (open: boolean) => void;
}
/**
* Provided by `WSProvider` through {@link WSPresenceContext}, read with
* `useWSPresence`.
* Carries who is online, for `PresenceRow`. `WSProvider` provides it through
* {@link WSPresenceContext}, and components read it with `useWSPresence`.
*/
export interface WSPresenceContextValue {
/** The signed-in users with a socket open, each once. */
onlineUsers: OnlineUser[];
}
/** Given to a consumer outside `WSProvider`, as if it were still connecting. */
/**
* Lets a component render outside `WSProvider`, as if the socket were
* connecting.
*/
export const defaultWSValue: WSContextValue = {
wsStatus: "connecting",
wsErrorMessage: null,
@@ -301,7 +336,7 @@ export const defaultWSValue: WSContextValue = {
clearUnreadNotifications: () => {},
};
/** Given to a consumer outside `WSProvider`: an empty chat. */
/** Lets a component render outside `WSProvider`, with an empty chat. */
export const defaultWSChatValue: WSChatContextValue = {
chatMessages: [],
deletedChatIds: new Set(),
@@ -322,25 +357,25 @@ export const defaultWSChatValue: WSChatContextValue = {
setChatOpen: () => {},
};
/** Given to a consumer outside `WSProvider`: nobody is online. */
/** Lets a component render outside `WSProvider`, with nobody online. */
export const defaultWSPresenceValue: WSPresenceContextValue = {
onlineUsers: [],
};
/**
* Holds the connection, the votes and likes, and the latest live event of
* each kind. Read it with `useWS`.
* Carries the live state pages lay over what they fetched: the connection, the
* votes and likes, and the latest event of each kind. Read it with `useWS`.
*/
export const WSContext: React.Context<WSContextValue> = createContext<
WSContextValue
>(defaultWSValue);
/** Holds the chat's live stream and read state. Read it with `useWSChat`. */
/** Carries the chat's live stream and read state. Read it with `useWSChat`. */
export const WSChatContext: React.Context<WSChatContextValue> = createContext<
WSChatContextValue
>(
defaultWSChatValue,
);
/** Holds who is online. Read it with `useWSPresence`. */
/** Carries who is online. Read it with `useWSPresence`. */
export const WSPresenceContext: React.Context<WSPresenceContextValue> =
createContext<
WSPresenceContextValue

View File

@@ -1,8 +1,9 @@
/**
* Holds the app's one WebSocket to `/ws` and turns its messages into the state
* of `WSContext`, `WSChatContext`, `WSPresenceContext` and
* `AvatarOverrideContext`. `App` mounts it around every page. The protocol is
* `shared/ws.ts`, and the server's side is `api/routes/ws.ts`.
* Brings the server's live updates into the app. It holds the one WebSocket to
* `/ws` and turns its messages into the state of `WSContext`, `WSChatContext`,
* `WSPresenceContext` and `AvatarOverrideContext`. `App` mounts it around every
* page. The protocol is `shared/ws.ts`, and the server's side is
* `api/routes/ws.ts`.
*
* @module
*/
@@ -94,9 +95,9 @@ const CHAT_READ_THROTTLE = 1_000;
const CHAT_ECHO_WINDOW = 60_000;
/**
* Held by {@link useOptimisticToggles} for an item whose toggles the server
* hasn't all acknowledged yet. `count` and `on` are as before the first of
* them, for a failure to restore.
* Lets a failed toggle restore what was shown before the first click the server
* hasn't acknowledged. {@link useOptimisticToggles} holds one for each such
* item; `count` and `on` are as they were before that click.
*/
interface PendingToggle {
timeout: ReturnType<typeof setTimeout>;