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
+174 -111
View File
@@ -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<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 {
const channelId = event.channelId as string;
@@ -385,13 +417,13 @@ function handleVoiceJoin(event: Record<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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,19 +487,39 @@ function handleVoiceJoin(event: Record<string, unknown>, 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,
const left = connectionManager.leaveCurrentRoom(userId);
if (left) {
broadcastRoomLeave(left.roomId, left.room, userId);
}
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,
action: 'leave',
channelId: userRoom.roomId,
isMuted,
isDeafened,
isCameraOn,
isScreenSharing,
});
}
}
}
// ─── DM Message Handlers ───────────────────────────────────────────────────
function handleDmMessageCreate(event: Record<string, unknown>, userId: string): void {
const dmChannelId = event.dmChannelId 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 {
const messageId = event.messageId 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 {
const channelId = event.channelId 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 {
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<string, unknown>, userId: string, username: string): void {
const dmChannelId = event.dmChannelId as string;
@@ -804,30 +845,41 @@ function handleDmCallStart(event: Record<string, unknown>, 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, {
// Ring other members
connectionManager.sendToDmMembers(dmChannelId, {
type: 'dm_call_incoming',
dmChannelId,
callerId: userId,
callerName: username,
});
}
}
}, userId);
}
function handleDmCallAccept(event: Record<string, unknown>, userId: string): void {
@@ -842,25 +894,51 @@ function handleDmCallAccept(event: Record<string, unknown>, 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, {
// 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<string, unknown>, userId: string): void {
@@ -875,28 +953,18 @@ function handleDmCallReject(event: Record<string, unknown>, 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, {
connectionManager.sendToDmMembers(dmChannelId, {
type: 'dm_call_rejected',
dmChannelId,
});
}
}
function handleDmCallEnd(event: Record<string, unknown>, userId: string): void {
const dmChannelId = event.dmChannelId as string;
@@ -910,25 +978,20 @@ function handleDmCallEnd(event: Record<string, unknown>, 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, {
connectionManager.sendToDmMembers(dmChannelId, {
type: 'dm_call_ended',
dmChannelId,
});
}
}
+269 -71
View File
@@ -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<string>;
metadata: ServerRoomMeta | DmRoomMeta;
startedAt: number;
}
// ─── ConnectionManager ─────────────────────────────────────────────────────
class ConnectionManager {
// userId → Set of WebSocket connections (multiple tabs)
private connections: Map<string, Set<WebSocket>> = new Map();
// userId → Set of server IDs the user belongs to
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)
private wsToUser: Map<WebSocket, string> = new Map();
// dmChannelId → { callerId, startedAt } — active DM calls
private activeCalls: Map<string, { callerId: string; startedAt: number }> = new Map();
// Unified voice room tracking (replaces voiceStates + activeCalls)
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
private voiceUserStates: Map<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }> = new Map();
// userId → Timeout
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 {
if (!this.connections.has(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;
}
/** 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);
}
}
}
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);
}
leftChannelId = channelId;
break;
}
}
return leftChannelId;
room.participants.add(userId);
this.userToRoom.set(userId, roomId);
return room;
}
getVoiceUsers(channelId: string): Set<string> {
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;
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);
}
getUserVoiceChannel(userId: string): string | null {
for (const [channelId, users] of this.voiceStates) {
if (users.has(userId)) {
return channelId;
}
}
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 {
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<string, string[]>;
voiceUserStates: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>;
readStates: ReadState[];
activeCalls: ActiveCallInfo[];
} {
const db = getDb();
@@ -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<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }> = {};
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<void> {
+11 -1
View File
@@ -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<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_updated'; message: MessageWithUser }
| { 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 { 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':