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:
Jannis Braun
2026-03-09 15:56:46 +01:00
parent 7e2986ca01
commit e2c18ad2b0
24 changed files with 1224 additions and 159 deletions
+12
View File
@@ -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 (
+10
View File
@@ -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' }),
+22 -3
View File
@@ -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,
});
+5
View File
@@ -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 });
+149 -1
View File
@@ -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 });
});
}
+11
View File
@@ -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;
}
+181
View File
@@ -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,
});
}
}
+44 -1
View File
@@ -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> {
+8 -1
View File
@@ -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<string, string[]>; voiceUserStates?: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>; readStates?: ReadState[]; activeCalls?: ActiveCallInfo[] }
| { type: 'ready'; user: User; spaces: SpaceWithChannelsAndMembers[]; dmChannels: DmChannel[]; folders?: SpaceFolder[]; voiceStates?: Record<string, string[]>; voiceUserStates?: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>; readStates?: ReadState[]; activeCalls?: ActiveCallInfo[]; serverVoiceStates?: Record<string, { serverMuted: boolean; serverDeafened: boolean }> }
| { 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 };
+9
View File
@@ -59,6 +59,9 @@ export class BackspaceApiClient {
members: (id: string) => Promise<MemberWithUser[]>;
updateMember: (spaceId: string, userId: string, data: UpdateMemberRequest) => Promise<MemberWithUser>;
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<MemberWithUser>('PATCH', `/spaces/${spaceId}/members/${userId}`, data),
removeMember: (spaceId: string, userId: string) =>
request<{ success: boolean }>('DELETE', `/spaces/${spaceId}/members/${userId}`),
getBans: (spaceId: string) =>
request<any[]>('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 = {
+16 -12
View File
@@ -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 && (
<div className="absolute -top-[18px] right-4 flex items-center glass rounded-[10px] overflow-hidden z-10 h-8">
<div className="flex items-center px-1 border-r border-white/[0.06] h-full">
{['👍', '❤️', '😂', '😮'].map(emoji => (
<button
key={emoji}
onClick={() => toggleReaction(emoji)}
className="p-1 hover:bg-interactive-hover rounded transition-colors text-[16px] leading-none"
>
{emoji}
</button>
))}
</div>
{canAddReactions && (
<div className="flex items-center px-1 border-r border-white/[0.06] h-full">
{['👍', '❤️', '😂', '😮'].map(emoji => (
<button
key={emoji}
onClick={() => toggleReaction(emoji)}
className="p-1 hover:bg-interactive-hover rounded transition-colors text-[16px] leading-none"
>
{emoji}
</button>
))}
</div>
)}
<button
onClick={() => setReplyTo(message)}
className="px-2 h-full text-txt-tertiary hover:text-txt-primary hover:bg-interactive-hover transition-all flex items-center justify-center"
@@ -4,6 +4,7 @@ import { isDmChannel, getChannelOrigin, getApiForOrigin, useSpaceStore } from '.
import { wsSend } from '../../hooks/useWebSocket';
import { MentionPopover } from './MentionPopover';
import { TypingIndicator } from './TypingIndicator';
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
import type { MemberWithUser } from '@backspace/shared';
interface MessageInputProps {
@@ -31,6 +32,12 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
const members = useSpaceStore((s) => s.members);
const typingTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
// Permission gating: DM channels always allow sending; space channels check SEND_MESSAGES
const channelPerms = useSpaceStore((s) => s.channelPermissions.get(channelId));
const isDm = isDmChannel(channelId);
const canSendMessages = isDm || hasPermissionBit(channelPerms, PermissionBits.SEND_MESSAGES);
const canAttachFiles = isDm || hasPermissionBit(channelPerms, PermissionBits.ATTACH_FILES);
// Auto-focus textarea on channel navigation
useEffect(() => {
textareaRef.current?.focus();
@@ -230,6 +237,16 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
textarea.style.height = Math.min(textarea.scrollHeight, 300) + 'px';
};
if (!canSendMessages) {
return (
<div data-pip-obstacle="bottom" className="relative px-3 pb-3 flex-shrink-0 md:absolute md:bottom-3 md:left-3 md:right-3 md:z-[110] md:px-0 md:pb-0 md:glass-bubble md:rounded-[14px]">
<div className="flex items-center justify-center py-[14px] px-4">
<span className="text-txt-tertiary text-[14px]">You do not have permission to send messages in this channel</span>
</div>
</div>
);
}
return (
<div data-pip-obstacle="bottom" className="relative px-3 pb-3 flex-shrink-0 md:absolute md:bottom-3 md:left-3 md:right-3 md:z-[110] md:px-0 md:pb-0 md:glass-bubble md:rounded-[14px]">
<TypingIndicator channelId={channelId} />
@@ -252,8 +269,8 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
<div
ref={inputContainerRef}
className={`relative bg-surface-input md:bg-transparent ${replyTo ? 'rounded-b-lg' : 'rounded-lg md:rounded-none'} overflow-visible`}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDrop={canAttachFiles ? handleDrop : undefined}
onDragOver={canAttachFiles ? handleDragOver : undefined}
>
{/* Mention autocomplete popover */}
{mentionState && filteredMembers.length > 0 && (
@@ -299,15 +316,17 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
<div className="flex items-center pl-[10px] pr-1">
{/* File attach button */}
<button
onClick={() => fileInputRef.current?.click()}
className="w-[34px] h-[34px] flex items-center justify-center rounded-[6px] text-txt-tertiary hover:text-txt-secondary transition-colors flex-shrink-0"
title="Attach file"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm5 11h-4v4h-2v-4H7v-2h4V7h2v4h4v2z" />
</svg>
</button>
{canAttachFiles && (
<button
onClick={() => fileInputRef.current?.click()}
className="w-[34px] h-[34px] flex items-center justify-center rounded-[6px] text-txt-tertiary hover:text-txt-secondary transition-colors flex-shrink-0"
title="Attach file"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm5 11h-4v4h-2v-4H7v-2h4V7h2v4h4v2z" />
</svg>
</button>
)}
<input
ref={fileInputRef}
type="file"
@@ -328,7 +347,7 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
value={content}
onChange={handleChange}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onPaste={canAttachFiles ? handlePaste : undefined}
placeholder={`Message ${channelName.startsWith('@') ? channelName : `#${channelName}`}`}
className="flex-1 py-[10px] px-1 bg-transparent text-txt-primary placeholder-txt-tertiary/60 outline-none resize-none text-[15px] leading-[1.375rem] max-h-[50vh] scrollbar-thin"
rows={1}
@@ -6,6 +6,7 @@ import { useAuthStore } from '../../stores/authStore';
import { useSocialStore } from '../../stores/socialStore';
import { Avatar } from '../ui/Avatar';
import { LoadingSpinner } from '../ui/LoadingSpinner';
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
import type { MessageWithUser } from '@backspace/shared';
const EMPTY_MESSAGES: MessageWithUser[] = [];
@@ -53,9 +54,16 @@ export function MessageList({ channelId }: MessageListProps) {
const prevMessagesLength = useRef(0);
const ackTimerRef = useRef<ReturnType<typeof setTimeout>>();
// 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 (
<div className="flex-1 flex items-center justify-center">
<span className="text-txt-tertiary text-[14px]">You do not have permission to view message history in this channel</span>
</div>
);
}
if (isLoading && messages.length === 0) {
return (
<div className="flex-1 flex items-center justify-center">
@@ -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() {
<path d="M5.293 7.293a1 1 0 011.414 0L9 9.586l2.293-2.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" />
</svg>
</button>
<button
onClick={() => openModal('invite')}
className="w-10 h-full flex items-center justify-center text-txt-tertiary hover:text-txt-primary hover:bg-interactive-hover transition-all flex-shrink-0"
title="Invite People"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M21 3H24V5H21V8H19V5H16V3H19V0H21V3ZM10 12C12.21 12 14 10.21 14 8C14 5.79 12.21 4 10 4C7.79 4 6 5.79 6 8C6 10.21 7.79 12 10 12ZM10 13C6.69 13 1 14.66 1 18V20H19V18C19 14.66 13.31 13 10 13Z" />
</svg>
</button>
{canCreateInvite && (
<button
onClick={() => openModal('invite')}
className="w-10 h-full flex items-center justify-center text-txt-tertiary hover:text-txt-primary hover:bg-interactive-hover transition-all flex-shrink-0"
title="Invite People"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M21 3H24V5H21V8H19V5H16V3H19V0H21V3ZM10 12C12.21 12 14 10.21 14 8C14 5.79 12.21 4 10 4C7.79 4 6 5.79 6 8C6 10.21 7.79 12 10 12ZM10 13C6.69 13 1 14.66 1 18V20H19V18C19 14.66 13.31 13 10 13Z" />
</svg>
</button>
)}
</div>
{/* Channels */}
@@ -419,14 +429,19 @@ export function ChannelSidebar() {
)}
</div>
<div className="space-y-[2px]">
{voiceChannels.map((channel) => (
<VoiceChannel
key={channel.id}
channelId={channel.id}
channelName={channel.name}
onClick={() => handleVoiceJoin(channel.id)}
/>
))}
{voiceChannels.map((channel) => {
const chPerms = channelPermissions.get(channel.id);
const canConnect = hasPermissionBit(chPerms, PermissionBits.CONNECT);
return (
<VoiceChannel
key={channel.id}
channelId={channel.id}
channelName={channel.name}
onClick={() => canConnect && handleVoiceJoin(channel.id)}
locked={!canConnect}
/>
);
})}
</div>
</div>
@@ -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({
<button
onClick={onMicToggle}
className={`w-8 h-8 flex items-center justify-center hover:bg-interactive-hover rounded-l-[4px] transition-colors ${
isMuted ? 'text-txt-danger' : 'text-txt-tertiary hover:text-txt-primary'
isServerMuted ? 'text-accent-amber cursor-not-allowed'
: isMuted ? 'text-txt-danger' : 'text-txt-tertiary hover:text-txt-primary'
}`}
title={isMuted ? 'Unmute' : 'Mute'}
title={isServerMuted ? 'Server Muted' : isMuted ? 'Unmute' : 'Mute'}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" />
<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" />
{isMuted && <line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />}
{(isMuted || isServerMuted) && <line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />}
</svg>
</button>
{/* Input chevron */}
@@ -796,13 +816,14 @@ function UserAreaPanel({
<button
onClick={onDeafenToggle}
className={`w-8 h-8 flex items-center justify-center hover:bg-interactive-hover rounded-l-[4px] transition-colors ${
isDeafened ? 'text-txt-danger' : 'text-txt-tertiary hover:text-txt-primary'
isServerDeafened ? 'text-accent-amber cursor-not-allowed'
: isDeafened ? 'text-txt-danger' : 'text-txt-tertiary hover:text-txt-primary'
}`}
title={isDeafened ? 'Undeafen' : 'Deafen'}
title={isServerDeafened ? 'Server Deafened' : isDeafened ? 'Undeafen' : 'Deafen'}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 3c-4.97 0-9 4.03-9 9v7c0 1.1.9 2 2 2h2v-7H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-2v7h2c1.1 0 2-.9 2-2v-7c0-4.97-4.03-9-9-9z" />
{isDeafened && <line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />}
{(isDeafened || isServerDeafened) && <line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />}
</svg>
</button>
{/* Output chevron */}
@@ -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
</button>
)}
{canBanMembers && (
<button onClick={() => setTab('bans')} className={tabClass('bans')}>
Bans
</button>
)}
</div>
</div>
@@ -315,6 +322,7 @@ export function SpaceSettingsModal() {
{tab === 'discovery' && canManageSpace && <DiscoveryPanel spaceId={currentSpaceId} />}
{tab === 'members' && <MembersPanel spaceId={currentSpaceId} />}
{tab === 'roles' && canManageRoles && <RolesPanel spaceId={currentSpaceId} />}
{tab === 'bans' && canBanMembers && <BansPanel spaceId={currentSpaceId} />}
</div>
</div>
</Modal>
@@ -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<Ban[]>([]);
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 (
<div className="flex items-center justify-center py-8">
<div className="text-txt-tertiary text-sm">Loading bans...</div>
</div>
);
}
return (
<div className="space-y-4">
{error && (
<div className="p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">{error}</div>
)}
<p className="text-xs text-txt-tertiary">Banned users cannot rejoin this space until unbanned.</p>
{bans.length === 0 ? (
<div className="text-center py-8 text-txt-tertiary text-sm">No banned users</div>
) : (
<div>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">
Bans ({bans.length})
</div>
<div className="rounded-lg bg-white/[0.02] p-2">
<div className="space-y-0.5">
{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 (
<div key={ban.userId} className="flex items-center justify-between p-2 rounded hover:bg-interactive-hover transition-colors">
<div className="flex items-center gap-2 min-w-0">
<Avatar
src={ban.user?.avatar ?? null}
name={displayName}
size={32}
userId={ban.userId}
/>
<div className="min-w-0">
<div className="text-sm font-medium truncate">{displayName}</div>
<div className="text-[11px] text-txt-tertiary truncate">
Banned by {moderatorName} on {bannedDate}
{ban.reason && `${ban.reason}`}
</div>
</div>
</div>
<button
onClick={() => handleUnban(ban.userId)}
className="px-2 py-1 text-xs text-txt-secondary hover:text-txt-primary hover:bg-surface-base rounded transition-colors flex-shrink-0"
>
Unban
</button>
</div>
);
})}
</div>
</div>
</div>
)}
</div>
);
}
@@ -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<Map<string, Set<string>>>(new Map());
const [expandedMemberId, setExpandedMemberId] = useState<string | null>(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) {
<div className="rounded-lg bg-white/[0.02] p-2">
<div className="space-y-0.5">
{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}
/>
<div className="min-w-0">
<div className="text-sm font-medium truncate">{displayName}</div>
<div className="text-sm font-medium truncate">
{displayName}
{domain && (
<span className="ml-1 text-[10px] text-txt-tertiary opacity-60">@{domain}</span>
)}
</div>
<div className="flex items-center gap-1 flex-wrap">
{isOwner && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-accent-rose/20 text-txt-danger font-medium">
@@ -151,6 +168,14 @@ export function MembersPanel({ spaceId }: MembersPanelProps) {
</div>
<div className="flex items-center gap-1 flex-shrink-0">
{canBan && member.userId !== currentUser?.id && !isOwner && (
<button
onClick={(e) => { e.stopPropagation(); handleBan(member.userId); }}
className="px-2 py-1 text-xs text-txt-danger hover:bg-accent-rose/10 rounded transition-colors"
>
Ban
</button>
)}
{canKick && member.userId !== currentUser?.id && !isOwner && (
<button
onClick={(e) => { e.stopPropagation(); handleKick(member.userId); }}
@@ -1,50 +1,76 @@
import React from 'react';
import React, { useState, useCallback } from 'react';
import { useVoiceStore } from '../../stores/voiceStore';
import { useSpaceStore } from '../../stores/spaceStore';
import { useAuthStore } from '../../stores/authStore';
import { Avatar } from '../ui/Avatar';
import { VoiceModContextMenu } from './VoiceModContextMenu';
const EMPTY_VOICE_USERS: string[] = [];
import { useSpaceStore } from '../../stores/spaceStore';
import { Avatar } from '../ui/Avatar';
interface VoiceChannelProps {
channelId: string;
channelName: string;
onClick: () => void;
locked?: boolean;
}
export function VoiceChannel({ channelId, channelName, onClick }: VoiceChannelProps) {
export function VoiceChannel({ channelId, channelName, onClick, locked }: VoiceChannelProps) {
const voiceUsers = useVoiceStore((s) => s.voiceUsers.get(channelId)) ?? EMPTY_VOICE_USERS;
const currentVoiceChannel = useVoiceStore((s) => s.currentVoiceChannelId);
const participants = useVoiceStore((s) => s.participants);
const localIsDeafened = useVoiceStore((s) => s.isDeafened);
const localIsMuted = useVoiceStore((s) => s.isMuted);
const voiceUserStates = useVoiceStore((s) => s.voiceUserStates);
const serverMutedUserIds = useVoiceStore((s) => s.serverMutedUserIds);
const serverDeafenedUserIds = useVoiceStore((s) => s.serverDeafenedUserIds);
const currentUserId = useVoiceStore((s) => {
// Derive from participants — avoids unnecessary authStore dependency
const local = s.participants.find(p => p.isLocal);
return local?.userId ?? null;
});
const members = useSpaceStore((s) => s.members);
const myUser = useAuthStore((s) => s.user);
const isActive = currentVoiceChannel === channelId;
// Context menu state
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; userId: string } | null>(null);
const handleContextMenu = useCallback(
(e: React.MouseEvent, userId: string) => {
if (userId === myUser?.id) return;
e.preventDefault();
setContextMenu({ x: e.clientX, y: e.clientY, userId });
},
[myUser?.id],
);
return (
<div>
<button
onClick={onClick}
className={`relative w-full flex items-center gap-1.5 px-[10px] h-8 rounded-[6px] group transition-colors ${
isActive
? 'bg-surface-elevated text-txt-primary'
: 'text-txt-tertiary hover:text-txt-secondary hover:bg-interactive-hover'
locked
? 'text-txt-tertiary/50 cursor-not-allowed'
: isActive
? 'bg-surface-elevated text-txt-primary'
: 'text-txt-tertiary hover:text-txt-secondary hover:bg-interactive-hover'
}`}
title={locked ? "You don't have permission to connect to this channel" : undefined}
>
{isActive && (
{isActive && !locked && (
<div
className="absolute -left-[2px] top-1/2 -translate-y-1/2 w-[3px] bg-white rounded-r-full"
style={{ height: '55%', opacity: 0.7 }}
/>
)}
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" className="flex-shrink-0 text-[#6e6e7a]">
<path d="M11 5L6 9H2V15H6L11 19V5ZM15.54 8.46C16.48 9.4 17 10.67 17 12S16.48 14.6 15.54 15.54L14.12 14.12C14.69 13.55 15 12.79 15 12S14.69 10.45 14.12 9.88L15.54 8.46ZM19.07 4.93C20.91 6.77 22 9.28 22 12C22 14.72 20.91 17.23 19.07 19.07L17.66 17.66C19.11 16.21 20 14.21 20 12C20 9.79 19.11 7.79 17.66 6.34L19.07 4.93Z" />
</svg>
{locked ? (
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" className="flex-shrink-0 text-[#6e6e7a]/50">
<path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z" />
</svg>
) : (
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" className="flex-shrink-0 text-[#6e6e7a]">
<path d="M11 5L6 9H2V15H6L11 19V5ZM15.54 8.46C16.48 9.4 17 10.67 17 12S16.48 14.6 15.54 15.54L14.12 14.12C14.69 13.55 15 12.79 15 12S14.69 10.45 14.12 9.88L15.54 8.46ZM19.07 4.93C20.91 6.77 22 9.28 22 12C22 14.72 20.91 17.23 19.07 19.07L17.66 17.66C19.11 16.21 20 14.21 20 12C20 9.79 19.11 7.79 17.66 6.34L19.07 4.93Z" />
</svg>
)}
<span className="truncate text-[15px] font-medium">{channelName}</span>
</button>
@@ -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 (
<div key={userId} className="flex items-center gap-2 px-[10px] py-1 rounded-[6px] hover:bg-interactive-hover transition-colors">
<div
key={userId}
className="flex items-center gap-2 px-[10px] py-1 rounded-[6px] hover:bg-interactive-hover transition-colors"
onContextMenu={(e) => handleContextMenu(e, userId)}
>
<Avatar
src={avatar}
name={displayName}
@@ -81,14 +111,31 @@ export function VoiceChannel({ channelId, channelName, onClick }: VoiceChannelPr
<span className="text-[13px] text-txt-secondary truncate flex-1 min-w-0">{displayName}</span>
{/* Status badges */}
<div className="flex items-center gap-1 flex-shrink-0">
{isMuted && (
{isServerMuted && (
<span title="Server Muted">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" className="text-accent-amber">
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" />
<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" />
<line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
</svg>
</span>
)}
{isServerDeafened && (
<span title="Server Deafened">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" className="text-accent-amber">
<path d="M12 3c-4.97 0-9 4.03-9 9v7c0 1.1.9 2 2 2h2v-7H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-2v7h2c1.1 0 2-.9 2-2v-7c0-4.97-4.03-9-9-9z" />
<line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
</svg>
</span>
)}
{!isServerMuted && isMuted && (
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" className="text-txt-danger">
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" />
<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" />
<line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
</svg>
)}
{isParticipantDeafened && (
{!isServerDeafened && isParticipantDeafened && (
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" className="text-txt-danger">
<path d="M12 3c-4.97 0-9 4.03-9 9v7c0 1.1.9 2 2 2h2v-7H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-2v7h2c1.1 0 2-.9 2-2v-7c0-4.97-4.03-9-9-9z" />
<line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
@@ -108,6 +155,16 @@ export function VoiceChannel({ channelId, channelName, onClick }: VoiceChannelPr
})}
</div>
)}
{/* Voice moderation context menu (portalled to body) */}
{contextMenu && (
<VoiceModContextMenu
targetUserId={contextMenu.userId}
channelId={channelId}
position={{ x: contextMenu.x, y: contextMenu.y }}
onClose={() => setContextMenu(null)}
/>
)}
</div>
);
}
@@ -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<HTMLButtonElement>(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 */}
<button
onClick={handleMute}
className={isMuted || isDeafened
? `${btnBase} bg-accent-rose/20 text-txt-danger hover:bg-accent-rose/30`
: btnDefault
className={isServerMuted
? `${btnBase} bg-accent-amber/20 text-accent-amber cursor-not-allowed`
: isMuted || isDeafened
? `${btnBase} bg-accent-rose/20 text-txt-danger hover:bg-accent-rose/30`
: btnDefault
}
title={isMuted ? 'Unmute (M)' : 'Mute (M)'}
title={isServerMuted ? 'Server Muted' : isMuted ? 'Unmute (M)' : 'Mute (M)'}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" />
<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" />
{(isMuted || isDeafened) && <line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />}
{(isMuted || isDeafened || isServerMuted) && <line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />}
</svg>
</button>
{/* Deafen */}
<button
onClick={handleDeafen}
className={isDeafened
? `${btnBase} bg-accent-rose/20 text-txt-danger hover:bg-accent-rose/30`
: btnDefault
className={isServerDeafened
? `${btnBase} bg-accent-amber/20 text-accent-amber cursor-not-allowed`
: isDeafened
? `${btnBase} bg-accent-rose/20 text-txt-danger hover:bg-accent-rose/30`
: btnDefault
}
title={isDeafened ? 'Undeafen (D)' : 'Deafen (D)'}
title={isServerDeafened ? 'Server Deafened' : isDeafened ? 'Undeafen (D)' : 'Deafen (D)'}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 3c-4.97 0-9 4.03-9 9v7c0 1.1.9 2 2 2h2v-7H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-2v7h2c1.1 0 2-.9 2-2v-7c0-4.97-4.03-9-9-9z" />
{isDeafened && <line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />}
{(isDeafened || isServerDeafened) && <line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />}
</svg>
</button>
@@ -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<HTMLButtonElement>(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 */}
<div className="relative flex items-center gap-1 px-3 pb-2 pt-1">
<button
onClick={handleCamera}
className={`${btnBase} ${
isCameraOn
? 'bg-surface-base text-status-online hover:bg-surface-channel'
: btnDefaultStyle
}`}
title={isCameraOn ? 'Turn Off Camera' : 'Turn On Camera'}
>
{isCameraOn ? (
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M17 10.5V7C17 6.45 16.55 6 16 6H4C3.45 6 3 6.45 3 7V17C3 17.55 3.45 18 4 18H16C16.55 18 17 17.55 17 17V13.5L21 17.5V6.5L17 10.5Z" />
</svg>
) : (
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M17 10.5V7C17 6.45 16.55 6 16 6H4C3.45 6 3 6.45 3 7V17C3 17.55 3.45 18 4 18H16C16.55 18 17 17.55 17 17V13.5L21 17.5V6.5L17 10.5Z" />
<line x1="2" y1="2" x2="22" y2="22" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
</svg>
)}
</button>
{canSpeak && (
<button
onClick={handleCamera}
className={`${btnBase} ${
isCameraOn
? 'bg-surface-base text-status-online hover:bg-surface-channel'
: btnDefaultStyle
}`}
title={isCameraOn ? 'Turn Off Camera' : 'Turn On Camera'}
>
{isCameraOn ? (
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M17 10.5V7C17 6.45 16.55 6 16 6H4C3.45 6 3 6.45 3 7V17C3 17.55 3.45 18 4 18H16C16.55 18 17 17.55 17 17V13.5L21 17.5V6.5L17 10.5Z" />
</svg>
) : (
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M17 10.5V7C17 6.45 16.55 6 16 6H4C3.45 6 3 6.45 3 7V17C3 17.55 3.45 18 4 18H16C16.55 18 17 17.55 17 17V13.5L21 17.5V6.5L17 10.5Z" />
<line x1="2" y1="2" x2="22" y2="22" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
</svg>
)}
</button>
)}
<button
onClick={handleScreenShare}
className={`${btnBase} ${
isScreenSharing
? 'bg-surface-base text-status-online hover:bg-surface-channel'
: btnDefaultStyle
}`}
title={isScreenSharing ? 'Stop Sharing' : 'Share Screen'}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M20 18C21.1 18 22 17.1 22 16V6C22 4.9 21.1 4 20 4H4C2.9 4 2 4.9 2 6V16C2 17.1 2.9 18 4 18H0V20H24V18H20ZM4 6H20V16H4V6Z" />
<path d="M15 11L11 14V12H9V10H11V8L15 11Z" />
</svg>
</button>
{canStream && (
<button
onClick={handleScreenShare}
className={`${btnBase} ${
isScreenSharing
? 'bg-surface-base text-status-online hover:bg-surface-channel'
: btnDefaultStyle
}`}
title={isScreenSharing ? 'Stop Sharing' : 'Share Screen'}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M20 18C21.1 18 22 17.1 22 16V6C22 4.9 21.1 4 20 4H4C2.9 4 2 4.9 2 6V16C2 17.1 2.9 18 4 18H0V20H24V18H20ZM4 6H20V16H4V6Z" />
<path d="M15 11L11 14V12H9V10H11V8L15 11Z" />
</svg>
</button>
)}
{/* Video Quality */}
<button
@@ -0,0 +1,174 @@
import React, { useEffect, useLayoutEffect, useRef } from 'react';
import ReactDOM from 'react-dom';
import { useVoiceStore } from '../../stores/voiceStore';
import { useSpaceStore, getChannelOrigin } from '../../stores/spaceStore';
import { wsSend } from '../../hooks/useWebSocket';
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
interface VoiceModMenuItemsProps {
targetUserId: string;
channelId: string;
onAction: () => void;
}
/**
* Headless moderation menu items (mute/deafen/move buttons).
* Renders nothing if the current user has no moderation permissions.
* Use inside any container — no portal or positioning logic.
*/
export function VoiceModMenuItems({ targetUserId, channelId, onAction }: VoiceModMenuItemsProps) {
const serverMutedUserIds = useVoiceStore((s) => s.serverMutedUserIds);
const serverDeafenedUserIds = useVoiceStore((s) => s.serverDeafenedUserIds);
const spacePermissions = useSpaceStore((s) => s.spacePermissions);
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
const channels = useSpaceStore((s) => s.channels);
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 otherVoiceChannels = channels.filter(
(c) => (c.type === 'voice' || c.type === 'video') && c.id !== channelId,
);
const voiceOrigin = getChannelOrigin(channelId);
const isServerMuted = serverMutedUserIds.has(targetUserId);
const isServerDeafened = serverDeafenedUserIds.has(targetUserId);
if (!canMuteMembers && !canDeafenMembers && !canMoveMembers) return null;
const handleServerMute = () => {
wsSend({ type: 'voice_server_mute', userId: targetUserId, muted: !isServerMuted }, voiceOrigin);
onAction();
};
const handleServerDeafen = () => {
wsSend({ type: 'voice_server_deafen', userId: targetUserId, deafened: !isServerDeafened }, voiceOrigin);
onAction();
};
const handleMove = (targetChannelId: string) => {
wsSend({ type: 'voice_move', userId: targetUserId, targetChannelId }, voiceOrigin);
onAction();
};
const btnClass = 'w-full text-left px-2 py-1.5 mx-1.5 text-sm rounded-sm flex items-center gap-2 text-txt-secondary hover:bg-accent-primary hover:text-white';
const btnStyle = { width: 'calc(100% - 12px)' };
return (
<>
{canMuteMembers && (
<button onClick={handleServerMute} className={btnClass} style={btnStyle}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" className="flex-shrink-0">
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" />
<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" />
{isServerMuted && (
<line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
)}
</svg>
{isServerMuted ? 'Server Unmute' : 'Server Mute'}
</button>
)}
{canDeafenMembers && (
<button onClick={handleServerDeafen} className={btnClass} style={btnStyle}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" className="flex-shrink-0">
<path d="M12 3c-4.97 0-9 4.03-9 9v7c0 1.1.9 2 2 2h2v-7H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-2v7h2c1.1 0 2-.9 2-2v-7c0-4.97-4.03-9-9-9z" />
{isServerDeafened && (
<line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
)}
</svg>
{isServerDeafened ? 'Server Undeafen' : 'Server Deafen'}
</button>
)}
{canMoveMembers && otherVoiceChannels.length > 0 && (
<>
<div className="h-px bg-white/[0.06] my-1 mx-1.5" />
<div className="px-3 py-1 text-[10px] text-txt-tertiary uppercase tracking-wider font-semibold">
Move to...
</div>
{otherVoiceChannels.map((ch) => (
<button key={ch.id} onClick={() => handleMove(ch.id)} className={btnClass} style={btnStyle}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" className="flex-shrink-0 text-txt-tertiary">
<path d="M11 5L6 9H2V15H6L11 19V5ZM15.54 8.46C16.48 9.4 17 10.67 17 12S16.48 14.6 15.54 15.54L14.12 14.12C14.69 13.55 15 12.79 15 12S14.69 10.45 14.12 9.88L15.54 8.46Z" />
</svg>
<span className="truncate">{ch.name}</span>
</button>
))}
</>
)}
</>
);
}
// ─── 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<HTMLDivElement>(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(
<div
ref={menuRef}
className="fixed z-[200] bg-surface-elevated rounded-md shadow-elevation-high py-1.5 min-w-[180px] max-h-[calc(100vh-16px)] overflow-y-auto scrollbar-thin animate-fade-in"
style={{ left: position.x, top: position.y }}
>
<VoiceModMenuItems
targetUserId={targetUserId}
channelId={channelId}
onAction={onClose}
/>
</div>,
document.body,
);
}
+105 -36
View File
@@ -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<HTMLDivElement>(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) {
</div>
</div>
{volumeMenu && !isLocal && (
{volumeMenu && !isLocal && ReactDOM.createPortal(
<div
className="fixed z-[60] bg-surface-base rounded-lg shadow-2xl p-3 min-w-[200px] border border-white/[0.06]"
ref={menuRef}
className="fixed z-[200] bg-surface-elevated rounded-md shadow-elevation-high min-w-[200px] animate-fade-in"
style={{ left: volumeMenu.x, top: volumeMenu.y }}
onClick={(e) => e.stopPropagation()}
>
<div className="text-xs text-txt-tertiary mb-2 font-medium uppercase tracking-wider">
User Volume
</div>
<div className="flex items-center gap-2">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="currentColor"
className="text-txt-tertiary flex-shrink-0"
>
<path d="M3 9v6h4l5 5V4L7 9H3z" />
</svg>
<input
type="range"
min="0"
max="200"
value={perUserVolume}
onChange={(e) =>
setParticipantVolume(
participant.userId,
parseInt(e.target.value),
)
}
className="flex-1 accent-accent-primary h-1"
{/* Moderation options (renders nothing if no perms) */}
{currentVoiceChannelId && (
<VoiceModSection
targetUserId={participant.userId}
channelId={currentVoiceChannelId}
onAction={() => setVolumeMenu(null)}
/>
<span className="text-xs text-txt-secondary min-w-[32px] text-right">
{perUserVolume}%
</span>
)}
{/* Volume slider */}
<div className="p-3">
<div className="text-xs text-txt-tertiary mb-2 font-medium uppercase tracking-wider">
User Volume
</div>
<div className="flex items-center gap-2">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="currentColor"
className="text-txt-tertiary flex-shrink-0"
>
<path d="M3 9v6h4l5 5V4L7 9H3z" />
</svg>
<input
type="range"
min="0"
max="200"
value={perUserVolume}
onChange={(e) =>
setParticipantVolume(
participant.userId,
parseInt(e.target.value),
)
}
className="flex-1 accent-accent-primary h-1"
/>
<span className="text-xs text-txt-secondary min-w-[32px] text-right">
{perUserVolume}%
</span>
</div>
</div>
</div>
</div>,
document.body,
)}
</div>
);
}
/** 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 (
<>
<div className="py-1.5">
<VoiceModMenuItems
targetUserId={targetUserId}
channelId={channelId}
onAction={onAction}
/>
</div>
<div className="h-px bg-white/[0.06] mx-1.5" />
</>
);
}
+65
View File
@@ -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<string, { serverMuted: boolean; serverDeafened: boolean }>)) {
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': {
+26
View File
@@ -86,6 +86,12 @@ interface VoiceState {
voiceUserStates: Map<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>;
setVoiceUserStatus: (userId: string, isMuted: boolean, isDeafened: boolean, isCameraOn: boolean, isScreenSharing: boolean) => void;
clearVoiceUserStatus: (userId: string) => void;
// Server mute/deafen state (moderator action)
serverMutedUserIds: Set<string>;
serverDeafenedUserIds: Set<string>;
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<VoiceState>()(
});
},
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<VoiceState>()(
streamVolumes: new Map(),
streamMutes: new Map(),
watchingStreams: new Set(),
serverMutedUserIds: new Set(),
serverDeafenedUserIds: new Set(),
}),
}),
{