/* eslint-disable @typescript-eslint/member-ordering */ import { Injectable, computed, effect, inject, signal } from '@angular/core'; import { Router } from '@angular/router'; import { Store } from '@ngrx/store'; import { NotificationAudioService, AppSound } from '../../../../core/services/notification-audio.service'; import { ViewportService } from '../../../../core/platform'; import { VoiceActivityService, VoiceConnectionFacade, VoicePlaybackService } from '../../../voice-connection'; import { VoiceSessionFacade } from '../../../voice-session'; import { DirectMessageService, PeerDeliveryService } from '../../../direct-message'; import type { DirectMessageConversation } from '../../../direct-message'; import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors'; import { UsersActions } from '../../../../store/users/users.actions'; import { DirectCallEventPayload, DirectMessageParticipant, User } from '../../../../shared-kernel'; import { DirectCallSession, participantToUser } from '../../domain/models/direct-call.model'; import { toDirectMessageParticipant } from '../../../direct-message'; @Injectable({ providedIn: 'root' }) export class DirectCallService { private readonly router = inject(Router); private readonly store = inject(Store); private readonly delivery = inject(PeerDeliveryService); private readonly directMessages = inject(DirectMessageService); private readonly audio = inject(NotificationAudioService); private readonly voice = inject(VoiceConnectionFacade); private readonly voiceSession = inject(VoiceSessionFacade); private readonly voiceActivity = inject(VoiceActivityService); private readonly playback = inject(VoicePlaybackService); private readonly viewport = inject(ViewportService); private readonly currentUser = this.store.selectSignal(selectCurrentUser); private readonly users = this.store.selectSignal(selectAllUsers); private readonly sessionsSignal = signal([]); private readonly mobileOverlayCallId = signal(null); 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); readonly mobileOverlaySession = computed(() => { const callId = this.mobileOverlayCallId(); if (!callId) { return null; } return this.visibleActiveSessions().find((session) => session.callId === callId) ?? null; }); constructor() { this.delivery.directCallEvents$.subscribe((event) => { if (event.directCall) { void this.handleIncomingCallEvent(event.directCall); } }); effect(() => { const session = this.currentSession(); if (!session || session.status === 'ended') { return; } const peerIds = this.remoteParticipantIds(session); this.voice.syncOutgoingVoiceRouting(peerIds); }); effect(() => { if (this.incomingCall() && !this.isDoNotDisturb()) { return; } this.audio.stop(AppSound.Call); }); effect(() => { if (this.mobileOverlayCallId() && !this.mobileOverlaySession()) { this.mobileOverlayCallId.set(null); } }); } sessionById(callId: string | null | undefined): DirectCallSession | null { if (!callId) { return null; } return this.sessionsSignal().find((session) => session.callId === callId) ?? null; } isCallingUser(user: User): boolean { const userId = this.userKey(user); return this.visibleActiveSessions().some((session) => session.participantIds.includes(userId)); } isCallingConversation(conversationId: string | null | undefined): boolean { if (!conversationId) { return false; } return this.visibleActiveSessions().some((session) => session.callId === conversationId || session.conversationId === conversationId); } hasConnectedParticipant(session: DirectCallSession | null | undefined): boolean { if (!session || session.status === 'ended') { return false; } return Object.values(session.participants).some((participant) => participant.joined); } hasOngoingActivity(session: DirectCallSession | null | undefined): boolean { return this.hasConnectedParticipant(session); } async startCall(user: User): Promise { const conversation = await this.directMessages.createConversation(user); const me = this.requireCurrentUser(); const meParticipant = toDirectMessageParticipant(me); const peerParticipant = toDirectMessageParticipant(user); const participantIds = this.uniqueIds([meParticipant.userId, peerParticipant.userId]); const activeSession = this.findLiveSessionForParticipants(participantIds, conversation.id); if (activeSession) { return await this.rejoinLiveSession(activeSession); } const existing = this.sessionById(conversation.id); const session = existing ?? this.createSession({ callId: conversation.id, conversationId: conversation.id, createdAt: Date.now(), initiatorId: meParticipant.userId, participantIds, participants: [meParticipant, peerParticipant], status: 'calling' }); this.upsertSession(session); this.currentSession.set(session); await this.joinCall(session.callId, false); this.sendCallEvent(peerParticipant.userId, 'ring', session); await this.openCallView(session.callId); return session; } async startConversationCall(conversation: DirectMessageConversation): Promise { if (this.isGroupConversation(conversation)) { return await this.startGroupCall(conversation); } const meId = this.currentUserId(); const peerId = conversation.participants.find((participantId) => participantId !== meId); if (!peerId) { throw new Error('Direct message conversation has no recipient to call.'); } const peer = this.userForParticipant(peerId) ?? participantToUser(this.participantFromConversation(conversation, peerId)); return await this.startCall(peer); } async openCall(callId: string): Promise { const session = this.sessionById(callId); if (session?.conversationId) { await this.directMessages.openConversation(session.conversationId); } this.currentSession.set(session); } async openCallView(callId: string): Promise { if (this.viewport.isMobile()) { await this.openMobileCallOverlay(callId); return; } await this.router.navigate(['/call', callId]); } async openMobileCallOverlay(callId: string): Promise { await this.openCall(callId); this.mobileOverlayCallId.set(callId); } closeMobileCallOverlay(): void { this.mobileOverlayCallId.set(null); } 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(); const meId = this.userKey(me); if (!session) { return; } this.leaveOtherJoinedCalls(callId); this.leaveCurrentVoiceTargetForCall(callId); this.audio.stop(AppSound.Call); const ok = await this.voice.ensureSignalingConnected(); if (!ok || !navigator.mediaDevices?.getUserMedia) { return; } const stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: false } }); await this.voice.setLocalStream(stream); this.voiceActivity.trackLocalMic(meId, stream); this.voice.startVoiceHeartbeat(session.callId, session.callId); this.updateLocalVoiceState(session, true); this.playback.playPendingStreams({ isConnected: true, outputVolume: 1, isDeafened: this.voice.isDeafened() }); const nextSession = this.markParticipantJoined(session, meId, true, 'connected'); this.upsertSession(nextSession); this.currentSession.set(nextSession); if (notifyPeers) { this.broadcastCallEvent('join', nextSession); } } leaveCall(callId: string, endForEveryone = false): void { const session = this.sessionById(callId); if (!session) { return; } this.leaveJoinedSession(session, endForEveryone); } leaveCurrentJoinedCall(exceptCallId?: string): void { for (const session of this.sessionsSignal()) { if (session.callId === exceptCallId || !this.isCurrentUserJoined(session)) { continue; } this.leaveJoinedSession(session); } } private leaveJoinedSession(session: DirectCallSession, endForEveryone = false): void { const action = endForEveryone ? 'end' : 'leave'; const nextSession = this.markCurrentUserLeft(session, endForEveryone); this.audio.stop(AppSound.Call); this.broadcastCallEvent(action, nextSession); this.stopLocalMedia(nextSession); this.upsertSession(nextSession); this.currentSession.set(null); } async inviteUser(callId: string, user: User): Promise { const session = this.sessionById(callId); if (!session) { return; } const participant = toDirectMessageParticipant(user); const nextSession = this.createSession({ ...session, participantIds: this.uniqueIds([...session.participantIds, participant.userId]), participants: [...Object.values(session.participants).map((entry) => entry.profile), participant], status: session.status }); const convertedSession = await this.convertToGroupConversationIfNeeded(this.preserveJoinedParticipants(session, nextSession)); this.upsertSession(convertedSession); this.currentSession.set(convertedSession); this.broadcastCallEvent('update', convertedSession, [participant.userId]); this.sendCallEvent(participant.userId, 'ring', convertedSession); } remoteParticipantIds(session: DirectCallSession): string[] { const meId = this.currentUserId(); return session.participantIds.filter((participantId) => participantId !== meId); } userForParticipant(participantId: string): User | null { const known = this.users().find((user) => user.id === participantId || user.oderId === participantId || user.peerId === participantId); if (known) { return known; } const participant = this.currentSession()?.participants[participantId]?.profile; return participant ? participantToUser(participant) : null; } private async handleIncomingCallEvent(payload: DirectCallEventPayload): Promise { const meId = this.currentUserId(); if (!meId || payload.sender.userId === meId) { return; } if (!this.callPayloadIncludesParticipant(payload, meId)) { return; } const participants = this.callParticipantsFromPayload(payload); const existing = this.sessionById(payload.callId); const incomingSession = this.createSession({ callId: payload.callId, conversationId: payload.conversationId, createdAt: payload.createdAt, initiatorId: existing?.initiatorId ?? payload.sender.userId, participantIds: this.uniqueIds([ ...payload.participantIds, meId, payload.sender.userId ]), participants, status: this.resolveIncomingStatus(payload.action, existing?.status) }); const preservedSession = existing ? this.preserveJoinedParticipants(existing, incomingSession) : incomingSession; const session = this.applyIncomingParticipantState(preservedSession, payload); this.upsertSession(session); this.currentSession.set(this.currentSession()?.callId === session.callId ? session : this.currentSession()); this.markRemoteVoiceState(payload.sender.userId, session, payload.action === 'join'); if (payload.action === 'update') { await this.ensureCallConversation(session); return; } if (payload.action === 'ring') { await this.ensureCallConversation(session); if (this.shouldAlertIncomingCall(session)) { this.audio.playLoop(AppSound.Call); } else { this.audio.stop(AppSound.Call); } if (this.shouldAlertIncomingCall(session)) { await this.showIncomingNotification(payload.sender.displayName, payload.callId); } return; } this.audio.stop(AppSound.Call); if (payload.action === 'end') { if (this.currentSession()?.callId === payload.callId) { this.stopLocalMedia(session); this.currentSession.set(null); } } } private async startGroupCall(conversation: DirectMessageConversation): Promise { const me = this.requireCurrentUser(); const meParticipant = toDirectMessageParticipant(me); const participantIds = this.uniqueIds([...conversation.participants, meParticipant.userId]); const conversationParticipants = participantIds.map((participantId) => this.participantFromConversation(conversation, participantId)); const participants = this.uniqueParticipants([meParticipant, ...conversationParticipants]); const activeSession = this.findLiveSessionForParticipants(participantIds, conversation.id); if (activeSession) { return await this.rejoinLiveSession(activeSession); } const existing = this.sessionById(conversation.id); const session = existing && existing.status !== 'ended' ? existing : this.createSession({ callId: conversation.id, conversationId: conversation.id, createdAt: Date.now(), initiatorId: meParticipant.userId, participantIds, participants, status: 'calling' }); this.upsertSession(session); this.currentSession.set(session); await this.joinCall(session.callId, false); this.broadcastCallEvent('ring', this.sessionById(session.callId) ?? session); await this.router.navigate(['/call', session.callId]); return this.sessionById(session.callId) ?? session; } private async rejoinLiveSession(session: DirectCallSession): Promise { this.upsertSession(session); this.currentSession.set(session); if (!this.isCurrentUserJoined(session)) { await this.joinCall(session.callId); } const nextSession = this.sessionById(session.callId) ?? session; await this.router.navigate(['/call', nextSession.callId]); return nextSession; } private leaveOtherJoinedCalls(callId: string): void { this.leaveCurrentJoinedCall(callId); } private leaveCurrentVoiceTargetForCall(callId: string): void { const user = this.currentUser(); const voiceState = user?.voiceState; if (!voiceState?.isConnected || (voiceState.roomId === callId && voiceState.serverId === callId)) { return; } const userId = user?.id; const userKey = user ? this.userKey(user) : undefined; this.voice.stopVoiceHeartbeat(); if (userKey) { this.voiceActivity.untrackLocalMic(userKey); } this.voice.disableVoice(); this.playback.teardownAll(); this.voiceSession.endSession(); if (userId) { this.store.dispatch(UsersActions.updateVoiceState({ userId, voiceState: { isConnected: false, isMuted: false, isDeafened: false, roomId: undefined, serverId: undefined } })); } this.voice.broadcastMessage({ type: 'voice-state', oderId: userKey, displayName: user?.displayName || 'User', voiceState: { isConnected: false, isMuted: false, isDeafened: false, roomId: voiceState.roomId, serverId: voiceState.serverId } }); } private async ensureConversation(sender: DirectMessageParticipant): Promise { await this.directMessages.createConversation(participantToUser(sender)); } private isGroupConversation(conversation: DirectMessageConversation): boolean { return conversation.kind === 'group' || conversation.participants.length > 2; } private participantFromConversation(conversation: DirectMessageConversation, participantId: string): DirectMessageParticipant { const knownUser = this.userForParticipant(participantId); const profile = conversation.participantProfiles[participantId]; if (knownUser) { return toDirectMessageParticipant(knownUser); } return profile ?? { userId: participantId, username: participantId, displayName: participantId }; } private resolveIncomingStatus(action: DirectCallEventPayload['action'], currentStatus?: DirectCallSession['status']): DirectCallSession['status'] { if (action === 'ring') { return currentStatus === 'connected' ? 'connected' : 'ringing'; } if (action === 'join') { return 'connected'; } if (action === 'end') { return 'ended'; } if (action === 'update') { return currentStatus ?? 'ringing'; } if (action === 'leave') { return currentStatus === 'ringing' ? 'ringing' : 'connected'; } return currentStatus ?? 'ringing'; } private applyIncomingParticipantState(session: DirectCallSession, payload: DirectCallEventPayload): DirectCallSession { if (payload.action === 'ring' || payload.action === 'join') { return this.markParticipantJoined(session, payload.sender.userId, true, payload.action === 'join' ? 'connected' : session.status); } if (payload.action === 'leave') { const nextSession = this.markParticipantJoined(session, payload.sender.userId, false, session.status); return this.hasConnectedParticipant(nextSession) ? nextSession : { ...nextSession, status: 'ended' }; } if (payload.action === 'end') { return { ...session, status: 'ended' }; } return session; } private sendCallEvent(recipientId: string, action: DirectCallEventPayload['action'], session: DirectCallSession): void { const me = this.requireCurrentUser(); this.delivery.sendCallEvent(recipientId, { type: 'direct-call', directCall: { action, callId: session.callId, conversationId: session.conversationId, createdAt: session.createdAt, sender: toDirectMessageParticipant(me), participantIds: session.participantIds, participants: Object.values(session.participants).map((participant) => participant.profile) } }); } private broadcastCallEvent(action: DirectCallEventPayload['action'], session: DirectCallSession, excludedParticipantIds: string[] = []): void { const excluded = new Set(excludedParticipantIds); for (const participantId of this.remoteParticipantIds(session)) { if (excluded.has(participantId)) { continue; } this.sendCallEvent(participantId, action, session); } } private async convertToGroupConversationIfNeeded(session: DirectCallSession): Promise { if (session.participantIds.length <= 2) { return session; } const conversation = await this.directMessages.createGroupConversation( Object.values(session.participants).map((participant) => participant.profile), this.groupConversationTitle(session), session.conversationId.startsWith('dm-group-') ? session.conversationId : undefined ); return { ...session, conversationId: conversation.id }; } private preserveJoinedParticipants(previousSession: DirectCallSession, nextSession: DirectCallSession): DirectCallSession { return { ...nextSession, participants: Object.fromEntries(Object.values(nextSession.participants).map((participant) => [ participant.userId, { ...participant, joined: previousSession.participants[participant.userId]?.joined ?? participant.joined } ])) }; } private async ensureCallConversation(session: DirectCallSession): Promise { if (session.participantIds.length > 2) { await this.directMessages.createGroupConversation( Object.values(session.participants).map((participant) => participant.profile), this.groupConversationTitle(session), session.conversationId ); return; } const sender = Object.values(session.participants) .map((participant) => participant.profile) .find((participant) => participant.userId !== this.currentUserId()); if (sender) { await this.ensureConversation(sender); } } private callParticipantsFromPayload(payload: DirectCallEventPayload): DirectMessageParticipant[] { return this.uniqueParticipants([ payload.sender, toDirectMessageParticipant(this.requireCurrentUser()), ...(payload.participants ?? []), ...payload.participantIds .map((participantId) => this.userForParticipant(participantId)) .filter((user): user is User => !!user) .map((user) => toDirectMessageParticipant(user)) ]); } private callPayloadIncludesParticipant(payload: DirectCallEventPayload, participantId: string): boolean { return payload.participantIds.includes(participantId) || (payload.participants ?? []).some((participant) => participant.userId === participantId); } private groupConversationTitle(session: DirectCallSession): string { const names = Object.values(session.participants) .map((participant) => participant.profile.displayName || participant.profile.username || participant.userId); if (names.length <= 3) { return names.join(', '); } return `${names.slice(0, 3).join(', ')} +${names.length - 3}`; } private createSession(input: { callId: string; conversationId: string; createdAt: number; initiatorId: string; participantIds: string[]; participants: DirectMessageParticipant[]; status: DirectCallSession['status']; }): DirectCallSession { const participants = Object.fromEntries(this.uniqueParticipants(input.participants).map((participant) => [ participant.userId, { userId: participant.userId, profile: participant, joined: input.status === 'connected' && participant.userId === this.currentUserId() } ])); return { callId: input.callId, conversationId: input.conversationId, createdAt: input.createdAt, initiatorId: input.initiatorId, participantIds: this.uniqueIds(input.participantIds), participants, status: input.status }; } private markParticipantJoined( session: DirectCallSession, participantId: string, joined: boolean, status: DirectCallSession['status'] ): DirectCallSession { const participant = session.participants[participantId]; return { ...session, status, participants: { ...session.participants, ...(participant ? { [participantId]: { ...participant, joined } } : {}) } }; } private upsertSession(session: DirectCallSession): void { this.sessionsSignal.update((sessions) => [...sessions.filter((entry) => entry.callId !== session.callId), session]); } private markCurrentUserLeft(session: DirectCallSession, endForEveryone: boolean): DirectCallSession { const meId = this.currentUserId(); const locallyLeftSession = meId ? this.markParticipantJoined(session, meId, false, session.status) : session; return { ...locallyLeftSession, status: endForEveryone || !this.hasConnectedParticipant(locallyLeftSession) ? 'ended' as const : 'connected' as const }; } private findLiveSessionForParticipants(participantIds: string[], conversationId?: string | null): DirectCallSession | null { const normalizedParticipantIds = this.uniqueIds(participantIds).sort(); return this.visibleActiveSessions().find((session) => { if (conversationId && (session.callId === conversationId || session.conversationId === conversationId)) { return true; } const sessionParticipantIds = this.uniqueIds(session.participantIds).sort(); return sessionParticipantIds.length === normalizedParticipantIds.length && sessionParticipantIds.every((participantId, index) => participantId === normalizedParticipantIds[index]); }) ?? null; } private isCurrentUserJoined(session: DirectCallSession): boolean { const meId = this.currentUserId(); return !!meId && !!session.participants[meId]?.joined; } private stopLocalMedia(session: DirectCallSession): void { const meId = this.currentUserId(); if (meId) { this.voiceActivity.untrackLocalMic(meId); } this.voice.stopVoiceHeartbeat(); this.voice.disableVoice(); this.playback.teardownAll(); this.updateLocalVoiceState(session, false); } private updateLocalVoiceState(session: DirectCallSession, connected: boolean): void { const user = this.currentUser(); if (!user?.id) { return; } this.store.dispatch(UsersActions.updateVoiceState({ userId: user.id, voiceState: { isConnected: connected, isMuted: connected ? this.voice.isMuted() : false, isDeafened: connected ? this.voice.isDeafened() : false, roomId: connected ? session.callId : undefined, serverId: connected ? session.callId : undefined } })); } private markRemoteVoiceState(userId: string, session: DirectCallSession, connected: boolean): void { this.store.dispatch(UsersActions.updateVoiceState({ userId, voiceState: { isConnected: connected, isMuted: false, isDeafened: false, roomId: connected ? session.callId : undefined, serverId: connected ? session.callId : undefined } })); } 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; } let permission = Notification.permission; if (permission === 'default') { permission = await Notification.requestPermission(); } if (permission !== 'granted') { return; } const notification = new Notification('Incoming call', { body: `${displayName} is calling you` }); notification.onclick = () => { window.focus(); void this.router.navigate(['/call', callId]); }; } private uniqueParticipants(participants: DirectMessageParticipant[]): DirectMessageParticipant[] { const seen = new Set(); return participants.filter((participant) => { if (seen.has(participant.userId)) { return false; } seen.add(participant.userId); return true; }); } private uniqueIds(ids: string[]): string[] { return ids.filter((id, index) => !!id && ids.indexOf(id) === index); } private userKey(user: User): string { return user.oderId || user.id; } private currentUserId(): string | null { const user = this.currentUser(); return user ? this.userKey(user) : null; } private requireCurrentUser(): User { const user = this.currentUser(); if (!user) { throw new Error('Cannot use calls without a current user.'); } return user; } }