feat: bans system, voice moderation, and federated space settings fixes
Add ban/unban functionality with BansPanel in space settings, voice moderation context menu (mute/deafen/disconnect), and fix federated space settings panels to use origin-aware API client. Show domain indicators for federated members in MembersPanel.
This commit is contained in:
@@ -116,6 +116,18 @@ export function runMigrations(db: Database.Database): void {
|
||||
);
|
||||
`);
|
||||
|
||||
// Ensure bans table exists (idempotent)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS bans (
|
||||
space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
reason TEXT,
|
||||
banned_by TEXT NOT NULL REFERENCES users(id),
|
||||
created_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (space_id, user_id)
|
||||
);
|
||||
`);
|
||||
|
||||
// Ensure join_requests table exists (idempotent)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS join_requests (
|
||||
|
||||
@@ -197,6 +197,16 @@ export const instanceSettings = sqliteTable('instance_settings', {
|
||||
updatedAt: integer('updated_at').notNull(),
|
||||
});
|
||||
|
||||
export const bans = sqliteTable('bans', {
|
||||
spaceId: text('space_id').notNull().references(() => spaces.id, { onDelete: 'cascade' }),
|
||||
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||
reason: text('reason'),
|
||||
bannedBy: text('banned_by').notNull().references(() => users.id),
|
||||
createdAt: integer('created_at').notNull(),
|
||||
}, (table) => ({
|
||||
pk: primaryKey({ columns: [table.spaceId, table.userId] }),
|
||||
}));
|
||||
|
||||
export const joinRequests = sqliteTable('join_requests', {
|
||||
id: text('id').primaryKey(),
|
||||
spaceId: text('space_id').notNull().references(() => spaces.id, { onDelete: 'cascade' }),
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { AccessToken } from 'livekit-server-sdk';
|
||||
import { AccessToken, TrackSource } from 'livekit-server-sdk';
|
||||
import { authenticate } from '../utils/auth.js';
|
||||
import { config } from '../config.js';
|
||||
import { getChannelSpaceId, hasPermission, isDmMember, PermissionBits } from '../utils/permissions.js';
|
||||
import { getChannelSpaceId, hasPermission, computePermissions, isDmMember, PermissionBits } from '../utils/permissions.js';
|
||||
import type { LiveKitTokenRequest, LiveKitTokenResponse } from '@backspace/shared';
|
||||
|
||||
export async function livekitRoutes(app: FastifyInstance): Promise<void> {
|
||||
@@ -18,6 +18,10 @@ export async function livekitRoutes(app: FastifyInstance): Promise<void> {
|
||||
// Determine room name based on channel type
|
||||
let roomName: string;
|
||||
|
||||
// Default: full publish (DM calls always get full permissions)
|
||||
let canSpeak = true;
|
||||
let canStream = true;
|
||||
|
||||
if (dmChannelId && typeof dmChannelId === 'string') {
|
||||
// DM call token
|
||||
if (!isDmMember(dmChannelId, request.userId)) {
|
||||
@@ -33,6 +37,10 @@ export async function livekitRoutes(app: FastifyInstance): Promise<void> {
|
||||
if (!hasPermission(request.userId, spaceId, PermissionBits.CONNECT, channelId)) {
|
||||
return reply.code(403).send({ error: 'Missing CONNECT permission', statusCode: 403 });
|
||||
}
|
||||
// Check SPEAK and STREAM permissions for granular token grants
|
||||
const perms = computePermissions(request.userId, spaceId, channelId);
|
||||
canSpeak = (perms & PermissionBits.SPEAK) !== 0n || (perms & PermissionBits.ADMINISTRATOR) !== 0n;
|
||||
canStream = (perms & PermissionBits.STREAM) !== 0n || (perms & PermissionBits.ADMINISTRATOR) !== 0n;
|
||||
roomName = channelId;
|
||||
} else {
|
||||
return reply.code(400).send({ error: 'channelId or dmChannelId is required', statusCode: 400 });
|
||||
@@ -45,10 +53,21 @@ export async function livekitRoutes(app: FastifyInstance): Promise<void> {
|
||||
ttl: '1h',
|
||||
});
|
||||
|
||||
// Build canPublishSources based on permissions
|
||||
const canPublishSources: TrackSource[] = [];
|
||||
if (canSpeak) {
|
||||
canPublishSources.push(TrackSource.MICROPHONE);
|
||||
canPublishSources.push(TrackSource.CAMERA);
|
||||
}
|
||||
if (canStream) {
|
||||
canPublishSources.push(TrackSource.SCREEN_SHARE, TrackSource.SCREEN_SHARE_AUDIO);
|
||||
}
|
||||
|
||||
token.addGrant({
|
||||
room: roomName,
|
||||
roomJoin: true,
|
||||
canPublish: true,
|
||||
canPublish: canSpeak || canStream,
|
||||
canPublishSources,
|
||||
canSubscribe: true,
|
||||
canPublishData: true,
|
||||
});
|
||||
|
||||
@@ -260,6 +260,11 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
|
||||
return reply.code(403).send({ error: 'Missing SEND_MESSAGES permission', statusCode: 403 });
|
||||
}
|
||||
|
||||
if (attachmentIds && attachmentIds.length > 0 &&
|
||||
!hasPermission(request.userId, spaceId, PermissionBits.ATTACH_FILES, id)) {
|
||||
return reply.code(403).send({ error: 'Missing ATTACH_FILES permission', statusCode: 403 });
|
||||
}
|
||||
|
||||
if ((!content || typeof content !== 'string' || content.trim().length === 0) &&
|
||||
(!attachmentIds || attachmentIds.length === 0)) {
|
||||
return reply.code(400).send({ error: 'Message must have content or attachments', statusCode: 400 });
|
||||
|
||||
@@ -3,7 +3,7 @@ import { eq, and, inArray } from 'drizzle-orm';
|
||||
import { getDb, schema } from '../db/index.js';
|
||||
import { authenticate } from '../utils/auth.js';
|
||||
import { generateSnowflake } from '../utils/snowflake.js';
|
||||
import { isMember, isSpaceOwner, hasPermission, computePermissions, PermissionBits } from '../utils/permissions.js';
|
||||
import { isMember, isSpaceOwner, isBanned, hasPermission, computePermissions, PermissionBits } from '../utils/permissions.js';
|
||||
import { DEFAULT_EVERYONE_PERMISSIONS, ALL_PERMISSIONS, permissionsToString } from '@backspace/shared/src/permissions.js';
|
||||
import crypto from 'crypto';
|
||||
import { connectionManager } from '../ws/handler.js';
|
||||
@@ -411,6 +411,10 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
|
||||
return reply.code(400).send({ error: 'Invalid invite code', statusCode: 400 });
|
||||
}
|
||||
|
||||
if (isBanned(id, request.userId)) {
|
||||
return reply.code(403).send({ error: 'You are banned from this space', statusCode: 403 });
|
||||
}
|
||||
|
||||
if (isMember(id, request.userId)) {
|
||||
return reply.code(409).send({ error: 'You are already a member of this space', statusCode: 409 });
|
||||
}
|
||||
@@ -463,6 +467,10 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
|
||||
return reply.code(404).send({ error: 'Invalid invite code', statusCode: 404 });
|
||||
}
|
||||
|
||||
if (isBanned(server.id, request.userId)) {
|
||||
return reply.code(403).send({ error: 'You are banned from this space', statusCode: 403 });
|
||||
}
|
||||
|
||||
if (isMember(server.id, request.userId)) {
|
||||
return reply.code(409).send({ error: 'You are already a member of this space', statusCode: 409 });
|
||||
}
|
||||
@@ -931,4 +939,144 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
|
||||
|
||||
return reply.code(200).send({ success: true });
|
||||
});
|
||||
|
||||
// ─── Ban Management ───────────────────────────────────────────────────────
|
||||
|
||||
// GET /api/spaces/:id/bans - List bans
|
||||
app.get<{ Params: { id: string } }>('/api/spaces/:id/bans', {
|
||||
preHandler: authenticate,
|
||||
}, async (request, reply) => {
|
||||
const { id } = request.params;
|
||||
const db = getDb();
|
||||
|
||||
if (!hasPermission(request.userId, id, PermissionBits.BAN_MEMBERS)) {
|
||||
return reply.code(403).send({ error: 'Missing BAN_MEMBERS permission', statusCode: 403 });
|
||||
}
|
||||
|
||||
const banRows = db.select().from(schema.bans)
|
||||
.where(eq(schema.bans.spaceId, id))
|
||||
.all();
|
||||
|
||||
if (banRows.length === 0) return reply.code(200).send([]);
|
||||
|
||||
const userIds = [...new Set(banRows.map(b => b.userId))];
|
||||
const bannedByIds = [...new Set(banRows.map(b => b.bannedBy))];
|
||||
const allUserIds = [...new Set([...userIds, ...bannedByIds])];
|
||||
const users = db.select().from(schema.users)
|
||||
.where(inArray(schema.users.id, allUserIds))
|
||||
.all();
|
||||
const userMap = new Map(users.map(u => [u.id, u]));
|
||||
|
||||
const bans = banRows.map(b => {
|
||||
const user = userMap.get(b.userId);
|
||||
const moderator = userMap.get(b.bannedBy);
|
||||
return {
|
||||
spaceId: b.spaceId,
|
||||
userId: b.userId,
|
||||
reason: b.reason,
|
||||
bannedBy: b.bannedBy,
|
||||
createdAt: b.createdAt,
|
||||
user: user ? sanitizeUser(user) : null,
|
||||
moderator: moderator ? sanitizeUser(moderator) : null,
|
||||
};
|
||||
});
|
||||
|
||||
return reply.code(200).send(bans);
|
||||
});
|
||||
|
||||
// POST /api/spaces/:id/bans - Ban a member
|
||||
app.post<{ Params: { id: string }; Body: { userId: string; reason?: string } }>('/api/spaces/:id/bans', {
|
||||
preHandler: authenticate,
|
||||
}, async (request, reply) => {
|
||||
const { id } = request.params;
|
||||
const { userId: targetId, reason } = request.body;
|
||||
const db = getDb();
|
||||
|
||||
if (!targetId || typeof targetId !== 'string') {
|
||||
return reply.code(400).send({ error: 'userId is required', statusCode: 400 });
|
||||
}
|
||||
|
||||
if (!hasPermission(request.userId, id, PermissionBits.BAN_MEMBERS)) {
|
||||
return reply.code(403).send({ error: 'Missing BAN_MEMBERS permission', statusCode: 403 });
|
||||
}
|
||||
|
||||
// Cannot ban the space owner
|
||||
if (isSpaceOwner(id, targetId)) {
|
||||
return reply.code(400).send({ error: 'Cannot ban the space owner', statusCode: 400 });
|
||||
}
|
||||
|
||||
// Cannot ban yourself
|
||||
if (targetId === request.userId) {
|
||||
return reply.code(400).send({ error: 'Cannot ban yourself', statusCode: 400 });
|
||||
}
|
||||
|
||||
// Check if already banned
|
||||
if (isBanned(id, targetId)) {
|
||||
return reply.code(409).send({ error: 'User is already banned', statusCode: 409 });
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
db.transaction((tx) => {
|
||||
// Insert ban record
|
||||
tx.insert(schema.bans).values({
|
||||
spaceId: id,
|
||||
userId: targetId,
|
||||
reason: reason?.trim() || null,
|
||||
bannedBy: request.userId,
|
||||
createdAt: now,
|
||||
}).run();
|
||||
|
||||
// Remove member from space
|
||||
tx.delete(schema.spaceMembers).where(and(
|
||||
eq(schema.spaceMembers.spaceId, id),
|
||||
eq(schema.spaceMembers.userId, targetId),
|
||||
)).run();
|
||||
|
||||
// Remove member's role assignments
|
||||
tx.delete(schema.memberRoles).where(and(
|
||||
eq(schema.memberRoles.spaceId, id),
|
||||
eq(schema.memberRoles.userId, targetId),
|
||||
)).run();
|
||||
});
|
||||
|
||||
// Broadcast member_left event so other clients update their member list
|
||||
connectionManager.sendToSpace(id, {
|
||||
type: 'member_left',
|
||||
spaceId: id,
|
||||
userId: targetId,
|
||||
});
|
||||
|
||||
// Notify the banned user
|
||||
connectionManager.sendToUser(targetId, {
|
||||
type: 'member_banned',
|
||||
spaceId: id,
|
||||
reason: reason?.trim() || null,
|
||||
});
|
||||
|
||||
return reply.code(200).send({ success: true });
|
||||
});
|
||||
|
||||
// DELETE /api/spaces/:id/bans/:uid - Unban a user
|
||||
app.delete<{ Params: { id: string; uid: string } }>('/api/spaces/:id/bans/:uid', {
|
||||
preHandler: authenticate,
|
||||
}, async (request, reply) => {
|
||||
const { id, uid } = request.params;
|
||||
const db = getDb();
|
||||
|
||||
if (!hasPermission(request.userId, id, PermissionBits.BAN_MEMBERS)) {
|
||||
return reply.code(403).send({ error: 'Missing BAN_MEMBERS permission', statusCode: 403 });
|
||||
}
|
||||
|
||||
const result = db.delete(schema.bans).where(and(
|
||||
eq(schema.bans.spaceId, id),
|
||||
eq(schema.bans.userId, uid),
|
||||
)).run();
|
||||
|
||||
if (result.changes === 0) {
|
||||
return reply.code(404).send({ error: 'Ban not found', statusCode: 404 });
|
||||
}
|
||||
|
||||
return reply.code(200).send({ success: true });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -155,3 +155,14 @@ export function isDmMember(dmChannelId: string, userId: string): boolean {
|
||||
.get();
|
||||
return member !== undefined;
|
||||
}
|
||||
|
||||
export function isBanned(spaceId: string, userId: string): boolean {
|
||||
const db = getDb();
|
||||
const ban = db.select().from(schema.bans)
|
||||
.where(and(
|
||||
eq(schema.bans.spaceId, spaceId),
|
||||
eq(schema.bans.userId, userId),
|
||||
))
|
||||
.get();
|
||||
return ban !== undefined;
|
||||
}
|
||||
|
||||
@@ -150,6 +150,15 @@ export function handleClientEvent(
|
||||
case 'voice_status':
|
||||
handleVoiceStatus(event, userId);
|
||||
break;
|
||||
case 'voice_server_mute':
|
||||
handleVoiceServerMute(event, userId);
|
||||
break;
|
||||
case 'voice_server_deafen':
|
||||
handleVoiceServerDeafen(event, userId);
|
||||
break;
|
||||
case 'voice_move':
|
||||
handleVoiceMove(event, userId);
|
||||
break;
|
||||
default:
|
||||
connectionManager.sendToUser(userId, {
|
||||
type: 'error',
|
||||
@@ -708,6 +717,11 @@ function handleReactionAdd(event: Record<string, unknown>, userId: string): void
|
||||
const spaceId = getChannelSpaceId(message.channelId);
|
||||
if (!spaceId || !isMember(spaceId, userId)) return;
|
||||
|
||||
if (!hasPermission(userId, spaceId, PermissionBits.ADD_REACTIONS, message.channelId)) {
|
||||
connectionManager.sendToUser(userId, { type: 'error', message: 'Missing ADD_REACTIONS permission' });
|
||||
return;
|
||||
}
|
||||
|
||||
const reactionId = generateSnowflake();
|
||||
const now = Date.now();
|
||||
try {
|
||||
@@ -1040,3 +1054,170 @@ function handleDmCallEnd(event: Record<string, unknown>, userId: string): void {
|
||||
dmChannelId,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Voice Moderation Handlers ──────────────────────────────────────────────
|
||||
|
||||
function handleVoiceServerMute(event: Record<string, unknown>, userId: string): void {
|
||||
const targetUserId = event.userId as string;
|
||||
const muted = event.muted === true;
|
||||
|
||||
if (!targetUserId || typeof targetUserId !== 'string') {
|
||||
connectionManager.sendToUser(userId, { type: 'error', message: 'userId is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the target user's current room
|
||||
const targetRoom = connectionManager.getUserRoom(targetUserId);
|
||||
if (!targetRoom || targetRoom.room.roomType !== 'space') {
|
||||
connectionManager.sendToUser(userId, { type: 'error', message: 'Target user is not in a voice channel' });
|
||||
return;
|
||||
}
|
||||
|
||||
const meta = targetRoom.room.metadata as SpaceRoomMeta;
|
||||
if (!hasPermission(userId, meta.spaceId, PermissionBits.MUTE_MEMBERS, targetRoom.roomId)) {
|
||||
connectionManager.sendToUser(userId, { type: 'error', message: 'Missing MUTE_MEMBERS permission' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Cannot server-mute yourself
|
||||
if (targetUserId === userId) {
|
||||
connectionManager.sendToUser(userId, { type: 'error', message: 'Cannot server-mute yourself' });
|
||||
return;
|
||||
}
|
||||
|
||||
connectionManager.setServerMuted(targetUserId, muted);
|
||||
|
||||
// Broadcast to all space members
|
||||
connectionManager.sendToSpace(meta.spaceId, {
|
||||
type: 'voice_server_muted',
|
||||
userId: targetUserId,
|
||||
channelId: targetRoom.roomId,
|
||||
muted,
|
||||
});
|
||||
}
|
||||
|
||||
function handleVoiceServerDeafen(event: Record<string, unknown>, userId: string): void {
|
||||
const targetUserId = event.userId as string;
|
||||
const deafened = event.deafened === true;
|
||||
|
||||
if (!targetUserId || typeof targetUserId !== 'string') {
|
||||
connectionManager.sendToUser(userId, { type: 'error', message: 'userId is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const targetRoom = connectionManager.getUserRoom(targetUserId);
|
||||
if (!targetRoom || targetRoom.room.roomType !== 'space') {
|
||||
connectionManager.sendToUser(userId, { type: 'error', message: 'Target user is not in a voice channel' });
|
||||
return;
|
||||
}
|
||||
|
||||
const meta = targetRoom.room.metadata as SpaceRoomMeta;
|
||||
if (!hasPermission(userId, meta.spaceId, PermissionBits.DEAFEN_MEMBERS, targetRoom.roomId)) {
|
||||
connectionManager.sendToUser(userId, { type: 'error', message: 'Missing DEAFEN_MEMBERS permission' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (targetUserId === userId) {
|
||||
connectionManager.sendToUser(userId, { type: 'error', message: 'Cannot server-deafen yourself' });
|
||||
return;
|
||||
}
|
||||
|
||||
connectionManager.setServerDeafened(targetUserId, deafened);
|
||||
|
||||
connectionManager.sendToSpace(meta.spaceId, {
|
||||
type: 'voice_server_deafened',
|
||||
userId: targetUserId,
|
||||
channelId: targetRoom.roomId,
|
||||
deafened,
|
||||
});
|
||||
}
|
||||
|
||||
function handleVoiceMove(event: Record<string, unknown>, userId: string): void {
|
||||
const targetUserId = event.userId as string;
|
||||
const targetChannelId = event.targetChannelId as string;
|
||||
|
||||
if (!targetUserId || typeof targetUserId !== 'string') {
|
||||
connectionManager.sendToUser(userId, { type: 'error', message: 'userId is required' });
|
||||
return;
|
||||
}
|
||||
if (!targetChannelId || typeof targetChannelId !== 'string') {
|
||||
connectionManager.sendToUser(userId, { type: 'error', message: 'targetChannelId is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the target user's current room
|
||||
const currentRoom = connectionManager.getUserRoom(targetUserId);
|
||||
if (!currentRoom || currentRoom.room.roomType !== 'space') {
|
||||
connectionManager.sendToUser(userId, { type: 'error', message: 'Target user is not in a voice channel' });
|
||||
return;
|
||||
}
|
||||
|
||||
const meta = currentRoom.room.metadata as SpaceRoomMeta;
|
||||
if (!hasPermission(userId, meta.spaceId, PermissionBits.MOVE_MEMBERS, currentRoom.roomId)) {
|
||||
connectionManager.sendToUser(userId, { type: 'error', message: 'Missing MOVE_MEMBERS permission' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify target channel exists and is a voice/video channel in the same space
|
||||
const db = getDb();
|
||||
const targetChannel = db.select().from(schema.channels).where(eq(schema.channels.id, targetChannelId)).get();
|
||||
if (!targetChannel || targetChannel.spaceId !== meta.spaceId) {
|
||||
connectionManager.sendToUser(userId, { type: 'error', message: 'Target channel not found in this space' });
|
||||
return;
|
||||
}
|
||||
if (targetChannel.type !== 'voice' && targetChannel.type !== 'video') {
|
||||
connectionManager.sendToUser(userId, { type: 'error', message: 'Target channel is not a voice channel' });
|
||||
return;
|
||||
}
|
||||
if (targetChannelId === currentRoom.roomId) {
|
||||
connectionManager.sendToUser(userId, { type: 'error', message: 'User is already in that channel' });
|
||||
return;
|
||||
}
|
||||
|
||||
const oldChannelId = currentRoom.roomId;
|
||||
|
||||
// Leave current room
|
||||
connectionManager.leaveRoom(oldChannelId, targetUserId);
|
||||
|
||||
// Broadcast leave from old channel
|
||||
connectionManager.sendToSpace(meta.spaceId, {
|
||||
type: 'voice_state_update',
|
||||
channelId: oldChannelId,
|
||||
userId: targetUserId,
|
||||
action: 'leave',
|
||||
});
|
||||
|
||||
// Lazy-create target room and join
|
||||
connectionManager.createRoom(targetChannelId, 'space', { type: 'space', spaceId: meta.spaceId });
|
||||
connectionManager.joinRoom(targetChannelId, targetUserId);
|
||||
|
||||
// Broadcast join to new channel
|
||||
connectionManager.sendToSpace(meta.spaceId, {
|
||||
type: 'voice_state_update',
|
||||
channelId: targetChannelId,
|
||||
userId: targetUserId,
|
||||
action: 'join',
|
||||
});
|
||||
|
||||
// Notify the moved user so they reconnect to LiveKit
|
||||
connectionManager.sendToUser(targetUserId, {
|
||||
type: 'voice_moved',
|
||||
userId: targetUserId,
|
||||
oldChannelId,
|
||||
newChannelId: targetChannelId,
|
||||
});
|
||||
|
||||
// Preserve voice user status during move
|
||||
const status = connectionManager.getVoiceUserStatus(targetUserId);
|
||||
if (status) {
|
||||
connectionManager.sendToRoom(targetChannelId, {
|
||||
type: 'voice_status_update',
|
||||
userId: targetUserId,
|
||||
channelId: targetChannelId,
|
||||
isMuted: status.isMuted,
|
||||
isDeafened: status.isDeafened,
|
||||
isCameraOn: status.isCameraOn,
|
||||
isScreenSharing: status.isScreenSharing,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,6 +77,9 @@ class ConnectionManager {
|
||||
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();
|
||||
// Server-muted/deafened users (moderator action)
|
||||
private serverMutedUsers: Set<string> = new Set();
|
||||
private serverDeafenedUsers: Set<string> = new Set();
|
||||
|
||||
addConnection(userId: string, ws: WebSocket): void {
|
||||
if (!this.connections.has(userId)) {
|
||||
@@ -301,6 +304,7 @@ class ConnectionManager {
|
||||
|
||||
room.participants.delete(userId);
|
||||
this.userToRoom.delete(userId);
|
||||
this.clearServerVoiceState(userId);
|
||||
|
||||
// Auto-cleanup empty space rooms (they're lazy-created)
|
||||
if (room.participants.size === 0 && room.roomType === 'space') {
|
||||
@@ -382,6 +386,29 @@ class ConnectionManager {
|
||||
this.voiceUserStates.delete(userId);
|
||||
}
|
||||
|
||||
setServerMuted(userId: string, muted: boolean): void {
|
||||
if (muted) this.serverMutedUsers.add(userId);
|
||||
else this.serverMutedUsers.delete(userId);
|
||||
}
|
||||
|
||||
isServerMuted(userId: string): boolean {
|
||||
return this.serverMutedUsers.has(userId);
|
||||
}
|
||||
|
||||
setServerDeafened(userId: string, deafened: boolean): void {
|
||||
if (deafened) this.serverDeafenedUsers.add(userId);
|
||||
else this.serverDeafenedUsers.delete(userId);
|
||||
}
|
||||
|
||||
isServerDeafened(userId: string): boolean {
|
||||
return this.serverDeafenedUsers.has(userId);
|
||||
}
|
||||
|
||||
clearServerVoiceState(userId: string): void {
|
||||
this.serverMutedUsers.delete(userId);
|
||||
this.serverDeafenedUsers.delete(userId);
|
||||
}
|
||||
|
||||
getAllVoiceUserStates(): Map<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }> {
|
||||
return this.voiceUserStates;
|
||||
}
|
||||
@@ -536,6 +563,7 @@ function buildReadyPayload(userId: string): {
|
||||
folders: SpaceFolder[];
|
||||
voiceStates: Record<string, string[]>;
|
||||
voiceUserStates: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>;
|
||||
serverVoiceStates: Record<string, { serverMuted: boolean; serverDeafened: boolean }>;
|
||||
readStates: ReadState[];
|
||||
activeCalls: ActiveCallInfo[];
|
||||
} {
|
||||
@@ -847,6 +875,21 @@ function buildReadyPayload(userId: string): {
|
||||
}
|
||||
}
|
||||
|
||||
// Build server mute/deafen states for users currently in voice
|
||||
const serverVoiceStates: Record<string, { serverMuted: boolean; serverDeafened: boolean }> = {};
|
||||
for (const chId of Object.keys(voiceStates)) {
|
||||
const usersInChannel = voiceStates[chId];
|
||||
if (usersInChannel) {
|
||||
for (const uid of usersInChannel) {
|
||||
const sm = connectionManager.isServerMuted(uid);
|
||||
const sd = connectionManager.isServerDeafened(uid);
|
||||
if (sm || sd) {
|
||||
serverVoiceStates[uid] = { serverMuted: sm, serverDeafened: sd };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch read states for unread tracking
|
||||
const readStateRows = db.select()
|
||||
.from(schema.readStates)
|
||||
@@ -858,7 +901,7 @@ function buildReadyPayload(userId: string): {
|
||||
lastReadMessageId: rs.lastReadMessageId,
|
||||
}));
|
||||
|
||||
return { user, spaces, dmChannels, folders, voiceStates, voiceUserStates, readStates, activeCalls };
|
||||
return { user, spaces, dmChannels, folders, voiceStates, voiceUserStates, serverVoiceStates, readStates, activeCalls };
|
||||
}
|
||||
|
||||
export async function registerWebSocket(app: FastifyInstance): Promise<void> {
|
||||
|
||||
Reference in New Issue
Block a user