fix: server-deafen now shows amber mute icon across all UI surfaces

When a moderator server-deafens a user, the implied mute is server-imposed
and should display amber (not red) everywhere. Updated VoiceControlBar,
ChannelSidebar, VoiceChannel, and VoiceUser to check isServerDeafened
alongside isServerMuted for amber color and cursor-not-allowed state.

Also includes smart mute/deafen toggle logic (Discord-style coupling),
server-side enforcement of mute/deafen bypass, and cleanup of server
voice state on user departure.
This commit is contained in:
Jannis Braun
2026-03-09 17:12:00 +01:00
parent e2c18ad2b0
commit f6cdfe993f
7 changed files with 129 additions and 76 deletions
@@ -40,34 +40,41 @@ export function ChannelSidebar() {
const location = useLocation();
const handleMicToggle = async () => {
if (isServerMuted || isServerDeafened) return; // Blocked by server mute/deafen
if (isServerMuted || isServerDeafened) return;
const wasDeafened = useVoiceStore.getState().isDeafened;
toggleMic();
// Broadcast mute status via WebSocket so non-joined users can see it
const willBeMuted = !isMuted;
const { isCameraOn, isScreenSharing } = useVoiceStore.getState();
// Read fresh state after the smart toggle (may have cleared deafen too)
const { isMuted: m, isDeafened: d, isCameraOn: c, isScreenSharing: ss } = useVoiceStore.getState();
const voiceOrigin = currentVoiceChannelId ? getChannelOrigin(currentVoiceChannelId) : '';
wsSend({ type: 'voice_status', isMuted: willBeMuted, isDeafened, isCameraOn, isScreenSharing }, voiceOrigin);
wsSend({ type: 'voice_status', isMuted: m, isDeafened: d, isCameraOn: c, isScreenSharing: ss }, voiceOrigin);
// If unmuting while deafened cleared deafen, broadcast deafen=false via LiveKit data channel
if (wasDeafened && !d) {
const room = getActiveRoom();
if (room) {
const encoder = new TextEncoder();
room.localParticipant.publishData(
encoder.encode(JSON.stringify({ type: 'deafen', deafened: false })),
{ reliable: true }
).catch(() => {});
}
}
};
const handleDeafenToggle = async () => {
if (isServerDeafened) return; // Blocked by server deafen
if (isServerDeafened) return;
const room = getActiveRoom();
const willDeafen = !isDeafened;
// Update store FIRST so updateParticipants reads correct state when LiveKit events fire
toggleDeafen();
if (willDeafen && !isMuted) toggleMic();
if (!willDeafen && isMuted) toggleMic();
// Broadcast status via WebSocket so non-joined users can see it
const willBeMuted = willDeafen ? true : false;
const { isCameraOn, isScreenSharing } = useVoiceStore.getState();
// Read fresh state — smart toggle handles mute coupling
const { isMuted: m, isDeafened: d, isCameraOn: c, isScreenSharing: ss } = useVoiceStore.getState();
// If server-muted, enforce muted even after undeafen
const effectiveMuted = isServerMuted ? true : m;
const voiceOrigin2 = currentVoiceChannelId ? getChannelOrigin(currentVoiceChannelId) : '';
wsSend({ type: 'voice_status', isMuted: willBeMuted, isDeafened: willDeafen, isCameraOn, isScreenSharing }, voiceOrigin2);
wsSend({ type: 'voice_status', isMuted: effectiveMuted, isDeafened: d, isCameraOn: c, isScreenSharing: ss }, voiceOrigin2);
if (room) {
try {
// Broadcast deafen state to other participants via LiveKit data channel
const encoder = new TextEncoder();
room.localParticipant.publishData(
encoder.encode(JSON.stringify({ type: 'deafen', deafened: willDeafen })),
encoder.encode(JSON.stringify({ type: 'deafen', deafened: d })),
{ reliable: true }
).catch(() => {});
} catch (err) {
@@ -788,15 +795,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 ? 'text-accent-amber cursor-not-allowed'
: isMuted ? 'text-txt-danger' : 'text-txt-tertiary hover:text-txt-primary'
(isServerMuted || isServerDeafened) ? 'text-accent-amber cursor-not-allowed'
: isMuted || isDeafened ? 'text-txt-danger' : 'text-txt-tertiary hover:text-txt-primary'
}`}
title={isServerMuted ? 'Server Muted' : isMuted ? 'Unmute' : 'Mute'}
title={(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 || isServerMuted) && <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>
{/* Input chevron */}
@@ -111,8 +111,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 && (
<span title="Server Muted">
{(isServerMuted || isServerDeafened) && (
<span title={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" />
@@ -128,7 +128,7 @@ export function VoiceChannel({ channelId, channelName, onClick, locked }: VoiceC
</svg>
</span>
)}
{!isServerMuted && isMuted && (
{!isServerMuted && !isServerDeafened && 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" />
@@ -35,34 +35,45 @@ export function VoiceControlBar() {
const handleMute = React.useCallback(async () => {
if (isServerMuted || isServerDeafened) return;
const wasDeafened = useVoiceStore.getState().isDeafened;
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, isServerMuted, isServerDeafened]);
// Read fresh state after the smart toggle (may have cleared deafen too)
const { isMuted: m, isDeafened: d, isCameraOn: c, isScreenSharing: ss } = useVoiceStore.getState();
wsSend({ type: 'voice_status', isMuted: m, isDeafened: d, isCameraOn: c, isScreenSharing: ss }, voiceOrigin);
// If unmuting while deafened cleared deafen, broadcast deafen=false via LiveKit data channel
if (wasDeafened && !d) {
const room = getActiveRoom();
if (room) {
const encoder = new TextEncoder();
room.localParticipant.publishData(
encoder.encode(JSON.stringify({ type: 'deafen', deafened: false })),
{ reliable: true }
).catch(() => {});
}
}
}, [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
toggleDeafen();
if (willDeafen && !isMuted) toggleMic();
if (!willDeafen && isMuted) toggleMic();
// Broadcast via WebSocket
wsSend({ type: 'voice_status', isMuted: willDeafen, isDeafened: willDeafen, isCameraOn, isScreenSharing }, voiceOrigin);
// Read fresh state — smart toggle handles mute coupling
const { isMuted: m, isDeafened: d, isCameraOn: c, isScreenSharing: ss } = useVoiceStore.getState();
// If server-muted, enforce muted even after undeafen
const effectiveMuted = isServerMuted ? true : m;
wsSend({ type: 'voice_status', isMuted: effectiveMuted, isDeafened: d, isCameraOn: c, isScreenSharing: ss }, voiceOrigin);
if (room) {
try {
// Broadcast deafen state via LiveKit data channel for in-room users
const encoder = new TextEncoder();
room.localParticipant.publishData(
encoder.encode(JSON.stringify({ type: 'deafen', deafened: willDeafen })),
encoder.encode(JSON.stringify({ type: 'deafen', deafened: d })),
{ reliable: true }
).catch(() => {});
} catch (err) {
console.error('[VoiceControlBar] Failed to toggle deafen:', err);
}
}
}, [isDeafened, isMuted, isCameraOn, isScreenSharing, toggleDeafen, toggleMic]);
}, [toggleDeafen, isServerMuted, isServerDeafened, voiceOrigin]);
const handleCamera = async () => {
const room = getActiveRoom();
@@ -155,13 +166,13 @@ export function VoiceControlBar() {
{/* Mute */}
<button
onClick={handleMute}
className={isServerMuted
className={(isServerMuted || isServerDeafened)
? `${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={isServerMuted ? 'Server Muted' : isMuted ? 'Unmute (M)' : 'Mute (M)'}
title={(isServerMuted || isServerDeafened) ? '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" />
+41 -30
View File
@@ -20,6 +20,8 @@ export function VoiceUser({ tile, large }: VoiceUserProps) {
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
const participantVolumes = useVoiceStore((s) => s.participantVolumes);
const isSpeaking = useVoiceStore((s) => s.speakingParticipantIds.has(participant.identity));
const serverMutedUserIds = useVoiceStore((s) => s.serverMutedUserIds);
const serverDeafenedUserIds = useVoiceStore((s) => s.serverDeafenedUserIds);
const [, forceUpdate] = useState(0);
@@ -146,36 +148,45 @@ export function VoiceUser({ tile, large }: VoiceUserProps) {
)}
</div>
<div className="flex items-center gap-1 flex-shrink-0">
{participant.isMuted && (
<div className="w-5 h-5 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
x1="3"
y1="3"
x2="21"
y2="21"
stroke="white"
strokeWidth="2"
/>
</svg>
</div>
)}
{(isLocal ? isDeafened : participant.isDeafened) && (
<div className="w-5 h-5 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 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="white"
strokeWidth="2"
/>
</svg>
</div>
)}
{participant.isMuted && (() => {
const isServerMutedUser = serverMutedUserIds.has(participant.userId);
const isServerDeafenedUser = serverDeafenedUserIds.has(participant.userId);
const badgeBg = (isServerMutedUser || isServerDeafenedUser) ? 'bg-accent-amber/90' : 'bg-accent-rose/90';
return (
<div className={`w-5 h-5 ${badgeBg} 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
x1="3"
y1="3"
x2="21"
y2="21"
stroke="white"
strokeWidth="2"
/>
</svg>
</div>
);
})()}
{(isLocal ? isDeafened : participant.isDeafened) && (() => {
const isServerDeafenedUser = serverDeafenedUserIds.has(participant.userId);
const badgeBg = isServerDeafenedUser ? 'bg-accent-amber/90' : 'bg-accent-rose/90';
return (
<div className={`w-5 h-5 ${badgeBg} rounded-full flex items-center justify-center`}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="white">
<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="white"
strokeWidth="2"
/>
</svg>
</div>
);
})()}
</div>
</div>
</div>