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:
+174
-111
@@ -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,19 +487,39 @@ 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, {
|
connectionManager.clearVoiceUserStatus(userId);
|
||||||
type: 'voice_state_update',
|
}
|
||||||
channelId,
|
|
||||||
|
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,
|
userId,
|
||||||
action: 'leave',
|
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;
|
||||||
@@ -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()
|
|
||||||
.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',
|
type: 'dm_call_incoming',
|
||||||
dmChannelId,
|
dmChannelId,
|
||||||
callerId: userId,
|
callerId: userId,
|
||||||
callerName: username,
|
callerName: username,
|
||||||
});
|
}, userId);
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
||||||
|
|
||||||
|
// 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',
|
type: 'dm_call_accepted',
|
||||||
dmChannelId,
|
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,28 +953,18 @@ 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()
|
|
||||||
.from(schema.dmMembers)
|
|
||||||
.where(eq(schema.dmMembers.dmChannelId, dmChannelId))
|
|
||||||
.all();
|
|
||||||
|
|
||||||
for (const member of dmMembers) {
|
|
||||||
connectionManager.sendToUser(member.userId, {
|
|
||||||
type: 'dm_call_rejected',
|
type: 'dm_call_rejected',
|
||||||
dmChannelId,
|
dmChannelId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function handleDmCallEnd(event: Record<string, unknown>, userId: string): void {
|
function handleDmCallEnd(event: Record<string, unknown>, userId: string): void {
|
||||||
const dmChannelId = event.dmChannelId as string;
|
const dmChannelId = event.dmChannelId as string;
|
||||||
@@ -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()
|
|
||||||
.from(schema.dmMembers)
|
|
||||||
.where(eq(schema.dmMembers.dmChannelId, dmChannelId))
|
|
||||||
.all();
|
|
||||||
|
|
||||||
for (const member of dmMembers) {
|
|
||||||
connectionManager.sendToUser(member.userId, {
|
|
||||||
type: 'dm_call_ended',
|
type: 'dm_call_ended',
|
||||||
dmChannelId,
|
dmChannelId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|||||||
@@ -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)) {
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 {
|
room.participants.add(userId);
|
||||||
let leftChannelId: string | null = null;
|
this.userToRoom.set(userId, roomId);
|
||||||
for (const [channelId, users] of this.voiceStates) {
|
return room;
|
||||||
if (users.has(userId)) {
|
|
||||||
users.delete(userId);
|
|
||||||
if (users.size === 0) {
|
|
||||||
this.voiceStates.delete(channelId);
|
|
||||||
}
|
|
||||||
leftChannelId = channelId;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return leftChannelId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
|
|
||||||
|
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 {
|
return room;
|
||||||
for (const [channelId, users] of this.voiceStates) {
|
|
||||||
if (users.has(userId)) {
|
|
||||||
return channelId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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();
|
||||||
|
|
||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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':
|
||||||
|
|||||||
Reference in New Issue
Block a user