/* 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 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. */ import React from 'react'; import { base32 } from "rfc4648"; import { MatrixCall, CallErrorCode, CallState, CallEvent, CallParty, CallType } from "matrix-js-sdk/src/webrtc/call"; import { CallError } from "matrix-js-sdk/src/webrtc/call"; import { logger } from 'matrix-js-sdk/src/logger'; import { randomUppercaseString, randomLowercaseString } from "matrix-js-sdk/src/randomstring"; import EventEmitter from 'events'; import { RuleId, TweakName, Tweaks } from "matrix-js-sdk/src/@types/PushRules"; import { PushProcessor } from 'matrix-js-sdk/src/pushprocessor'; import { SyncState } from "matrix-js-sdk/src/sync"; 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 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 Analytics from './Analytics'; import CountlyAnalytics from "./CountlyAnalytics"; import { UIFeature } from "./settings/UIFeature"; import { Action } from './dispatcher/actions'; import VoipUserMapper from './VoipUserMapper'; import { addManagedHybridWidget, isManagedHybridWidgetEnabled } from './widgets/ManagedHybrid'; import SdkConfig from './SdkConfig'; import { ensureDMExists, findDMForUser } from './createRoom'; 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 CallHandlerEvent { CallsChanged = "calls_changed", CallChangeRoom = "call_change_room", SilencedCallsChanged = "silenced_calls_changed", CallState = "call_state", } /** * CallHandler manages all currently active calls. It should be used for * placing, answering, rejecting and hanging up calls. It also handles ringing, * PSTN support and other things. */ export default class CallHandler extends EventEmitter { private calls = new Map(); // 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(); // callId (target) -> call (transferee) private audioPromises = new Map>(); 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(); 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(); private silencedCalls = new Set(); // callIds public static get instance() { 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; } public start(): void { // 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); } public stop(): void { const cli = MatrixClientPeg.get(); if (cli) { cli.removeListener('Call.incoming', this.onCallIncoming); } } public silenceCall(callId: string): void { 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): void { 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: number): Promise { 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(): boolean { return this.supportsPstnProtocol; } public getSupportsVirtualRooms(): boolean { return this.supportsSipNativeVirtual; } public pstnLookup(phoneNumber: string): Promise { return MatrixClientPeg.get().getThirdpartyUser( this.pstnSupportPrefixed ? PROTOCOL_PSTN_PREFIXED : PROTOCOL_PSTN, { 'm.id.phone': phoneNumber, }, ); } public sipVirtualLookup(nativeMxid: string): Promise { return MatrixClientPeg.get().getThirdpartyUser( PROTOCOL_SIP_VIRTUAL, { 'native_mxid': nativeMxid, }, ); } public sipNativeLookup(virtualMxid: string): Promise { return MatrixClientPeg.get().getThirdpartyUser( PROTOCOL_SIP_NATIVE, { 'virtual_mxid': virtualMxid, }, ); } private onCallIncoming = (call: MatrixCall): void => { // if the runtime env doesn't do VoIP, stop here. if (!MatrixClientPeg.get().supportsVoip()) { return; } const mappedRoomId = CallHandler.instance.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)); }; public getCallById(callId: string): MatrixCall { for (const call of this.calls.values()) { if (call.callId === callId) return call; } return null; } public getCallForRoom(roomId: string): MatrixCall | null { return this.calls.get(roomId) || null; } public getAnyActiveCall(): MatrixCall | null { for (const call of this.calls.values()) { if (call.state !== CallState.Ended) { return call; } } return null; } public getAllActiveCalls(): MatrixCall[] { const activeCalls = []; for (const call of this.calls.values()) { if (call.state !== CallState.Ended && call.state !== CallState.Ringing) { activeCalls.push(call); } } return activeCalls; } public getAllActiveCallsNotInRoom(notInThisRoomId: string): MatrixCall[] { const callsNotInThatRoom = []; for (const [roomId, call] of this.calls.entries()) { if (roomId !== notInThisRoomId && call.state !== CallState.Ended) { callsNotInThatRoom.push(call); } } return callsNotInThatRoom; } public getAllActiveCallsForPip(roomId: string) { const room = MatrixClientPeg.get().getRoom(roomId); if (WidgetLayoutStore.instance.hasMaximisedWidget(room)) { // This checks if there is space for the call view in the aux panel // If there is no space any call should be displayed in PiP return this.getAllActiveCalls(); } return this.getAllActiveCallsNotInRoom(roomId); } public getTransfereeForCallId(callId: string): MatrixCall { return this.transferees[callId]; } public play(audioId: AudioID): void { // 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()); } } } public pause(audioId: AudioID): void { // 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): boolean { // 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): void { 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); dis.dispatch({ action: 'call_state', room_id: mappedRoomId, state: 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): Promise { 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): void { const mappedRoomId = CallHandler.instance.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); } this.emit(CallHandlerEvent.CallState, mappedRoomId, status); } private removeCallForRoom(roomId: string): void { logger.log("Removing call for room ", roomId); this.calls.delete(roomId); this.emit(CallHandlerEvent.CallsChanged, this.calls); } private showICEFallbackPrompt(): void { const cli = MatrixClientPeg.get(); const code = sub => { sub }; Modal.createTrackedDialog('No TURN servers', '', QuestionDialog, { title: _t("Call failed due to misconfigured server"), description:

{ _t( "Please ask the administrator of your homeserver " + "(%(homeserverDomain)s) to configure a TURN server in " + "order for calls to work reliably.", { homeserverDomain: cli.getDomain() }, { code }, ) }

{ _t( "Alternatively, you can try to use the public server at " + "turn.matrix.org, 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 }, ) }

, 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): void { let title; let description; if (call.type === CallType.Voice) { title = _t("Unable to access microphone"); description =
{ _t( "Call failed because microphone could not be accessed. " + "Check that a microphone is plugged in and set up correctly.", ) }
; } else if (call.type === CallType.Video) { title = _t("Unable to access webcam / microphone"); description =
{ _t("Call failed because webcam or microphone could not be accessed. Check that:") }
  • { _t("A microphone and webcam are plugged in and set up correctly") }
  • { _t("Permission is granted to use the webcam") }
  • { _t("No other application is using the webcam") }
; } Modal.createTrackedDialog('Media capture failed', '', ErrorDialog, { title, description, }, null, true); } private async placeMatrixCall(roomId: string, type: CallType, transferee?: MatrixCall): Promise { Analytics.trackEvent('voip', 'placeCall', 'type', type); CountlyAnalytics.instance.trackStartCall(roomId, type === CallType.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 === CallType.Voice) { call.placeVoiceCall(); } else if (type === 'video') { call.placeVideoCall(); } else { logger.error("Unknown conf call type: " + type); } } public placeCall(roomId: string, type?: CallType, transferee?: MatrixCall): void { // We might be using managed hybrid widgets if (isManagedHybridWidgetEnabled()) { addManagedHybridWidget(roomId); return; } // if the runtime env doesn't do VoIP, whine. if (!MatrixClientPeg.get().supportsVoip()) { Modal.createTrackedDialog('Call Handler', 'VoIP is unsupported', ErrorDialog, { title: _t('Calls are unsupported'), description: _t('You cannot place calls in this browser.'), }); return; } if (MatrixClientPeg.get().getSyncState() === SyncState.Error) { Modal.createTrackedDialog('Call Handler', 'Sync error', ErrorDialog, { title: _t('Connectivity to the server has been lost'), description: _t('You cannot place calls without a connection to the server.'), }); 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(roomId); if (!room) { logger.error(`Room ${roomId} 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 joinedMemberCount = room.getJoinedMemberCount(); const invitedAndJoinedMemberCount = room.getInvitedAndJoinedMemberCount(); if (joinedMemberCount <= 1) { Modal.createTrackedDialog('Call Handler', 'Cannot place call with self', ErrorDialog, { description: _t('You cannot place a call with yourself.'), }); } else if (invitedAndJoinedMemberCount === 2) { logger.info(`Place ${type} call in ${roomId}`); this.placeMatrixCall(roomId, type, transferee); } else { // > 2 this.placeJitsiCall(roomId, type); } } public hangupAllCalls(): void { for (const call of this.calls.values()) { this.stopRingingIfPossible(call.callId); call.hangup(CallErrorCode.UserHangup, false); } } public hangupOrReject(roomId: string, reject?: boolean): void { const call = this.calls.get(roomId); // no call to hangup if (!call) return; this.stopRingingIfPossible(call.callId); if (reject) { call.reject(); } else { call.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) } public answerCall(roomId: string): void { const call = this.calls.get(roomId); this.stopRingingIfPossible(call.callId); // no call to answer if (!this.calls.has(roomId)) return; 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; } call.answer(); this.setActiveCallRoomId(roomId); CountlyAnalytics.instance.trackJoinCall(roomId, call.type === CallType.Video, false); dis.dispatch({ action: Action.ViewRoom, room_id: roomId, }); } private stopRingingIfPossible(callId: string): void { this.silencedCalls.delete(callId); if (this.areAnyCallsUnsilenced()) return; this.pause(AudioID.Ring); } public async dialNumber(number: string): Promise { 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: Action.ViewRoom, room_id: roomId, }); await this.placeMatrixCall(roomId, CallType.Voice, null); } public async startTransferToPhoneNumber( call: MatrixCall, destination: string, consultFirst: boolean, ): Promise { 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); } public async startTransferToMatrixID( call: MatrixCall, destination: string, consultFirst: boolean, ): Promise { if (consultFirst) { const dmRoomId = await ensureDMExists(MatrixClientPeg.get(), destination); this.placeCall(dmRoomId, call.type, call); dis.dispatch({ action: Action.ViewRoom, 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'), }); } } } public setActiveCallRoomId(activeCallRoomId: string): void { 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 */ public hasAnyUnheldCall(): boolean { for (const call of this.calls.values()) { if (call.state === CallState.Ended) continue; if (!call.isRemoteOnHold()) return true; } return false; } private async placeJitsiCall(roomId: string, type: string): Promise { logger.info("Place conference call in " + roomId); Analytics.trackEvent('voip', 'placeConferenceCall'); CountlyAnalytics.instance.trackStartCall(roomId, type === CallType.Video, true); 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); }); } public terminateCallApp(roomId: string): void { logger.info("Terminating conference call in " + roomId); 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); }); }, }); } public hangupCallApp(roomId: string): void { logger.info("Leaving conference call in " + roomId); 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); } } }