Files
gerbeur/src/contexts/ws/WSProvider.tsx
khannurien 134d1a0b97 v4: a moderator's or an admin's @everyone in the chat notifies every user — there was no way to reach everyone at once. The new chat:everyone permission, held by moderators and admins, lets a chat message saying @everyone notify every user but its author and those reading the chat, through notifyEveryone, after notifyMentions, so a user also named is told once, by name; the notification is a user_mentioned with everyone set, which the notifications page and the digest word as "mentioned everyone in the chat". chat_messages.mentions_everyone, sent as ChatMessage.mentionsEveryone, is set from the author's role each time the body is written, so a user's @everyone reaches no one, a moderator's edit of a user's message can't make it reach everyone, and a demoted author's next edit loses it. Only an edit that adds @everyone notifies, so fixing an announcement's typo reaches no one new. The client highlights and chimes by the flag, never by the text; Markdown shows @everyone as a mention only in a flagged message, and never links it to a profile. The chat composer offers @everyone first to those who may say it. No account may be named everyone, in any case: migration 0023 adds the column, flags the moderators' and admins' past @everyone messages, renames a user named everyone to everyone_N and drops an alias everyone. 265 backend tests and 681 frontend tests pass, and deno check, tsc, eslint, doc:lint and i18n:compile --strict are clean; checked in screenshots of the chat at 1280 and 390px, with a moderator's and a user's @everyone and the suggestions open.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:54:09 +00:00

1226 lines
39 KiB
TypeScript

