From 0a90c982c715e987f77afcb91e2b1e9debc9b352 Mon Sep 17 00:00:00 2001 From: David Baker Date: Thu, 21 Jan 2021 19:20:35 +0000 Subject: [PATCH 1/3] Add VoIP user mapper The accompanying element-web PR with the config documentation should explain what this is & why. Internally, this breaks the assumption that call.roomId is the room that the call appears in for the user. call.roomId may now be a 'virtual' room while the react SDK actually displays it in a different room. React SDK always stores the calls under the user-facing rooms, and provides a function to get the user-facing room for a given call. --- src/CallHandler.tsx | 43 ++++++++--- src/VoipUserMapper.ts | 77 +++++++++++++++++++ src/components/views/rooms/MessageComposer.js | 9 ++- src/components/views/voip/CallView.tsx | 21 +++-- src/components/views/voip/IncomingCallBox.tsx | 6 +- src/settings/Settings.ts | 4 + .../room-list/filters/VisibilityProvider.ts | 14 ++-- test/VoipUserMapper-test.ts | 31 ++++++++ 8 files changed, 173 insertions(+), 32 deletions(-) create mode 100644 src/VoipUserMapper.ts create mode 100644 test/VoipUserMapper-test.ts diff --git a/src/CallHandler.tsx b/src/CallHandler.tsx index bcb2042f84..bc57f25813 100644 --- a/src/CallHandler.tsx +++ b/src/CallHandler.tsx @@ -83,6 +83,7 @@ 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 { roomForVirtualRoom, getOrCreateVirtualRoomForRoom } from './VoipUserMapper'; const CHECK_PSTN_SUPPORT_ATTEMPTS = 3; @@ -133,6 +134,15 @@ export default class 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 static roomIdForCall(call: MatrixCall) { + if (!call) return null; + return roomForVirtualRoom(call.roomId) || call.roomId; + } + start() { this.dispatcherRef = dis.register(this.onAction); // add empty handlers for media actions, otherwise the media keys @@ -284,11 +294,15 @@ export default class CallHandler { // 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 callForThisRoom = this.getCallForRoom(call.roomId); + const mappedRoomId = CallHandler.roomIdForCall(call); + + const callForThisRoom = this.getCallForRoom(mappedRoomId); return callForThisRoom && call.callId === callForThisRoom.callId; } private setCallListeners(call: MatrixCall) { + const mappedRoomId = CallHandler.roomIdForCall(call); + call.on(CallEvent.Error, (err: CallError) => { if (!this.matchesCallForThisRoom(call)) return; @@ -318,7 +332,7 @@ export default class CallHandler { Analytics.trackEvent('voip', 'callHangup'); - this.removeCallForRoom(call.roomId); + this.removeCallForRoom(mappedRoomId); }); call.on(CallEvent.State, (newState: CallState, oldState: CallState) => { if (!this.matchesCallForThisRoom(call)) return; @@ -343,7 +357,7 @@ export default class CallHandler { break; case CallState.Ended: Analytics.trackEvent('voip', 'callEnded', 'hangupReason', call.hangupReason); - this.removeCallForRoom(call.roomId); + this.removeCallForRoom(mappedRoomId); if (oldState === CallState.InviteSent && ( call.hangupParty === CallParty.Remote || (call.hangupParty === CallParty.Local && call.hangupReason === CallErrorCode.InviteTimeout) @@ -392,7 +406,7 @@ export default class CallHandler { this.pause(AudioID.Ringback); } - this.calls.set(newCall.roomId, newCall); + this.calls.set(mappedRoomId, newCall); this.setCallListeners(newCall); this.setCallState(newCall, newCall.state); }); @@ -404,13 +418,15 @@ export default class CallHandler { } private setCallState(call: MatrixCall, status: CallState) { + const mappedRoomId = CallHandler.roomIdForCall(call); + console.log( - `Call state in ${call.roomId} changed to ${status}`, + `Call state in ${mappedRoomId} changed to ${status}`, ); dis.dispatch({ action: 'call_state', - room_id: call.roomId, + room_id: mappedRoomId, state: status, }); } @@ -477,14 +493,20 @@ export default class CallHandler { }, null, true); } - private placeCall( + private async placeCall( roomId: string, type: PlaceCallType, localElement: HTMLVideoElement, remoteElement: HTMLVideoElement, ) { Analytics.trackEvent('voip', 'placeCall', 'type', type); CountlyAnalytics.instance.trackStartCall(roomId, type === PlaceCallType.Video, false); - const call = createNewMatrixCall(MatrixClientPeg.get(), roomId); + + const mappedRoomId = (await getOrCreateVirtualRoomForRoom(roomId)) || roomId; + logger.debug("Mapped real room " + roomId + " to room ID " + mappedRoomId); + + const call = createNewMatrixCall(MatrixClientPeg.get(), mappedRoomId); + this.calls.set(roomId, call); + this.setCallListeners(call); this.setCallAudioElement(call); @@ -586,13 +608,14 @@ export default class CallHandler { const call = payload.call as MatrixCall; - if (this.getCallForRoom(call.roomId)) { + const mappedRoomId = CallHandler.roomIdForCall(call); + if (this.getCallForRoom(mappedRoomId)) { // ignore multiple incoming calls to the same room return; } Analytics.trackEvent('voip', 'receiveCall', 'type', call.type); - this.calls.set(call.roomId, call) + this.calls.set(mappedRoomId, call) this.setCallListeners(call); } break; diff --git a/src/VoipUserMapper.ts b/src/VoipUserMapper.ts new file mode 100644 index 0000000000..c5de686ab8 --- /dev/null +++ b/src/VoipUserMapper.ts @@ -0,0 +1,77 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +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 { ensureDMExists, findDMForUser } from './createRoom'; +import { MatrixClientPeg } from "./MatrixClientPeg"; +import DMRoomMap from "./utils/DMRoomMap"; +import SdkConfig from "./SdkConfig"; + +// Functions for mapping users & rooms for the voip_mxid_translate_pattern +// config option + +export function voipUserMapperEnabled(): boolean { + return SdkConfig.get()['voip_mxid_translate_pattern'] !== undefined; +} + +function userToVirtualUser(userId: string, templateString?: string): string { + if (templateString === undefined) templateString = SdkConfig.get()['voip_mxid_translate_pattern']; + if (!templateString) return null; + return templateString.replace('${mxid}', encodeURIComponent(userId).replace(/%/g, '=').toLowerCase()); +} + +function virtualUserToUser(userId: string, templateString?: string): string { + if (templateString === undefined) templateString = SdkConfig.get()['voip_mxid_translate_pattern']; + if (!templateString) return null; + + const regexString = templateString.replace('${mxid}', '(.+)'); + + const match = userId.match('^' + regexString + '$'); + if (!match) return null; + + return decodeURIComponent(match[1].replace(/=/g, '%')); +} + +async function getOrCreateVirtualRoomForUser(userId: string):Promise { + const virtualUser = userToVirtualUser(userId); + if (!virtualUser) return null; + + return await ensureDMExists(MatrixClientPeg.get(), virtualUser); +} + +export async function getOrCreateVirtualRoomForRoom(roomId: string):Promise { + const user = DMRoomMap.shared().getUserIdForRoomId(roomId); + if (!user) return null; + return getOrCreateVirtualRoomForUser(user); +} + +export function roomForVirtualRoom(roomId: string):string { + const virtualUser = DMRoomMap.shared().getUserIdForRoomId(roomId); + if (!virtualUser) return null; + const realUser = virtualUserToUser(virtualUser); + const room = findDMForUser(MatrixClientPeg.get(), realUser); + if (room) { + return room.roomId; + } else { + return null; + } +} + +export function isVirtualRoom(roomId: string):boolean { + const virtualUser = DMRoomMap.shared().getUserIdForRoomId(roomId); + if (!virtualUser) return null; + const realUser = virtualUserToUser(virtualUser); + return Boolean(realUser); +} diff --git a/src/components/views/rooms/MessageComposer.js b/src/components/views/rooms/MessageComposer.js index 4ddff8f4b0..da430e622a 100644 --- a/src/components/views/rooms/MessageComposer.js +++ b/src/components/views/rooms/MessageComposer.js @@ -109,9 +109,12 @@ function HangupButton(props) { dis.dispatch({ action, - // hangup the call for this room, which may not be the room in props - // (e.g. conferences which will hangup the 1:1 room instead) - room_id: call.roomId, + // hangup the call for this room. NB. We use the room in props as the room ID + // as call.roomId may be the 'virtual room', and the dispatch actions always + // use the user-facing room (there was a time when we deliberately used + // call.roomId and *not* props.roomId, but that was for the old + // style Freeswitch conference calls and those times are gone.) + room_id: props.roomId, }); }; diff --git a/src/components/views/voip/CallView.tsx b/src/components/views/voip/CallView.tsx index 6fbc396bee..fac87bf02a 100644 --- a/src/components/views/voip/CallView.tsx +++ b/src/components/views/voip/CallView.tsx @@ -212,9 +212,10 @@ export default class CallView extends React.Component { }; private onExpandClick = () => { + const userFacingRoomId = CallHandler.roomIdForCall(this.props.call); dis.dispatch({ action: 'view_room', - room_id: this.props.call.roomId, + room_id: userFacingRoomId, }); }; @@ -340,27 +341,33 @@ export default class CallView extends React.Component { }; private onRoomAvatarClick = () => { + const userFacingRoomId = CallHandler.roomIdForCall(this.props.call); dis.dispatch({ action: 'view_room', - room_id: this.props.call.roomId, + room_id: userFacingRoomId, }); } private onSecondaryRoomAvatarClick = () => { + const userFacingRoomId = CallHandler.roomIdForCall(this.props.secondaryCall); + dis.dispatch({ action: 'view_room', - room_id: this.props.secondaryCall.roomId, + room_id: userFacingRoomId, }); } private onCallResumeClick = () => { - CallHandler.sharedInstance().setActiveCallRoomId(this.props.call.roomId); + const userFacingRoomId = CallHandler.roomIdForCall(this.props.call); + CallHandler.sharedInstance().setActiveCallRoomId(userFacingRoomId); } public render() { const client = MatrixClientPeg.get(); - const callRoom = client.getRoom(this.props.call.roomId); - const secCallRoom = this.props.secondaryCall ? client.getRoom(this.props.secondaryCall.roomId) : null; + const callRoomId = CallHandler.roomIdForCall(this.props.call); + const secondaryCallRoomId = CallHandler.roomIdForCall(this.props.secondaryCall); + const callRoom = client.getRoom(callRoomId); + const secCallRoom = this.props.secondaryCall ? client.getRoom(secondaryCallRoomId) : null; let dialPad; let contextMenu; @@ -456,7 +463,7 @@ export default class CallView extends React.Component { onClick={() => { dis.dispatch({ action: 'hangup', - room_id: this.props.call.roomId, + room_id: callRoomId, }); }} /> diff --git a/src/components/views/voip/IncomingCallBox.tsx b/src/components/views/voip/IncomingCallBox.tsx index 8e1d23e38e..a495093d85 100644 --- a/src/components/views/voip/IncomingCallBox.tsx +++ b/src/components/views/voip/IncomingCallBox.tsx @@ -70,7 +70,7 @@ export default class IncomingCallBox extends React.Component { e.stopPropagation(); dis.dispatch({ action: 'answer', - room_id: this.state.incomingCall.roomId, + room_id: CallHandler.roomIdForCall(this.state.incomingCall), }); }; @@ -78,7 +78,7 @@ export default class IncomingCallBox extends React.Component { e.stopPropagation(); dis.dispatch({ action: 'reject', - room_id: this.state.incomingCall.roomId, + room_id: CallHandler.roomIdForCall(this.state.incomingCall), }); }; @@ -89,7 +89,7 @@ export default class IncomingCallBox extends React.Component { let room = null; if (this.state.incomingCall) { - room = MatrixClientPeg.get().getRoom(this.state.incomingCall.roomId); + room = MatrixClientPeg.get().getRoom(CallHandler.roomIdForCall(this.state.incomingCall)); } const caller = room ? room.name : _t("Unknown caller"); diff --git a/src/settings/Settings.ts b/src/settings/Settings.ts index b239b809fe..4a1e4e630d 100644 --- a/src/settings/Settings.ts +++ b/src/settings/Settings.ts @@ -398,6 +398,10 @@ export const SETTINGS: {[setting: string]: ISetting} = { supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS, default: null, }, + "voip_mxid_translate_pattern": { + supportedLevels: LEVELS_UI_FEATURE, // not a UI feature, but same level (config only, maybe .well-known in future) + default: null, + }, "language": { supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG, default: "en", diff --git a/src/stores/room-list/filters/VisibilityProvider.ts b/src/stores/room-list/filters/VisibilityProvider.ts index 553dd33ce0..6bc790bb1e 100644 --- a/src/stores/room-list/filters/VisibilityProvider.ts +++ b/src/stores/room-list/filters/VisibilityProvider.ts @@ -16,6 +16,7 @@ import {Room} from "matrix-js-sdk/src/models/room"; import { RoomListCustomisations } from "../../../customisations/RoomList"; +import { isVirtualRoom, voipUserMapperEnabled } from "../../../VoipUserMapper"; export class VisibilityProvider { private static internalInstance: VisibilityProvider; @@ -31,18 +32,13 @@ export class VisibilityProvider { } public isRoomVisible(room: Room): boolean { - /* eslint-disable prefer-const */ let isVisible = true; // Returned at the end of this function let forced = false; // When true, this function won't bother calling the customisation points - /* eslint-enable prefer-const */ - // ------ - // TODO: The `if` statements to control visibility of custom room types - // would go here. The remainder of this function assumes that the statements - // will be here. - // - // When removing this comment block, please remove the lint disable lines in the area. - // ------ + if (voipUserMapperEnabled() && isVirtualRoom(room.roomId)) { + isVisible = false; + forced = true; + } const isVisibleFn = RoomListCustomisations.isRoomVisible; if (!forced && isVisibleFn) { diff --git a/test/VoipUserMapper-test.ts b/test/VoipUserMapper-test.ts new file mode 100644 index 0000000000..a736efd6be --- /dev/null +++ b/test/VoipUserMapper-test.ts @@ -0,0 +1,31 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +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 { userToVirtualUser, virtualUserToUser } from '../src/VoipUserMapper'; + +const templateString = '@_greatappservice_${mxid}:frooble.example'; +const realUser = '@alice:boop.example'; +const virtualUser = "@_greatappservice_%40alice%3Aboop.example:frooble.example"; + +describe('VoipUserMapper', function() { + it('translates users to virtual users', function() { + expect(userToVirtualUser(realUser, templateString)).toEqual(virtualUser); + }); + + it('translates users to virtual users', function() { + expect(virtualUserToUser(virtualUser, templateString)).toEqual(realUser); + }); +}); From ba45b47240cf2110edc142c3a678d05443ce2686 Mon Sep 17 00:00:00 2001 From: David Baker Date: Thu, 21 Jan 2021 22:42:12 +0000 Subject: [PATCH 2/3] Oops, the tests won't work if we don't export the functions --- src/VoipUserMapper.ts | 6 ++++-- test/VoipUserMapper-test.ts | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/VoipUserMapper.ts b/src/VoipUserMapper.ts index c5de686ab8..a4f5822065 100644 --- a/src/VoipUserMapper.ts +++ b/src/VoipUserMapper.ts @@ -26,13 +26,15 @@ export function voipUserMapperEnabled(): boolean { return SdkConfig.get()['voip_mxid_translate_pattern'] !== undefined; } -function userToVirtualUser(userId: string, templateString?: string): string { +// only exported for tests +export function userToVirtualUser(userId: string, templateString?: string): string { if (templateString === undefined) templateString = SdkConfig.get()['voip_mxid_translate_pattern']; if (!templateString) return null; return templateString.replace('${mxid}', encodeURIComponent(userId).replace(/%/g, '=').toLowerCase()); } -function virtualUserToUser(userId: string, templateString?: string): string { +// only exported for tests +export function virtualUserToUser(userId: string, templateString?: string): string { if (templateString === undefined) templateString = SdkConfig.get()['voip_mxid_translate_pattern']; if (!templateString) return null; diff --git a/test/VoipUserMapper-test.ts b/test/VoipUserMapper-test.ts index a736efd6be..ee45379e4c 100644 --- a/test/VoipUserMapper-test.ts +++ b/test/VoipUserMapper-test.ts @@ -18,7 +18,7 @@ import { userToVirtualUser, virtualUserToUser } from '../src/VoipUserMapper'; const templateString = '@_greatappservice_${mxid}:frooble.example'; const realUser = '@alice:boop.example'; -const virtualUser = "@_greatappservice_%40alice%3Aboop.example:frooble.example"; +const virtualUser = "@_greatappservice_=40alice=3aboop.example:frooble.example"; describe('VoipUserMapper', function() { it('translates users to virtual users', function() { From 3803a5ece1c8e411afe1b279e2d5c94768b04fd7 Mon Sep 17 00:00:00 2001 From: David Baker Date: Fri, 22 Jan 2021 11:00:12 +0000 Subject: [PATCH 3/3] This isn't a setting any more, just a regular ol' config option --- src/settings/Settings.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/settings/Settings.ts b/src/settings/Settings.ts index 4a1e4e630d..b239b809fe 100644 --- a/src/settings/Settings.ts +++ b/src/settings/Settings.ts @@ -398,10 +398,6 @@ export const SETTINGS: {[setting: string]: ISetting} = { supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS, default: null, }, - "voip_mxid_translate_pattern": { - supportedLevels: LEVELS_UI_FEATURE, // not a UI feature, but same level (config only, maybe .well-known in future) - default: null, - }, "language": { supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG, default: "en",