900 lines
28 KiB
TypeScript
900 lines
28 KiB
TypeScript
/* 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 {
|
|
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 currentUser = this.store.selectSignal(selectCurrentUser);
|
|
private readonly users = this.store.selectSignal(selectAllUsers);
|
|
private readonly sessionsSignal = signal<DirectCallSession[]>([]);
|
|
|
|
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);
|
|
|
|
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);
|
|
});
|
|
}
|
|
|
|
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<DirectCallSession> {
|
|
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.router.navigate(['/call', session.callId]);
|
|
return session;
|
|
}
|
|
|
|
async startConversationCall(conversation: DirectMessageConversation): Promise<DirectCallSession> {
|
|
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<void> {
|
|
const session = this.sessionById(callId);
|
|
|
|
if (session?.conversationId) {
|
|
await this.directMessages.openConversation(session.conversationId);
|
|
}
|
|
|
|
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();
|
|
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<void> {
|
|
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<void> {
|
|
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<DirectCallSession> {
|
|
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<DirectCallSession> {
|
|
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<void> {
|
|
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<DirectCallSession> {
|
|
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<void> {
|
|
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<void> {
|
|
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<string>();
|
|
|
|
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;
|
|
}
|
|
}
|