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:
@@ -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