import { Router } from "@oak/oak"; import { ALLOWED_ORIGINS } from "../config.ts"; import { verifyJWT } from "../lib/jwt.ts"; import { broadcastChatMessage, broadcastChatMessageDeleted, broadcastChatMessageUpdated, broadcastCommentLikeUpdate, broadcastPresence, broadcastTyping, broadcastVoteUpdate, getOnlineUsers, handleClientPong, isUserChatOpen, register, setClientTyping, unregister, type WsClient, } from "../services/ws-service.ts"; import { createChatMessage, deleteChatMessage, updateChatMessage, } from "../services/chat-service.ts"; import { notifyMentions } from "../services/notification-service.ts"; import { hasPermission } from "../lib/permissions.ts"; import { castVote, getUserVotes, removeVote, } from "../services/vote-service.ts"; import { castCommentLike, getUserCommentLikes, removeCommentLike, } from "../services/comment-like-service.ts"; import { getUnreadCount } from "../services/notification-service.ts"; import { getUserById } from "../services/user-service.ts"; import { APIException, type ClientToServerMessage, } from "../model/interfaces.ts"; const router = new Router(); function isAllowedOrigin(origin: string): boolean { return ALLOWED_ORIGINS.includes(origin); } router.get("/ws", async (ctx) => { const origin = ctx.request.headers.get("origin") ?? ""; if (!isAllowedOrigin(origin)) { ctx.response.status = 403; return; } if (!ctx.isUpgradable) { ctx.response.status = 426; return; } const token = ctx.request.url.searchParams.get("token"); const authPayload = token ? await verifyJWT(token) : null; const socket = ctx.upgrade(); const avatarMime = authPayload ? getUserById(authPayload.userId).avatarMime : undefined; const client: WsClient = { socket, userId: authPayload?.userId, username: authPayload?.username, role: authPayload?.role, avatarMime, }; // Use addEventListener — more reliable than onopen= with Deno.serve socket.addEventListener("open", () => { register(client); broadcastPresence(); try { const myVotes = authPayload ? getUserVotes(authPayload.userId) : []; const myCommentLikes = authPayload ? getUserCommentLikes(authPayload.userId) : []; const unreadNotificationCount = authPayload ? getUnreadCount(authPayload.userId) : 0; socket.send(JSON.stringify({ type: "welcome", users: getOnlineUsers(), myVotes, myCommentLikes, unreadNotificationCount, })); } catch (err) { console.error("[ws] welcome send failed:", err); } }); socket.addEventListener("message", (event) => { let msg: ClientToServerMessage; try { msg = JSON.parse(event.data as string); } catch { return; } switch (msg.type) { case "ping": socket.send(JSON.stringify({ type: "pong" })); break; case "pong": handleClientPong(client); break; case "vote_cast": handleVote(client, msg.dumpId, "cast"); break; case "vote_remove": handleVote(client, msg.dumpId, "remove"); break; case "comment_like_cast": handleCommentLike(client, msg.commentId, "cast"); break; case "comment_like_remove": handleCommentLike(client, msg.commentId, "remove"); break; case "chat_send": handleChatSend(client, msg.body, msg.replyToId); break; case "chat_typing": if (client.userId) setClientTyping(client, msg.typing); break; case "chat_focus": client.chatOpen = msg.open; break; case "chat_edit": handleChatEdit(client, msg.id, msg.body); break; case "chat_delete": handleChatDelete(client, msg.id); break; } }); socket.addEventListener("close", () => { unregister(client); broadcastPresence(); // If they were mid-compose, drop them from the typing set right away. if (client.typingAt) broadcastTyping(); }); }); function handleVote( client: WsClient, dumpId: string, action: "cast" | "remove", ): void { const { socket } = client; if (!client.userId) { socket.send( JSON.stringify({ type: "error", message: "Authentication required" }), ); return; } try { const newCount = action === "cast" ? castVote(dumpId, client.userId) : removeVote(dumpId, client.userId); socket.send(JSON.stringify({ type: "vote_ack", dumpId, action, success: true, voteCount: newCount, })); broadcastVoteUpdate(dumpId, newCount, client.userId, action); } catch (err) { const message = err instanceof APIException ? err.message : "Vote failed"; socket.send(JSON.stringify({ type: "error", message, dumpId, action })); } } function handleCommentLike( client: WsClient, commentId: string, action: "cast" | "remove", ): void { const { socket } = client; if (!client.userId) { socket.send( JSON.stringify({ type: "error", message: "Authentication required" }), ); return; } try { const newCount = action === "cast" ? castCommentLike(commentId, client.userId) : removeCommentLike(commentId, client.userId); socket.send(JSON.stringify({ type: "comment_like_ack", commentId, action, likeCount: newCount, })); broadcastCommentLikeUpdate(commentId, newCount, client.userId, action); } catch (err) { const message = err instanceof APIException ? err.message : "Like failed"; socket.send(JSON.stringify({ type: "error", message, commentId, action })); } } function handleChatSend( client: WsClient, body: string, replyToId?: string, ): void { const { socket } = client; if (!client.userId) { socket.send( JSON.stringify({ type: "error", message: "Authentication required" }), ); return; } try { const message = createChatMessage(client.userId, body, replyToId); // Sending a message ends composition — clear the typing flag before the // message broadcast so the indicator doesn't linger next to it. setClientTyping(client, false); // Broadcast to everyone including the sender, so the sender's own message // simply arrives over the socket — no optimistic-send machinery needed. broadcastChatMessage(message); // Skip mention notifications for users who already have the chat open — // they've seen the message live. notifyMentions( client.userId, message.body, "chat", message.id, "", undefined, isUserChatOpen, ); } catch (err) { const message = err instanceof APIException ? err.message : "Failed to send message"; socket.send(JSON.stringify({ type: "error", message })); } } function canModerateChat(client: WsClient): boolean { return !!client.role && hasPermission(client.role, "chat:moderate"); } function handleChatEdit(client: WsClient, id: string, body: string): void { const { socket } = client; if (!client.userId) { socket.send( JSON.stringify({ type: "error", message: "Authentication required" }), ); return; } try { const message = updateChatMessage( id, body, client.userId, canModerateChat(client), ); broadcastChatMessageUpdated(message); // A newly added @mention in an edit should still notify (deduped per // message+user), skipping anyone already reading the chat. notifyMentions( client.userId, message.body, "chat", message.id, "", undefined, isUserChatOpen, ); } catch (err) { const message = err instanceof APIException ? err.message : "Failed to edit message"; socket.send(JSON.stringify({ type: "error", message })); } } function handleChatDelete(client: WsClient, id: string): void { const { socket } = client; if (!client.userId) { socket.send( JSON.stringify({ type: "error", message: "Authentication required" }), ); return; } try { deleteChatMessage(id, canModerateChat(client)); broadcastChatMessageDeleted(id); } catch (err) { const message = err instanceof APIException ? err.message : "Failed to delete message"; socket.send(JSON.stringify({ type: "error", message })); } } export default router;