Repair connectivity correctly v1

This commit is contained in:
2026-05-17 15:15:14 +02:00
parent e769a6ee4a
commit 9d0a4478b2
18 changed files with 1125 additions and 25 deletions

View File

@@ -195,7 +195,8 @@ export class RoomStateSyncEffects {
UsersActions.userLeft({
userId: signalingMessage.oderId,
serverId: signalingMessage.serverId,
serverIds: remainingServerIds
serverIds: remainingServerIds,
connectedPeerIds: this.webrtc.getConnectedPeers()
})
];

View File

@@ -300,6 +300,98 @@ describe('users reducer - status', () => {
expect(state.entities['u3']?.cameraState?.isEnabled).toBe(false);
expect(state.entities['u3']?.screenShareState?.isSharing).toBe(false);
});
it('preserves voice state on user_left while live peer transport still exists', () => {
const remoteUser = createUser({
id: 'u4',
oderId: 'u4',
displayName: 'Transient Peer',
presenceServerIds: ['s1'],
status: 'online',
voiceState: {
isConnected: true,
isMuted: false,
isDeafened: false,
isSpeaking: true,
roomId: 'voice-1',
serverId: 's1'
},
cameraState: { isEnabled: true }
});
const withUser = usersReducer(baseState, UsersActions.userJoined({ user: remoteUser }));
const state = usersReducer(withUser, UsersActions.userLeft({
userId: 'u4',
serverId: 's1',
connectedPeerIds: ['u4']
}));
expect(state.entities['u4']?.presenceServerIds).toEqual(['s1']);
expect(state.entities['u4']?.isOnline).toBe(true);
expect(state.entities['u4']?.status).toBe('online');
expect(state.entities['u4']?.voiceState?.isConnected).toBe(true);
expect(state.entities['u4']?.voiceState?.roomId).toBe('voice-1');
expect(state.entities['u4']?.cameraState?.isEnabled).toBe(true);
});
it('matches live user_left transports by oderId and peerId', () => {
const remoteUser = createUser({
id: 'db-id-5',
oderId: 'oder-5',
peerId: 'peer-5',
displayName: 'Peer Id Match',
presenceServerIds: ['s1'],
voiceState: {
isConnected: true,
isMuted: false,
isDeafened: false,
isSpeaking: false,
roomId: 'voice-1',
serverId: 's1'
}
});
const withUser = usersReducer(baseState, UsersActions.userJoined({ user: remoteUser }));
const byOderId = usersReducer(withUser, UsersActions.userLeft({
userId: 'db-id-5',
serverId: 's1',
connectedPeerIds: ['oder-5']
}));
const byPeerId = usersReducer(withUser, UsersActions.userLeft({
userId: 'db-id-5',
serverId: 's1',
connectedPeerIds: ['peer-5']
}));
expect(byOderId.entities['db-id-5']?.voiceState?.isConnected).toBe(true);
expect(byPeerId.entities['db-id-5']?.voiceState?.isConnected).toBe(true);
});
it('clears voice state on user_left when the peer transport is gone', () => {
const remoteUser = createUser({
id: 'u6',
oderId: 'u6',
displayName: 'Gone Peer',
presenceServerIds: ['s1'],
voiceState: {
isConnected: true,
isMuted: false,
isDeafened: false,
isSpeaking: true,
roomId: 'voice-1',
serverId: 's1'
}
});
const withUser = usersReducer(baseState, UsersActions.userJoined({ user: remoteUser }));
const state = usersReducer(withUser, UsersActions.userLeft({
userId: 'u6',
serverId: 's1',
connectedPeerIds: []
}));
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?.roomId).toBeUndefined();
});
});
describe('manual status overrides auto idle', () => {

View File

@@ -32,7 +32,7 @@ export const UsersActions = createActionGroup({
'Load Room Users Failure': props<{ error: string }>(),
'User Joined': props<{ user: User }>(),
'User Left': props<{ userId: string; serverId?: string; serverIds?: string[] }>(),
'User Left': props<{ userId: string; serverId?: string; serverIds?: string[]; connectedPeerIds?: string[] }>(),
'Sync Server Presence': props<{ roomId: string; users: User[]; connectedPeerIds?: string[] }>(),
'Update User': props<{ userId: string; updates: Partial<User> }>(),

View File

@@ -227,7 +227,8 @@ function buildAvatarUser(existingUser: User | undefined, incomingUser: {
function buildPresenceRemovalChanges(
user: User,
update: { serverId?: string; serverIds?: readonly string[] }
update: { serverId?: string; serverIds?: readonly string[] },
connectedPeerIds: ReadonlySet<string> = new Set<string>()
): Partial<User> {
const nextPresenceServerIds = update.serverIds !== undefined
? normalizePresenceServerIds(update.serverIds)
@@ -235,6 +236,24 @@ function buildPresenceRemovalChanges(
const isOnline = (nextPresenceServerIds?.length ?? 0) > 0;
const shouldClearLiveState = !isOnline
|| (!!user.voiceState?.serverId && !nextPresenceServerIds?.includes(user.voiceState.serverId));
const hasLiveState = user.voiceState?.isConnected === true
|| user.screenShareState?.isSharing === true
|| user.cameraState?.isEnabled === true;
if (shouldClearLiveState && hasLiveState && hasLivePeerTransport(user, connectedPeerIds)) {
const preservedPresenceServerIds = user.presenceServerIds
?? (user.voiceState?.serverId ? [user.voiceState.serverId] : undefined);
return {
presenceServerIds: preservedPresenceServerIds,
isOnline: true,
status: user.status && user.status !== 'offline' && user.status !== 'disconnected' ? user.status : 'online',
voiceState: user.voiceState,
screenShareState: user.screenShareState,
cameraState: user.cameraState,
gameActivity: user.gameActivity
};
}
return {
presenceServerIds: nextPresenceServerIds,
@@ -390,7 +409,7 @@ export const usersReducer = createReducer(
? usersAdapter.updateMany(stalePresenceUpdates, nextState)
: nextState;
}),
on(UsersActions.userLeft, (state, { userId, serverId, serverIds }) => {
on(UsersActions.userLeft, (state, { userId, serverId, serverIds, connectedPeerIds }) => {
const existingUser = state.entities[userId];
if (!existingUser) {
@@ -409,7 +428,7 @@ export const usersReducer = createReducer(
changes: buildPresenceRemovalChanges(existingUser, {
serverId,
serverIds
})
}, new Set(connectedPeerIds ?? []))
},
state
);