vibe coded v1
This commit is contained in:
200
src/contexts/WSProvider.tsx
Normal file
200
src/contexts/WSProvider.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { WSContext, type WSContextValue } from "./WSContext.ts";
|
||||
import { WS_URL } from "../config/api.ts";
|
||||
import type { Dump, OnlineUser } from "../model.ts";
|
||||
|
||||
interface WSProviderProps {
|
||||
children: ReactNode;
|
||||
token: string | null;
|
||||
}
|
||||
|
||||
const MAX_BACKOFF = 30_000;
|
||||
const ACK_TIMEOUT = 5_000;
|
||||
|
||||
export function WSProvider({ children, token }: WSProviderProps) {
|
||||
const [onlineUsers, setOnlineUsers] = useState<OnlineUser[]>([]);
|
||||
const [voteCounts, setVoteCounts] = useState<Record<string, number>>({});
|
||||
const [myVotes, setMyVotes] = useState<Set<string>>(new Set());
|
||||
const [recentDumps, setRecentDumps] = useState<Dump[]>([]);
|
||||
const [deletedDumpIds, setDeletedDumpIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// Refs to avoid stale closures in event handlers
|
||||
const voteCountsRef = useRef(voteCounts);
|
||||
const myVotesRef = useRef(myVotes);
|
||||
voteCountsRef.current = voteCounts;
|
||||
myVotesRef.current = myVotes;
|
||||
|
||||
const socketRef = useRef<WebSocket | null>(null);
|
||||
// Tracks pending optimistic votes: dumpId → revert timeout ID
|
||||
const pendingRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
let closed = false;
|
||||
let backoff = 500;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function connect() {
|
||||
if (closed) return;
|
||||
|
||||
const url = `${WS_URL}/ws${token ? `?token=${encodeURIComponent(token)}` : ""}`;
|
||||
const ws = new WebSocket(url);
|
||||
socketRef.current = ws;
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
let msg: Record<string, unknown>;
|
||||
try {
|
||||
msg = JSON.parse(event.data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (msg.type) {
|
||||
case "ping":
|
||||
ws.send(JSON.stringify({ type: "pong" }));
|
||||
break;
|
||||
|
||||
case "welcome": {
|
||||
backoff = 500; // reset backoff on successful connect
|
||||
const users = msg.users as OnlineUser[];
|
||||
const votes = msg.myVotes as string[];
|
||||
setOnlineUsers(users);
|
||||
setMyVotes(new Set(votes));
|
||||
break;
|
||||
}
|
||||
|
||||
case "presence_update":
|
||||
setOnlineUsers(msg.users as OnlineUser[]);
|
||||
break;
|
||||
|
||||
case "votes_update": {
|
||||
const { dumpId, voteCount } = msg as { dumpId: string; voteCount: number };
|
||||
setVoteCounts((prev) => ({ ...prev, [dumpId]: voteCount }));
|
||||
break;
|
||||
}
|
||||
|
||||
case "dump_created": {
|
||||
const dump = msg.dump as Dump;
|
||||
setRecentDumps((prev) => [dump, ...prev]);
|
||||
break;
|
||||
}
|
||||
|
||||
case "dump_deleted": {
|
||||
const dumpId = msg.dumpId as string;
|
||||
setDeletedDumpIds((prev) => new Set([...prev, dumpId]));
|
||||
setRecentDumps((prev) => prev.filter((d) => d.id !== dumpId));
|
||||
break;
|
||||
}
|
||||
|
||||
case "vote_ack": {
|
||||
const { dumpId, action, voteCount } = msg as {
|
||||
dumpId: string;
|
||||
action: "cast" | "remove";
|
||||
voteCount: number;
|
||||
};
|
||||
// Clear pending revert timeout
|
||||
const timeout = pendingRef.current.get(dumpId);
|
||||
if (timeout !== undefined) {
|
||||
clearTimeout(timeout);
|
||||
pendingRef.current.delete(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 "error":
|
||||
// On error, revert any pending optimistic update for the affected dump
|
||||
// (the revert timeout will handle it)
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (closed) return;
|
||||
reconnectTimer = setTimeout(() => {
|
||||
backoff = Math.min(backoff * 2, MAX_BACKOFF);
|
||||
connect();
|
||||
}, backoff);
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
// onclose will fire after onerror
|
||||
};
|
||||
}
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
closed = true;
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
socketRef.current?.close();
|
||||
socketRef.current = null;
|
||||
// Clear all pending revert timeouts
|
||||
for (const t of pendingRef.current.values()) clearTimeout(t);
|
||||
pendingRef.current.clear();
|
||||
};
|
||||
}, [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 ack
|
||||
const timeout = setTimeout(() => {
|
||||
pendingRef.current.delete(dumpId);
|
||||
setMyVotes((prev) => { const n = new Set(prev); n.delete(dumpId); return n; });
|
||||
setVoteCounts((prev) => ({ ...prev, [dumpId]: prevCount }));
|
||||
}, ACK_TIMEOUT);
|
||||
pendingRef.current.set(dumpId, timeout);
|
||||
|
||||
socketRef.current?.send(JSON.stringify({ type: "vote_cast", dumpId }));
|
||||
}, []);
|
||||
|
||||
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 ack
|
||||
const timeout = setTimeout(() => {
|
||||
pendingRef.current.delete(dumpId);
|
||||
setMyVotes((prev) => { const n = new Set(prev); n.add(dumpId); return n; });
|
||||
setVoteCounts((prev) => ({ ...prev, [dumpId]: prevCount }));
|
||||
}, ACK_TIMEOUT);
|
||||
pendingRef.current.set(dumpId, timeout);
|
||||
|
||||
socketRef.current?.send(JSON.stringify({ type: "vote_remove", dumpId }));
|
||||
}, []);
|
||||
|
||||
const value: WSContextValue = {
|
||||
onlineUsers,
|
||||
voteCounts,
|
||||
myVotes,
|
||||
recentDumps,
|
||||
deletedDumpIds,
|
||||
castVote,
|
||||
removeVote,
|
||||
};
|
||||
|
||||
return (
|
||||
<WSContext.Provider value={value}>
|
||||
{children}
|
||||
</WSContext.Provider>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user