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:
@@ -153,6 +153,7 @@ export function useLiveKit() {
|
||||
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 inputVolume = useVoiceStore((s) => s.inputVolume);
|
||||
const inputDeviceId = useVoiceStore((s) => s.inputDeviceId);
|
||||
const echoCancellation = useVoiceStore((s) => s.echoCancellation);
|
||||
@@ -212,7 +213,7 @@ export function useLiveKit() {
|
||||
const localMyId = cvId ? getMyUserIdForOrigin(localOrigin) : undefined;
|
||||
const localSpaceId = cvId ? useSpaceStore.getState().channelToSpaceMap.get(cvId) : null;
|
||||
const localKey = (localSpaceId && localMyId) ? `${localSpaceId}:${localMyId}` : '';
|
||||
isPartMuted = vs.isMuted || vs.serverMutedUserIds.has(localKey);
|
||||
isPartMuted = vs.isMuted || vs.serverMutedUserIds.has(localKey) || vs.permissionMutedUserIds.has(localKey);
|
||||
isPartDeafened = vs.isDeafened || vs.serverDeafenedUserIds.has(localKey);
|
||||
} else {
|
||||
isPartDeafened = userState?.isDeafened ?? useVoiceStore.getState().deafenedUserIds.has(userId);
|
||||
@@ -270,7 +271,7 @@ export function useLiveKit() {
|
||||
const effMyId = cvId ? getMyUserIdForOrigin(effOrigin) : undefined;
|
||||
const effSpaceId = cvId ? useSpaceStore.getState().channelToSpaceMap.get(cvId) : null;
|
||||
const effKey = (effSpaceId && effMyId) ? `${effSpaceId}:${effMyId}` : '';
|
||||
const effectiveMuted = isMuted || serverMutedUserIds.has(effKey);
|
||||
const effectiveMuted = isMuted || serverMutedUserIds.has(effKey) || permissionMutedUserIds.has(effKey);
|
||||
const effectiveDeafened = isDeafened || serverDeafenedUserIds.has(effKey);
|
||||
|
||||
const syncMic = async () => {
|
||||
@@ -337,7 +338,7 @@ export function useLiveKit() {
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [isMuted, isDeafened, serverMutedUserIds, serverDeafenedUserIds, inputDeviceId, inputVolume, isConnected, echoCancellation, noiseSuppression, autoGainControl, rnnoiseEnabled]);
|
||||
}, [isMuted, isDeafened, serverMutedUserIds, serverDeafenedUserIds, permissionMutedUserIds, inputDeviceId, inputVolume, isConnected, echoCancellation, noiseSuppression, autoGainControl, rnnoiseEnabled]);
|
||||
|
||||
const connect = useCallback(async (channelId: string, isDm?: boolean) => {
|
||||
const storedId = isDm ? `dm-${channelId}` : channelId;
|
||||
@@ -594,7 +595,7 @@ export function useLiveKit() {
|
||||
|
||||
useEffect(() => {
|
||||
updateParticipants();
|
||||
}, [voiceUserStates, isMuted, isDeafened, serverMutedUserIds, serverDeafenedUserIds, updateParticipants]);
|
||||
}, [voiceUserStates, isMuted, isDeafened, serverMutedUserIds, serverDeafenedUserIds, permissionMutedUserIds, updateParticipants]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!room) return;
|
||||
|
||||
@@ -184,6 +184,7 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
||||
|
||||
const nextServerMuted = new Set(vsState.serverMutedUserIds);
|
||||
const nextServerDeafened = new Set(vsState.serverDeafenedUserIds);
|
||||
const nextPermissionMuted = new Set(vsState.permissionMutedUserIds);
|
||||
|
||||
// Clear existing restrictions that belong to spaces on THIS origin
|
||||
// (If a space was deleted while offline, its orphaned restrictions remain, which is harmless)
|
||||
@@ -195,15 +196,20 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
||||
const spaceId = key.split(':')[0];
|
||||
if (spaceId && originSpaceIds.has(spaceId)) nextServerDeafened.delete(key);
|
||||
}
|
||||
for (const key of nextPermissionMuted) {
|
||||
const spaceId = key.split(':')[0];
|
||||
if (spaceId && originSpaceIds.has(spaceId)) nextPermissionMuted.delete(key);
|
||||
}
|
||||
|
||||
if (event.serverVoiceStates) {
|
||||
for (const [uid, state] of Object.entries(event.serverVoiceStates as Record<string, { serverMuted: boolean; serverDeafened: boolean }>)) {
|
||||
for (const [uid, state] of Object.entries(event.serverVoiceStates as Record<string, { serverMuted: boolean; serverDeafened: boolean; permissionMuted?: boolean }>)) {
|
||||
if (state.serverMuted) nextServerMuted.add(uid);
|
||||
if (state.serverDeafened) nextServerDeafened.add(uid);
|
||||
if (state.permissionMuted) nextPermissionMuted.add(uid);
|
||||
}
|
||||
}
|
||||
// Single atomic update
|
||||
useVoiceStore.setState({ serverMutedUserIds: nextServerMuted, serverDeafenedUserIds: nextServerDeafened });
|
||||
useVoiceStore.setState({ serverMutedUserIds: nextServerMuted, serverDeafenedUserIds: nextServerDeafened, permissionMutedUserIds: nextPermissionMuted });
|
||||
|
||||
// With decoupled state, user intent is never force-set by the server.
|
||||
// Effective state (intent || serverEnforcement) is computed reactively
|
||||
@@ -316,6 +322,14 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
||||
break;
|
||||
}
|
||||
|
||||
case 'voice_permission_muted': {
|
||||
const { setPermissionMutedUser } = useVoiceStore.getState();
|
||||
setPermissionMutedUser(event.spaceId, event.userId, event.muted);
|
||||
const myPermMuteId = isHome ? useAuthStore.getState().user?.id : getMyUserIdForOrigin(origin);
|
||||
if (event.userId === myPermMuteId) broadcastVoiceStatus();
|
||||
break;
|
||||
}
|
||||
|
||||
case 'voice_server_deafened': {
|
||||
const { setServerDeafenedUser } = useVoiceStore.getState();
|
||||
setServerDeafenedUser(event.spaceId, event.userId, event.deafened);
|
||||
|
||||
Reference in New Issue
Block a user