mirror of https://github.com/vector-im/riot-web
1170 lines
46 KiB
TypeScript
1170 lines
46 KiB
TypeScript
/*
|
|
Copyright 2015, 2016 OpenMarket Ltd
|
|
Copyright 2017, 2018 New Vector Ltd
|
|
Copyright 2019 - 2021 The Matrix.org Foundation C.I.C.
|
|
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
you may not use this file except in compliance with the License.
|
|
You may obtain a copy of the License at
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
See the License for the specific language governing permissions and
|
|
limitations under the License.
|
|
*/
|
|
|
|
/*
|
|
* Manages a list of all the currently active calls.
|
|
*
|
|
* This handler dispatches when voip calls are added/updated/removed from this list:
|
|
* {
|
|
* action: 'call_state'
|
|
* room_id: <room ID of the call>
|
|
* }
|
|
*
|
|
* To know the state of the call, this handler exposes a getter to
|
|
* obtain the call for a room:
|
|
* var call = CallHandler.getCall(roomId)
|
|
* var state = call.call_state; // ringing|ringback|connected|ended|busy|stop_ringback|stop_ringing
|
|
*
|
|
* This handler listens for and handles the following actions:
|
|
* {
|
|
* action: 'place_call',
|
|
* type: 'voice|video',
|
|
* room_id: <room that the place call button was pressed in>
|
|
* }
|
|
*
|
|
* {
|
|
* action: 'incoming_call'
|
|
* call: MatrixCall
|
|
* }
|
|
*
|
|
* {
|
|
* action: 'hangup'
|
|
* room_id: <room that the hangup button was pressed in>
|
|
* }
|
|
*
|
|
* {
|
|
* action: 'answer'
|
|
* room_id: <room that the answer button was pressed in>
|
|
* }
|
|
*/
|
|
|
|
import React from 'react';
|
|
|
|
import { MatrixClientPeg } from './MatrixClientPeg';
|
|
import Modal from './Modal';
|
|
import { _t } from './languageHandler';
|
|
import dis from './dispatcher/dispatcher';
|
|
import WidgetUtils from './utils/WidgetUtils';
|
|
import SettingsStore from './settings/SettingsStore';
|
|
import { Jitsi } from "./widgets/Jitsi";
|
|
import { WidgetType } from "./widgets/WidgetType";
|
|
import { SettingLevel } from "./settings/SettingLevel";
|
|
import { ActionPayload } from "./dispatcher/payloads";
|
|
import { base32 } from "rfc4648";
|
|
|
|
import QuestionDialog from "./components/views/dialogs/QuestionDialog";
|
|
import ErrorDialog from "./components/views/dialogs/ErrorDialog";
|
|
import WidgetStore from "./stores/WidgetStore";
|
|
import { WidgetMessagingStore } from "./stores/widgets/WidgetMessagingStore";
|
|
import { ElementWidgetActions } from "./stores/widgets/ElementWidgetActions";
|
|
import { MatrixCall, CallErrorCode, CallState, CallEvent, CallParty, CallType } from "matrix-js-sdk/src/webrtc/call";
|
|
import Analytics from './Analytics';
|
|
import CountlyAnalytics from "./CountlyAnalytics";
|
|
import { UIFeature } from "./settings/UIFeature";
|
|
import { CallError } from "matrix-js-sdk/src/webrtc/call";
|
|
import { logger } from 'matrix-js-sdk/src/logger';
|
|
import { Action } from './dispatcher/actions';
|
|
import VoipUserMapper from './VoipUserMapper';
|
|
import { addManagedHybridWidget, isManagedHybridWidgetEnabled } from './widgets/ManagedHybrid';
|
|
import { randomUppercaseString, randomLowercaseString } from "matrix-js-sdk/src/randomstring";
|
|
import EventEmitter from 'events';
|
|
import SdkConfig from './SdkConfig';
|
|
import { ensureDMExists, findDMForUser } from './createRoom';
|
|
import { RuleId, TweakName, Tweaks } from "matrix-js-sdk/src/@types/PushRules";
|
|
import { PushProcessor } from 'matrix-js-sdk/src/pushprocessor';
|
|
import { WidgetLayoutStore, Container } from './stores/widgets/WidgetLayoutStore';
|
|
import { getIncomingCallToastKey } from './toasts/IncomingCallToast';
|
|
import ToastStore from './stores/ToastStore';
|
|
import IncomingCallToast from "./toasts/IncomingCallToast";
|
|
|
|
export const PROTOCOL_PSTN = 'm.protocol.pstn';
|
|
export const PROTOCOL_PSTN_PREFIXED = 'im.vector.protocol.pstn';
|
|
export const PROTOCOL_SIP_NATIVE = 'im.vector.protocol.sip_native';
|
|
export const PROTOCOL_SIP_VIRTUAL = 'im.vector.protocol.sip_virtual';
|
|
|
|
const CHECK_PROTOCOLS_ATTEMPTS = 3;
|
|
// Event type for room account data and room creation content used to mark rooms as virtual rooms
|
|
// (and store the ID of their native room)
|
|
export const VIRTUAL_ROOM_EVENT_TYPE = 'im.vector.is_virtual_room';
|
|
|
|
enum AudioID {
|
|
Ring = 'ringAudio',
|
|
Ringback = 'ringbackAudio',
|
|
CallEnd = 'callendAudio',
|
|
Busy = 'busyAudio',
|
|
}
|
|
|
|
interface ThirdpartyLookupResponseFields {
|
|
/* eslint-disable camelcase */
|
|
|
|
// im.vector.sip_native
|
|
virtual_mxid?: string;
|
|
is_virtual?: boolean;
|
|
|
|
// im.vector.sip_virtual
|
|
native_mxid?: string;
|
|
is_native?: boolean;
|
|
|
|
// common
|
|
lookup_success?: boolean;
|
|
|
|
/* eslint-enable camelcase */
|
|
}
|
|
|
|
interface ThirdpartyLookupResponse {
|
|
userid: string;
|
|
protocol: string;
|
|
fields: ThirdpartyLookupResponseFields;
|
|
}
|
|
|
|
export enum PlaceCallType {
|
|
Voice = 'voice',
|
|
Video = 'video',
|
|
}
|
|
|
|
export enum CallHandlerEvent {
|
|
CallsChanged = "calls_changed",
|
|
CallChangeRoom = "call_change_room",
|
|
SilencedCallsChanged = "silenced_calls_changed",
|
|
}
|
|
|
|
export default class CallHandler extends EventEmitter {
|
|
private calls = new Map<string, MatrixCall>(); // roomId -> call
|
|
// Calls started as an attended transfer, ie. with the intention of transferring another
|
|
// call with a different party to this one.
|
|
private transferees = new Map<string, MatrixCall>(); // callId (target) -> call (transferee)
|
|
private audioPromises = new Map<AudioID, Promise<void>>();
|
|
private dispatcherRef: string = null;
|
|
private supportsPstnProtocol = null;
|
|
private pstnSupportPrefixed = null; // True if the server only support the prefixed pstn protocol
|
|
private supportsSipNativeVirtual = null; // im.vector.protocol.sip_virtual and im.vector.protocol.sip_native
|
|
private pstnSupportCheckTimer: number;
|
|
// For rooms we've been invited to, true if they're from virtual user, false if we've checked and they aren't.
|
|
private invitedRoomsAreVirtual = new Map<string, boolean>();
|
|
private invitedRoomCheckInProgress = false;
|
|
|
|
// Map of the asserted identity users after we've looked them up using the API.
|
|
// We need to be be able to determine the mapped room synchronously, so we
|
|
// do the async lookup when we get new information and then store these mappings here
|
|
private assertedIdentityNativeUsers = new Map<string, string>();
|
|
|
|
private silencedCalls = new Set<string>(); // callIds
|
|
|
|
static sharedInstance() {
|
|
if (!window.mxCallHandler) {
|
|
window.mxCallHandler = new CallHandler();
|
|
}
|
|
|
|
return window.mxCallHandler;
|
|
}
|
|
|
|
/*
|
|
* Gets the user-facing room associated with a call (call.roomId may be the call "virtual room"
|
|
* if a voip_mxid_translate_pattern is set in the config)
|
|
*/
|
|
public roomIdForCall(call: MatrixCall): string {
|
|
if (!call) return null;
|
|
|
|
const voipConfig = SdkConfig.get()['voip'];
|
|
|
|
if (voipConfig && voipConfig.obeyAssertedIdentity) {
|
|
const nativeUser = this.assertedIdentityNativeUsers[call.callId];
|
|
if (nativeUser) {
|
|
const room = findDMForUser(MatrixClientPeg.get(), nativeUser);
|
|
if (room) return room.roomId;
|
|
}
|
|
}
|
|
|
|
return VoipUserMapper.sharedInstance().nativeRoomForVirtualRoom(call.roomId) || call.roomId;
|
|
}
|
|
|
|
start() {
|
|
this.dispatcherRef = dis.register(this.onAction);
|
|
// add empty handlers for media actions, otherwise the media keys
|
|
// end up causing the audio elements with our ring/ringback etc
|
|
// audio clips in to play.
|
|
if (navigator.mediaSession) {
|
|
navigator.mediaSession.setActionHandler('play', function() {});
|
|
navigator.mediaSession.setActionHandler('pause', function() {});
|
|
navigator.mediaSession.setActionHandler('seekbackward', function() {});
|
|
navigator.mediaSession.setActionHandler('seekforward', function() {});
|
|
navigator.mediaSession.setActionHandler('previoustrack', function() {});
|
|
navigator.mediaSession.setActionHandler('nexttrack', function() {});
|
|
}
|
|
|
|
if (SettingsStore.getValue(UIFeature.Voip)) {
|
|
MatrixClientPeg.get().on('Call.incoming', this.onCallIncoming);
|
|
}
|
|
|
|
this.checkProtocols(CHECK_PROTOCOLS_ATTEMPTS);
|
|
}
|
|
|
|
stop() {
|
|
const cli = MatrixClientPeg.get();
|
|
if (cli) {
|
|
cli.removeListener('Call.incoming', this.onCallIncoming);
|
|
}
|
|
if (this.dispatcherRef !== null) {
|
|
dis.unregister(this.dispatcherRef);
|
|
this.dispatcherRef = null;
|
|
}
|
|
}
|
|
|
|
public silenceCall(callId: string) {
|
|
this.silencedCalls.add(callId);
|
|
this.emit(CallHandlerEvent.SilencedCallsChanged, this.silencedCalls);
|
|
|
|
// Don't pause audio if we have calls which are still ringing
|
|
if (this.areAnyCallsUnsilenced()) return;
|
|
this.pause(AudioID.Ring);
|
|
}
|
|
|
|
public unSilenceCall(callId: string) {
|
|
this.silencedCalls.delete(callId);
|
|
this.emit(CallHandlerEvent.SilencedCallsChanged, this.silencedCalls);
|
|
this.play(AudioID.Ring);
|
|
}
|
|
|
|
public isCallSilenced(callId: string): boolean {
|
|
return this.silencedCalls.has(callId);
|
|
}
|
|
|
|
/**
|
|
* Returns true if there is at least one unsilenced call
|
|
* @returns {boolean}
|
|
*/
|
|
private areAnyCallsUnsilenced(): boolean {
|
|
for (const call of this.calls.values()) {
|
|
if (
|
|
call.state === CallState.Ringing &&
|
|
!this.isCallSilenced(call.callId)
|
|
) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private async checkProtocols(maxTries) {
|
|
try {
|
|
const protocols = await MatrixClientPeg.get().getThirdpartyProtocols();
|
|
|
|
if (protocols[PROTOCOL_PSTN] !== undefined) {
|
|
this.supportsPstnProtocol = Boolean(protocols[PROTOCOL_PSTN]);
|
|
if (this.supportsPstnProtocol) this.pstnSupportPrefixed = false;
|
|
} else if (protocols[PROTOCOL_PSTN_PREFIXED] !== undefined) {
|
|
this.supportsPstnProtocol = Boolean(protocols[PROTOCOL_PSTN_PREFIXED]);
|
|
if (this.supportsPstnProtocol) this.pstnSupportPrefixed = true;
|
|
} else {
|
|
this.supportsPstnProtocol = null;
|
|
}
|
|
|
|
dis.dispatch({ action: Action.PstnSupportUpdated });
|
|
|
|
if (protocols[PROTOCOL_SIP_NATIVE] !== undefined && protocols[PROTOCOL_SIP_VIRTUAL] !== undefined) {
|
|
this.supportsSipNativeVirtual = Boolean(
|
|
protocols[PROTOCOL_SIP_NATIVE] && protocols[PROTOCOL_SIP_VIRTUAL],
|
|
);
|
|
}
|
|
|
|
dis.dispatch({ action: Action.VirtualRoomSupportUpdated });
|
|
} catch (e) {
|
|
if (maxTries === 1) {
|
|
logger.log("Failed to check for protocol support and no retries remain: assuming no support", e);
|
|
} else {
|
|
logger.log("Failed to check for protocol support: will retry", e);
|
|
this.pstnSupportCheckTimer = setTimeout(() => {
|
|
this.checkProtocols(maxTries - 1);
|
|
}, 10000);
|
|
}
|
|
}
|
|
}
|
|
|
|
public getSupportsPstnProtocol() {
|
|
return this.supportsPstnProtocol;
|
|
}
|
|
|
|
public getSupportsVirtualRooms() {
|
|
return this.supportsSipNativeVirtual;
|
|
}
|
|
|
|
public pstnLookup(phoneNumber: string): Promise<ThirdpartyLookupResponse[]> {
|
|
return MatrixClientPeg.get().getThirdpartyUser(
|
|
this.pstnSupportPrefixed ? PROTOCOL_PSTN_PREFIXED : PROTOCOL_PSTN, {
|
|
'm.id.phone': phoneNumber,
|
|
},
|
|
);
|
|
}
|
|
|
|
public sipVirtualLookup(nativeMxid: string): Promise<ThirdpartyLookupResponse[]> {
|
|
return MatrixClientPeg.get().getThirdpartyUser(
|
|
PROTOCOL_SIP_VIRTUAL, {
|
|
'native_mxid': nativeMxid,
|
|
},
|
|
);
|
|
}
|
|
|
|
public sipNativeLookup(virtualMxid: string): Promise<ThirdpartyLookupResponse[]> {
|
|
return MatrixClientPeg.get().getThirdpartyUser(
|
|
PROTOCOL_SIP_NATIVE, {
|
|
'virtual_mxid': virtualMxid,
|
|
},
|
|
);
|
|
}
|
|
|
|
private onCallIncoming = (call) => {
|
|
// we dispatch this synchronously to make sure that the event
|
|
// handlers on the call are set up immediately (so that if
|
|
// we get an immediate hangup, we don't get a stuck call)
|
|
dis.dispatch({
|
|
action: 'incoming_call',
|
|
call: call,
|
|
}, true);
|
|
};
|
|
|
|
public getCallById(callId: string): MatrixCall {
|
|
for (const call of this.calls.values()) {
|
|
if (call.callId === callId) return call;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
getCallForRoom(roomId: string): MatrixCall {
|
|
return this.calls.get(roomId) || null;
|
|
}
|
|
|
|
getAnyActiveCall() {
|
|
for (const call of this.calls.values()) {
|
|
if (call.state !== CallState.Ended) {
|
|
return call;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
getAllActiveCalls() {
|
|
const activeCalls = [];
|
|
|
|
for (const call of this.calls.values()) {
|
|
if (call.state !== CallState.Ended && call.state !== CallState.Ringing) {
|
|
activeCalls.push(call);
|
|
}
|
|
}
|
|
return activeCalls;
|
|
}
|
|
|
|
getAllActiveCallsNotInRoom(notInThisRoomId) {
|
|
const callsNotInThatRoom = [];
|
|
|
|
for (const [roomId, call] of this.calls.entries()) {
|
|
if (roomId !== notInThisRoomId && call.state !== CallState.Ended) {
|
|
callsNotInThatRoom.push(call);
|
|
}
|
|
}
|
|
return callsNotInThatRoom;
|
|
}
|
|
|
|
getTransfereeForCallId(callId: string): MatrixCall {
|
|
return this.transferees[callId];
|
|
}
|
|
|
|
play(audioId: AudioID) {
|
|
// TODO: Attach an invisible element for this instead
|
|
// which listens?
|
|
const audio = document.getElementById(audioId) as HTMLMediaElement;
|
|
if (audio) {
|
|
const playAudio = async () => {
|
|
try {
|
|
// This still causes the chrome debugger to break on promise rejection if
|
|
// the promise is rejected, even though we're catching the exception.
|
|
await audio.play();
|
|
} catch (e) {
|
|
// This is usually because the user hasn't interacted with the document,
|
|
// or chrome doesn't think so and is denying the request. Not sure what
|
|
// we can really do here...
|
|
// https://github.com/vector-im/element-web/issues/7657
|
|
logger.log("Unable to play audio clip", e);
|
|
}
|
|
};
|
|
if (this.audioPromises.has(audioId)) {
|
|
this.audioPromises.set(audioId, this.audioPromises.get(audioId).then(() => {
|
|
audio.load();
|
|
return playAudio();
|
|
}));
|
|
} else {
|
|
this.audioPromises.set(audioId, playAudio());
|
|
}
|
|
}
|
|
}
|
|
|
|
pause(audioId: AudioID) {
|
|
// TODO: Attach an invisible element for this instead
|
|
// which listens?
|
|
const audio = document.getElementById(audioId) as HTMLMediaElement;
|
|
if (audio) {
|
|
if (this.audioPromises.has(audioId)) {
|
|
this.audioPromises.set(audioId, this.audioPromises.get(audioId).then(() => audio.pause()));
|
|
} else {
|
|
// pause doesn't return a promise, so just do it
|
|
audio.pause();
|
|
}
|
|
}
|
|
}
|
|
|
|
private matchesCallForThisRoom(call: MatrixCall) {
|
|
// We don't allow placing more than one call per room, but that doesn't mean there
|
|
// can't be more than one, eg. in a glare situation. This checks that the given call
|
|
// is the call we consider 'the' call for its room.
|
|
const mappedRoomId = this.roomIdForCall(call);
|
|
|
|
const callForThisRoom = this.getCallForRoom(mappedRoomId);
|
|
return callForThisRoom && call.callId === callForThisRoom.callId;
|
|
}
|
|
|
|
private setCallListeners(call: MatrixCall) {
|
|
let mappedRoomId = this.roomIdForCall(call);
|
|
|
|
call.on(CallEvent.Error, (err: CallError) => {
|
|
if (!this.matchesCallForThisRoom(call)) return;
|
|
|
|
Analytics.trackEvent('voip', 'callError', 'error', err.toString());
|
|
logger.error("Call error:", err);
|
|
|
|
if (err.code === CallErrorCode.NoUserMedia) {
|
|
this.showMediaCaptureError(call);
|
|
return;
|
|
}
|
|
|
|
if (
|
|
MatrixClientPeg.get().getTurnServers().length === 0 &&
|
|
SettingsStore.getValue("fallbackICEServerAllowed") === null
|
|
) {
|
|
this.showICEFallbackPrompt();
|
|
return;
|
|
}
|
|
|
|
Modal.createTrackedDialog('Call Failed', '', ErrorDialog, {
|
|
title: _t('Call Failed'),
|
|
description: err.message,
|
|
});
|
|
});
|
|
call.on(CallEvent.Hangup, () => {
|
|
if (!this.matchesCallForThisRoom(call)) return;
|
|
|
|
Analytics.trackEvent('voip', 'callHangup');
|
|
|
|
this.removeCallForRoom(mappedRoomId);
|
|
});
|
|
call.on(CallEvent.State, (newState: CallState, oldState: CallState) => {
|
|
this.onCallStateChanged(newState, oldState, call);
|
|
});
|
|
call.on(CallEvent.Replaced, (newCall: MatrixCall) => {
|
|
if (!this.matchesCallForThisRoom(call)) return;
|
|
|
|
logger.log(`Call ID ${call.callId} is being replaced by call ID ${newCall.callId}`);
|
|
|
|
if (call.state === CallState.Ringing) {
|
|
this.pause(AudioID.Ring);
|
|
} else if (call.state === CallState.InviteSent) {
|
|
this.pause(AudioID.Ringback);
|
|
}
|
|
|
|
this.removeCallForRoom(mappedRoomId);
|
|
this.addCallForRoom(mappedRoomId, newCall);
|
|
this.setCallListeners(newCall);
|
|
this.setCallState(newCall, newCall.state);
|
|
});
|
|
call.on(CallEvent.AssertedIdentityChanged, async () => {
|
|
if (!this.matchesCallForThisRoom(call)) return;
|
|
|
|
logger.log(`Call ID ${call.callId} got new asserted identity:`, call.getRemoteAssertedIdentity());
|
|
|
|
const newAssertedIdentity = call.getRemoteAssertedIdentity().id;
|
|
let newNativeAssertedIdentity = newAssertedIdentity;
|
|
if (newAssertedIdentity) {
|
|
const response = await this.sipNativeLookup(newAssertedIdentity);
|
|
if (response.length && response[0].fields.lookup_success) {
|
|
newNativeAssertedIdentity = response[0].userid;
|
|
}
|
|
}
|
|
logger.log(`Asserted identity ${newAssertedIdentity} mapped to ${newNativeAssertedIdentity}`);
|
|
|
|
if (newNativeAssertedIdentity) {
|
|
this.assertedIdentityNativeUsers[call.callId] = newNativeAssertedIdentity;
|
|
|
|
// If we don't already have a room with this user, make one. This will be slightly odd
|
|
// if they called us because we'll be inviting them, but there's not much we can do about
|
|
// this if we want the actual, native room to exist (which we do). This is why it's
|
|
// important to only obey asserted identity in trusted environments, since anyone you're
|
|
// on a call with can cause you to send a room invite to someone.
|
|
await ensureDMExists(MatrixClientPeg.get(), newNativeAssertedIdentity);
|
|
|
|
const newMappedRoomId = this.roomIdForCall(call);
|
|
logger.log(`Old room ID: ${mappedRoomId}, new room ID: ${newMappedRoomId}`);
|
|
if (newMappedRoomId !== mappedRoomId) {
|
|
this.removeCallForRoom(mappedRoomId);
|
|
mappedRoomId = newMappedRoomId;
|
|
logger.log("Moving call to room " + mappedRoomId);
|
|
this.addCallForRoom(mappedRoomId, call, true);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
private onCallStateChanged = (newState: CallState, oldState: CallState, call: MatrixCall): void => {
|
|
if (!this.matchesCallForThisRoom(call)) return;
|
|
|
|
const mappedRoomId = this.roomIdForCall(call);
|
|
this.setCallState(call, newState);
|
|
|
|
switch (oldState) {
|
|
case CallState.Ringing:
|
|
this.pause(AudioID.Ring);
|
|
break;
|
|
case CallState.InviteSent:
|
|
this.pause(AudioID.Ringback);
|
|
break;
|
|
}
|
|
|
|
if (newState !== CallState.Ringing) {
|
|
this.silencedCalls.delete(call.callId);
|
|
}
|
|
|
|
switch (newState) {
|
|
case CallState.Ringing: {
|
|
const incomingCallPushRule = (
|
|
new PushProcessor(MatrixClientPeg.get()).getPushRuleById(RuleId.IncomingCall)
|
|
);
|
|
const pushRuleEnabled = incomingCallPushRule?.enabled;
|
|
const tweakSetToRing = incomingCallPushRule?.actions.some((action: Tweaks) => (
|
|
action.set_tweak === TweakName.Sound &&
|
|
action.value === "ring"
|
|
));
|
|
|
|
if (pushRuleEnabled && tweakSetToRing) {
|
|
this.play(AudioID.Ring);
|
|
} else {
|
|
this.silenceCall(call.callId);
|
|
}
|
|
break;
|
|
}
|
|
case CallState.InviteSent: {
|
|
this.play(AudioID.Ringback);
|
|
break;
|
|
}
|
|
case CallState.Ended: {
|
|
const hangupReason = call.hangupReason;
|
|
Analytics.trackEvent('voip', 'callEnded', 'hangupReason', hangupReason);
|
|
this.removeCallForRoom(mappedRoomId);
|
|
if (oldState === CallState.InviteSent && call.hangupParty === CallParty.Remote) {
|
|
this.play(AudioID.Busy);
|
|
|
|
// Don't show a modal when we got rejected/the call was hung up
|
|
if (!hangupReason || [CallErrorCode.UserHangup, "user hangup"].includes(hangupReason)) break;
|
|
|
|
let title;
|
|
let description;
|
|
// TODO: We should either do away with these or figure out a copy for each code (expect user_hangup...)
|
|
if (call.hangupReason === CallErrorCode.UserBusy) {
|
|
title = _t("User Busy");
|
|
description = _t("The user you called is busy.");
|
|
} else {
|
|
title = _t("Call Failed");
|
|
description = _t("The call could not be established");
|
|
}
|
|
|
|
Modal.createTrackedDialog('Call Handler', 'Call Failed', ErrorDialog, {
|
|
title, description,
|
|
});
|
|
} else if (
|
|
hangupReason === CallErrorCode.AnsweredElsewhere && oldState === CallState.Connecting
|
|
) {
|
|
Modal.createTrackedDialog('Call Handler', 'Call Failed', ErrorDialog, {
|
|
title: _t("Answered Elsewhere"),
|
|
description: _t("The call was answered on another device."),
|
|
});
|
|
} else if (oldState !== CallState.Fledgling && oldState !== CallState.Ringing) {
|
|
// don't play the end-call sound for calls that never got off the ground
|
|
this.play(AudioID.CallEnd);
|
|
}
|
|
|
|
this.logCallStats(call, mappedRoomId);
|
|
break;
|
|
}
|
|
}
|
|
};
|
|
|
|
private async logCallStats(call: MatrixCall, mappedRoomId: string) {
|
|
const stats = await call.getCurrentCallStats();
|
|
logger.debug(
|
|
`Call completed. Call ID: ${call.callId}, virtual room ID: ${call.roomId}, ` +
|
|
`user-facing room ID: ${mappedRoomId}, direction: ${call.direction}, ` +
|
|
`our Party ID: ${call.ourPartyId}, hangup party: ${call.hangupParty}, ` +
|
|
`hangup reason: ${call.hangupReason}`,
|
|
);
|
|
if (!stats) {
|
|
logger.debug(
|
|
"Call statistics are undefined. The call has " +
|
|
"probably failed before a peerConn was established",
|
|
);
|
|
return;
|
|
}
|
|
logger.debug("Local candidates:");
|
|
for (const cand of stats.filter(item => item.type === 'local-candidate')) {
|
|
const address = cand.address || cand.ip; // firefox uses 'address', chrome uses 'ip'
|
|
logger.debug(
|
|
`${cand.id} - type: ${cand.candidateType}, address: ${address}, port: ${cand.port}, ` +
|
|
`protocol: ${cand.protocol}, relay protocol: ${cand.relayProtocol}, network type: ${cand.networkType}`,
|
|
);
|
|
}
|
|
logger.debug("Remote candidates:");
|
|
for (const cand of stats.filter(item => item.type === 'remote-candidate')) {
|
|
const address = cand.address || cand.ip; // firefox uses 'address', chrome uses 'ip'
|
|
logger.debug(
|
|
`${cand.id} - type: ${cand.candidateType}, address: ${address}, port: ${cand.port}, ` +
|
|
`protocol: ${cand.protocol}`,
|
|
);
|
|
}
|
|
logger.debug("Candidate pairs:");
|
|
for (const pair of stats.filter(item => item.type === 'candidate-pair')) {
|
|
logger.debug(
|
|
`${pair.localCandidateId} / ${pair.remoteCandidateId} - state: ${pair.state}, ` +
|
|
`nominated: ${pair.nominated}, ` +
|
|
`requests sent ${pair.requestsSent}, requests received ${pair.requestsReceived}, ` +
|
|
`responses received: ${pair.responsesReceived}, responses sent: ${pair.responsesSent}, ` +
|
|
`bytes received: ${pair.bytesReceived}, bytes sent: ${pair.bytesSent}, `,
|
|
);
|
|
}
|
|
}
|
|
|
|
private setCallState(call: MatrixCall, status: CallState) {
|
|
const mappedRoomId = CallHandler.sharedInstance().roomIdForCall(call);
|
|
|
|
logger.log(
|
|
`Call state in ${mappedRoomId} changed to ${status}`,
|
|
);
|
|
|
|
const toastKey = getIncomingCallToastKey(call.callId);
|
|
if (status === CallState.Ringing) {
|
|
ToastStore.sharedInstance().addOrReplaceToast({
|
|
key: toastKey,
|
|
priority: 100,
|
|
component: IncomingCallToast,
|
|
bodyClassName: "mx_IncomingCallToast",
|
|
props: { call },
|
|
});
|
|
} else {
|
|
ToastStore.sharedInstance().dismissToast(toastKey);
|
|
}
|
|
|
|
dis.dispatch({
|
|
action: 'call_state',
|
|
room_id: mappedRoomId,
|
|
state: status,
|
|
});
|
|
}
|
|
|
|
private removeCallForRoom(roomId: string) {
|
|
logger.log("Removing call for room ", roomId);
|
|
this.calls.delete(roomId);
|
|
this.emit(CallHandlerEvent.CallsChanged, this.calls);
|
|
}
|
|
|
|
private showICEFallbackPrompt() {
|
|
const cli = MatrixClientPeg.get();
|
|
const code = sub => <code>{ sub }</code>;
|
|
Modal.createTrackedDialog('No TURN servers', '', QuestionDialog, {
|
|
title: _t("Call failed due to misconfigured server"),
|
|
description: <div>
|
|
<p>{ _t(
|
|
"Please ask the administrator of your homeserver " +
|
|
"(<code>%(homeserverDomain)s</code>) to configure a TURN server in " +
|
|
"order for calls to work reliably.",
|
|
{ homeserverDomain: cli.getDomain() }, { code },
|
|
) }</p>
|
|
<p>{ _t(
|
|
"Alternatively, you can try to use the public server at " +
|
|
"<code>turn.matrix.org</code>, but this will not be as reliable, and " +
|
|
"it will share your IP address with that server. You can also manage " +
|
|
"this in Settings.",
|
|
null, { code },
|
|
) }</p>
|
|
</div>,
|
|
button: _t('Try using turn.matrix.org'),
|
|
cancelButton: _t('OK'),
|
|
onFinished: (allow) => {
|
|
SettingsStore.setValue("fallbackICEServerAllowed", null, SettingLevel.DEVICE, allow);
|
|
cli.setFallbackICEServerAllowed(allow);
|
|
},
|
|
}, null, true);
|
|
}
|
|
|
|
private showMediaCaptureError(call: MatrixCall) {
|
|
let title;
|
|
let description;
|
|
|
|
if (call.type === CallType.Voice) {
|
|
title = _t("Unable to access microphone");
|
|
description = <div>
|
|
{ _t(
|
|
"Call failed because microphone could not be accessed. " +
|
|
"Check that a microphone is plugged in and set up correctly.",
|
|
) }
|
|
</div>;
|
|
} else if (call.type === CallType.Video) {
|
|
title = _t("Unable to access webcam / microphone");
|
|
description = <div>
|
|
{ _t("Call failed because webcam or microphone could not be accessed. Check that:") }
|
|
<ul>
|
|
<li>{ _t("A microphone and webcam are plugged in and set up correctly") }</li>
|
|
<li>{ _t("Permission is granted to use the webcam") }</li>
|
|
<li>{ _t("No other application is using the webcam") }</li>
|
|
</ul>
|
|
</div>;
|
|
}
|
|
|
|
Modal.createTrackedDialog('Media capture failed', '', ErrorDialog, {
|
|
title, description,
|
|
}, null, true);
|
|
}
|
|
|
|
private async placeCall(roomId: string, type: PlaceCallType, transferee: MatrixCall) {
|
|
Analytics.trackEvent('voip', 'placeCall', 'type', type);
|
|
CountlyAnalytics.instance.trackStartCall(roomId, type === PlaceCallType.Video, false);
|
|
|
|
const mappedRoomId = (await VoipUserMapper.sharedInstance().getOrCreateVirtualRoomForRoom(roomId)) || roomId;
|
|
logger.debug("Mapped real room " + roomId + " to room ID " + mappedRoomId);
|
|
|
|
const timeUntilTurnCresExpire = MatrixClientPeg.get().getTurnServersExpiry() - Date.now();
|
|
logger.log("Current turn creds expire in " + timeUntilTurnCresExpire + " ms");
|
|
const call = MatrixClientPeg.get().createCall(mappedRoomId);
|
|
|
|
try {
|
|
this.addCallForRoom(roomId, call);
|
|
} catch (e) {
|
|
Modal.createTrackedDialog('Call Handler', 'Existing Call with user', ErrorDialog, {
|
|
title: _t('Already in call'),
|
|
description: _t("You're already in a call with this person."),
|
|
});
|
|
return;
|
|
}
|
|
if (transferee) {
|
|
this.transferees[call.callId] = transferee;
|
|
}
|
|
|
|
this.setCallListeners(call);
|
|
|
|
this.setActiveCallRoomId(roomId);
|
|
|
|
if (type === PlaceCallType.Voice) {
|
|
call.placeVoiceCall();
|
|
} else if (type === 'video') {
|
|
call.placeVideoCall();
|
|
} else {
|
|
logger.error("Unknown conf call type: " + type);
|
|
}
|
|
}
|
|
|
|
private onAction = (payload: ActionPayload) => {
|
|
switch (payload.action) {
|
|
case 'place_call':
|
|
{
|
|
// We might be using managed hybrid widgets
|
|
if (isManagedHybridWidgetEnabled()) {
|
|
addManagedHybridWidget(payload.room_id);
|
|
return;
|
|
}
|
|
|
|
// if the runtime env doesn't do VoIP, whine.
|
|
if (!MatrixClientPeg.get().supportsVoip()) {
|
|
Modal.createTrackedDialog('Call Handler', 'VoIP is unsupported', ErrorDialog, {
|
|
title: _t('VoIP is unsupported'),
|
|
description: _t('You cannot place VoIP calls in this browser.'),
|
|
});
|
|
return;
|
|
}
|
|
|
|
// don't allow > 2 calls to be placed.
|
|
if (this.getAllActiveCalls().length > 1) {
|
|
Modal.createTrackedDialog('Call Handler', 'Existing Call', ErrorDialog, {
|
|
title: _t('Too Many Calls'),
|
|
description: _t("You've reached the maximum number of simultaneous calls."),
|
|
});
|
|
return;
|
|
}
|
|
|
|
const room = MatrixClientPeg.get().getRoom(payload.room_id);
|
|
if (!room) {
|
|
logger.error(`Room ${payload.room_id} does not exist.`);
|
|
return;
|
|
}
|
|
|
|
// We leave the check for whether there's already a call in this room until later,
|
|
// otherwise it can race.
|
|
|
|
const members = room.getJoinedMembers();
|
|
if (members.length <= 1) {
|
|
Modal.createTrackedDialog('Call Handler', 'Cannot place call with self', ErrorDialog, {
|
|
description: _t('You cannot place a call with yourself.'),
|
|
});
|
|
return;
|
|
} else if (members.length === 2) {
|
|
logger.info(`Place ${payload.type} call in ${payload.room_id}`);
|
|
|
|
this.placeCall(payload.room_id, payload.type, payload.transferee);
|
|
} else { // > 2
|
|
dis.dispatch({
|
|
action: "place_conference_call",
|
|
room_id: payload.room_id,
|
|
type: payload.type,
|
|
});
|
|
}
|
|
}
|
|
break;
|
|
case 'place_conference_call':
|
|
logger.info("Place conference call in " + payload.room_id);
|
|
Analytics.trackEvent('voip', 'placeConferenceCall');
|
|
CountlyAnalytics.instance.trackStartCall(payload.room_id, payload.type === PlaceCallType.Video, true);
|
|
this.startCallApp(payload.room_id, payload.type);
|
|
break;
|
|
case 'end_conference':
|
|
logger.info("Terminating conference call in " + payload.room_id);
|
|
this.terminateCallApp(payload.room_id);
|
|
break;
|
|
case 'hangup_conference':
|
|
logger.info("Leaving conference call in "+ payload.room_id);
|
|
this.hangupCallApp(payload.room_id);
|
|
break;
|
|
case 'incoming_call':
|
|
{
|
|
// if the runtime env doesn't do VoIP, stop here.
|
|
if (!MatrixClientPeg.get().supportsVoip()) {
|
|
return;
|
|
}
|
|
|
|
const call = payload.call as MatrixCall;
|
|
|
|
const mappedRoomId = CallHandler.sharedInstance().roomIdForCall(call);
|
|
if (this.getCallForRoom(mappedRoomId)) {
|
|
logger.log(
|
|
"Got incoming call for room " + mappedRoomId +
|
|
" but there's already a call for this room: ignoring",
|
|
);
|
|
return;
|
|
}
|
|
|
|
Analytics.trackEvent('voip', 'receiveCall', 'type', call.type);
|
|
|
|
this.addCallForRoom(mappedRoomId, call);
|
|
this.setCallListeners(call);
|
|
// Explicitly handle first state change
|
|
this.onCallStateChanged(call.state, null, call);
|
|
|
|
// get ready to send encrypted events in the room, so if the user does answer
|
|
// the call, we'll be ready to send. NB. This is the protocol-level room ID not
|
|
// the mapped one: that's where we'll send the events.
|
|
const cli = MatrixClientPeg.get();
|
|
cli.prepareToEncrypt(cli.getRoom(call.roomId));
|
|
}
|
|
break;
|
|
case 'hangup':
|
|
case 'reject':
|
|
this.stopRingingIfPossible(this.calls.get(payload.room_id).callId);
|
|
|
|
if (!this.calls.get(payload.room_id)) {
|
|
return; // no call to hangup
|
|
}
|
|
if (payload.action === 'reject') {
|
|
this.calls.get(payload.room_id).reject();
|
|
} else {
|
|
this.calls.get(payload.room_id).hangup(CallErrorCode.UserHangup, false);
|
|
}
|
|
// don't remove the call yet: let the hangup event handler do it (otherwise it will throw
|
|
// the hangup event away)
|
|
break;
|
|
case 'hangup_all':
|
|
this.stopRingingIfPossible(this.calls.get(payload.room_id).callId);
|
|
|
|
for (const call of this.calls.values()) {
|
|
call.hangup(CallErrorCode.UserHangup, false);
|
|
}
|
|
break;
|
|
case 'answer': {
|
|
this.stopRingingIfPossible(this.calls.get(payload.room_id).callId);
|
|
|
|
if (!this.calls.has(payload.room_id)) {
|
|
return; // no call to answer
|
|
}
|
|
|
|
if (this.getAllActiveCalls().length > 1) {
|
|
Modal.createTrackedDialog('Call Handler', 'Existing Call', ErrorDialog, {
|
|
title: _t('Too Many Calls'),
|
|
description: _t("You've reached the maximum number of simultaneous calls."),
|
|
});
|
|
return;
|
|
}
|
|
|
|
const call = this.calls.get(payload.room_id);
|
|
call.answer();
|
|
this.setActiveCallRoomId(payload.room_id);
|
|
CountlyAnalytics.instance.trackJoinCall(payload.room_id, call.type === CallType.Video, false);
|
|
dis.dispatch({
|
|
action: "view_room",
|
|
room_id: payload.room_id,
|
|
});
|
|
break;
|
|
}
|
|
case Action.DialNumber:
|
|
this.dialNumber(payload.number);
|
|
break;
|
|
case Action.TransferCallToMatrixID:
|
|
this.startTransferToMatrixID(payload.call, payload.destination, payload.consultFirst);
|
|
break;
|
|
case Action.TransferCallToPhoneNumber:
|
|
this.startTransferToPhoneNumber(payload.call, payload.destination, payload.consultFirst);
|
|
break;
|
|
}
|
|
};
|
|
|
|
private stopRingingIfPossible(callId: string): void {
|
|
this.silencedCalls.delete(callId);
|
|
if (this.areAnyCallsUnsilenced()) return;
|
|
this.pause(AudioID.Ring);
|
|
}
|
|
|
|
private async dialNumber(number: string) {
|
|
const results = await this.pstnLookup(number);
|
|
if (!results || results.length === 0 || !results[0].userid) {
|
|
Modal.createTrackedDialog('', '', ErrorDialog, {
|
|
title: _t("Unable to look up phone number"),
|
|
description: _t("There was an error looking up the phone number"),
|
|
});
|
|
return;
|
|
}
|
|
const userId = results[0].userid;
|
|
|
|
// Now check to see if this is a virtual user, in which case we should find the
|
|
// native user
|
|
let nativeUserId;
|
|
if (this.getSupportsVirtualRooms()) {
|
|
const nativeLookupResults = await this.sipNativeLookup(userId);
|
|
const lookupSuccess = nativeLookupResults.length > 0 && nativeLookupResults[0].fields.lookup_success;
|
|
nativeUserId = lookupSuccess ? nativeLookupResults[0].userid : userId;
|
|
logger.log("Looked up " + number + " to " + userId + " and mapped to native user " + nativeUserId);
|
|
} else {
|
|
nativeUserId = userId;
|
|
}
|
|
|
|
const roomId = await ensureDMExists(MatrixClientPeg.get(), nativeUserId);
|
|
|
|
dis.dispatch({
|
|
action: 'view_room',
|
|
room_id: roomId,
|
|
});
|
|
|
|
await this.placeCall(roomId, PlaceCallType.Voice, null);
|
|
}
|
|
|
|
private async startTransferToPhoneNumber(call: MatrixCall, destination: string, consultFirst: boolean) {
|
|
const results = await this.pstnLookup(destination);
|
|
if (!results || results.length === 0 || !results[0].userid) {
|
|
Modal.createTrackedDialog('', '', ErrorDialog, {
|
|
title: _t("Unable to transfer call"),
|
|
description: _t("There was an error looking up the phone number"),
|
|
});
|
|
return;
|
|
}
|
|
|
|
await this.startTransferToMatrixID(call, results[0].userid, consultFirst);
|
|
}
|
|
|
|
private async startTransferToMatrixID(call: MatrixCall, destination: string, consultFirst: boolean) {
|
|
if (consultFirst) {
|
|
const dmRoomId = await ensureDMExists(MatrixClientPeg.get(), destination);
|
|
|
|
dis.dispatch({
|
|
action: 'place_call',
|
|
type: call.type,
|
|
room_id: dmRoomId,
|
|
transferee: call,
|
|
});
|
|
dis.dispatch({
|
|
action: 'view_room',
|
|
room_id: dmRoomId,
|
|
should_peek: false,
|
|
joining: false,
|
|
});
|
|
} else {
|
|
try {
|
|
await call.transfer(destination);
|
|
} catch (e) {
|
|
logger.log("Failed to transfer call", e);
|
|
Modal.createTrackedDialog('Failed to transfer call', '', ErrorDialog, {
|
|
title: _t('Transfer Failed'),
|
|
description: _t('Failed to transfer call'),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
setActiveCallRoomId(activeCallRoomId: string) {
|
|
logger.info("Setting call in room " + activeCallRoomId + " active");
|
|
|
|
for (const [roomId, call] of this.calls.entries()) {
|
|
if (call.state === CallState.Ended) continue;
|
|
|
|
if (roomId === activeCallRoomId) {
|
|
call.setRemoteOnHold(false);
|
|
} else {
|
|
logger.info("Holding call in room " + roomId + " because another call is being set active");
|
|
call.setRemoteOnHold(true);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @returns true if we are currently in any call where we haven't put the remote party on hold
|
|
*/
|
|
hasAnyUnheldCall() {
|
|
for (const call of this.calls.values()) {
|
|
if (call.state === CallState.Ended) continue;
|
|
if (!call.isRemoteOnHold()) return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private async startCallApp(roomId: string, type: string) {
|
|
dis.dispatch({
|
|
action: 'appsDrawer',
|
|
show: true,
|
|
});
|
|
|
|
// prevent double clicking the call button
|
|
const room = MatrixClientPeg.get().getRoom(roomId);
|
|
const jitsiWidget = WidgetStore.instance.getApps(roomId).find((app) => WidgetType.JITSI.matches(app.type));
|
|
if (jitsiWidget) {
|
|
// If there already is a Jitsi widget pin it
|
|
WidgetLayoutStore.instance.moveToContainer(room, jitsiWidget, Container.Top);
|
|
return;
|
|
}
|
|
|
|
const jitsiDomain = Jitsi.getInstance().preferredDomain;
|
|
const jitsiAuth = await Jitsi.getInstance().getJitsiAuth();
|
|
let confId;
|
|
if (jitsiAuth === 'openidtoken-jwt') {
|
|
// Create conference ID from room ID
|
|
// For compatibility with Jitsi, use base32 without padding.
|
|
// More details here:
|
|
// https://github.com/matrix-org/prosody-mod-auth-matrix-user-verification
|
|
confId = base32.stringify(Buffer.from(roomId), { pad: false });
|
|
} else {
|
|
// Create a random conference ID
|
|
const random = randomUppercaseString(1) + randomLowercaseString(23);
|
|
confId = 'Jitsi' + random;
|
|
}
|
|
|
|
let widgetUrl = WidgetUtils.getLocalJitsiWrapperUrl({ auth: jitsiAuth });
|
|
|
|
// TODO: Remove URL hacks when the mobile clients eventually support v2 widgets
|
|
const parsedUrl = new URL(widgetUrl);
|
|
parsedUrl.search = ''; // set to empty string to make the URL class use searchParams instead
|
|
parsedUrl.searchParams.set('confId', confId);
|
|
widgetUrl = parsedUrl.toString();
|
|
|
|
const widgetData = {
|
|
conferenceId: confId,
|
|
isAudioOnly: type === 'voice',
|
|
domain: jitsiDomain,
|
|
auth: jitsiAuth,
|
|
roomName: room.name,
|
|
};
|
|
|
|
const widgetId = (
|
|
'jitsi_' +
|
|
MatrixClientPeg.get().credentials.userId +
|
|
'_' +
|
|
Date.now()
|
|
);
|
|
|
|
WidgetUtils.setRoomWidget(roomId, widgetId, WidgetType.JITSI, widgetUrl, 'Jitsi', widgetData).then(() => {
|
|
logger.log('Jitsi widget added');
|
|
}).catch((e) => {
|
|
if (e.errcode === 'M_FORBIDDEN') {
|
|
Modal.createTrackedDialog('Call Failed', '', ErrorDialog, {
|
|
title: _t('Permission Required'),
|
|
description: _t("You do not have permission to start a conference call in this room"),
|
|
});
|
|
}
|
|
logger.error(e);
|
|
});
|
|
}
|
|
|
|
private terminateCallApp(roomId: string) {
|
|
Modal.createTrackedDialog('Confirm Jitsi Terminate', '', QuestionDialog, {
|
|
hasCancelButton: true,
|
|
title: _t("End conference"),
|
|
description: _t("This will end the conference for everyone. Continue?"),
|
|
button: _t("End conference"),
|
|
onFinished: (proceed) => {
|
|
if (!proceed) return;
|
|
|
|
// We'll just obliterate them all. There should only ever be one, but might as well
|
|
// be safe.
|
|
const roomInfo = WidgetStore.instance.getRoom(roomId);
|
|
const jitsiWidgets = roomInfo.widgets.filter(w => WidgetType.JITSI.matches(w.type));
|
|
jitsiWidgets.forEach(w => {
|
|
// setting invalid content removes it
|
|
WidgetUtils.setRoomWidget(roomId, w.id);
|
|
});
|
|
},
|
|
});
|
|
}
|
|
|
|
private hangupCallApp(roomId: string) {
|
|
const roomInfo = WidgetStore.instance.getRoom(roomId);
|
|
if (!roomInfo) return; // "should never happen" clauses go here
|
|
|
|
const jitsiWidgets = roomInfo.widgets.filter(w => WidgetType.JITSI.matches(w.type));
|
|
jitsiWidgets.forEach(w => {
|
|
const messaging = WidgetMessagingStore.instance.getMessagingForId(w.id);
|
|
if (!messaging) return; // more "should never happen" words
|
|
|
|
messaging.transport.send(ElementWidgetActions.HangupCall, {});
|
|
});
|
|
}
|
|
|
|
private addCallForRoom(roomId: string, call: MatrixCall, changedRooms = false): void {
|
|
if (this.calls.has(roomId)) {
|
|
logger.log(`Couldn't add call to room ${roomId}: already have a call for this room`);
|
|
throw new Error("Already have a call for room " + roomId);
|
|
}
|
|
|
|
logger.log("setting call for room " + roomId);
|
|
this.calls.set(roomId, call);
|
|
|
|
// Should we always emit CallsChanged too?
|
|
if (changedRooms) {
|
|
this.emit(CallHandlerEvent.CallChangeRoom, call);
|
|
} else {
|
|
this.emit(CallHandlerEvent.CallsChanged, this.calls);
|
|
}
|
|
}
|
|
}
|