feat: Add incoming call modal

This commit is contained in:
2026-05-17 15:26:05 +02:00
parent 9d0a4478b2
commit 8e3ccf4157
8 changed files with 339 additions and 7 deletions

View File

@@ -149,6 +149,7 @@
<app-floating-voice-controls />
}
<app-settings-modal />
<app-incoming-call-modal />
<app-screen-share-source-picker />
<app-native-context-menu />
<app-debug-console [showLauncher]="false" />

View File

@@ -37,6 +37,7 @@ import { UserStatusService } from './core/services/user-status.service';
import { GameActivityService } from './domains/game-activity';
import { PluginBootstrapService } from './domains/plugins';
import { DirectCallService } from './domains/direct-call';
import { IncomingCallModalComponent } from './domains/direct-call/feature/incoming-call-modal/incoming-call-modal.component';
import { ServersRailComponent } from './features/servers/servers-rail/servers-rail.component';
import { TitleBarComponent } from './features/shell/title-bar/title-bar.component';
import { FloatingVoiceControlsComponent } from './domains/voice-session/feature/floating-voice-controls/floating-voice-controls.component';
@@ -64,6 +65,7 @@ import {
ServersRailComponent,
TitleBarComponent,
FloatingVoiceControlsComponent,
IncomingCallModalComponent,
SettingsModalComponent,
DebugConsoleComponent,
ScreenShareSourcePickerComponent,

View File

@@ -6,7 +6,7 @@ Direct calls coordinate private voice sessions started from people cards, direct
1. `DirectCallService.startCall()` creates or reuses the direct-message conversation for a peer, while `startConversationCall()` starts from an existing one-to-one or group conversation. Both paths reuse a live call for the same peer or group before creating a new session.
2. The caller joins a call-scoped voice session and sends a `direct-call` ring event through `PeerDeliveryService`. Joining a direct call first leaves any other joined call or server voice channel.
3. The recipient stores the incoming session, loops `assets/audio/call.wav`, and shows a desktop notification when permission allows. The ring stops when the recipient joins, leaves, or the call ends.
3. The recipient stores the incoming session, loops `assets/audio/call.wav`, shows an in-app answer/decline modal, and shows a desktop notification when permission allows. If the recipient is set to Do Not Disturb (`status: "busy"`), the session is stored silently without call audio, the in-app modal, or a desktop notification. The ring stops when the recipient joins, declines, leaves, or the call ends.
4. Opening `/call/:callId` shows the private call surface with portraits, voice indicators, media controls, screen/camera tiles, add-user control, and a narrow DM chat panel.
5. If a third participant is invited, the call creates a fresh empty group conversation and switches the call chat panel to it. Existing one-to-one messages stay in the original PM and are not copied into the group chat.
6. Starting a call from a group chat uses the group conversation id as the call id and rings every other participant.

View File

@@ -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();
});

View File

@@ -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;

View File

