feat: Add incoming call modal
This commit is contained in:
@@ -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" />
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user