/** * 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; rollback: () => void; } /** An optimistic like waiting for the server. */ interface PendingLike { timeout: ReturnType; 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 | 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. 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(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 [lastLikeEvent, setLastLikeEvent] = 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 >({}); // 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(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 pendingRef = useRef>( new Map(), ); const pendingLikesRef = useRef>( 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 | 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); 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 ( {children} ); }