@@ -0,0 +1,73 @@
@if (session()) {
<div class="fixed inset-0 z-[120] bg-black/60 backdrop-blur-sm"></div>
<div class="pointer-events-none fixed inset-0 z-[121] flex items-center justify-center p-4">
<section
class="pointer-events-auto w-full max-w-sm rounded-lg border border-border bg-card shadow-2xl"
role="dialog"
aria-modal="true"
aria-labelledby="incoming-call-title"
>
<div class="flex flex-col items-center px-6 pb-6 pt-7 text-center">
<div class="relative">
@if (caller(); as callerUser) {
<app-user-avatar
[avatarUrl]="callerUser.avatarUrl"
[name]="callerUser.displayName || callerUser.username"
[showStatusBadge]="true"
[status]="callerUser.status"
size="xl"
/>
} @else {
<div class="grid h-16 w-16 place-items-center rounded-full bg-secondary text-xl font-semibold text-secondary-foreground">
{{ callerName().charAt(0).toUpperCase() }}
</div>
}
<div class="absolute -bottom-1 -right-1 grid h-7 w-7 place-items-center rounded-full border border-card bg-green-600 text-white shadow-lg">
<ng-icon
name="lucidePhone"
class="h-3.5 w-3.5"
/>
</div>
</div>
<p class="mt-5 text-[11px] font-semibold uppercase tracking-[0.18em] text-primary">Incoming call</p>
<h2
id="incoming-call-title"
class="mt-2 text-xl font-semibold text-foreground"
>
{{ callerName() }} is calling
</h2>
<p class="mt-1 text-sm text-muted-foreground">{{ callKindLabel() }}</p>
<div class="mt-6 grid w-full grid-cols-2 gap-3">
<button
type="button"
class="inline-flex min-h-11 items-center justify-center gap-2 rounded-lg border border-border bg-secondary px-4 text-sm font-semibold text-foreground transition-colors hover:bg-secondary/80"
(click)="decline()"
>
<ng-icon
name="lucidePhoneOff"
class="h-4 w-4"
/>
Decline
</button>
<button
type="button"
class="inline-flex min-h-11 items-center justify-center gap-2 rounded-lg bg-green-600 px-4 text-sm font-semibold text-white transition-colors hover:bg-green-500 disabled:cursor-not-allowed disabled:opacity-70"
[disabled]="answering()"
(click)="answer()"
>
<ng-icon
name="lucidePhone"
class="h-4 w-4"
/>
Answer
</button>
</div>
</div>
</section>
</div>
}

View File

@@ -0,0 +1,109 @@
import {
Component,
HostListener,
computed,
inject,
signal
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucidePhone, lucidePhoneOff } from '@ng-icons/lucide';
import { UserAvatarComponent } from '../../../../shared';
import { selectCurrentUser } from '../../../../store/users/users.selectors';
import { User } from '../../../../shared-kernel';
import { DirectCallService } from '../../application/services/direct-call.service';
import { DirectCallSession, participantToUser } from '../../domain/models/direct-call.model';
@Component({
selector: 'app-incoming-call-modal',
standalone: true,
imports: [
CommonModule,
NgIcon,
UserAvatarComponent
],
viewProviders: [
provideIcons({
lucidePhone,
lucidePhoneOff
})
],
templateUrl: './incoming-call-modal.component.html'
})
export class IncomingCallModalComponent {
readonly calls = inject(DirectCallService);
readonly currentUser = inject(Store).selectSignal(selectCurrentUser);
readonly session = this.calls.incomingCall;
readonly answering = signal(false);
readonly caller = computed(() => {
const session = this.session();
if (!session) {
return null;
}
const callerId = this.callerIdFor(session);
const participant = callerId ? session.participants[callerId]?.profile : null;
return (callerId ? this.calls.userForParticipant(callerId) : null)
?? (participant ? participantToUser(participant) : null);
});
readonly callerName = computed(() => this.caller()?.displayName || 'Someone');
readonly callKindLabel = computed(() => {
const participantCount = this.session()?.participantIds.length ?? 0;
return participantCount > 2 ? `${participantCount} person call` : 'Direct call';
});
@HostListener('document:keydown.escape')
onEscape(): void {
this.decline();
}
async answer(): Promise<void> {
const session = this.session();
if (!session || this.answering()) {
return;
}
this.answering.set(true);
try {
await this.calls.answerIncomingCall(session.callId);
} finally {
this.answering.set(false);
}
}
decline(): void {
const session = this.session();
if (!session) {
return;
}
this.calls.declineIncomingCall(session.callId);
}
private callerIdFor(session: DirectCallSession): string | null {
const currentUserId = this.currentUserKey();
if (session.initiatorId && session.initiatorId !== currentUserId) {
return session.initiatorId;
}
return session.participantIds.find((participantId) => participantId !== currentUserId) ?? null;
}
private currentUserKey(): string | null {
const user = this.currentUser();
return user ? this.userKey(user) : null;
}
private userKey(user: User): string {
return user.oderId || user.id;
}
}

View File

@@ -1,2 +1,3 @@
export * from './application/services/direct-call.service';
export * from './domain/models/direct-call.model';
export * from './feature/incoming-call-modal/incoming-call-modal.component';