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:
khannurien
2026-09-29 14:31:14 +00:00
parent 42e0356d21
commit 0d4e344d77
287 changed files with 5679 additions and 5135 deletions

View File

@@ -1,5 +1,6 @@
/**
* The avatar override context: avatars changed live, provided by WSProvider.
* Declares the context through which `Avatar` learns of avatars changed since
* the page loaded. `WSProvider` fills it from `user_updated` messages.
*
* @module
*/
@@ -7,22 +8,22 @@
import { createContext } from "react";
/**
* Live avatar state learned from `user_updated` WS events. Kept in its own
* context (not WSContext) so that `Avatar` — rendered many times across feeds
* and comment threads — only re-renders when an avatar actually changes, not on
* every vote/comment/dump event.
* Set by `WSProvider` for a user whose profile changed, and read by `Avatar`,
* where it wins over the possibly stale props the page fetched.
*/
export interface AvatarOverride {
/**
* Whether the user currently has an avatar, so a freshly added or removed
* avatar flips even when the surrounding list data is stale.
*/
hasAvatar: boolean;
/** Cache-busts the avatar URL (derived from the user's `updatedAt`). */
/**
* The user's `updatedAt` in ms, or the arrival time without one. It busts the
* cached image.
*/
version: number;
}
/** The avatar override context, keyed by user id. */
/**
* 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.
*/
export const AvatarOverrideContext: React.Context<
Record<string, AvatarOverride>
> = createContext<

View File

