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:
@@ -484,6 +484,7 @@ function handleVoiceLeave(userId: string): void {
|
|||||||
broadcastRoomLeave(left.roomId, left.room, userId);
|
broadcastRoomLeave(left.roomId, left.room, userId);
|
||||||
}
|
}
|
||||||
connectionManager.clearVoiceUserStatus(userId);
|
connectionManager.clearVoiceUserStatus(userId);
|
||||||
|
connectionManager.clearServerVoiceState(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleVoiceStatus(event: Record<string, unknown>, userId: string): void {
|
function handleVoiceStatus(event: Record<string, unknown>, userId: string): void {
|
||||||
@@ -497,15 +498,19 @@ function handleVoiceStatus(event: Record<string, unknown>, userId: string): void
|
|||||||
const userRoom = connectionManager.getUserRoom(userId);
|
const userRoom = connectionManager.getUserRoom(userId);
|
||||||
if (!userRoom) return;
|
if (!userRoom) return;
|
||||||
|
|
||||||
connectionManager.setVoiceUserStatus(userId, isMuted, isDeafened, isCameraOn, isScreenSharing);
|
// Server-side enforcement: prevent clients from bypassing server mute/deafen
|
||||||
|
const effectiveMuted = connectionManager.isServerMuted(userId) ? true : isMuted;
|
||||||
|
const effectiveDeafened = connectionManager.isServerDeafened(userId) ? true : isDeafened;
|
||||||
|
|
||||||
|
connectionManager.setVoiceUserStatus(userId, effectiveMuted, effectiveDeafened, isCameraOn, isScreenSharing);
|
||||||
|
|
||||||
// sendToRoom routes to sendToSpace for space rooms, sendToDmMembers for DM rooms
|
// sendToRoom routes to sendToSpace for space rooms, sendToDmMembers for DM rooms
|
||||||
connectionManager.sendToRoom(userRoom.roomId, {
|
connectionManager.sendToRoom(userRoom.roomId, {
|
||||||
type: 'voice_status_update',
|
type: 'voice_status_update',
|
||||||
userId,
|
userId,
|
||||||
channelId: userRoom.roomId,
|
channelId: userRoom.roomId,
|
||||||
isMuted,
|
isMuted: effectiveMuted,
|
||||||
isDeafened,
|
isDeafened: effectiveDeafened,
|
||||||
isCameraOn,
|
isCameraOn,
|
||||||
isScreenSharing,
|
isScreenSharing,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -40,34 +40,41 @@ export function ChannelSidebar() {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
const handleMicToggle = async () => {
|
const handleMicToggle = async () => {
|
||||||
if (isServerMuted || isServerDeafened) return; // Blocked by server mute/deafen
|
if (isServerMuted || isServerDeafened) return;
|
||||||
|
const wasDeafened = useVoiceStore.getState().isDeafened;
|
||||||
toggleMic();
|
toggleMic();
|
||||||
// Broadcast mute status via WebSocket so non-joined users can see it
|
// Read fresh state after the smart toggle (may have cleared deafen too)
|
||||||
const willBeMuted = !isMuted;
|
const { isMuted: m, isDeafened: d, isCameraOn: c, isScreenSharing: ss } = useVoiceStore.getState();
|
||||||
const { isCameraOn, isScreenSharing } = useVoiceStore.getState();
|
|
||||||
const voiceOrigin = currentVoiceChannelId ? getChannelOrigin(currentVoiceChannelId) : '';
|
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 () => {
|
const handleDeafenToggle = async () => {
|
||||||
if (isServerDeafened) return; // Blocked by server deafen
|
if (isServerDeafened) return;
|
||||||
const room = getActiveRoom();
|
const room = getActiveRoom();
|
||||||
const willDeafen = !isDeafened;
|
|
||||||
// Update store FIRST so updateParticipants reads correct state when LiveKit events fire
|
|
||||||
toggleDeafen();
|
toggleDeafen();
|
||||||
if (willDeafen && !isMuted) toggleMic();
|
// Read fresh state — smart toggle handles mute coupling
|
||||||
if (!willDeafen && isMuted) toggleMic();
|
const { isMuted: m, isDeafened: d, isCameraOn: c, isScreenSharing: ss } = useVoiceStore.getState();
|
||||||
// Broadcast status via WebSocket so non-joined users can see it
|
// If server-muted, enforce muted even after undeafen
|
||||||
const willBeMuted = willDeafen ? true : false;
|
const effectiveMuted = isServerMuted ? true : m;
|
||||||
const { isCameraOn, isScreenSharing } = useVoiceStore.getState();
|
|
||||||
const voiceOrigin2 = currentVoiceChannelId ? getChannelOrigin(currentVoiceChannelId) : '';
|
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) {
|
if (room) {
|
||||||
try {
|
try {
|
||||||
// Broadcast deafen state to other participants via LiveKit data channel
|
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
room.localParticipant.publishData(
|
room.localParticipant.publishData(
|
||||||
encoder.encode(JSON.stringify({ type: 'deafen', deafened: willDeafen })),
|
encoder.encode(JSON.stringify({ type: 'deafen', deafened: d })),
|
||||||
{ reliable: true }
|
{ reliable: true }
|
||||||
).catch(() => {});
|
).catch(() => {});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -788,15 +795,15 @@ function UserAreaPanel({
|
|||||||
<button
|
<button
|
||||||
onClick={onMicToggle}
|
onClick={onMicToggle}
|
||||||
className={`w-8 h-8 flex items-center justify-center hover:bg-interactive-hover rounded-l-[4px] transition-colors ${
|
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'
|
(isServerMuted || isServerDeafened) ? 'text-accent-amber cursor-not-allowed'
|
||||||
: isMuted ? 'text-txt-danger' : 'text-txt-tertiary hover:text-txt-primary'
|
: 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">
|
<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="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" />
|
<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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{/* Input chevron */}
|
{/* 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>
|
<span className="text-[13px] text-txt-secondary truncate flex-1 min-w-0">{displayName}</span>
|
||||||
{/* Status badges */}
|
{/* Status badges */}
|
||||||
<div className="flex items-center gap-1 flex-shrink-0">
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
{isServerMuted && (
|
{(isServerMuted || isServerDeafened) && (
|
||||||
<span title="Server Muted">
|
<span title={isServerMuted ? "Server Muted" : "Muted (Server Deafened)"}>
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" className="text-accent-amber">
|
<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="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" />
|
<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>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{!isServerMuted && isMuted && (
|
{!isServerMuted && !isServerDeafened && isMuted && (
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" className="text-txt-danger">
|
<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="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" />
|
<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 () => {
|
const handleMute = React.useCallback(async () => {
|
||||||
if (isServerMuted || isServerDeafened) return;
|
if (isServerMuted || isServerDeafened) return;
|
||||||
|
const wasDeafened = useVoiceStore.getState().isDeafened;
|
||||||
toggleMic();
|
toggleMic();
|
||||||
// Broadcast via WebSocket so sidebar shows status without joining
|
// Read fresh state after the smart toggle (may have cleared deafen too)
|
||||||
wsSend({ type: 'voice_status', isMuted: !isMuted, isDeafened, isCameraOn, isScreenSharing }, voiceOrigin);
|
const { isMuted: m, isDeafened: d, isCameraOn: c, isScreenSharing: ss } = useVoiceStore.getState();
|
||||||
}, [isMuted, isDeafened, isCameraOn, isScreenSharing, toggleMic, voiceOrigin, isServerMuted, isServerDeafened]);
|
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 () => {
|
const handleDeafen = React.useCallback(async () => {
|
||||||
if (isServerDeafened) return;
|
if (isServerDeafened) return;
|
||||||
const room = getActiveRoom();
|
const room = getActiveRoom();
|
||||||
const willDeafen = !isDeafened;
|
|
||||||
// Update store FIRST so updateParticipants reads correct state
|
|
||||||
toggleDeafen();
|
toggleDeafen();
|
||||||
if (willDeafen && !isMuted) toggleMic();
|
// Read fresh state — smart toggle handles mute coupling
|
||||||
if (!willDeafen && isMuted) toggleMic();
|
const { isMuted: m, isDeafened: d, isCameraOn: c, isScreenSharing: ss } = useVoiceStore.getState();
|
||||||
// Broadcast via WebSocket
|
// If server-muted, enforce muted even after undeafen
|
||||||
wsSend({ type: 'voice_status', isMuted: willDeafen, isDeafened: willDeafen, isCameraOn, isScreenSharing }, voiceOrigin);
|
const effectiveMuted = isServerMuted ? true : m;
|
||||||
|
wsSend({ type: 'voice_status', isMuted: effectiveMuted, isDeafened: d, isCameraOn: c, isScreenSharing: ss }, voiceOrigin);
|
||||||
if (room) {
|
if (room) {
|
||||||
try {
|
try {
|
||||||
// Broadcast deafen state via LiveKit data channel for in-room users
|
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
room.localParticipant.publishData(
|
room.localParticipant.publishData(
|
||||||
encoder.encode(JSON.stringify({ type: 'deafen', deafened: willDeafen })),
|
encoder.encode(JSON.stringify({ type: 'deafen', deafened: d })),
|
||||||
{ reliable: true }
|
{ reliable: true }
|
||||||
).catch(() => {});
|
).catch(() => {});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[VoiceControlBar] Failed to toggle deafen:', err);
|
console.error('[VoiceControlBar] Failed to toggle deafen:', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [isDeafened, isMuted, isCameraOn, isScreenSharing, toggleDeafen, toggleMic]);
|
}, [toggleDeafen, isServerMuted, isServerDeafened, voiceOrigin]);
|
||||||
|
|
||||||
const handleCamera = async () => {
|
const handleCamera = async () => {
|
||||||
const room = getActiveRoom();
|
const room = getActiveRoom();
|
||||||
@@ -155,13 +166,13 @@ export function VoiceControlBar() {
|
|||||||
{/* Mute */}
|
{/* Mute */}
|
||||||
<button
|
<button
|
||||||
onClick={handleMute}
|
onClick={handleMute}
|
||||||
className={isServerMuted
|
className={(isServerMuted || isServerDeafened)
|
||||||
? `${btnBase} bg-accent-amber/20 text-accent-amber cursor-not-allowed`
|
? `${btnBase} bg-accent-amber/20 text-accent-amber cursor-not-allowed`
|
||||||
: isMuted || isDeafened
|
: isMuted || isDeafened
|
||||||
? `${btnBase} bg-accent-rose/20 text-txt-danger hover:bg-accent-rose/30`
|
? `${btnBase} bg-accent-rose/20 text-txt-danger hover:bg-accent-rose/30`
|
||||||
: btnDefault
|
: 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">
|
<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="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" />
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ export function VoiceUser({ tile, large }: VoiceUserProps) {
|
|||||||
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
|
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
|
||||||
const participantVolumes = useVoiceStore((s) => s.participantVolumes);
|
const participantVolumes = useVoiceStore((s) => s.participantVolumes);
|
||||||
const isSpeaking = useVoiceStore((s) => s.speakingParticipantIds.has(participant.identity));
|
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);
|
const [, forceUpdate] = useState(0);
|
||||||
|
|
||||||
@@ -146,36 +148,45 @@ export function VoiceUser({ tile, large }: VoiceUserProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 flex-shrink-0">
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
{participant.isMuted && (
|
{participant.isMuted && (() => {
|
||||||
<div className="w-5 h-5 bg-accent-rose/90 rounded-full flex items-center justify-center">
|
const isServerMutedUser = serverMutedUserIds.has(participant.userId);
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="white">
|
const isServerDeafenedUser = serverDeafenedUserIds.has(participant.userId);
|
||||||
<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" />
|
const badgeBg = (isServerMutedUser || isServerDeafenedUser) ? 'bg-accent-amber/90' : 'bg-accent-rose/90';
|
||||||
<line
|
return (
|
||||||
x1="3"
|
<div className={`w-5 h-5 ${badgeBg} rounded-full flex items-center justify-center`}>
|
||||||
y1="3"
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="white">
|
||||||
x2="21"
|
<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" />
|
||||||
y2="21"
|
<line
|
||||||
stroke="white"
|
x1="3"
|
||||||
strokeWidth="2"
|
y1="3"
|
||||||
/>
|
x2="21"
|
||||||
</svg>
|
y2="21"
|
||||||
</div>
|
stroke="white"
|
||||||
)}
|
strokeWidth="2"
|
||||||
{(isLocal ? isDeafened : participant.isDeafened) && (
|
/>
|
||||||
<div className="w-5 h-5 bg-accent-rose/90 rounded-full flex items-center justify-center">
|
</svg>
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="white">
|
</div>
|
||||||
<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"
|
{(isLocal ? isDeafened : participant.isDeafened) && (() => {
|
||||||
y1="3"
|
const isServerDeafenedUser = serverDeafenedUserIds.has(participant.userId);
|
||||||
x2="21"
|
const badgeBg = isServerDeafenedUser ? 'bg-accent-amber/90' : 'bg-accent-rose/90';
|
||||||
y2="21"
|
return (
|
||||||
stroke="white"
|
<div className={`w-5 h-5 ${badgeBg} rounded-full flex items-center justify-center`}>
|
||||||
strokeWidth="2"
|
<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" />
|
||||||
</svg>
|
<line
|
||||||
</div>
|
x1="3"
|
||||||
)}
|
y1="3"
|
||||||
|
x2="21"
|
||||||
|
y2="21"
|
||||||
|
stroke="white"
|
||||||
|
strokeWidth="2"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -266,6 +266,10 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
|||||||
addVoiceUser(event.channelId, event.userId);
|
addVoiceUser(event.channelId, event.userId);
|
||||||
} else {
|
} else {
|
||||||
removeVoiceUser(event.channelId, event.userId);
|
removeVoiceUser(event.channelId, event.userId);
|
||||||
|
// Clear server mute/deafen state for departed user
|
||||||
|
const { setServerMutedUser, setServerDeafenedUser } = useVoiceStore.getState();
|
||||||
|
setServerMutedUser(event.userId, false);
|
||||||
|
setServerDeafenedUser(event.userId, false);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -292,15 +296,15 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
|||||||
case 'voice_server_deafened': {
|
case 'voice_server_deafened': {
|
||||||
const { setServerDeafenedUser } = useVoiceStore.getState();
|
const { setServerDeafenedUser } = useVoiceStore.getState();
|
||||||
setServerDeafenedUser(event.userId, event.deafened);
|
setServerDeafenedUser(event.userId, event.deafened);
|
||||||
// If the local user was server-deafened, force-deafen and force-mute
|
// If the local user was server-deafened, force-deafen (smart toggle sets both muted+deafened)
|
||||||
const myUid = useAuthStore.getState().user?.id;
|
const myUid = useAuthStore.getState().user?.id;
|
||||||
if (event.userId === myUid && event.deafened) {
|
if (event.userId === myUid && event.deafened) {
|
||||||
const vs = useVoiceStore.getState();
|
const vs = useVoiceStore.getState();
|
||||||
if (!vs.isDeafened) {
|
if (!vs.isDeafened) {
|
||||||
vs.toggleDeafen();
|
vs.toggleDeafen();
|
||||||
if (!vs.isMuted) vs.toggleMic();
|
const fresh = useVoiceStore.getState();
|
||||||
const voiceOrigin = vs.currentVoiceChannelId ? getChannelOrigin(vs.currentVoiceChannelId) : '';
|
const voiceOrigin = fresh.currentVoiceChannelId ? getChannelOrigin(fresh.currentVoiceChannelId) : '';
|
||||||
wsSend({ type: 'voice_status', isMuted: true, isDeafened: true, isCameraOn: vs.isCameraOn, isScreenSharing: vs.isScreenSharing }, voiceOrigin);
|
wsSend({ type: 'voice_status', isMuted: true, isDeafened: true, isCameraOn: fresh.isCameraOn, isScreenSharing: fresh.isScreenSharing }, voiceOrigin);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -238,8 +238,21 @@ export const useVoiceStore = create<VoiceState>()(
|
|||||||
|
|
||||||
setOutputDevice: (deviceId) => set({ outputDeviceId: deviceId }),
|
setOutputDevice: (deviceId) => set({ outputDeviceId: deviceId }),
|
||||||
|
|
||||||
toggleMic: () => set((state) => ({ isMuted: !state.isMuted })),
|
toggleMic: () => set((state) => {
|
||||||
toggleDeafen: () => set((state) => ({ isDeafened: !state.isDeafened })),
|
if (state.isMuted && state.isDeafened) {
|
||||||
|
// Unmuting while deafened → clear both (Discord behavior)
|
||||||
|
return { isMuted: false, isDeafened: false };
|
||||||
|
}
|
||||||
|
return { isMuted: !state.isMuted };
|
||||||
|
}),
|
||||||
|
toggleDeafen: () => set((state) => {
|
||||||
|
if (state.isDeafened) {
|
||||||
|
// Undeafening → clear both
|
||||||
|
return { isMuted: false, isDeafened: false };
|
||||||
|
}
|
||||||
|
// Deafening → set both
|
||||||
|
return { isMuted: true, isDeafened: true };
|
||||||
|
}),
|
||||||
toggleCamera: () => set((state) => ({ isCameraOn: !state.isCameraOn })),
|
toggleCamera: () => set((state) => ({ isCameraOn: !state.isCameraOn })),
|
||||||
toggleScreenShare: () => set((state) => ({ isScreenSharing: !state.isScreenSharing })),
|
toggleScreenShare: () => set((state) => ({ isScreenSharing: !state.isScreenSharing })),
|
||||||
|
|
||||||
@@ -345,6 +358,8 @@ export const useVoiceStore = create<VoiceState>()(
|
|||||||
streamVolumes: new Map(),
|
streamVolumes: new Map(),
|
||||||
streamMutes: new Map(),
|
streamMutes: new Map(),
|
||||||
watchingStreams: new Set(),
|
watchingStreams: new Set(),
|
||||||
|
serverMutedUserIds: new Set(),
|
||||||
|
serverDeafenedUserIds: new Set(),
|
||||||
voiceUsers,
|
voiceUsers,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user