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) // Ensure join_requests table exists (idempotent)
db.exec(` db.exec(`
CREATE TABLE IF NOT EXISTS join_requests ( 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(), 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', { export const joinRequests = sqliteTable('join_requests', {
id: text('id').primaryKey(), id: text('id').primaryKey(),
spaceId: text('space_id').notNull().references(() => spaces.id, { onDelete: 'cascade' }), spaceId: text('space_id').notNull().references(() => spaces.id, { onDelete: 'cascade' }),
+22 -3
View File
@@ -1,8 +1,8 @@
import type { FastifyInstance } from 'fastify'; 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 { authenticate } from '../utils/auth.js';
import { config } from '../config.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'; import type { LiveKitTokenRequest, LiveKitTokenResponse } from '@backspace/shared';
export async function livekitRoutes(app: FastifyInstance): Promise<void> { 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 // Determine room name based on channel type
let roomName: string; let roomName: string;
// Default: full publish (DM calls always get full permissions)
let canSpeak = true;
let canStream = true;
if (dmChannelId && typeof dmChannelId === 'string') { if (dmChannelId && typeof dmChannelId === 'string') {
// DM call token // DM call token
if (!isDmMember(dmChannelId, request.userId)) { 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)) { if (!hasPermission(request.userId, spaceId, PermissionBits.CONNECT, channelId)) {
return reply.code(403).send({ error: 'Missing CONNECT permission', statusCode: 403 }); 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; roomName = channelId;
} else { } else {
return reply.code(400).send({ error: 'channelId or dmChannelId is required', statusCode: 400 }); 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', 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({ token.addGrant({
room: roomName, room: roomName,
roomJoin: true, roomJoin: true,
canPublish: true, canPublish: canSpeak || canStream,
canPublishSources,
canSubscribe: true, canSubscribe: true,
canPublishData: 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 }); 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) && if ((!content || typeof content !== 'string' || content.trim().length === 0) &&
(!attachmentIds || attachmentIds.length === 0)) { (!attachmentIds || attachmentIds.length === 0)) {
return reply.code(400).send({ error: 'Message must have content or attachments', statusCode: 400 }); 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 { getDb, schema } from '../db/index.js';
import { authenticate } from '../utils/auth.js'; import { authenticate } from '../utils/auth.js';
import { generateSnowflake } from '../utils/snowflake.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 { DEFAULT_EVERYONE_PERMISSIONS, ALL_PERMISSIONS, permissionsToString } from '@backspace/shared/src/permissions.js';
import crypto from 'crypto'; import crypto from 'crypto';
import { connectionManager } from '../ws/handler.js'; 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 }); 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)) { if (isMember(id, request.userId)) {
return reply.code(409).send({ error: 'You are already a member of this space', statusCode: 409 }); 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 }); 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)) { if (isMember(server.id, request.userId)) {
return reply.code(409).send({ error: 'You are already a member of this space', statusCode: 409 }); 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 }); 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(); .get();
return member !== undefined; 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': case 'voice_status':
handleVoiceStatus(event, userId); handleVoiceStatus(event, userId);
break; 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: default:
connectionManager.sendToUser(userId, { connectionManager.sendToUser(userId, {
type: 'error', type: 'error',
@@ -708,6 +717,11 @@ function handleReactionAdd(event: Record<string, unknown>, userId: string): void
const spaceId = getChannelSpaceId(message.channelId); const spaceId = getChannelSpaceId(message.channelId);
if (!spaceId || !isMember(spaceId, userId)) return; 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 reactionId = generateSnowflake();
const now = Date.now(); const now = Date.now();
try { try {
@@ -1040,3 +1054,170 @@ function handleDmCallEnd(event: Record<string, unknown>, userId: string): void {
dmChannelId, 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(); private pendingOfflineTimeouts: Map<string, NodeJS.Timeout> = new Map();
// roomId → Timeout for ringing DM rooms (60s auto-cleanup) // roomId → Timeout for ringing DM rooms (60s auto-cleanup)
private ringingTimeouts: Map<string, NodeJS.Timeout> = new Map(); 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 { addConnection(userId: string, ws: WebSocket): void {
if (!this.connections.has(userId)) { if (!this.connections.has(userId)) {
@@ -301,6 +304,7 @@ class ConnectionManager {
room.participants.delete(userId); room.participants.delete(userId);
this.userToRoom.delete(userId); this.userToRoom.delete(userId);
this.clearServerVoiceState(userId);
// Auto-cleanup empty space rooms (they're lazy-created) // Auto-cleanup empty space rooms (they're lazy-created)
if (room.participants.size === 0 && room.roomType === 'space') { if (room.participants.size === 0 && room.roomType === 'space') {
@@ -382,6 +386,29 @@ class ConnectionManager {
this.voiceUserStates.delete(userId); 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 }> { getAllVoiceUserStates(): Map<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }> {
return this.voiceUserStates; return this.voiceUserStates;
} }
@@ -536,6 +563,7 @@ function buildReadyPayload(userId: string): {
folders: SpaceFolder[]; folders: SpaceFolder[];
voiceStates: Record<string, string[]>; voiceStates: Record<string, string[]>;
voiceUserStates: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>; voiceUserStates: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>;
serverVoiceStates: Record<string, { serverMuted: boolean; serverDeafened: boolean }>;
readStates: ReadState[]; readStates: ReadState[];
activeCalls: ActiveCallInfo[]; 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 // Fetch read states for unread tracking
const readStateRows = db.select() const readStateRows = db.select()
.from(schema.readStates) .from(schema.readStates)
@@ -858,7 +901,7 @@ function buildReadyPayload(userId: string): {
lastReadMessageId: rs.lastReadMessageId, 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> { 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_reject'; dmChannelId: string }
| { type: 'dm_call_end'; dmChannelId: string } | { type: 'dm_call_end'; dmChannelId: string }
| { type: 'voice_status'; isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean } | { 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' }; | { type: 'ping' };
// Server → Client Events // Server → Client Events
export type ServerEvent = 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_created'; message: MessageWithUser }
| { type: 'message_updated'; message: MessageWithUser } | { type: 'message_updated'; message: MessageWithUser }
| { type: 'message_deleted'; messageId: string; channelId: string } | { type: 'message_deleted'; messageId: string; channelId: string }
@@ -276,6 +279,10 @@ export type ServerEvent =
| { type: 'join_request_received'; request: JoinRequest } | { type: 'join_request_received'; request: JoinRequest }
| { type: 'join_request_accepted'; request: JoinRequest; space: SpaceWithChannelsAndMembers } | { type: 'join_request_accepted'; request: JoinRequest; space: SpaceWithChannelsAndMembers }
| { type: 'join_request_declined'; request: JoinRequest } | { 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: 'pong' }
| { type: 'error'; message: string }; | { type: 'error'; message: string };
+9
View File
@@ -59,6 +59,9 @@ export class BackspaceApiClient {
members: (id: string) => Promise<MemberWithUser[]>; members: (id: string) => Promise<MemberWithUser[]>;
updateMember: (spaceId: string, userId: string, data: UpdateMemberRequest) => Promise<MemberWithUser>; updateMember: (spaceId: string, userId: string, data: UpdateMemberRequest) => Promise<MemberWithUser>;
removeMember: (spaceId: string, userId: string) => Promise<{ success: boolean }>; 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: { readonly channels: {
@@ -223,6 +226,12 @@ export class BackspaceApiClient {
request<MemberWithUser>('PATCH', `/spaces/${spaceId}/members/${userId}`, data), request<MemberWithUser>('PATCH', `/spaces/${spaceId}/members/${userId}`, data),
removeMember: (spaceId: string, userId: string) => removeMember: (spaceId: string, userId: string) =>
request<{ success: boolean }>('DELETE', `/spaces/${spaceId}/members/${userId}`), 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 = { this.channels = {
+16 -12
View File
@@ -54,6 +54,8 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
const myChPerms = channelPermissions.get(message.channelId); const myChPerms = channelPermissions.get(message.channelId);
const canManageMessages = hasPermissionBit(myChPerms, PermissionBits.MANAGE_MESSAGES); const canManageMessages = hasPermissionBit(myChPerms, PermissionBits.MANAGE_MESSAGES);
const canDelete = isAuthor || canManageMessages; 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 addReaction = useChatStore((s) => s.addReaction);
const removeReaction = useChatStore((s) => s.removeReaction); 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); const hasReacted = message.reactions?.some(r => isOwnReaction(r) && r.emoji === emoji);
if (hasReacted) { if (hasReacted) {
removeReaction(message.id, emoji); removeReaction(message.id, emoji);
} else { } else if (canAddReactions) {
addReaction(message.id, emoji); addReaction(message.id, emoji);
} }
}; };
@@ -324,17 +326,19 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
{/* Action buttons on hover */} {/* Action buttons on hover */}
{isHovered && !isEditing && ( {isHovered && !isEditing && (
<div className="absolute -top-[18px] right-4 flex items-center glass rounded-[10px] overflow-hidden z-10 h-8"> <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"> {canAddReactions && (
{['👍', '❤️', '😂', '😮'].map(emoji => ( <div className="flex items-center px-1 border-r border-white/[0.06] h-full">
<button {['👍', '❤️', '😂', '😮'].map(emoji => (
key={emoji} <button
onClick={() => toggleReaction(emoji)} key={emoji}
className="p-1 hover:bg-interactive-hover rounded transition-colors text-[16px] leading-none" onClick={() => toggleReaction(emoji)}
> className="p-1 hover:bg-interactive-hover rounded transition-colors text-[16px] leading-none"
{emoji} >
</button> {emoji}
))} </button>
</div> ))}
</div>
)}
<button <button
onClick={() => setReplyTo(message)} 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" 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 { wsSend } from '../../hooks/useWebSocket';
import { MentionPopover } from './MentionPopover'; import { MentionPopover } from './MentionPopover';
import { TypingIndicator } from './TypingIndicator'; import { TypingIndicator } from './TypingIndicator';
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
import type { MemberWithUser } from '@backspace/shared'; import type { MemberWithUser } from '@backspace/shared';
interface MessageInputProps { interface MessageInputProps {
@@ -31,6 +32,12 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
const members = useSpaceStore((s) => s.members); const members = useSpaceStore((s) => s.members);
const typingTimeoutRef = useRef<ReturnType<typeof setTimeout>>(); 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 // Auto-focus textarea on channel navigation
useEffect(() => { useEffect(() => {
textareaRef.current?.focus(); textareaRef.current?.focus();
@@ -230,6 +237,16 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
textarea.style.height = Math.min(textarea.scrollHeight, 300) + 'px'; 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 ( 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 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} /> <TypingIndicator channelId={channelId} />
@@ -252,8 +269,8 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
<div <div
ref={inputContainerRef} ref={inputContainerRef}
className={`relative bg-surface-input md:bg-transparent ${replyTo ? 'rounded-b-lg' : 'rounded-lg md:rounded-none'} overflow-visible`} className={`relative bg-surface-input md:bg-transparent ${replyTo ? 'rounded-b-lg' : 'rounded-lg md:rounded-none'} overflow-visible`}
onDrop={handleDrop} onDrop={canAttachFiles ? handleDrop : undefined}
onDragOver={handleDragOver} onDragOver={canAttachFiles ? handleDragOver : undefined}
> >
{/* Mention autocomplete popover */} {/* Mention autocomplete popover */}
{mentionState && filteredMembers.length > 0 && ( {mentionState && filteredMembers.length > 0 && (
@@ -299,15 +316,17 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
<div className="flex items-center pl-[10px] pr-1"> <div className="flex items-center pl-[10px] pr-1">
{/* File attach button */} {/* File attach button */}
<button {canAttachFiles && (
onClick={() => fileInputRef.current?.click()} <button
className="w-[34px] h-[34px] flex items-center justify-center rounded-[6px] text-txt-tertiary hover:text-txt-secondary transition-colors flex-shrink-0" onClick={() => fileInputRef.current?.click()}
title="Attach file" 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 width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
</svg> <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" />
</button> </svg>
</button>
)}
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
@@ -328,7 +347,7 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
value={content} value={content}
onChange={handleChange} onChange={handleChange}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onPaste={handlePaste} onPaste={canAttachFiles ? handlePaste : undefined}
placeholder={`Message ${channelName.startsWith('@') ? channelName : `#${channelName}`}`} 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" 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} rows={1}
@@ -6,6 +6,7 @@ import { useAuthStore } from '../../stores/authStore';
import { useSocialStore } from '../../stores/socialStore'; import { useSocialStore } from '../../stores/socialStore';
import { Avatar } from '../ui/Avatar'; import { Avatar } from '../ui/Avatar';
import { LoadingSpinner } from '../ui/LoadingSpinner'; import { LoadingSpinner } from '../ui/LoadingSpinner';
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
import type { MessageWithUser } from '@backspace/shared'; import type { MessageWithUser } from '@backspace/shared';
const EMPTY_MESSAGES: MessageWithUser[] = []; const EMPTY_MESSAGES: MessageWithUser[] = [];
@@ -53,9 +54,16 @@ export function MessageList({ channelId }: MessageListProps) {
const prevMessagesLength = useRef(0); const prevMessagesLength = useRef(0);
const ackTimerRef = useRef<ReturnType<typeof setTimeout>>(); 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(() => { useEffect(() => {
loadMessages(channelId); if (canReadHistory) {
}, [channelId, loadMessages]); loadMessages(channelId);
}
}, [channelId, loadMessages, canReadHistory]);
// Ack channel when messages load or when new messages arrive while near bottom // Ack channel when messages load or when new messages arrive while near bottom
useEffect(() => { useEffect(() => {
@@ -135,6 +143,14 @@ export function MessageList({ channelId }: MessageListProps) {
} }
}, [channelId, hasMore, isLoadingMore, loadMoreMessages]); }, [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) { if (isLoading && messages.length === 0) {
return ( return (
<div className="flex-1 flex items-center justify-center"> <div className="flex-1 flex items-center justify-center">
@@ -34,10 +34,13 @@ export function ChannelSidebar() {
const isDeafened = useVoiceStore((s) => s.isDeafened); const isDeafened = useVoiceStore((s) => s.isDeafened);
const toggleMic = useVoiceStore((s) => s.toggleMic); const toggleMic = useVoiceStore((s) => s.toggleMic);
const toggleDeafen = useVoiceStore((s) => s.toggleDeafen); 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 navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const handleMicToggle = async () => { const handleMicToggle = async () => {
if (isServerMuted || isServerDeafened) return; // Blocked by server mute/deafen
toggleMic(); toggleMic();
// Broadcast mute status via WebSocket so non-joined users can see it // Broadcast mute status via WebSocket so non-joined users can see it
const willBeMuted = !isMuted; const willBeMuted = !isMuted;
@@ -47,6 +50,7 @@ export function ChannelSidebar() {
}; };
const handleDeafenToggle = async () => { const handleDeafenToggle = async () => {
if (isServerDeafened) return; // Blocked by server deafen
const room = getActiveRoom(); const room = getActiveRoom();
const willDeafen = !isDeafened; const willDeafen = !isDeafened;
// Update store FIRST so updateParticipants reads correct state when LiveKit events fire // Update store FIRST so updateParticipants reads correct state when LiveKit events fire
@@ -84,7 +88,9 @@ export function ChannelSidebar() {
if (inst) return inst.label; if (inst) return inst.label;
try { return new URL(origin).host; } catch { return origin; } try { return new URL(origin).host; } catch { return origin; }
}, [space, federationInstances]); }, [space, federationInstances]);
const channelPermissions = useSpaceStore((s) => s.channelPermissions);
const canManageChannels = hasPermissionBit(mySpacePerms, PermissionBits.MANAGE_CHANNELS); const canManageChannels = hasPermissionBit(mySpacePerms, PermissionBits.MANAGE_CHANNELS);
const canCreateInvite = hasPermissionBit(mySpacePerms, PermissionBits.CREATE_INVITE);
const textChannels = channels.filter(c => c.type === 'text'); const textChannels = channels.filter(c => c.type === 'text');
const voiceChannels = channels.filter(c => c.type === 'voice' || c.type === 'video'); const voiceChannels = channels.filter(c => c.type === 'voice' || c.type === 'video');
@@ -122,6 +128,8 @@ export function ChannelSidebar() {
user={user} user={user}
isMuted={isMuted} isMuted={isMuted}
isDeafened={isDeafened} isDeafened={isDeafened}
isServerMuted={isServerMuted}
isServerDeafened={isServerDeafened}
isAdmin={!!user.isAdmin} isAdmin={!!user.isAdmin}
onMicToggle={handleMicToggle} onMicToggle={handleMicToggle}
onDeafenToggle={handleDeafenToggle} 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" /> <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> </svg>
</button> </button>
<button {canCreateInvite && (
onClick={() => openModal('invite')} <button
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" onClick={() => openModal('invite')}
title="Invite People" 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 width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
</svg> <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" />
</button> </svg>
</button>
)}
</div> </div>
{/* Channels */} {/* Channels */}
@@ -419,14 +429,19 @@ export function ChannelSidebar() {
)} )}
</div> </div>
<div className="space-y-[2px]"> <div className="space-y-[2px]">
{voiceChannels.map((channel) => ( {voiceChannels.map((channel) => {
<VoiceChannel const chPerms = channelPermissions.get(channel.id);
key={channel.id} const canConnect = hasPermissionBit(chPerms, PermissionBits.CONNECT);
channelId={channel.id} return (
channelName={channel.name} <VoiceChannel
onClick={() => handleVoiceJoin(channel.id)} key={channel.id}
/> channelId={channel.id}
))} channelName={channel.name}
onClick={() => canConnect && handleVoiceJoin(channel.id)}
locked={!canConnect}
/>
);
})}
</div> </div>
</div> </div>
@@ -444,6 +459,8 @@ function UserAreaPanel({
user, user,
isMuted, isMuted,
isDeafened, isDeafened,
isServerMuted,
isServerDeafened,
isAdmin, isAdmin,
onMicToggle, onMicToggle,
onDeafenToggle, onDeafenToggle,
@@ -453,6 +470,8 @@ function UserAreaPanel({
user: any; user: any;
isMuted: boolean; isMuted: boolean;
isDeafened: boolean; isDeafened: boolean;
isServerMuted: boolean;
isServerDeafened: boolean;
isAdmin: boolean; isAdmin: boolean;
onMicToggle: () => void; onMicToggle: () => void;
onDeafenToggle: () => void; onDeafenToggle: () => void;
@@ -769,14 +788,15 @@ function UserAreaPanel({
<button <button
onClick={onMicToggle} onClick={onMicToggle}
className={`w-8 h-8 flex items-center justify-center hover:bg-interactive-hover rounded-l-[4px] transition-colors ${ 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"> <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="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" /> <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> </svg>
</button> </button>
{/* Input chevron */} {/* Input chevron */}
@@ -796,13 +816,14 @@ function UserAreaPanel({
<button <button
onClick={onDeafenToggle} onClick={onDeafenToggle}
className={`w-8 h-8 flex items-center justify-center hover:bg-interactive-hover rounded-l-[4px] transition-colors ${ 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"> <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" /> <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> </svg>
</button> </button>
{/* Output chevron */} {/* Output chevron */}
@@ -9,6 +9,7 @@ import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
import { OverviewPanel } from './spaceSettingsPanels/OverviewPanel'; import { OverviewPanel } from './spaceSettingsPanels/OverviewPanel';
import { MembersPanel } from './spaceSettingsPanels/MembersPanel'; import { MembersPanel } from './spaceSettingsPanels/MembersPanel';
import { RolesPanel } from './spaceSettingsPanels/RolesPanel'; import { RolesPanel } from './spaceSettingsPanels/RolesPanel';
import { BansPanel } from './spaceSettingsPanels/BansPanel';
import type { SpaceVisibility, JoinRequest } from '@backspace/shared'; import type { SpaceVisibility, JoinRequest } from '@backspace/shared';
function DiscoveryPanel({ spaceId }: { spaceId: string }) { function DiscoveryPanel({ spaceId }: { spaceId: string }) {
@@ -269,13 +270,14 @@ export function SpaceSettingsModal() {
const spaces = useSpaceStore((s) => s.spaces); const spaces = useSpaceStore((s) => s.spaces);
const spacePermissions = useSpaceStore((s) => s.spacePermissions); 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 isOpen = activeModal === 'spaceSettings';
const space = spaces.find(s => s.id === currentSpaceId); const space = spaces.find(s => s.id === currentSpaceId);
const mySpacePerms = currentSpaceId ? spacePermissions.get(currentSpaceId) : undefined; const mySpacePerms = currentSpaceId ? spacePermissions.get(currentSpaceId) : undefined;
const canManageSpace = hasPermissionBit(mySpacePerms, PermissionBits.MANAGE_SPACE); const canManageSpace = hasPermissionBit(mySpacePerms, PermissionBits.MANAGE_SPACE);
const canManageRoles = hasPermissionBit(mySpacePerms, PermissionBits.MANAGE_ROLES); const canManageRoles = hasPermissionBit(mySpacePerms, PermissionBits.MANAGE_ROLES);
const canBanMembers = hasPermissionBit(mySpacePerms, PermissionBits.BAN_MEMBERS);
if (!space || !currentSpaceId) return null; if (!space || !currentSpaceId) return null;
@@ -306,6 +308,11 @@ export function SpaceSettingsModal() {
Roles Roles
</button> </button>
)} )}
{canBanMembers && (
<button onClick={() => setTab('bans')} className={tabClass('bans')}>
Bans
</button>
)}
</div> </div>
</div> </div>
@@ -315,6 +322,7 @@ export function SpaceSettingsModal() {
{tab === 'discovery' && canManageSpace && <DiscoveryPanel spaceId={currentSpaceId} />} {tab === 'discovery' && canManageSpace && <DiscoveryPanel spaceId={currentSpaceId} />}
{tab === 'members' && <MembersPanel spaceId={currentSpaceId} />} {tab === 'members' && <MembersPanel spaceId={currentSpaceId} />}
{tab === 'roles' && canManageRoles && <RolesPanel spaceId={currentSpaceId} />} {tab === 'roles' && canManageRoles && <RolesPanel spaceId={currentSpaceId} />}
{tab === 'bans' && canBanMembers && <BansPanel spaceId={currentSpaceId} />}
</div> </div>
</div> </div>
</Modal> </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 React, { useState } from 'react';
import { Avatar } from '../../ui/Avatar'; import { Avatar } from '../../ui/Avatar';
import { useSpaceStore } from '../../../stores/spaceStore'; import { useSpaceStore, getApiForOrigin } from '../../../stores/spaceStore';
import { useAuthStore } from '../../../stores/authStore'; import { useAuthStore } from '../../../stores/authStore';
import { api } from '../../../api/client'; import { parseFederatedUsername } from '../../../utils/identity';
import { hasPermissionBit, PermissionBits } from '../../../utils/permissions'; import { hasPermissionBit, PermissionBits } from '../../../utils/permissions';
import type { MemberWithUser } from '@backspace/shared'; import type { MemberWithUser } from '@backspace/shared';
@@ -19,9 +19,11 @@ export function MembersPanel({ spaceId }: MembersPanelProps) {
const spacePermissions = useSpaceStore((s) => s.spacePermissions); const spacePermissions = useSpaceStore((s) => s.spacePermissions);
const space = spaces.find((s) => s.id === spaceId); const space = spaces.find((s) => s.id === spaceId);
const spaceApi = getApiForOrigin(space?._instanceOrigin ?? '');
const myPerms = spacePermissions.get(spaceId); const myPerms = spacePermissions.get(spaceId);
const canManageRoles = hasPermissionBit(myPerms, PermissionBits.MANAGE_ROLES); const canManageRoles = hasPermissionBit(myPerms, PermissionBits.MANAGE_ROLES);
const canKick = hasPermissionBit(myPerms, PermissionBits.KICK_MEMBERS); 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 [pendingRoleChanges, setPendingRoleChanges] = useState<Map<string, Set<string>>>(new Map());
const [expandedMemberId, setExpandedMemberId] = useState<string | null>(null); const [expandedMemberId, setExpandedMemberId] = useState<string | null>(null);
@@ -52,7 +54,7 @@ export function MembersPanel({ spaceId }: MembersPanelProps) {
const roleIds = pendingRoleChanges.get(userId); const roleIds = pendingRoleChanges.get(userId);
if (!roleIds) return; if (!roleIds) return;
try { try {
await api.spaces.updateMember(spaceId, userId, { roleIds: Array.from(roleIds) }); await spaceApi.spaces.updateMember(spaceId, userId, { roleIds: Array.from(roleIds) });
setPendingRoleChanges((prev) => { setPendingRoleChanges((prev) => {
const next = new Map(prev); const next = new Map(prev);
next.delete(userId); next.delete(userId);
@@ -75,13 +77,22 @@ export function MembersPanel({ spaceId }: MembersPanelProps) {
const handleKick = async (userId: string) => { const handleKick = async (userId: string) => {
try { try {
await api.spaces.removeMember(spaceId, userId); await spaceApi.spaces.removeMember(spaceId, userId);
await loadSpaceDetail(spaceId); await loadSpaceDetail(spaceId);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to kick member'); 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) => const canExpandMember = (member: MemberWithUser) =>
canManageRoles && member.userId !== currentUser?.id && member.userId !== space.ownerId && assignableRoles.length > 0; 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="rounded-lg bg-white/[0.02] p-2">
<div className="space-y-0.5"> <div className="space-y-0.5">
{members.map((member) => { {members.map((member) => {
const { domain } = parseFederatedUsername(member.user.username);
const displayName = member.user.displayName ?? member.user.username; const displayName = member.user.displayName ?? member.user.username;
const isOwner = member.userId === space.ownerId; const isOwner = member.userId === space.ownerId;
const memberRoleIds = getMemberRoleIds(member); const memberRoleIds = getMemberRoleIds(member);
@@ -127,7 +139,12 @@ export function MembersPanel({ spaceId }: MembersPanelProps) {
user={member.user} user={member.user}
/> />
<div className="min-w-0"> <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"> <div className="flex items-center gap-1 flex-wrap">
{isOwner && ( {isOwner && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-accent-rose/20 text-txt-danger font-medium"> <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>
<div className="flex items-center gap-1 flex-shrink-0"> <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 && ( {canKick && member.userId !== currentUser?.id && !isOwner && (
<button <button
onClick={(e) => { e.stopPropagation(); handleKick(member.userId); }} 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 { 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[] = []; const EMPTY_VOICE_USERS: string[] = [];
import { useSpaceStore } from '../../stores/spaceStore';
import { Avatar } from '../ui/Avatar';
interface VoiceChannelProps { interface VoiceChannelProps {
channelId: string; channelId: string;
channelName: string; channelName: string;
onClick: () => void; 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 voiceUsers = useVoiceStore((s) => s.voiceUsers.get(channelId)) ?? EMPTY_VOICE_USERS;
const currentVoiceChannel = useVoiceStore((s) => s.currentVoiceChannelId); const currentVoiceChannel = useVoiceStore((s) => s.currentVoiceChannelId);
const participants = useVoiceStore((s) => s.participants); const participants = useVoiceStore((s) => s.participants);
const localIsDeafened = useVoiceStore((s) => s.isDeafened); const localIsDeafened = useVoiceStore((s) => s.isDeafened);
const localIsMuted = useVoiceStore((s) => s.isMuted); const localIsMuted = useVoiceStore((s) => s.isMuted);
const voiceUserStates = useVoiceStore((s) => s.voiceUserStates); const voiceUserStates = useVoiceStore((s) => s.voiceUserStates);
const serverMutedUserIds = useVoiceStore((s) => s.serverMutedUserIds);
const serverDeafenedUserIds = useVoiceStore((s) => s.serverDeafenedUserIds);
const currentUserId = useVoiceStore((s) => { const currentUserId = useVoiceStore((s) => {
// Derive from participants — avoids unnecessary authStore dependency
const local = s.participants.find(p => p.isLocal); const local = s.participants.find(p => p.isLocal);
return local?.userId ?? null; return local?.userId ?? null;
}); });
const members = useSpaceStore((s) => s.members); const members = useSpaceStore((s) => s.members);
const myUser = useAuthStore((s) => s.user);
const isActive = currentVoiceChannel === channelId; 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 ( return (
<div> <div>
<button <button
onClick={onClick} onClick={onClick}
className={`relative w-full flex items-center gap-1.5 px-[10px] h-8 rounded-[6px] group transition-colors ${ className={`relative w-full flex items-center gap-1.5 px-[10px] h-8 rounded-[6px] group transition-colors ${
isActive locked
? 'bg-surface-elevated text-txt-primary' ? 'text-txt-tertiary/50 cursor-not-allowed'
: 'text-txt-tertiary hover:text-txt-secondary hover:bg-interactive-hover' : 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 <div
className="absolute -left-[2px] top-1/2 -translate-y-1/2 w-[3px] bg-white rounded-r-full" className="absolute -left-[2px] top-1/2 -translate-y-1/2 w-[3px] bg-white rounded-r-full"
style={{ height: '55%', opacity: 0.7 }} style={{ height: '55%', opacity: 0.7 }}
/> />
)} )}
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" className="flex-shrink-0 text-[#6e6e7a]"> {locked ? (
<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 width="18" height="18" viewBox="0 0 24 24" fill="currentColor" className="flex-shrink-0 text-[#6e6e7a]/50">
</svg> <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> <span className="truncate text-[15px] font-medium">{channelName}</span>
</button> </button>
@@ -57,8 +83,6 @@ export function VoiceChannel({ channelId, channelName, onClick }: VoiceChannelPr
const displayName = member?.user.displayName ?? member?.user.username ?? participant?.username ?? userId; const displayName = member?.user.displayName ?? member?.user.username ?? participant?.username ?? userId;
const avatar = member?.user.avatar ?? null; const avatar = member?.user.avatar ?? null;
const status = member?.user.status; 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 wsStatus = voiceUserStates.get(userId);
const isParticipantDeafened = userId === currentUserId const isParticipantDeafened = userId === currentUserId
? localIsDeafened ? localIsDeafened
@@ -68,9 +92,15 @@ export function VoiceChannel({ channelId, channelName, onClick }: VoiceChannelPr
: (participant?.isMuted ?? wsStatus?.isMuted ?? false); : (participant?.isMuted ?? wsStatus?.isMuted ?? false);
const hasCamera = participant?.isCameraOn ?? wsStatus?.isCameraOn ?? false; const hasCamera = participant?.isCameraOn ?? wsStatus?.isCameraOn ?? false;
const isScreenSharing = participant?.isScreenSharing ?? wsStatus?.isScreenSharing ?? false; const isScreenSharing = participant?.isScreenSharing ?? wsStatus?.isScreenSharing ?? false;
const isServerMuted = serverMutedUserIds.has(userId);
const isServerDeafened = serverDeafenedUserIds.has(userId);
return ( 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 <Avatar
src={avatar} src={avatar}
name={displayName} 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> <span className="text-[13px] text-txt-secondary truncate flex-1 min-w-0">{displayName}</span>
{/* Status badges */} {/* Status badges */}
<div className="flex items-center gap-1 flex-shrink-0"> <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"> <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="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" /> <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" /> <line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
</svg> </svg>
)} )}
{isParticipantDeafened && ( {!isServerDeafened && isParticipantDeafened && (
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" className="text-txt-danger"> <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" /> <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" /> <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> </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> </div>
); );
} }
@@ -1,6 +1,7 @@
import React, { useEffect, useState, useRef } from 'react'; import React, { useEffect, useState, useRef } from 'react';
import { useVoiceStore } from '../../stores/voiceStore'; import { useVoiceStore } from '../../stores/voiceStore';
import { useUIStore } from '../../stores/uiStore'; import { useUIStore } from '../../stores/uiStore';
import { useAuthStore } from '../../stores/authStore';
import { getActiveRoom } from '../../hooks/useLiveKit'; import { getActiveRoom } from '../../hooks/useLiveKit';
import { wsSend } from '../../hooks/useWebSocket'; import { wsSend } from '../../hooks/useWebSocket';
import { getChannelOrigin } from '../../stores/spaceStore'; import { getChannelOrigin } from '../../stores/spaceStore';
@@ -25,17 +26,22 @@ export function VoiceControlBar() {
const voiceFullscreen = useUIStore((s) => s.voiceFullscreen); const voiceFullscreen = useUIStore((s) => s.voiceFullscreen);
const toggleVoiceFullscreen = useUIStore((s) => s.toggleVoiceFullscreen); const toggleVoiceFullscreen = useUIStore((s) => s.toggleVoiceFullscreen);
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId); 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 voiceOrigin = currentVoiceChannelId ? getChannelOrigin(currentVoiceChannelId) : '';
const [qualityOpen, setQualityOpen] = useState(false); const [qualityOpen, setQualityOpen] = useState(false);
const qualityBtnRef = useRef<HTMLButtonElement>(null); const qualityBtnRef = useRef<HTMLButtonElement>(null);
const handleMute = React.useCallback(async () => { const handleMute = React.useCallback(async () => {
if (isServerMuted || isServerDeafened) return;
toggleMic(); toggleMic();
// Broadcast via WebSocket so sidebar shows status without joining // Broadcast via WebSocket so sidebar shows status without joining
wsSend({ type: 'voice_status', isMuted: !isMuted, isDeafened, isCameraOn, isScreenSharing }, voiceOrigin); 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 () => { const handleDeafen = React.useCallback(async () => {
if (isServerDeafened) return;
const room = getActiveRoom(); const room = getActiveRoom();
const willDeafen = !isDeafened; const willDeafen = !isDeafened;
// Update store FIRST so updateParticipants reads correct state // Update store FIRST so updateParticipants reads correct state
@@ -149,31 +155,35 @@ export function VoiceControlBar() {
{/* Mute */} {/* Mute */}
<button <button
onClick={handleMute} onClick={handleMute}
className={isMuted || isDeafened className={isServerMuted
? `${btnBase} bg-accent-rose/20 text-txt-danger hover:bg-accent-rose/30` ? `${btnBase} bg-accent-amber/20 text-accent-amber cursor-not-allowed`
: btnDefault : 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"> <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="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" /> <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> </svg>
</button> </button>
{/* Deafen */} {/* Deafen */}
<button <button
onClick={handleDeafen} onClick={handleDeafen}
className={isDeafened className={isServerDeafened
? `${btnBase} bg-accent-rose/20 text-txt-danger hover:bg-accent-rose/30` ? `${btnBase} bg-accent-amber/20 text-accent-amber cursor-not-allowed`
: btnDefault : 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"> <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" /> <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> </svg>
</button> </button>
@@ -6,6 +6,7 @@ import { wsSend } from '../../hooks/useWebSocket';
import { ScreenShareSettingsPopover } from './ScreenShareSettingsPopover'; import { ScreenShareSettingsPopover } from './ScreenShareSettingsPopover';
import { ConnectionInfoPopover } from './ConnectionInfoPopover'; import { ConnectionInfoPopover } from './ConnectionInfoPopover';
import { startScreenShare, stopScreenShare } from '../../utils/screenShare'; import { startScreenShare, stopScreenShare } from '../../utils/screenShare';
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
/** /**
* VoiceControls renders the voice status + button rows. * VoiceControls renders the voice status + button rows.
@@ -28,6 +29,12 @@ export function VoiceControls() {
const qualityBtnRef = useRef<HTMLButtonElement>(null); const qualityBtnRef = useRef<HTMLButtonElement>(null);
const activeDmCall = useVoiceStore((s) => s.activeDmCall); 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) : ''; const voiceOrigin = currentVoiceChannelId ? getChannelOrigin(currentVoiceChannelId) : '';
@@ -155,41 +162,45 @@ export function VoiceControls() {
{/* Row 2: Camera, Screen Share, Video Quality, Noise Suppression */} {/* Row 2: Camera, Screen Share, Video Quality, Noise Suppression */}
<div className="relative flex items-center gap-1 px-3 pb-2 pt-1"> <div className="relative flex items-center gap-1 px-3 pb-2 pt-1">
<button {canSpeak && (
onClick={handleCamera} <button
className={`${btnBase} ${ onClick={handleCamera}
isCameraOn className={`${btnBase} ${
? 'bg-surface-base text-status-online hover:bg-surface-channel' isCameraOn
: btnDefaultStyle ? 'bg-surface-base text-status-online hover:bg-surface-channel'
}`} : btnDefaultStyle
title={isCameraOn ? 'Turn Off Camera' : 'Turn On Camera'} }`}
> title={isCameraOn ? 'Turn Off Camera' : 'Turn On Camera'}
{isCameraOn ? ( >
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"> {isCameraOn ? (
<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 width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
</svg> <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" /> <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<line x1="2" y1="2" x2="22" y2="22" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" /> <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> <line x1="2" y1="2" x2="22" y2="22" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
)} </svg>
</button> )}
</button>
)}
<button {canStream && (
onClick={handleScreenShare} <button
className={`${btnBase} ${ onClick={handleScreenShare}
isScreenSharing className={`${btnBase} ${
? 'bg-surface-base text-status-online hover:bg-surface-channel' isScreenSharing
: btnDefaultStyle ? 'bg-surface-base text-status-online hover:bg-surface-channel'
}`} : btnDefaultStyle
title={isScreenSharing ? 'Stop Sharing' : 'Share Screen'} }`}
> 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" /> <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M15 11L11 14V12H9V10H11V8L15 11Z" /> <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" />
</svg> <path d="M15 11L11 14V12H9V10H11V8L15 11Z" />
</button> </svg>
</button>
)}
{/* Video Quality */} {/* Video Quality */}
<button <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 { Avatar } from '../ui/Avatar';
import { useVoiceStore } from '../../stores/voiceStore'; 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'; import type { UserTile } from '../../hooks/useLiveKit';
interface VoiceUserProps { interface VoiceUserProps {
@@ -13,6 +17,7 @@ export function VoiceUser({ tile, large }: VoiceUserProps) {
const { participant } = tile; const { participant } = tile;
const isDeafened = useVoiceStore((s) => s.isDeafened); const isDeafened = useVoiceStore((s) => s.isDeafened);
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
const participantVolumes = useVoiceStore((s) => s.participantVolumes); const participantVolumes = useVoiceStore((s) => s.participantVolumes);
const isSpeaking = useVoiceStore((s) => s.speakingParticipantIds.has(participant.identity)); const isSpeaking = useVoiceStore((s) => s.speakingParticipantIds.has(participant.identity));
@@ -50,6 +55,7 @@ export function VoiceUser({ tile, large }: VoiceUserProps) {
}, [tile.lkVideoTrack]); }, [tile.lkVideoTrack]);
// Context Menu // Context Menu
const menuRef = useRef<HTMLDivElement>(null);
const [volumeMenu, setVolumeMenu] = useState<{ const [volumeMenu, setVolumeMenu] = useState<{
x: number; x: number;
y: number; y: number;
@@ -65,11 +71,31 @@ export function VoiceUser({ tile, large }: VoiceUserProps) {
[isLocal], [isLocal],
); );
// Click-outside dismissal — mousedown + contains check
useEffect(() => { useEffect(() => {
if (!volumeMenu) return; if (!volumeMenu) return;
const close = () => setVolumeMenu(null); const handler = (e: MouseEvent) => {
window.addEventListener('click', close); if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
return () => window.removeEventListener('click', close); 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]); }, [volumeMenu]);
return ( return (
@@ -154,44 +180,87 @@ export function VoiceUser({ tile, large }: VoiceUserProps) {
</div> </div>
</div> </div>
{volumeMenu && !isLocal && ( {volumeMenu && !isLocal && ReactDOM.createPortal(
<div <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 }} 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"> {/* Moderation options (renders nothing if no perms) */}
User Volume {currentVoiceChannelId && (
</div> <VoiceModSection
<div className="flex items-center gap-2"> targetUserId={participant.userId}
<svg channelId={currentVoiceChannelId}
width="16" onAction={() => setVolumeMenu(null)}
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}% {/* Volume slider */}
</span> <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> </div>,
document.body,
)} )}
</div> </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); 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 // Re-register in voice channel after WS reconnect — the server lost
// voice state on restart, so we must tell it we're still connected. // 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); setVoiceUserStatus(event.userId, event.isMuted, event.isDeafened, event.isCameraOn, event.isScreenSharing);
break; 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': case 'member_joined':
if (!isHome) normalizeUserAssets(event.member.user, origin); if (!isHome) normalizeUserAssets(event.member.user, origin);
addMember(event.member); addMember(event.member);
@@ -274,6 +332,13 @@ function handleEvent(origin: string, event: ServerEvent): void {
removeMember(event.userId); removeMember(event.userId);
break; 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) ────────────────────────────────────────────── // ─── DM events (home-only) ──────────────────────────────────────────────
case 'dm_message_created': { case 'dm_message_created': {
+26
View File
@@ -86,6 +86,12 @@ interface VoiceState {
voiceUserStates: Map<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>; voiceUserStates: Map<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>;
setVoiceUserStatus: (userId: string, isMuted: boolean, isDeafened: boolean, isCameraOn: boolean, isScreenSharing: boolean) => void; setVoiceUserStatus: (userId: string, isMuted: boolean, isDeafened: boolean, isCameraOn: boolean, isScreenSharing: boolean) => void;
clearVoiceUserStatus: (userId: string) => 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[]; getVoiceUsers: (channelId: string) => string[];
clearAllVoiceUsers: () => void; clearAllVoiceUsers: () => void;
clearVoiceUsersForOrigin: (origin: string) => 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) ?? [], getVoiceUsers: (channelId) => get().voiceUsers.get(channelId) ?? [],
clearAllVoiceUsers: () => set({ voiceUsers: new Map(), voiceUserStates: new Map() }), clearAllVoiceUsers: () => set({ voiceUsers: new Map(), voiceUserStates: new Map() }),
@@ -352,6 +376,8 @@ export const useVoiceStore = create<VoiceState>()(
streamVolumes: new Map(), streamVolumes: new Map(),
streamMutes: new Map(), streamMutes: new Map(),
watchingStreams: new Set(), watchingStreams: new Set(),
serverMutedUserIds: new Set(),
serverDeafenedUserIds: new Set(),
}), }),
}), }),
{ {