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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user