feat: Add incoming call modal
This commit is contained in:
+69
-4
@@ -66,11 +66,59 @@ describe('DirectCallService', () => {
|
||||
|
||||
await vi.waitFor(() => expect(context.service.visibleActiveSessions()).toHaveLength(1));
|
||||
await vi.waitFor(() => expect(context.audio.playLoop).toHaveBeenCalledWith(AppSound.Call));
|
||||
expect(context.service.incomingCall()?.callId).toBe('dm-alice-bob');
|
||||
|
||||
context.directCallEvents.next(createCallEvent('leave', alice, ['alice', 'bob']));
|
||||
|
||||
await vi.waitFor(() => expect(context.service.visibleActiveSessions()).toHaveLength(0));
|
||||
expect(context.audio.stop).toHaveBeenCalledWith(AppSound.Call);
|
||||
expect(context.service.incomingCall()).toBeNull();
|
||||
});
|
||||
|
||||
it('suppresses incoming call audio and modal state while do not disturb is active', async () => {
|
||||
const busyBob = { ...bob, status: 'busy' as const };
|
||||
const context = createServiceContext({ currentUser: busyBob, allUsers: [alice, busyBob] });
|
||||
|
||||
context.directCallEvents.next(createCallEvent('ring', alice, ['alice', 'bob']));
|
||||
|
||||
await vi.waitFor(() => expect(context.service.sessionById('dm-alice-bob')).not.toBeNull());
|
||||
expect(context.audio.playLoop).not.toHaveBeenCalled();
|
||||
await vi.waitFor(() => expect(context.audio.stop).toHaveBeenCalledWith(AppSound.Call));
|
||||
expect(context.service.incomingCall()).toBeNull();
|
||||
});
|
||||
|
||||
it('answers an incoming call from the modal action', async () => {
|
||||
const context = createServiceContext({ currentUser: bob, allUsers: [alice, bob] });
|
||||
|
||||
context.directCallEvents.next(createCallEvent('ring', alice, ['alice', 'bob']));
|
||||
|
||||
await vi.waitFor(() => expect(context.service.incomingCall()?.callId).toBe('dm-alice-bob'));
|
||||
await context.service.answerIncomingCall('dm-alice-bob');
|
||||
|
||||
expect(context.audio.stop).toHaveBeenCalledWith(AppSound.Call);
|
||||
expect(context.router.navigate).toHaveBeenCalledWith(['/call', 'dm-alice-bob']);
|
||||
expect(context.service.incomingCall()).toBeNull();
|
||||
});
|
||||
|
||||
it('declines an incoming call from the modal action', async () => {
|
||||
const context = createServiceContext({ currentUser: bob, allUsers: [alice, bob] });
|
||||
|
||||
context.directCallEvents.next(createCallEvent('ring', alice, ['alice', 'bob']));
|
||||
|
||||
await vi.waitFor(() => expect(context.service.incomingCall()?.callId).toBe('dm-alice-bob'));
|
||||
context.service.declineIncomingCall('dm-alice-bob');
|
||||
|
||||
expect(context.audio.stop).toHaveBeenCalledWith(AppSound.Call);
|
||||
expect(context.delivery.sendCallEvent).toHaveBeenCalledWith('alice', expect.objectContaining({
|
||||
directCall: expect.objectContaining({
|
||||
action: 'leave',
|
||||
callId: 'dm-alice-bob'
|
||||
}),
|
||||
type: 'direct-call'
|
||||
}));
|
||||
|
||||
expect(context.service.sessionById('dm-alice-bob')?.status).toBe('ended');
|
||||
expect(context.service.incomingCall()).toBeNull();
|
||||
});
|
||||
|
||||
it('rejoins an existing direct call instead of ringing a duplicate after leaving locally', async () => {
|
||||
@@ -91,9 +139,21 @@ describe('DirectCallService', () => {
|
||||
});
|
||||
|
||||
it('reuses an existing group call by conversation id instead of creating a duplicate call', async () => {
|
||||
const context = createServiceContext({ currentUser: alice, allUsers: [alice, bob, charlie] });
|
||||
const session = createGroupSession('dm-original-call', 'dm-group-live', [alice, bob, charlie]);
|
||||
const conversation = createGroupConversation('dm-group-live', [alice, bob, charlie]);
|
||||
const context = createServiceContext({ currentUser: alice, allUsers: [
|
||||
alice,
|
||||
bob,
|
||||
charlie
|
||||
] });
|
||||
const session = createGroupSession('dm-original-call', 'dm-group-live', [
|
||||
alice,
|
||||
bob,
|
||||
charlie
|
||||
]);
|
||||
const conversation = createGroupConversation('dm-group-live', [
|
||||
alice,
|
||||
bob,
|
||||
charlie
|
||||
]);
|
||||
|
||||
session.participants.alice.joined = false;
|
||||
session.participants.bob.joined = true;
|
||||
@@ -110,7 +170,11 @@ describe('DirectCallService', () => {
|
||||
});
|
||||
|
||||
it('leaves a joined call before joining a different call', async () => {
|
||||
const context = createServiceContext({ currentUser: alice, allUsers: [alice, bob, charlie] });
|
||||
const context = createServiceContext({ currentUser: alice, allUsers: [
|
||||
alice,
|
||||
bob,
|
||||
charlie
|
||||
] });
|
||||
const firstSession = createSession('connected', true);
|
||||
const nextSession = createDirectSession('dm-alice-charlie', alice, charlie, 'connected', false);
|
||||
|
||||
@@ -158,6 +222,7 @@ describe('DirectCallService', () => {
|
||||
serverId: 'server-1'
|
||||
})
|
||||
}));
|
||||
|
||||
expect(context.voiceSession.endSession).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -45,6 +45,24 @@ export class DirectCallService {
|
||||
readonly sessions = computed(() => this.sessionsSignal());
|
||||
readonly activeSessions = computed(() => this.sessions().filter((session) => session.status !== 'ended'));
|
||||
readonly visibleActiveSessions = computed(() => this.activeSessions().filter((session) => this.hasOngoingActivity(session)));
|
||||
readonly incomingCall = computed<DirectCallSession | null>(() => {
|
||||
if (this.isDoNotDisturb()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const meId = this.currentUserId();
|
||||
|
||||
if (!meId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [...this.activeSessions()]
|
||||
.sort((left, right) => right.createdAt - left.createdAt)
|
||||
.find((session) => session.status === 'ringing'
|
||||
&& this.currentSession()?.callId !== session.callId
|
||||
&& !session.participants[meId]?.joined
|
||||
&& this.hasConnectedParticipant(session)) ?? null;
|
||||
});
|
||||
readonly currentSession = signal<DirectCallSession | null>(null);
|
||||
readonly hasActiveCall = computed(() => this.visibleActiveSessions().length > 0);
|
||||
|
||||
@@ -66,6 +84,14 @@ export class DirectCallService {
|
||||
|
||||
this.voice.syncOutgoingVoiceRouting(peerIds);
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
if (this.incomingCall() && !this.isDoNotDisturb()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.audio.stop(AppSound.Call);
|
||||
});
|
||||
}
|
||||
|
||||
sessionById(callId: string | null | undefined): DirectCallSession | null {
|
||||
@@ -160,6 +186,50 @@ export class DirectCallService {
|
||||
this.currentSession.set(session);
|
||||
}
|
||||
|
||||
async answerIncomingCall(callId: string): Promise<void> {
|
||||
const session = this.sessionById(callId);
|
||||
|
||||
if (!session || session.status === 'ended') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.audio.stop(AppSound.Call);
|
||||
this.currentSession.set(session);
|
||||
await this.joinCall(callId);
|
||||
await this.router.navigate(['/call', callId]);
|
||||
}
|
||||
|
||||
declineIncomingCall(callId: string): void {
|
||||
const session = this.sessionById(callId);
|
||||
|
||||
if (!session || session.status === 'ended') {
|
||||
return;
|
||||
}
|
||||
|
||||
const meId = this.currentUserId();
|
||||
const nextSession = meId
|
||||
? {
|
||||
...this.markParticipantJoined(session, meId, false, 'ended'),
|
||||
status: 'ended' as const
|
||||
}
|
||||
: {
|
||||
...session,
|
||||
status: 'ended' as const
|
||||
};
|
||||
|
||||
this.audio.stop(AppSound.Call);
|
||||
|
||||
if (meId) {
|
||||
this.broadcastCallEvent('leave', session);
|
||||
}
|
||||
|
||||
this.upsertSession(nextSession);
|
||||
|
||||
if (this.currentSession()?.callId === callId) {
|
||||
this.currentSession.set(null);
|
||||
}
|
||||
}
|
||||
|
||||
async joinCall(callId: string, notifyPeers = true): Promise<void> {
|
||||
const session = this.sessionById(callId);
|
||||
const me = this.requireCurrentUser();
|
||||
@@ -315,13 +385,16 @@ export class DirectCallService {
|
||||
if (payload.action === 'ring') {
|
||||
await this.ensureCallConversation(session);
|
||||
|
||||
if (session.status !== 'connected') {
|
||||
if (this.shouldAlertIncomingCall(session)) {
|
||||
this.audio.playLoop(AppSound.Call);
|
||||
} else {
|
||||
this.audio.stop(AppSound.Call);
|
||||
}
|
||||
|
||||
await this.showIncomingNotification(payload.sender.displayName, payload.callId);
|
||||
if (this.shouldAlertIncomingCall(session)) {
|
||||
await this.showIncomingNotification(payload.sender.displayName, payload.callId);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -745,6 +818,14 @@ export class DirectCallService {
|
||||
}));
|
||||
}
|
||||
|
||||
private shouldAlertIncomingCall(session: DirectCallSession): boolean {
|
||||
return session.status !== 'connected' && !this.isDoNotDisturb();
|
||||
}
|
||||
|
||||
private isDoNotDisturb(): boolean {
|
||||
return this.currentUser()?.status === 'busy';
|
||||
}
|
||||
|
||||
private async showIncomingNotification(displayName: string, callId: string): Promise<void> {
|
||||
if (typeof Notification === 'undefined') {
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user