Fix private calls
This commit is contained in:
@@ -0,0 +1,809 @@
|
||||
/* 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 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);
|
||||
});
|
||||
}
|
||||
|
||||
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 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;
|
||||
}
|
||||
|
||||
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 (session.status !== 'connected') {
|
||||
this.audio.playLoop(AppSound.Call);
|
||||
} else {
|
||||
this.audio.stop(AppSound.Call);
|
||||
}
|
||||
|
||||
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 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 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user