fix: Bug - Voice states doesn't get cleared for all users on leave
Broadcast a cleared voice_state when voice-active sockets drop and reset mute/deafen flags on disconnect or reconnect so stale session state cannot leak to other clients. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
92
toju-app/src/app/store/users/user-voice-state.rules.spec.ts
Normal file
92
toju-app/src/app/store/users/user-voice-state.rules.spec.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
import { mergeVoiceStateUpdate } from './user-voice-state.rules';
|
||||
|
||||
describe('mergeVoiceStateUpdate', () => {
|
||||
it('clears mute and deafen flags when a user disconnects from voice', () => {
|
||||
const next = mergeVoiceStateUpdate(
|
||||
{
|
||||
isConnected: true,
|
||||
isMuted: true,
|
||||
isDeafened: true,
|
||||
isSpeaking: true,
|
||||
roomId: 'voice-1',
|
||||
serverId: 'server-1'
|
||||
},
|
||||
{ isConnected: false }
|
||||
);
|
||||
|
||||
expect(next).toMatchObject({
|
||||
isConnected: false,
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
isSpeaking: false
|
||||
});
|
||||
});
|
||||
|
||||
it('does not carry stale mute state into a fresh voice connection', () => {
|
||||
const next = mergeVoiceStateUpdate(
|
||||
{
|
||||
isConnected: false,
|
||||
isMuted: true,
|
||||
isDeafened: true,
|
||||
isSpeaking: false,
|
||||
roomId: undefined,
|
||||
serverId: undefined
|
||||
},
|
||||
{
|
||||
isConnected: true,
|
||||
roomId: 'voice-1',
|
||||
serverId: 'server-1'
|
||||
}
|
||||
);
|
||||
|
||||
expect(next).toMatchObject({
|
||||
isConnected: true,
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
isSpeaking: false,
|
||||
roomId: 'voice-1',
|
||||
serverId: 'server-1'
|
||||
});
|
||||
});
|
||||
|
||||
it('honors an explicit mute flag on reconnect when provided', () => {
|
||||
const next = mergeVoiceStateUpdate(
|
||||
{
|
||||
isConnected: false,
|
||||
isMuted: true,
|
||||
isDeafened: false,
|
||||
isSpeaking: false
|
||||
},
|
||||
{
|
||||
isConnected: true,
|
||||
isMuted: true,
|
||||
roomId: 'voice-1',
|
||||
serverId: 'server-1'
|
||||
}
|
||||
);
|
||||
|
||||
expect(next.isMuted).toBe(true);
|
||||
});
|
||||
|
||||
it('preserves mute toggles during an active voice session', () => {
|
||||
const next = mergeVoiceStateUpdate(
|
||||
{
|
||||
isConnected: true,
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
isSpeaking: false,
|
||||
roomId: 'voice-1',
|
||||
serverId: 'server-1'
|
||||
},
|
||||
{ isMuted: true }
|
||||
);
|
||||
|
||||
expect(next.isMuted).toBe(true);
|
||||
expect(next.isConnected).toBe(true);
|
||||
});
|
||||
});
|
||||
51
toju-app/src/app/store/users/user-voice-state.rules.ts
Normal file
51
toju-app/src/app/store/users/user-voice-state.rules.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { VoiceState } from '../../shared-kernel';
|
||||
|
||||
const DEFAULT_VOICE_STATE: VoiceState = {
|
||||
isConnected: false,
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
isSpeaking: false
|
||||
};
|
||||
|
||||
/** Merge a partial voice-state patch without leaking stale mute/deafen flags across sessions. */
|
||||
export function mergeVoiceStateUpdate(
|
||||
previous: VoiceState | undefined,
|
||||
update: Partial<VoiceState>
|
||||
): VoiceState {
|
||||
const prev = previous ?? DEFAULT_VOICE_STATE;
|
||||
const hasRoomId = Object.prototype.hasOwnProperty.call(update, 'roomId');
|
||||
const hasServerId = Object.prototype.hasOwnProperty.call(update, 'serverId');
|
||||
const hasClientInstanceId = Object.prototype.hasOwnProperty.call(update, 'clientInstanceId');
|
||||
const hasIsMuted = Object.prototype.hasOwnProperty.call(update, 'isMuted');
|
||||
const hasIsDeafened = Object.prototype.hasOwnProperty.call(update, 'isDeafened');
|
||||
const hasIsSpeaking = Object.prototype.hasOwnProperty.call(update, 'isSpeaking');
|
||||
const nextConnected = update.isConnected ?? prev.isConnected;
|
||||
const isDisconnecting = update.isConnected === false;
|
||||
const isReconnecting = nextConnected === true && prev.isConnected === false;
|
||||
const resolveSessionFlag = (
|
||||
key: 'isMuted' | 'isDeafened' | 'isSpeaking',
|
||||
hasExplicit: boolean
|
||||
): boolean => {
|
||||
if (isDisconnecting) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isReconnecting) {
|
||||
return hasExplicit ? update[key] === true : false;
|
||||
}
|
||||
|
||||
return update[key] ?? prev[key];
|
||||
};
|
||||
|
||||
return {
|
||||
isConnected: nextConnected,
|
||||
isMuted: resolveSessionFlag('isMuted', hasIsMuted),
|
||||
isDeafened: resolveSessionFlag('isDeafened', hasIsDeafened),
|
||||
isSpeaking: resolveSessionFlag('isSpeaking', hasIsSpeaking),
|
||||
isMutedByAdmin: update.isMutedByAdmin ?? prev.isMutedByAdmin,
|
||||
volume: update.volume ?? prev.volume,
|
||||
roomId: hasRoomId ? update.roomId : prev.roomId,
|
||||
serverId: hasServerId ? update.serverId : prev.serverId,
|
||||
clientInstanceId: hasClientInstanceId ? update.clientInstanceId : prev.clientInstanceId
|
||||
};
|
||||
}
|
||||
@@ -373,8 +373,8 @@ describe('users reducer - status', () => {
|
||||
presenceServerIds: ['s1'],
|
||||
voiceState: {
|
||||
isConnected: true,
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
isMuted: true,
|
||||
isDeafened: true,
|
||||
isSpeaking: true,
|
||||
roomId: 'voice-1',
|
||||
serverId: 's1'
|
||||
@@ -390,6 +390,8 @@ describe('users reducer - status', () => {
|
||||
expect(state.entities['u6']?.presenceServerIds).toBeUndefined();
|
||||
expect(state.entities['u6']?.isOnline).toBe(false);
|
||||
expect(state.entities['u6']?.voiceState?.isConnected).toBe(false);
|
||||
expect(state.entities['u6']?.voiceState?.isMuted).toBe(false);
|
||||
expect(state.entities['u6']?.voiceState?.isDeafened).toBe(false);
|
||||
expect(state.entities['u6']?.voiceState?.roomId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
UserStatus
|
||||
} from '../../shared-kernel';
|
||||
import { UsersActions } from './users.actions';
|
||||
import { mergeVoiceStateUpdate } from './user-voice-state.rules';
|
||||
|
||||
function normalizePresenceServerIds(serverIds: readonly string[] | undefined): string[] | undefined {
|
||||
if (!Array.isArray(serverIds)) {
|
||||
@@ -148,7 +149,8 @@ function buildDisconnectedVoiceState(user: User): User['voiceState'] {
|
||||
isDeafened: false,
|
||||
isSpeaking: false,
|
||||
roomId: undefined,
|
||||
serverId: undefined
|
||||
serverId: undefined,
|
||||
clientInstanceId: undefined
|
||||
};
|
||||
}
|
||||
|
||||
@@ -510,37 +512,17 @@ export const usersReducer = createReducer(
|
||||
state
|
||||
)
|
||||
),
|
||||
on(UsersActions.updateVoiceState, (state, { userId, voiceState }) => {
|
||||
const prev = state.entities[userId]?.voiceState || {
|
||||
isConnected: false,
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
isSpeaking: false
|
||||
};
|
||||
const hasRoomId = Object.prototype.hasOwnProperty.call(voiceState, 'roomId');
|
||||
const hasServerId = Object.prototype.hasOwnProperty.call(voiceState, 'serverId');
|
||||
const hasClientInstanceId = Object.prototype.hasOwnProperty.call(voiceState, 'clientInstanceId');
|
||||
|
||||
return usersAdapter.updateOne(
|
||||
on(UsersActions.updateVoiceState, (state, { userId, voiceState }) =>
|
||||
usersAdapter.updateOne(
|
||||
{
|
||||
id: userId,
|
||||
changes: {
|
||||
voiceState: {
|
||||
isConnected: voiceState.isConnected ?? prev.isConnected,
|
||||
isMuted: voiceState.isMuted ?? prev.isMuted,
|
||||
isDeafened: voiceState.isDeafened ?? prev.isDeafened,
|
||||
isSpeaking: voiceState.isSpeaking ?? prev.isSpeaking,
|
||||
isMutedByAdmin: voiceState.isMutedByAdmin ?? prev.isMutedByAdmin,
|
||||
volume: voiceState.volume ?? prev.volume,
|
||||
roomId: hasRoomId ? voiceState.roomId : prev.roomId,
|
||||
serverId: hasServerId ? voiceState.serverId : prev.serverId,
|
||||
clientInstanceId: hasClientInstanceId ? voiceState.clientInstanceId : prev.clientInstanceId
|
||||
}
|
||||
voiceState: mergeVoiceStateUpdate(state.entities[userId]?.voiceState, voiceState)
|
||||
}
|
||||
},
|
||||
state
|
||||
);
|
||||
}),
|
||||
)
|
||||
),
|
||||
on(UsersActions.updateScreenShareState, (state, { userId, screenShareState }) => {
|
||||
const prev = state.entities[userId]?.screenShareState || {
|
||||
isSharing: false
|
||||
|
||||
Reference in New Issue
Block a user