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.
This commit is contained in:
David Baker 2021-01-21 19:20:35 +00:00
parent 604b9378ce
commit 0a90c982c7
8 changed files with 173 additions and 32 deletions

View file

@ -83,6 +83,7 @@ import {UIFeature} from "./settings/UIFeature";
import { CallError } 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 { logger } from 'matrix-js-sdk/src/logger';
import { Action } from './dispatcher/actions'; import { Action } from './dispatcher/actions';
import { roomForVirtualRoom, getOrCreateVirtualRoomForRoom } from './VoipUserMapper';
const CHECK_PSTN_SUPPORT_ATTEMPTS = 3; const CHECK_PSTN_SUPPORT_ATTEMPTS = 3;
@ -133,6 +134,15 @@ export default class CallHandler {
return window.mxCallHandler; 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() { start() {
this.dispatcherRef = dis.register(this.onAction); this.dispatcherRef = dis.register(this.onAction);
// add empty handlers for media actions, otherwise the media keys // 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 // 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 // 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. // 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; return callForThisRoom && call.callId === callForThisRoom.callId;
} }
private setCallListeners(call: MatrixCall) { private setCallListeners(call: MatrixCall) {
const mappedRoomId = CallHandler.roomIdForCall(call);
call.on(CallEvent.Error, (err: CallError) => { call.on(CallEvent.Error, (err: CallError) => {
if (!this.matchesCallForThisRoom(call)) return; if (!this.matchesCallForThisRoom(call)) return;
@ -318,7 +332,7 @@ export default class CallHandler {
Analytics.trackEvent('voip', 'callHangup'); Analytics.trackEvent('voip', 'callHangup');
this.removeCallForRoom(call.roomId); this.removeCallForRoom(mappedRoomId);
}); });
call.on(CallEvent.State, (newState: CallState, oldState: CallState) => { call.on(CallEvent.State, (newState: CallState, oldState: CallState) => {
if (!this.matchesCallForThisRoom(call)) return; if (!this.matchesCallForThisRoom(call)) return;
@ -343,7 +357,7 @@ export default class CallHandler {
break; break;
case CallState.Ended: case CallState.Ended:
Analytics.trackEvent('voip', 'callEnded', 'hangupReason', call.hangupReason); Analytics.trackEvent('voip', 'callEnded', 'hangupReason', call.hangupReason);
this.removeCallForRoom(call.roomId); this.removeCallForRoom(mappedRoomId);
if (oldState === CallState.InviteSent && ( if (oldState === CallState.InviteSent && (
call.hangupParty === CallParty.Remote || call.hangupParty === CallParty.Remote ||
(call.hangupParty === CallParty.Local && call.hangupReason === CallErrorCode.InviteTimeout) (call.hangupParty === CallParty.Local && call.hangupReason === CallErrorCode.InviteTimeout)
@ -392,7 +406,7 @@ export default class CallHandler {
this.pause(AudioID.Ringback); this.pause(AudioID.Ringback);
} }
this.calls.set(newCall.roomId, newCall); this.calls.set(mappedRoomId, newCall);
this.setCallListeners(newCall); this.setCallListeners(newCall);
this.setCallState(newCall, newCall.state); this.setCallState(newCall, newCall.state);
}); });
@ -404,13 +418,15 @@ export default class CallHandler {
} }
private setCallState(call: MatrixCall, status: CallState) { private setCallState(call: MatrixCall, status: CallState) {
const mappedRoomId = CallHandler.roomIdForCall(call);
console.log( console.log(
`Call state in ${call.roomId} changed to ${status}`, `Call state in ${mappedRoomId} changed to ${status}`,
); );
dis.dispatch({ dis.dispatch({
action: 'call_state', action: 'call_state',
room_id: call.roomId, room_id: mappedRoomId,
state: status, state: status,
}); });
} }
@ -477,14 +493,20 @@ export default class CallHandler {
}, null, true); }, null, true);
} }
private placeCall( private async placeCall(
roomId: string, type: PlaceCallType, roomId: string, type: PlaceCallType,
localElement: HTMLVideoElement, remoteElement: HTMLVideoElement, localElement: HTMLVideoElement, remoteElement: HTMLVideoElement,
) { ) {
Analytics.trackEvent('voip', 'placeCall', 'type', type); Analytics.trackEvent('voip', 'placeCall', 'type', type);
CountlyAnalytics.instance.trackStartCall(roomId, type === PlaceCallType.Video, false); 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.calls.set(roomId, call);
this.setCallListeners(call); this.setCallListeners(call);
this.setCallAudioElement(call); this.setCallAudioElement(call);
@ -586,13 +608,14 @@ export default class CallHandler {
const call = payload.call as MatrixCall; 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 // ignore multiple incoming calls to the same room
return; return;
} }
Analytics.trackEvent('voip', 'receiveCall', 'type', call.type); Analytics.trackEvent('voip', 'receiveCall', 'type', call.type);
this.calls.set(call.roomId, call) this.calls.set(mappedRoomId, call)
this.setCallListeners(call); this.setCallListeners(call);
} }
break; break;

77
src/VoipUserMapper.ts Normal file
View file

@ -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<string> {
const virtualUser = userToVirtualUser(userId);
if (!virtualUser) return null;
return await ensureDMExists(MatrixClientPeg.get(), virtualUser);
}
export async function getOrCreateVirtualRoomForRoom(roomId: string):Promise<string> {
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);
}

