diff --git a/toju-app/src/app/app.html b/toju-app/src/app/app.html index 8a6b633..31fbbc4 100644 --- a/toju-app/src/app/app.html +++ b/toju-app/src/app/app.html @@ -149,6 +149,7 @@ } + diff --git a/toju-app/src/app/app.ts b/toju-app/src/app/app.ts index 9b85988..ce00862 100644 --- a/toju-app/src/app/app.ts +++ b/toju-app/src/app/app.ts @@ -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, diff --git a/toju-app/src/app/domains/direct-call/README.md b/toju-app/src/app/domains/direct-call/README.md index a808adf..890f183 100644 --- a/toju-app/src/app/domains/direct-call/README.md +++ b/toju-app/src/app/domains/direct-call/README.md @@ -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. diff --git a/toju-app/src/app/domains/direct-call/application/services/direct-call.service.spec.ts b/toju-app/src/app/domains/direct-call/application/services/direct-call.service.spec.ts index 4aaebd7..9c7e17f 100644 --- a/toju-app/src/app/domains/direct-call/application/services/direct-call.service.spec.ts +++ b/toju-app/src/app/domains/direct-call/application/services/direct-call.service.spec.ts @@ -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(); }); diff --git a/toju-app/src/app/domains/direct-call/application/services/direct-call.service.ts b/toju-app/src/app/domains/direct-call/application/services/direct-call.service.ts index cbab558..dd5d216 100644 --- a/toju-app/src/app/domains/direct-call/application/services/direct-call.service.ts +++ b/toju-app/src/app/domains/direct-call/application/services/direct-call.service.ts @@ -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(() => { + 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(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 { + 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 { 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 { if (typeof Notification === 'undefined') { return; diff --git a/toju-app/src/app/domains/direct-call/feature/incoming-call-modal/incoming-call-modal.component.html b/toju-app/src/app/domains/direct-call/feature/incoming-call-modal/incoming-call-modal.component.html new file mode 100644 index 0000000..e64b6f7 --- /dev/null +++ b/toju-app/src/app/domains/direct-call/feature/incoming-call-modal/incoming-call-modal.component.html @@ -0,0 +1,73 @@ +@if (session()) { + + + + + + + @if (caller(); as callerUser) { + + } @else { + + {{ callerName().charAt(0).toUpperCase() }} + + } + + + + + + + Incoming call + + {{ callerName() }} is calling + + {{ callKindLabel() }} + + + + + Decline + + + + + Answer + + + + + +} diff --git a/toju-app/src/app/domains/direct-call/feature/incoming-call-modal/incoming-call-modal.component.ts b/toju-app/src/app/domains/direct-call/feature/incoming-call-modal/incoming-call-modal.component.ts new file mode 100644 index 0000000..0568559 --- /dev/null +++ b/toju-app/src/app/domains/direct-call/feature/incoming-call-modal/incoming-call-modal.component.ts @@ -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 { + 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; + } +} diff --git a/toju-app/src/app/domains/direct-call/index.ts b/toju-app/src/app/domains/direct-call/index.ts index 92e3a8a..96d95b1 100644 --- a/toju-app/src/app/domains/direct-call/index.ts +++ b/toju-app/src/app/domains/direct-call/index.ts @@ -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';
Incoming call
{{ callKindLabel() }}