chore: Fix app

This commit is contained in:
2026-07-14 00:41:05 +02:00
parent 3e090933fd
commit edc4d935d8
98 changed files with 2878 additions and 155 deletions
@@ -477,6 +477,56 @@ describe('DirectCallService', () => {
expect(context.voiceSession.endSession).toHaveBeenCalled();
});
it('surfaces a join error when the microphone permission is denied instead of failing silently', async () => {
const context = createServiceContext({ currentUser: alice, allUsers: [alice, bob] });
const session = createSession('connected', false);
session.participants.bob.joined = true;
(context.service as DirectCallService & { upsertSession: (nextSession: DirectCallSession) => void }).upsertSession(session);
context.mobileMedia.ensureVoiceCapturePermissions.mockResolvedValue(false);
await withStubbedGetUserMedia(vi.fn(async () => new FakeMediaStream()), async () => {
await context.service.joinCall(session.callId);
});
expect(context.service.joinError()).not.toBeNull();
expect(context.voice.setLocalStream).not.toHaveBeenCalled();
expect(context.service.sessionById(session.callId)?.participants.alice.joined).not.toBe(true);
});
it('surfaces a join error when getUserMedia rejects', async () => {
const context = createServiceContext({ currentUser: alice, allUsers: [alice, bob] });
const session = createSession('connected', false);
session.participants.bob.joined = true;
(context.service as DirectCallService & { upsertSession: (nextSession: DirectCallSession) => void }).upsertSession(session);
await withStubbedGetUserMedia(vi.fn(async () => {
throw new Error('NotReadableError');
}), async () => {
await context.service.joinCall(session.callId);
});
expect(context.service.joinError()).not.toBeNull();
expect(context.voice.setLocalStream).not.toHaveBeenCalled();
});
it('clears the join error on a successful join', async () => {
const context = createServiceContext({ currentUser: alice, allUsers: [alice, bob] });
const session = createSession('connected', false);
session.participants.bob.joined = true;
(context.service as DirectCallService & { upsertSession: (nextSession: DirectCallSession) => void }).upsertSession(session);
await withStubbedGetUserMedia(vi.fn(async () => new FakeMediaStream()), async () => {
await context.service.joinCall(session.callId);
});
expect(context.service.joinError()).toBeNull();
expect(context.voice.setLocalStream).toHaveBeenCalled();
expect(context.service.sessionById(session.callId)?.participants.alice.joined).toBe(true);
});
it('starts group calls by keeping the rail-visible call session and ringing every other participant', async () => {
const context = createServiceContext({ currentUser: alice, allUsers: [
alice,
@@ -643,6 +693,10 @@ function createServiceContext(options: ServiceContextOptions): ServiceContext {
const voiceSession = {
endSession: vi.fn()
};
const mobileMedia = {
ensureVoiceCapturePermissions: vi.fn(async () => true),
setSpeakerphoneEnabled: vi.fn(async () => undefined)
};
const credentialStore = {
listValidCredentials: vi.fn(() => (options.selfActorIds ?? []).map((userId) => ({
serverUrl: `https://signal.example/${userId}`,
@@ -732,10 +786,7 @@ function createServiceContext(options: ServiceContextOptions): ServiceContext {
},
{
provide: MobileMediaService,
useValue: {
ensureVoiceCapturePermissions: vi.fn(async () => true),
setSpeakerphoneEnabled: vi.fn(async () => undefined)
}
useValue: mobileMedia
},
{
provide: RealtimeSessionFacade,
@@ -761,6 +812,7 @@ function createServiceContext(options: ServiceContextOptions): ServiceContext {
directCallEvents,
directMessages,
effectScheduler,
mobileMedia,
router,
service: runInInjectionContext(injector, () => new DirectCallService()),
voice,
@@ -768,6 +820,42 @@ function createServiceContext(options: ServiceContextOptions): ServiceContext {
};
}
class FakeMediaStream {
getTracks(): unknown[] {
return [];
}
}
/** Temporarily provide `navigator.mediaDevices.getUserMedia` for the join path under Node. */
async function withStubbedGetUserMedia(
getUserMedia: () => Promise<unknown>,
run: () => Promise<void>
): Promise<void> {
const globalWithNavigator = globalThis as { navigator?: { mediaDevices?: unknown } };
const originalNavigator = globalWithNavigator.navigator;
Object.defineProperty(globalThis, 'navigator', {
configurable: true,
value: {
...(originalNavigator ?? {}),
mediaDevices: { getUserMedia }
}
});
try {
await run();
} finally {
if (originalNavigator === undefined) {
delete globalWithNavigator.navigator;
} else {
Object.defineProperty(globalThis, 'navigator', {
configurable: true,
value: originalNavigator
});
}
}
}
function createCallEvent(action: 'leave' | 'ring', sender: User, participantIds: string[]): ChatEvent {
return {
type: 'direct-call',
@@ -91,6 +91,8 @@ export class DirectCallService {
&& this.hasConnectedParticipant(session)) ?? null;
});
readonly currentSession = signal<DirectCallSession | null>(null);
/** User-facing reason the last joinCall attempt failed; null after a successful join. */
readonly joinError = signal<string | null>(null);
readonly hasActiveCall = computed(() => this.visibleActiveSessions().length > 0);
readonly mobileOverlaySession = computed(() => {
const callId = this.mobileOverlayCallId();
@@ -333,6 +335,7 @@ export class DirectCallService {
return;
}
this.joinError.set(null);
this.leaveOtherJoinedCalls(callId);
this.leaveCurrentVoiceTargetForCall(callId);
this.audio.stop(AppSound.Call);
@@ -344,22 +347,36 @@ export class DirectCallService {
const ok = await this.voice.ensureSignalingConnected();
if (!ok || !navigator.mediaDevices?.getUserMedia) {
if (!ok) {
this.joinError.set(this.i18n.instant('call.errors.signalingUnavailable'));
return;
}
if (!navigator.mediaDevices?.getUserMedia) {
this.joinError.set(this.i18n.instant('call.errors.captureUnsupported'));
return;
}
const voicePermissionsGranted = await this.mobileMedia.ensureVoiceCapturePermissions();
if (!voicePermissionsGranted) {
this.joinError.set(this.i18n.instant('call.errors.microphonePermissionDenied'));
return;
}
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: false
}
});
let stream: MediaStream;
try {
stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: false
}
});
} catch {
this.joinError.set(this.i18n.instant('call.errors.microphoneUnavailable'));
return;
}
await this.voice.setLocalStream(stream);
this.voiceActivity.trackLocalMic(meId, stream);