/* Copyright 2024 New Vector Ltd. Copyright 2019, 2020 The Matrix.org Foundation C.I.C. Copyright 2015, 2016 OpenMarket Ltd SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ import { MatrixClient, ClientEvent, Room, EventType, RoomCreateTypeField, RoomType, ICreateRoomOpts, HistoryVisibility, JoinRule, Preset, RestrictedAllowType, Visibility, } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; import Modal, { IHandle } from "./Modal"; import { _t, UserFriendlyError } 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 { shouldForceDisableEncryption } from "./utils/crypto/shouldForceDisableEncryption"; import { waitForMember } from "./utils/membership"; import { PreferredRoomVersions } from "./utils/PreferredRoomVersions"; import SettingsStore from "./settings/SettingsStore"; import { MEGOLM_ENCRYPTION_ALGORITHM } from "./utils/crypto"; // 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 client The Matrix Client instance to create the room with * @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(client: MatrixClient, opts: IOpts): Promise { opts = opts || {}; if (opts.spinner === undefined) opts.spinner = true; if (opts.guestAccess === undefined) opts.guestAccess = true; if (opts.encryption === undefined) opts.encryption = false; 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; // We allow UX of DMing ourselves as a form of creating a personal room but the server throws // an error when a user tries to invite themselves so we filter it out if (opts.dmUserId && opts.dmUserId !== client.getUserId() && createOpts.invite === undefined) { switch (getAddressType(opts.dmUserId)) { case "mx-user-id": createOpts.invite = [opts.dmUserId]; break; case "email": { const isUrl = client.getIdentityServerUrl(true); if (!isUrl) { throw new UserFriendlyError("cannot_invite_without_identity_server"); } createOpts.invite_3pid = [ { id_server: isUrl, medium: "email", address: opts.dmUserId, }, ]; break; } } } 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.getSafeUserId()]: 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 [ElementCall.MEMBER_EVENT_TYPE.name]: 0, // Make calls immutable, even to admins [ElementCall.CALL_EVENT_TYPE.name]: 200, }, users: { // Temporarily give ourselves the power to set up a call [client.getSafeUserId()]: 200, }, }; } } else if (SettingsStore.getValue("feature_group_calls")) { createOpts.power_level_content_override = { events: { ...DEFAULT_EVENT_POWER_LEVELS, // It should always (including non video rooms) be possible to join a group call. [ElementCall.MEMBER_EVENT_TYPE.name]: 0, // Make sure only admins can enable it (DEPRECATED) [ElementCall.CALL_EVENT_TYPE.name]: 100, }, }; } // 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: MEGOLM_ENCRYPTION_ALGORITHM, }, }); } if (opts.joinRule === JoinRule.Knock) { createOpts.room_version = PreferredRoomVersions.KnockRooms; } 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) { ({ content_uri: 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: IHandle | undefined; if (opts.spinner) modal = Modal.createDialog(Spinner, undefined, "mx_Dialog_spinner"); let roomId: string; let room: Promise; 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): Promise => { 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): void => { if (emittedRoom.roomId === roomId) { resolve(emittedRoom); client.off(ClientEvent.Room, onRoom); } }; client.on(ClientEvent.Room, onRoom); } }); if (opts.dmUserId) await Rooms.setDMRoom(client, roomId, opts.dmUserId); }) .then(() => { if (opts.parentSpace) { return SpaceStore.instance.addRoomToSpace( opts.parentSpace, roomId, [client.getDomain()!], opts.suggested, ); } }) .then(async (): Promise => { 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 await client.setPowerLevel(roomId, client.getUserId()!, 100); } 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 await client.setPowerLevel(roomId, client.getUserId()!, 100); } }) .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({ 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("create_room|generic_error"); 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("create_room|unsupported_version"); } Modal.createDialog(ErrorDialog, { title: _t("create_room|error_title"), 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[]): Promise { try { const usersDeviceMap = await client.getCrypto()?.getUserDeviceInfo(userIds, true); if (!usersDeviceMap) { return false; } for (const devices of usersDeviceMap.values()) { if (devices.size === 0) { // This user does not have any encryption-capable devices. return false; } } } catch (e) { logger.error("Error determining if it's possible to encrypt to all users: ", e); return false; // assume not } return true; } // 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 { const existingDMRoom = findDMForUser(client, userId); let roomId: string | null; if (existingDMRoom) { roomId = existingDMRoom.roomId; } else { roomId = await createRoom(client, { 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 { const existingDMRoom = findDMForUser(client, userId); let roomId: string | null; if (existingDMRoom) { roomId = existingDMRoom.roomId; } else { let encryption: boolean | undefined; if (privateShouldBeEncrypted(client)) { encryption = await canEncryptToAllUsers(client, [userId]); } roomId = await createRoom(client, { encryption, dmUserId: userId, spinner: false, andView: false }); if (!roomId) return null; await waitForMember(client, roomId, userId); } return roomId; } interface AllowedEncryptionSetting { /** * True when the user is allowed to choose whether encryption is enabled */ allowChange: boolean; /** * Set when user is not allowed to choose encryption setting * True when encryption is forced to enabled */ forcedValue?: boolean; } /** * Check if server configuration supports the user changing encryption for a room * First check if server features force enable encryption for the given room type * If not, check if server .well-known forces encryption to disabled * If either are forced, then do not allow the user to change room's encryption * @param client * @param chatPreset chat type * @returns Promise */ export async function checkUserIsAllowedToChangeEncryption( client: MatrixClient, chatPreset: Preset, ): Promise { const doesServerForceEncryptionForPreset = await client.doesServerForceEncryptionForPreset(chatPreset); const doesWellKnownForceDisableEncryption = shouldForceDisableEncryption(client); // server is forcing encryption to ENABLED // while .well-known config is forcing it to DISABLED // server version config overrides wk config if (doesServerForceEncryptionForPreset && doesWellKnownForceDisableEncryption) { console.warn( `Conflicting e2ee settings: server config and .well-known configuration disagree. Using server forced encryption setting for chat type ${chatPreset}`, ); } if (doesServerForceEncryptionForPreset) { return { allowChange: false, forcedValue: true }; } if (doesWellKnownForceDisableEncryption) { return { allowChange: false, forcedValue: false }; } return { allowChange: true }; }