feat: real-time SPEAK permission enforcement in voice channels

Permission changes now take effect immediately without requiring
disconnect/reconnect. Modeled as "permission mute" parallel to
server mute — server recomputes SPEAK for all voice participants
on role/override changes and broadcasts state via WebSocket.
Includes amber UI indicators and mic toggle blocking.
This commit is contained in:
Jannis Braun
2026-03-10 14:50:01 +01:00
parent 4d9454382c
commit ce63c5ed36
13 changed files with 173 additions and 28 deletions
@@ -37,27 +37,30 @@ export function ChannelSidebar() {
const myOriginId = useSpaceStore((s) => currentVoiceChannelId ? getMyUserIdForOrigin(getChannelOrigin(currentVoiceChannelId)) : s.members.find(m => m.userId === user?.id)?.userId ?? user?.id);
const serverMutedUserIds = useVoiceStore((s) => s.serverMutedUserIds);
const serverDeafenedUserIds = useVoiceStore((s) => s.serverDeafenedUserIds);
const permissionMutedUserIds = useVoiceStore((s) => s.permissionMutedUserIds);
const isServerMuted = !!(myOriginId && spaceId && serverMutedUserIds.has(`${spaceId}:${myOriginId}`));
const isServerDeafened = !!(myOriginId && spaceId && serverDeafenedUserIds.has(`${spaceId}:${myOriginId}`));
const isPermissionMuted = !!(myOriginId && spaceId && permissionMutedUserIds.has(`${spaceId}:${myOriginId}`));
const navigate = useNavigate();
const location = useLocation();
const floatingPanelRef = useRef<HTMLDivElement>(null);
const [panelHeight, setPanelHeight] = useState(140);
const floatingPanelHeight = useUIStore((s) => s.floatingPanelHeight);
const setFloatingPanelHeight = useUIStore((s) => s.setFloatingPanelHeight);
useEffect(() => {
const el = floatingPanelRef.current;
if (!el) return;
const ro = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry) setPanelHeight(entry.contentRect.height);
if (entry) setFloatingPanelHeight(entry.contentRect.height);
});
ro.observe(el);
return () => ro.disconnect();
}, []);
}, [setFloatingPanelHeight]);
const handleMicToggle = async () => {
if (isServerMuted || isServerDeafened) return;
if (isServerMuted || isServerDeafened || isPermissionMuted) return;
const wasDeafened = useVoiceStore.getState().isDeafened;
toggleMic();
broadcastVoiceStatus();
@@ -128,6 +131,7 @@ export function ChannelSidebar() {
isDeafened={isDeafened}
isServerMuted={isServerMuted}
isServerDeafened={isServerDeafened}
isPermissionMuted={isPermissionMuted}
onMicToggle={handleMicToggle}
onDeafenToggle={handleDeafenToggle}
onSettingsClick={(tab) => openModal('userSettings', tab ? { tab } : {})}
@@ -145,7 +149,7 @@ export function ChannelSidebar() {
Find or start a conversation
</button>
</div>
<div className="flex-1 overflow-y-auto pt-4 px-2 no-scrollbar" style={{ paddingBottom: panelHeight + 24 }}>
<div className="flex-1 overflow-y-auto pt-4 px-2 no-scrollbar" style={{ paddingBottom: floatingPanelHeight + 24 }}>
<div
onClick={handleHomeClick}
className={`flex items-center gap-3 px-2 h-[42px] rounded-[4px] cursor-pointer mb-[2px] transition-colors group ${
@@ -325,7 +329,7 @@ export function ChannelSidebar() {
</div>
{/* Channels */}
<div className="flex-1 overflow-y-auto pt-3 px-2 space-y-[21px] no-scrollbar" style={{ paddingBottom: panelHeight + 24 }}>
<div className="flex-1 overflow-y-auto pt-3 px-2 space-y-[21px] no-scrollbar" style={{ paddingBottom: floatingPanelHeight + 24 }}>
{/* Text Channels */}
<div>
<div className="flex items-center justify-between px-1 mb-1 group cursor-pointer">
@@ -457,6 +461,7 @@ function UserAreaPanel({
isDeafened,
isServerMuted,
isServerDeafened,
isPermissionMuted,
onMicToggle,
onDeafenToggle,
onSettingsClick,
@@ -466,6 +471,7 @@ function UserAreaPanel({
isDeafened: boolean;
isServerMuted: boolean;
isServerDeafened: boolean;
isPermissionMuted: boolean;
onMicToggle: () => void;
onDeafenToggle: () => void;
onSettingsClick: (tab?: string) => void;
@@ -780,15 +786,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 ${
(isServerMuted || isServerDeafened) ? 'text-accent-amber cursor-not-allowed'
(isServerMuted || isServerDeafened || isPermissionMuted) ? 'text-accent-amber cursor-not-allowed'
: isMuted || isDeafened ? 'text-txt-danger' : 'text-txt-tertiary hover:text-txt-primary'
}`}
title={(isServerMuted || isServerDeafened) ? 'Server Muted' : isMuted ? 'Unmute' : 'Mute'}
title={(isPermissionMuted) ? 'Muted (No Speak Permission)' : (isServerMuted || isServerDeafened) ? '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 || isDeafened || isServerMuted || isServerDeafened) && <line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />}
{(isMuted || isDeafened || isServerMuted || isServerDeafened || isPermissionMuted) && <line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />}
</svg>
</button>
{/* Input chevron */}
@@ -170,6 +170,7 @@ export function SpaceSidebar() {
const setShowDms = useUIStore((s) => s.setShowDms);
const openModal = useUIStore((s) => s.openModal);
const addToast = useUIStore((s) => s.addToast);
const floatingPanelHeight = useUIStore((s) => s.floatingPanelHeight);
const setCurrentChannel = useChatStore((s) => s.setCurrentChannel);
const unreadChannels = useChatStore((s) => s.unreadChannels);
const instances = useInstanceStore((s) => s.instances);
@@ -246,7 +247,7 @@ export function SpaceSidebar() {
};
return (
<nav data-pip-obstacle="left" className="w-[72px] bg-surface-base flex flex-col items-center py-3 overflow-y-auto flex-shrink-0 no-scrollbar select-none md:fixed md:inset-y-0 md:left-0 md:z-[100] md:glass-strip">
<nav data-pip-obstacle="left" className="w-[72px] bg-surface-base flex flex-col items-center py-3 overflow-y-auto flex-shrink-0 no-scrollbar select-none md:fixed md:inset-y-0 md:left-0 md:z-[100] md:glass-strip" style={{ paddingBottom: floatingPanelHeight + 24 }}>
<SidebarItem
id="@me"
name="Direct Messages"
@@ -23,6 +23,7 @@ export function VoiceChannel({ channelId, channelName, onClick, locked }: VoiceC
const voiceUserStates = useVoiceStore((s) => s.voiceUserStates);
const serverMutedUserIds = useVoiceStore((s) => s.serverMutedUserIds);
const serverDeafenedUserIds = useVoiceStore((s) => s.serverDeafenedUserIds);
const permissionMutedUserIds = useVoiceStore((s) => s.permissionMutedUserIds);
const participantMutes = useVoiceStore((s) => s.participantMutes);
const unwatchedCameras = useVoiceStore((s) => s.unwatchedCameras);
const currentUserId = useVoiceStore((s) => {
@@ -98,6 +99,7 @@ export function VoiceChannel({ channelId, channelName, onClick, locked }: VoiceC
const spaceId = channelToSpaceMap.get(channelId);
const isServerMuted = serverMutedUserIds.has(`${spaceId}:${userId}`);
const isServerDeafened = serverDeafenedUserIds.has(`${spaceId}:${userId}`);
const isPermissionMuted = permissionMutedUserIds.has(`${spaceId}:${userId}`);
return (
<div
@@ -115,8 +117,8 @@ export function VoiceChannel({ channelId, channelName, onClick, locked }: VoiceC
<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">
{(isServerMuted || isServerDeafened) && (
<span title={isServerMuted ? "Server Muted" : "Muted (Server Deafened)"}>
{(isServerMuted || isServerDeafened || isPermissionMuted) && (
<span title={isPermissionMuted ? "Muted (No Speak Permission)" : isServerMuted ? "Server Muted" : "Muted (Server Deafened)"}>
<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" />
@@ -132,7 +134,7 @@ export function VoiceChannel({ channelId, channelName, onClick, locked }: VoiceC
</svg>
</span>
)}
{!isServerMuted && !isServerDeafened && isMuted && (
{!isServerMuted && !isServerDeafened && !isPermissionMuted && 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" />
@@ -19,6 +19,7 @@ export function VoiceUser({ tile, large }: VoiceUserProps) {
const isSpeaking = useVoiceStore((s) => s.speakingParticipantIds.has(participant.identity));
const serverMutedUserIds = useVoiceStore((s) => s.serverMutedUserIds);
const serverDeafenedUserIds = useVoiceStore((s) => s.serverDeafenedUserIds);
const permissionMutedUserIds = useVoiceStore((s) => s.permissionMutedUserIds);
const unwatchedCameras = useVoiceStore((s) => s.unwatchedCameras);
const participantMutes = useVoiceStore((s) => s.participantMutes);
const spaceId = useSpaceStore((s) => currentVoiceChannelId ? s.channelToSpaceMap.get(currentVoiceChannelId) : null);
@@ -121,12 +122,13 @@ export function VoiceUser({ tile, large }: VoiceUserProps) {
{(() => {
const isServerMutedUser = spaceId ? serverMutedUserIds.has(`${spaceId}:${participant.userId}`) : false;
const isServerDeafenedUser = spaceId ? serverDeafenedUserIds.has(`${spaceId}:${participant.userId}`) : false;
const effectivelyMuted = participant.isMuted || isServerMutedUser || isServerDeafenedUser;
const isPermissionMutedUser = spaceId ? permissionMutedUserIds.has(`${spaceId}:${participant.userId}`) : false;
const effectivelyMuted = participant.isMuted || isServerMutedUser || isServerDeafenedUser || isPermissionMutedUser;
const effectivelyDeafened = (isLocal ? isDeafened : participant.isDeafened) || isServerDeafenedUser;
return (
<>
{effectivelyMuted && (
<div className={`w-5 h-5 ${(isServerMutedUser || isServerDeafenedUser) ? 'bg-accent-amber/90' : 'bg-accent-rose/90'} rounded-full flex items-center justify-center`}>
<div className={`w-5 h-5 ${(isServerMutedUser || isServerDeafenedUser || isPermissionMutedUser) ? 'bg-accent-amber/90' : 'bg-accent-rose/90'} rounded-full flex items-center justify-center`}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="white">
<path d="M12 2C10.9 2 10 2.9 10 4V12C10 13.1 10.9 14 12 14C13.1 14 14 13.1 14 12V4C14 2.9 13.1 2 12 2Z" />
<line