v3: code quality pass, various bug fixes

This commit is contained in:
khannurien
2026-03-23 07:47:49 +00:00
parent d94a319d96
commit fbbbb43258
44 changed files with 1060 additions and 698 deletions

View File

@@ -3,12 +3,14 @@ import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import {
type CommentEvent,
type PlaylistEvent,
type UserEvent,
type VoteEvent,
WSContext,
type WSContextValue,
@@ -22,23 +24,79 @@ import type {
RawDump,
RawNotification,
RawPlaylist,
RawPublicUser,
} from "../model.ts";
import {
deserializeComment,
deserializeDump,
deserializeNotification,
deserializePlaylist,
deserializePublicUser,
} from "../model.ts";
interface WSProviderProps {
children: ReactNode;
token: string | null;
userId: string | null;
}
const MAX_BACKOFF = 30_000;
const ACK_TIMEOUT = 5_000;
export function WSProvider({ children, token }: WSProviderProps) {
// ── Type guards for incoming WS messages ──────────────────────────────────────
function isOnlineUser(obj: unknown): obj is OnlineUser {
if (!obj || typeof obj !== "object") return false;
const o = obj as Record<string, unknown>;
return typeof o.userId === "string" &&
typeof o.username === "string" &&
typeof o.hasAvatar === "boolean";
}
function isOnlineUserArray(val: unknown): val is OnlineUser[] {
return Array.isArray(val) && val.every(isOnlineUser);
}
function isStringArray(val: unknown): val is string[] {
return Array.isArray(val) && val.every((x) => typeof x === "string");
}
function isVotesUpdatePayload(
msg: Record<string, unknown>,
): msg is { dumpId: string; voteCount: number; voterId: string; action: "cast" | "remove" } {
return typeof msg.dumpId === "string" &&
typeof msg.voteCount === "number" &&
typeof msg.voterId === "string" &&
(msg.action === "cast" || msg.action === "remove");
}
function isVoteAckPayload(
msg: Record<string, unknown>,
): msg is { dumpId: string; action: "cast" | "remove"; voteCount: number } {
return typeof msg.dumpId === "string" &&
(msg.action === "cast" || msg.action === "remove") &&
typeof msg.voteCount === "number";
}
function isPlaylistDeletedPayload(
msg: Record<string, unknown>,
): msg is { playlistId: string; userId: string } {
return typeof msg.playlistId === "string" && typeof msg.userId === "string";
}
function isPlaylistDumpsUpdatedPayload(
msg: Record<string, unknown>,
): msg is { playlistId: string; dumpIds: string[] } {
return typeof msg.playlistId === "string" && isStringArray(msg.dumpIds);
}
function isCommentDeletedPayload(
msg: Record<string, unknown>,
): msg is { commentId: string; dumpId: string } {
return typeof msg.commentId === "string" && typeof msg.dumpId === "string";
}
export function WSProvider({ children, token, userId }: WSProviderProps) {
const [onlineUsers, setOnlineUsers] = useState<OnlineUser[]>([]);
const [voteCounts, setVoteCounts] = useState<Record<string, number>>({});
const [myVotes, setMyVotes] = useState<Set<string>>(new Set());
@@ -55,6 +113,7 @@ export function WSProvider({ children, token }: WSProviderProps) {
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,
@@ -63,9 +122,11 @@ export function WSProvider({ children, token }: WSProviderProps) {
// Refs to avoid stale closures in event handlers
const voteCountsRef = useRef(voteCounts);
const myVotesRef = useRef(myVotes);
const userIdRef = useRef(userId);
useLayoutEffect(() => {
voteCountsRef.current = voteCounts;
myVotesRef.current = myVotes;
userIdRef.current = userId;
});
const socketRef = useRef<WebSocket | null>(null);
@@ -103,41 +164,48 @@ export function WSProvider({ children, token }: WSProviderProps) {
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));
if (!isOnlineUserArray(msg.users) || !isStringArray(msg.myVotes)) break;
setOnlineUsers(msg.users);
setMyVotes(new Set(msg.myVotes));
setUnreadNotificationCount(
(msg.unreadNotificationCount as number) ?? 0,
typeof msg.unreadNotificationCount === "number"
? msg.unreadNotificationCount
: 0,
);
break;
}
case "presence_update":
setOnlineUsers(msg.users as OnlineUser[]);
if (isOnlineUserArray(msg.users)) setOnlineUsers(msg.users);
break;
case "votes_update": {
const { dumpId, voteCount, voterId, action } = msg as {
dumpId: string;
voteCount: number;
voterId: string;
action: "cast" | "remove";
};
if (!isVotesUpdatePayload(msg)) break;
const { dumpId, voteCount, voterId, action } = msg;
setVoteCounts((prev) => ({ ...prev, [dumpId]: voteCount }));
if (voterId && action) {
setLastVoteEvent({ dumpId, voterId, action });
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) {
setMyVotes((prev) => {
const next = new Set(prev);
if (action === "cast") next.add(dumpId);
else next.delete(dumpId);
return next;
});
}
break;
}
case "dump_created": {
if (!msg.dump || typeof msg.dump !== "object") break;
const dump = deserializeDump(msg.dump as RawDump);
setRecentDumps((prev) => [dump, ...prev]);
break;
}
case "dump_updated": {
if (!msg.dump || typeof msg.dump !== "object") break;
const dump = deserializeDump(msg.dump as RawDump);
setLastDumpEvent(dump);
// Un-delete if this dump was previously removed from the feed
@@ -156,18 +224,16 @@ export function WSProvider({ children, token }: WSProviderProps) {
}
case "dump_deleted": {
const dumpId = msg.dumpId as string;
if (typeof msg.dumpId !== "string") break;
const dumpId = msg.dumpId;
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;
};
if (!isVoteAckPayload(msg)) break;
const { dumpId, action, voteCount } = msg;
// Clear pending revert timeout
const timeout = pendingRef.current.get(dumpId);
if (timeout !== undefined) {
@@ -188,6 +254,7 @@ export function WSProvider({ children, token }: WSProviderProps) {
case "playlist_created":
case "playlist_updated": {
if (!msg.playlist || typeof msg.playlist !== "object") break;
const playlist = deserializePlaylist(msg.playlist as RawPlaylist);
setLastPlaylistEvent({
type: msg.type === "playlist_created" ? "created" : "updated",
@@ -198,20 +265,16 @@ export function WSProvider({ children, token }: WSProviderProps) {
}
case "playlist_deleted": {
const { playlistId, userId } = msg as {
playlistId: string;
userId: string;
};
if (!isPlaylistDeletedPayload(msg)) break;
const { playlistId, userId } = msg;
setDeletedPlaylistIds((prev) => new Set([...prev, playlistId]));
setLastPlaylistEvent({ type: "deleted", playlistId, userId });
break;
}
case "playlist_dumps_updated": {
const { playlistId, dumpIds } = msg as {
playlistId: string;
dumpIds: string[];
};
if (!isPlaylistDumpsUpdatedPayload(msg)) break;
const { playlistId, dumpIds } = msg;
setLastPlaylistEvent({
type: "dumps_updated",
playlistId,
@@ -220,7 +283,15 @@ export function WSProvider({ children, token }: WSProviderProps) {
break;
}
case "user_updated": {
if (!msg.user || typeof msg.user !== "object") break;
const user = deserializePublicUser(msg.user as RawPublicUser);
setLastUserEvent({ user });
break;
}
case "comment_created": {
if (!msg.comment || typeof msg.comment !== "object") break;
const comment = deserializeComment(msg.comment as RawComment);
setLastCommentEvent({
type: "created",
@@ -231,15 +302,14 @@ export function WSProvider({ children, token }: WSProviderProps) {
}
case "comment_deleted": {
const { commentId, dumpId } = msg as {
commentId: string;
dumpId: string;
};
if (!isCommentDeletedPayload(msg)) break;
const { commentId, dumpId } = msg;
setLastCommentEvent({ type: "deleted", dumpId, commentId });
break;
}
case "comment_updated": {
if (!msg.comment || typeof msg.comment !== "object") break;
const comment = deserializeComment(msg.comment as RawComment);
setLastCommentEvent({
type: "updated",
@@ -250,6 +320,7 @@ export function WSProvider({ children, token }: WSProviderProps) {
}
case "notification_created": {
if (!msg.notification || typeof msg.notification !== "object") break;
const notification = deserializeNotification(
msg.notification as RawNotification,
);
@@ -361,7 +432,7 @@ export function WSProvider({ children, token }: WSProviderProps) {
setUnreadNotificationCount(0);
}, []);
const value: WSContextValue = {
const value: WSContextValue = useMemo(() => ({
onlineUsers,
voteCounts,
myVotes,
@@ -372,13 +443,32 @@ export function WSProvider({ children, token }: WSProviderProps) {
lastPlaylistEvent,
deletedPlaylistIds,
lastCommentEvent,
lastUserEvent,
unreadNotificationCount,
lastNotification,
castVote,
removeVote,
injectDump,
clearUnreadNotifications,
};
}), [
onlineUsers,
voteCounts,
myVotes,
recentDumps,
deletedDumpIds,
lastVoteEvent,
lastDumpEvent,
lastPlaylistEvent,
deletedPlaylistIds,
lastCommentEvent,
lastUserEvent,
unreadNotificationCount,
lastNotification,
castVote,
removeVote,
injectDump,
clearUnreadNotifications,
]);
return (
<WSContext.Provider value={value}>