From 653e59bfb2e8e5a339f7537bdd942adc10e8b3dd Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Mon, 23 Feb 2026 22:15:41 +0100 Subject: [PATCH] refactor: unify backend voice signaling with VoiceRoom abstraction Replace dual voiceStates + activeCalls maps with a single VoiceRoom system that tracks both server channels and DM calls uniformly. Fixes four bugs: - voice_status silently dropped for DM call participants - DM calls not cleaned up on WebSocket disconnect - DM call state missing from ready payload on reconnect - No spatial tracking of DM call participants --- packages/server/src/ws/events.ts | 313 +++++++++++++--------- packages/server/src/ws/handler.ts | 344 +++++++++++++++++++------ packages/shared/src/types.ts | 12 +- packages/web/src/hooks/useWebSocket.ts | 35 ++- 4 files changed, 504 insertions(+), 200 deletions(-) diff --git a/packages/server/src/ws/events.ts b/packages/server/src/ws/events.ts index ba2c289c..a21f4b11 100644 --- a/packages/server/src/ws/events.ts +++ b/packages/server/src/ws/events.ts @@ -2,6 +2,7 @@ import { eq, inArray, and } from 'drizzle-orm'; import { getDb, schema } from '../db/index.js'; import { generateSnowflake } from '../utils/snowflake.js'; import { connectionManager } from './handler.js'; +import type { VoiceRoom, DmRoomMeta, ServerRoomMeta } from './handler.js'; import { isMember, getChannelServerId, isDmMember } from '../utils/permissions.js'; import { broadcastDmMessage } from '../routes/dm.js'; import type { User, MessageWithUser, Attachment, DmMessageWithUser } from '@opencord/shared'; @@ -366,6 +367,37 @@ function handlePresenceUpdate(event: Record, userId: string): v }); } +// ─── Voice Handlers (Unified Room API) ───────────────────────────────────── + +/** Helper: broadcast a voice leave and auto-end empty DM calls. */ +function broadcastRoomLeave(roomId: string, room: VoiceRoom, userId: string): void { + if (room.roomType === 'server') { + const meta = room.metadata as ServerRoomMeta; + connectionManager.sendToServer(meta.serverId, { + type: 'voice_state_update', + channelId: roomId, + userId, + action: 'leave', + }); + } else { + connectionManager.sendToDmMembers(roomId, { + type: 'voice_state_update', + channelId: roomId, + userId, + action: 'leave', + }); + // Auto-end call if DM room is now empty and was active + const updatedRoom = connectionManager.getRoom(roomId); + if (updatedRoom && updatedRoom.participants.size === 0 && (updatedRoom.metadata as DmRoomMeta).state === 'active') { + connectionManager.destroyRoom(roomId); + connectionManager.sendToDmMembers(roomId, { + type: 'dm_call_ended', + dmChannelId: roomId, + }); + } + } +} + function handleVoiceJoin(event: Record, userId: string): void { const channelId = event.channelId as string; @@ -385,13 +417,13 @@ function handleVoiceJoin(event: Record, userId: string): void { return; } - // If the user is already in this exact channel (e.g. WS reconnect re-registration), + // If the user is already in this exact room (e.g. WS reconnect re-registration), // skip the leave+join broadcast to avoid visual flicker for other users. - const currentChannel = connectionManager.getUserVoiceChannel(userId); - if (currentChannel === channelId) { + const currentRoom = connectionManager.getUserRoom(userId); + if (currentRoom && currentRoom.roomId === channelId) { const status = connectionManager.getVoiceUserStatus(userId); if (status) { - connectionManager.sendToServer(serverId, { + connectionManager.sendToRoom(channelId, { type: 'voice_status_update', userId, channelId, @@ -404,25 +436,35 @@ function handleVoiceJoin(event: Record, userId: string): void { return; } - // Leave any current voice channel - const previousChannel = connectionManager.leaveAllVoice(userId); - if (previousChannel) { - const prevServerId = getChannelServerId(previousChannel); - if (prevServerId) { - connectionManager.sendToServer(prevServerId, { - type: 'voice_state_update', - channelId: previousChannel, - userId, - action: 'leave', - }); + // Leave current room (server OR DM) + const left = connectionManager.leaveCurrentRoom(userId); + if (left) { + broadcastRoomLeave(left.roomId, left.room, userId); + } + + // Cancel any ringing DM rooms where this user is the caller + // (edge case: user starts DM call then joins server voice before anyone accepts) + for (const [roomId, room] of connectionManager.getAllRooms()) { + if (room.roomType === 'dm') { + const meta = room.metadata as DmRoomMeta; + if (meta.state === 'ringing' && meta.callerId === userId) { + connectionManager.destroyRoom(roomId); + connectionManager.sendToDmMembers(roomId, { + type: 'dm_call_ended', + dmChannelId: roomId, + }); + } } } - // Join new voice channel - connectionManager.joinVoice(channelId, userId); + // Lazy-create server room + connectionManager.createRoom(channelId, 'server', { type: 'server', serverId }); - // Broadcast to server - connectionManager.sendToServer(serverId, { + // Join room + connectionManager.joinRoom(channelId, userId); + + // Broadcast join + connectionManager.sendToRoom(channelId, { type: 'voice_state_update', channelId, userId, @@ -432,7 +474,7 @@ function handleVoiceJoin(event: Record, userId: string): void { // Also broadcast current voice status if it exists (persisted during moves) const status = connectionManager.getVoiceUserStatus(userId); if (status) { - connectionManager.sendToServer(serverId, { + connectionManager.sendToRoom(channelId, { type: 'voice_status_update', userId, channelId, @@ -445,20 +487,40 @@ function handleVoiceJoin(event: Record, userId: string): void { } function handleVoiceLeave(userId: string): void { - const channelId = connectionManager.leaveAllVoice(userId); - if (channelId) { - const serverId = getChannelServerId(channelId); - if (serverId) { - connectionManager.sendToServer(serverId, { - type: 'voice_state_update', - channelId, - userId, - action: 'leave', - }); - } + const left = connectionManager.leaveCurrentRoom(userId); + if (left) { + broadcastRoomLeave(left.roomId, left.room, userId); } + connectionManager.clearVoiceUserStatus(userId); } +function handleVoiceStatus(event: Record, userId: string): void { + const isMuted = event.isMuted === true; + const isDeafened = event.isDeafened === true; + const isCameraOn = event.isCameraOn === true; + const isScreenSharing = event.isScreenSharing === true; + + // BUG FIX: uses unified getUserRoom() instead of server-only getUserVoiceChannel() + // This now works for both server channels AND DM calls. + const userRoom = connectionManager.getUserRoom(userId); + if (!userRoom) return; + + connectionManager.setVoiceUserStatus(userId, isMuted, isDeafened, isCameraOn, isScreenSharing); + + // sendToRoom routes to sendToServer for server rooms, sendToDmMembers for DM rooms + connectionManager.sendToRoom(userRoom.roomId, { + type: 'voice_status_update', + userId, + channelId: userRoom.roomId, + isMuted, + isDeafened, + isCameraOn, + isScreenSharing, + }); +} + +// ─── DM Message Handlers ─────────────────────────────────────────────────── + function handleDmMessageCreate(event: Record, userId: string): void { const dmChannelId = event.dmChannelId as string; const content = event.content as string; @@ -650,6 +712,8 @@ function handleDmMessageDelete(event: Record, userId: string): } } +// ─── Reaction Handlers ───────────────────────────────────────────────────── + function handleReactionAdd(event: Record, userId: string): void { const messageId = event.messageId as string; const emoji = event.emoji as string; @@ -720,6 +784,8 @@ function handleReactionRemove(event: Record, userId: string): v } } +// ─── Read State Handler ──────────────────────────────────────────────────── + function handleChannelAck(event: Record, userId: string): void { const channelId = event.channelId as string; const messageId = event.messageId as string; @@ -765,32 +831,7 @@ function handleChannelAck(event: Record, userId: string): void }); } -function handleVoiceStatus(event: Record, userId: string): void { - const isMuted = event.isMuted === true; - const isDeafened = event.isDeafened === true; - const isCameraOn = event.isCameraOn === true; - const isScreenSharing = event.isScreenSharing === true; - - const channelId = connectionManager.getUserVoiceChannel(userId); - if (!channelId) return; - - const serverId = getChannelServerId(channelId); - if (!serverId) return; - - connectionManager.setVoiceUserStatus(userId, isMuted, isDeafened, isCameraOn, isScreenSharing); - - connectionManager.sendToServer(serverId, { - type: 'voice_status_update', - userId, - channelId, - isMuted, - isDeafened, - isCameraOn, - isScreenSharing, - }); -} - -// ─── DM Call Handlers ────────────────────────────────────────────────────────── +// ─── DM Call Handlers (Unified Room API) ─────────────────────────────────── function handleDmCallStart(event: Record, userId: string, username: string): void { const dmChannelId = event.dmChannelId as string; @@ -804,30 +845,41 @@ function handleDmCallStart(event: Record, userId: string, usern return; } - // Try to start the call (fails if already active) - const started = connectionManager.startCall(dmChannelId, userId); - if (!started) { + // Leave current room if in one + const left = connectionManager.leaveCurrentRoom(userId); + if (left) { + broadcastRoomLeave(left.roomId, left.room, userId); + } + connectionManager.clearVoiceUserStatus(userId); + + // Cancel any other ringing rooms started by this user + for (const [roomId, room] of connectionManager.getAllRooms()) { + if (room.roomType === 'dm' && roomId !== dmChannelId) { + const meta = room.metadata as DmRoomMeta; + if (meta.state === 'ringing' && meta.callerId === userId) { + connectionManager.destroyRoom(roomId); + connectionManager.sendToDmMembers(roomId, { + type: 'dm_call_ended', + dmChannelId: roomId, + }); + } + } + } + + // Create DM room in ringing state (fails if already active) + const created = connectionManager.createDmRoom(dmChannelId, userId); + if (!created) { connectionManager.sendToUser(userId, { type: 'error', message: 'A call is already active in this DM channel' }); return; } - // Find the other DM member(s) and send incoming call notification - const db = getDb(); - const dmMembers = db.select() - .from(schema.dmMembers) - .where(eq(schema.dmMembers.dmChannelId, dmChannelId)) - .all(); - - for (const member of dmMembers) { - if (member.userId !== userId) { - connectionManager.sendToUser(member.userId, { - type: 'dm_call_incoming', - dmChannelId, - callerId: userId, - callerName: username, - }); - } - } + // Ring other members + connectionManager.sendToDmMembers(dmChannelId, { + type: 'dm_call_incoming', + dmChannelId, + callerId: userId, + callerName: username, + }, userId); } function handleDmCallAccept(event: Record, userId: string): void { @@ -842,25 +894,51 @@ function handleDmCallAccept(event: Record, userId: string): voi return; } - const activeCall = connectionManager.getActiveCall(dmChannelId); - if (!activeCall) { + const room = connectionManager.getRoom(dmChannelId); + if (!room || room.roomType !== 'dm') { connectionManager.sendToUser(userId, { type: 'error', message: 'No active call in this DM channel' }); return; } - // Notify all DM members that the call was accepted - const db = getDb(); - const dmMembers = db.select() - .from(schema.dmMembers) - .where(eq(schema.dmMembers.dmChannelId, dmChannelId)) - .all(); + const meta = room.metadata as DmRoomMeta; - for (const member of dmMembers) { - connectionManager.sendToUser(member.userId, { - type: 'dm_call_accepted', - dmChannelId, - }); + // Activate the room (ringing → active) + connectionManager.activateDmRoom(dmChannelId); + + // Leave current rooms for both caller and acceptor + const callerLeft = connectionManager.leaveCurrentRoom(meta.callerId); + if (callerLeft) { + broadcastRoomLeave(callerLeft.roomId, callerLeft.room, meta.callerId); } + const acceptorLeft = connectionManager.leaveCurrentRoom(userId); + if (acceptorLeft) { + broadcastRoomLeave(acceptorLeft.roomId, acceptorLeft.room, userId); + } + + // Join both participants + connectionManager.joinRoom(dmChannelId, meta.callerId); + connectionManager.joinRoom(dmChannelId, userId); + + // Notify all DM members that the call was accepted + connectionManager.sendToDmMembers(dmChannelId, { + type: 'dm_call_accepted', + dmChannelId, + }); + + // Broadcast voice_state_update join for both participants + // This populates the frontend's voiceUsers map generically + connectionManager.sendToDmMembers(dmChannelId, { + type: 'voice_state_update', + channelId: dmChannelId, + userId: meta.callerId, + action: 'join', + }); + connectionManager.sendToDmMembers(dmChannelId, { + type: 'voice_state_update', + channelId: dmChannelId, + userId, + action: 'join', + }); } function handleDmCallReject(event: Record, userId: string): void { @@ -875,27 +953,17 @@ function handleDmCallReject(event: Record, userId: string): voi return; } - const activeCall = connectionManager.getActiveCall(dmChannelId); - if (!activeCall) { - return; // No active call, silently ignore - } + const room = connectionManager.getRoom(dmChannelId); + if (!room) return; // No active call, silently ignore - // End the call since it was rejected - connectionManager.endCall(dmChannelId); + // Destroy room + connectionManager.destroyRoom(dmChannelId); // Notify all DM members that the call was rejected - const db = getDb(); - const dmMembers = db.select() - .from(schema.dmMembers) - .where(eq(schema.dmMembers.dmChannelId, dmChannelId)) - .all(); - - for (const member of dmMembers) { - connectionManager.sendToUser(member.userId, { - type: 'dm_call_rejected', - dmChannelId, - }); - } + connectionManager.sendToDmMembers(dmChannelId, { + type: 'dm_call_rejected', + dmChannelId, + }); } function handleDmCallEnd(event: Record, userId: string): void { @@ -910,25 +978,20 @@ function handleDmCallEnd(event: Record, userId: string): void { return; } - const activeCall = connectionManager.getActiveCall(dmChannelId); - if (!activeCall) { - return; // No active call, silently ignore + const room = connectionManager.getRoom(dmChannelId); + if (!room) return; // No active call, silently ignore + + // Clear voice user states for all participants + for (const participantId of room.participants) { + connectionManager.clearVoiceUserStatus(participantId); } - // End the call - connectionManager.endCall(dmChannelId); + // Destroy room (removes all participants from userToRoom) + connectionManager.destroyRoom(dmChannelId); // Notify all DM members that the call ended - const db = getDb(); - const dmMembers = db.select() - .from(schema.dmMembers) - .where(eq(schema.dmMembers.dmChannelId, dmChannelId)) - .all(); - - for (const member of dmMembers) { - connectionManager.sendToUser(member.userId, { - type: 'dm_call_ended', - dmChannelId, - }); - } + connectionManager.sendToDmMembers(dmChannelId, { + type: 'dm_call_ended', + dmChannelId, + }); } diff --git a/packages/server/src/ws/handler.ts b/packages/server/src/ws/handler.ts index 3a4e5ede..1f0a26c0 100644 --- a/packages/server/src/ws/handler.ts +++ b/packages/server/src/ws/handler.ts @@ -13,6 +13,7 @@ import type { ServerEvent, ServerFolder, ReadState, + ActiveCallInfo, } from '@opencord/shared'; function sanitizeUser(row: typeof schema.users.$inferSelect): User { @@ -33,21 +34,46 @@ export interface AuthenticatedSocket { username: string; } +// ─── VoiceRoom Abstraction ───────────────────────────────────────────────── + +export interface ServerRoomMeta { + type: 'server'; + serverId: string; +} + +export interface DmRoomMeta { + type: 'dm'; + callerId: string; + state: 'ringing' | 'active'; +} + +export interface VoiceRoom { + roomId: string; + roomType: 'server' | 'dm'; + participants: Set; + metadata: ServerRoomMeta | DmRoomMeta; + startedAt: number; +} + +// ─── ConnectionManager ───────────────────────────────────────────────────── + class ConnectionManager { // userId → Set of WebSocket connections (multiple tabs) private connections: Map> = new Map(); // userId → Set of server IDs the user belongs to private userServers: Map> = new Map(); - // channelId → Set of userIds in voice channel - private voiceStates: Map> = new Map(); // ws → userId (reverse lookup) private wsToUser: Map = new Map(); - // dmChannelId → { callerId, startedAt } — active DM calls - private activeCalls: Map = new Map(); + // Unified voice room tracking (replaces voiceStates + activeCalls) + private voiceRooms: Map = new Map(); + // O(1) reverse index: userId → roomId + private userToRoom: Map = new Map(); // userId → { isMuted, isDeafened, isCameraOn, isScreenSharing } — voice user status private voiceUserStates: Map = new Map(); // userId → Timeout private pendingOfflineTimeouts: Map = new Map(); + // roomId → Timeout for ringing DM rooms (60s auto-cleanup) + private ringingTimeouts: Map = new Map(); addConnection(userId: string, ws: WebSocket): void { if (!this.connections.has(userId)) { @@ -55,7 +81,7 @@ class ConnectionManager { } this.connections.get(userId)!.add(ws); this.wsToUser.set(ws, userId); - + // If they were pending offline, cancel it! this.cancelDisconnect(userId); } @@ -105,19 +131,48 @@ class ConnectionManager { const db = getDb(); db.update(schema.users).set({ status: 'offline' }).where(eq(schema.users.id, userId)).run(); - // Leave voice if in one - const leftChannel = this.leaveAllVoice(userId); + // Leave voice room if in one (handles both server and DM rooms) + const left = this.leaveCurrentRoom(userId); this.clearVoiceUserStatus(userId); - if (leftChannel) { - // Get channel's server to broadcast - const channel = db.select().from(schema.channels).where(eq(schema.channels.id, leftChannel)).get(); - if (channel) { - this.sendToServer(channel.serverId, { + if (left) { + if (left.room.roomType === 'server') { + const meta = left.room.metadata as ServerRoomMeta; + this.sendToServer(meta.serverId, { type: 'voice_state_update', - channelId: leftChannel, + channelId: left.roomId, userId: userId, action: 'leave', }); + } else { + // DM room — broadcast leave and auto-end if empty + this.sendToDmMembers(left.roomId, { + type: 'voice_state_update', + channelId: left.roomId, + userId: userId, + action: 'leave', + }); + const updatedRoom = this.voiceRooms.get(left.roomId); + if (updatedRoom && updatedRoom.participants.size === 0 && (updatedRoom.metadata as DmRoomMeta).state === 'active') { + this.destroyRoom(left.roomId); + this.sendToDmMembers(left.roomId, { + type: 'dm_call_ended', + dmChannelId: left.roomId, + }); + } + } + } + + // Destroy any ringing DM rooms where this user is the caller + for (const [roomId, room] of this.voiceRooms) { + if (room.roomType === 'dm') { + const meta = room.metadata as DmRoomMeta; + if (meta.state === 'ringing' && meta.callerId === userId) { + this.destroyRoom(roomId); + this.sendToDmMembers(roomId, { + type: 'dm_call_ended', + dmChannelId: roomId, + }); + } } } @@ -156,55 +211,162 @@ class ConnectionManager { return this.userServers.get(userId) ?? new Set(); } - // Voice state management - joinVoice(channelId: string, userId: string): void { - // Leave any existing voice channel first - this.leaveAllVoice(userId); - if (!this.voiceStates.has(channelId)) { - this.voiceStates.set(channelId, new Set()); - } - this.voiceStates.get(channelId)!.add(userId); + // ─── Unified VoiceRoom API ───────────────────────────────────────────────── + + /** Create a room. Returns false if room already exists. */ + createRoom(roomId: string, roomType: 'server' | 'dm', metadata: ServerRoomMeta | DmRoomMeta): boolean { + if (this.voiceRooms.has(roomId)) return false; + this.voiceRooms.set(roomId, { + roomId, + roomType, + participants: new Set(), + metadata, + startedAt: Date.now(), + }); + return true; } - leaveVoice(channelId: string, userId: string): void { - const users = this.voiceStates.get(channelId); - if (users) { - users.delete(userId); - if (users.size === 0) { - this.voiceStates.delete(channelId); + /** Create a DM room in ringing state with 60s auto-cleanup. */ + createDmRoom(dmChannelId: string, callerId: string): boolean { + const created = this.createRoom(dmChannelId, 'dm', { + type: 'dm', + callerId, + state: 'ringing', + }); + if (!created) return false; + + // 60s ringing timeout — auto-destroy if still ringing + const timeout = setTimeout(() => { + this.ringingTimeouts.delete(dmChannelId); + const room = this.voiceRooms.get(dmChannelId); + if (room && room.roomType === 'dm' && (room.metadata as DmRoomMeta).state === 'ringing') { + this.destroyRoom(dmChannelId); + this.sendToDmMembers(dmChannelId, { + type: 'dm_call_ended', + dmChannelId, + }); } - } + }, 60_000); + this.ringingTimeouts.set(dmChannelId, timeout); + + return true; } - leaveAllVoice(userId: string): string | null { - let leftChannelId: string | null = null; - for (const [channelId, users] of this.voiceStates) { - if (users.has(userId)) { - users.delete(userId); - if (users.size === 0) { - this.voiceStates.delete(channelId); + /** Transition a DM room from ringing → active. Returns false if not found or not ringing. */ + activateDmRoom(dmChannelId: string): boolean { + const room = this.voiceRooms.get(dmChannelId); + if (!room || room.roomType !== 'dm') return false; + const meta = room.metadata as DmRoomMeta; + if (meta.state !== 'ringing') return false; + meta.state = 'active'; + + // Clear ringing timeout + const timeout = this.ringingTimeouts.get(dmChannelId); + if (timeout) { + clearTimeout(timeout); + this.ringingTimeouts.delete(dmChannelId); + } + return true; + } + + /** Add a user to a room. Enforces one-room-per-user invariant. Returns the room or null if room doesn't exist. */ + joinRoom(roomId: string, userId: string): VoiceRoom | null { + const room = this.voiceRooms.get(roomId); + if (!room) return null; + + // Enforce one-room-per-user invariant: silently remove from old room + const currentRoomId = this.userToRoom.get(userId); + if (currentRoomId && currentRoomId !== roomId) { + const oldRoom = this.voiceRooms.get(currentRoomId); + if (oldRoom) { + oldRoom.participants.delete(userId); + if (oldRoom.participants.size === 0 && oldRoom.roomType === 'server') { + this.voiceRooms.delete(currentRoomId); } - leftChannelId = channelId; - break; } } - return leftChannelId; + + room.participants.add(userId); + this.userToRoom.set(userId, roomId); + return room; } - getVoiceUsers(channelId: string): Set { - return this.voiceStates.get(channelId) ?? new Set(); - } + /** Remove a user from a specific room. Returns the room or null if not found. */ + leaveRoom(roomId: string, userId: string): VoiceRoom | null { + const room = this.voiceRooms.get(roomId); + if (!room || !room.participants.has(userId)) return null; - getUserVoiceChannel(userId: string): string | null { - for (const [channelId, users] of this.voiceStates) { - if (users.has(userId)) { - return channelId; - } + room.participants.delete(userId); + this.userToRoom.delete(userId); + + // Auto-cleanup empty server rooms (they're lazy-created) + if (room.participants.size === 0 && room.roomType === 'server') { + this.voiceRooms.delete(roomId); } - return null; + + return room; } - // Voice user status management + /** Leave whatever room the user is in. Returns { roomId, room } or null. */ + leaveCurrentRoom(userId: string): { roomId: string; room: VoiceRoom } | null { + const roomId = this.userToRoom.get(userId); + if (!roomId) return null; + + const room = this.leaveRoom(roomId, userId); + if (!room) return null; + + return { roomId, room }; + } + + /** Destroy a room entirely. Returns displaced userIds. */ + destroyRoom(roomId: string): string[] { + const room = this.voiceRooms.get(roomId); + if (!room) return []; + + const displaced: string[] = []; + for (const userId of room.participants) { + this.userToRoom.delete(userId); + displaced.push(userId); + } + + this.voiceRooms.delete(roomId); + + // Clear ringing timeout if any + const timeout = this.ringingTimeouts.get(roomId); + if (timeout) { + clearTimeout(timeout); + this.ringingTimeouts.delete(roomId); + } + + return displaced; + } + + /** Get a room by ID. */ + getRoom(roomId: string): VoiceRoom | undefined { + return this.voiceRooms.get(roomId); + } + + /** Get participants in a room. */ + getRoomParticipants(roomId: string): Set { + return this.voiceRooms.get(roomId)?.participants ?? new Set(); + } + + /** Get the room a user is currently in. Returns { roomId, room } or null. */ + getUserRoom(userId: string): { roomId: string; room: VoiceRoom } | null { + const roomId = this.userToRoom.get(userId); + if (!roomId) return null; + const room = this.voiceRooms.get(roomId); + if (!room) return null; + return { roomId, room }; + } + + /** Read-only access to all rooms. */ + getAllRooms(): Map { + return this.voiceRooms; + } + + // ─── Voice User Status (unchanged) ──────────────────────────────────────── + setVoiceUserStatus(userId: string, isMuted: boolean, isDeafened: boolean, isCameraOn: boolean, isScreenSharing: boolean): void { this.voiceUserStates.set(userId, { isMuted, isDeafened, isCameraOn, isScreenSharing }); } @@ -221,22 +383,9 @@ class ConnectionManager { return this.voiceUserStates; } - // DM call management - startCall(dmChannelId: string, callerId: string): boolean { - if (this.activeCalls.has(dmChannelId)) return false; // Already in a call - this.activeCalls.set(dmChannelId, { callerId, startedAt: Date.now() }); - return true; - } + // ─── Broadcasting ───────────────────────────────────────────────────────── - endCall(dmChannelId: string): void { - this.activeCalls.delete(dmChannelId); - } - - getActiveCall(dmChannelId: string): { callerId: string; startedAt: number } | undefined { - return this.activeCalls.get(dmChannelId); - } - - // Send to a specific user (all their connections) + /** Send to a specific user (all their connections). */ sendToUser(userId: string, event: ServerEvent): void { const connections = this.getUserConnections(userId); const message = JSON.stringify(event); @@ -247,7 +396,7 @@ class ConnectionManager { } } - // Send to all members of a server + /** Send to all members of a server. */ sendToServer(serverId: string, event: ServerEvent, excludeUserId?: string): void { const message = JSON.stringify(event); for (const [userId, serverIds] of this.userServers) { @@ -262,7 +411,35 @@ class ConnectionManager { } } - // Send to all connections of all online users (for global events) + /** Send to all DM channel members (queries dm_members table). */ + sendToDmMembers(dmChannelId: string, event: ServerEvent, excludeUserId?: string): void { + const db = getDb(); + const dmMembers = db.select() + .from(schema.dmMembers) + .where(eq(schema.dmMembers.dmChannelId, dmChannelId)) + .all(); + + for (const member of dmMembers) { + if (member.userId !== excludeUserId) { + this.sendToUser(member.userId, event); + } + } + } + + /** Send to a room — routes to sendToServer (server rooms) or sendToDmMembers (DM rooms). */ + sendToRoom(roomId: string, event: ServerEvent, excludeUserId?: string): void { + const room = this.voiceRooms.get(roomId); + if (!room) return; + + if (room.roomType === 'server') { + const meta = room.metadata as ServerRoomMeta; + this.sendToServer(meta.serverId, event, excludeUserId); + } else { + this.sendToDmMembers(roomId, event, excludeUserId); + } + } + + /** Send to all connections of all online users. */ sendToAll(event: ServerEvent, excludeUserId?: string): void { const message = JSON.stringify(event); for (const [userId, connections] of this.connections) { @@ -291,6 +468,7 @@ function buildReadyPayload(userId: string): { voiceStates: Record; voiceUserStates: Record; readStates: ReadState[]; + activeCalls: ActiveCallInfo[]; } { const db = getDb(); @@ -349,11 +527,11 @@ function buildReadyPayload(userId: string): { .map(m => { const u = userMap.get(m.userId); if (!u) return null; - + const assignedRoleIds = memberRoleRows .filter(mr => mr.userId === m.userId) .map(mr => mr.roleId); - + const memberRoles = roles .filter(r => assignedRoleIds.includes(r.id)) .map(r => ({ @@ -481,7 +659,7 @@ function buildReadyPayload(userId: string): { .where(eq(schema.serverFolderMembers.folderId, folder.id)) .all() .map(m => m.serverId); - + folders.push({ id: folder.id, userId: folder.userId, @@ -498,15 +676,35 @@ function buildReadyPayload(userId: string): { for (const srv of servers) { for (const ch of srv.channels) { if (ch.type === 'voice' || ch.type === 'video') { - const users = connectionManager.getVoiceUsers(ch.id); - if (users.size > 0) { - voiceStates[ch.id] = Array.from(users); + const participants = connectionManager.getRoomParticipants(ch.id); + if (participants.size > 0) { + voiceStates[ch.id] = Array.from(participants); } } } } - // Build voice user states — tell the client mute/deafen/camera/screenshare status of voice users + // Build active calls from user's DM memberships + const activeCalls: ActiveCallInfo[] = []; + for (const dm of dmMemberships) { + const room = connectionManager.getRoom(dm.dmChannelId); + if (room && room.roomType === 'dm') { + const dmMeta = room.metadata as DmRoomMeta; + activeCalls.push({ + dmChannelId: dm.dmChannelId, + callerId: dmMeta.callerId, + participants: Array.from(room.participants), + startedAt: room.startedAt, + state: dmMeta.state, + }); + // Inject DM call participants into voiceStates so frontend's generic handler works + if (room.participants.size > 0) { + voiceStates[dm.dmChannelId] = Array.from(room.participants); + } + } + } + + // Build voice user states — includes both server and DM participants now const voiceUserStates: Record = {}; for (const chId of Object.keys(voiceStates)) { const usersInChannel = voiceStates[chId]; @@ -531,7 +729,7 @@ function buildReadyPayload(userId: string): { lastReadMessageId: rs.lastReadMessageId, })); - return { user, servers, dmChannels, folders, voiceStates, voiceUserStates, readStates }; + return { user, servers, dmChannels, folders, voiceStates, voiceUserStates, readStates, activeCalls }; } export async function registerWebSocket(app: FastifyInstance): Promise { diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 3ca49546..9855c38b 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -135,6 +135,16 @@ export interface Attachment { createdAt: number; } +// ─── Active Call Types ─────────────────────────────────────────────────────── + +export interface ActiveCallInfo { + dmChannelId: string; + callerId: string; + participants: string[]; + startedAt: number; + state: 'ringing' | 'active'; +} + // ─── DM Types ─────────────────────────────────────────────────────────────── export interface DmChannel { @@ -191,7 +201,7 @@ export type ClientEvent = // Server → Client Events export type ServerEvent = - | { type: 'ready'; user: User; servers: ServerWithChannelsAndMembers[]; dmChannels: DmChannel[]; folders?: ServerFolder[]; voiceStates?: Record; voiceUserStates?: Record; readStates?: ReadState[] } + | { type: 'ready'; user: User; servers: ServerWithChannelsAndMembers[]; dmChannels: DmChannel[]; folders?: ServerFolder[]; voiceStates?: Record; voiceUserStates?: Record; readStates?: ReadState[]; activeCalls?: ActiveCallInfo[] } | { type: 'message_created'; message: MessageWithUser } | { type: 'message_updated'; message: MessageWithUser } | { type: 'message_deleted'; messageId: string; channelId: string } diff --git a/packages/web/src/hooks/useWebSocket.ts b/packages/web/src/hooks/useWebSocket.ts index fe4d72b5..f8bbaf9b 100644 --- a/packages/web/src/hooks/useWebSocket.ts +++ b/packages/web/src/hooks/useWebSocket.ts @@ -4,7 +4,7 @@ import { useServerStore } from '../stores/serverStore'; import { useChatStore } from '../stores/chatStore'; import { useVoiceStore } from '../stores/voiceStore'; import { useSocialStore } from '../stores/socialStore'; -import type { ServerEvent, ClientEvent } from '@opencord/shared'; +import type { ServerEvent, ClientEvent, ActiveCallInfo } from '@opencord/shared'; let globalWs: WebSocket | null = null; let reconnectAttempts = 0; @@ -61,6 +61,39 @@ function handleEvent(event: ServerEvent): void { wsSend({ type: 'voice_status', isMuted: curMuted, isDeafened: curDeafened, isCameraOn: curCamera, isScreenSharing: curScreen }); } } + // Restore DM call state from server (handles reconnect and page refresh) + { + const { activeDmCall, setActiveDmCall, setIncomingCall, incomingCall } = useVoiceStore.getState(); + const myId = event.user.id; + if (event.activeCalls && event.activeCalls.length > 0) { + for (const call of event.activeCalls) { + const isParticipant = call.participants.includes(myId); + if (call.state === 'active' && isParticipant) { + // Restore active DM call + setActiveDmCall({ dmChannelId: call.dmChannelId }); + break; + } else if (call.state === 'ringing' && call.callerId !== myId) { + // Restore incoming call (we're the callee) + // Look up caller name from DM channel members + const dmCh = event.dmChannels?.find((d: any) => d.id === call.dmChannelId); + const callerUser = dmCh?.members?.find((m: any) => m.id === call.callerId); + setIncomingCall({ + dmChannelId: call.dmChannelId, + callerId: call.callerId, + callerName: callerUser?.displayName || callerUser?.username || call.callerId, + }); + } + } + } else { + // No active calls on server — clear stale local state + if (activeDmCall) { + setActiveDmCall(null); + } + if (incomingCall) { + setIncomingCall(null); + } + } + } break; case 'message_created':