Files
gerbeur/src/contexts/ws/WSProvider.tsx
khannurien 0d4e344d77 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>
2026-09-29 14:31:14 +00:00

1249 lines
40 KiB
TypeScript

/**
* 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
*/
import {
type ReactNode,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import {
type ChatError,
type ChatReplyTarget,
type CommentEvent,
type LikeEvent,
type PendingChatMessage,
type PlaylistEvent,
type UserEvent,
type VoteEvent,
WSChatContext,
type WSChatContextValue,
WSContext,
type WSContextValue,
WSPresenceContext,
type WSPresenceContextValue,
} from "./WSContext.ts";
import {
type AvatarOverride,
AvatarOverrideContext,
} from "./AvatarOverrideContext.ts";
import { WS_URL } from "../../config/api.ts";
import type { ChatMessage } from "../../../shared/chat.ts";
import type { Dump } from "../../../shared/dump.ts";
import type { Wire } from "../../../shared/api.ts";
import type {
ClientToServerMessage,
OnlineUser,
ServerToClientMessage,
} from "../../../shared/ws.ts";
import type { Notification } from "../../../shared/notification.ts";
import {
deserializeChatMessage,
deserializeComment,
deserializeDump,
deserializeNotification,
deserializePlaylist,
deserializePublicUser,
} from "../../utils/shared/deserialize.ts";
import { t } from "@lingui/core/macro";
import { useAuth } from "../../hooks/auth/useAuth.ts";
import { setTitleBadge } from "../../hooks/shared/useDocumentTitle.ts";
import { playChime } from "../../utils/shared/sound.ts";
import { mentionsUser } from "../../utils/chat/text.ts";
/** Passed to {@link WSProvider} by `App`. */
export interface WSProviderProps {
children: ReactNode;
}
/** The longest wait between reconnection attempts, in ms. */
const MAX_BACKOFF = 30_000;
/** 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 banner shows, in ms. */
const CONNECT_TIMEOUT = 2_500;
/**
* The most dumps `recentDumps` holds. Older arrivals are in the feeds' later
* pages.
*/
const MAX_RECENT_DUMPS = 100;
/**
* 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 waits for its echo, in ms. */
const CHAT_SEND_TIMEOUT = 10_000;
/**
* 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;
/**
* 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 waiting for the server. */
interface PendingVote {
timeout: ReturnType<typeof setTimeout>;
rollback: () => void;
}
/** An optimistic like waiting for the server. */
interface PendingLike {
timeout: ReturnType<typeof setTimeout>;
rollback: () => void;
}
/**
* 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 {
const msg = JSON.parse(data);
if (!msg || typeof msg !== "object" || typeof msg.type !== "string") {
return null;
}
return msg as Wire<ServerToClientMessage>;
} catch {
return null;
}
}
/**
* Provides {@link WSContext}, {@link WSChatContext}, {@link WSPresenceContext}
* 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();
const userId = user?.id ?? null;
const [wsStatus, setWSStatus] = useState<
"connecting" | "connected" | "disconnected"
>("connecting");
const [wsErrorMessage, setWSErrorMessage] = useState<string | null>(null);
// For the reset below, which must follow every state declaration.
const [prevToken, setPrevToken] = useState(token);
const [onlineUsers, setOnlineUsers] = useState<OnlineUser[]>([]);
const [voteCounts, setVoteCounts] = useState<Record<string, number>>({});
const [myVotes, setMyVotes] = useState<Set<string>>(new Set());
const [likeCounts, setLikeCounts] = useState<Record<string, number>>({});
const [myLikes, setMyLikes] = useState<Set<string>>(new Set());
const [recentDumps, setRecentDumps] = useState<Dump[]>([]);
const [deletedDumpIds, setDeletedDumpIds] = useState<Set<string>>(new Set());
const [lastVoteEvent, setLastVoteEvent] = useState<VoteEvent | null>(null);
const [lastLikeEvent, setLastLikeEvent] = useState<LikeEvent | null>(null);
const [lastDumpEvent, setLastDumpEvent] = useState<Dump | null>(null);
const [lastPlaylistEvent, setLastPlaylistEvent] = useState<
PlaylistEvent | null
>(null);
const [deletedPlaylistIds, setDeletedPlaylistIds] = useState<Set<string>>(
new Set(),
);
const [lastCommentEvent, setLastCommentEvent] = useState<CommentEvent | null>(
null,
);
const [lastUserEvent, setLastUserEvent] = useState<UserEvent | null>(null);
const [unreadNotificationCount, setUnreadNotificationCount] = useState(0);
const [lastNotification, setLastNotification] = useState<Notification | null>(
null,
);
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([]);
const [deletedChatIds, setDeletedChatIds] = useState<Set<string>>(new Set());
const [unreadChatCount, setUnreadChatCount] = useState(0);
const [chatReadAt, setChatReadAt] = useState<Date | null>(null);
const [pendingChatMessages, setPendingChatMessages] = useState<
PendingChatMessage[]
>([]);
const [chatError, setChatError] = useState<ChatError | null>(null);
const [typingUsers, setTypingUsers] = useState<OnlineUser[]>([]);
const [avatarOverrides, setAvatarOverrides] = useState<
Record<string, AvatarOverride>
>({});
// Never reset: pages compare it with the value they last saw.
const [connectionEpoch, setConnectionEpoch] = useState(0);
// 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");
setWSErrorMessage(null);
setOnlineUsers([]);
setVoteCounts({});
setMyVotes(new Set());
setLikeCounts({});
setMyLikes(new Set());
setRecentDumps([]);
setDeletedDumpIds(new Set());
setLastVoteEvent(null);
setLastLikeEvent(null);
setLastDumpEvent(null);
setLastPlaylistEvent(null);
setDeletedPlaylistIds(new Set());
setLastCommentEvent(null);
setLastUserEvent(null);
setUnreadNotificationCount(0);
setLastNotification(null);
setChatMessages([]);
setDeletedChatIds(new Set());
setUnreadChatCount(0);
setChatReadAt(null);
setPendingChatMessages([]);
setChatError(null);
setTypingUsers([]);
setAvatarOverrides({});
}
// 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);
const onForceLogoutRef = useRef(logout);
useLayoutEffect(() => {
voteCountsRef.current = voteCounts;
myVotesRef.current = myVotes;
likeCountsRef.current = likeCounts;
myLikesRef.current = myLikes;
userIdRef.current = userId;
usernameRef.current = user?.username ?? null;
onForceLogoutRef.current = logout;
});
const socketRef = useRef<WebSocket | null>(null);
// A new socket starts with the chat closed on the server's side, so the
// `welcome` handler announces it again.
const chatOpenRef = useRef(false);
// 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,
[],
);
// 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);
// 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);
const chatReadLastSendRef = useRef(0);
const failPendingChat = useCallback((nonce: string, error: string) => {
const timer = chatSendTimersRef.current.get(nonce);
if (timer) clearTimeout(timer);
chatSendTimersRef.current.delete(nonce);
setPendingChatMessages((prev) =>
prev.map((p) => p.nonce === nonce ? { ...p, status: "failed", error } : p)
);
}, []);
const retirePendingChat = useCallback((nonce: string) => {
const timer = chatSendTimersRef.current.get(nonce);
if (timer) clearTimeout(timer);
chatSendTimersRef.current.delete(nonce);
setPendingChatMessages((prev) => prev.filter((p) => p.nonce !== nonce));
}, []);
// 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(),
);
const pendingLikesRef = useRef<Map<string, PendingLike>>(
new Map(),
);
const clearPendingVote = useCallback((dumpId: string) => {
const pending = pendingRef.current.get(dumpId);
if (!pending) return;
clearTimeout(pending.timeout);
pendingRef.current.delete(dumpId);
}, []);
const clearAllPendingVotes = useCallback(() => {
for (const pending of pendingRef.current.values()) {
clearTimeout(pending.timeout);
}
pendingRef.current.clear();
}, []);
const schedulePendingVote = useCallback((
dumpId: string,
rollback: () => void,
) => {
clearPendingVote(dumpId);
const timeout = setTimeout(() => {
pendingRef.current.delete(dumpId);
rollback();
}, ACK_TIMEOUT);
pendingRef.current.set(dumpId, { timeout, rollback });
}, [clearPendingVote]);
const clearPendingLike = useCallback((commentId: string) => {
const pending = pendingLikesRef.current.get(commentId);
if (!pending) return;
clearTimeout(pending.timeout);
pendingLikesRef.current.delete(commentId);
}, []);
const clearAllPendingLikes = useCallback(() => {
for (const pending of pendingLikesRef.current.values()) {
clearTimeout(pending.timeout);
}
pendingLikesRef.current.clear();
}, []);
const schedulePendingLike = useCallback((
commentId: string,
rollback: () => void,
) => {
clearPendingLike(commentId);
const timeout = setTimeout(() => {
pendingLikesRef.current.delete(commentId);
rollback();
}, ACK_TIMEOUT);
pendingLikesRef.current.set(commentId, { timeout, rollback });
}, [clearPendingLike]);
useEffect(() => {
let closed = false;
let backoff = 500;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let connectTimeout: ReturnType<typeof setTimeout> | null = null;
let everConnected = false;
// For this token. Every `welcome` after the first is a reconnect.
let welcomeCount = 0;
function connect() {
if (closed) return;
const url = `${WS_URL}/ws${
token ? `?token=${encodeURIComponent(token)}` : ""
}`;
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");
setWSErrorMessage(
t`Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects.`,
);
ws.close();
}, CONNECT_TIMEOUT);
ws.onopen = () => {
if (connectTimeout) {
clearTimeout(connectTimeout);
connectTimeout = null;
}
everConnected = true;
setWSStatus("connected");
setWSErrorMessage(null);
};
ws.onmessage = (event) => {
const msg = parseWSMessage(event.data);
if (!msg) return;
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;
setOnlineUsers(msg.users);
setMyVotes(new Set(msg.myVotes));
setMyLikes(new Set(msg.myCommentLikes));
setUnreadNotificationCount(msg.unreadNotificationCount);
setUnreadChatCount(msg.unreadChatCount);
if (msg.chatReadAt) {
const readAt = new Date(msg.chatReadAt);
setChatReadAt(readAt);
chatReadSentRef.current = Math.max(
chatReadSentRef.current,
readAt.getTime(),
);
}
// The server's sets replace whatever was pending. The optimistic
// counts stay, though, until a live count replaces them.
clearAllPendingVotes();
clearAllPendingLikes();
welcomeCount += 1;
if (welcomeCount > 1) {
setConnectionEpoch((n) => n + 1);
// 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 set while offline goes out now.
if (chatReadQueuedRef.current > chatReadSentRef.current) {
ws.send(
JSON.stringify(
{
type: "chat_read",
at: new Date(chatReadQueuedRef.current).toISOString(),
} satisfies ClientToServerMessage,
),
);
chatReadSentRef.current = chatReadQueuedRef.current;
}
// The server knows the chat is open per socket, and this one is
// new.
if (isViewingChat()) {
ws.send(
JSON.stringify(
{
type: "chat_focus",
open: true,
} satisfies ClientToServerMessage,
),
);
}
break;
case "presence_update":
setOnlineUsers(msg.users);
break;
case "votes_update": {
const { dumpId, voteCount, voterId, action } = msg;
setVoteCounts((prev) => ({ ...prev, [dumpId]: voteCount }));
setLastVoteEvent({ dumpId, voterId, action });
// 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) => {
const next = new Set(prev);
if (action === "cast") next.add(dumpId);
else next.delete(dumpId);
return next;
});
}
break;
}
case "comment_likes_update": {
const { commentId, likeCount, likerId, action } = msg;
setLikeCounts((prev) => ({ ...prev, [commentId]: likeCount }));
setLastLikeEvent({ commentId, likerId, action });
// As for votes.
if (likerId === userIdRef.current) {
clearPendingLike(commentId);
setMyLikes((prev) => {
const next = new Set(prev);
if (action === "cast") next.add(commentId);
else next.delete(commentId);
return next;
});
}
break;
}
case "dump_created": {
// 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;
const next = new Set(prev);
next.delete(dump.id);
return next;
});
setRecentDumps((prev) =>
[dump, ...prev.filter((d) => d.id !== dump.id)].slice(
0,
MAX_RECENT_DUMPS,
)
);
break;
}
case "dump_updated": {
const dump = deserializeDump(msg.dump);
setLastDumpEvent(dump);
// 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)
: prev
);
break;
}
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));
break;
}
case "vote_ack": {
const { dumpId, action, voteCount } = msg;
clearPendingVote(dumpId);
setVoteCounts((prev) => ({ ...prev, [dumpId]: voteCount }));
setMyVotes((prev) => {
const next = new Set(prev);
if (action === "cast") next.add(dumpId);
else next.delete(dumpId);
return next;
});
break;
}
case "comment_like_ack": {
const { commentId, action, likeCount } = msg;
clearPendingLike(commentId);
setLikeCounts((prev) => ({ ...prev, [commentId]: likeCount }));
setMyLikes((prev) => {
const next = new Set(prev);
if (action === "cast") next.add(commentId);
else next.delete(commentId);
return next;
});
break;
}
case "playlist_created":
case "playlist_updated": {
const playlist = deserializePlaylist(msg.playlist);
setLastPlaylistEvent({
type: msg.type === "playlist_created" ? "created" : "updated",
playlistId: playlist.id,
playlist,
});
break;
}
case "playlist_deleted": {
const { playlistId, userId } = msg;
setDeletedPlaylistIds((prev) => new Set([...prev, playlistId]));
setLastPlaylistEvent({ type: "deleted", playlistId, userId });
break;
}
case "playlist_dumps_updated": {
const { playlistId, dumpIds, dumpCount } = msg;
setLastPlaylistEvent({
type: "dumps_updated",
playlistId,
dumpIds,
dumpCount,
});
break;
}
case "user_updated": {
const user = deserializePublicUser(msg.user);
setLastUserEvent({ user });
setAvatarOverrides((prev) => ({
...prev,
[user.id]: {
hasAvatar: !!user.avatarMime,
version: user.updatedAt?.getTime() ?? Date.now(),
},
}));
break;
}
case "comment_created": {
const comment = deserializeComment(msg.comment);
setLastCommentEvent({
type: "created",
dumpId: comment.dumpId,
comment,
});
break;
}
case "comment_deleted": {
const { commentId, dumpId } = msg;
setLastCommentEvent({ type: "deleted", dumpId, commentId });
break;
}
case "comment_updated": {
const comment = deserializeComment(msg.comment);
setLastCommentEvent({
type: "updated",
dumpId: comment.dumpId,
comment,
});
break;
}
case "notification_created": {
const notification = deserializeNotification(msg.notification);
setLastNotification(notification);
setUnreadNotificationCount((prev) => prev + 1);
playChime("notify", notification.id);
break;
}
case "chat_message": {
const message = deserializeChatMessage(msg.message);
setChatMessages((prev) => {
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);
// 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)) {
// 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);
}
}
break;
}
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];
const next = [...prev];
next[idx] = message;
return next;
});
break;
}
case "chat_message_deleted": {
const { id } = msg;
setDeletedChatIds((prev) => new Set(prev).add(id));
setChatMessages((prev) => prev.filter((m) => m.id !== id));
break;
}
case "chat_typing_update":
setTypingUsers(msg.users);
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,
readAt.getTime(),
);
setChatReadAt(readAt);
setUnreadChatCount(msg.unread);
break;
}
case "force_logout":
onForceLogoutRef.current();
break;
case "error": {
if (msg.scope === "chat") {
const message = msg.message ?? t`Something went wrong`;
if (msg.nonce) failPendingChat(msg.nonce, message);
else setChatError({ id: ++chatErrorSeqRef.current, message });
break;
}
// 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) {
clearTimeout(pending.timeout);
pendingRef.current.delete(msg.dumpId);
pending.rollback();
}
} else if (msg.commentId) {
const pending = pendingLikesRef.current.get(msg.commentId);
if (pending) {
clearTimeout(pending.timeout);
pendingLikesRef.current.delete(msg.commentId);
pending.rollback();
}
}
break;
}
}
};
ws.onclose = () => {
if (connectTimeout) {
clearTimeout(connectTimeout);
connectTimeout = null;
}
if (closed) return;
// 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,
t`Connection lost — this message may not have been sent`,
);
}
setWSStatus("disconnected");
setWSErrorMessage(
everConnected
? t`Live updates are temporarily disconnected. Trying to reconnect…`
: t`Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects.`,
);
reconnectTimer = setTimeout(() => {
backoff = Math.min(backoff * 2, MAX_BACKOFF);
connect();
}, backoff);
};
ws.onerror = () => {
// `onclose` follows, and reconnects.
};
}
connect();
const pending = pendingRef.current;
const pendingLikes = pendingLikesRef.current;
return () => {
closed = true;
if (reconnectTimer) clearTimeout(reconnectTimer);
if (connectTimeout) clearTimeout(connectTimeout);
socketRef.current?.close();
socketRef.current = null;
for (const pendingVote of pending.values()) {
clearTimeout(pendingVote.timeout);
}
pending.clear();
for (const pendingLike of pendingLikes.values()) {
clearTimeout(pendingLike.timeout);
}
pendingLikes.clear();
};
}, [
clearAllPendingVotes,
clearPendingVote,
clearAllPendingLikes,
clearPendingLike,
failPendingChat,
isViewingChat,
retirePendingChat,
token,
]);
const castVote = useCallback((dumpId: string, shownCount?: number) => {
// 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;
setMyVotes((prev) => {
const n = new Set(prev);
n.add(dumpId);
return n;
});
setVoteCounts((prev) => ({ ...prev, [dumpId]: prevCount + 1 }));
schedulePendingVote(dumpId, () => {
setMyVotes((prev) => {
const n = new Set(prev);
n.delete(dumpId);
return n;
});
setVoteCounts((prev) => ({ ...prev, [dumpId]: prevCount }));
});
if (socketRef.current?.readyState === WebSocket.OPEN) {
socketRef.current.send(
JSON.stringify(
{ type: "vote_cast", dumpId } satisfies ClientToServerMessage,
),
);
}
// Nothing is sent while the socket is down, so the timer rolls it back.
}, [schedulePendingVote]);
const removeVote = useCallback((dumpId: string, shownCount?: number) => {
const prevCount = voteCountsRef.current[dumpId] ?? shownCount ?? 0;
const prevVoted = myVotesRef.current.has(dumpId);
if (!prevVoted) return;
setMyVotes((prev) => {
const n = new Set(prev);
n.delete(dumpId);
return n;
});
setVoteCounts((prev) => ({
...prev,
[dumpId]: Math.max(0, prevCount - 1),
}));
schedulePendingVote(dumpId, () => {
setMyVotes((prev) => {
const n = new Set(prev);
n.add(dumpId);
return n;
});
setVoteCounts((prev) => ({ ...prev, [dumpId]: prevCount }));
});
if (socketRef.current?.readyState === WebSocket.OPEN) {
socketRef.current.send(
JSON.stringify(
{ type: "vote_remove", dumpId } satisfies ClientToServerMessage,
),
);
}
}, [schedulePendingVote]);
const castCommentLike = useCallback(
(commentId: string, shownCount?: number) => {
const prevCount = likeCountsRef.current[commentId] ?? shownCount ?? 0;
const prevLiked = myLikesRef.current.has(commentId);
if (prevLiked) return;
setMyLikes((prev) => {
const n = new Set(prev);
n.add(commentId);
return n;
});
setLikeCounts((prev) => ({ ...prev, [commentId]: prevCount + 1 }));
schedulePendingLike(commentId, () => {
setMyLikes((prev) => {
const n = new Set(prev);
n.delete(commentId);
return n;
});
setLikeCounts((prev) => ({ ...prev, [commentId]: prevCount }));
});
if (socketRef.current?.readyState === WebSocket.OPEN) {
socketRef.current.send(
JSON.stringify(
{
type: "comment_like_cast",
commentId,
} satisfies ClientToServerMessage,
),
);
}
},
[schedulePendingLike],
);
const removeCommentLike = useCallback(
(commentId: string, shownCount?: number) => {
const prevCount = likeCountsRef.current[commentId] ?? shownCount ?? 0;
const prevLiked = myLikesRef.current.has(commentId);
if (!prevLiked) return;
setMyLikes((prev) => {
const n = new Set(prev);
n.delete(commentId);
return n;
});
setLikeCounts((prev) => ({
...prev,
[commentId]: Math.max(0, prevCount - 1),
}));
schedulePendingLike(commentId, () => {
setMyLikes((prev) => {
const n = new Set(prev);
n.add(commentId);
return n;
});
setLikeCounts((prev) => ({ ...prev, [commentId]: prevCount }));
});
if (socketRef.current?.readyState === WebSocket.OPEN) {
socketRef.current.send(
JSON.stringify(
{
type: "comment_like_remove",
commentId,
} satisfies ClientToServerMessage,
),
);
}
},
[schedulePendingLike],
);
const injectDump = useCallback((dump: Dump) => {
setRecentDumps((prev) => {
if (prev.some((d) => d.id === dump.id)) return prev;
return [dump, ...prev].slice(0, MAX_RECENT_DUMPS);
});
}, []);
const clearUnreadNotifications = useCallback(() => {
setUnreadNotificationCount(0);
}, []);
// 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) {
failPendingChat(pending.nonce, t`You're offline — not sent`);
return;
}
socket.send(
JSON.stringify(
{
type: "chat_send",
body: pending.body,
nonce: pending.nonce,
...(pending.replyTo ? { replyToId: pending.replyTo.id } : {}),
} satisfies ClientToServerMessage,
),
);
chatSendTimersRef.current.set(
pending.nonce,
setTimeout(
() =>
failPendingChat(
pending.nonce,
t`No reply from the server — this message may not have been sent`,
),
CHAT_SEND_TIMEOUT,
),
);
}, [failPendingChat]);
const sendChatMessage = useCallback(
(body: string, replyTo?: ChatReplyTarget) => {
const trimmed = body.trim();
if (!trimmed) return;
const pending: PendingChatMessage = {
nonce: crypto.randomUUID(),
body: trimmed,
replyTo,
createdAt: new Date(),
status: "sending",
};
setPendingChatMessages((prev) => [...prev, pending]);
transmitPendingChat(pending);
},
[transmitPendingChat],
);
const pendingChatRef = useRef(pendingChatMessages);
useLayoutEffect(() => {
pendingChatRef.current = pendingChatMessages;
});
const retryChatMessage = useCallback((nonce: string) => {
const pending = pendingChatRef.current.find((p) => p.nonce === nonce);
if (!pending) return;
// A late echo of the first attempt must not retire the retry.
const retry: PendingChatMessage = {
...pending,
nonce: crypto.randomUUID(),
status: "sending",
error: undefined,
};
setPendingChatMessages((prev) =>
prev.map((p) => (p.nonce === nonce ? retry : p))
);
transmitPendingChat(retry);
}, [transmitPendingChat]);
const discardChatMessage = useCallback((nonce: string) => {
retirePendingChat(nonce);
}, [retirePendingChat]);
const editChatMessage = useCallback((id: string, body: string) => {
const trimmed = body.trim();
if (!trimmed) return;
if (socketRef.current?.readyState === WebSocket.OPEN) {
socketRef.current.send(
JSON.stringify(
{
type: "chat_edit",
id,
body: trimmed,
} satisfies ClientToServerMessage,
),
);
}
}, []);
const deleteChatMessage = useCallback((id: string) => {
if (socketRef.current?.readyState === WebSocket.OPEN) {
socketRef.current.send(
JSON.stringify(
{ type: "chat_delete", id } satisfies ClientToServerMessage,
),
);
}
}, []);
const dismissChatError = useCallback(() => setChatError(null), []);
const flushChatRead = useCallback(() => {
chatReadTimerRef.current = null;
const at = chatReadQueuedRef.current;
if (at <= chatReadSentRef.current) return;
const socket = socketRef.current;
// Offline, it stays queued, and the next `welcome` sends it.
if (socket?.readyState !== WebSocket.OPEN) return;
socket.send(
JSON.stringify(
{
type: "chat_read",
at: new Date(at).toISOString(),
} satisfies ClientToServerMessage,
),
);
chatReadSentRef.current = at;
chatReadLastSendRef.current = Date.now();
}, []);
const markChatRead = useCallback((at: Date) => {
const time = at.getTime();
if (time <= Math.max(chatReadSentRef.current, chatReadQueuedRef.current)) {
return;
}
chatReadQueuedRef.current = time;
// The badge goes at once. The server's count follows the marker.
setUnreadChatCount(0);
if (chatReadTimerRef.current) return;
const wait = Math.max(
0,
CHAT_READ_THROTTLE - (Date.now() - chatReadLastSendRef.current),
);
chatReadTimerRef.current = setTimeout(flushChatRead, wait);
}, [flushChatRead]);
useEffect(() => () => {
if (chatReadTimerRef.current) clearTimeout(chatReadTimerRef.current);
for (const timer of chatSendTimersRef.current.values()) clearTimeout(timer);
}, []);
const reconcileChat = useCallback(
(messages: ChatMessage[], from: Date, to: Date) => {
// 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
.filter((m) =>
byId.has(m.id) || m.createdAt < from || m.createdAt > to
)
.map((m) => byId.get(m.id) ?? m)
);
// 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;
const landed = mine.some((m) =>
m.body === pending.body &&
Math.abs(m.createdAt.getTime() - pending.createdAt.getTime()) <
CHAT_ECHO_WINDOW
);
if (landed) retirePendingChat(pending.nonce);
}
},
[retirePendingChat],
);
const setChatTyping = useCallback((typing: boolean) => {
if (socketRef.current?.readyState === WebSocket.OPEN) {
socketRef.current.send(
JSON.stringify(
{ type: "chat_typing", typing } satisfies ClientToServerMessage,
),
);
}
}, []);
const sendChatFocus = useCallback(() => {
if (socketRef.current?.readyState === WebSocket.OPEN) {
socketRef.current.send(
JSON.stringify(
{
type: "chat_focus",
open: isViewingChat(),
} satisfies ClientToServerMessage,
),
);
}
}, [isViewingChat]);
const setChatOpen = useCallback((open: boolean) => {
chatOpenRef.current = open;
sendChatFocus();
}, [sendChatFocus]);
// Hiding the tab stops the user reading an open chat, and showing it again
// resumes.
useEffect(() => {
const onVisibility = () => {
if (chatOpenRef.current) sendChatFocus();
};
document.addEventListener("visibilitychange", onVisibility);
return () => document.removeEventListener("visibilitychange", onVisibility);
}, [sendChatFocus]);
useEffect(() => {
setTitleBadge(unreadNotificationCount + unreadChatCount);
}, [unreadNotificationCount, unreadChatCount]);
const value: WSContextValue = useMemo(() => ({
wsStatus,
wsErrorMessage,
voteCounts,
myVotes,
likeCounts,
myLikes,
recentDumps,
deletedDumpIds,
lastVoteEvent,
lastLikeEvent,
lastDumpEvent,
lastPlaylistEvent,
deletedPlaylistIds,
lastCommentEvent,
lastUserEvent,
unreadNotificationCount,
lastNotification,
connectionEpoch,
castVote,
removeVote,
castCommentLike,
removeCommentLike,
injectDump,
clearUnreadNotifications,
}), [
wsStatus,
wsErrorMessage,
voteCounts,
myVotes,
likeCounts,
myLikes,
recentDumps,
deletedDumpIds,
lastVoteEvent,
lastLikeEvent,
lastDumpEvent,
lastPlaylistEvent,
deletedPlaylistIds,
lastCommentEvent,
lastUserEvent,
unreadNotificationCount,
lastNotification,
connectionEpoch,
castVote,
removeVote,
castCommentLike,
removeCommentLike,
injectDump,
clearUnreadNotifications,
]);
const chatValue: WSChatContextValue = useMemo(() => ({
chatMessages,
deletedChatIds,
unreadChatCount,
chatReadAt,
pendingChatMessages,
chatError,
typingUsers,
sendChatMessage,
retryChatMessage,
discardChatMessage,
editChatMessage,
deleteChatMessage,
dismissChatError,
markChatRead,
reconcileChat,
setChatTyping,
setChatOpen,
}), [
chatMessages,
deletedChatIds,
unreadChatCount,
chatReadAt,
pendingChatMessages,
chatError,
typingUsers,
sendChatMessage,
retryChatMessage,
discardChatMessage,
editChatMessage,
deleteChatMessage,
dismissChatError,
markChatRead,
reconcileChat,
setChatTyping,
setChatOpen,
]);
const presenceValue: WSPresenceContextValue = useMemo(
() => ({ onlineUsers }),
[onlineUsers],
);
return (
<WSContext.Provider value={value}>
<WSChatContext.Provider value={chatValue}>
<WSPresenceContext.Provider value={presenceValue}>
<AvatarOverrideContext.Provider value={avatarOverrides}>
{children}
</AvatarOverrideContext.Provider>
</WSPresenceContext.Provider>
</WSChatContext.Provider>
</WSContext.Provider>
);
}