diff --git a/packages/server/src/db/migrate.ts b/packages/server/src/db/migrate.ts index 261e7d83..ea577faa 100644 --- a/packages/server/src/db/migrate.ts +++ b/packages/server/src/db/migrate.ts @@ -412,90 +412,79 @@ function migrateEveryoneRoles(db: Database.Database): void { /** * Remove the USE_VOICE_ACTIVITY bit (was bit 25) and shift STREAM (26→25) - * and DISCONNECT_MEMBERS (27→26) down. Idempotent: uses a sentinel flag in - * instance_settings metadata to avoid re-running. + * and DISCONNECT_MEMBERS (27→26) down. + * + * Gated behind a persistent `voice_bit_migrated` flag in instance_settings + * because the old and new bit positions overlap (STREAM moved into the same + * bit 25 that USE_VOICE_ACTIVITY occupied), making bit-inspection unreliable + * as an idempotency check. The previous version of this function had exactly + * that bug — it re-ran on every startup and silently stripped STREAM and + * DISCONNECT_MEMBERS from every role. + * + * On first run with the flag: repairs @everyone roles by re-adding STREAM, + * then sets the flag so it never runs again. */ function migrateRemoveVoiceActivityBit(db: Database.Database): void { - // Use a pragma-style check: if STREAM is already at bit 25 in DEFAULT_EVERYONE_PERMISSIONS - // of the @everyone roles, the migration has already run. But for robustness, use a flag column. - // We'll check if any role still has bit 25 set AND bit 26 set (old layout had both USE_VOICE_ACTIVITY - // and STREAM). Simplest approach: track via a one-time marker. - const OLD_VOICE_ACTIVITY = 1n << 25n; // old USE_VOICE_ACTIVITY - const OLD_STREAM = 1n << 26n; // old STREAM - const OLD_DISCONNECT = 1n << 27n; // old DISCONNECT_MEMBERS - - // Check if any role still uses the old bit layout (has bit 26 or 27 set) - const roles = db.prepare('SELECT id, permissions FROM roles WHERE permissions IS NOT NULL').all() as { id: string; permissions: string }[]; - const overrides = db.prepare('SELECT channel_id, target_type, target_id, allow, deny FROM channel_overrides').all() as { - channel_id: string; target_type: string; target_id: string; allow: string; deny: string; - }[]; - - let needsMigration = false; - for (const role of roles) { - try { - const p = BigInt(role.permissions); - if ((p & OLD_STREAM) !== 0n || (p & OLD_DISCONNECT) !== 0n || (p & OLD_VOICE_ACTIVITY) !== 0n) { - needsMigration = true; - break; - } - } catch { /* skip invalid */ } - } - if (!needsMigration) { - for (const ov of overrides) { - try { - const a = BigInt(ov.allow); - const d = BigInt(ov.deny); - if ((a & OLD_STREAM) !== 0n || (a & OLD_DISCONNECT) !== 0n || (a & OLD_VOICE_ACTIVITY) !== 0n || - (d & OLD_STREAM) !== 0n || (d & OLD_DISCONNECT) !== 0n || (d & OLD_VOICE_ACTIVITY) !== 0n) { - needsMigration = true; - break; - } - } catch { /* skip invalid */ } - } + // Ensure the flag column exists + const cols = db.pragma('table_info(instance_settings)') as { name: string }[]; + if (!cols.some(c => c.name === 'voice_bit_migrated')) { + db.exec('ALTER TABLE instance_settings ADD COLUMN voice_bit_migrated INTEGER DEFAULT 0'); } - if (!needsMigration) return; + // Check if already migrated + const row = db.prepare('SELECT voice_bit_migrated FROM instance_settings WHERE id = 1').get() as + { voice_bit_migrated: number } | undefined; + if (row && row.voice_bit_migrated === 1) return; - function shiftPermBits(p: bigint): bigint { - const hasStream = (p & OLD_STREAM) !== 0n; - const hasDisconnect = (p & OLD_DISCONNECT) !== 0n; - // Clear bits 25, 26, 27 - p = p & ~(OLD_VOICE_ACTIVITY | OLD_STREAM | OLD_DISCONNECT); - // Re-set at new positions - if (hasStream) p |= (1n << 25n); // STREAM now at 25 - if (hasDisconnect) p |= (1n << 26n); // DISCONNECT_MEMBERS now at 26 - return p; - } - - console.log('Migrating: Shifting permission bits (removing USE_VOICE_ACTIVITY)...'); + // The bit-shifting migration already ran (possibly many times) via the old + // broken code. All roles are already on the new layout (STREAM=25, + // DISCONNECT_MEMBERS=26). The damage is that repeated re-runs wiped those + // bits. Repair what we can: + const STREAM_BIT = 1n << 25n; const updateRole = db.prepare('UPDATE roles SET permissions = ? WHERE id = ?'); - for (const role of roles) { + + // Repair @everyone roles: re-add STREAM where it's missing. + // @everyone role id === space id, so join on that. + const spaces = db.prepare('SELECT id FROM spaces').all() as { id: string }[]; + for (const space of spaces) { + const role = db.prepare('SELECT id, permissions FROM roles WHERE id = ?').get(space.id) as + { id: string; permissions: string } | undefined; + if (!role?.permissions) continue; try { - const old = BigInt(role.permissions); - const shifted = shiftPermBits(old); - if (shifted !== old) { - updateRole.run(shifted.toString(), role.id); + const perms = BigInt(role.permissions); + if ((perms & STREAM_BIT) === 0n) { + updateRole.run((perms | STREAM_BIT).toString(), role.id); + console.log(`Repair: Re-added STREAM to @everyone role for space ${space.id}`); } } catch { /* skip invalid */ } } - const updateOverride = db.prepare( - 'UPDATE channel_overrides SET allow = ?, deny = ? WHERE channel_id = ? AND target_type = ? AND target_id = ?' - ); - for (const ov of overrides) { + // For non-@everyone roles, warn about potentially lost bits so admins can + // manually re-enable STREAM / DISCONNECT_MEMBERS if needed. + const customRoles = db.prepare( + 'SELECT id, space_id, name, permissions FROM roles WHERE id NOT IN (SELECT id FROM spaces) AND permissions IS NOT NULL' + ).all() as { id: string; space_id: string; name: string; permissions: string }[]; + + let warnCount = 0; + for (const role of customRoles) { try { - const oldAllow = BigInt(ov.allow); - const oldDeny = BigInt(ov.deny); - const newAllow = shiftPermBits(oldAllow); - const newDeny = shiftPermBits(oldDeny); - if (newAllow !== oldAllow || newDeny !== oldDeny) { - updateOverride.run(newAllow.toString(), newDeny.toString(), ov.channel_id, ov.target_type, ov.target_id); + const perms = BigInt(role.permissions); + if ((perms & STREAM_BIT) === 0n) { + warnCount++; } } catch { /* skip invalid */ } } + if (warnCount > 0) { + console.log( + `Repair: ${warnCount} custom role(s) may be missing STREAM/DISCONNECT_MEMBERS permissions ` + + `due to a previous migration bug. Admins can re-enable these in Space Settings → Roles.` + ); + } - console.log('Migrating: Permission bit shift complete.'); + // Set flag so this never runs again + db.prepare('UPDATE instance_settings SET voice_bit_migrated = 1 WHERE id = 1').run(); + console.log('Migrating: Voice permission bit migration flagged as complete.'); } /** Delete corrupted read_states rows where last_read_message_id is not a valid snowflake (numeric string) */ diff --git a/packages/server/src/routes/channels.ts b/packages/server/src/routes/channels.ts index 6b5bfc78..158f8e42 100644 --- a/packages/server/src/routes/channels.ts +++ b/packages/server/src/routes/channels.ts @@ -38,6 +38,24 @@ function rowToCategory(row: typeof schema.channelCategories.$inferSelect): Chann }; } +/** + * Check if a channel is private by looking for a VIEW_CHANNEL deny on @everyone. + * The @everyone role ID equals the space ID. + */ +function isChannelPrivate(channelId: string, spaceId: string): boolean { + const db = getDb(); + const override = db.select().from(schema.channelOverrides).where( + and( + eq(schema.channelOverrides.channelId, channelId), + eq(schema.channelOverrides.targetType, 'role'), + eq(schema.channelOverrides.targetId, spaceId), + ) + ).get(); + if (!override) return false; + const denyBits = BigInt(override.deny || '0'); + return (denyBits & PermissionBits.VIEW_CHANNEL) !== 0n; +} + /** * After a channel override changes, notify each space member: * - VIEW_CHANNEL holders receive channel_updated (with their myPermissions) @@ -49,6 +67,7 @@ function broadcastOverrideChange(spaceId: string, channelId: string): void { if (!channel) return; const channelData = rowToChannel(channel); + const priv = isChannelPrivate(channelId, spaceId); for (const [userId, spaceIds] of connectionManager.getUserSpaceEntries()) { if (!spaceIds.has(spaceId)) continue; @@ -57,7 +76,7 @@ function broadcastOverrideChange(spaceId: string, channelId: string): void { if ((perms & PermissionBits.VIEW_CHANNEL) !== 0n) { connectionManager.sendToUser(userId, { type: 'channel_updated', - channel: { ...channelData, myPermissions: permissionsToString(perms) }, + channel: { ...channelData, isPrivate: priv, myPermissions: permissionsToString(perms) }, spaceId, }); } else { @@ -184,7 +203,7 @@ export async function channelRoutes(app: FastifyInstance): Promise { if ((perms & PermissionBits.VIEW_CHANNEL) !== 0n) { connectionManager.sendToUser(userId, { type: 'channel_created', - channel: { ...channelData, myPermissions: permissionsToString(perms) }, + channel: { ...channelData, isPrivate: false, myPermissions: permissionsToString(perms) }, spaceId: id, }); } @@ -400,13 +419,28 @@ export async function channelRoutes(app: FastifyInstance): Promise { } // Validate that allow/deny are valid bigint strings + let allowBits: bigint; + let denyBits: bigint; try { - BigInt(allow || '0'); - BigInt(deny || '0'); + allowBits = BigInt(allow || '0'); + denyBits = BigInt(deny || '0'); } catch { return reply.code(400).send({ error: 'allow and deny must be valid decimal integer strings', statusCode: 400 }); } + // Privilege escalation guard: non-admin users can only grant permissions they possess + const callerPerms = computePermissions(request.userId, channel.spaceId); + if ((callerPerms & PermissionBits.ADMINISTRATOR) === 0n) { + const escalatedAllow = allowBits & ~callerPerms; + if (escalatedAllow !== 0n) { + return reply.code(403).send({ error: 'Cannot grant permissions you do not possess', statusCode: 403 }); + } + const escalatedDeny = denyBits & ~callerPerms; + if (escalatedDeny !== 0n) { + return reply.code(403).send({ error: 'Cannot deny permissions you do not possess', statusCode: 403 }); + } + } + // Upsert: delete existing then insert db.transaction((tx) => { tx.delete(schema.channelOverrides).where( @@ -730,6 +764,7 @@ function broadcastChannelLayout(spaceId: string): void { if ((perms & PermissionBits.VIEW_CHANNEL) !== 0n) { visibleChannels.push({ ...rowToChannel(ch), + isPrivate: isChannelPrivate(ch.id, spaceId), myPermissions: permissionsToString(perms), }); } diff --git a/packages/server/src/routes/spaces.ts b/packages/server/src/routes/spaces.ts index df1f0e7e..8611ff1e 100644 --- a/packages/server/src/routes/spaces.ts +++ b/packages/server/src/routes/spaces.ts @@ -290,13 +290,29 @@ export async function spaceRoutes(app: FastifyInstance): Promise { // Compute space-level permissions for the requesting user const spacePerms = computePermissions(request.userId, id); + // Batch-fetch all channel overrides for @everyone (role = spaceId) to determine isPrivate + const everyoneOverrides = db.select().from(schema.channelOverrides) + .where(and( + eq(schema.channelOverrides.targetType, 'role'), + eq(schema.channelOverrides.targetId, id), + )) + .all(); + const privateChannelIds = new Set(); + for (const o of everyoneOverrides) { + const denyBits = BigInt(o.deny || '0'); + if ((denyBits & PermissionBits.VIEW_CHANNEL) !== 0n) { + privateChannelIds.add(o.channelId); + } + } + // Filter channels by VIEW_CHANNEL permission and attach per-channel myPermissions - const visibleChannels: (Channel & { myPermissions: string })[] = []; + const visibleChannels: (Channel & { isPrivate: boolean; myPermissions: string })[] = []; for (const ch of channels) { const perms = computePermissions(request.userId, id, ch.id); if ((perms & PermissionBits.VIEW_CHANNEL) !== 0n) { visibleChannels.push({ ...rowToChannel(ch), + isPrivate: privateChannelIds.has(ch.id), myPermissions: permissionsToString(perms), }); } diff --git a/packages/server/src/ws/handler.ts b/packages/server/src/ws/handler.ts index e4738c11..fcbc496d 100644 --- a/packages/server/src/ws/handler.ts +++ b/packages/server/src/ws/handler.ts @@ -2,7 +2,7 @@ import type { FastifyInstance } from 'fastify'; import type { WebSocket } from 'ws'; import { verifyJwt } from '../utils/auth.js'; import { getDb, schema } from '../db/index.js'; -import { eq, inArray, desc, sql } from 'drizzle-orm'; +import { eq, and, inArray, desc, sql } from 'drizzle-orm'; import { handleClientEvent } from './events.js'; import { computePermissions, PermissionBits, permissionsToString } from '../utils/permissions.js'; import type { @@ -698,6 +698,25 @@ function buildReadyPayload(userId: string): { arr.push(ch); } + // Batch: determine which channels are private (VIEW_CHANNEL denied on @everyone) + // @everyone role ID equals the space ID, so we query for overrides targeting role = spaceId + const allEveroneOverrides = batchInArray( + spaceIds, + ids => db.select().from(schema.channelOverrides).where( + and( + eq(schema.channelOverrides.targetType, 'role'), + inArray(schema.channelOverrides.targetId, ids), + ) + ).all(), + ); + const privateChannelIds = new Set(); + for (const o of allEveroneOverrides) { + const denyBits = BigInt(o.deny || '0'); + if ((denyBits & PermissionBits.VIEW_CHANNEL) !== 0n) { + privateChannelIds.add(o.channelId); + } + } + // Batch: all categories for all spaces (1 query instead of N) const allCategories = batchInArray( spaceIds, @@ -805,6 +824,7 @@ function buildReadyPayload(userId: string): { topic: ch.topic, position: ch.position ?? 0, categoryId: ch.categoryId ?? null, + isPrivate: privateChannelIds.has(ch.id), createdAt: ch.createdAt, lastMessageId: lastMsgMap.get(ch.id) ?? null, myPermissions: permissionsToString(chPerms), diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index d3d8e917..52e4bc61 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -149,6 +149,7 @@ export interface Channel { topic: string | null; position: number; categoryId: string | null; + isPrivate?: boolean; createdAt: number; lastMessageId?: string | null; myPermissions?: string; // Computed per-user BigInt decimal string diff --git a/packages/web/index.html b/packages/web/index.html index f0189099..a3406427 100644 --- a/packages/web/index.html +++ b/packages/web/index.html @@ -5,9 +5,6 @@ - - - Backspace diff --git a/packages/web/public/fonts/DMSans-Variable-Italic.woff2 b/packages/web/public/fonts/DMSans-Variable-Italic.woff2 new file mode 100644 index 00000000..f37b00f7 Binary files /dev/null and b/packages/web/public/fonts/DMSans-Variable-Italic.woff2 differ diff --git a/packages/web/public/fonts/DMSans-Variable.woff2 b/packages/web/public/fonts/DMSans-Variable.woff2 new file mode 100644 index 00000000..9dbef56b Binary files /dev/null and b/packages/web/public/fonts/DMSans-Variable.woff2 differ diff --git a/packages/web/src/components/layout/ChannelSidebar.tsx b/packages/web/src/components/layout/ChannelSidebar.tsx index a1eea26b..ef9c4e45 100644 --- a/packages/web/src/components/layout/ChannelSidebar.tsx +++ b/packages/web/src/components/layout/ChannelSidebar.tsx @@ -130,14 +130,20 @@ export function ChannelSidebar() { }); }, [collapseKey]); + // Filter channels by VIEW_CHANNEL (defense-in-depth — server already filters, + // but this catches transient races where channels and permissions are briefly out of sync) + const visibleChannels = useMemo(() => + channels.filter(ch => hasPermissionBit(channelPermissions.get(ch.id), PermissionBits.VIEW_CHANNEL)), + [channels, channelPermissions]); + // Group channels by category const sortedCategories = useMemo(() => [...categories].sort((a, b) => a.position - b.position), [categories]); const uncategorizedChannels = useMemo(() => - channels.filter(c => !c.categoryId).sort((a, b) => a.position - b.position), [channels]); + visibleChannels.filter(c => !c.categoryId).sort((a, b) => a.position - b.position), [visibleChannels]); const channelsByCategory = useMemo(() => { const map = new Map(); - for (const ch of channels) { + for (const ch of visibleChannels) { if (!ch.categoryId) continue; let arr = map.get(ch.categoryId); if (!arr) { arr = []; map.set(ch.categoryId, arr); } @@ -147,7 +153,7 @@ export function ChannelSidebar() { map.set(key, arr.sort((a, b) => a.position - b.position)); } return map; - }, [channels]); + }, [visibleChannels]); // Check if a collapsed category has unread channels const categoryHasUnread = useCallback((categoryId: string) => { diff --git a/packages/web/src/components/modals/ChannelSettingsModal.tsx b/packages/web/src/components/modals/ChannelSettingsModal.tsx index 93ff5669..b8b34c1a 100644 --- a/packages/web/src/components/modals/ChannelSettingsModal.tsx +++ b/packages/web/src/components/modals/ChannelSettingsModal.tsx @@ -1,10 +1,12 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react'; import { Modal } from '../ui/Modal'; import { ConfirmDialog } from '../ui/ConfirmDialog'; import { useUIStore } from '../../stores/uiStore'; import { useSpaceStore, getApiForOrigin } from '../../stores/spaceStore'; import { api } from '../../api/client'; import { PermissionBits, permissionsToString, stringToPermissions, hasPermissionBit } from '../../utils/permissions'; +import { Toggle } from '../ui/Toggle'; +import type { Role, MemberWithUser } from '@backspace/shared'; interface ChannelOverride { channelId: string; @@ -14,6 +16,789 @@ interface ChannelOverride { deny: string; } +// ─── Permission Definitions for Channel Overrides ────────────────────────────── + +interface PermissionDef { + key: keyof typeof PermissionBits; + label: string; + bit: bigint; +} + +const TEXT_CHANNEL_PERMISSIONS: PermissionDef[] = [ + { key: 'VIEW_CHANNEL', label: 'View Channel', bit: PermissionBits.VIEW_CHANNEL }, + { key: 'SEND_MESSAGES', label: 'Send Messages', bit: PermissionBits.SEND_MESSAGES }, + { key: 'MANAGE_MESSAGES', label: 'Manage Messages', bit: PermissionBits.MANAGE_MESSAGES }, + { key: 'ATTACH_FILES', label: 'Attach Files', bit: PermissionBits.ATTACH_FILES }, + { key: 'READ_MESSAGE_HISTORY', label: 'Read Message History', bit: PermissionBits.READ_MESSAGE_HISTORY }, + { key: 'ADD_REACTIONS', label: 'Add Reactions', bit: PermissionBits.ADD_REACTIONS }, +]; + +const VOICE_CHANNEL_PERMISSIONS: PermissionDef[] = [ + { key: 'VIEW_CHANNEL', label: 'View Channel', bit: PermissionBits.VIEW_CHANNEL }, + { key: 'CONNECT', label: 'Connect', bit: PermissionBits.CONNECT }, + { key: 'SPEAK', label: 'Speak', bit: PermissionBits.SPEAK }, + { key: 'STREAM', label: 'Stream', bit: PermissionBits.STREAM }, + { key: 'MUTE_MEMBERS', label: 'Mute Members', bit: PermissionBits.MUTE_MEMBERS }, + { key: 'DEAFEN_MEMBERS', label: 'Deafen Members', bit: PermissionBits.DEAFEN_MEMBERS }, + { key: 'MOVE_MEMBERS', label: 'Move Members', bit: PermissionBits.MOVE_MEMBERS }, + { key: 'DISCONNECT_MEMBERS', label: 'Disconnect Members', bit: PermissionBits.DISCONNECT_MEMBERS }, +]; + +// ─── Tri-State Toggle ────────────────────────────────────────────────────────── + +type TriState = 'allow' | 'neutral' | 'deny'; + +function TriStateToggle({ + value, + onChange, + disabled, +}: { + value: TriState; + onChange: (v: TriState) => void; + disabled?: boolean; +}) { + const btnClass = (v: TriState, active: boolean) => { + const base = 'w-6 h-6 flex items-center justify-center rounded-full transition-colors text-xs font-bold'; + if (disabled) return `${base} cursor-not-allowed opacity-40`; + if (!active) return `${base} cursor-pointer text-txt-muted hover:text-txt-tertiary`; + switch (v) { + case 'deny': return `${base} cursor-pointer bg-accent-rose/15 text-accent-rose`; + case 'neutral': return `${base} cursor-pointer bg-white/[0.06] text-txt-tertiary`; + case 'allow': return `${base} cursor-pointer bg-accent-primary/15 text-accent-primary`; + } + }; + + return ( +
+ + + +
+ ); +} + +// ─── Override Entry (expandable row) ──────────────────────────────────────────── + +function OverrideEntry({ + label, + color, + permDefs, + allow, + deny, + onChange, + onRemove, + isEveryone, +}: { + label: string; + color?: string; + permDefs: PermissionDef[]; + allow: bigint; + deny: bigint; + onChange: (allow: bigint, deny: bigint) => void; + onRemove?: () => void; + isEveryone?: boolean; +}) { + const [expanded, setExpanded] = useState(false); + + const getState = (bit: bigint): TriState => { + if ((allow & bit) !== 0n) return 'allow'; + if ((deny & bit) !== 0n) return 'deny'; + return 'neutral'; + }; + + const setState = (bit: bigint, state: TriState) => { + let newAllow = allow & ~bit; + let newDeny = deny & ~bit; + if (state === 'allow') newAllow |= bit; + if (state === 'deny') newDeny |= bit; + onChange(newAllow, newDeny); + }; + + // Compact summary of non-neutral permissions + const summary = permDefs.filter(p => getState(p.bit) !== 'neutral'); + + return ( +
+ + )} + + + + + {expanded && ( +
+ {permDefs.map((perm) => ( +
+ {perm.label} + setState(perm.bit, v)} + /> +
+ ))} +
+ )} +
+ ); +} + +// ─── Permissions Tab ──────────────────────────────────────────────────────────── + +function PermissionsTab({ + channelId, + channelType, + spaceId, + overrides, + onOverridesChange, +}: { + channelId: string; + channelType: 'text' | 'voice'; + spaceId: string; + overrides: ChannelOverride[]; + onOverridesChange: () => void; +}) { + const spaces = useSpaceStore((s) => s.spaces); + const space = spaces.find(s => s.id === spaceId); + const roles = useSpaceStore((s) => s.roles); + const members = useSpaceStore((s) => s.members); + + // Draft state: keyed by "role:id" or "member:id" + const [draftOverrides, setDraftOverrides] = useState>(new Map()); + const [pendingRemovals, setPendingRemovals] = useState>(new Set()); + const [newOverrides, setNewOverrides] = useState>(new Map()); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(''); + + // Add role/member dropdown state + const [showAddRole, setShowAddRole] = useState(false); + const [showAddMember, setShowAddMember] = useState(false); + const [memberSearch, setMemberSearch] = useState(''); + + // Refs + click-outside/Escape for dropdown menus + const roleDropdownRef = useRef(null); + const memberDropdownRef = useRef(null); + + useEffect(() => { + if (!showAddRole) return; + const handleMouseDown = (e: MouseEvent) => { + if (roleDropdownRef.current && !roleDropdownRef.current.contains(e.target as Node)) { + setShowAddRole(false); + } + }; + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') setShowAddRole(false); + }; + document.addEventListener('mousedown', handleMouseDown); + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('mousedown', handleMouseDown); + document.removeEventListener('keydown', handleKeyDown); + }; + }, [showAddRole]); + + useEffect(() => { + if (!showAddMember) return; + const handleMouseDown = (e: MouseEvent) => { + if (memberDropdownRef.current && !memberDropdownRef.current.contains(e.target as Node)) { + setShowAddMember(false); + setMemberSearch(''); + } + }; + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + setShowAddMember(false); + setMemberSearch(''); + } + }; + document.addEventListener('mousedown', handleMouseDown); + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('mousedown', handleMouseDown); + document.removeEventListener('keydown', handleKeyDown); + }; + }, [showAddMember]); + + const permDefs = channelType === 'voice' ? VOICE_CHANNEL_PERMISSIONS : TEXT_CHANNEL_PERMISSIONS; + + // Build map of existing overrides keyed by "role:id" or "member:id" + const existingOverrideMap = useMemo(() => { + const map = new Map(); + for (const o of overrides) { + map.set(`${o.targetType}:${o.targetId}`, o); + } + return map; + }, [overrides]); + + // Roles that already have overrides + const existingRoleIds = useMemo(() => { + const set = new Set(); + for (const o of overrides) { + if (o.targetType === 'role') set.add(o.targetId); + } + for (const [key] of newOverrides) { + if (key.startsWith('role:')) set.add(key.slice(5)); + } + return set; + }, [overrides, newOverrides]); + + // Members that already have overrides + const existingMemberIds = useMemo(() => { + const set = new Set(); + for (const o of overrides) { + if (o.targetType === 'member') set.add(o.targetId); + } + for (const [key] of newOverrides) { + if (key.startsWith('member:')) set.add(key.slice(7)); + } + return set; + }, [overrides, newOverrides]); + + // Available roles to add (not already in overrides) + const availableRoles = useMemo(() => + roles.filter(r => !existingRoleIds.has(r.id) && !pendingRemovals.has(`role:${r.id}`)), + [roles, existingRoleIds, pendingRemovals]); + + // Available members to add (not already in overrides), filtered by search + const availableMembers = useMemo(() => { + const filtered = members.filter(m => + !existingMemberIds.has(m.userId) && + !pendingRemovals.has(`member:${m.userId}`) + ); + if (!memberSearch.trim()) return filtered.slice(0, 20); + const q = memberSearch.toLowerCase(); + return filtered.filter(m => + m.user.username.toLowerCase().includes(q) || + (m.user.displayName?.toLowerCase().includes(q)) + ).slice(0, 20); + }, [members, existingMemberIds, pendingRemovals, memberSearch]); + + // Get effective allow/deny for a key — considers drafts, new overrides, and originals + const getEffective = useCallback((key: string): { allow: bigint; deny: bigint } => { + if (newOverrides.has(key)) { + const n = newOverrides.get(key)!; + return { allow: n.allow, deny: n.deny }; + } + if (draftOverrides.has(key)) return draftOverrides.get(key)!; + const orig = existingOverrideMap.get(key); + if (orig) return { allow: stringToPermissions(orig.allow), deny: stringToPermissions(orig.deny) }; + return { allow: 0n, deny: 0n }; + }, [draftOverrides, newOverrides, existingOverrideMap]); + + // Update handler for an override entry + const handleChange = useCallback((key: string, allow: bigint, deny: bigint) => { + if (newOverrides.has(key)) { + setNewOverrides(prev => { + const next = new Map(prev); + const entry = next.get(key)!; + next.set(key, { ...entry, allow, deny }); + return next; + }); + } else { + setDraftOverrides(prev => { + const next = new Map(prev); + next.set(key, { allow, deny }); + return next; + }); + } + }, [newOverrides]); + + // Remove handler + const handleRemove = useCallback((key: string) => { + if (newOverrides.has(key)) { + setNewOverrides(prev => { + const next = new Map(prev); + next.delete(key); + return next; + }); + } else { + setPendingRemovals(prev => { + const next = new Set(prev); + next.add(key); + return next; + }); + // Remove from drafts too + setDraftOverrides(prev => { + const next = new Map(prev); + next.delete(key); + return next; + }); + } + }, [newOverrides]); + + // Add role override + const handleAddRole = useCallback((roleId: string) => { + const key = `role:${roleId}`; + setNewOverrides(prev => { + const next = new Map(prev); + next.set(key, { targetType: 'role', targetId: roleId, allow: 0n, deny: 0n }); + return next; + }); + // If it was pending removal, unmark it + setPendingRemovals(prev => { + const next = new Set(prev); + next.delete(key); + return next; + }); + setShowAddRole(false); + }, []); + + // Add member override + const handleAddMember = useCallback((userId: string) => { + const key = `member:${userId}`; + setNewOverrides(prev => { + const next = new Map(prev); + next.set(key, { targetType: 'member', targetId: userId, allow: 0n, deny: 0n }); + return next; + }); + setPendingRemovals(prev => { + const next = new Set(prev); + next.delete(key); + return next; + }); + setShowAddMember(false); + setMemberSearch(''); + }, []); + + const hasChanges = draftOverrides.size > 0 || newOverrides.size > 0 || pendingRemovals.size > 0; + + const handleDiscard = useCallback(() => { + setDraftOverrides(new Map()); + setNewOverrides(new Map()); + setPendingRemovals(new Set()); + setSaveError(''); + }, []); + + const handleSave = useCallback(async () => { + setSaving(true); + setSaveError(''); + + const channelApi = getApiForOrigin(space?._instanceOrigin ?? ''); + + try { + const promises: Promise[] = []; + + // Delete removed overrides + for (const key of pendingRemovals) { + const parts = key.split(':'); + promises.push(channelApi.channels.deleteOverride(channelId, parts[0]!, parts[1]!)); + } + + // Update modified existing overrides + for (const [key, { allow, deny }] of draftOverrides) { + if (pendingRemovals.has(key)) continue; + const parts = key.split(':'); + promises.push(channelApi.channels.putOverride(channelId, { + targetType: parts[0]!, + targetId: parts[1]!, + allow: permissionsToString(allow), + deny: permissionsToString(deny), + })); + } + + // Create new overrides + for (const [, { targetType, targetId, allow, deny }] of newOverrides) { + promises.push(channelApi.channels.putOverride(channelId, { + targetType, + targetId, + allow: permissionsToString(allow), + deny: permissionsToString(deny), + })); + } + + const results = await Promise.allSettled(promises); + const failures = results.filter(r => r.status === 'rejected'); + if (failures.length > 0) { + const first = failures[0] as PromiseRejectedResult; + setSaveError(first.reason?.message || `${failures.length} override(s) failed to save`); + } + + // Reset draft state and re-fetch overrides + setDraftOverrides(new Map()); + setNewOverrides(new Map()); + setPendingRemovals(new Set()); + onOverridesChange(); + } catch (err) { + setSaveError(err instanceof Error ? err.message : 'Failed to save overrides'); + } finally { + setSaving(false); + } + }, [channelId, draftOverrides, newOverrides, pendingRemovals, space, onOverridesChange]); + + // Build ordered lists of role and member overrides + const roleOverrides = useMemo(() => { + const items: { key: string; role: Role; isNew: boolean }[] = []; + + // Existing overrides (excluding pending removals) + for (const o of overrides) { + if (o.targetType !== 'role') continue; + const key = `role:${o.targetId}`; + if (pendingRemovals.has(key)) continue; + const role = roles.find(r => r.id === o.targetId); + if (!role) continue; + items.push({ key, role, isNew: false }); + } + + // New overrides + for (const [key, entry] of newOverrides) { + if (!key.startsWith('role:')) continue; + const role = roles.find(r => r.id === entry.targetId); + if (!role) continue; + if (items.some(i => i.key === key)) continue; + items.push({ key, role, isNew: true }); + } + + // Sort: @everyone first, then by position + items.sort((a, b) => { + if (a.role.id === spaceId) return -1; + if (b.role.id === spaceId) return 1; + return (a.role.position ?? 0) - (b.role.position ?? 0); + }); + + return items; + }, [overrides, newOverrides, pendingRemovals, roles, spaceId]); + + const memberOverrides = useMemo(() => { + const items: { key: string; member: MemberWithUser; isNew: boolean }[] = []; + + for (const o of overrides) { + if (o.targetType !== 'member') continue; + const key = `member:${o.targetId}`; + if (pendingRemovals.has(key)) continue; + const member = members.find(m => m.userId === o.targetId); + if (!member) continue; + items.push({ key, member, isNew: false }); + } + + for (const [key, entry] of newOverrides) { + if (!key.startsWith('member:')) continue; + const member = members.find(m => m.userId === entry.targetId); + if (!member) continue; + if (items.some(i => i.key === key)) continue; + items.push({ key, member, isNew: true }); + } + + return items; + }, [overrides, newOverrides, pendingRemovals, members]); + + return ( +
+ {/* Role Overrides */} +
+
+ Role Overrides +
+
+ {roleOverrides.map(({ key, role }) => { + const eff = getEffective(key); + return ( + handleChange(key, a, d)} + onRemove={() => handleRemove(key)} + isEveryone={role.id === spaceId} + /> + ); + })} +
+ + {/* Add Role */} +
+ {!showAddRole ? ( + + ) : ( +
+
+ {availableRoles.length === 0 ? ( +
No more roles to add
+ ) : ( + availableRoles.map(role => ( + + )) + )} +
+
+ +
+
+ )} +
+
+ + {/* Member Overrides */} +
+
+ Member Overrides +
+
+ {memberOverrides.map(({ key, member }) => { + const eff = getEffective(key); + return ( + handleChange(key, a, d)} + onRemove={() => handleRemove(key)} + /> + ); + })} +
+ + {/* Add Member */} +
+ {!showAddMember ? ( + + ) : ( +
+
+ setMemberSearch(e.target.value)} + placeholder="Search members..." + className="w-full px-2.5 py-1.5 text-sm bg-surface-input rounded mb-1 text-txt-primary placeholder-txt-muted outline-none" + autoFocus + /> +
+
+ {availableMembers.length === 0 ? ( +
No members found
+ ) : ( + availableMembers.map(member => ( + + )) + )} +
+
+ +
+
+ )} +
+
+ + {/* Error */} + {saveError && ( +
+ {saveError} +
+ )} + + {/* Save/Discard pill */} + {hasChanges && ( +
+
+
+ + +
+
+
+ )} +
+ ); +} + +// ─── Overview Tab ─────────────────────────────────────────────────────────────── + +function OverviewTab({ + channelId, + channelName, + channelType, + isPrivate, + isFetching, + isLoading, + error, + canManageChannels, + onTogglePrivate, + onDeleteChannel, +}: { + channelId: string; + channelName: string; + channelType: string; + isPrivate: boolean; + isFetching: boolean; + isLoading: boolean; + error: string; + canManageChannels: boolean; + onTogglePrivate: () => void; + onDeleteChannel: () => void; +}) { + return ( +
+
+ +
+ {isPrivate ? ( + + + + ) : ( + + + + )} + {channelName} +
+
+ + {error && ( +
+ {error} +
+ )} + +
+
+
+
Private Channel
+
+ Only selected members and roles will be able to view this channel. +
+
+
+ +
+
+
+ + {isPrivate && !isFetching && ( +
+ + + + + This channel is hidden from members without explicit access. Users with the Administrator permission or space owners can always see all channels. + +
+ )} + + {canManageChannels && ( +
+ + +
+ )} +
+ ); +} + +// ─── Main Modal ───────────────────────────────────────────────────────────────── + export function ChannelSettingsModal() { const activeModal = useUIStore((s) => s.activeModal); const modalData = useUIStore((s) => s.modalData); @@ -23,12 +808,14 @@ export function ChannelSettingsModal() { const spaces = useSpaceStore((s) => s.spaces); const spacePermissions = useSpaceStore((s) => s.spacePermissions); + const [tab, setTab] = useState<'overview' | 'permissions'>('overview'); const [isPrivate, setIsPrivate] = useState(false); const [isLoading, setIsLoading] = useState(false); const [isFetching, setIsFetching] = useState(true); const [error, setError] = useState(''); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [isDeleting, setIsDeleting] = useState(false); + const [overrides, setOverrides] = useState([]); const isOpen = activeModal === 'channelSettings'; const channelId = modalData?.channelId as string | undefined; @@ -36,29 +823,33 @@ export function ChannelSettingsModal() { const myPerms = currentSpaceId ? spacePermissions.get(currentSpaceId) : undefined; const canManageChannels = myPerms !== undefined && hasPermissionBit(myPerms, PermissionBits.MANAGE_CHANNELS); + const canManageRoles = myPerms !== undefined && hasPermissionBit(myPerms, PermissionBits.MANAGE_ROLES); - // Reset delete state when modal closes + // Reset state when modal closes useEffect(() => { if (!isOpen) { setShowDeleteConfirm(false); setIsDeleting(false); + setTab('overview'); + setOverrides([]); } }, [isOpen]); // Fetch overrides when modal opens - useEffect(() => { - if (!isOpen || !channelId || !currentSpaceId) { - setIsFetching(false); - return; - } + const fetchOverrides = useCallback(() => { + if (!channelId || !currentSpaceId) return; setIsFetching(true); setError(''); - api.channels.getOverrides(channelId) - .then((overrides: ChannelOverride[]) => { + const space = spaces.find(s => s.id === currentSpaceId); + const channelApi = getApiForOrigin(space?._instanceOrigin ?? ''); + + channelApi.channels.getOverrides(channelId) + .then((data: ChannelOverride[]) => { + setOverrides(data); // Check if @everyone role (id === spaceId) has VIEW_CHANNEL denied - const everyoneOverride = overrides.find( + const everyoneOverride = data.find( o => o.targetType === 'role' && o.targetId === currentSpaceId ); if (everyoneOverride) { @@ -74,7 +865,15 @@ export function ChannelSettingsModal() { .finally(() => { setIsFetching(false); }); - }, [isOpen, channelId, currentSpaceId]); + }, [channelId, currentSpaceId, spaces]); + + useEffect(() => { + if (isOpen && channelId && currentSpaceId) { + fetchOverrides(); + } else { + setIsFetching(false); + } + }, [isOpen, channelId, currentSpaceId, fetchOverrides]); if (!isOpen || !channel || !channelId || !currentSpaceId) return null; @@ -82,10 +881,13 @@ export function ChannelSettingsModal() { setError(''); setIsLoading(true); + const space = spaces.find(s => s.id === currentSpaceId); + const channelApi = getApiForOrigin(space?._instanceOrigin ?? ''); + try { if (!isPrivate) { // Make private: deny VIEW_CHANNEL for @everyone role - await api.channels.putOverride(channelId, { + await channelApi.channels.putOverride(channelId, { targetType: 'role', targetId: currentSpaceId, allow: '0', @@ -94,9 +896,11 @@ export function ChannelSettingsModal() { setIsPrivate(true); } else { // Make public: remove the @everyone VIEW_CHANNEL deny override - await api.channels.deleteOverride(channelId, 'role', currentSpaceId); + await channelApi.channels.deleteOverride(channelId, 'role', currentSpaceId); setIsPrivate(false); } + // Re-fetch overrides to keep permissions tab in sync + fetchOverrides(); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to update channel privacy'); } finally { @@ -118,81 +922,71 @@ export function ChannelSettingsModal() { } }; + const showTabs = canManageRoles; + + const tabClass = (t: typeof tab) => + `w-full text-left px-2.5 py-1.5 rounded text-sm transition-colors ${ + tab === t ? 'bg-interactive-selected text-txt-primary' : 'text-txt-tertiary hover:text-txt-secondary hover:bg-interactive-hover' + }`; + return ( <> - -
-
- -
- - - - {channel.name} -
-
- - {error && ( -
- {error} -
- )} - -
-
-
-
Private Channel
-
- Only selected members and roles will be able to view this channel. -
+ + {showTabs ? ( +
+ {/* Tabs */} +
+
+ +
- + )} + {tab === 'permissions' && ( + + )}
- - {isPrivate && !isFetching && ( -
- - - - - This channel is hidden from members without explicit access. Users with the Administrator permission or space owners can always see all channels. - -
- )} - - {canManageChannels && ( -
- - -
- )} -
+ ) : ( + setShowDeleteConfirm(true)} + /> + )} s.openUserProfile); const initials = name.charAt(0).toUpperCase(); - // Match prototype: 24px→10px, 32-34px→12px, 40px→15px, 56px+→18px - const fontPx = size <= 24 ? 10 : size <= 34 ? 12 : size <= 44 ? 15 : 18; + const fontPx = Math.round(size * 0.4); const gradient = getAvatarGradient(userId ?? user?.homeUserId ?? user?.id, name, avatarColor ?? user?.avatarColor); const ringWidth = ring?.width ?? 0; diff --git a/packages/web/src/hooks/useWebSocket.ts b/packages/web/src/hooks/useWebSocket.ts index f16eda13..0a034ec1 100644 --- a/packages/web/src/hooks/useWebSocket.ts +++ b/packages/web/src/hooks/useWebSocket.ts @@ -612,6 +612,11 @@ function handleEvent(origin: string, event: ServerEvent): void { if (event.spaceId === curSpaceId3) { setChannels3(curChannels3.filter(c => c.id !== event.channelId)); } + // If the user is currently viewing the deleted channel, clear it + const { currentChannelId: deletedViewChannelId } = useChatStore.getState(); + if (deletedViewChannelId === event.channelId) { + useChatStore.getState().setCurrentChannel(null); + } chPermsMap3.delete(event.channelId); ctsMap3.delete(event.channelId); coMap3.delete(event.channelId); diff --git a/packages/web/src/stores/voiceStore.ts b/packages/web/src/stores/voiceStore.ts index c15ab156..4210761a 100644 --- a/packages/web/src/stores/voiceStore.ts +++ b/packages/web/src/stores/voiceStore.ts @@ -21,6 +21,7 @@ interface VoiceState { isScreenSharing: boolean; participants: ParticipantInfo[]; speakingParticipantIds: Set; + speakingUserIds: Set; connectionError: string | null; isLiveKitConnected: boolean; connectionQuality: 'excellent' | 'good' | 'poor' | 'lost' | 'unknown'; @@ -124,6 +125,7 @@ export const useVoiceStore = create()( isScreenSharing: false, participants: [], speakingParticipantIds: new Set(), + speakingUserIds: new Set(), connectionError: null, isLiveKitConnected: false, connectionQuality: 'unknown', @@ -264,7 +266,14 @@ export const useVoiceStore = create()( }), setParticipants: (participants) => set({ participants }), - setSpeakingParticipants: (ids) => set({ speakingParticipantIds: ids }), + setSpeakingParticipants: (ids) => { + const userIds = new Set(); + for (const id of ids) { + const sep = id.indexOf(':'); + if (sep !== -1) userIds.add(id.substring(0, sep)); + } + set({ speakingParticipantIds: ids, speakingUserIds: userIds }); + }, setConnectionError: (error) => set({ connectionError: error }), setIsLiveKitConnected: (connected) => set({ isLiveKitConnected: connected }), setConnectionQuality: (quality) => set({ connectionQuality: quality }), @@ -378,6 +387,7 @@ export const useVoiceStore = create()( currentVoiceChannelId: null, participants: [], speakingParticipantIds: new Set(), + speakingUserIds: new Set(), connectionError: null, isLiveKitConnected: false, connectionQuality: 'unknown', @@ -437,6 +447,7 @@ export const useVoiceStore = create()( isScreenSharing: false, participants: [], speakingParticipantIds: new Set(), + speakingUserIds: new Set(), connectionError: null, isLiveKitConnected: false, connectionQuality: 'unknown', @@ -464,6 +475,7 @@ export const useVoiceStore = create()( isScreenSharing: false, participants: [], speakingParticipantIds: new Set(), + speakingUserIds: new Set(), connectionError: null, isLiveKitConnected: false, connectionQuality: 'unknown', @@ -488,6 +500,7 @@ export const useVoiceStore = create()( isScreenSharing: false, participants: [], speakingParticipantIds: new Set(), + speakingUserIds: new Set(), connectionError: null, isLiveKitConnected: false, connectionQuality: 'unknown', @@ -581,6 +594,7 @@ export const useVoiceStore = create()( merged.voiceUsers = currentState.voiceUsers; merged.participants = currentState.participants; merged.speakingParticipantIds = currentState.speakingParticipantIds; + merged.speakingUserIds = currentState.speakingUserIds; merged.deafenedUserIds = currentState.deafenedUserIds; merged.voiceUserStates = currentState.voiceUserStates; merged.participantVolumes = currentState.participantVolumes; diff --git a/packages/web/src/styles/globals.css b/packages/web/src/styles/globals.css index dd67bbe9..55c836fe 100644 --- a/packages/web/src/styles/globals.css +++ b/packages/web/src/styles/globals.css @@ -138,7 +138,7 @@ /* ── Glass Material Tiers ── * .glass — Popovers, context menus, autocomplete (small, no backdrop) * .glass-modal — Center-screen dialogs (large, with backdrop, higher opacity for legibility) - * .glass-strip — Space sidebar edge (no border-radius, directional shadow) + * .glass-strip — Space sidebar edge (moved to @layer utilities to win over bg-surface-* utilities) * .glass-bubble — Persistent floating controls (voice bar, input pill) * .glass-pill — Inline decorations (reactions, tags, lighter blur) * @@ -159,17 +159,6 @@ transition: border-color 0.2s ease, box-shadow 0.2s ease; } - /* Space strip glass — no border-radius, rightward shadow */ - .glass-strip { - backdrop-filter: blur(20px) saturate(120%); - -webkit-backdrop-filter: blur(20px) saturate(120%); - background: var(--glass-bg); - border-right: 1px solid rgba(255, 255, 255, 0.05); - box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.03), - 2px 0 12px rgba(0, 0, 0, 0.20); - } - /* Floating bubble glass — bottom bar, input pill */ .glass-bubble { backdrop-filter: blur(20px) saturate(120%); @@ -247,6 +236,17 @@ } @layer utilities { + /* Space strip glass — in utilities layer so md:glass-strip wins over bg-surface-* */ + .glass-strip { + backdrop-filter: blur(20px) saturate(120%); + -webkit-backdrop-filter: blur(20px) saturate(120%); + background: var(--glass-bg); + border-right: 1px solid rgba(255, 255, 255, 0.05); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.03), + 2px 0 12px rgba(0, 0, 0, 0.20); + } + .rounded-inherit { border-radius: inherit; } diff --git a/packages/web/tailwind.config.js b/packages/web/tailwind.config.js index 5b41d50e..3cddac7e 100644 --- a/packages/web/tailwind.config.js +++ b/packages/web/tailwind.config.js @@ -70,7 +70,7 @@ export default { 'glass': '0 2px 8px rgba(0,0,0,0.25), 0 8px 24px rgba(0,0,0,0.15), inset 0 1px 0 var(--glass-highlight)', }, fontFamily: { - sans: ['Inter', '-apple-system', 'BlinkMacSystemFont', 'system-ui', 'sans-serif'], + sans: ['DM Sans', '-apple-system', 'BlinkMacSystemFont', 'system-ui', 'sans-serif'], }, }, },