Files
element-web/src/createRoom.ts
Robin cb735c9439 Element Call video rooms (#9267)
* Add an element_call_url config option

* Add a labs flag for Element Call video rooms

* Add Element Call as another video rooms backend

* Consolidate event power level defaults

* Remember to clean up participantsExpirationTimer

* Fix a code smell

* Test the clean method

* Fix some strict mode errors

* Test that clean still works when there are no state events

* Test auto-approval of Element Call widget capabilities

* Deduplicate some code to placate SonarCloud

* Fix more strict mode errors

* Test that calls disconnect when leaving the room

* Test the get methods of JitsiCall and ElementCall more

* Test Call.ts even more

* Test creation of Element video rooms

* Test that createRoom works for non-video-rooms

* Test Call's get method rather than the methods of derived classes

* Ensure that the clean method is able to preserve devices

* Remove duplicate clean method

* Fix lints

* Fix some strict mode errors in RoomPreviewCard

* Test RoomPreviewCard changes

* Quick and dirty hotfix for the community testing session

* Revert "Quick and dirty hotfix for the community testing session"

This reverts commit 37056514fbc040aaf1bff2539da770a1c8ba72a2.

* Fix the event schema for org.matrix.msc3401.call.member devices

* Remove org.matrix.call_duplicate_session from Element Call capabilities

It's no longer used by Element Call when running as a widget.

* Replace element_call_url with a map

* Make PiPs work for virtual widgets

* Auto-approve room timeline capability

Because Element Call uses this now

* Create a reusable isVideoRoom util
2022-09-16 11:12:27 -04:00

423 lines
16 KiB
TypeScript

/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2019, 2020 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 { MatrixClient, ClientEvent } from "matrix-js-sdk/src/client";
import { Room } from "matrix-js-sdk/src/models/room";
import { EventType, RoomCreateTypeField, RoomType } from "matrix-js-sdk/src/@types/event";
import { ICreateRoomOpts } from "matrix-js-sdk/src/@types/requests";
import {
HistoryVisibility,
JoinRule,
Preset,
RestrictedAllowType,
Visibility,
} from "matrix-js-sdk/src/@types/partials";
import { logger } from "matrix-js-sdk/src/logger";
import { MatrixClientPeg } from './MatrixClientPeg';
import Modal from './Modal';
import { _t } from './languageHandler';
import dis from "./dispatcher/dispatcher";
import * as Rooms from "./Rooms";
import { getAddressType } from "./UserAddress";
import { VIRTUAL_ROOM_EVENT_TYPE } from "./call-types";
import SpaceStore from "./stores/spaces/SpaceStore";
import { makeSpaceParentEvent } from "./utils/space";
import { JitsiCall, ElementCall } from "./models/Call";
import { Action } from "./dispatcher/actions";
import ErrorDialog from "./components/views/dialogs/ErrorDialog";
import Spinner from "./components/views/elements/Spinner";
import { ViewRoomPayload } from "./dispatcher/payloads/ViewRoomPayload";
import { findDMForUser } from "./utils/dm/findDMForUser";
import { privateShouldBeEncrypted } from "./utils/rooms";
import { waitForMember } from "./utils/membership";
import { PreferredRoomVersions } from "./utils/PreferredRoomVersions";
// we define a number of interfaces which take their names from the js-sdk
/* eslint-disable camelcase */
export interface IOpts {
dmUserId?: string;
createOpts?: ICreateRoomOpts;
spinner?: boolean;
guestAccess?: boolean;
encryption?: boolean;
inlineErrors?: boolean;
andView?: boolean;
avatar?: File | string; // will upload if given file, else mxcUrl is needed
roomType?: RoomType | string;
historyVisibility?: HistoryVisibility;
parentSpace?: Room;
// contextually only makes sense if parentSpace is specified, if true then will be added to parentSpace as suggested
suggested?: boolean;
joinRule?: JoinRule;
}
const DEFAULT_EVENT_POWER_LEVELS = {
[EventType.RoomName]: 50,
[EventType.RoomAvatar]: 50,
[EventType.RoomPowerLevels]: 100,
[EventType.RoomHistoryVisibility]: 100,
[EventType.RoomCanonicalAlias]: 50,
[EventType.RoomTombstone]: 100,
[EventType.RoomServerAcl]: 100,
[EventType.RoomEncryption]: 100,
};
/**
* Create a new room, and switch to it.
*
* @param {object=} opts parameters for creating the room
* @param {string=} opts.dmUserId If specified, make this a DM room for this user and invite them
* @param {object=} opts.createOpts set of options to pass to createRoom call.
* @param {bool=} opts.spinner True to show a modal spinner while the room is created.
* Default: True
* @param {bool=} opts.guestAccess Whether to enable guest access.
* Default: True
* @param {bool=} opts.encryption Whether to enable encryption.
* Default: False
* @param {bool=} opts.inlineErrors True to raise errors off the promise instead of resolving to null.
* Default: False
* @param {bool=} opts.andView True to dispatch an action to view the room once it has been created.
*
* @returns {Promise} which resolves to the room id, or null if the
* action was aborted or failed.
*/
export default async function createRoom(opts: IOpts): Promise<string | null> {
opts = opts || {};
if (opts.spinner === undefined) opts.spinner = true;
if (opts.guestAccess === undefined) opts.guestAccess = true;
if (opts.encryption === undefined) opts.encryption = false;
const client = MatrixClientPeg.get();
if (client.isGuest()) {
dis.dispatch({ action: 'require_registration' });
return null;
}
const defaultPreset = opts.dmUserId ? Preset.TrustedPrivateChat : Preset.PrivateChat;
// set some defaults for the creation
const createOpts: ICreateRoomOpts = opts.createOpts || {};
createOpts.preset = createOpts.preset || defaultPreset;
createOpts.visibility = createOpts.visibility || Visibility.Private;
if (opts.dmUserId && createOpts.invite === undefined) {
switch (getAddressType(opts.dmUserId)) {
case 'mx-user-id':
createOpts.invite = [opts.dmUserId];
break;
case 'email':
createOpts.invite_3pid = [{
id_server: MatrixClientPeg.get().getIdentityServerUrl(true),
medium: 'email',
address: opts.dmUserId,
}];
}
}
if (opts.dmUserId && createOpts.is_direct === undefined) {
createOpts.is_direct = true;
}
if (opts.roomType) {
createOpts.creation_content = {
...createOpts.creation_content,
[RoomCreateTypeField]: opts.roomType,
};
// Video rooms require custom power levels
if (opts.roomType === RoomType.ElementVideo) {
createOpts.power_level_content_override = {
events: {
...DEFAULT_EVENT_POWER_LEVELS,
// Allow all users to send call membership updates
[JitsiCall.MEMBER_EVENT_TYPE]: 0,
// Make widgets immutable, even to admins
"im.vector.modular.widgets": 200,
},
users: {
// Temporarily give ourselves the power to set up a widget
[client.getUserId()!]: 200,
},
};
} else if (opts.roomType === RoomType.UnstableCall) {
createOpts.power_level_content_override = {
events: {
...DEFAULT_EVENT_POWER_LEVELS,
// Allow all users to send call membership updates
"org.matrix.msc3401.call.member": 0,
// Make calls immutable, even to admins
"org.matrix.msc3401.call": 200,
},
users: {
// Temporarily give ourselves the power to set up a call
[client.getUserId()!]: 200,
},
};
}
}
// By default, view the room after creating it
if (opts.andView === undefined) {
opts.andView = true;
}
createOpts.initial_state = createOpts.initial_state || [];
// Allow guests by default since the room is private and they'd
// need an invite. This means clicking on a 3pid invite email can
// actually drop you right in to a chat.
if (opts.guestAccess) {
createOpts.initial_state.push({
type: 'm.room.guest_access',
state_key: '',
content: {
guest_access: 'can_join',
},
});
}
if (opts.encryption) {
createOpts.initial_state.push({
type: 'm.room.encryption',
state_key: '',
content: {
algorithm: 'm.megolm.v1.aes-sha2',
},
});
}
if (opts.parentSpace) {
createOpts.initial_state.push(makeSpaceParentEvent(opts.parentSpace, true));
if (!opts.historyVisibility) {
opts.historyVisibility = createOpts.preset === Preset.PublicChat
? HistoryVisibility.WorldReadable
: HistoryVisibility.Invited;
}
if (opts.joinRule === JoinRule.Restricted) {
createOpts.room_version = PreferredRoomVersions.RestrictedRooms;
createOpts.initial_state.push({
type: EventType.RoomJoinRules,
content: {
"join_rule": JoinRule.Restricted,
"allow": [{
"type": RestrictedAllowType.RoomMembership,
"room_id": opts.parentSpace.roomId,
}],
},
});
}
}
// we handle the restricted join rule in the parentSpace handling block above
if (opts.joinRule && opts.joinRule !== JoinRule.Restricted) {
createOpts.initial_state.push({
type: EventType.RoomJoinRules,
content: { join_rule: opts.joinRule },
});
}
if (opts.avatar) {
let url = opts.avatar;
if (opts.avatar instanceof File) {
url = await client.uploadContent(opts.avatar);
}
createOpts.initial_state.push({
type: EventType.RoomAvatar,
content: { url },
});
}
if (opts.historyVisibility) {
createOpts.initial_state.push({
type: EventType.RoomHistoryVisibility,
content: {
"history_visibility": opts.historyVisibility,
},
});
}
let modal;
if (opts.spinner) modal = Modal.createDialog(Spinner, null, 'mx_Dialog_spinner');
let roomId: string;
let room: Promise<Room>;
return client.createRoom(createOpts).catch(function(err) {
// NB This checks for the Synapse-specific error condition of a room creation
// having been denied because the requesting user wanted to publish the room,
// but the server denies them that permission (via room_list_publication_rules).
// The check below responds by retrying without publishing the room.
if (err.httpStatus === 403 && err.errcode === "M_UNKNOWN" && err.data.error === "Not allowed to publish room") {
logger.warn("Failed to publish room, try again without publishing it");
createOpts.visibility = Visibility.Private;
return client.createRoom(createOpts);
} else {
return Promise.reject(err);
}
}).finally(function() {
if (modal) modal.close();
}).then(async res => {
roomId = res.room_id;
room = new Promise(resolve => {
const storedRoom = client.getRoom(roomId);
if (storedRoom) {
resolve(storedRoom);
} else {
// The room hasn't arrived down sync yet
const onRoom = (emittedRoom: Room) => {
if (emittedRoom.roomId === roomId) {
resolve(emittedRoom);
client.off(ClientEvent.Room, onRoom);
}
};
client.on(ClientEvent.Room, onRoom);
}
});
if (opts.dmUserId) await Rooms.setDMRoom(roomId, opts.dmUserId);
}).then(() => {
if (opts.parentSpace) {
return SpaceStore.instance.addRoomToSpace(opts.parentSpace, roomId, [client.getDomain()], opts.suggested);
}
}).then(async () => {
if (opts.roomType === RoomType.ElementVideo) {
// Set up this video room with a Jitsi call
await JitsiCall.create(await room);
// Reset our power level back to admin so that the widget becomes immutable
const plEvent = (await room).currentState.getStateEvents(EventType.RoomPowerLevels, "");
await client.setPowerLevel(roomId, client.getUserId()!, 100, plEvent);
} else if (opts.roomType === RoomType.UnstableCall) {
// Set up this video room with an Element call
await ElementCall.create(await room);
// Reset our power level back to admin so that the call becomes immutable
const plEvent = (await room).currentState.getStateEvents(EventType.RoomPowerLevels, "");
await client.setPowerLevel(roomId, client.getUserId()!, 100, plEvent);
}
}).then(function() {
// NB we haven't necessarily blocked on the room promise, so we race
// here with the client knowing that the room exists, causing things
// like https://github.com/vector-im/vector-web/issues/1813
// Even if we were to block on the echo, servers tend to split the room
// state over multiple syncs so we can't atomically know when we have the
// entire thing.
if (opts.andView) {
dis.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: roomId,
should_peek: false,
// Creating a room will have joined us to the room,
// so we are expecting the room to come down the sync
// stream, if it hasn't already.
joining: true,
justCreatedOpts: opts,
metricsTrigger: "Created",
});
}
return roomId;
}, function(err) {
// Raise the error if the caller requested that we do so.
if (opts.inlineErrors) throw err;
// We also failed to join the room (this sets joining to false in RoomViewStore)
dis.dispatch({
action: Action.JoinRoomError,
roomId,
});
logger.error("Failed to create room " + roomId + " " + err);
let description = _t("Server may be unavailable, overloaded, or you hit a bug.");
if (err.errcode === "M_UNSUPPORTED_ROOM_VERSION") {
// Technically not possible with the UI as of April 2019 because there's no
// options for the user to change this. However, it's not a bad thing to report
// the error to the user for if/when the UI is available.
description = _t("The server does not support the room version specified.");
}
Modal.createDialog(ErrorDialog, {
title: _t("Failure to create room"),
description,
});
return null;
});
}
/*
* Ensure that for every user in a room, there is at least one device that we
* can encrypt to.
*/
export async function canEncryptToAllUsers(client: MatrixClient, userIds: string[]) {
try {
const usersDeviceMap = await client.downloadKeys(userIds);
// { "@user:host": { "DEVICE": {...}, ... }, ... }
return Object.values(usersDeviceMap).every((userDevices) =>
// { "DEVICE": {...}, ... }
Object.keys(userDevices).length > 0,
);
} catch (e) {
logger.error("Error determining if it's possible to encrypt to all users: ", e);
return false; // assume not
}
}
// Similar to ensureDMExists but also adds creation content
// without polluting ensureDMExists with unrelated stuff (also
// they're never encrypted).
export async function ensureVirtualRoomExists(
client: MatrixClient, userId: string, nativeRoomId: string,
): Promise<string> {
const existingDMRoom = findDMForUser(client, userId);
let roomId;
if (existingDMRoom) {
roomId = existingDMRoom.roomId;
} else {
roomId = await createRoom({
dmUserId: userId,
spinner: false,
andView: false,
createOpts: {
creation_content: {
// This allows us to recognise that the room is a virtual room
// when it comes down our sync stream (we also put the ID of the
// respective native room in there because why not?)
[VIRTUAL_ROOM_EVENT_TYPE]: nativeRoomId,
},
},
});
}
return roomId;
}
export async function ensureDMExists(client: MatrixClient, userId: string): Promise<string> {
const existingDMRoom = findDMForUser(client, userId);
let roomId;
if (existingDMRoom) {
roomId = existingDMRoom.roomId;
} else {
let encryption: boolean = undefined;
if (privateShouldBeEncrypted()) {
encryption = await canEncryptToAllUsers(client, [userId]);
}
roomId = await createRoom({ encryption, dmUserId: userId, spinner: false, andView: false });
await waitForMember(client, roomId, userId);
}
return roomId;
}