/**
* Brings the server's live updates into the app. It holds the one WebSocket to
* `/ws` and turns its messages into the state of `WSContext`, `WSChatContext`,
* `WSPresenceContext` and `AvatarOverrideContext`. `App` mounts it around every
* page. The protocol is `shared/ws.ts`, and the server's side is
* `api/routes/ws.ts`.
*
* @module
*/
import {
type ReactNode,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import {
type ChatError,
type ChatReplyTarget,
type CommentEvent,
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;
/**
* Lets a failed toggle restore what was shown before the first click the server
* hasn't acknowledged. {@link useOptimisticToggles} holds one for each such
* item; `count` and `on` are as they were before that click.
*/
interface PendingToggle {
timeout: ReturnType<typeof setTimeout>;
count: number;
on: boolean;
outstanding: number;
}
/**
* Returned by {@link useOptimisticToggles}, for the vote and like actions and
* the socket's handlers.
*/
interface OptimisticToggles {
/** Records a toggle already shown, from the count and state before it. */
begin: (id: string, count: number, on: boolean) => void;
/** Settles one toggle; true once none of the item's is left in flight. */
ack: (id: string) => boolean;
/** Checks whether the item has toggles in flight. */
pending: (id: string) => boolean;
/** Restores the item as it was before its toggles, now. */
fail: (id: string) => void;
/**
* Settles every toggle against the server's set after a (re)connect. Each
* count moves by one from its baseline when the server's state differs.
*/
reconcile: (serverOn: Set<string>) => void;
/** Forgets every toggle, for a new session. */
clear: () => void;
}
/**
* Tracks optimistic toggles of votes or likes, which show at once and go back
* when the server refuses them or doesn't answer within {@link ACK_TIMEOUT}.
* Several clicks on one item before any ack share one baseline, so a failure
* restores what was there before the first, and only the last ack's count is
* applied.
*/
function useOptimisticToggles(
setOn: React.Dispatch<React.SetStateAction<Set<string>>>,
setCounts: React.Dispatch<React.SetStateAction<Record<string, number>>>,
): OptimisticToggles {
const ref = useRef(new Map<string, PendingToggle>());
return useMemo(() => {
const pendingMap = ref.current;
const restore = (id: string, count: number, on: boolean) => {
setOn((prev) => {
const next = new Set(prev);
if (on) next.add(id);
else next.delete(id);
return next;
});
setCounts((prev) => ({ ...prev, [id]: count }));
};
const fail = (id: string) => {
const entry = pendingMap.get(id);
if (!entry) return;
clearTimeout(entry.timeout);
pendingMap.delete(id);
restore(id, entry.count, entry.on);
};
return {
begin(id, count, on) {
const entry = pendingMap.get(id);
if (entry) clearTimeout(entry.timeout);
// Nothing is sent while the socket is down, so the timer fails it.
pendingMap.set(id, {
count: entry?.count ?? count,
on: entry?.on ?? on,
outstanding: (entry?.outstanding ?? 0) + 1,
timeout: setTimeout(() => fail(id), ACK_TIMEOUT),
});
},
ack(id) {
const entry = pendingMap.get(id);
if (!entry) return true;
entry.outstanding -= 1;
if (entry.outstanding > 0) return false;
clearTimeout(entry.timeout);
pendingMap.delete(id);
return true;
},
pending: (id) => pendingMap.has(id),
fail,
reconcile(serverOn) {
for (const [id, entry] of pendingMap) {
clearTimeout(entry.timeout);
const on = serverOn.has(id);
const count = entry.count + (on === entry.on ? 0 : on ? 1 : -1);
setCounts((prev) => ({ ...prev, [id]: Math.max(0, count) }));
}
pendingMap.clear();
},
clear() {
for (const entry of pendingMap.values()) clearTimeout(entry.timeout);
pendingMap.clear();
},
};
}, [setOn, setCounts]);
}
/**
* 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.
*/
export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
const { token, user } = 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 [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>
>({});
// It is never reset, since pages compare it with the value they last saw.
const [connectionEpoch, setConnectionEpoch] = useState(0);
const [followsVersion, setFollowsVersion] = 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);
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);
useLayoutEffect(() => {
voteCountsRef.current = voteCounts;
myVotesRef.current = myVotes;
likeCountsRef.current = likeCounts;
myLikesRef.current = myLikes;
userIdRef.current = userId;
usernameRef.current = user?.username ?? null;
});
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 votes = useOptimisticToggles(setMyVotes, setVoteCounts);
const likes = useOptimisticToggles(setMyLikes, setLikeCounts);
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);
// Toggles sent before a reconnect may or may not have landed: the
// server's sets say which, and fix the counts shown for them.
votes.reconcile(new Set(msg.myVotes));
likes.reconcile(new Set(msg.myCommentLikes));
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(),
);
}
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;
setLastVoteEvent({ dumpId, voterId, action });
// While this tab has votes on it in flight, their acks settle it.
if (votes.pending(dumpId)) break;
setVoteCounts((prev) => ({ ...prev, [dumpId]: voteCount }));
// The user's own vote from another tab keeps this tab's button in
// step.
if (voterId === userIdRef.current) {
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 } = msg;
// As for votes.
if (likes.pending(commentId)) break;
setLikeCounts((prev) => ({ ...prev, [commentId]: likeCount }));
if (likerId === userIdRef.current) {
const { action } = msg;
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,
)
);
// The profiles put a dump back where it was when it turns public.
setLastDumpEvent(dump);
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 to others when someone's 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;
// With more clicks in flight, the last ack tells how it ended.
if (!votes.ack(dumpId)) break;
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;
if (!likes.ack(commentId)) break;
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 (
message.mentionsEveryone ||
(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 "follows_changed":
setFollowsVersion((n) => n + 1);
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 "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) votes.fail(msg.dumpId);
else if (msg.commentId) likes.fail(msg.commentId);
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();
return () => {
closed = true;
if (reconnectTimer) clearTimeout(reconnectTimer);
if (connectTimeout) clearTimeout(connectTimeout);
socketRef.current?.close();
socketRef.current = null;
votes.clear();
likes.clear();
};
}, [
votes,
likes,
failPendingChat,
isViewingChat,
retirePendingChat,
token,
]);
/**
* Shows a vote or like toggled at once, records it with `toggles`, and sends
* `type` for it. `on` is the state wanted; a click that asks for the state
* already shown does nothing.
*/
const toggle = useCallback((
toggles: OptimisticToggles,
onRef: React.RefObject<Set<string>>,
countsRef: React.RefObject<Record<string, number>>,
setOn: React.Dispatch<React.SetStateAction<Set<string>>>,
setCounts: React.Dispatch<React.SetStateAction<Record<string, number>>>,
id: string,
on: boolean,
shownCount: number | undefined,
message: ClientToServerMessage,
) => {
// Until the socket brings a count, the one on screen is the only one.
const prevCount = countsRef.current[id] ?? shownCount ?? 0;
const prevOn = onRef.current.has(id);
if (prevOn === on) return;
setOn((prev) => {
const next = new Set(prev);
if (on) next.add(id);
else next.delete(id);
return next;
});
setCounts((prev) => ({
...prev,
[id]: Math.max(0, prevCount + (on ? 1 : -1)),
}));
toggles.begin(id, prevCount, prevOn);
if (socketRef.current?.readyState === WebSocket.OPEN) {
socketRef.current.send(JSON.stringify(message));
}
}, []);
const castVote = useCallback(
(dumpId: string, shownCount?: number) =>
toggle(
votes,
myVotesRef,
voteCountsRef,
setMyVotes,
setVoteCounts,
dumpId,
true,
shownCount,
{ type: "vote_cast", dumpId },
),
[toggle, votes],
);
const removeVote = useCallback(
(dumpId: string, shownCount?: number) =>
toggle(
votes,
myVotesRef,
voteCountsRef,
setMyVotes,
setVoteCounts,
dumpId,
false,
shownCount,
{ type: "vote_remove", dumpId },
),
[toggle, votes],
);
const castCommentLike = useCallback(
(commentId: string, shownCount?: number) =>
toggle(
likes,
myLikesRef,
likeCountsRef,
setMyLikes,
setLikeCounts,
commentId,
true,
shownCount,
{ type: "comment_like_cast", commentId },
),
[toggle, likes],
);
const removeCommentLike = useCallback(
(commentId: string, shownCount?: number) =>
toggle(
likes,
myLikesRef,
likeCountsRef,
setMyLikes,
setLikeCounts,
commentId,
false,
shownCount,
{ type: "comment_like_remove", commentId },
),
[toggle, likes],
);
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,
lastDumpEvent,
lastPlaylistEvent,
deletedPlaylistIds,
lastCommentEvent,
lastUserEvent,
unreadNotificationCount,
lastNotification,
connectionEpoch,
followsVersion,
castVote,
removeVote,
castCommentLike,
removeCommentLike,
injectDump,
clearUnreadNotifications,
}), [
wsStatus,
wsErrorMessage,
voteCounts,
myVotes,
likeCounts,
myLikes,
recentDumps,
deletedDumpIds,
lastVoteEvent,
lastDumpEvent,
lastPlaylistEvent,
deletedPlaylistIds,
lastCommentEvent,
lastUserEvent,
unreadNotificationCount,
lastNotification,
connectionEpoch,
followsVersion,
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>
);
}