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,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]);