From 2755cc0aac9e5763bd88a7b5b480fb586c4d6e3a Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 10 Mar 2026 15:46:59 +0100 Subject: [PATCH] refactor: remove USE_VOICE_ACTIVITY dead code and enforce STREAM permission in VoiceControlBar Remove the unused USE_VOICE_ACTIVITY permission bit (was bit 25) and shift STREAM to bit 25, DISCONNECT_MEMBERS to bit 26. Add a database migration to remap stored permission values. Gate camera and screen share buttons in VoiceControlBar behind canSpeak/canStream, matching VoiceControls behavior. --- CLAUDE.md | 5 +- packages/server/src/db/migrate.ts | 91 +++++++++++++++++++ packages/shared/src/permissions.ts | 8 +- .../modals/spaceSettingsPanels/RolesPanel.tsx | 1 - .../src/components/voice/VoiceControlBar.tsx | 62 +++++++------ 5 files changed, 132 insertions(+), 35 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1bdd8cf8..4dd47ed8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -615,9 +615,8 @@ Bitwise permission engine defined in `packages/shared/src/permissions.ts`. Store | 22 | MUTE_MEMBERS | Server-mute other members | | 23 | DEAFEN_MEMBERS | Server-deafen other members | | 24 | MOVE_MEMBERS | Move members between voice channels | -| 25 | USE_VOICE_ACTIVITY | Use voice activity detection | -| 26 | STREAM | Share screen in voice channels | -| 27 | DISCONNECT_MEMBERS | Disconnect members from voice channels | +| 25 | STREAM | Share screen in voice channels | +| 26 | DISCONNECT_MEMBERS | Disconnect members from voice channels | **Resolution order:** Owner → @everyone role → Assigned roles (OR'd) → ADMINISTRATOR shortcut → Channel overrides (@everyone → role overrides → member override). diff --git a/packages/server/src/db/migrate.ts b/packages/server/src/db/migrate.ts index 11e1104f..6bcbccfa 100644 --- a/packages/server/src/db/migrate.ts +++ b/packages/server/src/db/migrate.ts @@ -172,6 +172,9 @@ export function runMigrations(db: Database.Database): void { // ─── Admin flag: ensure at least one admin exists (first registered user) ── migrateFirstAdmin(db); + // ─── Remove USE_VOICE_ACTIVITY bit and shift STREAM/DISCONNECT_MEMBERS down ─ + migrateRemoveVoiceActivityBit(db); + // ─── Clean up corrupted read_states (temp_ IDs leaked from optimistic messages) ─ migrateCorruptedReadStates(db); @@ -303,6 +306,94 @@ 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. + */ +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 */ } + } + } + + if (!needsMigration) 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)...'); + + const updateRole = db.prepare('UPDATE roles SET permissions = ? WHERE id = ?'); + for (const role of roles) { + try { + const old = BigInt(role.permissions); + const shifted = shiftPermBits(old); + if (shifted !== old) { + updateRole.run(shifted.toString(), role.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) { + 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); + } + } catch { /* skip invalid */ } + } + + console.log('Migrating: Permission bit shift complete.'); +} + /** Delete corrupted read_states rows where last_read_message_id is not a valid snowflake (numeric string) */ function migrateCorruptedReadStates(db: Database.Database): void { const deleted = db.prepare( diff --git a/packages/shared/src/permissions.ts b/packages/shared/src/permissions.ts index 9456b067..88deea84 100644 --- a/packages/shared/src/permissions.ts +++ b/packages/shared/src/permissions.ts @@ -21,9 +21,8 @@ export const PermissionBits = { MUTE_MEMBERS: 1n << 22n, DEAFEN_MEMBERS: 1n << 23n, MOVE_MEMBERS: 1n << 24n, - USE_VOICE_ACTIVITY: 1n << 25n, - STREAM: 1n << 26n, - DISCONNECT_MEMBERS: 1n << 27n, + STREAM: 1n << 25n, + DISCONNECT_MEMBERS: 1n << 26n, } as const; export type PermissionBit = (typeof PermissionBits)[keyof typeof PermissionBits]; @@ -39,8 +38,7 @@ export const DEFAULT_EVERYONE_PERMISSIONS = PermissionBits.ATTACH_FILES | PermissionBits.READ_MESSAGE_HISTORY | PermissionBits.ADD_REACTIONS | - PermissionBits.STREAM | - PermissionBits.USE_VOICE_ACTIVITY; + PermissionBits.STREAM; // ─── Helpers ──────────────────────────────────────────────────────────────── diff --git a/packages/web/src/components/modals/spaceSettingsPanels/RolesPanel.tsx b/packages/web/src/components/modals/spaceSettingsPanels/RolesPanel.tsx index e72b517f..7aa4db16 100644 --- a/packages/web/src/components/modals/spaceSettingsPanels/RolesPanel.tsx +++ b/packages/web/src/components/modals/spaceSettingsPanels/RolesPanel.tsx @@ -44,7 +44,6 @@ const PERMISSION_GROUPS: { name: string; perms: PermDef[] }[] = [ { bit: PermissionBits.DEAFEN_MEMBERS, label: 'Deafen Members' }, { bit: PermissionBits.MOVE_MEMBERS, label: 'Move Members' }, { bit: PermissionBits.DISCONNECT_MEMBERS, label: 'Disconnect Members' }, - { bit: PermissionBits.USE_VOICE_ACTIVITY, label: 'Voice Activity' }, { bit: PermissionBits.STREAM, label: 'Stream' }, ], }, diff --git a/packages/web/src/components/voice/VoiceControlBar.tsx b/packages/web/src/components/voice/VoiceControlBar.tsx index fcf6c2a0..47dbe257 100644 --- a/packages/web/src/components/voice/VoiceControlBar.tsx +++ b/packages/web/src/components/voice/VoiceControlBar.tsx @@ -8,6 +8,7 @@ import { useSpaceStore, getChannelOrigin, getMyUserIdForOrigin } from '../../sto import { ScreenShareSettingsPopover } from './ScreenShareSettingsPopover'; import { CAMERA_PRESET, startScreenShare, stopScreenShare } from '../../utils/screenShare'; import { broadcastVoiceStatus, broadcastDeafenViaLiveKit } from '../../utils/voice'; +import { hasPermissionBit, PermissionBits } from '../../utils/permissions'; const btnBase = 'w-10 h-10 flex items-center justify-center rounded-full transition-colors'; const btnDefault = `${btnBase} bg-surface-channel text-txt-secondary hover:bg-surface-elevated hover:text-txt-primary`; @@ -35,6 +36,11 @@ export function VoiceControlBar() { const serverDeafenedUserIds = useVoiceStore((s) => s.serverDeafenedUserIds); const isServerMuted = !!(myOriginId && spaceId && serverMutedUserIds.has(`${spaceId}:${myOriginId}`)); const isServerDeafened = !!(myOriginId && spaceId && serverDeafenedUserIds.has(`${spaceId}:${myOriginId}`)); + const activeDmCall = useVoiceStore((s) => s.activeDmCall); + const channelPerms = useSpaceStore((s) => currentVoiceChannelId ? s.channelPermissions.get(currentVoiceChannelId) : undefined); + const isDmCall = !!activeDmCall; + const canSpeak = isDmCall || hasPermissionBit(channelPerms, PermissionBits.SPEAK); + const canStream = isDmCall || hasPermissionBit(channelPerms, PermissionBits.STREAM); const [qualityOpen, setQualityOpen] = useState(false); const qualityBtnRef = useRef(null); @@ -174,34 +180,38 @@ export function VoiceControlBar() { {/* Camera */} - + {canSpeak && ( + + )} {/* Screen Share */} - + {canStream && ( + + )} {/* Video Quality */}