fix: Fix multiple bugs with new authentication flow

This commit is contained in:
2026-06-07 15:04:21 +02:00
parent 9fc26b1ccf
commit 83456c018c
137 changed files with 4710 additions and 281 deletions

View File

@@ -82,6 +82,16 @@ When a voice session is active and the user navigates away from the voice-connec
Joining a new voice target is exclusive: entering another voice channel or private call first disconnects the current call/channel, clears local voice state, and broadcasts the leave for the previous target. Users never need to manually leave one voice target before joining another.
## Multi-device voice (Discord-style)
Each install has a stable `clientInstanceId` (`ClientInstanceService`). `VoiceState.clientInstanceId` records which device currently owns the microphone/WebRTC session for that user.
- **Local voice owner** — this device's `clientInstanceId` matches `voiceState.clientInstanceId`; mic, heartbeat, and WebRTC transmit run normally.
- **Passive client** — another device owns voice; this client still receives chat/presence and shows grayed "in voice on another device" UI in the room sidebar and private-call cards.
- **Takeover** — clicking **Join** on a passive client sends `voice_client_takeover` through signaling; the active device releases voice via `VoiceClientTakeoverService`, then the passive client completes a normal join.
Rules live in `domain/logic/client-voice-session.rules.ts`.
Remote voice playback is scoped to the active voice channel, not the whole server. Users stay connected to the shared peer mesh for text, presence, and screen-share control, but voice transport and playback only stay active for peers whose `voiceState.roomId` and `voiceState.serverId` match the local user's current voice session.
Owners and admins can also move connected users between voice channels from the room sidebar by dragging a user onto a different voice channel. The moved client updates its local heartbeat and voice-session metadata to the new channel, so routing, floating controls, and occupancy stay in sync after the move.

View File

@@ -0,0 +1,68 @@
import { Injectable, inject } from '@angular/core';
import { Store } from '@ngrx/store';
import type { User } from '../../../../shared-kernel';
import { VoiceConnectionFacade } from '../../../voice-connection';
import { ClientInstanceService } from '../../../../core/platform/client-instance.service';
import { UsersActions } from '../../../../store/users/users.actions';
import { isLocalVoiceOwner } from '../../domain/logic/client-voice-session.rules';
import { VoiceSessionFacade } from '../facades/voice-session.facade';
@Injectable({ providedIn: 'root' })
export class VoiceClientTakeoverService {
private readonly store = inject(Store);
private readonly voiceConnection = inject(VoiceConnectionFacade);
private readonly voiceSession = inject(VoiceSessionFacade);
private readonly clientInstance = inject(ClientInstanceService);
releaseLocalVoiceForTakeover(currentUser: User | null): void {
if (!currentUser?.voiceState?.isConnected) {
return;
}
if (!isLocalVoiceOwner(currentUser.voiceState, this.clientInstance.getClientInstanceId())) {
return;
}
const previousVoiceState = currentUser.voiceState;
this.voiceConnection.stopVoiceHeartbeat();
this.voiceConnection.disableVoice();
this.store.dispatch(
UsersActions.updateVoiceState({
userId: currentUser.id,
voiceState: {
isConnected: false,
isMuted: false,
isDeafened: false,
roomId: undefined,
serverId: undefined,
clientInstanceId: undefined
}
})
);
this.store.dispatch(
UsersActions.updateCameraState({
userId: currentUser.id,
cameraState: { isEnabled: false }
})
);
this.voiceConnection.broadcastMessage({
type: 'voice-state',
oderId: currentUser.oderId || currentUser.id,
displayName: currentUser.displayName || 'User',
voiceState: {
isConnected: false,
isMuted: false,
isDeafened: false,
roomId: previousVoiceState.roomId,
serverId: previousVoiceState.serverId,
clientInstanceId: undefined
}
});
this.voiceSession.endSession();
}
}

View File

@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest';
import type { VoiceState } from '../../../../shared-kernel';
import {
isLocalVoiceOwner,
isVoiceOnAnotherClient,
shouldTransmitVoice
} from './client-voice-session.rules';
describe('client-voice-session.rules', () => {
const localClientInstanceId = 'device-a';
it('treats the matching client instance as the local voice owner', () => {
const voiceState: VoiceState = {
isConnected: true,
isMuted: false,
isDeafened: false,
isSpeaking: false,
clientInstanceId: localClientInstanceId
};
expect(isLocalVoiceOwner(voiceState, localClientInstanceId)).toBe(true);
expect(isVoiceOnAnotherClient(voiceState, localClientInstanceId)).toBe(false);
expect(shouldTransmitVoice(voiceState, localClientInstanceId)).toBe(true);
});
it('treats a different client instance as passive voice', () => {
const voiceState: VoiceState = {
isConnected: true,
isMuted: false,
isDeafened: false,
isSpeaking: false,
clientInstanceId: 'device-b'
};
expect(isLocalVoiceOwner(voiceState, localClientInstanceId)).toBe(false);
expect(isVoiceOnAnotherClient(voiceState, localClientInstanceId)).toBe(true);
expect(shouldTransmitVoice(voiceState, localClientInstanceId)).toBe(false);
});
it('allows transmission when disconnected', () => {
const voiceState: VoiceState = {
isConnected: false,
isMuted: false,
isDeafened: false,
isSpeaking: false
};
expect(shouldTransmitVoice(voiceState, localClientInstanceId)).toBe(true);
});
});

View File

@@ -0,0 +1,34 @@
import type { VoiceState } from '../../../../shared-kernel';
export function isLocalVoiceOwner(
voiceState: Pick<VoiceState, 'isConnected' | 'clientInstanceId'> | null | undefined,
clientInstanceId: string
): boolean {
return !!voiceState?.isConnected
&& !!voiceState.clientInstanceId
&& voiceState.clientInstanceId === clientInstanceId;
}
export function isVoiceOnAnotherClient(
voiceState: Pick<VoiceState, 'isConnected' | 'clientInstanceId'> | null | undefined,
clientInstanceId: string
): boolean {
return !!voiceState?.isConnected
&& !!voiceState.clientInstanceId
&& voiceState.clientInstanceId !== clientInstanceId;
}
export function shouldTransmitVoice(
voiceState: Pick<VoiceState, 'isConnected' | 'clientInstanceId'> | null | undefined,
clientInstanceId: string
): boolean {
if (!voiceState?.isConnected) {
return true;
}
if (!voiceState.clientInstanceId) {
return true;
}
return voiceState.clientInstanceId === clientInstanceId;
}

View File

@@ -1,5 +1,7 @@
export * from './application/facades/voice-session.facade';
export * from './application/services/voice-client-takeover.service';
export * from './application/services/voice-workspace.service';
export * from './domain/logic/client-voice-session.rules';
export * from './domain/models/voice-session.model';
export * from './infrastructure/util/voice-settings-storage.util';