View file

@ -109,9 +109,12 @@ function HangupButton(props) {
dis.dispatch({ dis.dispatch({
action, action,
// hangup the call for this room, which may not be the room in props // hangup the call for this room. NB. We use the room in props as the room ID
// (e.g. conferences which will hangup the 1:1 room instead) // as call.roomId may be the 'virtual room', and the dispatch actions always
room_id: call.roomId, // 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,
}); });
}; };

View file

@ -212,9 +212,10 @@ export default class CallView extends React.Component<IProps, IState> {
}; };
private onExpandClick = () => { private onExpandClick = () => {
const userFacingRoomId = CallHandler.roomIdForCall(this.props.call);
dis.dispatch({ dis.dispatch({
action: 'view_room', action: 'view_room',
room_id: this.props.call.roomId, room_id: userFacingRoomId,
}); });
}; };
@ -340,27 +341,33 @@ export default class CallView extends React.Component<IProps, IState> {
}; };
private onRoomAvatarClick = () => { private onRoomAvatarClick = () => {
const userFacingRoomId = CallHandler.roomIdForCall(this.props.call);
dis.dispatch({ dis.dispatch({
action: 'view_room', action: 'view_room',
room_id: this.props.call.roomId, room_id: userFacingRoomId,
}); });
} }
private onSecondaryRoomAvatarClick = () => { private onSecondaryRoomAvatarClick = () => {
const userFacingRoomId = CallHandler.roomIdForCall(this.props.secondaryCall);
dis.dispatch({ dis.dispatch({
action: 'view_room', action: 'view_room',
room_id: this.props.secondaryCall.roomId, room_id: userFacingRoomId,
}); });
} }
private onCallResumeClick = () => { private onCallResumeClick = () => {
CallHandler.sharedInstance().setActiveCallRoomId(this.props.call.roomId); const userFacingRoomId = CallHandler.roomIdForCall(this.props.call);
CallHandler.sharedInstance().setActiveCallRoomId(userFacingRoomId);
} }
public render() { public render() {
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();
const callRoom = client.getRoom(this.props.call.roomId); const callRoomId = CallHandler.roomIdForCall(this.props.call);
const secCallRoom = this.props.secondaryCall ? client.getRoom(this.props.secondaryCall.roomId) : null; const secondaryCallRoomId = CallHandler.roomIdForCall(this.props.secondaryCall);
const callRoom = client.getRoom(callRoomId);
const secCallRoom = this.props.secondaryCall ? client.getRoom(secondaryCallRoomId) : null;
let dialPad; let dialPad;
let contextMenu; let contextMenu;
@ -456,7 +463,7 @@ export default class CallView extends React.Component<IProps, IState> {
onClick={() => { onClick={() => {
dis.dispatch({ dis.dispatch({
action: 'hangup', action: 'hangup',
room_id: this.props.call.roomId, room_id: callRoomId,
}); });
}} }}
/> />

View file

@ -70,7 +70,7 @@ export default class IncomingCallBox extends React.Component<IProps, IState> {
e.stopPropagation(); e.stopPropagation();
dis.dispatch({ dis.dispatch({
action: 'answer', 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<IProps, IState> {
e.stopPropagation(); e.stopPropagation();
dis.dispatch({ dis.dispatch({
action: 'reject', 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<IProps, IState> {
let room = null; let room = null;
if (this.state.incomingCall) { 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"); const caller = room ? room.name : _t("Unknown caller");

View file

@ -398,6 +398,10 @@ export const SETTINGS: {[setting: string]: ISetting} = {
supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS, supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS,
default: null, 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": { "language": {
supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG, supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG,
default: "en", default: "en",

View file

@ -16,6 +16,7 @@
import {Room} from "matrix-js-sdk/src/models/room"; import {Room} from "matrix-js-sdk/src/models/room";
import { RoomListCustomisations } from "../../../customisations/RoomList"; import { RoomListCustomisations } from "../../../customisations/RoomList";
import { isVirtualRoom, voipUserMapperEnabled } from "../../../VoipUserMapper";
export class VisibilityProvider { export class VisibilityProvider {
private static internalInstance: VisibilityProvider; private static internalInstance: VisibilityProvider;
@ -31,18 +32,13 @@ export class VisibilityProvider {
} }
public isRoomVisible(room: Room): boolean { public isRoomVisible(room: Room): boolean {
/* eslint-disable prefer-const */
let isVisible = true; // Returned at the end of this function let isVisible = true; // Returned at the end of this function
let forced = false; // When true, this function won't bother calling the customisation points let forced = false; // When true, this function won't bother calling the customisation points
/* eslint-enable prefer-const */
// ------ if (voipUserMapperEnabled() && isVirtualRoom(room.roomId)) {
// TODO: The `if` statements to control visibility of custom room types isVisible = false;
// would go here. The remainder of this function assumes that the statements forced = true;
// will be here. }
//
// When removing this comment block, please remove the lint disable lines in the area.
// ------
const isVisibleFn = RoomListCustomisations.isRoomVisible; const isVisibleFn = RoomListCustomisations.isRoomVisible;
if (!forced && isVisibleFn) { if (!forced && isVisibleFn) {

View file

@ -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);
});
});