/** * 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; 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) => 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>>, setCounts: React.Dispatch>>, ): OptimisticToggles { const ref = useRef(new Map()); 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 | null { try { const msg = JSON.parse(data); if (!msg || typeof msg !== "object" || typeof msg.type !== "string") { return null; } return msg as Wire; } 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(null); // For the reset below, which must follow every state declaration. const [prevToken, setPrevToken] = useState(token); const [onlineUsers, setOnlineUsers] = useState([]); const [voteCounts, setVoteCounts] = useState>({}); const [myVotes, setMyVotes] = useState>(new Set()); const [likeCounts, setLikeCounts] = useState>({}); const [myLikes, setMyLikes] = useState>(new Set()); const [recentDumps, setRecentDumps] = useState([]); const [deletedDumpIds, setDeletedDumpIds] = useState>(new Set()); const [lastVoteEvent, setLastVoteEvent] = useState(null); const [lastDumpEvent, setLastDumpEvent] = useState(null); const [lastPlaylistEvent, setLastPlaylistEvent] = useState< PlaylistEvent | null >(null); const [deletedPlaylistIds, setDeletedPlaylistIds] = useState>( new Set(), ); const [lastCommentEvent, setLastCommentEvent] = useState( null, ); const [lastUserEvent, setLastUserEvent] = useState(null); const [unreadNotificationCount, setUnreadNotificationCount] = useState(0); const [lastNotification, setLastNotification] = useState( null, ); const [chatMessages, setChatMessages] = useState([]); const [deletedChatIds, setDeletedChatIds] = useState>(new Set()); const [unreadChatCount, setUnreadChatCount] = useState(0); const [chatReadAt, setChatReadAt] = useState(null); const [pendingChatMessages, setPendingChatMessages] = useState< PendingChatMessage[] >([]); const [chatError, setChatError] = useState(null); const [typingUsers, setTypingUsers] = useState([]); const [avatarOverrides, setAvatarOverrides] = useState< Record >({}); // 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(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>>( 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 | 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 | null = null; let connectTimeout: ReturnType | 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>, countsRef: React.RefObject>, setOn: React.Dispatch>>, setCounts: React.Dispatch>>, 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 ( {children} ); }