import { type ReactNode, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { type CommentEvent, type LikeEvent, type PlaylistEvent, type UserEvent, type VoteEvent, WSContext, type WSContextValue, } from "./WSContext.ts"; import { WS_URL } from "../config/api.ts"; import type { Dump, IncomingWSMessage, Notification, OnlineUser, OutgoingWSMessage, } from "../model.ts"; import { deserializeComment, deserializeDump, deserializeNotification, deserializePlaylist, deserializePublicUser, } from "../model.ts"; import { t } from "@lingui/core/macro"; import { useAuth } from "../hooks/useAuth.ts"; interface WSProviderProps { children: ReactNode; } const MAX_BACKOFF = 30_000; const ACK_TIMEOUT = 5_000; const CONNECT_TIMEOUT = 2_500; interface PendingVote { timeout: ReturnType; rollback: () => void; } interface PendingLike { timeout: ReturnType; rollback: () => void; } // Minimal runtime check: verify the `type` field is a known string so we can // safely cast to the discriminated union and let TypeScript narrow from there. function parseWSMessage(data: string): IncomingWSMessage | null { try { const msg = JSON.parse(data); if (!msg || typeof msg !== "object" || typeof msg.type !== "string") { return null; } return msg as IncomingWSMessage; } catch { return null; } } export function WSProvider({ children }: WSProviderProps) { const { token, user, logout } = useAuth(); const userId = user?.id ?? null; const [wsStatus, setWSStatus] = useState< "connecting" | "connected" | "disconnected" >("connecting"); const [wsErrorMessage, setWSErrorMessage] = useState(null); // Reset status to "connecting" during render when token changes, rather than // inside the effect (which would cause a cascading re-render). const [prevToken, setPrevToken] = useState(token); if (prevToken !== token) { setPrevToken(token); setWSStatus("connecting"); setWSErrorMessage(null); } 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, ); // Refs to avoid stale closures in event handlers const voteCountsRef = useRef(voteCounts); const myVotesRef = useRef(myVotes); const likeCountsRef = useRef(likeCounts); const myLikesRef = useRef(myLikes); const userIdRef = useRef(userId); // Stable ref for logout so the effect doesn't reconnect when the function // reference changes on re-renders. const onForceLogoutRef = useRef(logout); useLayoutEffect(() => { voteCountsRef.current = voteCounts; myVotesRef.current = myVotes; likeCountsRef.current = likeCounts; myLikesRef.current = myLikes; userIdRef.current = userId; onForceLogoutRef.current = logout; }); const socketRef = useRef(null); // Tracks pending optimistic votes: dumpId → pending rollback handler const pendingRef = useRef>( new Map(), ); // Tracks pending optimistic comment likes: commentId → pending rollback handler 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; function connect() { if (closed) return; const url = `${WS_URL}/ws${ token ? `?token=${encodeURIComponent(token)}` : "" }`; const ws = new WebSocket(url); socketRef.current = ws; 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": ws.send( JSON.stringify({ type: "pong" } satisfies OutgoingWSMessage), ); break; case "welcome": backoff = 500; // reset backoff on successful connect setOnlineUsers(msg.users); setMyVotes(new Set(msg.myVotes)); setMyLikes(new Set(msg.myCommentLikes)); setUnreadNotificationCount(msg.unreadNotificationCount); // welcome provides authoritative server state — cancel any // in-flight revert timers, they are now superseded. clearAllPendingVotes(); clearAllPendingLikes(); 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 }); // Keep myVotes in sync across tabs: if this vote event belongs to // the current user (from another tab), update myVotes accordingly. 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 }); // Keep myLikes in sync across tabs: if this like event belongs to // the current user (from another tab), update myLikes accordingly. 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": { const dump = deserializeDump(msg.dump); setRecentDumps((prev) => [dump, ...prev]); break; } case "dump_updated": { const dump = deserializeDump(msg.dump); setLastDumpEvent(dump); // Un-delete if this dump was previously removed from the feed // (e.g. it was made private, and is now public again). setDeletedDumpIds((prev) => { if (!prev.has(dump.id)) return prev; const next = new Set(prev); next.delete(dump.id); return next; }); // Add to live feed if not already present (private→public). setRecentDumps((prev) => prev.some((d) => d.id === dump.id) ? prev : [dump, ...prev] ); break; } case "dump_deleted": { 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); // Reconcile with authoritative count setVoteCounts((prev) => ({ ...prev, [dumpId]: voteCount })); // Confirm vote state setMyVotes((prev) => { const next = new Set(prev); if (action === "cast") next.add(dumpId); else next.delete(dumpId); return next; }); break; } case "comment_like_ack": { const { commentId, action, likeCount } = msg; clearPendingLike(commentId); // Reconcile with authoritative count setLikeCounts((prev) => ({ ...prev, [commentId]: likeCount })); // Confirm like state setMyLikes((prev) => { const next = new Set(prev); if (action === "cast") next.add(commentId); 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 } = msg; setLastPlaylistEvent({ type: "dumps_updated", playlistId, dumpIds, }); break; } case "user_updated": { const user = deserializePublicUser(msg.user); setLastUserEvent({ user }); 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); break; } case "force_logout": onForceLogoutRef.current(); break; case "error": // Vote errors currently don't identify which dump/action failed, so // fall back to the per-dump timeout rollback instead of guessing. break; } }; ws.onclose = () => { if (connectTimeout) { clearTimeout(connectTimeout); connectTimeout = null; } if (closed) return; 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 will fire after onerror }; } 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, token, ]); const castVote = useCallback((dumpId: string) => { // Optimistic update const prevCount = voteCountsRef.current[dumpId] ?? 0; const prevVoted = myVotesRef.current.has(dumpId); if (prevVoted) return; // already voted setMyVotes((prev) => { const n = new Set(prev); n.add(dumpId); return n; }); setVoteCounts((prev) => ({ ...prev, [dumpId]: prevCount + 1 })); // Schedule revert if no authoritative confirmation arrives. 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 OutgoingWSMessage, ), ); } // If socket is not OPEN, the revert timer will handle cleanup after ACK_TIMEOUT }, [schedulePendingVote]); const removeVote = useCallback((dumpId: string) => { // Optimistic update const prevCount = voteCountsRef.current[dumpId] ?? 0; const prevVoted = myVotesRef.current.has(dumpId); if (!prevVoted) return; // not voted setMyVotes((prev) => { const n = new Set(prev); n.delete(dumpId); return n; }); setVoteCounts((prev) => ({ ...prev, [dumpId]: Math.max(0, prevCount - 1), })); // Schedule revert if no authoritative confirmation arrives. 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 OutgoingWSMessage, ), ); } // If socket is not OPEN, the revert timer will handle cleanup after ACK_TIMEOUT }, [schedulePendingVote]); const castCommentLike = useCallback((commentId: string) => { // Optimistic update const prevCount = likeCountsRef.current[commentId] ?? 0; const prevLiked = myLikesRef.current.has(commentId); if (prevLiked) return; // already liked setMyLikes((prev) => { const n = new Set(prev); n.add(commentId); return n; }); setLikeCounts((prev) => ({ ...prev, [commentId]: prevCount + 1 })); // Schedule revert if no authoritative confirmation arrives. 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 OutgoingWSMessage, ), ); } // If socket is not OPEN, the revert timer will handle cleanup after ACK_TIMEOUT }, [schedulePendingLike]); const removeCommentLike = useCallback((commentId: string) => { // Optimistic update const prevCount = likeCountsRef.current[commentId] ?? 0; const prevLiked = myLikesRef.current.has(commentId); if (!prevLiked) return; // not liked setMyLikes((prev) => { const n = new Set(prev); n.delete(commentId); return n; }); setLikeCounts((prev) => ({ ...prev, [commentId]: Math.max(0, prevCount - 1), })); // Schedule revert if no authoritative confirmation arrives. 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 OutgoingWSMessage, ), ); } // If socket is not OPEN, the revert timer will handle cleanup after ACK_TIMEOUT }, [schedulePendingLike]); const injectDump = useCallback((dump: Dump) => { setRecentDumps((prev) => { if (prev.some((d) => d.id === dump.id)) return prev; return [dump, ...prev]; }); }, []); const clearUnreadNotifications = useCallback(() => { setUnreadNotificationCount(0); }, []); const value: WSContextValue = useMemo(() => ({ wsStatus, wsErrorMessage, onlineUsers, voteCounts, myVotes, likeCounts, myLikes, recentDumps, deletedDumpIds, lastVoteEvent, lastLikeEvent, lastDumpEvent, lastPlaylistEvent, deletedPlaylistIds, lastCommentEvent, lastUserEvent, unreadNotificationCount, lastNotification, castVote, removeVote, castCommentLike, removeCommentLike, injectDump, clearUnreadNotifications, }), [ wsStatus, wsErrorMessage, onlineUsers, voteCounts, myVotes, likeCounts, myLikes, recentDumps, deletedDumpIds, lastVoteEvent, lastLikeEvent, lastDumpEvent, lastPlaylistEvent, deletedPlaylistIds, lastCommentEvent, lastUserEvent, unreadNotificationCount, lastNotification, castVote, removeVote, castCommentLike, removeCommentLike, injectDump, clearUnreadNotifications, ]); return ( {children} ); }