import type { Comment, Dump, OnlineUser, Playlist, ServerToClientMessage, User, } from "../model/interfaces.ts"; export interface WsClient { socket: WebSocket; userId?: string; username?: string; avatarMime?: string; avatarVersion?: number; pongReceived?: boolean; } const clients = new Set(); export function register(client: WsClient): void { clients.add(client); } export function unregister(client: WsClient): void { clients.delete(client); } export function updateClientAvatar(userId: string, avatarMime: string): void { const version = Date.now(); for (const client of clients) { if (client.userId === userId) { client.avatarMime = avatarMime; client.avatarVersion = version; } } broadcastPresence(); } export function getOnlineUsers(): OnlineUser[] { const seen = new Map(); for (const client of clients) { if (client.userId && !seen.has(client.userId)) { seen.set(client.userId, { userId: client.userId, username: client.username!, hasAvatar: !!client.avatarMime, avatarVersion: client.avatarVersion, }); } } return Array.from(seen.values()); } function send(socket: WebSocket, data: ServerToClientMessage): void { if (socket.readyState === WebSocket.OPEN) { socket.send(JSON.stringify(data)); } } export function sendToUser(userId: string, data: ServerToClientMessage): void { for (const client of clients) { if (client.userId === userId) { send(client.socket, data); } } } export function disconnectUser(userId: string): void { for (const client of clients) { if (client.userId === userId) { send(client.socket, { type: "force_logout" }); client.socket.close(1000, "Account deleted"); } } } export function broadcastPresence(): void { const users = getOnlineUsers(); for (const client of clients) { send(client.socket, { type: "presence_update", users }); } } export function broadcastNewDump(dump: Dump): void { for (const client of clients) { send(client.socket, { type: "dump_created", dump }); } } export function broadcastDumpUpdated(dump: Dump): void { for (const client of clients) { send(client.socket, { type: "dump_updated", dump }); } } export function broadcastDumpDeleted(dumpId: string): void { for (const client of clients) { send(client.socket, { type: "dump_deleted", dumpId }); } } export function broadcastVoteUpdate( dumpId: string, voteCount: number, voterId: string, action: "cast" | "remove", ): void { for (const client of clients) { send(client.socket, { type: "votes_update", dumpId, voteCount, voterId, action, }); } } function sendToPlaylistAudience( playlist: Pick, data: ServerToClientMessage, ): void { for (const client of clients) { if (playlist.isPublic || client.userId === playlist.userId) { send(client.socket, data); } } } export function broadcastPlaylistCreated(playlist: Playlist): void { sendToPlaylistAudience(playlist, { type: "playlist_created", playlist }); } export function broadcastPlaylistUpdated(playlist: Playlist): void { // Broadcast to ALL clients so non-owners can react to visibility changes // (e.g. remove a now-private playlist from their feed). for (const client of clients) { send(client.socket, { type: "playlist_updated", playlist }); } } export function broadcastPlaylistDeleted( playlistId: string, userId: string, isPublic: boolean, ): void { sendToPlaylistAudience({ isPublic, userId }, { type: "playlist_deleted", playlistId, userId, }); } export function broadcastPlaylistDumpsUpdated( playlist: Playlist, dumpIds: string[], ): void { sendToPlaylistAudience(playlist, { type: "playlist_dumps_updated", playlistId: playlist.id, dumpIds, }); } export function broadcastUserUpdated(user: Omit): void { for (const client of clients) { send(client.socket, { type: "user_updated", user }); } } export function broadcastCommentCreated(comment: Comment): void { for (const client of clients) { send(client.socket, { type: "comment_created", comment }); } } export function broadcastCommentDeleted( commentId: string, dumpId: string, ): void { for (const client of clients) { send(client.socket, { type: "comment_deleted", commentId, dumpId }); } } export function broadcastCommentUpdated(comment: Comment): void { for (const client of clients) { send(client.socket, { type: "comment_updated", comment }); } } export function handleClientPong(client: WsClient): void { client.pongReceived = true; } // Keepalive: ping all clients every 30s, disconnect non-responsive ones const PING_INTERVAL = 30_000; setInterval(() => { for (const client of clients) { if (client.socket.readyState !== WebSocket.OPEN) { clients.delete(client); continue; } // Disconnect if no pong since last ping (pongReceived starts undefined, skip first cycle) if (client.pongReceived === false) { client.socket.close(1001, "Ping timeout"); clients.delete(client); continue; } client.pongReceived = false; send(client.socket, { type: "ping" }); } }, PING_INTERVAL);