v3: code quality pass, various bug fixes
This commit is contained in:
@@ -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}>
|
||||
|
||||
Reference in New Issue
Block a user