@@ -1,8 +1,7 @@
/**
* The socket contexts, split three ways by how often each part changes, so a
* consumer re-renders only with what it reads: typing and presence update every
* few seconds while people are around, chat with every message, and a feed has
* no use for either. WSProvider provides all three.
* 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.
*
* @module
*/
@@ -16,225 +15,266 @@ import type { OnlineUser } from "../../../shared/ws.ts";
import type { Playlist } from "../../../shared/playlist.ts";
import type { PublicUser } from "../../../shared/user.ts";
/** A vote someone cast or withdrew. */
/**
* 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.
*/
export interface VoteEvent {
/** The dump's id. */
dumpId: string;
/** Who voted. */
voterId: string;
/** Whether the vote was cast or withdrawn. */
action: "cast" | "remove";
}
/** A like someone cast or withdrew. */
/** Set as `lastLikeEvent` by `WSProvider` on a `comment_likes_update`. */
export interface LikeEvent {
/** The comment's id. */
commentId: string;
/** Who liked it. */
likerId: string;
/** Whether the like was cast or withdrawn. */
action: "cast" | "remove";
}
/** A playlist created, changed, deleted, or whose dumps changed. */
/**
* 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`.
*/
export interface PlaylistEvent {
/** What happened. */
type: "created" | "updated" | "deleted" | "dumps_updated";
/** The playlist's id. */
playlistId: string;
/** The playlist as it is now, when created or updated. */
/** Set when created or updated. */
playlist?: Playlist;
/** The owner's id, when deleted. */
/** The owner's id, set when deleted. */
userId?: string;
/** The dumps the user can see in it, in order, when its dumps changed. */
/**
* Set when the dumps changed. It lists only the dumps this user may see, in
* order.
*/
dumpIds?: string[];
/** How many dumps it holds, when its dumps changed. */
/** Set when the dumps changed. It counts private dumps too. */
dumpCount?: number;
}
/** A comment posted, edited or deleted. */
/**
* Set as `lastCommentEvent` by `WSProvider` on each `comment_*` message. The
* `Dump` page applies it to its thread, and `useDumpListSync` to its comment
* counts.
*/
export interface CommentEvent {
/** What happened. */
type: "created" | "deleted" | "updated";
/** The id of the dump it is under. */
dumpId: string;
/** The comment as it is now, when posted or edited. */
/** Set when created or updated. */
comment?: Comment;
/** The comment's id, when deleted. */
/** Set when deleted. */
commentId?: string;
}
/** A user whose public profile changed. */
/**
* Set as `lastUserEvent` by `WSProvider` on a `user_updated`, which the
* server sends on any change to a profile. `UserPublicProfile` applies it.
*/
export interface UserEvent {
/** The user as they are now. */
user: PublicUser;
}
/** The replied-to message, as shown in the reply bar and on a pending send. */
/**
* 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.
*/
export interface ChatReplyTarget {
/** The message's id. */
id: string;
/** Its author's name. */
author: string;
/** Its text. */
body: string;
}
/**
* A message this client sent that the server hasn't echoed back yet — shown in
* the list as "sending", or "failed" with a way to retry or discard it.
* 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.
*/
export interface PendingChatMessage {
/** The id the echo will carry back. */
/**
* Sent with `chat_send` and returned with the echo or the error. A retry
* gets a new one, so a late echo of the first attempt can't retire it.
*/
nonce: string;
/** The message. */
body: string;
/** The message it replies to. */
replyTo?: ChatReplyTarget;
/** When it was sent. */
/** When this client sent it, to find it in the history after a reconnect. */
createdAt: Date;
/** Whether it is on its way or failed. */
/**
* `failed` when the server refused it, the socket was down or closed, or no
* echo came in 10 s. A failed message may still have reached the server.
*/
status: "sending" | "failed";
/** Why it failed. */
error?: string;
}
/** A failed chat edit or delete, surfaced in the chat until dismissed. */
/**
* Set as `chatError` by `WSProvider` when the server refuses a chat edit or
* delete. `ChatModal` shows it for a few seconds.
*/
export interface ChatError {
/** Distinguishes one error from the next. */
/** Tells apart two errors with the same message. */
id: number;
/** What went wrong. */
message: string;
}
/**
* What {@link WSContext} holds: the connection, votes, likes, and the live
* dump, playlist, comment, user and notification events.
* 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.
*/
export interface WSContextValue {
/** The connection's state. */
wsStatus: "connecting" | "connected" | "disconnected";
/** Why the socket is down, while it is. */
/** Set while the socket is down, for the banner that says so. */
wsErrorMessage: string | null;
/** Live vote counts, by dump id. */
/**
* Counts known to the socket, by dump id: pushed by the server, or changed
* optimistically. A missing dump shows its fetched `voteCount`.
*/
voteCounts: Record<string, number>;
/** The ids of the dumps the user upvoted. */
/** Reset from the server's `welcome` on every connection. */
myVotes: Set<string>;
/** Live like counts, by comment id. */
/** As `voteCounts`, for comments. */
likeCounts: Record<string, number>;
/** The ids of the comments the user likes. */
/** As `myVotes`, for comments. */
myLikes: Set<string>;
/** Dumps posted (or made public) this session, newest first (capped). */
/**
* 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.
*/
recentDumps: Dump[];
/** Ids of dumps deleted this session. */
/**
* Dumps deleted or made private this session, for the lists to filter out.
* A dump made public again leaves it.
*/
deletedDumpIds: Set<string>;
/** The latest vote. */
lastVoteEvent: VoteEvent | null;
/** The latest like. */
/** Read by no consumer at the moment. */
lastLikeEvent: LikeEvent | null;
/** The latest edited dump. */
/** The dump as it is after its latest edit. */
lastDumpEvent: Dump | null;
/** The latest playlist change. */
lastPlaylistEvent: PlaylistEvent | null;
/** Ids of playlists deleted this session. */
deletedPlaylistIds: Set<string>;
/** The latest comment change. */
lastCommentEvent: CommentEvent | null;
/** The latest profile change. */
lastUserEvent: UserEvent | null;
/** How many notifications the user hasn't read. */
/** Read from the `welcome`, then counted up by each new notification. */
unreadNotificationCount: number;
/** The latest notification received. */
/** Read by the bell, which rings, and by the notifications page. */
lastNotification: Notification | null;
/** Increments on each WS reconnect so pages can backfill missed events. */
/**
* Incremented on each reconnect, never on the first connection, so pages
* fetch again what they missed while the socket was down.
*/
connectionEpoch: number;
/**
* Upvotes a dump, optimistically. `shownCount`: the count on screen, used
* until a live one arrives.
* Upvotes a dump at once and sends `vote_cast`. The server's answer confirms
* or reverts it, and so does a timeout of 5 s, which covers a closed socket.
* `shownCount` is the count on screen, used when `voteCounts` has none yet.
* Does nothing when the user already voted.
*/
castVote: (dumpId: string, shownCount?: number) => void;
/** Withdraws an upvote, optimistically; `shownCount` as for `castVote`. */
/** Withdraws an upvote, as {@link WSContextValue.castVote} casts one. */
removeVote: (dumpId: string, shownCount?: number) => void;
/** Likes a comment, optimistically; `shownCount` as for `castVote`. */
/** Likes a comment, as {@link WSContextValue.castVote} upvotes a dump. */
castCommentLike: (commentId: string, shownCount?: number) => void;
/** Withdraws a like, optimistically; `shownCount` as for `castVote`. */
/** Withdraws a like, as {@link WSContextValue.castVote} casts a vote. */
removeCommentLike: (commentId: string, shownCount?: number) => void;
/**
* Adds a dump this client just posted to `recentDumps`, without waiting for
* its broadcast.
* Adds a dump this user just posted to `recentDumps`, without waiting for
* its broadcast, which a private dump never gets. `DumpCreateModal` calls it.
*/
injectDump: (dump: Dump) => void;
/** Sets the unread notification count to zero. */
/**
* Zeroes the bell's count, once the notifications page has marked them read
* on the server.
*/
clearUnreadNotifications: () => void;
}
/** The chat room: its live stream, unread state and what to send into it. */
/**
* 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.
*/
export interface WSChatContextValue {
/** Live buffer of chat messages received this session (capped). */
/**
* 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.
*/
chatMessages: ChatMessage[];
/**
* Ids of chat messages deleted live this session, so fetched history can
* filter them out.
* Messages deleted this session, which `ChatModal` filters out of its
* fetched history.
*/
deletedChatIds: Set<string>;
/**
* Messages from others since the read marker — kept by the server, so it
* survives reloads and agrees across tabs and devices.
* Messages from others after the read marker. The server keeps the marker,
* so the count agrees across tabs and devices.
*/
unreadChatCount: number;
/**
* The server's read marker: created_at of the newest message the user has
* seen. Null until the socket's welcome.
* The time of the newest message the user has seen, which the server keeps.
* Null until the `welcome`, and for a visitor.
*/
chatReadAt: Date | null;
/** Messages sent and not yet echoed back. */
pendingChatMessages: PendingChatMessage[];
/** The latest failed edit or delete, until dismissed. */
/** Only for edits and deletes. A failed send shows on its pending message. */
chatError: ChatError | null;
/**
* Users currently composing a chat message (excludes nobody — the chatbox
* filters out the current user itself). Shaped like presence for avatars.
*/
/** Includes the user when they are typing: `ChatModal` filters them out. */
typingUsers: OnlineUser[];
/** Sends a message, replying to `replyTo` if given. */
/** Trims `body` and sends it as a pending message. Ignores a blank body. */
sendChatMessage: (body: string, replyTo?: ChatReplyTarget) => void;
/** Resends the failed message `nonce`. */
retryChatMessage: (nonce: string) => void;
/** Drops the failed message `nonce`. */
discardChatMessage: (nonce: string) => void;
/** Edits a message. */
/**
* Sends the edit if the socket is open, else drops it. It shows once the
* server broadcasts it, or fails as `chatError`.
*/
editChatMessage: (id: string, body: string) => void;
/** Deletes a message. */
/** Sends the deletion if the socket is open, as `editChatMessage` does. */
deleteChatMessage: (id: string) => void;
/** Clears `chatError`. */
dismissChatError: () => void;
/** Moves the read marker up to the message created at `at`. */
/**
* Moves the read marker up to `at`, the time of the newest message seen. It
* zeroes `unreadChatCount` at once and sends the marker at most once a
* second. A marker set offline goes out with the next `welcome`.
*/
markChatRead: (at: Date) => void;
/**
* Folds an authoritative fetch of the stream between `from` and `to` into the
* live buffer after a reconnect: buffered messages in that range which the
* server no longer has were deleted while the socket was down.
* 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.
*/
reconcileChat: (messages: ChatMessage[], from: Date, to: Date) => void;
/**
* Informs the server whether the user is currently composing a message, so it
* can show/hide the "is typing" indicator to other clients.
* Tells the server whether the user is typing, when the socket is open.
* `ChatModal` resends `true` every few seconds while typing continues, since
* the server forgets a typer after 6 s.
*/
setChatTyping: (typing: boolean) => void;
/**
* Informs the server whether the chatbox is open (suppresses redundant
* mention notifications while the user is reading chat).
* 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.
*/
setChatOpen: (open: boolean) => void;
}
/** Who's connected. */
/**
* Provided by `WSProvider` through {@link WSPresenceContext}, read with
* `useWSPresence`.
*/
export interface WSPresenceContextValue {
/** The signed-in users with a socket open. */
/** The signed-in users with a socket open, each once. */
onlineUsers: OnlineUser[];
}
/** What a consumer outside any provider gets. */
/** Given to a consumer outside `WSProvider`, as if it were still connecting. */
export const defaultWSValue: WSContextValue = {
wsStatus: "connecting",
wsErrorMessage: null,
@@ -262,7 +302,7 @@ export const defaultWSValue: WSContextValue = {
clearUnreadNotifications: () => {},
};
/** What a consumer outside any provider gets. */
/** Given to a consumer outside `WSProvider`: an empty chat. */
export const defaultWSChatValue: WSChatContextValue = {
chatMessages: [],
deletedChatIds: new Set(),
@@ -283,22 +323,25 @@ export const defaultWSChatValue: WSChatContextValue = {
setChatOpen: () => {},
};
/** What a consumer outside any provider gets. */
/** Given to a consumer outside `WSProvider`: nobody is online. */
export const defaultWSPresenceValue: WSPresenceContextValue = {
onlineUsers: [],
};
/** The socket context: connection, votes, likes and live events. */
/**
* Holds the connection, the votes and likes, and the latest live event of
* each kind. Read it with `useWS`.
*/
export const WSContext: React.Context<WSContextValue> = createContext<
WSContextValue
>(defaultWSValue);
/** The chat context. */
/** Holds the chat's live stream and read state. Read it with `useWSChat`. */
export const WSChatContext: React.Context<WSChatContextValue> = createContext<
WSChatContextValue
>(
defaultWSChatValue,
);
/** The presence context. */
/** Holds who is online. Read it with `useWSPresence`. */
export const WSPresenceContext: React.Context<WSPresenceContextValue> =
createContext<
WSPresenceContextValue

View File

@@ -1,7 +1,8 @@
/**
* The socket's provider: one connection per session, and the live state it
* feeds — presence, votes, likes, dumps, playlists, comments, users,
* notifications and the chat.
* 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`.
*
* @module
*/
@@ -59,69 +60,55 @@ import { setTitleBadge } from "../../hooks/shared/useDocumentTitle.ts";
import { playChime } from "../../utils/shared/sound.ts";
import { mentionsUser } from "../../utils/chat/text.ts";
/** The props of {@link WSProvider}. */
/** Passed to {@link WSProvider} by `App`. */
export interface WSProviderProps {
/** The app, which reads the live state. */
children: ReactNode;
}
/** The longest wait between reconnection attempts, in ms. */
const MAX_BACKOFF = 30_000;
/**
* How long an optimistic vote or like waits for its acknowledgement before it
* is rolled back, in ms.
*/
/** How long an optimistic vote or like waits for the server, in ms. */
const ACK_TIMEOUT = 5_000;
/**
* How long a connection attempt may take before the socket is shown as down, in
* ms.
*/
/** How long a connection attempt may take before the banner shows, in ms. */
const CONNECT_TIMEOUT = 2_500;
/**
* Cap on the live "new dumps" buffer, so a long-lived session can't grow it
* without bound. Feeds merge these on top of their own paginated list, so a
* generous cap is plenty — older live arrivals are reachable via normal
* pagination.
* The most dumps `recentDumps` holds. Older arrivals are in the feeds' later
* pages.
*/
const MAX_RECENT_DUMPS = 100;
/**
* Cap on the live chat buffer, so a long-lived session can't grow it without
* bound. Older messages remain reachable via the /api/chat history endpoint.
* The most messages `chatMessages` holds. Older ones are in the history,
* which `ChatModal` fetches.
*/
const MAX_CHAT_MESSAGES = 200;
/**
* How long a sent chat message may go without its echo before it's shown as
* failed, in ms.
*/
/** How long a sent chat message waits for its echo, in ms. */
const CHAT_SEND_TIMEOUT = 10_000;
/** How often the read marker may go out, in ms. */
/**
* The least time between two read markers, in ms. The server drops markers
* past a rate of one a second.
*/
const CHAT_READ_THROTTLE = 1_000;
/**
* A failed send whose text turns up in the resynced history within this long of
* being sent did reach the server — only its echo was lost.
* How close in time a message of the history must be to a failed send with
* the same text to count as that send, in ms.
*/
const CHAT_ECHO_WINDOW = 60_000;
/** An optimistic vote awaiting its acknowledgement. */
/** An optimistic vote waiting for the server. */
interface PendingVote {
/** The timer that rolls it back. */
timeout: ReturnType<typeof setTimeout>;
/** Undoes it. */
rollback: () => void;
}
/** An optimistic like awaiting its acknowledgement. */
/** An optimistic like waiting for the server. */
interface PendingLike {
/** The timer that rolls it back. */
timeout: ReturnType<typeof setTimeout>;
/** Undoes it. */
rollback: () => void;
}
/**
* Returns `data` as a server message, or null when it isn't one. The runtime
* check is minimal: the `type` field must be a string, so the cast to the
* discriminated union is safe and TypeScript narrows from there.
* Returns `data` as a server message, or null when it isn't JSON with a string
* `type`. The fields aren't checked: the server is trusted.
*/
function parseWSMessage(data: string): Wire<ServerToClientMessage> | null {
try {
@@ -137,12 +124,11 @@ function parseWSMessage(data: string): Wire<ServerToClientMessage> | null {
/**
* Provides {@link WSContext}, {@link WSChatContext}, {@link WSPresenceContext}
* and {@link AvatarOverrideContext}. It connects with the session's token and
* reconnects with a growing backoff; each reconnect bumps `connectionEpoch` so
* pages backfill what they missed. Votes, likes and chat messages apply at once
* and are confirmed or rolled back by the server's answer. A new notification
* or chat message chimes, and the unread count goes in the tab title. A
* `force_logout` signs the user out; a change of session clears the live state.
* and {@link AvatarOverrideContext}. It connects with the session's token, or
* anonymously, and reconnects with a backoff doubling up to 30 s. A new token
* (sign-in, sign-out, another account) clears the live state and reconnects.
* A new notification or chat message chimes, and the unread counts go in the
* tab title. A `force_logout` signs the user out.
*/
export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
const { token, user, logout } = useAuth();
@@ -152,9 +138,7 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
>("connecting");
const [wsErrorMessage, setWSErrorMessage] = useState<string | null>(null);
// Tracks the token to detect login/logout below. The actual reset runs after
// all state declarations (see the `prevToken !== token` block) because it
// touches setters that aren't in scope yet here.
// For the reset below, which must follow every state declaration.
const [prevToken, setPrevToken] = useState(token);
const [onlineUsers, setOnlineUsers] = useState<OnlineUser[]>([]);
@@ -190,20 +174,16 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
>([]);
const [chatError, setChatError] = useState<ChatError | null>(null);
const [typingUsers, setTypingUsers] = useState<OnlineUser[]>([]);
// Live avatar overrides for site-wide Avatar refresh (own narrow context).
const [avatarOverrides, setAvatarOverrides] = useState<
Record<string, AvatarOverride>
>({});
// Bumped each time the socket reconnects (a `welcome` after the first), so
// pages can backfill events they missed while disconnected. Monotonic — not
// reset on token change.
// Never reset: pages compare it with the value they last saw.
const [connectionEpoch, setConnectionEpoch] = useState(0);
// Reset during render when the token changes (login/logout/account switch),
// rather than inside the effect (which would cause a cascading re-render).
// The socket reconnects and `welcome` re-syncs presence/votes/likes/unread,
// but the accumulated realtime state below would otherwise bleed from the
// previous session into the next (e.g. another account's recent dumps).
// A new token means another session: the `welcome` resyncs votes, likes and
// unread counts, but what the last session gathered (another account's
// private dumps, its pending messages) must go. Reset during render, since
// an effect would render the stale state once more.
if (prevToken !== token) {
setPrevToken(token);
setWSStatus("connecting");
@@ -234,15 +214,15 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
setAvatarOverrides({});
}
// Refs to avoid stale closures in event handlers
// The socket's handlers and the stable callbacks read the current values
// through these refs. Depending on the values instead would reconnect the
// socket, or change the callbacks, on every vote.
const voteCountsRef = useRef(voteCounts);
const myVotesRef = useRef(myVotes);
const likeCountsRef = useRef(likeCounts);
const myLikesRef = useRef(myLikes);
const userIdRef = useRef(userId);
const usernameRef = useRef(user?.username ?? null);
// Stable ref for logout so the effect doesn't reconnect when the function
// reference changes on re-renders.
const onForceLogoutRef = useRef(logout);
useLayoutEffect(() => {
voteCountsRef.current = voteCounts;
@@ -255,23 +235,23 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
});
const socketRef = useRef<WebSocket | null>(null);
// Tracks whether the chatbox is open so it can be re-announced to the server
// after a reconnect (the fresh socket starts with the flag cleared).
// A new socket starts with the chat closed on the server's side, so the
// `welcome` handler announces it again.
const chatOpenRef = useRef(false);
// The user is only actually reading the chat when the chatbox is open *and*
// the tab is on screen: a chatbox left open in a background tab must not
// A chat left open in a background tab isn't being read: it must not
// swallow mention notifications or unread counts.
const isViewingChat = useCallback(
() => chatOpenRef.current && !document.hidden,
[],
);
// Echo timeouts of in-flight chat sends, by nonce.
// By nonce. A send is in flight while it has a timer here.
const chatSendTimersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(
new Map(),
);
const chatErrorSeqRef = useRef(0);
// Read marker bookkeeping: the newest marker sent (or learned from the
// server), and a trailing timer so a burst of arrivals sends one marker.
// The read marker, in ms. `Sent` is the newest the server has, from this tab
// or another. `Queued` is the newest to send, which a trailing timer sends
// once for a burst of messages.
const chatReadSentRef = useRef(0);
const chatReadQueuedRef = useRef(0);
const chatReadTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -293,11 +273,12 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
setPendingChatMessages((prev) => prev.filter((p) => p.nonce !== nonce));
}, []);
// Tracks pending optimistic votes: dumpId → pending rollback handler
// By dump id, and by comment id for likes. One per item: a second click
// before the answer replaces the first's timer, so only the latest change
// can be rolled back.
const pendingRef = useRef<Map<string, PendingVote>>(
new Map(),
);
// Tracks pending optimistic comment likes: commentId → pending rollback handler
const pendingLikesRef = useRef<Map<string, PendingLike>>(
new Map(),
);
@@ -360,8 +341,7 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let connectTimeout: ReturnType<typeof setTimeout> | null = null;
let everConnected = false;
// Counts `welcome` messages for this token; the first is the initial
// connect, any later one is a reconnect worth a backfill epoch bump.
// For this token. Every `welcome` after the first is a reconnect.
let welcomeCount = 0;
function connect() {
@@ -373,6 +353,8 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
const ws = new WebSocket(url);
socketRef.current = ws;
// A connection that hangs may take far longer than this to fail. Closing
// it shows the banner and goes through `onclose` to the next attempt.
connectTimeout = setTimeout(() => {
if (ws.readyState !== WebSocket.CONNECTING) return;
setWSStatus("disconnected");
@@ -398,13 +380,14 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
switch (msg.type) {
case "ping":
// The server closes a socket that misses a pong.
ws.send(
JSON.stringify({ type: "pong" } satisfies ClientToServerMessage),
);
break;
case "welcome":
backoff = 500; // reset backoff on successful connect
backoff = 500;
setOnlineUsers(msg.users);
setMyVotes(new Set(msg.myVotes));
setMyLikes(new Set(msg.myCommentLikes));
@@ -418,24 +401,21 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
readAt.getTime(),
);
}
// welcome provides authoritative server state — cancel any
// in-flight revert timers, they are now superseded.
// The server's sets replace whatever was pending. The optimistic
// counts stay, though, until a live count replaces them.
clearAllPendingVotes();
clearAllPendingLikes();
// Reconnect (not the first welcome): signal pages to backfill the
// dumps/comments they couldn't receive while disconnected.
welcomeCount += 1;
if (welcomeCount > 1) {
setConnectionEpoch((n) => n + 1);
// With the chatbox closed nothing will resync the live buffer,
// and whatever was edited or deleted meanwhile would win over
// the fresh history it fetches on open — so just drop it.
// With the chat closed, nothing resyncs the live buffer, and its
// stale copies would win over the history fetched on open.
if (!chatOpenRef.current) {
setChatMessages([]);
setDeletedChatIds(new Set());
}
}
// A marker that was waiting on the socket goes out now.
// A marker set while offline goes out now.
if (chatReadQueuedRef.current > chatReadSentRef.current) {
ws.send(
JSON.stringify(
@@ -447,8 +427,8 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
);
chatReadSentRef.current = chatReadQueuedRef.current;
}
// Restore the chat-open flag on the fresh connection so mention
// suppression keeps working across reconnects.
// The server knows the chat is open per socket, and this one is
// new.
if (isViewingChat()) {
ws.send(
JSON.stringify(
@@ -469,8 +449,8 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
const { dumpId, voteCount, voterId, action } = msg;
setVoteCounts((prev) => ({ ...prev, [dumpId]: voteCount }));
setLastVoteEvent({ dumpId, voterId, action });
// Keep myVotes in sync across tabs: if this vote event belongs to
// the current user (from another tab), update myVotes accordingly.
// The user's own vote, from this tab or another: it settles the
// pending one and keeps every tab's button in step.
if (voterId === userIdRef.current) {
clearPendingVote(dumpId);
setMyVotes((prev) => {
@@ -487,8 +467,7 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
const { commentId, likeCount, likerId, action } = msg;
setLikeCounts((prev) => ({ ...prev, [commentId]: likeCount }));
setLastLikeEvent({ commentId, likerId, action });
// Keep myLikes in sync across tabs: if this like event belongs to
// the current user (from another tab), update myLikes accordingly.
// As for votes.
if (likerId === userIdRef.current) {
clearPendingLike(commentId);
setMyLikes((prev) => {
@@ -502,8 +481,8 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
}
case "dump_created": {
// Also sent when a dump goes from private to public: to everyone
// who couldn't see it, that's an arrival.
// Also sent when a dump goes public, which to everyone else is an
// arrival, and may undo its `dump_deleted` from going private.
const dump = deserializeDump(msg.dump);
setDeletedDumpIds((prev) => {
if (!prev.has(dump.id)) return prev;
@@ -523,9 +502,8 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
case "dump_updated": {
const dump = deserializeDump(msg.dump);
setLastDumpEvent(dump);
// An edit is not an arrival: refresh a live entry in place, but
// don't pull an old dump to the top of the feeds (going public
// comes as a `dump_created`).
// An edit is not an arrival: it must not pull an old dump to the
// top of the feeds.
setRecentDumps((prev) =>
prev.some((d) => d.id === dump.id)
? prev.map((d) => d.id === dump.id ? dump : d)
@@ -535,6 +513,7 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
}
case "dump_deleted": {
// Also sent when a dump goes private.
const { dumpId } = msg;
setDeletedDumpIds((prev) => new Set([...prev, dumpId]));
setRecentDumps((prev) => prev.filter((d) => d.id !== dumpId));
@@ -544,9 +523,7 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
case "vote_ack": {
const { dumpId, action, voteCount } = msg;
clearPendingVote(dumpId);
// Reconcile with authoritative count
setVoteCounts((prev) => ({ ...prev, [dumpId]: voteCount }));
// Confirm vote state
setMyVotes((prev) => {
const next = new Set(prev);
if (action === "cast") next.add(dumpId);
@@ -559,9 +536,7 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
case "comment_like_ack": {
const { commentId, action, likeCount } = msg;
clearPendingLike(commentId);
// Reconcile with authoritative count
setLikeCounts((prev) => ({ ...prev, [commentId]: likeCount }));
// Confirm like state
setMyLikes((prev) => {
const next = new Set(prev);
if (action === "cast") next.add(commentId);
@@ -603,8 +578,6 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
case "user_updated": {
const user = deserializePublicUser(msg.user);
setLastUserEvent({ user });
// Refresh this user's avatar everywhere it's rendered. updatedAt
// (bumped on any profile/avatar write) doubles as the version.
setAvatarOverrides((prev) => ({
...prev,
[user.id]: {
@@ -655,16 +628,16 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
if (prev.some((m) => m.id === message.id)) return prev;
return [...prev, message].slice(-MAX_CHAT_MESSAGES);
});
// The sender's own echo: its pending copy is now a message.
if (msg.nonce) retirePendingChat(msg.nonce);
// Count as unread unless it's the current user's own message, or
// they're looking at it (the chatbox then moves the read marker).
// A message read live isn't counted: the chat moves the marker.
if (message.userId !== userIdRef.current) {
if (!isViewingChat()) setUnreadChatCount((prev) => prev + 1);
const username = usernameRef.current;
if (username && mentionsUser(message.body, username)) {
// Someone reading the chat gets no notification for a mention
// (the server skips it), so the chime has to come from here;
// otherwise the notification that follows brings its own.
// The server sends no notification for a mention read live, so
// the chime comes from here. Otherwise the notification that
// follows chimes.
if (isViewingChat()) playChime("notify", message.id);
} else if (!isViewingChat()) {
playChime("chat", message.id);
@@ -675,6 +648,8 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
case "chat_message_updated": {
const message = deserializeChatMessage(msg.message);
// An edit of a message older than the buffer is added to it, so
// it wins over the history's copy. `ChatModal` sorts by time.
setChatMessages((prev) => {
const idx = prev.findIndex((m) => m.id === message.id);
if (idx === -1) return [...prev, message];
@@ -697,6 +672,7 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
break;
case "chat_read_update": {
// Sent to every socket of the user, whichever tab moved it.
const readAt = new Date(msg.readAt);
chatReadSentRef.current = Math.max(
chatReadSentRef.current,
@@ -718,9 +694,8 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
else setChatError({ id: ++chatErrorSeqRef.current, message });
break;
}
// When the server identifies the failed optimistic action, revert
// its pending vote/like right away instead of waiting out the ACK
// timeout. Without context, the per-action timer handles cleanup.
// A failed vote or like names its dump or comment, so it is
// rolled back at once. Other errors leave it to the timer.
if (msg.dumpId) {
const pending = pendingRef.current.get(msg.dumpId);
if (pending) {
@@ -747,9 +722,8 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
connectTimeout = null;
}
if (closed) return;
// Sends still waiting on their echo won't get one on this socket.
// They may have landed, though: the resync after reconnect retires
// any whose text shows up in the history.
// Sends still waiting won't get their echo on this socket. They may
// have landed, though: `reconcileChat` retires those it finds.
for (const nonce of chatSendTimersRef.current.keys()) {
failPendingChat(
nonce,
@@ -769,7 +743,7 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
};
ws.onerror = () => {
// onclose will fire after onerror
// `onclose` follows, and reconnects.
};
}
@@ -804,11 +778,10 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
]);
const castVote = useCallback((dumpId: string, shownCount?: number) => {
// Optimistic update. Until a live event arrives there's no count here, only
// the one the caller got over REST and is showing.
// Until the socket brings a count, the one on screen is the only one.
const prevCount = voteCountsRef.current[dumpId] ?? shownCount ?? 0;
const prevVoted = myVotesRef.current.has(dumpId);
if (prevVoted) return; // already voted
if (prevVoted) return;
setMyVotes((prev) => {
const n = new Set(prev);
@@ -817,7 +790,6 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
});
setVoteCounts((prev) => ({ ...prev, [dumpId]: prevCount + 1 }));
// Schedule revert if no authoritative confirmation arrives.
schedulePendingVote(dumpId, () => {
setMyVotes((prev) => {
const n = new Set(prev);
@@ -834,15 +806,13 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
),
);
}
// If socket is not OPEN, the revert timer will handle cleanup after ACK_TIMEOUT
// Nothing is sent while the socket is down, so the timer rolls it back.
}, [schedulePendingVote]);
const removeVote = useCallback((dumpId: string, shownCount?: number) => {
// Optimistic update. Until a live event arrives there's no count here, only
// the one the caller got over REST and is showing.
const prevCount = voteCountsRef.current[dumpId] ?? shownCount ?? 0;
const prevVoted = myVotesRef.current.has(dumpId);
if (!prevVoted) return; // not voted
if (!prevVoted) return;
setMyVotes((prev) => {
const n = new Set(prev);
@@ -854,7 +824,6 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
[dumpId]: Math.max(0, prevCount - 1),
}));
// Schedule revert if no authoritative confirmation arrives.
schedulePendingVote(dumpId, () => {
setMyVotes((prev) => {
const n = new Set(prev);
@@ -871,16 +840,13 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
),
);
}
// If socket is not OPEN, the revert timer will handle cleanup after ACK_TIMEOUT
}, [schedulePendingVote]);
const castCommentLike = useCallback(
(commentId: string, shownCount?: number) => {
// Optimistic update. Until a live event arrives there's no count here, only
// the one the caller got over REST and is showing.
const prevCount = likeCountsRef.current[commentId] ?? shownCount ?? 0;
const prevLiked = myLikesRef.current.has(commentId);
if (prevLiked) return; // already liked
if (prevLiked) return;
setMyLikes((prev) => {
const n = new Set(prev);
@@ -889,7 +855,6 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
});
setLikeCounts((prev) => ({ ...prev, [commentId]: prevCount + 1 }));
// Schedule revert if no authoritative confirmation arrives.
schedulePendingLike(commentId, () => {
setMyLikes((prev) => {
const n = new Set(prev);
@@ -909,18 +874,15 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
),
);
}
// If socket is not OPEN, the revert timer will handle cleanup after ACK_TIMEOUT
},
[schedulePendingLike],
);
const removeCommentLike = useCallback(
(commentId: string, shownCount?: number) => {
// Optimistic update. Until a live event arrives there's no count here, only
// the one the caller got over REST and is showing.
const prevCount = likeCountsRef.current[commentId] ?? shownCount ?? 0;
const prevLiked = myLikesRef.current.has(commentId);
if (!prevLiked) return; // not liked
if (!prevLiked) return;
setMyLikes((prev) => {
const n = new Set(prev);
@@ -932,7 +894,6 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
[commentId]: Math.max(0, prevCount - 1),
}));
// Schedule revert if no authoritative confirmation arrives.
schedulePendingLike(commentId, () => {
setMyLikes((prev) => {
const n = new Set(prev);
@@ -952,7 +913,6 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
),
);
}
// If socket is not OPEN, the revert timer will handle cleanup after ACK_TIMEOUT
},
[schedulePendingLike],
);
@@ -968,9 +928,8 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
setUnreadNotificationCount(0);
}, []);
// Sends (or resends) a pending message: it shows as "sending" until its echo
// arrives, or as failed if the socket is down, the server rejects it, or no
// echo comes back in time.
// The pending message leaves the list with its echo (`chat_message`), or
// fails on a refusal (`error`), on a closed socket or after the timeout.
const transmitPendingChat = useCallback((pending: PendingChatMessage) => {
const socket = socketRef.current;
if (socket?.readyState !== WebSocket.OPEN) {
@@ -1025,8 +984,7 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
const retryChatMessage = useCallback((nonce: string) => {
const pending = pendingChatRef.current.find((p) => p.nonce === nonce);
if (!pending) return;
// A fresh nonce: should the first attempt's echo still turn up, it mustn't
// retire the retry.
// A late echo of the first attempt must not retire the retry.
const retry: PendingChatMessage = {
...pending,
nonce: crypto.randomUUID(),
@@ -1076,7 +1034,7 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
const at = chatReadQueuedRef.current;
if (at <= chatReadSentRef.current) return;
const socket = socketRef.current;
// Offline: stays queued, and the next welcome sends it.
// Offline, it stays queued, and the next `welcome` sends it.
if (socket?.readyState !== WebSocket.OPEN) return;
socket.send(
JSON.stringify(
@@ -1096,7 +1054,7 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
return;
}
chatReadQueuedRef.current = time;
// The badge goes at once; the server's count follows with the marker.
// The badge goes at once. The server's count follows the marker.
setUnreadChatCount(0);
if (chatReadTimerRef.current) return;
const wait = Math.max(
@@ -1113,9 +1071,8 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
const reconcileChat = useCallback(
(messages: ChatMessage[], from: Date, to: Date) => {
// In that stretch the fetched copy is the truth: a live copy of a
// message edited while the socket was down is stale, and the merge in
// the chatbox would let it win.
// A live copy of a message edited while the socket was down is stale,
// and `ChatModal`'s merge would let it win over the fetched one.
const byId = new Map(messages.map((m) => [m.id, m]));
setChatMessages((prev) =>
prev
@@ -1124,7 +1081,8 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
)
.map((m) => byId.get(m.id) ?? m)
);
// Failed sends that did land (only the echo was lost) are retired.
// A failed send that did land lost only its echo. Its text and time
// are all there is to recognize it by.
const mine = messages.filter((m) => m.userId === userIdRef.current);
for (const pending of pendingChatRef.current) {
if (pending.status !== "failed") continue;
@@ -1149,8 +1107,6 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
}
}, []);
// Tell the server whether the user is reading the chat right now (chatbox
// open and tab visible), so it knows when a mention notification is moot.
const sendChatFocus = useCallback(() => {
if (socketRef.current?.readyState === WebSocket.OPEN) {
socketRef.current.send(
@@ -1169,7 +1125,8 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
sendChatFocus();
}, [sendChatFocus]);
// Re-announce chat focus whenever the tab is hidden or shown.
// Hiding the tab stops the user reading an open chat, and showing it again
// resumes.
useEffect(() => {
const onVisibility = () => {
if (chatOpenRef.current) sendChatFocus();
@@ -1178,7 +1135,6 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
return () => document.removeEventListener("visibilitychange", onVisibility);
}, [sendChatFocus]);
// Unread count in the tab title, so a background tab shows there's news.
useEffect(() => {
setTitleBadge(unreadNotificationCount + unreadChatCount);
}, [unreadNotificationCount, unreadChatCount]);