feat: bans system, voice moderation, and federated space settings fixes
Add ban/unban functionality with BansPanel in space settings, voice moderation context menu (mute/deafen/disconnect), and fix federated space settings panels to use origin-aware API client. Show domain indicators for federated members in MembersPanel.
This commit is contained in:
@@ -116,6 +116,18 @@ export function runMigrations(db: Database.Database): void {
|
||||
);
|
||||
`);
|
||||
|
||||
// Ensure bans table exists (idempotent)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS bans (
|
||||
space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
reason TEXT,
|
||||
banned_by TEXT NOT NULL REFERENCES users(id),
|
||||
created_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (space_id, user_id)
|
||||
);
|
||||
`);
|
||||
|
||||
// Ensure join_requests table exists (idempotent)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS join_requests (
|
||||
|
||||
@@ -197,6 +197,16 @@ export const instanceSettings = sqliteTable('instance_settings', {
|
||||
updatedAt: integer('updated_at').notNull(),
|
||||
});
|
||||
|
||||
export const bans = sqliteTable('bans', {
|
||||
spaceId: text('space_id').notNull().references(() => spaces.id, { onDelete: 'cascade' }),
|
||||
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||
reason: text('reason'),
|
||||
bannedBy: text('banned_by').notNull().references(() => users.id),
|
||||
createdAt: integer('created_at').notNull(),
|
||||
}, (table) => ({
|
||||
pk: primaryKey({ columns: [table.spaceId, table.userId] }),
|
||||
}));
|
||||
|
||||
export const joinRequests = sqliteTable('join_requests', {
|
||||
id: text('id').primaryKey(),
|
||||
spaceId: text('space_id').notNull().references(() => spaces.id, { onDelete: 'cascade' }),
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { AccessToken } from 'livekit-server-sdk';
|
||||
import { AccessToken, TrackSource } from 'livekit-server-sdk';
|
||||
import { authenticate } from '../utils/auth.js';
|
||||
import { config } from '../config.js';
|
||||
import { getChannelSpaceId, hasPermission, isDmMember, PermissionBits } from '../utils/permissions.js';
|
||||
import { getChannelSpaceId, hasPermission, computePermissions, isDmMember, PermissionBits } from '../utils/permissions.js';
|
||||
import type { LiveKitTokenRequest, LiveKitTokenResponse } from '@backspace/shared';
|
||||
|
||||
export async function livekitRoutes(app: FastifyInstance): Promise<void> {
|
||||
@@ -18,6 +18,10 @@ export async function livekitRoutes(app: FastifyInstance): Promise<void> {
|
||||
// Determine room name based on channel type
|
||||
let roomName: string;
|
||||
|
||||
// Default: full publish (DM calls always get full permissions)
|
||||
let canSpeak = true;
|
||||
let canStream = true;
|
||||
|
||||
if (dmChannelId && typeof dmChannelId === 'string') {
|
||||
// DM call token
|
||||
if (!isDmMember(dmChannelId, request.userId)) {
|
||||
@@ -33,6 +37,10 @@ export async function livekitRoutes(app: FastifyInstance): Promise<void> {
|
||||
if (!hasPermission(request.userId, spaceId, PermissionBits.CONNECT, channelId)) {
|
||||
return reply.code(403).send({ error: 'Missing CONNECT permission', statusCode: 403 });
|
||||
}
|
||||
// Check SPEAK and STREAM permissions for granular token grants
|
||||
const perms = computePermissions(request.userId, spaceId, channelId);
|
||||
canSpeak = (perms & PermissionBits.SPEAK) !== 0n || (perms & PermissionBits.ADMINISTRATOR) !== 0n;
|
||||
canStream = (perms & PermissionBits.STREAM) !== 0n || (perms & PermissionBits.ADMINISTRATOR) !== 0n;
|
||||
roomName = channelId;
|
||||
} else {
|
||||
return reply.code(400).send({ error: 'channelId or dmChannelId is required', statusCode: 400 });
|
||||
@@ -45,10 +53,21 @@ export async function livekitRoutes(app: FastifyInstance): Promise<void> {
|
||||
ttl: '1h',
|
||||
});
|
||||
|
||||
// Build canPublishSources based on permissions
|
||||
const canPublishSources: TrackSource[] = [];
|
||||
if (canSpeak) {
|
||||
canPublishSources.push(TrackSource.MICROPHONE);
|
||||
canPublishSources.push(TrackSource.CAMERA);
|
||||
}
|
||||
if (canStream) {
|
||||
canPublishSources.push(TrackSource.SCREEN_SHARE, TrackSource.SCREEN_SHARE_AUDIO);
|
||||
}
|
||||
|
||||
token.addGrant({
|
||||
room: roomName,
|
||||
roomJoin: true,
|
||||
canPublish: true,
|
||||
canPublish: canSpeak || canStream,
|
||||
canPublishSources,
|
||||
canSubscribe: true,
|
||||
canPublishData: true,
|
||||
});
|
||||
|
||||
@@ -260,6 +260,11 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
|
||||
return reply.code(403).send({ error: 'Missing SEND_MESSAGES permission', statusCode: 403 });
|
||||
}
|
||||
|
||||
if (attachmentIds && attachmentIds.length > 0 &&
|
||||
!hasPermission(request.userId, spaceId, PermissionBits.ATTACH_FILES, id)) {
|
||||
return reply.code(403).send({ error: 'Missing ATTACH_FILES permission', statusCode: 403 });
|
||||
}
|
||||
|
||||
if ((!content || typeof content !== 'string' || content.trim().length === 0) &&
|
||||
(!attachmentIds || attachmentIds.length === 0)) {
|
||||
return reply.code(400).send({ error: 'Message must have content or attachments', statusCode: 400 });
|
||||
|
||||
@@ -3,7 +3,7 @@ import { eq, and, inArray } from 'drizzle-orm';
|
||||
import { getDb, schema } from '../db/index.js';
|
||||
import { authenticate } from '../utils/auth.js';
|
||||
import { generateSnowflake } from '../utils/snowflake.js';
|
||||
import { isMember, isSpaceOwner, hasPermission, computePermissions, PermissionBits } from '../utils/permissions.js';
|
||||
import { isMember, isSpaceOwner, isBanned, hasPermission, computePermissions, PermissionBits } from '../utils/permissions.js';
|
||||
import { DEFAULT_EVERYONE_PERMISSIONS, ALL_PERMISSIONS, permissionsToString } from '@backspace/shared/src/permissions.js';
|
||||
import crypto from 'crypto';
|
||||
import { connectionManager } from '../ws/handler.js';
|
||||
@@ -411,6 +411,10 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
|
||||
return reply.code(400).send({ error: 'Invalid invite code', statusCode: 400 });
|
||||
}
|
||||
|
||||
if (isBanned(id, request.userId)) {
|
||||
return reply.code(403).send({ error: 'You are banned from this space', statusCode: 403 });
|
||||
}
|
||||
|
||||
if (isMember(id, request.userId)) {
|
||||
return reply.code(409).send({ error: 'You are already a member of this space', statusCode: 409 });
|
||||
}
|
||||
@@ -463,6 +467,10 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
|
||||
return reply.code(404).send({ error: 'Invalid invite code', statusCode: 404 });
|
||||
}
|
||||
|
||||
if (isBanned(server.id, request.userId)) {
|
||||
return reply.code(403).send({ error: 'You are banned from this space', statusCode: 403 });
|
||||
}
|
||||
|
||||
if (isMember(server.id, request.userId)) {
|
||||
return reply.code(409).send({ error: 'You are already a member of this space', statusCode: 409 });
|
||||
}
|
||||
@@ -931,4 +939,144 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
|
||||
|
||||
return reply.code(200).send({ success: true });
|
||||
});
|
||||
|
||||
// ─── Ban Management ───────────────────────────────────────────────────────
|
||||
|
||||
// GET /api/spaces/:id/bans - List bans
|
||||
app.get<{ Params: { id: string } }>('/api/spaces/:id/bans', {
|
||||
preHandler: authenticate,
|
||||
}, async (request, reply) => {
|
||||
const { id } = request.params;
|
||||
const db = getDb();
|
||||
|
||||
if (!hasPermission(request.userId, id, PermissionBits.BAN_MEMBERS)) {
|
||||
return reply.code(403).send({ error: 'Missing BAN_MEMBERS permission', statusCode: 403 });
|
||||
}
|
||||
|
||||
const banRows = db.select().from(schema.bans)
|
||||
.where(eq(schema.bans.spaceId, id))
|
||||
.all();
|
||||
|
||||
if (banRows.length === 0) return reply.code(200).send([]);
|
||||
|
||||
const userIds = [...new Set(banRows.map(b => b.userId))];
|
||||
const bannedByIds = [...new Set(banRows.map(b => b.bannedBy))];
|
||||
const allUserIds = [...new Set([...userIds, ...bannedByIds])];
|
||||
const users = db.select().from(schema.users)
|
||||
.where(inArray(schema.users.id, allUserIds))
|
||||
.all();
|
||||
const userMap = new Map(users.map(u => [u.id, u]));
|
||||
|
||||
const bans = banRows.map(b => {
|
||||
const user = userMap.get(b.userId);
|
||||
const moderator = userMap.get(b.bannedBy);
|
||||
return {
|
||||
spaceId: b.spaceId,
|
||||
userId: b.userId,
|
||||
reason: b.reason,
|
||||
bannedBy: b.bannedBy,
|
||||
createdAt: b.createdAt,
|
||||
user: user ? sanitizeUser(user) : null,
|
||||
moderator: moderator ? sanitizeUser(moderator) : null,
|
||||
};
|
||||
});
|
||||
|
||||
return reply.code(200).send(bans);
|
||||
});
|
||||
|
||||
// POST /api/spaces/:id/bans - Ban a member
|
||||
app.post<{ Params: { id: string }; Body: { userId: string; reason?: string } }>('/api/spaces/:id/bans', {
|
||||
preHandler: authenticate,
|
||||
}, async (request, reply) => {
|
||||
const { id } = request.params;
|
||||
const { userId: targetId, reason } = request.body;
|
||||
const db = getDb();
|
||||
|
||||
if (!targetId || typeof targetId !== 'string') {
|
||||
return reply.code(400).send({ error: 'userId is required', statusCode: 400 });
|
||||
}
|
||||
|
||||
if (!hasPermission(request.userId, id, PermissionBits.BAN_MEMBERS)) {
|
||||
return reply.code(403).send({ error: 'Missing BAN_MEMBERS permission', statusCode: 403 });
|
||||
}
|
||||
|
||||
// Cannot ban the space owner
|
||||
if (isSpaceOwner(id, targetId)) {
|
||||
return reply.code(400).send({ error: 'Cannot ban the space owner', statusCode: 400 });
|
||||
}
|
||||
|
||||
// Cannot ban yourself
|
||||
if (targetId === request.userId) {
|
||||
return reply.code(400).send({ error: 'Cannot ban yourself', statusCode: 400 });
|
||||
}
|
||||
|
||||
// Check if already banned
|
||||
if (isBanned(id, targetId)) {
|
||||
return reply.code(409).send({ error: 'User is already banned', statusCode: 409 });
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
db.transaction((tx) => {
|
||||
// Insert ban record
|
||||
tx.insert(schema.bans).values({
|
||||
spaceId: id,
|
||||
userId: targetId,
|
||||
reason: reason?.trim() || null,
|
||||
bannedBy: request.userId,
|
||||
createdAt: now,
|
||||
}).run();
|
||||
|
||||
// Remove member from space
|
||||
tx.delete(schema.spaceMembers).where(and(
|
||||
eq(schema.spaceMembers.spaceId, id),
|
||||
eq(schema.spaceMembers.userId, targetId),
|
||||
)).run();
|
||||
|
||||
// Remove member's role assignments
|
||||
tx.delete(schema.memberRoles).where(and(
|
||||
eq(schema.memberRoles.spaceId, id),
|
||||
eq(schema.memberRoles.userId, targetId),
|
||||
)).run();
|
||||
});
|
||||
|
||||
// Broadcast member_left event so other clients update their member list
|
||||
connectionManager.sendToSpace(id, {
|
||||
type: 'member_left',
|
||||
spaceId: id,
|
||||
userId: targetId,
|
||||
});
|
||||
|
||||
// Notify the banned user
|
||||
connectionManager.sendToUser(targetId, {
|
||||
type: 'member_banned',
|
||||
spaceId: id,
|
||||
reason: reason?.trim() || null,
|
||||
});
|
||||
|
||||
return reply.code(200).send({ success: true });
|
||||
});
|
||||
|
||||
// DELETE /api/spaces/:id/bans/:uid - Unban a user
|
||||
app.delete<{ Params: { id: string; uid: string } }>('/api/spaces/:id/bans/:uid', {
|
||||
preHandler: authenticate,
|
||||
}, async (request, reply) => {
|
||||
const { id, uid } = request.params;
|
||||
const db = getDb();
|
||||
|
||||
if (!hasPermission(request.userId, id, PermissionBits.BAN_MEMBERS)) {
|
||||
return reply.code(403).send({ error: 'Missing BAN_MEMBERS permission', statusCode: 403 });
|
||||
}
|
||||
|
||||
const result = db.delete(schema.bans).where(and(
|
||||
eq(schema.bans.spaceId, id),
|
||||
eq(schema.bans.userId, uid),
|
||||
)).run();
|
||||
|
||||
if (result.changes === 0) {
|
||||
return reply.code(404).send({ error: 'Ban not found', statusCode: 404 });
|
||||
}
|
||||
|
||||
return reply.code(200).send({ success: true });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -155,3 +155,14 @@ export function isDmMember(dmChannelId: string, userId: string): boolean {
|
||||
.get();
|
||||
return member !== undefined;
|
||||
}
|
||||
|
||||
export function isBanned(spaceId: string, userId: string): boolean {
|
||||
const db = getDb();
|
||||
const ban = db.select().from(schema.bans)
|
||||
.where(and(
|
||||
eq(schema.bans.spaceId, spaceId),
|
||||
eq(schema.bans.userId, userId),
|
||||
))
|
||||
.get();
|
||||
return ban !== undefined;
|
||||
}
|
||||
|
||||
@@ -150,6 +150,15 @@ export function handleClientEvent(
|
||||
case 'voice_status':
|
||||
handleVoiceStatus(event, userId);
|
||||
break;
|
||||
case 'voice_server_mute':
|
||||
handleVoiceServerMute(event, userId);
|
||||
break;
|
||||
case 'voice_server_deafen':
|
||||
handleVoiceServerDeafen(event, userId);
|
||||
break;
|
||||
case 'voice_move':
|
||||
handleVoiceMove(event, userId);
|
||||
break;
|
||||
default:
|
||||
connectionManager.sendToUser(userId, {
|
||||
type: 'error',
|
||||
@@ -708,6 +717,11 @@ function handleReactionAdd(event: Record<string, unknown>, userId: string): void
|
||||
const spaceId = getChannelSpaceId(message.channelId);
|
||||
if (!spaceId || !isMember(spaceId, userId)) return;
|
||||
|
||||
if (!hasPermission(userId, spaceId, PermissionBits.ADD_REACTIONS, message.channelId)) {
|
||||
connectionManager.sendToUser(userId, { type: 'error', message: 'Missing ADD_REACTIONS permission' });
|
||||
return;
|
||||
}
|
||||
|
||||
const reactionId = generateSnowflake();
|
||||
const now = Date.now();
|
||||
try {
|
||||
@@ -1040,3 +1054,170 @@ function handleDmCallEnd(event: Record<string, unknown>, userId: string): void {
|
||||
dmChannelId,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Voice Moderation Handlers ──────────────────────────────────────────────
|
||||
|
||||
function handleVoiceServerMute(event: Record<string, unknown>, userId: string): void {
|
||||
const targetUserId = event.userId as string;
|
||||
const muted = event.muted === true;
|
||||
|
||||
if (!targetUserId || typeof targetUserId !== 'string') {
|
||||
connectionManager.sendToUser(userId, { type: 'error', message: 'userId is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the target user's current room
|
||||
const targetRoom = connectionManager.getUserRoom(targetUserId);
|
||||
if (!targetRoom || targetRoom.room.roomType !== 'space') {
|
||||
connectionManager.sendToUser(userId, { type: 'error', message: 'Target user is not in a voice channel' });
|
||||
return;
|
||||
}
|
||||
|
||||
const meta = targetRoom.room.metadata as SpaceRoomMeta;
|
||||
if (!hasPermission(userId, meta.spaceId, PermissionBits.MUTE_MEMBERS, targetRoom.roomId)) {
|
||||
connectionManager.sendToUser(userId, { type: 'error', message: 'Missing MUTE_MEMBERS permission' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Cannot server-mute yourself
|
||||
if (targetUserId === userId) {
|
||||
connectionManager.sendToUser(userId, { type: 'error', message: 'Cannot server-mute yourself' });
|
||||
return;
|
||||
}
|
||||
|
||||
connectionManager.setServerMuted(targetUserId, muted);
|
||||
|
||||
// Broadcast to all space members
|
||||
connectionManager.sendToSpace(meta.spaceId, {
|
||||
type: 'voice_server_muted',
|
||||
userId: targetUserId,
|
||||
channelId: targetRoom.roomId,
|
||||
muted,
|
||||
});
|
||||
}
|
||||
|
||||
function handleVoiceServerDeafen(event: Record<string, unknown>, userId: string): void {
|
||||
const targetUserId = event.userId as string;
|
||||
const deafened = event.deafened === true;
|
||||
|
||||
if (!targetUserId || typeof targetUserId !== 'string') {
|
||||
connectionManager.sendToUser(userId, { type: 'error', message: 'userId is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const targetRoom = connectionManager.getUserRoom(targetUserId);
|
||||
if (!targetRoom || targetRoom.room.roomType !== 'space') {
|
||||
connectionManager.sendToUser(userId, { type: 'error', message: 'Target user is not in a voice channel' });
|
||||
return;
|
||||
}
|
||||
|
||||
const meta = targetRoom.room.metadata as SpaceRoomMeta;
|
||||
if (!hasPermission(userId, meta.spaceId, PermissionBits.DEAFEN_MEMBERS, targetRoom.roomId)) {
|
||||
connectionManager.sendToUser(userId, { type: 'error', message: 'Missing DEAFEN_MEMBERS permission' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (targetUserId === userId) {
|
||||
connectionManager.sendToUser(userId, { type: 'error', message: 'Cannot server-deafen yourself' });
|
||||
return;
|
||||
}
|
||||
|
||||
connectionManager.setServerDeafened(targetUserId, deafened);
|
||||
|
||||
connectionManager.sendToSpace(meta.spaceId, {
|
||||
type: 'voice_server_deafened',
|
||||
userId: targetUserId,
|
||||
channelId: targetRoom.roomId,
|
||||
deafened,
|
||||
});
|
||||
}
|
||||
|
||||
function handleVoiceMove(event: Record<string, unknown>, userId: string): void {
|
||||
const targetUserId = event.userId as string;
|
||||
const targetChannelId = event.targetChannelId as string;
|
||||
|
||||
if (!targetUserId || typeof targetUserId !== 'string') {
|
||||
connectionManager.sendToUser(userId, { type: 'error', message: 'userId is required' });
|
||||
return;
|
||||
}
|
||||
if (!targetChannelId || typeof targetChannelId !== 'string') {
|
||||
connectionManager.sendToUser(userId, { type: 'error', message: 'targetChannelId is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the target user's current room
|
||||
const currentRoom = connectionManager.getUserRoom(targetUserId);
|
||||
if (!currentRoom || currentRoom.room.roomType !== 'space') {
|
||||
connectionManager.sendToUser(userId, { type: 'error', message: 'Target user is not in a voice channel' });
|
||||
return;
|
||||
}
|
||||
|
||||
const meta = currentRoom.room.metadata as SpaceRoomMeta;
|
||||
if (!hasPermission(userId, meta.spaceId, PermissionBits.MOVE_MEMBERS, currentRoom.roomId)) {
|
||||
connectionManager.sendToUser(userId, { type: 'error', message: 'Missing MOVE_MEMBERS permission' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify target channel exists and is a voice/video channel in the same space
|
||||
const db = getDb();
|
||||
const targetChannel = db.select().from(schema.channels).where(eq(schema.channels.id, targetChannelId)).get();
|
||||
if (!targetChannel || targetChannel.spaceId !== meta.spaceId) {
|
||||
connectionManager.sendToUser(userId, { type: 'error', message: 'Target channel not found in this space' });
|
||||
return;
|
||||
}
|
||||
if (targetChannel.type !== 'voice' && targetChannel.type !== 'video') {
|
||||
connectionManager.sendToUser(userId, { type: 'error', message: 'Target channel is not a voice channel' });
|
||||
return;
|
||||
}
|
||||
if (targetChannelId === currentRoom.roomId) {
|
||||
connectionManager.sendToUser(userId, { type: 'error', message: 'User is already in that channel' });
|
||||
return;
|
||||
}
|
||||
|
||||
const oldChannelId = currentRoom.roomId;
|
||||
|
||||
// Leave current room
|
||||
connectionManager.leaveRoom(oldChannelId, targetUserId);
|
||||
|
||||
// Broadcast leave from old channel
|
||||
connectionManager.sendToSpace(meta.spaceId, {
|
||||
type: 'voice_state_update',
|
||||
channelId: oldChannelId,
|
||||
userId: targetUserId,
|
||||
action: 'leave',
|
||||
});
|
||||
|
||||
// Lazy-create target room and join
|
||||
connectionManager.createRoom(targetChannelId, 'space', { type: 'space', spaceId: meta.spaceId });
|
||||
connectionManager.joinRoom(targetChannelId, targetUserId);
|
||||
|
||||
// Broadcast join to new channel
|
||||
connectionManager.sendToSpace(meta.spaceId, {
|
||||
type: 'voice_state_update',
|
||||
channelId: targetChannelId,
|
||||
userId: targetUserId,
|
||||
action: 'join',
|
||||
});
|
||||
|
||||
// Notify the moved user so they reconnect to LiveKit
|
||||
connectionManager.sendToUser(targetUserId, {
|
||||
type: 'voice_moved',
|
||||
userId: targetUserId,
|
||||
oldChannelId,
|
||||
newChannelId: targetChannelId,
|
||||
});
|
||||
|
||||
// Preserve voice user status during move
|
||||
const status = connectionManager.getVoiceUserStatus(targetUserId);
|
||||
if (status) {
|
||||
connectionManager.sendToRoom(targetChannelId, {
|
||||
type: 'voice_status_update',
|
||||
userId: targetUserId,
|
||||
channelId: targetChannelId,
|
||||
isMuted: status.isMuted,
|
||||
isDeafened: status.isDeafened,
|
||||
isCameraOn: status.isCameraOn,
|
||||
isScreenSharing: status.isScreenSharing,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,6 +77,9 @@ class ConnectionManager {
|
||||
private pendingOfflineTimeouts: Map<string, NodeJS.Timeout> = new Map();
|
||||
// roomId → Timeout for ringing DM rooms (60s auto-cleanup)
|
||||
private ringingTimeouts: Map<string, NodeJS.Timeout> = new Map();
|
||||
// Server-muted/deafened users (moderator action)
|
||||
private serverMutedUsers: Set<string> = new Set();
|
||||
private serverDeafenedUsers: Set<string> = new Set();
|
||||
|
||||
addConnection(userId: string, ws: WebSocket): void {
|
||||
if (!this.connections.has(userId)) {
|
||||
@@ -301,6 +304,7 @@ class ConnectionManager {
|
||||
|
||||
room.participants.delete(userId);
|
||||
this.userToRoom.delete(userId);
|
||||
this.clearServerVoiceState(userId);
|
||||
|
||||
// Auto-cleanup empty space rooms (they're lazy-created)
|
||||
if (room.participants.size === 0 && room.roomType === 'space') {
|
||||
@@ -382,6 +386,29 @@ class ConnectionManager {
|
||||
this.voiceUserStates.delete(userId);
|
||||
}
|
||||
|
||||
setServerMuted(userId: string, muted: boolean): void {
|
||||
if (muted) this.serverMutedUsers.add(userId);
|
||||
else this.serverMutedUsers.delete(userId);
|
||||
}
|
||||
|
||||
isServerMuted(userId: string): boolean {
|
||||
return this.serverMutedUsers.has(userId);
|
||||
}
|
||||
|
||||
setServerDeafened(userId: string, deafened: boolean): void {
|
||||
if (deafened) this.serverDeafenedUsers.add(userId);
|
||||
else this.serverDeafenedUsers.delete(userId);
|
||||
}
|
||||
|
||||
isServerDeafened(userId: string): boolean {
|
||||
return this.serverDeafenedUsers.has(userId);
|
||||
}
|
||||
|
||||
clearServerVoiceState(userId: string): void {
|
||||
this.serverMutedUsers.delete(userId);
|
||||
this.serverDeafenedUsers.delete(userId);
|
||||
}
|
||||
|
||||
getAllVoiceUserStates(): Map<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }> {
|
||||
return this.voiceUserStates;
|
||||
}
|
||||
@@ -536,6 +563,7 @@ function buildReadyPayload(userId: string): {
|
||||
folders: SpaceFolder[];
|
||||
voiceStates: Record<string, string[]>;
|
||||
voiceUserStates: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>;
|
||||
serverVoiceStates: Record<string, { serverMuted: boolean; serverDeafened: boolean }>;
|
||||
readStates: ReadState[];
|
||||
activeCalls: ActiveCallInfo[];
|
||||
} {
|
||||
@@ -847,6 +875,21 @@ function buildReadyPayload(userId: string): {
|
||||
}
|
||||
}
|
||||
|
||||
// Build server mute/deafen states for users currently in voice
|
||||
const serverVoiceStates: Record<string, { serverMuted: boolean; serverDeafened: boolean }> = {};
|
||||
for (const chId of Object.keys(voiceStates)) {
|
||||
const usersInChannel = voiceStates[chId];
|
||||
if (usersInChannel) {
|
||||
for (const uid of usersInChannel) {
|
||||
const sm = connectionManager.isServerMuted(uid);
|
||||
const sd = connectionManager.isServerDeafened(uid);
|
||||
if (sm || sd) {
|
||||
serverVoiceStates[uid] = { serverMuted: sm, serverDeafened: sd };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch read states for unread tracking
|
||||
const readStateRows = db.select()
|
||||
.from(schema.readStates)
|
||||
@@ -858,7 +901,7 @@ function buildReadyPayload(userId: string): {
|
||||
lastReadMessageId: rs.lastReadMessageId,
|
||||
}));
|
||||
|
||||
return { user, spaces, dmChannels, folders, voiceStates, voiceUserStates, readStates, activeCalls };
|
||||
return { user, spaces, dmChannels, folders, voiceStates, voiceUserStates, serverVoiceStates, readStates, activeCalls };
|
||||
}
|
||||
|
||||
export async function registerWebSocket(app: FastifyInstance): Promise<void> {
|
||||
|
||||
@@ -237,11 +237,14 @@ export type ClientEvent =
|
||||
| { type: 'dm_call_reject'; dmChannelId: string }
|
||||
| { type: 'dm_call_end'; dmChannelId: string }
|
||||
| { type: 'voice_status'; isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }
|
||||
| { type: 'voice_server_mute'; userId: string; muted: boolean }
|
||||
| { type: 'voice_server_deafen'; userId: string; deafened: boolean }
|
||||
| { type: 'voice_move'; userId: string; targetChannelId: string }
|
||||
| { type: 'ping' };
|
||||
|
||||
// Server → Client Events
|
||||
export type ServerEvent =
|
||||
| { type: 'ready'; user: User; spaces: SpaceWithChannelsAndMembers[]; dmChannels: DmChannel[]; folders?: SpaceFolder[]; voiceStates?: Record<string, string[]>; voiceUserStates?: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>; readStates?: ReadState[]; activeCalls?: ActiveCallInfo[] }
|
||||
| { type: 'ready'; user: User; spaces: SpaceWithChannelsAndMembers[]; dmChannels: DmChannel[]; folders?: SpaceFolder[]; voiceStates?: Record<string, string[]>; voiceUserStates?: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>; readStates?: ReadState[]; activeCalls?: ActiveCallInfo[]; serverVoiceStates?: Record<string, { serverMuted: boolean; serverDeafened: boolean }> }
|
||||
| { type: 'message_created'; message: MessageWithUser }
|
||||
| { type: 'message_updated'; message: MessageWithUser }
|
||||
| { type: 'message_deleted'; messageId: string; channelId: string }
|
||||
@@ -276,6 +279,10 @@ export type ServerEvent =
|
||||
| { type: 'join_request_received'; request: JoinRequest }
|
||||
| { type: 'join_request_accepted'; request: JoinRequest; space: SpaceWithChannelsAndMembers }
|
||||
| { type: 'join_request_declined'; request: JoinRequest }
|
||||
| { type: 'voice_server_muted'; userId: string; channelId: string; muted: boolean }
|
||||
| { type: 'voice_server_deafened'; userId: string; channelId: string; deafened: boolean }
|
||||
| { type: 'voice_moved'; userId: string; oldChannelId: string; newChannelId: string }
|
||||
| { type: 'member_banned'; spaceId: string; reason: string | null }
|
||||
| { type: 'pong' }
|
||||
| { type: 'error'; message: string };
|
||||
|
||||
|
||||
@@ -59,6 +59,9 @@ export class BackspaceApiClient {
|
||||
members: (id: string) => Promise<MemberWithUser[]>;
|
||||
updateMember: (spaceId: string, userId: string, data: UpdateMemberRequest) => Promise<MemberWithUser>;
|
||||
removeMember: (spaceId: string, userId: string) => Promise<{ success: boolean }>;
|
||||
getBans: (spaceId: string) => Promise<{ spaceId: string; userId: string; reason: string | null; bannedBy: string; createdAt: number; user: any; moderator: any }[]>;
|
||||
ban: (spaceId: string, userId: string, reason?: string) => Promise<{ success: boolean }>;
|
||||
unban: (spaceId: string, userId: string) => Promise<{ success: boolean }>;
|
||||
};
|
||||
|
||||
readonly channels: {
|
||||
@@ -223,6 +226,12 @@ export class BackspaceApiClient {
|
||||
request<MemberWithUser>('PATCH', `/spaces/${spaceId}/members/${userId}`, data),
|
||||
removeMember: (spaceId: string, userId: string) =>
|
||||
request<{ success: boolean }>('DELETE', `/spaces/${spaceId}/members/${userId}`),
|
||||
getBans: (spaceId: string) =>
|
||||
request<any[]>('GET', `/spaces/${spaceId}/bans`),
|
||||
ban: (spaceId: string, userId: string, reason?: string) =>
|
||||
request<{ success: boolean }>('POST', `/spaces/${spaceId}/bans`, { userId, reason }),
|
||||
unban: (spaceId: string, userId: string) =>
|
||||
request<{ success: boolean }>('DELETE', `/spaces/${spaceId}/bans/${userId}`),
|
||||
};
|
||||
|
||||
this.channels = {
|
||||
|
||||
@@ -54,6 +54,8 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
|
||||
const myChPerms = channelPermissions.get(message.channelId);
|
||||
const canManageMessages = hasPermissionBit(myChPerms, PermissionBits.MANAGE_MESSAGES);
|
||||
const canDelete = isAuthor || canManageMessages;
|
||||
const isDmMessage = !!(message as any).dmChannelId || !message.channelId;
|
||||
const canAddReactions = isDmMessage || hasPermissionBit(myChPerms, PermissionBits.ADD_REACTIONS);
|
||||
|
||||
const addReaction = useChatStore((s) => s.addReaction);
|
||||
const removeReaction = useChatStore((s) => s.removeReaction);
|
||||
@@ -66,7 +68,7 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
|
||||
const hasReacted = message.reactions?.some(r => isOwnReaction(r) && r.emoji === emoji);
|
||||
if (hasReacted) {
|
||||
removeReaction(message.id, emoji);
|
||||
} else {
|
||||
} else if (canAddReactions) {
|
||||
addReaction(message.id, emoji);
|
||||
}
|
||||
};
|
||||
@@ -324,17 +326,19 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
|
||||
{/* Action buttons on hover */}
|
||||
{isHovered && !isEditing && (
|
||||
<div className="absolute -top-[18px] right-4 flex items-center glass rounded-[10px] overflow-hidden z-10 h-8">
|
||||
<div className="flex items-center px-1 border-r border-white/[0.06] h-full">
|
||||
{['👍', '❤️', '😂', '😮'].map(emoji => (
|
||||
<button
|
||||
key={emoji}
|
||||
onClick={() => toggleReaction(emoji)}
|
||||
className="p-1 hover:bg-interactive-hover rounded transition-colors text-[16px] leading-none"
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{canAddReactions && (
|
||||
<div className="flex items-center px-1 border-r border-white/[0.06] h-full">
|
||||
{['👍', '❤️', '😂', '😮'].map(emoji => (
|
||||
<button
|
||||
key={emoji}
|
||||
onClick={() => toggleReaction(emoji)}
|
||||
className="p-1 hover:bg-interactive-hover rounded transition-colors text-[16px] leading-none"
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setReplyTo(message)}
|
||||
className="px-2 h-full text-txt-tertiary hover:text-txt-primary hover:bg-interactive-hover transition-all flex items-center justify-center"
|
||||
|
||||
@@ -4,6 +4,7 @@ import { isDmChannel, getChannelOrigin, getApiForOrigin, useSpaceStore } from '.
|
||||
import { wsSend } from '../../hooks/useWebSocket';
|
||||
import { MentionPopover } from './MentionPopover';
|
||||
import { TypingIndicator } from './TypingIndicator';
|
||||
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
|
||||
import type { MemberWithUser } from '@backspace/shared';
|
||||
|
||||
interface MessageInputProps {
|
||||
@@ -31,6 +32,12 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
||||
const members = useSpaceStore((s) => s.members);
|
||||
const typingTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
// Permission gating: DM channels always allow sending; space channels check SEND_MESSAGES
|
||||
const channelPerms = useSpaceStore((s) => s.channelPermissions.get(channelId));
|
||||
const isDm = isDmChannel(channelId);
|
||||
const canSendMessages = isDm || hasPermissionBit(channelPerms, PermissionBits.SEND_MESSAGES);
|
||||
const canAttachFiles = isDm || hasPermissionBit(channelPerms, PermissionBits.ATTACH_FILES);
|
||||
|
||||
// Auto-focus textarea on channel navigation
|
||||
useEffect(() => {
|
||||
textareaRef.current?.focus();
|
||||
@@ -230,6 +237,16 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
||||
textarea.style.height = Math.min(textarea.scrollHeight, 300) + 'px';
|
||||
};
|
||||
|
||||
if (!canSendMessages) {
|
||||
return (
|
||||
<div data-pip-obstacle="bottom" className="relative px-3 pb-3 flex-shrink-0 md:absolute md:bottom-3 md:left-3 md:right-3 md:z-[110] md:px-0 md:pb-0 md:glass-bubble md:rounded-[14px]">
|
||||
<div className="flex items-center justify-center py-[14px] px-4">
|
||||
<span className="text-txt-tertiary text-[14px]">You do not have permission to send messages in this channel</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-pip-obstacle="bottom" className="relative px-3 pb-3 flex-shrink-0 md:absolute md:bottom-3 md:left-3 md:right-3 md:z-[110] md:px-0 md:pb-0 md:glass-bubble md:rounded-[14px]">
|
||||
<TypingIndicator channelId={channelId} />
|
||||
@@ -252,8 +269,8 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
||||
<div
|
||||
ref={inputContainerRef}
|
||||
className={`relative bg-surface-input md:bg-transparent ${replyTo ? 'rounded-b-lg' : 'rounded-lg md:rounded-none'} overflow-visible`}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={canAttachFiles ? handleDrop : undefined}
|
||||
onDragOver={canAttachFiles ? handleDragOver : undefined}
|
||||
>
|
||||
{/* Mention autocomplete popover */}
|
||||
{mentionState && filteredMembers.length > 0 && (
|
||||
@@ -299,15 +316,17 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
||||
|
||||
<div className="flex items-center pl-[10px] pr-1">
|
||||
{/* File attach button */}
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="w-[34px] h-[34px] flex items-center justify-center rounded-[6px] text-txt-tertiary hover:text-txt-secondary transition-colors flex-shrink-0"
|
||||
title="Attach file"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm5 11h-4v4h-2v-4H7v-2h4V7h2v4h4v2z" />
|
||||
</svg>
|
||||
</button>
|
||||
{canAttachFiles && (
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="w-[34px] h-[34px] flex items-center justify-center rounded-[6px] text-txt-tertiary hover:text-txt-secondary transition-colors flex-shrink-0"
|
||||
title="Attach file"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm5 11h-4v4h-2v-4H7v-2h4V7h2v4h4v2z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
@@ -328,7 +347,7 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
||||
value={content}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
onPaste={canAttachFiles ? handlePaste : undefined}
|
||||
placeholder={`Message ${channelName.startsWith('@') ? channelName : `#${channelName}`}`}
|
||||
className="flex-1 py-[10px] px-1 bg-transparent text-txt-primary placeholder-txt-tertiary/60 outline-none resize-none text-[15px] leading-[1.375rem] max-h-[50vh] scrollbar-thin"
|
||||
rows={1}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useAuthStore } from '../../stores/authStore';
|
||||
import { useSocialStore } from '../../stores/socialStore';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
import { LoadingSpinner } from '../ui/LoadingSpinner';
|
||||
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
|
||||
import type { MessageWithUser } from '@backspace/shared';
|
||||
|
||||
const EMPTY_MESSAGES: MessageWithUser[] = [];
|
||||
@@ -53,9 +54,16 @@ export function MessageList({ channelId }: MessageListProps) {
|
||||
const prevMessagesLength = useRef(0);
|
||||
const ackTimerRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
// Permission check: DM channels always allow history; space channels check READ_MESSAGE_HISTORY
|
||||
const channelPerms = useSpaceStore((s) => s.channelPermissions.get(channelId));
|
||||
const isDm = isDmChannel(channelId);
|
||||
const canReadHistory = isDm || hasPermissionBit(channelPerms, PermissionBits.READ_MESSAGE_HISTORY);
|
||||
|
||||
useEffect(() => {
|
||||
loadMessages(channelId);
|
||||
}, [channelId, loadMessages]);
|
||||
if (canReadHistory) {
|
||||
loadMessages(channelId);
|
||||
}
|
||||
}, [channelId, loadMessages, canReadHistory]);
|
||||
|
||||
// Ack channel when messages load or when new messages arrive while near bottom
|
||||
useEffect(() => {
|
||||
@@ -135,6 +143,14 @@ export function MessageList({ channelId }: MessageListProps) {
|
||||
}
|
||||
}, [channelId, hasMore, isLoadingMore, loadMoreMessages]);
|
||||
|
||||
if (!canReadHistory) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<span className="text-txt-tertiary text-[14px]">You do not have permission to view message history in this channel</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading && messages.length === 0) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
|
||||
@@ -34,10 +34,13 @@ export function ChannelSidebar() {
|
||||
const isDeafened = useVoiceStore((s) => s.isDeafened);
|
||||
const toggleMic = useVoiceStore((s) => s.toggleMic);
|
||||
const toggleDeafen = useVoiceStore((s) => s.toggleDeafen);
|
||||
const isServerMuted = useVoiceStore((s) => user ? s.serverMutedUserIds.has(user.id) : false);
|
||||
const isServerDeafened = useVoiceStore((s) => user ? s.serverDeafenedUserIds.has(user.id) : false);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const handleMicToggle = async () => {
|
||||
if (isServerMuted || isServerDeafened) return; // Blocked by server mute/deafen
|
||||
toggleMic();
|
||||
// Broadcast mute status via WebSocket so non-joined users can see it
|
||||
const willBeMuted = !isMuted;
|
||||
@@ -47,6 +50,7 @@ export function ChannelSidebar() {
|
||||
};
|
||||
|
||||
const handleDeafenToggle = async () => {
|
||||
if (isServerDeafened) return; // Blocked by server deafen
|
||||
const room = getActiveRoom();
|
||||
const willDeafen = !isDeafened;
|
||||
// Update store FIRST so updateParticipants reads correct state when LiveKit events fire
|
||||
@@ -84,7 +88,9 @@ export function ChannelSidebar() {
|
||||
if (inst) return inst.label;
|
||||
try { return new URL(origin).host; } catch { return origin; }
|
||||
}, [space, federationInstances]);
|
||||
const channelPermissions = useSpaceStore((s) => s.channelPermissions);
|
||||
const canManageChannels = hasPermissionBit(mySpacePerms, PermissionBits.MANAGE_CHANNELS);
|
||||
const canCreateInvite = hasPermissionBit(mySpacePerms, PermissionBits.CREATE_INVITE);
|
||||
|
||||
const textChannels = channels.filter(c => c.type === 'text');
|
||||
const voiceChannels = channels.filter(c => c.type === 'voice' || c.type === 'video');
|
||||
@@ -122,6 +128,8 @@ export function ChannelSidebar() {
|
||||
user={user}
|
||||
isMuted={isMuted}
|
||||
isDeafened={isDeafened}
|
||||
isServerMuted={isServerMuted}
|
||||
isServerDeafened={isServerDeafened}
|
||||
isAdmin={!!user.isAdmin}
|
||||
onMicToggle={handleMicToggle}
|
||||
onDeafenToggle={handleDeafenToggle}
|
||||
@@ -307,15 +315,17 @@ export function ChannelSidebar() {
|
||||
<path d="M5.293 7.293a1 1 0 011.414 0L9 9.586l2.293-2.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openModal('invite')}
|
||||
className="w-10 h-full flex items-center justify-center text-txt-tertiary hover:text-txt-primary hover:bg-interactive-hover transition-all flex-shrink-0"
|
||||
title="Invite People"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M21 3H24V5H21V8H19V5H16V3H19V0H21V3ZM10 12C12.21 12 14 10.21 14 8C14 5.79 12.21 4 10 4C7.79 4 6 5.79 6 8C6 10.21 7.79 12 10 12ZM10 13C6.69 13 1 14.66 1 18V20H19V18C19 14.66 13.31 13 10 13Z" />
|
||||
</svg>
|
||||
</button>
|
||||
{canCreateInvite && (
|
||||
<button
|
||||
onClick={() => openModal('invite')}
|
||||
className="w-10 h-full flex items-center justify-center text-txt-tertiary hover:text-txt-primary hover:bg-interactive-hover transition-all flex-shrink-0"
|
||||
title="Invite People"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M21 3H24V5H21V8H19V5H16V3H19V0H21V3ZM10 12C12.21 12 14 10.21 14 8C14 5.79 12.21 4 10 4C7.79 4 6 5.79 6 8C6 10.21 7.79 12 10 12ZM10 13C6.69 13 1 14.66 1 18V20H19V18C19 14.66 13.31 13 10 13Z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Channels */}
|
||||
@@ -419,14 +429,19 @@ export function ChannelSidebar() {
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-[2px]">
|
||||
{voiceChannels.map((channel) => (
|
||||
<VoiceChannel
|
||||
key={channel.id}
|
||||
channelId={channel.id}
|
||||
channelName={channel.name}
|
||||
onClick={() => handleVoiceJoin(channel.id)}
|
||||
/>
|
||||
))}
|
||||
{voiceChannels.map((channel) => {
|
||||
const chPerms = channelPermissions.get(channel.id);
|
||||
const canConnect = hasPermissionBit(chPerms, PermissionBits.CONNECT);
|
||||
return (
|
||||
<VoiceChannel
|
||||
key={channel.id}
|
||||
channelId={channel.id}
|
||||
channelName={channel.name}
|
||||
onClick={() => canConnect && handleVoiceJoin(channel.id)}
|
||||
locked={!canConnect}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -444,6 +459,8 @@ function UserAreaPanel({
|
||||
user,
|
||||
isMuted,
|
||||
isDeafened,
|
||||
isServerMuted,
|
||||
isServerDeafened,
|
||||
isAdmin,
|
||||
onMicToggle,
|
||||
onDeafenToggle,
|
||||
@@ -453,6 +470,8 @@ function UserAreaPanel({
|
||||
user: any;
|
||||
isMuted: boolean;
|
||||
isDeafened: boolean;
|
||||
isServerMuted: boolean;
|
||||
isServerDeafened: boolean;
|
||||
isAdmin: boolean;
|
||||
onMicToggle: () => void;
|
||||
onDeafenToggle: () => void;
|
||||
@@ -769,14 +788,15 @@ function UserAreaPanel({
|
||||
<button
|
||||
onClick={onMicToggle}
|
||||
className={`w-8 h-8 flex items-center justify-center hover:bg-interactive-hover rounded-l-[4px] transition-colors ${
|
||||
isMuted ? 'text-txt-danger' : 'text-txt-tertiary hover:text-txt-primary'
|
||||
isServerMuted ? 'text-accent-amber cursor-not-allowed'
|
||||
: isMuted ? 'text-txt-danger' : 'text-txt-tertiary hover:text-txt-primary'
|
||||
}`}
|
||||
title={isMuted ? 'Unmute' : 'Mute'}
|
||||
title={isServerMuted ? 'Server Muted' : isMuted ? 'Unmute' : 'Mute'}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" />
|
||||
<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" />
|
||||
{isMuted && <line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />}
|
||||
{(isMuted || isServerMuted) && <line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />}
|
||||
</svg>
|
||||
</button>
|
||||
{/* Input chevron */}
|
||||
@@ -796,13 +816,14 @@ function UserAreaPanel({
|
||||
<button
|
||||
onClick={onDeafenToggle}
|
||||
className={`w-8 h-8 flex items-center justify-center hover:bg-interactive-hover rounded-l-[4px] transition-colors ${
|
||||
isDeafened ? 'text-txt-danger' : 'text-txt-tertiary hover:text-txt-primary'
|
||||
isServerDeafened ? 'text-accent-amber cursor-not-allowed'
|
||||
: isDeafened ? 'text-txt-danger' : 'text-txt-tertiary hover:text-txt-primary'
|
||||
}`}
|
||||
title={isDeafened ? 'Undeafen' : 'Deafen'}
|
||||
title={isServerDeafened ? 'Server Deafened' : isDeafened ? 'Undeafen' : 'Deafen'}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 3c-4.97 0-9 4.03-9 9v7c0 1.1.9 2 2 2h2v-7H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-2v7h2c1.1 0 2-.9 2-2v-7c0-4.97-4.03-9-9-9z" />
|
||||
{isDeafened && <line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />}
|
||||
{(isDeafened || isServerDeafened) && <line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />}
|
||||
</svg>
|
||||
</button>
|
||||
{/* Output chevron */}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
|
||||
import { OverviewPanel } from './spaceSettingsPanels/OverviewPanel';
|
||||
import { MembersPanel } from './spaceSettingsPanels/MembersPanel';
|
||||
import { RolesPanel } from './spaceSettingsPanels/RolesPanel';
|
||||
import { BansPanel } from './spaceSettingsPanels/BansPanel';
|
||||
import type { SpaceVisibility, JoinRequest } from '@backspace/shared';
|
||||
|
||||
function DiscoveryPanel({ spaceId }: { spaceId: string }) {
|
||||
@@ -269,13 +270,14 @@ export function SpaceSettingsModal() {
|
||||
const spaces = useSpaceStore((s) => s.spaces);
|
||||
const spacePermissions = useSpaceStore((s) => s.spacePermissions);
|
||||
|
||||
const [tab, setTab] = useState<'overview' | 'discovery' | 'members' | 'roles'>('overview');
|
||||
const [tab, setTab] = useState<'overview' | 'discovery' | 'members' | 'roles' | 'bans'>('overview');
|
||||
|
||||
const isOpen = activeModal === 'spaceSettings';
|
||||
const space = spaces.find(s => s.id === currentSpaceId);
|
||||
const mySpacePerms = currentSpaceId ? spacePermissions.get(currentSpaceId) : undefined;
|
||||
const canManageSpace = hasPermissionBit(mySpacePerms, PermissionBits.MANAGE_SPACE);
|
||||
const canManageRoles = hasPermissionBit(mySpacePerms, PermissionBits.MANAGE_ROLES);
|
||||
const canBanMembers = hasPermissionBit(mySpacePerms, PermissionBits.BAN_MEMBERS);
|
||||
|
||||
if (!space || !currentSpaceId) return null;
|
||||
|
||||
@@ -306,6 +308,11 @@ export function SpaceSettingsModal() {
|
||||
Roles
|
||||
</button>
|
||||
)}
|
||||
{canBanMembers && (
|
||||
<button onClick={() => setTab('bans')} className={tabClass('bans')}>
|
||||
Bans
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -315,6 +322,7 @@ export function SpaceSettingsModal() {
|
||||
{tab === 'discovery' && canManageSpace && <DiscoveryPanel spaceId={currentSpaceId} />}
|
||||
{tab === 'members' && <MembersPanel spaceId={currentSpaceId} />}
|
||||
{tab === 'roles' && canManageRoles && <RolesPanel spaceId={currentSpaceId} />}
|
||||
{tab === 'bans' && canBanMembers && <BansPanel spaceId={currentSpaceId} />}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Avatar } from '../../ui/Avatar';
|
||||
import { useSpaceStore, getApiForOrigin } from '../../../stores/spaceStore';
|
||||
|
||||
interface Ban {
|
||||
spaceId: string;
|
||||
userId: string;
|
||||
reason: string | null;
|
||||
bannedBy: string;
|
||||
createdAt: number;
|
||||
user: { id: string; username: string; displayName?: string | null; avatar?: string | null } | null;
|
||||
moderator: { id: string; username: string; displayName?: string | null } | null;
|
||||
}
|
||||
|
||||
interface BansPanelProps {
|
||||
spaceId: string;
|
||||
}
|
||||
|
||||
export function BansPanel({ spaceId }: BansPanelProps) {
|
||||
const spaces = useSpaceStore((s) => s.spaces);
|
||||
const space = spaces.find((s) => s.id === spaceId);
|
||||
const spaceApi = getApiForOrigin(space?._instanceOrigin ?? '');
|
||||
|
||||
const [bans, setBans] = useState<Ban[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const loadBans = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const data = await spaceApi.spaces.getBans(spaceId);
|
||||
setBans(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load bans');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [spaceId, spaceApi]);
|
||||
|
||||
useEffect(() => {
|
||||
loadBans();
|
||||
}, [loadBans]);
|
||||
|
||||
const handleUnban = async (userId: string) => {
|
||||
try {
|
||||
await spaceApi.spaces.unban(spaceId, userId);
|
||||
setBans((prev) => prev.filter((b) => b.userId !== userId));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to unban user');
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-txt-tertiary text-sm">Loading bans...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{error && (
|
||||
<div className="p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">{error}</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-txt-tertiary">Banned users cannot rejoin this space until unbanned.</p>
|
||||
|
||||
{bans.length === 0 ? (
|
||||
<div className="text-center py-8 text-txt-tertiary text-sm">No banned users</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">
|
||||
Bans ({bans.length})
|
||||
</div>
|
||||
<div className="rounded-lg bg-white/[0.02] p-2">
|
||||
<div className="space-y-0.5">
|
||||
{bans.map((ban) => {
|
||||
const displayName = ban.user?.displayName ?? ban.user?.username ?? ban.userId;
|
||||
const moderatorName = ban.moderator?.displayName ?? ban.moderator?.username ?? ban.bannedBy;
|
||||
const bannedDate = new Date(ban.createdAt).toLocaleDateString();
|
||||
|
||||
return (
|
||||
<div key={ban.userId} className="flex items-center justify-between p-2 rounded hover:bg-interactive-hover transition-colors">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Avatar
|
||||
src={ban.user?.avatar ?? null}
|
||||
name={displayName}
|
||||
size={32}
|
||||
userId={ban.userId}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium truncate">{displayName}</div>
|
||||
<div className="text-[11px] text-txt-tertiary truncate">
|
||||
Banned by {moderatorName} on {bannedDate}
|
||||
{ban.reason && ` — ${ban.reason}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleUnban(ban.userId)}
|
||||
className="px-2 py-1 text-xs text-txt-secondary hover:text-txt-primary hover:bg-surface-base rounded transition-colors flex-shrink-0"
|
||||
>
|
||||
Unban
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Avatar } from '../../ui/Avatar';
|
||||
import { useSpaceStore } from '../../../stores/spaceStore';
|
||||
import { useSpaceStore, getApiForOrigin } from '../../../stores/spaceStore';
|
||||
import { useAuthStore } from '../../../stores/authStore';
|
||||
import { api } from '../../../api/client';
|
||||
import { parseFederatedUsername } from '../../../utils/identity';
|
||||
import { hasPermissionBit, PermissionBits } from '../../../utils/permissions';
|
||||
import type { MemberWithUser } from '@backspace/shared';
|
||||
|
||||
@@ -19,9 +19,11 @@ export function MembersPanel({ spaceId }: MembersPanelProps) {
|
||||
const spacePermissions = useSpaceStore((s) => s.spacePermissions);
|
||||
|
||||
const space = spaces.find((s) => s.id === spaceId);
|
||||
const spaceApi = getApiForOrigin(space?._instanceOrigin ?? '');
|
||||
const myPerms = spacePermissions.get(spaceId);
|
||||
const canManageRoles = hasPermissionBit(myPerms, PermissionBits.MANAGE_ROLES);
|
||||
const canKick = hasPermissionBit(myPerms, PermissionBits.KICK_MEMBERS);
|
||||
const canBan = hasPermissionBit(myPerms, PermissionBits.BAN_MEMBERS);
|
||||
|
||||
const [pendingRoleChanges, setPendingRoleChanges] = useState<Map<string, Set<string>>>(new Map());
|
||||
const [expandedMemberId, setExpandedMemberId] = useState<string | null>(null);
|
||||
@@ -52,7 +54,7 @@ export function MembersPanel({ spaceId }: MembersPanelProps) {
|
||||
const roleIds = pendingRoleChanges.get(userId);
|
||||
if (!roleIds) return;
|
||||
try {
|
||||
await api.spaces.updateMember(spaceId, userId, { roleIds: Array.from(roleIds) });
|
||||
await spaceApi.spaces.updateMember(spaceId, userId, { roleIds: Array.from(roleIds) });
|
||||
setPendingRoleChanges((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(userId);
|
||||
@@ -75,13 +77,22 @@ export function MembersPanel({ spaceId }: MembersPanelProps) {
|
||||
|
||||
const handleKick = async (userId: string) => {
|
||||
try {
|
||||
await api.spaces.removeMember(spaceId, userId);
|
||||
await spaceApi.spaces.removeMember(spaceId, userId);
|
||||
await loadSpaceDetail(spaceId);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to kick member');
|
||||
}
|
||||
};
|
||||
|
||||
const handleBan = async (userId: string) => {
|
||||
try {
|
||||
await spaceApi.spaces.ban(spaceId, userId);
|
||||
await loadSpaceDetail(spaceId);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to ban member');
|
||||
}
|
||||
};
|
||||
|
||||
const canExpandMember = (member: MemberWithUser) =>
|
||||
canManageRoles && member.userId !== currentUser?.id && member.userId !== space.ownerId && assignableRoles.length > 0;
|
||||
|
||||
@@ -99,6 +110,7 @@ export function MembersPanel({ spaceId }: MembersPanelProps) {
|
||||
<div className="rounded-lg bg-white/[0.02] p-2">
|
||||
<div className="space-y-0.5">
|
||||
{members.map((member) => {
|
||||
const { domain } = parseFederatedUsername(member.user.username);
|
||||
const displayName = member.user.displayName ?? member.user.username;
|
||||
const isOwner = member.userId === space.ownerId;
|
||||
const memberRoleIds = getMemberRoleIds(member);
|
||||
@@ -127,7 +139,12 @@ export function MembersPanel({ spaceId }: MembersPanelProps) {
|
||||
user={member.user}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium truncate">{displayName}</div>
|
||||
<div className="text-sm font-medium truncate">
|
||||
{displayName}
|
||||
{domain && (
|
||||
<span className="ml-1 text-[10px] text-txt-tertiary opacity-60">@{domain}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{isOwner && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-accent-rose/20 text-txt-danger font-medium">
|
||||
@@ -151,6 +168,14 @@ export function MembersPanel({ spaceId }: MembersPanelProps) {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{canBan && member.userId !== currentUser?.id && !isOwner && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleBan(member.userId); }}
|
||||
className="px-2 py-1 text-xs text-txt-danger hover:bg-accent-rose/10 rounded transition-colors"
|
||||
>
|
||||
Ban
|
||||
</button>
|
||||
)}
|
||||
{canKick && member.userId !== currentUser?.id && !isOwner && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleKick(member.userId); }}
|
||||
|
||||
@@ -1,50 +1,76 @@
|
||||
import React from 'react';
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { useVoiceStore } from '../../stores/voiceStore';
|
||||
import { useSpaceStore } from '../../stores/spaceStore';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
import { VoiceModContextMenu } from './VoiceModContextMenu';
|
||||
|
||||
const EMPTY_VOICE_USERS: string[] = [];
|
||||
import { useSpaceStore } from '../../stores/spaceStore';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
|
||||
interface VoiceChannelProps {
|
||||
channelId: string;
|
||||
channelName: string;
|
||||
onClick: () => void;
|
||||
locked?: boolean;
|
||||
}
|
||||
|
||||
export function VoiceChannel({ channelId, channelName, onClick }: VoiceChannelProps) {
|
||||
export function VoiceChannel({ channelId, channelName, onClick, locked }: VoiceChannelProps) {
|
||||
const voiceUsers = useVoiceStore((s) => s.voiceUsers.get(channelId)) ?? EMPTY_VOICE_USERS;
|
||||
const currentVoiceChannel = useVoiceStore((s) => s.currentVoiceChannelId);
|
||||
const participants = useVoiceStore((s) => s.participants);
|
||||
const localIsDeafened = useVoiceStore((s) => s.isDeafened);
|
||||
const localIsMuted = useVoiceStore((s) => s.isMuted);
|
||||
const voiceUserStates = useVoiceStore((s) => s.voiceUserStates);
|
||||
const serverMutedUserIds = useVoiceStore((s) => s.serverMutedUserIds);
|
||||
const serverDeafenedUserIds = useVoiceStore((s) => s.serverDeafenedUserIds);
|
||||
const currentUserId = useVoiceStore((s) => {
|
||||
// Derive from participants — avoids unnecessary authStore dependency
|
||||
const local = s.participants.find(p => p.isLocal);
|
||||
return local?.userId ?? null;
|
||||
});
|
||||
const members = useSpaceStore((s) => s.members);
|
||||
const myUser = useAuthStore((s) => s.user);
|
||||
const isActive = currentVoiceChannel === channelId;
|
||||
|
||||
// Context menu state
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; userId: string } | null>(null);
|
||||
|
||||
const handleContextMenu = useCallback(
|
||||
(e: React.MouseEvent, userId: string) => {
|
||||
if (userId === myUser?.id) return;
|
||||
e.preventDefault();
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, userId });
|
||||
},
|
||||
[myUser?.id],
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`relative w-full flex items-center gap-1.5 px-[10px] h-8 rounded-[6px] group transition-colors ${
|
||||
isActive
|
||||
? 'bg-surface-elevated text-txt-primary'
|
||||
: 'text-txt-tertiary hover:text-txt-secondary hover:bg-interactive-hover'
|
||||
locked
|
||||
? 'text-txt-tertiary/50 cursor-not-allowed'
|
||||
: isActive
|
||||
? 'bg-surface-elevated text-txt-primary'
|
||||
: 'text-txt-tertiary hover:text-txt-secondary hover:bg-interactive-hover'
|
||||
}`}
|
||||
title={locked ? "You don't have permission to connect to this channel" : undefined}
|
||||
>
|
||||
{isActive && (
|
||||
{isActive && !locked && (
|
||||
<div
|
||||
className="absolute -left-[2px] top-1/2 -translate-y-1/2 w-[3px] bg-white rounded-r-full"
|
||||
style={{ height: '55%', opacity: 0.7 }}
|
||||
/>
|
||||
)}
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" className="flex-shrink-0 text-[#6e6e7a]">
|
||||
<path d="M11 5L6 9H2V15H6L11 19V5ZM15.54 8.46C16.48 9.4 17 10.67 17 12S16.48 14.6 15.54 15.54L14.12 14.12C14.69 13.55 15 12.79 15 12S14.69 10.45 14.12 9.88L15.54 8.46ZM19.07 4.93C20.91 6.77 22 9.28 22 12C22 14.72 20.91 17.23 19.07 19.07L17.66 17.66C19.11 16.21 20 14.21 20 12C20 9.79 19.11 7.79 17.66 6.34L19.07 4.93Z" />
|
||||
</svg>
|
||||
{locked ? (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" className="flex-shrink-0 text-[#6e6e7a]/50">
|
||||
<path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" className="flex-shrink-0 text-[#6e6e7a]">
|
||||
<path d="M11 5L6 9H2V15H6L11 19V5ZM15.54 8.46C16.48 9.4 17 10.67 17 12S16.48 14.6 15.54 15.54L14.12 14.12C14.69 13.55 15 12.79 15 12S14.69 10.45 14.12 9.88L15.54 8.46ZM19.07 4.93C20.91 6.77 22 9.28 22 12C22 14.72 20.91 17.23 19.07 19.07L17.66 17.66C19.11 16.21 20 14.21 20 12C20 9.79 19.11 7.79 17.66 6.34L19.07 4.93Z" />
|
||||
</svg>
|
||||
)}
|
||||
<span className="truncate text-[15px] font-medium">{channelName}</span>
|
||||
</button>
|
||||
|
||||
@@ -57,8 +83,6 @@ export function VoiceChannel({ channelId, channelName, onClick }: VoiceChannelPr
|
||||
const displayName = member?.user.displayName ?? member?.user.username ?? participant?.username ?? userId;
|
||||
const avatar = member?.user.avatar ?? null;
|
||||
const status = member?.user.status;
|
||||
// Resolve status: for local user use store directly, for remote users
|
||||
// try LiveKit participant first, then fall back to WebSocket voiceUserStates
|
||||
const wsStatus = voiceUserStates.get(userId);
|
||||
const isParticipantDeafened = userId === currentUserId
|
||||
? localIsDeafened
|
||||
@@ -68,9 +92,15 @@ export function VoiceChannel({ channelId, channelName, onClick }: VoiceChannelPr
|
||||
: (participant?.isMuted ?? wsStatus?.isMuted ?? false);
|
||||
const hasCamera = participant?.isCameraOn ?? wsStatus?.isCameraOn ?? false;
|
||||
const isScreenSharing = participant?.isScreenSharing ?? wsStatus?.isScreenSharing ?? false;
|
||||
const isServerMuted = serverMutedUserIds.has(userId);
|
||||
const isServerDeafened = serverDeafenedUserIds.has(userId);
|
||||
|
||||
return (
|
||||
<div key={userId} className="flex items-center gap-2 px-[10px] py-1 rounded-[6px] hover:bg-interactive-hover transition-colors">
|
||||
<div
|
||||
key={userId}
|
||||
className="flex items-center gap-2 px-[10px] py-1 rounded-[6px] hover:bg-interactive-hover transition-colors"
|
||||
onContextMenu={(e) => handleContextMenu(e, userId)}
|
||||
>
|
||||
<Avatar
|
||||
src={avatar}
|
||||
name={displayName}
|
||||
@@ -81,14 +111,31 @@ export function VoiceChannel({ channelId, channelName, onClick }: VoiceChannelPr
|
||||
<span className="text-[13px] text-txt-secondary truncate flex-1 min-w-0">{displayName}</span>
|
||||
{/* Status badges */}
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{isMuted && (
|
||||
{isServerMuted && (
|
||||
<span title="Server Muted">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" className="text-accent-amber">
|
||||
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" />
|
||||
<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" />
|
||||
<line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
{isServerDeafened && (
|
||||
<span title="Server Deafened">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" className="text-accent-amber">
|
||||
<path d="M12 3c-4.97 0-9 4.03-9 9v7c0 1.1.9 2 2 2h2v-7H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-2v7h2c1.1 0 2-.9 2-2v-7c0-4.97-4.03-9-9-9z" />
|
||||
<line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
{!isServerMuted && isMuted && (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" className="text-txt-danger">
|
||||
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" />
|
||||
<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" />
|
||||
<line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
)}
|
||||
{isParticipantDeafened && (
|
||||
{!isServerDeafened && isParticipantDeafened && (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" className="text-txt-danger">
|
||||
<path d="M12 3c-4.97 0-9 4.03-9 9v7c0 1.1.9 2 2 2h2v-7H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-2v7h2c1.1 0 2-.9 2-2v-7c0-4.97-4.03-9-9-9z" />
|
||||
<line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
|
||||
@@ -108,6 +155,16 @@ export function VoiceChannel({ channelId, channelName, onClick }: VoiceChannelPr
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Voice moderation context menu (portalled to body) */}
|
||||
{contextMenu && (
|
||||
<VoiceModContextMenu
|
||||
targetUserId={contextMenu.userId}
|
||||
channelId={channelId}
|
||||
position={{ x: contextMenu.x, y: contextMenu.y }}
|
||||
onClose={() => setContextMenu(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { useVoiceStore } from '../../stores/voiceStore';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
import { getActiveRoom } from '../../hooks/useLiveKit';
|
||||
import { wsSend } from '../../hooks/useWebSocket';
|
||||
import { getChannelOrigin } from '../../stores/spaceStore';
|
||||
@@ -25,17 +26,22 @@ export function VoiceControlBar() {
|
||||
const voiceFullscreen = useUIStore((s) => s.voiceFullscreen);
|
||||
const toggleVoiceFullscreen = useUIStore((s) => s.toggleVoiceFullscreen);
|
||||
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
|
||||
const myUser = useAuthStore((s) => s.user);
|
||||
const isServerMuted = useVoiceStore((s) => myUser ? s.serverMutedUserIds.has(myUser.id) : false);
|
||||
const isServerDeafened = useVoiceStore((s) => myUser ? s.serverDeafenedUserIds.has(myUser.id) : false);
|
||||
const voiceOrigin = currentVoiceChannelId ? getChannelOrigin(currentVoiceChannelId) : '';
|
||||
const [qualityOpen, setQualityOpen] = useState(false);
|
||||
const qualityBtnRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const handleMute = React.useCallback(async () => {
|
||||
if (isServerMuted || isServerDeafened) return;
|
||||
toggleMic();
|
||||
// Broadcast via WebSocket so sidebar shows status without joining
|
||||
wsSend({ type: 'voice_status', isMuted: !isMuted, isDeafened, isCameraOn, isScreenSharing }, voiceOrigin);
|
||||
}, [isMuted, isDeafened, isCameraOn, isScreenSharing, toggleMic, voiceOrigin]);
|
||||
}, [isMuted, isDeafened, isCameraOn, isScreenSharing, toggleMic, voiceOrigin, isServerMuted, isServerDeafened]);
|
||||
|
||||
const handleDeafen = React.useCallback(async () => {
|
||||
if (isServerDeafened) return;
|
||||
const room = getActiveRoom();
|
||||
const willDeafen = !isDeafened;
|
||||
// Update store FIRST so updateParticipants reads correct state
|
||||
@@ -149,31 +155,35 @@ export function VoiceControlBar() {
|
||||
{/* Mute */}
|
||||
<button
|
||||
onClick={handleMute}
|
||||
className={isMuted || isDeafened
|
||||
? `${btnBase} bg-accent-rose/20 text-txt-danger hover:bg-accent-rose/30`
|
||||
: btnDefault
|
||||
className={isServerMuted
|
||||
? `${btnBase} bg-accent-amber/20 text-accent-amber cursor-not-allowed`
|
||||
: isMuted || isDeafened
|
||||
? `${btnBase} bg-accent-rose/20 text-txt-danger hover:bg-accent-rose/30`
|
||||
: btnDefault
|
||||
}
|
||||
title={isMuted ? 'Unmute (M)' : 'Mute (M)'}
|
||||
title={isServerMuted ? 'Server Muted' : isMuted ? 'Unmute (M)' : 'Mute (M)'}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" />
|
||||
<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" />
|
||||
{(isMuted || isDeafened) && <line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />}
|
||||
{(isMuted || isDeafened || isServerMuted) && <line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />}
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Deafen */}
|
||||
<button
|
||||
onClick={handleDeafen}
|
||||
className={isDeafened
|
||||
? `${btnBase} bg-accent-rose/20 text-txt-danger hover:bg-accent-rose/30`
|
||||
: btnDefault
|
||||
className={isServerDeafened
|
||||
? `${btnBase} bg-accent-amber/20 text-accent-amber cursor-not-allowed`
|
||||
: isDeafened
|
||||
? `${btnBase} bg-accent-rose/20 text-txt-danger hover:bg-accent-rose/30`
|
||||
: btnDefault
|
||||
}
|
||||
title={isDeafened ? 'Undeafen (D)' : 'Deafen (D)'}
|
||||
title={isServerDeafened ? 'Server Deafened' : isDeafened ? 'Undeafen (D)' : 'Deafen (D)'}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 3c-4.97 0-9 4.03-9 9v7c0 1.1.9 2 2 2h2v-7H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-2v7h2c1.1 0 2-.9 2-2v-7c0-4.97-4.03-9-9-9z" />
|
||||
{isDeafened && <line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />}
|
||||
{(isDeafened || isServerDeafened) && <line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />}
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { wsSend } from '../../hooks/useWebSocket';
|
||||
import { ScreenShareSettingsPopover } from './ScreenShareSettingsPopover';
|
||||
import { ConnectionInfoPopover } from './ConnectionInfoPopover';
|
||||
import { startScreenShare, stopScreenShare } from '../../utils/screenShare';
|
||||
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
|
||||
|
||||
/**
|
||||
* VoiceControls renders the voice status + button rows.
|
||||
@@ -28,6 +29,12 @@ export function VoiceControls() {
|
||||
const qualityBtnRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const activeDmCall = useVoiceStore((s) => s.activeDmCall);
|
||||
const channelPerms = useSpaceStore((s) => currentVoiceChannelId ? s.channelPermissions.get(currentVoiceChannelId) : undefined);
|
||||
|
||||
// In DM calls, all permissions are granted; in space channels, check SPEAK and STREAM
|
||||
const isDmCall = !!activeDmCall;
|
||||
const canSpeak = isDmCall || hasPermissionBit(channelPerms, PermissionBits.SPEAK);
|
||||
const canStream = isDmCall || hasPermissionBit(channelPerms, PermissionBits.STREAM);
|
||||
|
||||
const voiceOrigin = currentVoiceChannelId ? getChannelOrigin(currentVoiceChannelId) : '';
|
||||
|
||||
@@ -155,41 +162,45 @@ export function VoiceControls() {
|
||||
|
||||
{/* Row 2: Camera, Screen Share, Video Quality, Noise Suppression */}
|
||||
<div className="relative flex items-center gap-1 px-3 pb-2 pt-1">
|
||||
<button
|
||||
onClick={handleCamera}
|
||||
className={`${btnBase} ${
|
||||
isCameraOn
|
||||
? 'bg-surface-base text-status-online hover:bg-surface-channel'
|
||||
: btnDefaultStyle
|
||||
}`}
|
||||
title={isCameraOn ? 'Turn Off Camera' : 'Turn On Camera'}
|
||||
>
|
||||
{isCameraOn ? (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M17 10.5V7C17 6.45 16.55 6 16 6H4C3.45 6 3 6.45 3 7V17C3 17.55 3.45 18 4 18H16C16.55 18 17 17.55 17 17V13.5L21 17.5V6.5L17 10.5Z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M17 10.5V7C17 6.45 16.55 6 16 6H4C3.45 6 3 6.45 3 7V17C3 17.55 3.45 18 4 18H16C16.55 18 17 17.55 17 17V13.5L21 17.5V6.5L17 10.5Z" />
|
||||
<line x1="2" y1="2" x2="22" y2="22" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
{canSpeak && (
|
||||
<button
|
||||
onClick={handleCamera}
|
||||
className={`${btnBase} ${
|
||||
isCameraOn
|
||||
? 'bg-surface-base text-status-online hover:bg-surface-channel'
|
||||
: btnDefaultStyle
|
||||
}`}
|
||||
title={isCameraOn ? 'Turn Off Camera' : 'Turn On Camera'}
|
||||
>
|
||||
{isCameraOn ? (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M17 10.5V7C17 6.45 16.55 6 16 6H4C3.45 6 3 6.45 3 7V17C3 17.55 3.45 18 4 18H16C16.55 18 17 17.55 17 17V13.5L21 17.5V6.5L17 10.5Z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M17 10.5V7C17 6.45 16.55 6 16 6H4C3.45 6 3 6.45 3 7V17C3 17.55 3.45 18 4 18H16C16.55 18 17 17.55 17 17V13.5L21 17.5V6.5L17 10.5Z" />
|
||||
<line x1="2" y1="2" x2="22" y2="22" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleScreenShare}
|
||||
className={`${btnBase} ${
|
||||
isScreenSharing
|
||||
? 'bg-surface-base text-status-online hover:bg-surface-channel'
|
||||
: btnDefaultStyle
|
||||
}`}
|
||||
title={isScreenSharing ? 'Stop Sharing' : 'Share Screen'}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M20 18C21.1 18 22 17.1 22 16V6C22 4.9 21.1 4 20 4H4C2.9 4 2 4.9 2 6V16C2 17.1 2.9 18 4 18H0V20H24V18H20ZM4 6H20V16H4V6Z" />
|
||||
<path d="M15 11L11 14V12H9V10H11V8L15 11Z" />
|
||||
</svg>
|
||||
</button>
|
||||
{canStream && (
|
||||
<button
|
||||
onClick={handleScreenShare}
|
||||
className={`${btnBase} ${
|
||||
isScreenSharing
|
||||
? 'bg-surface-base text-status-online hover:bg-surface-channel'
|
||||
: btnDefaultStyle
|
||||
}`}
|
||||
title={isScreenSharing ? 'Stop Sharing' : 'Share Screen'}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M20 18C21.1 18 22 17.1 22 16V6C22 4.9 21.1 4 20 4H4C2.9 4 2 4.9 2 6V16C2 17.1 2.9 18 4 18H0V20H24V18H20ZM4 6H20V16H4V6Z" />
|
||||
<path d="M15 11L11 14V12H9V10H11V8L15 11Z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Video Quality */}
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import React, { useEffect, useLayoutEffect, useRef } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { useVoiceStore } from '../../stores/voiceStore';
|
||||
import { useSpaceStore, getChannelOrigin } from '../../stores/spaceStore';
|
||||
import { wsSend } from '../../hooks/useWebSocket';
|
||||
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
|
||||
|
||||
interface VoiceModMenuItemsProps {
|
||||
targetUserId: string;
|
||||
channelId: string;
|
||||
onAction: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Headless moderation menu items (mute/deafen/move buttons).
|
||||
* Renders nothing if the current user has no moderation permissions.
|
||||
* Use inside any container — no portal or positioning logic.
|
||||
*/
|
||||
export function VoiceModMenuItems({ targetUserId, channelId, onAction }: VoiceModMenuItemsProps) {
|
||||
const serverMutedUserIds = useVoiceStore((s) => s.serverMutedUserIds);
|
||||
const serverDeafenedUserIds = useVoiceStore((s) => s.serverDeafenedUserIds);
|
||||
|
||||
const spacePermissions = useSpaceStore((s) => s.spacePermissions);
|
||||
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
|
||||
const channels = useSpaceStore((s) => s.channels);
|
||||
|
||||
const myPerms = currentSpaceId ? spacePermissions.get(currentSpaceId) : undefined;
|
||||
const canMuteMembers = hasPermissionBit(myPerms, PermissionBits.MUTE_MEMBERS);
|
||||
const canDeafenMembers = hasPermissionBit(myPerms, PermissionBits.DEAFEN_MEMBERS);
|
||||
const canMoveMembers = hasPermissionBit(myPerms, PermissionBits.MOVE_MEMBERS);
|
||||
|
||||
const otherVoiceChannels = channels.filter(
|
||||
(c) => (c.type === 'voice' || c.type === 'video') && c.id !== channelId,
|
||||
);
|
||||
|
||||
const voiceOrigin = getChannelOrigin(channelId);
|
||||
|
||||
const isServerMuted = serverMutedUserIds.has(targetUserId);
|
||||
const isServerDeafened = serverDeafenedUserIds.has(targetUserId);
|
||||
|
||||
if (!canMuteMembers && !canDeafenMembers && !canMoveMembers) return null;
|
||||
|
||||
const handleServerMute = () => {
|
||||
wsSend({ type: 'voice_server_mute', userId: targetUserId, muted: !isServerMuted }, voiceOrigin);
|
||||
onAction();
|
||||
};
|
||||
|
||||
const handleServerDeafen = () => {
|
||||
wsSend({ type: 'voice_server_deafen', userId: targetUserId, deafened: !isServerDeafened }, voiceOrigin);
|
||||
onAction();
|
||||
};
|
||||
|
||||
const handleMove = (targetChannelId: string) => {
|
||||
wsSend({ type: 'voice_move', userId: targetUserId, targetChannelId }, voiceOrigin);
|
||||
onAction();
|
||||
};
|
||||
|
||||
const btnClass = 'w-full text-left px-2 py-1.5 mx-1.5 text-sm rounded-sm flex items-center gap-2 text-txt-secondary hover:bg-accent-primary hover:text-white';
|
||||
const btnStyle = { width: 'calc(100% - 12px)' };
|
||||
|
||||
return (
|
||||
<>
|
||||
{canMuteMembers && (
|
||||
<button onClick={handleServerMute} className={btnClass} style={btnStyle}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" className="flex-shrink-0">
|
||||
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" />
|
||||
<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" />
|
||||
{isServerMuted && (
|
||||
<line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
|
||||
)}
|
||||
</svg>
|
||||
{isServerMuted ? 'Server Unmute' : 'Server Mute'}
|
||||
</button>
|
||||
)}
|
||||
{canDeafenMembers && (
|
||||
<button onClick={handleServerDeafen} className={btnClass} style={btnStyle}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" className="flex-shrink-0">
|
||||
<path d="M12 3c-4.97 0-9 4.03-9 9v7c0 1.1.9 2 2 2h2v-7H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-2v7h2c1.1 0 2-.9 2-2v-7c0-4.97-4.03-9-9-9z" />
|
||||
{isServerDeafened && (
|
||||
<line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
|
||||
)}
|
||||
</svg>
|
||||
{isServerDeafened ? 'Server Undeafen' : 'Server Deafen'}
|
||||
</button>
|
||||
)}
|
||||
{canMoveMembers && otherVoiceChannels.length > 0 && (
|
||||
<>
|
||||
<div className="h-px bg-white/[0.06] my-1 mx-1.5" />
|
||||
<div className="px-3 py-1 text-[10px] text-txt-tertiary uppercase tracking-wider font-semibold">
|
||||
Move to...
|
||||
</div>
|
||||
{otherVoiceChannels.map((ch) => (
|
||||
<button key={ch.id} onClick={() => handleMove(ch.id)} className={btnClass} style={btnStyle}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" className="flex-shrink-0 text-txt-tertiary">
|
||||
<path d="M11 5L6 9H2V15H6L11 19V5ZM15.54 8.46C16.48 9.4 17 10.67 17 12S16.48 14.6 15.54 15.54L14.12 14.12C14.69 13.55 15 12.79 15 12S14.69 10.45 14.12 9.88L15.54 8.46Z" />
|
||||
</svg>
|
||||
<span className="truncate">{ch.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Standalone portalled context menu ─────────────────────────────────────────
|
||||
|
||||
interface VoiceModContextMenuProps {
|
||||
targetUserId: string;
|
||||
channelId: string;
|
||||
position: { x: number; y: number };
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full standalone moderation context menu rendered via createPortal to document.body.
|
||||
* Escapes any CSS containing-block / overflow clipping from parent transforms.
|
||||
* Includes viewport-aware positioning and click-outside dismissal.
|
||||
*/
|
||||
export function VoiceModContextMenu({ targetUserId, channelId, position, onClose }: VoiceModContextMenuProps) {
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const spacePermissions = useSpaceStore((s) => s.spacePermissions);
|
||||
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
|
||||
const myPerms = currentSpaceId ? spacePermissions.get(currentSpaceId) : undefined;
|
||||
const canMuteMembers = hasPermissionBit(myPerms, PermissionBits.MUTE_MEMBERS);
|
||||
const canDeafenMembers = hasPermissionBit(myPerms, PermissionBits.DEAFEN_MEMBERS);
|
||||
const canMoveMembers = hasPermissionBit(myPerms, PermissionBits.MOVE_MEMBERS);
|
||||
const hasModPerms = canMuteMembers || canDeafenMembers || canMoveMembers;
|
||||
|
||||
// Click-outside dismissal — always called (hooks must be unconditional)
|
||||
useEffect(() => {
|
||||
if (!hasModPerms) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [hasModPerms, onClose]);
|
||||
|
||||
// Viewport-aware positioning — direct DOM mutation, no extra state/render
|
||||
useLayoutEffect(() => {
|
||||
const el = menuRef.current;
|
||||
if (!hasModPerms || !el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
let x = position.x;
|
||||
let y = position.y;
|
||||
if (rect.right > window.innerWidth) x = window.innerWidth - rect.width - 8;
|
||||
if (rect.bottom > window.innerHeight) y = window.innerHeight - rect.height - 8;
|
||||
if (x < 8) x = 8;
|
||||
if (y < 8) y = 8;
|
||||
el.style.left = `${x}px`;
|
||||
el.style.top = `${y}px`;
|
||||
}, [position, hasModPerms]);
|
||||
|
||||
if (!hasModPerms) return null;
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="fixed z-[200] bg-surface-elevated rounded-md shadow-elevation-high py-1.5 min-w-[180px] max-h-[calc(100vh-16px)] overflow-y-auto scrollbar-thin animate-fade-in"
|
||||
style={{ left: position.x, top: position.y }}
|
||||
>
|
||||
<VoiceModMenuItems
|
||||
targetUserId={targetUserId}
|
||||
channelId={channelId}
|
||||
onAction={onClose}
|
||||
/>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
||||
import React, { useRef, useEffect, useLayoutEffect, useState, useCallback } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
import { useVoiceStore } from '../../stores/voiceStore';
|
||||
import { VoiceModMenuItems } from './VoiceModContextMenu';
|
||||
import { useSpaceStore } from '../../stores/spaceStore';
|
||||
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
|
||||
import type { UserTile } from '../../hooks/useLiveKit';
|
||||
|
||||
interface VoiceUserProps {
|
||||
@@ -13,6 +17,7 @@ export function VoiceUser({ tile, large }: VoiceUserProps) {
|
||||
|
||||
const { participant } = tile;
|
||||
const isDeafened = useVoiceStore((s) => s.isDeafened);
|
||||
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
|
||||
const participantVolumes = useVoiceStore((s) => s.participantVolumes);
|
||||
const isSpeaking = useVoiceStore((s) => s.speakingParticipantIds.has(participant.identity));
|
||||
|
||||
@@ -50,6 +55,7 @@ export function VoiceUser({ tile, large }: VoiceUserProps) {
|
||||
}, [tile.lkVideoTrack]);
|
||||
|
||||
// Context Menu
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const [volumeMenu, setVolumeMenu] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
@@ -65,11 +71,31 @@ export function VoiceUser({ tile, large }: VoiceUserProps) {
|
||||
[isLocal],
|
||||
);
|
||||
|
||||
// Click-outside dismissal — mousedown + contains check
|
||||
useEffect(() => {
|
||||
if (!volumeMenu) return;
|
||||
const close = () => setVolumeMenu(null);
|
||||
window.addEventListener('click', close);
|
||||
return () => window.removeEventListener('click', close);
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
setVolumeMenu(null);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [volumeMenu]);
|
||||
|
||||
// Viewport-aware positioning — direct DOM mutation, no state churn
|
||||
useLayoutEffect(() => {
|
||||
const el = menuRef.current;
|
||||
if (!volumeMenu || !el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
let x = volumeMenu.x;
|
||||
let y = volumeMenu.y;
|
||||
if (rect.right > window.innerWidth) x = window.innerWidth - rect.width - 8;
|
||||
if (rect.bottom > window.innerHeight) y = window.innerHeight - rect.height - 8;
|
||||
if (x < 8) x = 8;
|
||||
if (y < 8) y = 8;
|
||||
el.style.left = `${x}px`;
|
||||
el.style.top = `${y}px`;
|
||||
}, [volumeMenu]);
|
||||
|
||||
return (
|
||||
@@ -154,44 +180,87 @@ export function VoiceUser({ tile, large }: VoiceUserProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{volumeMenu && !isLocal && (
|
||||
{volumeMenu && !isLocal && ReactDOM.createPortal(
|
||||
<div
|
||||
className="fixed z-[60] bg-surface-base rounded-lg shadow-2xl p-3 min-w-[200px] border border-white/[0.06]"
|
||||
ref={menuRef}
|
||||
className="fixed z-[200] bg-surface-elevated rounded-md shadow-elevation-high min-w-[200px] animate-fade-in"
|
||||
style={{ left: volumeMenu.x, top: volumeMenu.y }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="text-xs text-txt-tertiary mb-2 font-medium uppercase tracking-wider">
|
||||
User Volume
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="text-txt-tertiary flex-shrink-0"
|
||||
>
|
||||
<path d="M3 9v6h4l5 5V4L7 9H3z" />
|
||||
</svg>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="200"
|
||||
value={perUserVolume}
|
||||
onChange={(e) =>
|
||||
setParticipantVolume(
|
||||
participant.userId,
|
||||
parseInt(e.target.value),
|
||||
)
|
||||
}
|
||||
className="flex-1 accent-accent-primary h-1"
|
||||
{/* Moderation options (renders nothing if no perms) */}
|
||||
{currentVoiceChannelId && (
|
||||
<VoiceModSection
|
||||
targetUserId={participant.userId}
|
||||
channelId={currentVoiceChannelId}
|
||||
onAction={() => setVolumeMenu(null)}
|
||||
/>
|
||||
<span className="text-xs text-txt-secondary min-w-[32px] text-right">
|
||||
{perUserVolume}%
|
||||
</span>
|
||||
)}
|
||||
{/* Volume slider */}
|
||||
<div className="p-3">
|
||||
<div className="text-xs text-txt-tertiary mb-2 font-medium uppercase tracking-wider">
|
||||
User Volume
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="text-txt-tertiary flex-shrink-0"
|
||||
>
|
||||
<path d="M3 9v6h4l5 5V4L7 9H3z" />
|
||||
</svg>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="200"
|
||||
value={perUserVolume}
|
||||
onChange={(e) =>
|
||||
setParticipantVolume(
|
||||
participant.userId,
|
||||
parseInt(e.target.value),
|
||||
)
|
||||
}
|
||||
className="flex-1 accent-accent-primary h-1"
|
||||
/>
|
||||
<span className="text-xs text-txt-secondary min-w-[32px] text-right">
|
||||
{perUserVolume}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Renders moderation items + divider only when the user has mod perms. */
|
||||
function VoiceModSection({ targetUserId, channelId, onAction }: {
|
||||
targetUserId: string;
|
||||
channelId: string;
|
||||
onAction: () => void;
|
||||
}) {
|
||||
const spacePermissions = useSpaceStore((s) => s.spacePermissions);
|
||||
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
|
||||
const myPerms = currentSpaceId ? spacePermissions.get(currentSpaceId) : undefined;
|
||||
|
||||
const hasMod =
|
||||
hasPermissionBit(myPerms, PermissionBits.MUTE_MEMBERS) ||
|
||||
hasPermissionBit(myPerms, PermissionBits.DEAFEN_MEMBERS) ||
|
||||
hasPermissionBit(myPerms, PermissionBits.MOVE_MEMBERS);
|
||||
|
||||
if (!hasMod) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="py-1.5">
|
||||
<VoiceModMenuItems
|
||||
targetUserId={targetUserId}
|
||||
channelId={channelId}
|
||||
onAction={onAction}
|
||||
/>
|
||||
</div>
|
||||
<div className="h-px bg-white/[0.06] mx-1.5" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -167,6 +167,14 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
||||
setVoiceUserStatus(uid, status.isMuted, status.isDeafened, status.isCameraOn, status.isScreenSharing);
|
||||
}
|
||||
}
|
||||
// Populate server mute/deafen states
|
||||
if (event.serverVoiceStates) {
|
||||
const { setServerMutedUser, setServerDeafenedUser } = useVoiceStore.getState();
|
||||
for (const [uid, state] of Object.entries(event.serverVoiceStates as Record<string, { serverMuted: boolean; serverDeafened: boolean }>)) {
|
||||
if (state.serverMuted) setServerMutedUser(uid, true);
|
||||
if (state.serverDeafened) setServerDeafenedUser(uid, true);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-register in voice channel after WS reconnect — the server lost
|
||||
// voice state on restart, so we must tell it we're still connected.
|
||||
@@ -265,6 +273,56 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
||||
setVoiceUserStatus(event.userId, event.isMuted, event.isDeafened, event.isCameraOn, event.isScreenSharing);
|
||||
break;
|
||||
|
||||
case 'voice_server_muted': {
|
||||
const { setServerMutedUser } = useVoiceStore.getState();
|
||||
setServerMutedUser(event.userId, event.muted);
|
||||
// If the local user was server-muted, force-mute the mic
|
||||
const myUserId = useAuthStore.getState().user?.id;
|
||||
if (event.userId === myUserId && event.muted) {
|
||||
const vs = useVoiceStore.getState();
|
||||
if (!vs.isMuted) {
|
||||
vs.toggleMic();
|
||||
const voiceOrigin = vs.currentVoiceChannelId ? getChannelOrigin(vs.currentVoiceChannelId) : '';
|
||||
wsSend({ type: 'voice_status', isMuted: true, isDeafened: vs.isDeafened, isCameraOn: vs.isCameraOn, isScreenSharing: vs.isScreenSharing }, voiceOrigin);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'voice_server_deafened': {
|
||||
const { setServerDeafenedUser } = useVoiceStore.getState();
|
||||
setServerDeafenedUser(event.userId, event.deafened);
|
||||
// If the local user was server-deafened, force-deafen and force-mute
|
||||
const myUid = useAuthStore.getState().user?.id;
|
||||
if (event.userId === myUid && event.deafened) {
|
||||
const vs = useVoiceStore.getState();
|
||||
if (!vs.isDeafened) {
|
||||
vs.toggleDeafen();
|
||||
if (!vs.isMuted) vs.toggleMic();
|
||||
const voiceOrigin = vs.currentVoiceChannelId ? getChannelOrigin(vs.currentVoiceChannelId) : '';
|
||||
wsSend({ type: 'voice_status', isMuted: true, isDeafened: true, isCameraOn: vs.isCameraOn, isScreenSharing: vs.isScreenSharing }, voiceOrigin);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'voice_moved': {
|
||||
// The local user was moved to a different channel by a moderator
|
||||
const myMovedId = useAuthStore.getState().user?.id;
|
||||
if (event.userId === myMovedId) {
|
||||
// Import dynamically to avoid circular deps — joinVoiceChannel handles
|
||||
// leaving old channel, setting new channel, and triggering LiveKit reconnect
|
||||
import('../utils/voice').then(({ joinVoiceChannel }) => {
|
||||
// Force-set the channel (joinVoiceChannel skips if same channel)
|
||||
const vs = useVoiceStore.getState();
|
||||
// Clear current channel first so joinVoiceChannel doesn't bail
|
||||
vs.setCurrentVoiceChannel(null);
|
||||
joinVoiceChannel(event.newChannelId);
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'member_joined':
|
||||
if (!isHome) normalizeUserAssets(event.member.user, origin);
|
||||
addMember(event.member);
|
||||
@@ -274,6 +332,13 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
||||
removeMember(event.userId);
|
||||
break;
|
||||
|
||||
case 'member_banned': {
|
||||
// The current user has been banned from a space — remove it from the sidebar
|
||||
const { removeSpace: rmSpace } = useSpaceStore.getState();
|
||||
rmSpace(event.spaceId);
|
||||
break;
|
||||
}
|
||||
|
||||
// ─── DM events (home-only) ──────────────────────────────────────────────
|
||||
|
||||
case 'dm_message_created': {
|
||||
|
||||
@@ -86,6 +86,12 @@ interface VoiceState {
|
||||
voiceUserStates: Map<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>;
|
||||
setVoiceUserStatus: (userId: string, isMuted: boolean, isDeafened: boolean, isCameraOn: boolean, isScreenSharing: boolean) => void;
|
||||
clearVoiceUserStatus: (userId: string) => void;
|
||||
// Server mute/deafen state (moderator action)
|
||||
serverMutedUserIds: Set<string>;
|
||||
serverDeafenedUserIds: Set<string>;
|
||||
setServerMutedUser: (userId: string, muted: boolean) => void;
|
||||
setServerDeafenedUser: (userId: string, deafened: boolean) => void;
|
||||
clearServerVoiceStates: () => void;
|
||||
getVoiceUsers: (channelId: string) => string[];
|
||||
clearAllVoiceUsers: () => void;
|
||||
clearVoiceUsersForOrigin: (origin: string) => void;
|
||||
@@ -273,6 +279,24 @@ export const useVoiceStore = create<VoiceState>()(
|
||||
});
|
||||
},
|
||||
|
||||
serverMutedUserIds: new Set(),
|
||||
serverDeafenedUserIds: new Set(),
|
||||
setServerMutedUser: (userId, muted) => {
|
||||
set((state) => {
|
||||
const newSet = new Set(state.serverMutedUserIds);
|
||||
if (muted) newSet.add(userId); else newSet.delete(userId);
|
||||
return { serverMutedUserIds: newSet };
|
||||
});
|
||||
},
|
||||
setServerDeafenedUser: (userId, deafened) => {
|
||||
set((state) => {
|
||||
const newSet = new Set(state.serverDeafenedUserIds);
|
||||
if (deafened) newSet.add(userId); else newSet.delete(userId);
|
||||
return { serverDeafenedUserIds: newSet };
|
||||
});
|
||||
},
|
||||
clearServerVoiceStates: () => set({ serverMutedUserIds: new Set(), serverDeafenedUserIds: new Set() }),
|
||||
|
||||
getVoiceUsers: (channelId) => get().voiceUsers.get(channelId) ?? [],
|
||||
|
||||
clearAllVoiceUsers: () => set({ voiceUsers: new Map(), voiceUserStates: new Map() }),
|
||||
@@ -352,6 +376,8 @@ export const useVoiceStore = create<VoiceState>()(
|
||||
streamVolumes: new Map(),
|
||||
streamMutes: new Map(),
|
||||
watchingStreams: new Set(),
|
||||
serverMutedUserIds: new Set(),
|
||||
serverDeafenedUserIds: new Set(),
|
||||
}),
|
||||
}),
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user