diff --git a/packages/server/src/db/migrate.ts b/packages/server/src/db/migrate.ts index b536613f..c5e91811 100644 --- a/packages/server/src/db/migrate.ts +++ b/packages/server/src/db/migrate.ts @@ -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 ( diff --git a/packages/server/src/db/schema.ts b/packages/server/src/db/schema.ts index 32eec279..91e06837 100644 --- a/packages/server/src/db/schema.ts +++ b/packages/server/src/db/schema.ts @@ -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' }), diff --git a/packages/server/src/routes/livekit.ts b/packages/server/src/routes/livekit.ts index 08a69dad..f2a15f95 100644 --- a/packages/server/src/routes/livekit.ts +++ b/packages/server/src/routes/livekit.ts @@ -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 { @@ -18,6 +18,10 @@ export async function livekitRoutes(app: FastifyInstance): Promise { // 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 { 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 { 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, }); diff --git a/packages/server/src/routes/messages.ts b/packages/server/src/routes/messages.ts index 5ec6518b..442599a2 100644 --- a/packages/server/src/routes/messages.ts +++ b/packages/server/src/routes/messages.ts @@ -260,6 +260,11 @@ export async function messageRoutes(app: FastifyInstance): Promise { 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 }); diff --git a/packages/server/src/routes/spaces.ts b/packages/server/src/routes/spaces.ts index 41d9101a..284b5599 100644 --- a/packages/server/src/routes/spaces.ts +++ b/packages/server/src/routes/spaces.ts @@ -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 { 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 { 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 { 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 }); + }); } diff --git a/packages/server/src/utils/permissions.ts b/packages/server/src/utils/permissions.ts index 4bbcc497..76e68071 100644 --- a/packages/server/src/utils/permissions.ts +++ b/packages/server/src/utils/permissions.ts @@ -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; +} diff --git a/packages/server/src/ws/events.ts b/packages/server/src/ws/events.ts index 61a6d800..68a80de2 100644 --- a/packages/server/src/ws/events.ts +++ b/packages/server/src/ws/events.ts @@ -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, 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, userId: string): void { dmChannelId, }); } + +// ─── Voice Moderation Handlers ────────────────────────────────────────────── + +function handleVoiceServerMute(event: Record, 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, 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, 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, + }); + } +} diff --git a/packages/server/src/ws/handler.ts b/packages/server/src/ws/handler.ts index 30ddc733..c6ed0ae8 100644 --- a/packages/server/src/ws/handler.ts +++ b/packages/server/src/ws/handler.ts @@ -77,6 +77,9 @@ class ConnectionManager { private pendingOfflineTimeouts: Map = new Map(); // roomId → Timeout for ringing DM rooms (60s auto-cleanup) private ringingTimeouts: Map = new Map(); + // Server-muted/deafened users (moderator action) + private serverMutedUsers: Set = new Set(); + private serverDeafenedUsers: Set = 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 { return this.voiceUserStates; } @@ -536,6 +563,7 @@ function buildReadyPayload(userId: string): { folders: SpaceFolder[]; voiceStates: Record; voiceUserStates: Record; + serverVoiceStates: Record; 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 = {}; + 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 { diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index f8b3cfc2..7bd2501c 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -237,11 +237,14 @@ export type ClientEvent = | { type: 'dm_call_reject'; dmChannelId: string } | { type: 'dm_call_end'; dmChannelId: string } | { type: 'voice_status'; isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean } + | { type: 'voice_server_mute'; userId: string; muted: boolean } + | { type: 'voice_server_deafen'; userId: string; deafened: boolean } + | { type: 'voice_move'; userId: string; targetChannelId: string } | { type: 'ping' }; // Server → Client Events export type ServerEvent = - | { type: 'ready'; user: User; spaces: SpaceWithChannelsAndMembers[]; dmChannels: DmChannel[]; folders?: SpaceFolder[]; voiceStates?: Record; voiceUserStates?: Record; readStates?: ReadState[]; activeCalls?: ActiveCallInfo[] } + | { type: 'ready'; user: User; spaces: SpaceWithChannelsAndMembers[]; dmChannels: DmChannel[]; folders?: SpaceFolder[]; voiceStates?: Record; voiceUserStates?: Record; readStates?: ReadState[]; activeCalls?: ActiveCallInfo[]; serverVoiceStates?: Record } | { type: 'message_created'; message: MessageWithUser } | { type: 'message_updated'; message: MessageWithUser } | { type: 'message_deleted'; messageId: string; channelId: string } @@ -276,6 +279,10 @@ export type ServerEvent = | { type: 'join_request_received'; request: JoinRequest } | { type: 'join_request_accepted'; request: JoinRequest; space: SpaceWithChannelsAndMembers } | { type: 'join_request_declined'; request: JoinRequest } + | { type: 'voice_server_muted'; userId: string; channelId: string; muted: boolean } + | { type: 'voice_server_deafened'; userId: string; channelId: string; deafened: boolean } + | { type: 'voice_moved'; userId: string; oldChannelId: string; newChannelId: string } + | { type: 'member_banned'; spaceId: string; reason: string | null } | { type: 'pong' } | { type: 'error'; message: string }; diff --git a/packages/web/src/api/client.ts b/packages/web/src/api/client.ts index 04e6add3..2d027599 100644 --- a/packages/web/src/api/client.ts +++ b/packages/web/src/api/client.ts @@ -59,6 +59,9 @@ export class BackspaceApiClient { members: (id: string) => Promise; updateMember: (spaceId: string, userId: string, data: UpdateMemberRequest) => Promise; removeMember: (spaceId: string, userId: string) => Promise<{ success: boolean }>; + getBans: (spaceId: string) => Promise<{ spaceId: string; userId: string; reason: string | null; bannedBy: string; createdAt: number; user: any; moderator: any }[]>; + ban: (spaceId: string, userId: string, reason?: string) => Promise<{ success: boolean }>; + unban: (spaceId: string, userId: string) => Promise<{ success: boolean }>; }; readonly channels: { @@ -223,6 +226,12 @@ export class BackspaceApiClient { request('PATCH', `/spaces/${spaceId}/members/${userId}`, data), removeMember: (spaceId: string, userId: string) => request<{ success: boolean }>('DELETE', `/spaces/${spaceId}/members/${userId}`), + getBans: (spaceId: string) => + request('GET', `/spaces/${spaceId}/bans`), + ban: (spaceId: string, userId: string, reason?: string) => + request<{ success: boolean }>('POST', `/spaces/${spaceId}/bans`, { userId, reason }), + unban: (spaceId: string, userId: string) => + request<{ success: boolean }>('DELETE', `/spaces/${spaceId}/bans/${userId}`), }; this.channels = { diff --git a/packages/web/src/components/chat/Message.tsx b/packages/web/src/components/chat/Message.tsx index 733fe35e..12421b20 100644 --- a/packages/web/src/components/chat/Message.tsx +++ b/packages/web/src/components/chat/Message.tsx @@ -54,6 +54,8 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) { const myChPerms = channelPermissions.get(message.channelId); const canManageMessages = hasPermissionBit(myChPerms, PermissionBits.MANAGE_MESSAGES); const canDelete = isAuthor || canManageMessages; + const isDmMessage = !!(message as any).dmChannelId || !message.channelId; + const canAddReactions = isDmMessage || hasPermissionBit(myChPerms, PermissionBits.ADD_REACTIONS); const addReaction = useChatStore((s) => s.addReaction); const removeReaction = useChatStore((s) => s.removeReaction); @@ -66,7 +68,7 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) { const hasReacted = message.reactions?.some(r => isOwnReaction(r) && r.emoji === emoji); if (hasReacted) { removeReaction(message.id, emoji); - } else { + } else if (canAddReactions) { addReaction(message.id, emoji); } }; @@ -324,17 +326,19 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) { {/* Action buttons on hover */} {isHovered && !isEditing && (
-
- {['👍', '❤️', '😂', '😮'].map(emoji => ( - - ))} -
+ {canAddReactions && ( +
+ {['👍', '❤️', '😂', '😮'].map(emoji => ( + + ))} +
+ )} + {canAttachFiles && ( + + )} >(); + // Permission check: DM channels always allow history; space channels check READ_MESSAGE_HISTORY + const channelPerms = useSpaceStore((s) => s.channelPermissions.get(channelId)); + const isDm = isDmChannel(channelId); + const canReadHistory = isDm || hasPermissionBit(channelPerms, PermissionBits.READ_MESSAGE_HISTORY); + useEffect(() => { - loadMessages(channelId); - }, [channelId, loadMessages]); + if (canReadHistory) { + loadMessages(channelId); + } + }, [channelId, loadMessages, canReadHistory]); // Ack channel when messages load or when new messages arrive while near bottom useEffect(() => { @@ -135,6 +143,14 @@ export function MessageList({ channelId }: MessageListProps) { } }, [channelId, hasMore, isLoadingMore, loadMoreMessages]); + if (!canReadHistory) { + return ( +
+ You do not have permission to view message history in this channel +
+ ); + } + if (isLoading && messages.length === 0) { return (
diff --git a/packages/web/src/components/layout/ChannelSidebar.tsx b/packages/web/src/components/layout/ChannelSidebar.tsx index 654ff4c7..bc1a35d7 100644 --- a/packages/web/src/components/layout/ChannelSidebar.tsx +++ b/packages/web/src/components/layout/ChannelSidebar.tsx @@ -34,10 +34,13 @@ export function ChannelSidebar() { const isDeafened = useVoiceStore((s) => s.isDeafened); const toggleMic = useVoiceStore((s) => s.toggleMic); const toggleDeafen = useVoiceStore((s) => s.toggleDeafen); + const isServerMuted = useVoiceStore((s) => user ? s.serverMutedUserIds.has(user.id) : false); + const isServerDeafened = useVoiceStore((s) => user ? s.serverDeafenedUserIds.has(user.id) : false); const navigate = useNavigate(); const location = useLocation(); const handleMicToggle = async () => { + if (isServerMuted || isServerDeafened) return; // Blocked by server mute/deafen toggleMic(); // Broadcast mute status via WebSocket so non-joined users can see it const willBeMuted = !isMuted; @@ -47,6 +50,7 @@ export function ChannelSidebar() { }; const handleDeafenToggle = async () => { + if (isServerDeafened) return; // Blocked by server deafen const room = getActiveRoom(); const willDeafen = !isDeafened; // Update store FIRST so updateParticipants reads correct state when LiveKit events fire @@ -84,7 +88,9 @@ export function ChannelSidebar() { if (inst) return inst.label; try { return new URL(origin).host; } catch { return origin; } }, [space, federationInstances]); + const channelPermissions = useSpaceStore((s) => s.channelPermissions); const canManageChannels = hasPermissionBit(mySpacePerms, PermissionBits.MANAGE_CHANNELS); + const canCreateInvite = hasPermissionBit(mySpacePerms, PermissionBits.CREATE_INVITE); const textChannels = channels.filter(c => c.type === 'text'); const voiceChannels = channels.filter(c => c.type === 'voice' || c.type === 'video'); @@ -122,6 +128,8 @@ export function ChannelSidebar() { user={user} isMuted={isMuted} isDeafened={isDeafened} + isServerMuted={isServerMuted} + isServerDeafened={isServerDeafened} isAdmin={!!user.isAdmin} onMicToggle={handleMicToggle} onDeafenToggle={handleDeafenToggle} @@ -307,15 +315,17 @@ export function ChannelSidebar() { - + {canCreateInvite && ( + + )}
{/* Channels */} @@ -419,14 +429,19 @@ export function ChannelSidebar() { )}
- {voiceChannels.map((channel) => ( - handleVoiceJoin(channel.id)} - /> - ))} + {voiceChannels.map((channel) => { + const chPerms = channelPermissions.get(channel.id); + const canConnect = hasPermissionBit(chPerms, PermissionBits.CONNECT); + return ( + canConnect && handleVoiceJoin(channel.id)} + locked={!canConnect} + /> + ); + })}
@@ -444,6 +459,8 @@ function UserAreaPanel({ user, isMuted, isDeafened, + isServerMuted, + isServerDeafened, isAdmin, onMicToggle, onDeafenToggle, @@ -453,6 +470,8 @@ function UserAreaPanel({ user: any; isMuted: boolean; isDeafened: boolean; + isServerMuted: boolean; + isServerDeafened: boolean; isAdmin: boolean; onMicToggle: () => void; onDeafenToggle: () => void; @@ -769,14 +788,15 @@ function UserAreaPanel({ {/* Input chevron */} @@ -796,13 +816,14 @@ function UserAreaPanel({ {/* Output chevron */} diff --git a/packages/web/src/components/modals/SpaceSettings.tsx b/packages/web/src/components/modals/SpaceSettings.tsx index 0b2819a3..d0703a9c 100644 --- a/packages/web/src/components/modals/SpaceSettings.tsx +++ b/packages/web/src/components/modals/SpaceSettings.tsx @@ -9,6 +9,7 @@ import { hasPermissionBit, PermissionBits } from '../../utils/permissions'; import { OverviewPanel } from './spaceSettingsPanels/OverviewPanel'; import { MembersPanel } from './spaceSettingsPanels/MembersPanel'; import { RolesPanel } from './spaceSettingsPanels/RolesPanel'; +import { BansPanel } from './spaceSettingsPanels/BansPanel'; import type { SpaceVisibility, JoinRequest } from '@backspace/shared'; function DiscoveryPanel({ spaceId }: { spaceId: string }) { @@ -269,13 +270,14 @@ export function SpaceSettingsModal() { const spaces = useSpaceStore((s) => s.spaces); const spacePermissions = useSpaceStore((s) => s.spacePermissions); - const [tab, setTab] = useState<'overview' | 'discovery' | 'members' | 'roles'>('overview'); + const [tab, setTab] = useState<'overview' | 'discovery' | 'members' | 'roles' | 'bans'>('overview'); const isOpen = activeModal === 'spaceSettings'; const space = spaces.find(s => s.id === currentSpaceId); const mySpacePerms = currentSpaceId ? spacePermissions.get(currentSpaceId) : undefined; const canManageSpace = hasPermissionBit(mySpacePerms, PermissionBits.MANAGE_SPACE); const canManageRoles = hasPermissionBit(mySpacePerms, PermissionBits.MANAGE_ROLES); + const canBanMembers = hasPermissionBit(mySpacePerms, PermissionBits.BAN_MEMBERS); if (!space || !currentSpaceId) return null; @@ -306,6 +308,11 @@ export function SpaceSettingsModal() { Roles )} + {canBanMembers && ( + + )} @@ -315,6 +322,7 @@ export function SpaceSettingsModal() { {tab === 'discovery' && canManageSpace && } {tab === 'members' && } {tab === 'roles' && canManageRoles && } + {tab === 'bans' && canBanMembers && } diff --git a/packages/web/src/components/modals/spaceSettingsPanels/BansPanel.tsx b/packages/web/src/components/modals/spaceSettingsPanels/BansPanel.tsx new file mode 100644 index 00000000..d5afee7f --- /dev/null +++ b/packages/web/src/components/modals/spaceSettingsPanels/BansPanel.tsx @@ -0,0 +1,115 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { Avatar } from '../../ui/Avatar'; +import { useSpaceStore, getApiForOrigin } from '../../../stores/spaceStore'; + +interface Ban { + spaceId: string; + userId: string; + reason: string | null; + bannedBy: string; + createdAt: number; + user: { id: string; username: string; displayName?: string | null; avatar?: string | null } | null; + moderator: { id: string; username: string; displayName?: string | null } | null; +} + +interface BansPanelProps { + spaceId: string; +} + +export function BansPanel({ spaceId }: BansPanelProps) { + const spaces = useSpaceStore((s) => s.spaces); + const space = spaces.find((s) => s.id === spaceId); + const spaceApi = getApiForOrigin(space?._instanceOrigin ?? ''); + + const [bans, setBans] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(''); + + const loadBans = useCallback(async () => { + try { + setIsLoading(true); + const data = await spaceApi.spaces.getBans(spaceId); + setBans(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load bans'); + } finally { + setIsLoading(false); + } + }, [spaceId, spaceApi]); + + useEffect(() => { + loadBans(); + }, [loadBans]); + + const handleUnban = async (userId: string) => { + try { + await spaceApi.spaces.unban(spaceId, userId); + setBans((prev) => prev.filter((b) => b.userId !== userId)); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to unban user'); + } + }; + + if (isLoading) { + return ( +
+
Loading bans...
+
+ ); + } + + return ( +
+ {error && ( +
{error}
+ )} + +

Banned users cannot rejoin this space until unbanned.

+ + {bans.length === 0 ? ( +
No banned users
+ ) : ( +
+
+ Bans ({bans.length}) +
+
+
+ {bans.map((ban) => { + const displayName = ban.user?.displayName ?? ban.user?.username ?? ban.userId; + const moderatorName = ban.moderator?.displayName ?? ban.moderator?.username ?? ban.bannedBy; + const bannedDate = new Date(ban.createdAt).toLocaleDateString(); + + return ( +
+
+ +
+
{displayName}
+
+ Banned by {moderatorName} on {bannedDate} + {ban.reason && ` — ${ban.reason}`} +
+
+
+ +
+ ); + })} +
+
+
+ )} +
+ ); +} diff --git a/packages/web/src/components/modals/spaceSettingsPanels/MembersPanel.tsx b/packages/web/src/components/modals/spaceSettingsPanels/MembersPanel.tsx index 0d5ff8b6..3c0fce6b 100644 --- a/packages/web/src/components/modals/spaceSettingsPanels/MembersPanel.tsx +++ b/packages/web/src/components/modals/spaceSettingsPanels/MembersPanel.tsx @@ -1,8 +1,8 @@ import React, { useState } from 'react'; import { Avatar } from '../../ui/Avatar'; -import { useSpaceStore } from '../../../stores/spaceStore'; +import { useSpaceStore, getApiForOrigin } from '../../../stores/spaceStore'; import { useAuthStore } from '../../../stores/authStore'; -import { api } from '../../../api/client'; +import { parseFederatedUsername } from '../../../utils/identity'; import { hasPermissionBit, PermissionBits } from '../../../utils/permissions'; import type { MemberWithUser } from '@backspace/shared'; @@ -19,9 +19,11 @@ export function MembersPanel({ spaceId }: MembersPanelProps) { const spacePermissions = useSpaceStore((s) => s.spacePermissions); const space = spaces.find((s) => s.id === spaceId); + const spaceApi = getApiForOrigin(space?._instanceOrigin ?? ''); const myPerms = spacePermissions.get(spaceId); const canManageRoles = hasPermissionBit(myPerms, PermissionBits.MANAGE_ROLES); const canKick = hasPermissionBit(myPerms, PermissionBits.KICK_MEMBERS); + const canBan = hasPermissionBit(myPerms, PermissionBits.BAN_MEMBERS); const [pendingRoleChanges, setPendingRoleChanges] = useState>>(new Map()); const [expandedMemberId, setExpandedMemberId] = useState(null); @@ -52,7 +54,7 @@ export function MembersPanel({ spaceId }: MembersPanelProps) { const roleIds = pendingRoleChanges.get(userId); if (!roleIds) return; try { - await api.spaces.updateMember(spaceId, userId, { roleIds: Array.from(roleIds) }); + await spaceApi.spaces.updateMember(spaceId, userId, { roleIds: Array.from(roleIds) }); setPendingRoleChanges((prev) => { const next = new Map(prev); next.delete(userId); @@ -75,13 +77,22 @@ export function MembersPanel({ spaceId }: MembersPanelProps) { const handleKick = async (userId: string) => { try { - await api.spaces.removeMember(spaceId, userId); + await spaceApi.spaces.removeMember(spaceId, userId); await loadSpaceDetail(spaceId); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to kick member'); } }; + const handleBan = async (userId: string) => { + try { + await spaceApi.spaces.ban(spaceId, userId); + await loadSpaceDetail(spaceId); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to ban member'); + } + }; + const canExpandMember = (member: MemberWithUser) => canManageRoles && member.userId !== currentUser?.id && member.userId !== space.ownerId && assignableRoles.length > 0; @@ -99,6 +110,7 @@ export function MembersPanel({ spaceId }: MembersPanelProps) {
{members.map((member) => { + const { domain } = parseFederatedUsername(member.user.username); const displayName = member.user.displayName ?? member.user.username; const isOwner = member.userId === space.ownerId; const memberRoleIds = getMemberRoleIds(member); @@ -127,7 +139,12 @@ export function MembersPanel({ spaceId }: MembersPanelProps) { user={member.user} />
-
{displayName}
+
+ {displayName} + {domain && ( + @{domain} + )} +
{isOwner && ( @@ -151,6 +168,14 @@ export function MembersPanel({ spaceId }: MembersPanelProps) {
+ {canBan && member.userId !== currentUser?.id && !isOwner && ( + + )} {canKick && member.userId !== currentUser?.id && !isOwner && ( @@ -57,8 +83,6 @@ export function VoiceChannel({ channelId, channelName, onClick }: VoiceChannelPr const displayName = member?.user.displayName ?? member?.user.username ?? participant?.username ?? userId; const avatar = member?.user.avatar ?? null; const status = member?.user.status; - // Resolve status: for local user use store directly, for remote users - // try LiveKit participant first, then fall back to WebSocket voiceUserStates const wsStatus = voiceUserStates.get(userId); const isParticipantDeafened = userId === currentUserId ? localIsDeafened @@ -68,9 +92,15 @@ export function VoiceChannel({ channelId, channelName, onClick }: VoiceChannelPr : (participant?.isMuted ?? wsStatus?.isMuted ?? false); const hasCamera = participant?.isCameraOn ?? wsStatus?.isCameraOn ?? false; const isScreenSharing = participant?.isScreenSharing ?? wsStatus?.isScreenSharing ?? false; + const isServerMuted = serverMutedUserIds.has(userId); + const isServerDeafened = serverDeafenedUserIds.has(userId); return ( -
+
handleContextMenu(e, userId)} + > {displayName} {/* Status badges */}
- {isMuted && ( + {isServerMuted && ( + + + + + + + + )} + {isServerDeafened && ( + + + + + + + )} + {!isServerMuted && isMuted && ( )} - {isParticipantDeafened && ( + {!isServerDeafened && isParticipantDeafened && ( @@ -108,6 +155,16 @@ export function VoiceChannel({ channelId, channelName, onClick }: VoiceChannelPr })}
)} + + {/* Voice moderation context menu (portalled to body) */} + {contextMenu && ( + setContextMenu(null)} + /> + )}
); } diff --git a/packages/web/src/components/voice/VoiceControlBar.tsx b/packages/web/src/components/voice/VoiceControlBar.tsx index 4cbcb580..61030a52 100644 --- a/packages/web/src/components/voice/VoiceControlBar.tsx +++ b/packages/web/src/components/voice/VoiceControlBar.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useState, useRef } from 'react'; import { useVoiceStore } from '../../stores/voiceStore'; import { useUIStore } from '../../stores/uiStore'; +import { useAuthStore } from '../../stores/authStore'; import { getActiveRoom } from '../../hooks/useLiveKit'; import { wsSend } from '../../hooks/useWebSocket'; import { getChannelOrigin } from '../../stores/spaceStore'; @@ -25,17 +26,22 @@ export function VoiceControlBar() { const voiceFullscreen = useUIStore((s) => s.voiceFullscreen); const toggleVoiceFullscreen = useUIStore((s) => s.toggleVoiceFullscreen); const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId); + const myUser = useAuthStore((s) => s.user); + const isServerMuted = useVoiceStore((s) => myUser ? s.serverMutedUserIds.has(myUser.id) : false); + const isServerDeafened = useVoiceStore((s) => myUser ? s.serverDeafenedUserIds.has(myUser.id) : false); const voiceOrigin = currentVoiceChannelId ? getChannelOrigin(currentVoiceChannelId) : ''; const [qualityOpen, setQualityOpen] = useState(false); const qualityBtnRef = useRef(null); const handleMute = React.useCallback(async () => { + if (isServerMuted || isServerDeafened) return; toggleMic(); // Broadcast via WebSocket so sidebar shows status without joining wsSend({ type: 'voice_status', isMuted: !isMuted, isDeafened, isCameraOn, isScreenSharing }, voiceOrigin); - }, [isMuted, isDeafened, isCameraOn, isScreenSharing, toggleMic, voiceOrigin]); + }, [isMuted, isDeafened, isCameraOn, isScreenSharing, toggleMic, voiceOrigin, isServerMuted, isServerDeafened]); const handleDeafen = React.useCallback(async () => { + if (isServerDeafened) return; const room = getActiveRoom(); const willDeafen = !isDeafened; // Update store FIRST so updateParticipants reads correct state @@ -149,31 +155,35 @@ export function VoiceControlBar() { {/* Mute */} {/* Deafen */} diff --git a/packages/web/src/components/voice/VoiceControls.tsx b/packages/web/src/components/voice/VoiceControls.tsx index a6383a86..ddffdfae 100644 --- a/packages/web/src/components/voice/VoiceControls.tsx +++ b/packages/web/src/components/voice/VoiceControls.tsx @@ -6,6 +6,7 @@ import { wsSend } from '../../hooks/useWebSocket'; import { ScreenShareSettingsPopover } from './ScreenShareSettingsPopover'; import { ConnectionInfoPopover } from './ConnectionInfoPopover'; import { startScreenShare, stopScreenShare } from '../../utils/screenShare'; +import { hasPermissionBit, PermissionBits } from '../../utils/permissions'; /** * VoiceControls renders the voice status + button rows. @@ -28,6 +29,12 @@ export function VoiceControls() { const qualityBtnRef = useRef(null); const activeDmCall = useVoiceStore((s) => s.activeDmCall); + const channelPerms = useSpaceStore((s) => currentVoiceChannelId ? s.channelPermissions.get(currentVoiceChannelId) : undefined); + + // In DM calls, all permissions are granted; in space channels, check SPEAK and STREAM + const isDmCall = !!activeDmCall; + const canSpeak = isDmCall || hasPermissionBit(channelPerms, PermissionBits.SPEAK); + const canStream = isDmCall || hasPermissionBit(channelPerms, PermissionBits.STREAM); const voiceOrigin = currentVoiceChannelId ? getChannelOrigin(currentVoiceChannelId) : ''; @@ -155,41 +162,45 @@ export function VoiceControls() { {/* Row 2: Camera, Screen Share, Video Quality, Noise Suppression */}
- + {canSpeak && ( + + )} - + {canStream && ( + + )} {/* Video Quality */} + )} + {canDeafenMembers && ( + + )} + {canMoveMembers && otherVoiceChannels.length > 0 && ( + <> +
+
+ Move to... +
+ {otherVoiceChannels.map((ch) => ( + + ))} + + )} + + ); +} + +// ─── Standalone portalled context menu ───────────────────────────────────────── + +interface VoiceModContextMenuProps { + targetUserId: string; + channelId: string; + position: { x: number; y: number }; + onClose: () => void; +} + +/** + * Full standalone moderation context menu rendered via createPortal to document.body. + * Escapes any CSS containing-block / overflow clipping from parent transforms. + * Includes viewport-aware positioning and click-outside dismissal. + */ +export function VoiceModContextMenu({ targetUserId, channelId, position, onClose }: VoiceModContextMenuProps) { + const menuRef = useRef(null); + + const spacePermissions = useSpaceStore((s) => s.spacePermissions); + const currentSpaceId = useSpaceStore((s) => s.currentSpaceId); + const myPerms = currentSpaceId ? spacePermissions.get(currentSpaceId) : undefined; + const canMuteMembers = hasPermissionBit(myPerms, PermissionBits.MUTE_MEMBERS); + const canDeafenMembers = hasPermissionBit(myPerms, PermissionBits.DEAFEN_MEMBERS); + const canMoveMembers = hasPermissionBit(myPerms, PermissionBits.MOVE_MEMBERS); + const hasModPerms = canMuteMembers || canDeafenMembers || canMoveMembers; + + // Click-outside dismissal — always called (hooks must be unconditional) + useEffect(() => { + if (!hasModPerms) return; + const handler = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + onClose(); + } + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [hasModPerms, onClose]); + + // Viewport-aware positioning — direct DOM mutation, no extra state/render + useLayoutEffect(() => { + const el = menuRef.current; + if (!hasModPerms || !el) return; + const rect = el.getBoundingClientRect(); + let x = position.x; + let y = position.y; + if (rect.right > window.innerWidth) x = window.innerWidth - rect.width - 8; + if (rect.bottom > window.innerHeight) y = window.innerHeight - rect.height - 8; + if (x < 8) x = 8; + if (y < 8) y = 8; + el.style.left = `${x}px`; + el.style.top = `${y}px`; + }, [position, hasModPerms]); + + if (!hasModPerms) return null; + + return ReactDOM.createPortal( +
+ +
, + document.body, + ); +} diff --git a/packages/web/src/components/voice/VoiceUser.tsx b/packages/web/src/components/voice/VoiceUser.tsx index 42e7e632..0f01516f 100644 --- a/packages/web/src/components/voice/VoiceUser.tsx +++ b/packages/web/src/components/voice/VoiceUser.tsx @@ -1,6 +1,10 @@ -import React, { useRef, useEffect, useState, useCallback } from 'react'; +import React, { useRef, useEffect, useLayoutEffect, useState, useCallback } from 'react'; +import ReactDOM from 'react-dom'; import { Avatar } from '../ui/Avatar'; import { useVoiceStore } from '../../stores/voiceStore'; +import { VoiceModMenuItems } from './VoiceModContextMenu'; +import { useSpaceStore } from '../../stores/spaceStore'; +import { hasPermissionBit, PermissionBits } from '../../utils/permissions'; import type { UserTile } from '../../hooks/useLiveKit'; interface VoiceUserProps { @@ -13,6 +17,7 @@ export function VoiceUser({ tile, large }: VoiceUserProps) { const { participant } = tile; const isDeafened = useVoiceStore((s) => s.isDeafened); + const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId); const participantVolumes = useVoiceStore((s) => s.participantVolumes); const isSpeaking = useVoiceStore((s) => s.speakingParticipantIds.has(participant.identity)); @@ -50,6 +55,7 @@ export function VoiceUser({ tile, large }: VoiceUserProps) { }, [tile.lkVideoTrack]); // Context Menu + const menuRef = useRef(null); const [volumeMenu, setVolumeMenu] = useState<{ x: number; y: number; @@ -65,11 +71,31 @@ export function VoiceUser({ tile, large }: VoiceUserProps) { [isLocal], ); + // Click-outside dismissal — mousedown + contains check useEffect(() => { if (!volumeMenu) return; - const close = () => setVolumeMenu(null); - window.addEventListener('click', close); - return () => window.removeEventListener('click', close); + const handler = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + setVolumeMenu(null); + } + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [volumeMenu]); + + // Viewport-aware positioning — direct DOM mutation, no state churn + useLayoutEffect(() => { + const el = menuRef.current; + if (!volumeMenu || !el) return; + const rect = el.getBoundingClientRect(); + let x = volumeMenu.x; + let y = volumeMenu.y; + if (rect.right > window.innerWidth) x = window.innerWidth - rect.width - 8; + if (rect.bottom > window.innerHeight) y = window.innerHeight - rect.height - 8; + if (x < 8) x = 8; + if (y < 8) y = 8; + el.style.left = `${x}px`; + el.style.top = `${y}px`; }, [volumeMenu]); return ( @@ -154,44 +180,87 @@ export function VoiceUser({ tile, large }: VoiceUserProps) {
- {volumeMenu && !isLocal && ( + {volumeMenu && !isLocal && ReactDOM.createPortal(
e.stopPropagation()} > -
- User Volume -
-
- - - - - setParticipantVolume( - participant.userId, - parseInt(e.target.value), - ) - } - className="flex-1 accent-accent-primary h-1" + {/* Moderation options (renders nothing if no perms) */} + {currentVoiceChannelId && ( + setVolumeMenu(null)} /> - - {perUserVolume}% - + )} + {/* Volume slider */} +
+
+ User Volume +
+
+ + + + + setParticipantVolume( + participant.userId, + parseInt(e.target.value), + ) + } + className="flex-1 accent-accent-primary h-1" + /> + + {perUserVolume}% + +
-
+
, + document.body, )}
); } + +/** Renders moderation items + divider only when the user has mod perms. */ +function VoiceModSection({ targetUserId, channelId, onAction }: { + targetUserId: string; + channelId: string; + onAction: () => void; +}) { + const spacePermissions = useSpaceStore((s) => s.spacePermissions); + const currentSpaceId = useSpaceStore((s) => s.currentSpaceId); + const myPerms = currentSpaceId ? spacePermissions.get(currentSpaceId) : undefined; + + const hasMod = + hasPermissionBit(myPerms, PermissionBits.MUTE_MEMBERS) || + hasPermissionBit(myPerms, PermissionBits.DEAFEN_MEMBERS) || + hasPermissionBit(myPerms, PermissionBits.MOVE_MEMBERS); + + if (!hasMod) return null; + + return ( + <> +
+ +
+
+ + ); +} diff --git a/packages/web/src/hooks/useWebSocket.ts b/packages/web/src/hooks/useWebSocket.ts index 9c695e2e..1c693f04 100644 --- a/packages/web/src/hooks/useWebSocket.ts +++ b/packages/web/src/hooks/useWebSocket.ts @@ -167,6 +167,14 @@ function handleEvent(origin: string, event: ServerEvent): void { setVoiceUserStatus(uid, status.isMuted, status.isDeafened, status.isCameraOn, status.isScreenSharing); } } + // Populate server mute/deafen states + if (event.serverVoiceStates) { + const { setServerMutedUser, setServerDeafenedUser } = useVoiceStore.getState(); + for (const [uid, state] of Object.entries(event.serverVoiceStates as Record)) { + if (state.serverMuted) setServerMutedUser(uid, true); + if (state.serverDeafened) setServerDeafenedUser(uid, true); + } + } // Re-register in voice channel after WS reconnect — the server lost // voice state on restart, so we must tell it we're still connected. @@ -265,6 +273,56 @@ function handleEvent(origin: string, event: ServerEvent): void { setVoiceUserStatus(event.userId, event.isMuted, event.isDeafened, event.isCameraOn, event.isScreenSharing); break; + case 'voice_server_muted': { + const { setServerMutedUser } = useVoiceStore.getState(); + setServerMutedUser(event.userId, event.muted); + // If the local user was server-muted, force-mute the mic + const myUserId = useAuthStore.getState().user?.id; + if (event.userId === myUserId && event.muted) { + const vs = useVoiceStore.getState(); + if (!vs.isMuted) { + vs.toggleMic(); + const voiceOrigin = vs.currentVoiceChannelId ? getChannelOrigin(vs.currentVoiceChannelId) : ''; + wsSend({ type: 'voice_status', isMuted: true, isDeafened: vs.isDeafened, isCameraOn: vs.isCameraOn, isScreenSharing: vs.isScreenSharing }, voiceOrigin); + } + } + break; + } + + case 'voice_server_deafened': { + const { setServerDeafenedUser } = useVoiceStore.getState(); + setServerDeafenedUser(event.userId, event.deafened); + // If the local user was server-deafened, force-deafen and force-mute + const myUid = useAuthStore.getState().user?.id; + if (event.userId === myUid && event.deafened) { + const vs = useVoiceStore.getState(); + if (!vs.isDeafened) { + vs.toggleDeafen(); + if (!vs.isMuted) vs.toggleMic(); + const voiceOrigin = vs.currentVoiceChannelId ? getChannelOrigin(vs.currentVoiceChannelId) : ''; + wsSend({ type: 'voice_status', isMuted: true, isDeafened: true, isCameraOn: vs.isCameraOn, isScreenSharing: vs.isScreenSharing }, voiceOrigin); + } + } + break; + } + + case 'voice_moved': { + // The local user was moved to a different channel by a moderator + const myMovedId = useAuthStore.getState().user?.id; + if (event.userId === myMovedId) { + // Import dynamically to avoid circular deps — joinVoiceChannel handles + // leaving old channel, setting new channel, and triggering LiveKit reconnect + import('../utils/voice').then(({ joinVoiceChannel }) => { + // Force-set the channel (joinVoiceChannel skips if same channel) + const vs = useVoiceStore.getState(); + // Clear current channel first so joinVoiceChannel doesn't bail + vs.setCurrentVoiceChannel(null); + joinVoiceChannel(event.newChannelId); + }); + } + break; + } + case 'member_joined': if (!isHome) normalizeUserAssets(event.member.user, origin); addMember(event.member); @@ -274,6 +332,13 @@ function handleEvent(origin: string, event: ServerEvent): void { removeMember(event.userId); break; + case 'member_banned': { + // The current user has been banned from a space — remove it from the sidebar + const { removeSpace: rmSpace } = useSpaceStore.getState(); + rmSpace(event.spaceId); + break; + } + // ─── DM events (home-only) ────────────────────────────────────────────── case 'dm_message_created': { diff --git a/packages/web/src/stores/voiceStore.ts b/packages/web/src/stores/voiceStore.ts index 79c12139..ddd2cf25 100644 --- a/packages/web/src/stores/voiceStore.ts +++ b/packages/web/src/stores/voiceStore.ts @@ -86,6 +86,12 @@ interface VoiceState { voiceUserStates: Map; setVoiceUserStatus: (userId: string, isMuted: boolean, isDeafened: boolean, isCameraOn: boolean, isScreenSharing: boolean) => void; clearVoiceUserStatus: (userId: string) => void; + // Server mute/deafen state (moderator action) + serverMutedUserIds: Set; + serverDeafenedUserIds: Set; + setServerMutedUser: (userId: string, muted: boolean) => void; + setServerDeafenedUser: (userId: string, deafened: boolean) => void; + clearServerVoiceStates: () => void; getVoiceUsers: (channelId: string) => string[]; clearAllVoiceUsers: () => void; clearVoiceUsersForOrigin: (origin: string) => void; @@ -273,6 +279,24 @@ export const useVoiceStore = create()( }); }, + serverMutedUserIds: new Set(), + serverDeafenedUserIds: new Set(), + setServerMutedUser: (userId, muted) => { + set((state) => { + const newSet = new Set(state.serverMutedUserIds); + if (muted) newSet.add(userId); else newSet.delete(userId); + return { serverMutedUserIds: newSet }; + }); + }, + setServerDeafenedUser: (userId, deafened) => { + set((state) => { + const newSet = new Set(state.serverDeafenedUserIds); + if (deafened) newSet.add(userId); else newSet.delete(userId); + return { serverDeafenedUserIds: newSet }; + }); + }, + clearServerVoiceStates: () => set({ serverMutedUserIds: new Set(), serverDeafenedUserIds: new Set() }), + getVoiceUsers: (channelId) => get().voiceUsers.get(channelId) ?? [], clearAllVoiceUsers: () => set({ voiceUsers: new Map(), voiceUserStates: new Map() }), @@ -352,6 +376,8 @@ export const useVoiceStore = create()( streamVolumes: new Map(), streamMutes: new Map(), watchingStreams: new Set(), + serverMutedUserIds: new Set(), + serverDeafenedUserIds: new Set(), }), }), {