v3: code quality pass
This commit is contained in:
@@ -18,13 +18,10 @@ import {
|
||||
import { WS_URL } from "../config/api.ts";
|
||||
import type {
|
||||
Dump,
|
||||
IncomingWSMessage,
|
||||
Notification,
|
||||
OnlineUser,
|
||||
RawComment,
|
||||
RawDump,
|
||||
RawNotification,
|
||||
RawPlaylist,
|
||||
RawPublicUser,
|
||||
OutgoingWSMessage,
|
||||
} from "../model.ts";
|
||||
import {
|
||||
deserializeComment,
|
||||
@@ -43,62 +40,18 @@ interface WSProviderProps {
|
||||
const MAX_BACKOFF = 30_000;
|
||||
const ACK_TIMEOUT = 5_000;
|
||||
|
||||
// ── 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";
|
||||
// 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, token, userId }: WSProviderProps) {
|
||||
@@ -155,39 +108,28 @@ export function WSProvider({ children, token, userId }: WSProviderProps) {
|
||||
socketRef.current = ws;
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
let msg: Record<string, unknown>;
|
||||
try {
|
||||
msg = JSON.parse(event.data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const msg = parseWSMessage(event.data);
|
||||
if (!msg) return;
|
||||
|
||||
switch (msg.type) {
|
||||
case "ping":
|
||||
ws.send(JSON.stringify({ type: "pong" }));
|
||||
break;
|
||||
|
||||
case "welcome": {
|
||||
backoff = 500; // reset backoff on successful connect
|
||||
if (!isOnlineUserArray(msg.users) || !isStringArray(msg.myVotes)) {
|
||||
break;
|
||||
}
|
||||
setOnlineUsers(msg.users);
|
||||
setMyVotes(new Set(msg.myVotes));
|
||||
setUnreadNotificationCount(
|
||||
typeof msg.unreadNotificationCount === "number"
|
||||
? msg.unreadNotificationCount
|
||||
: 0,
|
||||
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));
|
||||
setUnreadNotificationCount(msg.unreadNotificationCount);
|
||||
break;
|
||||
|
||||
case "presence_update":
|
||||
if (isOnlineUserArray(msg.users)) setOnlineUsers(msg.users);
|
||||
setOnlineUsers(msg.users);
|
||||
break;
|
||||
|
||||
case "votes_update": {
|
||||
if (!isVotesUpdatePayload(msg)) break;
|
||||
const { dumpId, voteCount, voterId, action } = msg;
|
||||
setVoteCounts((prev) => ({ ...prev, [dumpId]: voteCount }));
|
||||
setLastVoteEvent({ dumpId, voterId, action });
|
||||
@@ -205,15 +147,13 @@ export function WSProvider({ children, token, userId }: WSProviderProps) {
|
||||
}
|
||||
|
||||
case "dump_created": {
|
||||
if (!msg.dump || typeof msg.dump !== "object") break;
|
||||
const dump = deserializeDump(msg.dump as RawDump);
|
||||
const dump = deserializeDump(msg.dump);
|
||||
setRecentDumps((prev) => [dump, ...prev]);
|
||||
break;
|
||||
}
|
||||
|
||||
case "dump_updated": {
|
||||
if (!msg.dump || typeof msg.dump !== "object") break;
|
||||
const dump = deserializeDump(msg.dump as RawDump);
|
||||
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).
|
||||
@@ -231,15 +171,13 @@ export function WSProvider({ children, token, userId }: WSProviderProps) {
|
||||
}
|
||||
|
||||
case "dump_deleted": {
|
||||
if (typeof msg.dumpId !== "string") break;
|
||||
const dumpId = msg.dumpId;
|
||||
const { dumpId } = msg;
|
||||
setDeletedDumpIds((prev) => new Set([...prev, dumpId]));
|
||||
setRecentDumps((prev) => prev.filter((d) => d.id !== dumpId));
|
||||
break;
|
||||
}
|
||||
|
||||
case "vote_ack": {
|
||||
if (!isVoteAckPayload(msg)) break;
|
||||
const { dumpId, action, voteCount } = msg;
|
||||
// Clear pending revert timeout
|
||||
const timeout = pendingRef.current.get(dumpId);
|
||||
@@ -261,8 +199,7 @@ export function WSProvider({ children, token, userId }: WSProviderProps) {
|
||||
|
||||
case "playlist_created":
|
||||
case "playlist_updated": {
|
||||
if (!msg.playlist || typeof msg.playlist !== "object") break;
|
||||
const playlist = deserializePlaylist(msg.playlist as RawPlaylist);
|
||||
const playlist = deserializePlaylist(msg.playlist);
|
||||
setLastPlaylistEvent({
|
||||
type: msg.type === "playlist_created" ? "created" : "updated",
|
||||
playlistId: playlist.id,
|
||||
@@ -272,7 +209,6 @@ export function WSProvider({ children, token, userId }: WSProviderProps) {
|
||||
}
|
||||
|
||||
case "playlist_deleted": {
|
||||
if (!isPlaylistDeletedPayload(msg)) break;
|
||||
const { playlistId, userId } = msg;
|
||||
setDeletedPlaylistIds((prev) => new Set([...prev, playlistId]));
|
||||
setLastPlaylistEvent({ type: "deleted", playlistId, userId });
|
||||
@@ -280,7 +216,6 @@ export function WSProvider({ children, token, userId }: WSProviderProps) {
|
||||
}
|
||||
|
||||
case "playlist_dumps_updated": {
|
||||
if (!isPlaylistDumpsUpdatedPayload(msg)) break;
|
||||
const { playlistId, dumpIds } = msg;
|
||||
setLastPlaylistEvent({
|
||||
type: "dumps_updated",
|
||||
@@ -291,15 +226,13 @@ export function WSProvider({ children, token, userId }: WSProviderProps) {
|
||||
}
|
||||
|
||||
case "user_updated": {
|
||||
if (!msg.user || typeof msg.user !== "object") break;
|
||||
const user = deserializePublicUser(msg.user as RawPublicUser);
|
||||
const user = deserializePublicUser(msg.user);
|
||||
setLastUserEvent({ user });
|
||||
break;
|
||||
}
|
||||
|
||||
case "comment_created": {
|
||||
if (!msg.comment || typeof msg.comment !== "object") break;
|
||||
const comment = deserializeComment(msg.comment as RawComment);
|
||||
const comment = deserializeComment(msg.comment);
|
||||
setLastCommentEvent({
|
||||
type: "created",
|
||||
dumpId: comment.dumpId,
|
||||
@@ -309,15 +242,13 @@ export function WSProvider({ children, token, userId }: WSProviderProps) {
|
||||
}
|
||||
|
||||
case "comment_deleted": {
|
||||
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);
|
||||
const comment = deserializeComment(msg.comment);
|
||||
setLastCommentEvent({
|
||||
type: "updated",
|
||||
dumpId: comment.dumpId,
|
||||
@@ -327,12 +258,7 @@ export function WSProvider({ children, token, userId }: WSProviderProps) {
|
||||
}
|
||||
|
||||
case "notification_created": {
|
||||
if (!msg.notification || typeof msg.notification !== "object") {
|
||||
break;
|
||||
}
|
||||
const notification = deserializeNotification(
|
||||
msg.notification as RawNotification,
|
||||
);
|
||||
const notification = deserializeNotification(msg.notification);
|
||||
setLastNotification(notification);
|
||||
setUnreadNotificationCount((prev) => prev + 1);
|
||||
break;
|
||||
@@ -396,7 +322,9 @@ export function WSProvider({ children, token, userId }: WSProviderProps) {
|
||||
}, ACK_TIMEOUT);
|
||||
pendingRef.current.set(dumpId, timeout);
|
||||
|
||||
socketRef.current?.send(JSON.stringify({ type: "vote_cast", dumpId }));
|
||||
socketRef.current?.send(
|
||||
JSON.stringify({ type: "vote_cast", dumpId } satisfies OutgoingWSMessage),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const removeVote = useCallback((dumpId: string) => {
|
||||
@@ -427,7 +355,11 @@ export function WSProvider({ children, token, userId }: WSProviderProps) {
|
||||
}, ACK_TIMEOUT);
|
||||
pendingRef.current.set(dumpId, timeout);
|
||||
|
||||
socketRef.current?.send(JSON.stringify({ type: "vote_remove", dumpId }));
|
||||
socketRef.current?.send(
|
||||
JSON.stringify(
|
||||
{ type: "vote_remove", dumpId } satisfies OutgoingWSMessage,
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const injectDump = useCallback((dump: Dump) => {
|
||||
|
||||
Reference in New Issue
Block a user