fix: Fix multiple bugs with new authentication flow
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user