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
This commit is contained in:
Jannis Braun
2026-02-23 22:15:41 +01:00
parent 628d417723
commit 653e59bfb2
4 changed files with 504 additions and 200 deletions
+188 -125
View File
@@ -2,6 +2,7 @@ import { eq, inArray, and } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js'; import { getDb, schema } from '../db/index.js';
import { generateSnowflake } from '../utils/snowflake.js'; import { generateSnowflake } from '../utils/snowflake.js';
import { connectionManager } from './handler.js'; import { connectionManager } from './handler.js';
import type { VoiceRoom, DmRoomMeta, ServerRoomMeta } from './handler.js';
import { isMember, getChannelServerId, isDmMember } from '../utils/permissions.js'; import { isMember, getChannelServerId, isDmMember } from '../utils/permissions.js';
import { broadcastDmMessage } from '../routes/dm.js'; import { broadcastDmMessage } from '../routes/dm.js';
import type { User, MessageWithUser, Attachment, DmMessageWithUser } from '@opencord/shared'; import type { User, MessageWithUser, Attachment, DmMessageWithUser } from '@opencord/shared';
@@ -366,6 +367,37 @@ function handlePresenceUpdate(event: Record<string, unknown>, 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<string, unknown>, userId: string): void { function handleVoiceJoin(event: Record<string, unknown>, userId: string): void {
const channelId = event.channelId as string; const channelId = event.channelId as string;
@@ -385,13 +417,13 @@ function handleVoiceJoin(event: Record<string, unknown>, userId: string): void {
return; 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. // skip the leave+join broadcast to avoid visual flicker for other users.
const currentChannel = connectionManager.getUserVoiceChannel(userId); const currentRoom = connectionManager.getUserRoom(userId);
if (currentChannel === channelId) { if (currentRoom && currentRoom.roomId === channelId) {
const status = connectionManager.getVoiceUserStatus(userId); const status = connectionManager.getVoiceUserStatus(userId);
if (status) { if (status) {
connectionManager.sendToServer(serverId, { connectionManager.sendToRoom(channelId, {
type: 'voice_status_update', type: 'voice_status_update',
userId, userId,
channelId, channelId,
@@ -404,25 +436,35 @@ function handleVoiceJoin(event: Record<string, unknown>, userId: string): void {
return; return;
} }
// Leave any current voice channel // Leave current room (server OR DM)
const previousChannel = connectionManager.leaveAllVoice(userId); const left = connectionManager.leaveCurrentRoom(userId);
if (previousChannel) { if (left) {
const prevServerId = getChannelServerId(previousChannel); broadcastRoomLeave(left.roomId, left.room, userId);
if (prevServerId) { }
connectionManager.sendToServer(prevServerId, {
type: 'voice_state_update', // Cancel any ringing DM rooms where this user is the caller
channelId: previousChannel, // (edge case: user starts DM call then joins server voice before anyone accepts)
userId, for (const [roomId, room] of connectionManager.getAllRooms()) {
action: 'leave', 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 // Lazy-create server room
connectionManager.joinVoice(channelId, userId); connectionManager.createRoom(channelId, 'server', { type: 'server', serverId });
// Broadcast to server // Join room
connectionManager.sendToServer(serverId, { connectionManager.joinRoom(channelId, userId);
// Broadcast join
connectionManager.sendToRoom(channelId, {
type: 'voice_state_update', type: 'voice_state_update',
channelId, channelId,
userId, userId,
@@ -432,7 +474,7 @@ function handleVoiceJoin(event: Record<string, unknown>, userId: string): void {
// Also broadcast current voice status if it exists (persisted during moves) // Also broadcast current voice status if it exists (persisted during moves)
const status = connectionManager.getVoiceUserStatus(userId); const status = connectionManager.getVoiceUserStatus(userId);
if (status) { if (status) {
connectionManager.sendToServer(serverId, { connectionManager.sendToRoom(channelId, {
type: 'voice_status_update', type: 'voice_status_update',
userId, userId,
channelId, channelId,
@@ -445,20 +487,40 @@ function handleVoiceJoin(event: Record<string, unknown>, userId: string): void {
} }
function handleVoiceLeave(userId: string): void { function handleVoiceLeave(userId: string): void {
const channelId = connectionManager.leaveAllVoice(userId); const left = connectionManager.leaveCurrentRoom(userId);
if (channelId) { if (left) {
const serverId = getChannelServerId(channelId); broadcastRoomLeave(left.roomId, left.room, userId);
if (serverId) {
connectionManager.sendToServer(serverId, {
type: 'voice_state_update',
channelId,
userId,
action: 'leave',
});
}
} }
connectionManager.clearVoiceUserStatus(userId);
} }
function handleVoiceStatus(event: Record<string, unknown>, 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<string, unknown>, userId: string): void { function handleDmMessageCreate(event: Record<string, unknown>, userId: string): void {
const dmChannelId = event.dmChannelId as string; const dmChannelId = event.dmChannelId as string;
const content = event.content as string; const content = event.content as string;
@@ -650,6 +712,8 @@ function handleDmMessageDelete(event: Record<string, unknown>, userId: string):
} }
} }
// ─── Reaction Handlers ─────────────────────────────────────────────────────
function handleReactionAdd(event: Record<string, unknown>, userId: string): void { function handleReactionAdd(event: Record<string, unknown>, userId: string): void {
const messageId = event.messageId as string; const messageId = event.messageId as string;
const emoji = event.emoji as string; const emoji = event.emoji as string;
@@ -720,6 +784,8 @@ function handleReactionRemove(event: Record<string, unknown>, userId: string): v
} }
} }
// ─── Read State Handler ────────────────────────────────────────────────────
function handleChannelAck(event: Record<string, unknown>, userId: string): void { function handleChannelAck(event: Record<string, unknown>, userId: string): void {
const channelId = event.channelId as string; const channelId = event.channelId as string;
const messageId = event.messageId as string; const messageId = event.messageId as string;
@@ -765,32 +831,7 @@ function handleChannelAck(event: Record<string, unknown>, userId: string): void
}); });
} }
function handleVoiceStatus(event: Record<string, unknown>, userId: string): void { // ─── DM Call Handlers (Unified Room API) ───────────────────────────────────
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 ──────────────────────────────────────────────────────────
function handleDmCallStart(event: Record<string, unknown>, userId: string, username: string): void { function handleDmCallStart(event: Record<string, unknown>, userId: string, username: string): void {
const dmChannelId = event.dmChannelId as string; const dmChannelId = event.dmChannelId as string;
@@ -804,30 +845,41 @@ function handleDmCallStart(event: Record<string, unknown>, userId: string, usern
return; return;
} }
// Try to start the call (fails if already active) // Leave current room if in one
const started = connectionManager.startCall(dmChannelId, userId); const left = connectionManager.leaveCurrentRoom(userId);
if (!started) { 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' }); connectionManager.sendToUser(userId, { type: 'error', message: 'A call is already active in this DM channel' });
return; return;
} }
// Find the other DM member(s) and send incoming call notification // Ring other members
const db = getDb(); connectionManager.sendToDmMembers(dmChannelId, {
const dmMembers = db.select() type: 'dm_call_incoming',
.from(schema.dmMembers) dmChannelId,
.where(eq(schema.dmMembers.dmChannelId, dmChannelId)) callerId: userId,
.all(); callerName: username,
}, userId);
for (const member of dmMembers) {
if (member.userId !== userId) {
connectionManager.sendToUser(member.userId, {
type: 'dm_call_incoming',
dmChannelId,
callerId: userId,
callerName: username,
});
}
}
} }
function handleDmCallAccept(event: Record<string, unknown>, userId: string): void { function handleDmCallAccept(event: Record<string, unknown>, userId: string): void {
@@ -842,25 +894,51 @@ function handleDmCallAccept(event: Record<string, unknown>, userId: string): voi
return; return;
} }
const activeCall = connectionManager.getActiveCall(dmChannelId); const room = connectionManager.getRoom(dmChannelId);
if (!activeCall) { if (!room || room.roomType !== 'dm') {
connectionManager.sendToUser(userId, { type: 'error', message: 'No active call in this DM channel' }); connectionManager.sendToUser(userId, { type: 'error', message: 'No active call in this DM channel' });
return; return;
} }
// Notify all DM members that the call was accepted const meta = room.metadata as DmRoomMeta;
const db = getDb();
const dmMembers = db.select()
.from(schema.dmMembers)
.where(eq(schema.dmMembers.dmChannelId, dmChannelId))
.all();
for (const member of dmMembers) { // Activate the room (ringing → active)
connectionManager.sendToUser(member.userId, { connectionManager.activateDmRoom(dmChannelId);
type: 'dm_call_accepted',
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<string, unknown>, userId: string): void { function handleDmCallReject(event: Record<string, unknown>, userId: string): void {
@@ -875,27 +953,17 @@ function handleDmCallReject(event: Record<string, unknown>, userId: string): voi
return; return;
} }
const activeCall = connectionManager.getActiveCall(dmChannelId); const room = connectionManager.getRoom(dmChannelId);
if (!activeCall) { if (!room) return; // No active call, silently ignore
return; // No active call, silently ignore
}
// End the call since it was rejected // Destroy room
connectionManager.endCall(dmChannelId); connectionManager.destroyRoom(dmChannelId);
// Notify all DM members that the call was rejected // Notify all DM members that the call was rejected
const db = getDb(); connectionManager.sendToDmMembers(dmChannelId, {
const dmMembers = db.select() type: 'dm_call_rejected',
.from(schema.dmMembers) dmChannelId,
.where(eq(schema.dmMembers.dmChannelId, dmChannelId)) });
.all();
for (const member of dmMembers) {
connectionManager.sendToUser(member.userId, {
type: 'dm_call_rejected',
dmChannelId,
});
}
} }
function handleDmCallEnd(event: Record<string, unknown>, userId: string): void { function handleDmCallEnd(event: Record<string, unknown>, userId: string): void {
@@ -910,25 +978,20 @@ function handleDmCallEnd(event: Record<string, unknown>, userId: string): void {
return; return;
} }
const activeCall = connectionManager.getActiveCall(dmChannelId); const room = connectionManager.getRoom(dmChannelId);
if (!activeCall) { if (!room) return; // No active call, silently ignore
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 // Destroy room (removes all participants from userToRoom)
connectionManager.endCall(dmChannelId); connectionManager.destroyRoom(dmChannelId);
// Notify all DM members that the call ended // Notify all DM members that the call ended
const db = getDb(); connectionManager.sendToDmMembers(dmChannelId, {
const dmMembers = db.select() type: 'dm_call_ended',
.from(schema.dmMembers) dmChannelId,
.where(eq(schema.dmMembers.dmChannelId, dmChannelId)) });
.all();
for (const member of dmMembers) {
connectionManager.sendToUser(member.userId, {
type: 'dm_call_ended',
dmChannelId,
});
}
} }
+271 -73
View File
@@ -13,6 +13,7 @@ import type {
ServerEvent, ServerEvent,
ServerFolder, ServerFolder,
ReadState, ReadState,
ActiveCallInfo,
} from '@opencord/shared'; } from '@opencord/shared';
function sanitizeUser(row: typeof schema.users.$inferSelect): User { function sanitizeUser(row: typeof schema.users.$inferSelect): User {
@@ -33,21 +34,46 @@ export interface AuthenticatedSocket {
username: string; 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<string>;
metadata: ServerRoomMeta | DmRoomMeta;
startedAt: number;
}
// ─── ConnectionManager ─────────────────────────────────────────────────────
class ConnectionManager { class ConnectionManager {
// userId → Set of WebSocket connections (multiple tabs) // userId → Set of WebSocket connections (multiple tabs)
private connections: Map<string, Set<WebSocket>> = new Map(); private connections: Map<string, Set<WebSocket>> = new Map();
// userId → Set of server IDs the user belongs to // userId → Set of server IDs the user belongs to
private userServers: Map<string, Set<string>> = new Map(); private userServers: Map<string, Set<string>> = new Map();
// channelId → Set of userIds in voice channel
private voiceStates: Map<string, Set<string>> = new Map();
// ws → userId (reverse lookup) // ws → userId (reverse lookup)
private wsToUser: Map<WebSocket, string> = new Map(); private wsToUser: Map<WebSocket, string> = new Map();
// dmChannelId → { callerId, startedAt } — active DM calls // Unified voice room tracking (replaces voiceStates + activeCalls)
private activeCalls: Map<string, { callerId: string; startedAt: number }> = new Map(); private voiceRooms: Map<string, VoiceRoom> = new Map();
// O(1) reverse index: userId → roomId
private userToRoom: Map<string, string> = new Map();
// userId → { isMuted, isDeafened, isCameraOn, isScreenSharing } — voice user status // userId → { isMuted, isDeafened, isCameraOn, isScreenSharing } — voice user status
private voiceUserStates: Map<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }> = new Map(); private voiceUserStates: Map<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }> = new Map();
// userId → Timeout // userId → Timeout
private pendingOfflineTimeouts: Map<string, NodeJS.Timeout> = new Map(); private pendingOfflineTimeouts: Map<string, NodeJS.Timeout> = new Map();
// roomId → Timeout for ringing DM rooms (60s auto-cleanup)
private ringingTimeouts: Map<string, NodeJS.Timeout> = new Map();
addConnection(userId: string, ws: WebSocket): void { addConnection(userId: string, ws: WebSocket): void {
if (!this.connections.has(userId)) { if (!this.connections.has(userId)) {
@@ -55,7 +81,7 @@ class ConnectionManager {
} }
this.connections.get(userId)!.add(ws); this.connections.get(userId)!.add(ws);
this.wsToUser.set(ws, userId); this.wsToUser.set(ws, userId);
// If they were pending offline, cancel it! // If they were pending offline, cancel it!
this.cancelDisconnect(userId); this.cancelDisconnect(userId);
} }
@@ -105,19 +131,48 @@ class ConnectionManager {
const db = getDb(); const db = getDb();
db.update(schema.users).set({ status: 'offline' }).where(eq(schema.users.id, userId)).run(); db.update(schema.users).set({ status: 'offline' }).where(eq(schema.users.id, userId)).run();
// Leave voice if in one // Leave voice room if in one (handles both server and DM rooms)
const leftChannel = this.leaveAllVoice(userId); const left = this.leaveCurrentRoom(userId);
this.clearVoiceUserStatus(userId); this.clearVoiceUserStatus(userId);
if (leftChannel) { if (left) {
// Get channel's server to broadcast if (left.room.roomType === 'server') {
const channel = db.select().from(schema.channels).where(eq(schema.channels.id, leftChannel)).get(); const meta = left.room.metadata as ServerRoomMeta;
if (channel) { this.sendToServer(meta.serverId, {
this.sendToServer(channel.serverId, {
type: 'voice_state_update', type: 'voice_state_update',
channelId: leftChannel, channelId: left.roomId,
userId: userId, userId: userId,
action: 'leave', 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(); return this.userServers.get(userId) ?? new Set();
} }
// Voice state management // ─── Unified VoiceRoom API ─────────────────────────────────────────────────
joinVoice(channelId: string, userId: string): void {
// Leave any existing voice channel first /** Create a room. Returns false if room already exists. */
this.leaveAllVoice(userId); createRoom(roomId: string, roomType: 'server' | 'dm', metadata: ServerRoomMeta | DmRoomMeta): boolean {
if (!this.voiceStates.has(channelId)) { if (this.voiceRooms.has(roomId)) return false;
this.voiceStates.set(channelId, new Set()); this.voiceRooms.set(roomId, {
} roomId,
this.voiceStates.get(channelId)!.add(userId); roomType,
participants: new Set(),
metadata,
startedAt: Date.now(),
});
return true;
} }
leaveVoice(channelId: string, userId: string): void { /** Create a DM room in ringing state with 60s auto-cleanup. */
const users = this.voiceStates.get(channelId); createDmRoom(dmChannelId: string, callerId: string): boolean {
if (users) { const created = this.createRoom(dmChannelId, 'dm', {
users.delete(userId); type: 'dm',
if (users.size === 0) { callerId,
this.voiceStates.delete(channelId); 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 { /** Transition a DM room from ringing → active. Returns false if not found or not ringing. */
let leftChannelId: string | null = null; activateDmRoom(dmChannelId: string): boolean {
for (const [channelId, users] of this.voiceStates) { const room = this.voiceRooms.get(dmChannelId);
if (users.has(userId)) { if (!room || room.roomType !== 'dm') return false;
users.delete(userId); const meta = room.metadata as DmRoomMeta;
if (users.size === 0) { if (meta.state !== 'ringing') return false;
this.voiceStates.delete(channelId); 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<string> { /** Remove a user from a specific room. Returns the room or null if not found. */
return this.voiceStates.get(channelId) ?? new Set(); 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 { room.participants.delete(userId);
for (const [channelId, users] of this.voiceStates) { this.userToRoom.delete(userId);
if (users.has(userId)) {
return channelId; // 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<string> {
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<string, VoiceRoom> {
return this.voiceRooms;
}
// ─── Voice User Status (unchanged) ────────────────────────────────────────
setVoiceUserStatus(userId: string, isMuted: boolean, isDeafened: boolean, isCameraOn: boolean, isScreenSharing: boolean): void { setVoiceUserStatus(userId: string, isMuted: boolean, isDeafened: boolean, isCameraOn: boolean, isScreenSharing: boolean): void {
this.voiceUserStates.set(userId, { isMuted, isDeafened, isCameraOn, isScreenSharing }); this.voiceUserStates.set(userId, { isMuted, isDeafened, isCameraOn, isScreenSharing });
} }
@@ -221,22 +383,9 @@ class ConnectionManager {
return this.voiceUserStates; return this.voiceUserStates;
} }
// DM call management // ─── Broadcasting ─────────────────────────────────────────────────────────
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;
}
endCall(dmChannelId: string): void { /** Send to a specific user (all their connections). */
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)
sendToUser(userId: string, event: ServerEvent): void { sendToUser(userId: string, event: ServerEvent): void {
const connections = this.getUserConnections(userId); const connections = this.getUserConnections(userId);
const message = JSON.stringify(event); 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 { sendToServer(serverId: string, event: ServerEvent, excludeUserId?: string): void {
const message = JSON.stringify(event); const message = JSON.stringify(event);
for (const [userId, serverIds] of this.userServers) { 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 { sendToAll(event: ServerEvent, excludeUserId?: string): void {
const message = JSON.stringify(event); const message = JSON.stringify(event);
for (const [userId, connections] of this.connections) { for (const [userId, connections] of this.connections) {
@@ -291,6 +468,7 @@ function buildReadyPayload(userId: string): {
voiceStates: Record<string, string[]>; voiceStates: Record<string, string[]>;
voiceUserStates: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>; voiceUserStates: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>;
readStates: ReadState[]; readStates: ReadState[];
activeCalls: ActiveCallInfo[];
} { } {
const db = getDb(); const db = getDb();
@@ -349,11 +527,11 @@ function buildReadyPayload(userId: string): {
.map(m => { .map(m => {
const u = userMap.get(m.userId); const u = userMap.get(m.userId);
if (!u) return null; if (!u) return null;
const assignedRoleIds = memberRoleRows const assignedRoleIds = memberRoleRows
.filter(mr => mr.userId === m.userId) .filter(mr => mr.userId === m.userId)
.map(mr => mr.roleId); .map(mr => mr.roleId);
const memberRoles = roles const memberRoles = roles
.filter(r => assignedRoleIds.includes(r.id)) .filter(r => assignedRoleIds.includes(r.id))
.map(r => ({ .map(r => ({
@@ -481,7 +659,7 @@ function buildReadyPayload(userId: string): {
.where(eq(schema.serverFolderMembers.folderId, folder.id)) .where(eq(schema.serverFolderMembers.folderId, folder.id))
.all() .all()
.map(m => m.serverId); .map(m => m.serverId);
folders.push({ folders.push({
id: folder.id, id: folder.id,
userId: folder.userId, userId: folder.userId,
@@ -498,15 +676,35 @@ function buildReadyPayload(userId: string): {
for (const srv of servers) { for (const srv of servers) {
for (const ch of srv.channels) { for (const ch of srv.channels) {
if (ch.type === 'voice' || ch.type === 'video') { if (ch.type === 'voice' || ch.type === 'video') {
const users = connectionManager.getVoiceUsers(ch.id); const participants = connectionManager.getRoomParticipants(ch.id);
if (users.size > 0) { if (participants.size > 0) {
voiceStates[ch.id] = Array.from(users); 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<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }> = {}; const voiceUserStates: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }> = {};
for (const chId of Object.keys(voiceStates)) { for (const chId of Object.keys(voiceStates)) {
const usersInChannel = voiceStates[chId]; const usersInChannel = voiceStates[chId];
@@ -531,7 +729,7 @@ function buildReadyPayload(userId: string): {
lastReadMessageId: rs.lastReadMessageId, 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<void> { export async function registerWebSocket(app: FastifyInstance): Promise<void> {
+11 -1
View File
@@ -135,6 +135,16 @@ export interface Attachment {
createdAt: number; createdAt: number;
} }
// ─── Active Call Types ───────────────────────────────────────────────────────
export interface ActiveCallInfo {
dmChannelId: string;
callerId: string;
participants: string[];
startedAt: number;
state: 'ringing' | 'active';
}
// ─── DM Types ─────────────────────────────────────────────────────────────── // ─── DM Types ───────────────────────────────────────────────────────────────
export interface DmChannel { export interface DmChannel {
@@ -191,7 +201,7 @@ export type ClientEvent =
// Server → Client Events // Server → Client Events
export type ServerEvent = export type ServerEvent =
| { type: 'ready'; user: User; servers: ServerWithChannelsAndMembers[]; dmChannels: DmChannel[]; folders?: ServerFolder[]; voiceStates?: Record<string, string[]>; voiceUserStates?: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>; readStates?: ReadState[] } | { type: 'ready'; user: User; servers: ServerWithChannelsAndMembers[]; dmChannels: DmChannel[]; folders?: ServerFolder[]; voiceStates?: Record<string, string[]>; voiceUserStates?: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>; readStates?: ReadState[]; activeCalls?: ActiveCallInfo[] }
| { type: 'message_created'; message: MessageWithUser } | { type: 'message_created'; message: MessageWithUser }
| { type: 'message_updated'; message: MessageWithUser } | { type: 'message_updated'; message: MessageWithUser }
| { type: 'message_deleted'; messageId: string; channelId: string } | { type: 'message_deleted'; messageId: string; channelId: string }
+34 -1
View File
@@ -4,7 +4,7 @@ import { useServerStore } from '../stores/serverStore';
import { useChatStore } from '../stores/chatStore'; import { useChatStore } from '../stores/chatStore';
import { useVoiceStore } from '../stores/voiceStore'; import { useVoiceStore } from '../stores/voiceStore';
import { useSocialStore } from '../stores/socialStore'; 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 globalWs: WebSocket | null = null;
let reconnectAttempts = 0; let reconnectAttempts = 0;
@@ -61,6 +61,39 @@ function handleEvent(event: ServerEvent): void {
wsSend({ type: 'voice_status', isMuted: curMuted, isDeafened: curDeafened, isCameraOn: curCamera, isScreenSharing: curScreen }); 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; break;
case 'message_created': case 'message_created':