diff --git a/src/CallHandler.tsx b/src/CallHandler.tsx index 733c9b8499..380f337cda 100644 --- a/src/CallHandler.tsx +++ b/src/CallHandler.tsx @@ -58,9 +58,9 @@ import IncomingCallToast, { getIncomingCallToastKey } from './toasts/IncomingCal import ToastStore from './stores/ToastStore'; import Resend from './Resend'; import { ViewRoomPayload } from "./dispatcher/payloads/ViewRoomPayload"; -import { findDMForUser } from "./utils/direct-messages"; import { KIND_CALL_TRANSFER } from "./components/views/dialogs/InviteDialogTypes"; import { OpenInviteDialogPayload } from "./dispatcher/payloads/OpenInviteDialogPayload"; +import { findDMForUser } from './utils/dm/findDMForUser'; export const PROTOCOL_PSTN = 'm.protocol.pstn'; export const PROTOCOL_PSTN_PREFIXED = 'im.vector.protocol.pstn'; diff --git a/src/VoipUserMapper.ts b/src/VoipUserMapper.ts index f7026da394..3734f8c395 100644 --- a/src/VoipUserMapper.ts +++ b/src/VoipUserMapper.ts @@ -23,7 +23,7 @@ import { MatrixClientPeg } from "./MatrixClientPeg"; import DMRoomMap from "./utils/DMRoomMap"; import CallHandler from './CallHandler'; import { VIRTUAL_ROOM_EVENT_TYPE } from "./call-types"; -import { findDMForUser } from "./utils/direct-messages"; +import { findDMForUser } from './utils/dm/findDMForUser'; // Functions for mapping virtual users & rooms. Currently the only lookup // is sip virtual: there could be others in the future. diff --git a/src/components/views/dialogs/InviteDialog.tsx b/src/components/views/dialogs/InviteDialog.tsx index 28caf66ec0..295bae79c2 100644 --- a/src/components/views/dialogs/InviteDialog.tsx +++ b/src/components/views/dialogs/InviteDialog.tsx @@ -62,10 +62,11 @@ import CopyableText from "../elements/CopyableText"; import { ScreenName } from '../../../PosthogTrackers'; import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; import { getKeyBindingsManager } from "../../../KeyBindingsManager"; -import { DirectoryMember, IDMUserTileProps, Member, startDm, ThreepidMember } from "../../../utils/direct-messages"; +import { DirectoryMember, IDMUserTileProps, Member, ThreepidMember } from "../../../utils/direct-messages"; import { AnyInviteKind, KIND_CALL_TRANSFER, KIND_DM, KIND_INVITE } from './InviteDialogTypes'; import Modal from '../../../Modal'; import dis from "../../../dispatcher/dispatcher"; +import { startDm } from '../../../utils/dm/startDm'; // we have a number of types defined from the Matrix spec which can't reasonably be altered here. /* eslint-disable camelcase */ diff --git a/src/components/views/dialogs/spotlight/SpotlightDialog.tsx b/src/components/views/dialogs/spotlight/SpotlightDialog.tsx index fa2c5c48b5..4eed99fd56 100644 --- a/src/components/views/dialogs/spotlight/SpotlightDialog.tsx +++ b/src/components/views/dialogs/spotlight/SpotlightDialog.tsx @@ -70,7 +70,7 @@ import { RecentAlgorithm } from "../../../../stores/room-list/algorithms/tag-sor import { RoomViewStore } from "../../../../stores/RoomViewStore"; import { getMetaSpaceName } from "../../../../stores/spaces"; import SpaceStore from "../../../../stores/spaces/SpaceStore"; -import { DirectoryMember, Member, startDm } from "../../../../utils/direct-messages"; +import { DirectoryMember, Member } from "../../../../utils/direct-messages"; import DMRoomMap from "../../../../utils/DMRoomMap"; import { makeUserPermalink } from "../../../../utils/permalinks/Permalinks"; import { buildActivityScores, buildMemberScores, compareMembers } from "../../../../utils/SortMembers"; @@ -92,6 +92,7 @@ import { RoomResultContextMenus } from "./RoomResultContextMenus"; import { RoomContextDetails } from "../../rooms/RoomContextDetails"; import { TooltipOption } from "./TooltipOption"; import { isLocalRoom } from "../../../../utils/localRoom/isLocalRoom"; +import { startDm } from "../../../../utils/dm/startDm"; const MAX_RECENT_SEARCHES = 10; const SECTION_LIMIT = 50; // only show 50 results per section for performance reasons diff --git a/src/components/views/right_panel/UserInfo.tsx b/src/components/views/right_panel/UserInfo.tsx index 04a5097649..53dff79721 100644 --- a/src/components/views/right_panel/UserInfo.tsx +++ b/src/components/views/right_panel/UserInfo.tsx @@ -78,8 +78,8 @@ import { IRightPanelCardState } from '../../../stores/right-panel/RightPanelStor import UserIdentifierCustomisations from '../../../customisations/UserIdentifier'; import PosthogTrackers from "../../../PosthogTrackers"; import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; -import { findDMForUser } from "../../../utils/direct-messages"; import { privateShouldBeEncrypted } from "../../../utils/rooms"; +import { findDMForUser } from '../../../utils/dm/findDMForUser'; export interface IDevice { deviceId: string; diff --git a/src/createRoom.ts b/src/createRoom.ts index 3f85be9f00..61397a8312 100644 --- a/src/createRoom.ts +++ b/src/createRoom.ts @@ -42,7 +42,7 @@ 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/direct-messages"; +import { findDMForUser } from "./utils/dm/findDMForUser"; import { privateShouldBeEncrypted } from "./utils/rooms"; import { waitForMember } from "./utils/membership"; import { PreferredRoomVersions } from "./utils/PreferredRoomVersions"; diff --git a/src/utils/direct-messages.ts b/src/utils/direct-messages.ts index e67c01c7ca..2ed20b4f64 100644 --- a/src/utils/direct-messages.ts +++ b/src/utils/direct-messages.ts @@ -14,54 +14,26 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { IInvite3PID } from "matrix-js-sdk/src/@types/requests"; -import { MatrixClient } from "matrix-js-sdk/src/client"; +import { ClientEvent, MatrixClient } from "matrix-js-sdk/src/client"; import { Room } from "matrix-js-sdk/src/models/room"; +import { logger } from "matrix-js-sdk/src/logger"; -import createRoom, { canEncryptToAllUsers } from "../createRoom"; +import { canEncryptToAllUsers } from "../createRoom"; import { Action } from "../dispatcher/actions"; import { ViewRoomPayload } from "../dispatcher/payloads/ViewRoomPayload"; -import { getAddressType } from "../UserAddress"; -import DMRoomMap from "./DMRoomMap"; -import { isJoinedOrNearlyJoined } from "./membership"; import dis from "../dispatcher/dispatcher"; +import { LocalRoom, LocalRoomState } from "../models/LocalRoom"; +import { waitForRoomReadyAndApplyAfterCreateCallbacks } from "./local-room"; +import { findDMRoom } from "./dm/findDMRoom"; import { privateShouldBeEncrypted } from "./rooms"; +import { createDmLocalRoom } from "./dm/createDmLocalRoom"; +import { startDm } from "./dm/startDm"; -export function findDMForUser(client: MatrixClient, userId: string): Room { - const roomIds = DMRoomMap.shared().getDMRoomsForUserId(userId); - const rooms = roomIds.map(id => client.getRoom(id)); - const suitableDMRooms = rooms.filter(r => { - // Validate that we are joined and the other person is also joined. We'll also make sure - // that the room also looks like a DM (until we have canonical DMs to tell us). For now, - // a DM is a room of two people that contains those two people exactly. This does mean - // that bots, assistants, etc will ruin a room's DM-ness, though this is a problem for - // canonical DMs to solve. - if (r && r.getMyMembership() === "join") { - const members = r.currentState.getMembers(); - const joinedMembers = members.filter(m => isJoinedOrNearlyJoined(m.membership)); - const otherMember = joinedMembers.find(m => m.userId === userId); - return otherMember && joinedMembers.length === 2; - } - return false; - }).sort((r1, r2) => { - return r2.getLastActiveTimestamp() - - r1.getLastActiveTimestamp(); - }); - if (suitableDMRooms.length) { - return suitableDMRooms[0]; - } -} - -export async function startDm(client: MatrixClient, targets: Member[]): Promise { - const targetIds = targets.map(t => t.userId); - - // Check if there is already a DM with these people and reuse it if possible. - let existingRoom: Room; - if (targetIds.length === 1) { - existingRoom = findDMForUser(client, targetIds[0]); - } else { - existingRoom = DMRoomMap.shared().getDMRoomForIdentifiers(targetIds); - } +export async function startDmOnFirstMessage( + client: MatrixClient, + targets: Member[], +): Promise { + const existingRoom = findDMRoom(client, targets); if (existingRoom) { dis.dispatch({ action: Action.ViewRoom, @@ -70,51 +42,47 @@ export async function startDm(client: MatrixClient, targets: Member[]): Promise< joining: false, metricsTrigger: "MessageUser", }); + return existingRoom; + } + + const room = await createDmLocalRoom(client, targets); + dis.dispatch({ + action: Action.ViewRoom, + room_id: room.roomId, + joining: false, + targets, + }); + return room; +} + +/** + * Starts a DM based on a local room. + * + * @async + * @param {MatrixClient} client + * @param {LocalRoom} localRoom + * @returns {Promise} Resolves to the created room id + */ +export async function createRoomFromLocalRoom(client: MatrixClient, localRoom: LocalRoom): Promise { + if (!localRoom.isNew) { + // This action only makes sense for new local rooms. return; } - const createRoomOptions = { inlineErrors: true } as any; // XXX: Type out `createRoomOptions` + localRoom.state = LocalRoomState.CREATING; + client.emit(ClientEvent.Room, localRoom); - if (privateShouldBeEncrypted()) { - // Check whether all users have uploaded device keys before. - // If so, enable encryption in the new room. - const has3PidMembers = targets.some(t => t instanceof ThreepidMember); - if (!has3PidMembers) { - const allHaveDeviceKeys = await canEncryptToAllUsers(client, targetIds); - if (allHaveDeviceKeys) { - createRoomOptions.encryption = true; - } - } - } - - // Check if it's a traditional DM and create the room if required. - // TODO: [Canonical DMs] Remove this check and instead just create the multi-person DM - const isSelf = targetIds.length === 1 && targetIds[0] === client.getUserId(); - if (targetIds.length === 1 && !isSelf) { - createRoomOptions.dmUserId = targetIds[0]; - } - - if (targetIds.length > 1) { - createRoomOptions.createOpts = targetIds.reduce( - (roomOptions, address) => { - const type = getAddressType(address); - if (type === 'email') { - const invite: IInvite3PID = { - id_server: client.getIdentityServerUrl(true), - medium: 'email', - address, - }; - roomOptions.invite_3pid.push(invite); - } else if (type === 'mx-user-id') { - roomOptions.invite.push(address); - } - return roomOptions; - }, - { invite: [], invite_3pid: [] }, - ); - } - - await createRoom(createRoomOptions); + return startDm(client, localRoom.targets, false).then( + (roomId) => { + localRoom.actualRoomId = roomId; + return waitForRoomReadyAndApplyAfterCreateCallbacks(client, localRoom); + }, + () => { + logger.warn(`Error creating DM for local room ${localRoom.roomId}`); + localRoom.state = LocalRoomState.ERROR; + client.emit(ClientEvent.Room, localRoom); + }, + ); } // This is the interface that is expected by various components in the Invite Dialog and RoomInvite. @@ -200,3 +168,28 @@ export interface IDMUserTileProps { member: Member; onRemove(member: Member): void; } + +/** + * Detects whether a room should be encrypted. + * + * @async + * @param {MatrixClient} client + * @param {Member[]} targets The members to which run the check against + * @returns {Promise} + */ +export async function determineCreateRoomEncryptionOption(client: MatrixClient, targets: Member[]): Promise { + if (privateShouldBeEncrypted()) { + // Check whether all users have uploaded device keys before. + // If so, enable encryption in the new room. + const has3PidMembers = targets.some(t => t instanceof ThreepidMember); + if (!has3PidMembers) { + const targetIds = targets.map(t => t.userId); + const allHaveDeviceKeys = await canEncryptToAllUsers(client, targetIds); + if (allHaveDeviceKeys) { + return true; + } + } + } + + return false; +} diff --git a/src/utils/dm/createDmLocalRoom.ts b/src/utils/dm/createDmLocalRoom.ts new file mode 100644 index 0000000000..9fe68986bc --- /dev/null +++ b/src/utils/dm/createDmLocalRoom.ts @@ -0,0 +1,123 @@ +/* +Copyright 2022 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 { MEGOLM_ALGORITHM } from "matrix-js-sdk/src/crypto/olmlib"; +import { EventType, KNOWN_SAFE_ROOM_VERSION, MatrixClient, MatrixEvent } from "matrix-js-sdk/src/matrix"; + +import { LocalRoom, LOCAL_ROOM_ID_PREFIX } from "../../../src/models/LocalRoom"; +import { determineCreateRoomEncryptionOption, Member } from "../../../src/utils/direct-messages"; + +/** + * Create a DM local room. This room will not be send to the server and only exists inside the client. + * It sets up the local room with some artificial state events + * so that can be used in most components instead of a „real“ room. + * + * @async + * @param {MatrixClient} client + * @param {Member[]} targets DM partners + * @returns {Promise} Resolves to the new local room + */ +export async function createDmLocalRoom( + client: MatrixClient, + targets: Member[], +): Promise { + const userId = client.getUserId(); + + const localRoom = new LocalRoom(LOCAL_ROOM_ID_PREFIX + client.makeTxnId(), client, userId); + const events = []; + + events.push(new MatrixEvent({ + event_id: `~${localRoom.roomId}:${client.makeTxnId()}`, + type: EventType.RoomCreate, + content: { + creator: userId, + room_version: KNOWN_SAFE_ROOM_VERSION, + }, + state_key: "", + user_id: userId, + sender: userId, + room_id: localRoom.roomId, + origin_server_ts: Date.now(), + })); + + if (await determineCreateRoomEncryptionOption(client, targets)) { + localRoom.encrypted = true; + events.push( + new MatrixEvent({ + event_id: `~${localRoom.roomId}:${client.makeTxnId()}`, + type: EventType.RoomEncryption, + content: { + algorithm: MEGOLM_ALGORITHM, + }, + user_id: userId, + sender: userId, + state_key: "", + room_id: localRoom.roomId, + origin_server_ts: Date.now(), + }), + ); + } + + events.push(new MatrixEvent({ + event_id: `~${localRoom.roomId}:${client.makeTxnId()}`, + type: EventType.RoomMember, + content: { + displayname: userId, + membership: "join", + }, + state_key: userId, + user_id: userId, + sender: userId, + room_id: localRoom.roomId, + })); + + targets.forEach((target: Member) => { + events.push(new MatrixEvent({ + event_id: `~${localRoom.roomId}:${client.makeTxnId()}`, + type: EventType.RoomMember, + content: { + displayname: target.name, + avatar_url: target.getMxcAvatarUrl(), + membership: "invite", + isDirect: true, + }, + state_key: target.userId, + sender: userId, + room_id: localRoom.roomId, + })); + events.push(new MatrixEvent({ + event_id: `~${localRoom.roomId}:${client.makeTxnId()}`, + type: EventType.RoomMember, + content: { + displayname: target.name, + avatar_url: target.getMxcAvatarUrl(), + membership: "join", + }, + state_key: target.userId, + sender: target.userId, + room_id: localRoom.roomId, + })); + }); + + localRoom.targets = targets; + localRoom.updateMyMembership("join"); + localRoom.addLiveEvents(events); + localRoom.currentState.setStateEvents(events); + localRoom.name = localRoom.getDefaultRoomName(client.getUserId()); + client.store.storeRoom(localRoom); + + return localRoom; +} diff --git a/src/utils/dm/findDMForUser.ts b/src/utils/dm/findDMForUser.ts new file mode 100644 index 0000000000..7513de88ad --- /dev/null +++ b/src/utils/dm/findDMForUser.ts @@ -0,0 +1,55 @@ +/* +Copyright 2022 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, Room } from "matrix-js-sdk/src/matrix"; + +import DMRoomMap from "../DMRoomMap"; +import { isLocalRoom } from "../localRoom/isLocalRoom"; +import { isJoinedOrNearlyJoined } from "../membership"; + +/** + * Tries to find a DM room with a specific user. + * + * @param {MatrixClient} client + * @param {string} userId ID of the user to find the DM for + * @returns {Room} Room if found + */ +export function findDMForUser(client: MatrixClient, userId: string): Room { + const roomIds = DMRoomMap.shared().getDMRoomsForUserId(userId); + const rooms = roomIds.map(id => client.getRoom(id)); + const suitableDMRooms = rooms.filter(r => { + // Validate that we are joined and the other person is also joined. We'll also make sure + // that the room also looks like a DM (until we have canonical DMs to tell us). For now, + // a DM is a room of two people that contains those two people exactly. This does mean + // that bots, assistants, etc will ruin a room's DM-ness, though this is a problem for + // canonical DMs to solve. + if (r && r.getMyMembership() === "join") { + if (isLocalRoom(r)) return false; + + const members = r.currentState.getMembers(); + const joinedMembers = members.filter(m => isJoinedOrNearlyJoined(m.membership)); + const otherMember = joinedMembers.find(m => m.userId === userId); + return otherMember && joinedMembers.length === 2; + } + return false; + }).sort((r1, r2) => { + return r2.getLastActiveTimestamp() - + r1.getLastActiveTimestamp(); + }); + if (suitableDMRooms.length) { + return suitableDMRooms[0]; + } +} diff --git a/src/utils/dm/findDMRoom.ts b/src/utils/dm/findDMRoom.ts new file mode 100644 index 0000000000..8cc6fa0d6d --- /dev/null +++ b/src/utils/dm/findDMRoom.ts @@ -0,0 +1,42 @@ +/* +Copyright 2022 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, Room } from "matrix-js-sdk/src/matrix"; + +import { Member } from "../direct-messages"; +import DMRoomMap from "../DMRoomMap"; +import { findDMForUser } from "./findDMForUser"; + +/** + * Tries to find a DM room with some other users. + * + * @param {MatrixClient} client + * @param {Member[]} targets The Members to try to find the room for + * @returns {Room | null} Resolved so the room if found, else null + */ +export function findDMRoom(client: MatrixClient, targets: Member[]): Room | null { + const targetIds = targets.map(t => t.userId); + let existingRoom: Room; + if (targetIds.length === 1) { + existingRoom = findDMForUser(client, targetIds[0]); + } else { + existingRoom = DMRoomMap.shared().getDMRoomForIdentifiers(targetIds); + } + if (existingRoom) { + return existingRoom; + } + return null; +} diff --git a/src/utils/dm/startDm.ts b/src/utils/dm/startDm.ts new file mode 100644 index 0000000000..c608a8b18d --- /dev/null +++ b/src/utils/dm/startDm.ts @@ -0,0 +1,90 @@ +/* +Copyright 2022 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 { IInvite3PID, MatrixClient, Room } from "matrix-js-sdk/src/matrix"; + +import { Action } from "../../dispatcher/actions"; +import { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload"; +import { determineCreateRoomEncryptionOption, Member } from "../direct-messages"; +import DMRoomMap from "../DMRoomMap"; +import { isLocalRoom } from "../localRoom/isLocalRoom"; +import { findDMForUser } from "./findDMForUser"; +import dis from "../../dispatcher/dispatcher"; +import { getAddressType } from "../../UserAddress"; +import createRoom from "../../createRoom"; + +/** + * Start a DM. + * + * @returns {Promise { + const targetIds = targets.map(t => t.userId); + + // Check if there is already a DM with these people and reuse it if possible. + let existingRoom: Room; + if (targetIds.length === 1) { + existingRoom = findDMForUser(client, targetIds[0]); + } else { + existingRoom = DMRoomMap.shared().getDMRoomForIdentifiers(targetIds); + } + if (existingRoom && !isLocalRoom(existingRoom)) { + dis.dispatch({ + action: Action.ViewRoom, + room_id: existingRoom.roomId, + should_peek: false, + joining: false, + metricsTrigger: "MessageUser", + }); + return Promise.resolve(existingRoom.roomId); + } + + const createRoomOptions = { inlineErrors: true } as any; // XXX: Type out `createRoomOptions` + + if (await determineCreateRoomEncryptionOption(client, targets)) { + createRoomOptions.encryption = true; + } + + // Check if it's a traditional DM and create the room if required. + // TODO: [Canonical DMs] Remove this check and instead just create the multi-person DM + const isSelf = targetIds.length === 1 && targetIds[0] === client.getUserId(); + if (targetIds.length === 1 && !isSelf) { + createRoomOptions.dmUserId = targetIds[0]; + } + + if (targetIds.length > 1) { + createRoomOptions.createOpts = targetIds.reduce( + (roomOptions, address) => { + const type = getAddressType(address); + if (type === 'email') { + const invite: IInvite3PID = { + id_server: client.getIdentityServerUrl(true), + medium: 'email', + address, + }; + roomOptions.invite_3pid.push(invite); + } else if (type === 'mx-user-id') { + roomOptions.invite.push(address); + } + return roomOptions; + }, + { invite: [], invite_3pid: [] }, + ); + } + + createRoomOptions.spinner = showSpinner; + return createRoom(createRoomOptions); +} diff --git a/src/utils/local-room.ts b/src/utils/local-room.ts index 29fab7206d..8b1a2e6379 100644 --- a/src/utils/local-room.ts +++ b/src/utils/local-room.ts @@ -15,12 +15,13 @@ limitations under the License. */ import { logger } from "matrix-js-sdk/src/logger"; -import { ClientEvent, EventType, MatrixClient } from "matrix-js-sdk/src/matrix"; +import { ClientEvent, MatrixClient } from "matrix-js-sdk/src/matrix"; import defaultDispatcher from "../dispatcher/dispatcher"; import { MatrixClientPeg } from "../MatrixClientPeg"; -import { LocalRoom, LocalRoomState, LOCAL_ROOM_ID_PREFIX } from "../models/LocalRoom"; -import * as thisModule from "./local-room"; +import { LocalRoom, LocalRoomState } from "../models/LocalRoom"; +import { isLocalRoom } from "./localRoom/isLocalRoom"; +import { isRoomReady } from "./localRoom/isRoomReady"; /** * Does a room action: @@ -40,7 +41,7 @@ export async function doMaybeLocalRoomAction( fn: (actualRoomId: string) => Promise, client?: MatrixClient, ): Promise { - if (roomId.startsWith(LOCAL_ROOM_ID_PREFIX)) { + if (isLocalRoom(roomId)) { client = client ?? MatrixClientPeg.get(); const room = client.getRoom(roomId) as LocalRoom; @@ -62,34 +63,6 @@ export async function doMaybeLocalRoomAction( return fn(roomId); } -/** - * Tests whether a room created based on a local room is ready. - */ -export function isRoomReady( - client: MatrixClient, - localRoom: LocalRoom, -): boolean { - // not ready if no actual room id exists - if (!localRoom.actualRoomId) return false; - - const room = client.getRoom(localRoom.actualRoomId); - // not ready if the room does not exist - if (!room) return false; - - // not ready if not all members joined/invited - if (room.getInvitedAndJoinedMemberCount() !== 1 + localRoom.targets?.length) return false; - - const roomHistoryVisibilityEvents = room.currentState.getStateEvents(EventType.RoomHistoryVisibility); - // not ready if the room history has not been configured - if (roomHistoryVisibilityEvents.length === 0) return false; - - const roomEncryptionEvents = room.currentState.getStateEvents(EventType.RoomEncryption); - // not ready if encryption has not been configured (applies only to encrypted rooms) - if (localRoom.encrypted === true && roomEncryptionEvents.length === 0) return false; - - return true; -} - /** * Waits until a room is ready and then applies the after-create local room callbacks. * Also implements a stopgap timeout after that a room is assumed to be ready. @@ -104,7 +77,7 @@ export async function waitForRoomReadyAndApplyAfterCreateCallbacks( client: MatrixClient, localRoom: LocalRoom, ): Promise { - if (thisModule.isRoomReady(client, localRoom)) { + if (isRoomReady(client, localRoom)) { return applyAfterCreateCallbacks(localRoom, localRoom.actualRoomId).then(() => { localRoom.state = LocalRoomState.CREATED; client.emit(ClientEvent.Room, localRoom); @@ -130,7 +103,7 @@ export async function waitForRoomReadyAndApplyAfterCreateCallbacks( }; const checkRoomStateIntervalHandle = setInterval(() => { - if (thisModule.isRoomReady(client, localRoom)) finish(); + if (isRoomReady(client, localRoom)) finish(); }, 500); const stopgapTimeoutHandle = setTimeout(stopgapFinish, 5000); }); diff --git a/src/utils/localRoom/isRoomReady.ts b/src/utils/localRoom/isRoomReady.ts new file mode 100644 index 0000000000..c26839236d --- /dev/null +++ b/src/utils/localRoom/isRoomReady.ts @@ -0,0 +1,47 @@ +/* +Copyright 2022 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 { EventType, MatrixClient } from "matrix-js-sdk/src/matrix"; + +import { LocalRoom } from "../../models/LocalRoom"; + +/** + * Tests whether a room created based on a local room is ready. + */ +export function isRoomReady( + client: MatrixClient, + localRoom: LocalRoom, +): boolean { + // not ready if no actual room id exists + if (!localRoom.actualRoomId) return false; + + const room = client.getRoom(localRoom.actualRoomId); + // not ready if the room does not exist + if (!room) return false; + + // not ready if not all members joined/invited + if (room.getInvitedAndJoinedMemberCount() !== 1 + localRoom.targets?.length) return false; + + const roomHistoryVisibilityEvents = room.currentState.getStateEvents(EventType.RoomHistoryVisibility); + // not ready if the room history has not been configured + if (roomHistoryVisibilityEvents.length === 0) return false; + + const roomEncryptionEvents = room.currentState.getStateEvents(EventType.RoomEncryption); + // not ready if encryption has not been configured (applies only to encrypted rooms) + if (localRoom.encrypted === true && roomEncryptionEvents.length === 0) return false; + + return true; +} diff --git a/src/verification.ts b/src/verification.ts index 498bc632b2..940de1cbaf 100644 --- a/src/verification.ts +++ b/src/verification.ts @@ -28,7 +28,7 @@ import { IDevice } from "./components/views/right_panel/UserInfo"; import ManualDeviceKeyVerificationDialog from "./components/views/dialogs/ManualDeviceKeyVerificationDialog"; import RightPanelStore from "./stores/right-panel/RightPanelStore"; import { IRightPanelCardState } from "./stores/right-panel/RightPanelStoreIPanelState"; -import { findDMForUser } from "./utils/direct-messages"; +import { findDMForUser } from "./utils/dm/findDMForUser"; async function enable4SIfNeeded() { const cli = MatrixClientPeg.get(); diff --git a/test/utils/direct-messages-test.ts b/test/utils/direct-messages-test.ts new file mode 100644 index 0000000000..13542440ce --- /dev/null +++ b/test/utils/direct-messages-test.ts @@ -0,0 +1,175 @@ +/* +Copyright 2022 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 { mocked } from "jest-mock"; +import { ClientEvent, MatrixClient, Room } from "matrix-js-sdk/src/matrix"; + +import DMRoomMap from "../../src/utils/DMRoomMap"; +import { createTestClient } from "../test-utils"; +import { LocalRoom, LocalRoomState, LOCAL_ROOM_ID_PREFIX } from "../../src/models/LocalRoom"; +import * as dmModule from "../../src/utils/direct-messages"; +import dis from "../../src/dispatcher/dispatcher"; +import { Action } from "../../src/dispatcher/actions"; +import { MatrixClientPeg } from "../../src/MatrixClientPeg"; +import { waitForRoomReadyAndApplyAfterCreateCallbacks } from "../../src/utils/local-room"; +import { findDMRoom } from "../../src/utils/dm/findDMRoom"; +import { createDmLocalRoom } from "../../src/utils/dm/createDmLocalRoom"; +import { startDm } from "../../src/utils/dm/startDm"; + +jest.mock("../../src/utils/rooms", () => ({ + ...(jest.requireActual("../../src/utils/rooms") as object), + privateShouldBeEncrypted: jest.fn(), +})); + +jest.mock("../../src/createRoom", () => ({ + ...(jest.requireActual("../../src/createRoom") as object), + canEncryptToAllUsers: jest.fn(), +})); + +jest.mock("../../src/utils/local-room", () => ({ + waitForRoomReadyAndApplyAfterCreateCallbacks: jest.fn(), +})); + +jest.mock("../../src/utils/dm/findDMForUser", () => ({ + findDMForUser: jest.fn(), +})); + +jest.mock("../../src/utils/dm/findDMRoom", () => ({ + findDMRoom: jest.fn(), +})); + +jest.mock("../../src/utils/dm/createDmLocalRoom", () => ({ + createDmLocalRoom: jest.fn(), +})); + +jest.mock("../../src/utils/dm/startDm", () => ({ + startDm: jest.fn(), +})); + +describe("direct-messages", () => { + const userId1 = "@user1:example.com"; + const member1 = new dmModule.DirectoryMember({ user_id: userId1 }); + let room1: Room; + let localRoom: LocalRoom; + let dmRoomMap: DMRoomMap; + let mockClient: MatrixClient; + let roomEvents: Room[]; + + beforeEach(() => { + jest.restoreAllMocks(); + + mockClient = createTestClient(); + jest.spyOn(MatrixClientPeg, "get").mockReturnValue(mockClient); + roomEvents = []; + mockClient.on(ClientEvent.Room, (room: Room) => { + roomEvents.push(room); + }); + + room1 = new Room("!room1:example.com", mockClient, userId1); + room1.getMyMembership = () => "join"; + + localRoom = new LocalRoom(LOCAL_ROOM_ID_PREFIX + "test", mockClient, userId1); + + dmRoomMap = { + getDMRoomForIdentifiers: jest.fn(), + getDMRoomsForUserId: jest.fn(), + } as unknown as DMRoomMap; + jest.spyOn(DMRoomMap, "shared").mockReturnValue(dmRoomMap); + jest.spyOn(dis, "dispatch"); + + jest.setSystemTime(new Date(2022, 7, 4, 11, 12, 30, 42)); + }); + + describe("startDmOnFirstMessage", () => { + describe("if no room exists", () => { + beforeEach(() => { + mocked(findDMRoom).mockReturnValue(null); + }); + + it("should create a local room and dispatch a view room event", async () => { + mocked(createDmLocalRoom).mockResolvedValue(localRoom); + const room = await dmModule.startDmOnFirstMessage(mockClient, [member1]); + expect(room).toBe(localRoom); + expect(dis.dispatch).toHaveBeenCalledWith({ + action: Action.ViewRoom, + room_id: room.roomId, + joining: false, + targets: [member1], + }); + }); + }); + + describe("if a room exists", () => { + beforeEach(() => { + mocked(findDMRoom).mockReturnValue(room1); + }); + + it("should return the room and dispatch a view room event", async () => { + const room = await dmModule.startDmOnFirstMessage(mockClient, [member1]); + expect(room).toBe(room1); + expect(dis.dispatch).toHaveBeenCalledWith({ + action: Action.ViewRoom, + room_id: room1.roomId, + should_peek: false, + joining: false, + metricsTrigger: "MessageUser", + }); + }); + }); + }); + + describe("createRoomFromLocalRoom", () => { + [LocalRoomState.CREATING, LocalRoomState.CREATED, LocalRoomState.ERROR].forEach((state: LocalRoomState) => { + it(`should do nothing for room in state ${state}`, async () => { + localRoom.state = state; + await dmModule.createRoomFromLocalRoom(mockClient, localRoom); + expect(localRoom.state).toBe(state); + expect(startDm).not.toHaveBeenCalled(); + }); + }); + + describe("on startDm error", () => { + beforeEach(() => { + mocked(startDm).mockRejectedValue(true); + }); + + it("should set the room state to error", async () => { + await dmModule.createRoomFromLocalRoom(mockClient, localRoom); + expect(localRoom.state).toBe(LocalRoomState.ERROR); + }); + }); + + describe("on startDm success", () => { + beforeEach(() => { + mocked(waitForRoomReadyAndApplyAfterCreateCallbacks).mockResolvedValue(room1.roomId); + mocked(startDm).mockResolvedValue(room1.roomId); + }); + + it( + "should set the room into creating state and call waitForRoomReadyAndApplyAfterCreateCallbacks", + async () => { + const result = await dmModule.createRoomFromLocalRoom(mockClient, localRoom); + expect(result).toBe(room1.roomId); + expect(localRoom.state).toBe(LocalRoomState.CREATING); + expect(waitForRoomReadyAndApplyAfterCreateCallbacks).toHaveBeenCalledWith( + mockClient, + localRoom, + ); + }, + ); + }); + }); +}); diff --git a/test/utils/dm/createDmLocalRoom-test.ts b/test/utils/dm/createDmLocalRoom-test.ts new file mode 100644 index 0000000000..e3b140db22 --- /dev/null +++ b/test/utils/dm/createDmLocalRoom-test.ts @@ -0,0 +1,114 @@ +/* +Copyright 2022 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 { mocked } from "jest-mock"; +import { EventType, KNOWN_SAFE_ROOM_VERSION, MatrixClient } from "matrix-js-sdk/src/matrix"; + +import { canEncryptToAllUsers } from "../../../src/createRoom"; +import { LocalRoom, LOCAL_ROOM_ID_PREFIX } from "../../../src/models/LocalRoom"; +import { DirectoryMember, Member, ThreepidMember } from "../../../src/utils/direct-messages"; +import { createDmLocalRoom } from "../../../src/utils/dm/createDmLocalRoom"; +import { privateShouldBeEncrypted } from "../../../src/utils/rooms"; +import { createTestClient } from "../../test-utils"; + +jest.mock("../../../src/utils/rooms", () => ({ + privateShouldBeEncrypted: jest.fn(), +})); + +jest.mock("../../../src/createRoom", () => ({ + canEncryptToAllUsers: jest.fn(), +})); + +function assertLocalRoom(room: LocalRoom, targets: Member[], encrypted: boolean) { + expect(room.roomId).toBe(LOCAL_ROOM_ID_PREFIX + "t1"); + expect(room.name).toBe(targets.length ? targets[0].name : "Empty Room"); + expect(room.encrypted).toBe(encrypted); + expect(room.targets).toEqual(targets); + expect(room.getMyMembership()).toBe("join"); + + const roomCreateEvent = room.currentState.getStateEvents(EventType.RoomCreate)[0]; + expect(roomCreateEvent).toBeDefined(); + expect(roomCreateEvent.getContent()["room_version"]).toBe(KNOWN_SAFE_ROOM_VERSION); + + // check that the user and all targets are joined + expect(room.getMember("@userId:matrix.org").membership).toBe("join"); + targets.forEach((target: Member) => { + expect(room.getMember(target.userId).membership).toBe("join"); + }); + + if (encrypted) { + const encryptionEvent = room.currentState.getStateEvents(EventType.RoomEncryption)[0]; + expect(encryptionEvent).toBeDefined(); + } +} + +describe("createDmLocalRoom", () => { + let mockClient: MatrixClient; + const userId1 = "@user1:example.com"; + const member1 = new DirectoryMember({ user_id: userId1 }); + const member2 = new ThreepidMember("user2"); + + beforeEach(() => { + mockClient = createTestClient(); + }); + + describe("when rooms should be encrypted", () => { + beforeEach(() => { + mocked(privateShouldBeEncrypted).mockReturnValue(true); + }); + + it("should create an unencrypted room for 3PID targets", async () => { + const room = await createDmLocalRoom(mockClient, [member2]); + expect(mockClient.store.storeRoom).toHaveBeenCalledWith(room); + assertLocalRoom(room, [member2], false); + }); + + describe("for MXID targets with encryption available", () => { + beforeEach(() => { + mocked(canEncryptToAllUsers).mockResolvedValue(true); + }); + + it("should create an encrypted room", async () => { + const room = await createDmLocalRoom(mockClient, [member1]); + expect(mockClient.store.storeRoom).toHaveBeenCalledWith(room); + assertLocalRoom(room, [member1], true); + }); + }); + + describe("for MXID targets with encryption unavailable", () => { + beforeEach(() => { + mocked(canEncryptToAllUsers).mockResolvedValue(false); + }); + + it("should create an unencrypted room", async () => { + const room = await createDmLocalRoom(mockClient, [member1]); + expect(mockClient.store.storeRoom).toHaveBeenCalledWith(room); + assertLocalRoom(room, [member1], false); + }); + }); + }); + + describe("if rooms should not be encrypted", () => { + beforeEach(() => { + mocked(privateShouldBeEncrypted).mockReturnValue(false); + }); + + it("should create an unencrypted room", async () => { + const room = await createDmLocalRoom(mockClient, [member1]); + assertLocalRoom(room, [member1], false); + }); + }); +}); diff --git a/test/utils/dm/findDMForUser-test.ts b/test/utils/dm/findDMForUser-test.ts new file mode 100644 index 0000000000..a3dda43c5c --- /dev/null +++ b/test/utils/dm/findDMForUser-test.ts @@ -0,0 +1,72 @@ +/* +Copyright 2022 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 { mocked } from "jest-mock"; +import { MatrixClient, Room } from "matrix-js-sdk/src/matrix"; + +import { DirectoryMember, ThreepidMember } from "../../../src/utils/direct-messages"; +import { MatrixClientPeg } from "../../../src/MatrixClientPeg"; +import DMRoomMap from "../../../src/utils/DMRoomMap"; +import { createTestClient } from "../../test-utils"; +import { findDMRoom } from "../../../src/utils/dm/findDMRoom"; +import { findDMForUser } from "../../../src/utils/dm/findDMForUser"; + +jest.mock("../../../src/utils/dm/findDMForUser", () => ({ + findDMForUser: jest.fn(), +})); + +describe("findDMRoom", () => { + const userId1 = "@user1:example.com"; + const member1 = new DirectoryMember({ user_id: userId1 }); + const member2 = new ThreepidMember("user2"); + let mockClient: MatrixClient; + let room1: Room; + let dmRoomMap: DMRoomMap; + + beforeEach(() => { + mockClient = createTestClient(); + jest.spyOn(MatrixClientPeg, "get").mockReturnValue(mockClient); + + room1 = new Room("!room1:example.com", mockClient, userId1); + room1.getMyMembership = () => "join"; + + dmRoomMap = { + getDMRoomForIdentifiers: jest.fn(), + getDMRoomsForUserId: jest.fn(), + } as unknown as DMRoomMap; + jest.spyOn(DMRoomMap, "shared").mockReturnValue(dmRoomMap); + }); + + it("should return the room for a single target with a room", () => { + mocked(findDMForUser).mockReturnValue(room1); + expect(findDMRoom(mockClient, [member1])).toBe(room1); + }); + + it("should return null for a single target without a room", () => { + mocked(findDMForUser).mockReturnValue(null); + expect(findDMRoom(mockClient, [member1])).toBeNull(); + }); + + it("should return the room for 2 targets with a room", () => { + mocked(dmRoomMap.getDMRoomForIdentifiers).mockReturnValue(room1); + expect(findDMRoom(mockClient, [member1, member2])).toBe(room1); + }); + + it("should return null for 2 targets without a room", () => { + mocked(dmRoomMap.getDMRoomForIdentifiers).mockReturnValue(null); + expect(findDMRoom(mockClient, [member1, member2])).toBeNull(); + }); +}); diff --git a/test/utils/dm/findDMRoom-test.ts b/test/utils/dm/findDMRoom-test.ts new file mode 100644 index 0000000000..b7ed77f582 --- /dev/null +++ b/test/utils/dm/findDMRoom-test.ts @@ -0,0 +1,70 @@ +/* + Copyright 2022 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 { mocked } from "jest-mock"; +import { MatrixClient, Room } from "matrix-js-sdk/src/matrix"; + +import { MatrixClientPeg } from "../../../src/MatrixClientPeg"; +import { DirectoryMember, ThreepidMember } from "../../../src/utils/direct-messages"; +import { findDMForUser } from "../../../src/utils/dm/findDMForUser"; +import { findDMRoom } from "../../../src/utils/dm/findDMRoom"; +import DMRoomMap from "../../../src/utils/DMRoomMap"; +import { createTestClient } from "../../test-utils"; + +jest.mock("../../../src/utils/dm/findDMForUser", () => ({ + findDMForUser: jest.fn(), +})); + +describe("findDMRoom", () => { + const userId1 = "@user1:example.com"; + const member1 = new DirectoryMember({ user_id: userId1 }); + const member2 = new ThreepidMember("user2"); + let mockClient: MatrixClient; + let room1: Room; + let dmRoomMap: DMRoomMap; + + beforeEach(() => { + mockClient = createTestClient(); + jest.spyOn(MatrixClientPeg, "get").mockReturnValue(mockClient); + room1 = new Room("!room1:example.com", mockClient, userId1); + + dmRoomMap = { + getDMRoomForIdentifiers: jest.fn(), + getDMRoomsForUserId: jest.fn(), + } as unknown as DMRoomMap; + jest.spyOn(DMRoomMap, "shared").mockReturnValue(dmRoomMap); + }); + + it("should return the room for a single target with a room", () => { + mocked(findDMForUser).mockReturnValue(room1); + expect(findDMRoom(mockClient, [member1])).toBe(room1); + }); + + it("should return null for a single target without a room", () => { + mocked(findDMForUser).mockReturnValue(null); + expect(findDMRoom(mockClient, [member1])).toBeNull(); + }); + + it("should return the room for 2 targets with a room", () => { + mocked(dmRoomMap.getDMRoomForIdentifiers).mockReturnValue(room1); + expect(findDMRoom(mockClient, [member1, member2])).toBe(room1); + }); + + it("should return null for 2 targets without a room", () => { + mocked(dmRoomMap.getDMRoomForIdentifiers).mockReturnValue(null); + expect(findDMRoom(mockClient, [member1, member2])).toBeNull(); + }); +}); diff --git a/test/utils/local-room-test.ts b/test/utils/local-room-test.ts index de752694c8..413696ed33 100644 --- a/test/utils/local-room-test.ts +++ b/test/utils/local-room-test.ts @@ -15,18 +15,20 @@ limitations under the License. */ import { mocked } from "jest-mock"; -import { EventType, MatrixClient, Room } from "matrix-js-sdk/src/matrix"; +import { MatrixClient, Room } from "matrix-js-sdk/src/matrix"; import { LocalRoom, LocalRoomState, LOCAL_ROOM_ID_PREFIX } from "../../src/models/LocalRoom"; import * as localRoomModule from "../../src/utils/local-room"; import defaultDispatcher from "../../src/dispatcher/dispatcher"; -import { createTestClient, makeMembershipEvent, mkEvent } from "../test-utils"; -import { DirectoryMember } from "../../src/utils/direct-messages"; +import { createTestClient } from "../test-utils"; +import { isRoomReady } from "../../src/utils/localRoom/isRoomReady"; + +jest.mock("../../src/utils/localRoom/isRoomReady", () => ({ + isRoomReady: jest.fn(), +})); describe("local-room", () => { const userId1 = "@user1:example.com"; - const member1 = new DirectoryMember({ user_id: userId1 }); - const userId2 = "@user2:example.com"; let room1: Room; let localRoom: LocalRoom; let client: MatrixClient; @@ -88,94 +90,6 @@ describe("local-room", () => { }); }); - describe("isRoomReady", () => { - beforeEach(() => { - localRoom.targets = [member1]; - }); - - it("should return false if the room has no actual room id", () => { - expect(localRoomModule.isRoomReady(client, localRoom)).toBe(false); - }); - - describe("for a room with an actual room id", () => { - beforeEach(() => { - localRoom.actualRoomId = room1.roomId; - mocked(client.getRoom).mockReturnValue(null); - }); - - it("it should return false", () => { - expect(localRoomModule.isRoomReady(client, localRoom)).toBe(false); - }); - - describe("and the room is known to the client", () => { - beforeEach(() => { - mocked(client.getRoom).mockImplementation((roomId: string) => { - if (roomId === room1.roomId) return room1; - }); - }); - - it("it should return false", () => { - expect(localRoomModule.isRoomReady(client, localRoom)).toBe(false); - }); - - describe("and all members have been invited or joined", () => { - beforeEach(() => { - room1.currentState.setStateEvents([ - makeMembershipEvent(room1.roomId, userId1, "join"), - makeMembershipEvent(room1.roomId, userId2, "invite"), - ]); - }); - - it("it should return false", () => { - expect(localRoomModule.isRoomReady(client, localRoom)).toBe(false); - }); - - describe("and a RoomHistoryVisibility event", () => { - beforeEach(() => { - room1.currentState.setStateEvents([mkEvent({ - user: userId1, - event: true, - type: EventType.RoomHistoryVisibility, - room: room1.roomId, - content: {}, - })]); - }); - - it("it should return true", () => { - expect(localRoomModule.isRoomReady(client, localRoom)).toBe(true); - }); - - describe("and an encrypted room", () => { - beforeEach(() => { - localRoom.encrypted = true; - }); - - it("it should return false", () => { - expect(localRoomModule.isRoomReady(client, localRoom)).toBe(false); - }); - - describe("and a room encryption state event", () => { - beforeEach(() => { - room1.currentState.setStateEvents([mkEvent({ - user: userId1, - event: true, - type: EventType.RoomEncryption, - room: room1.roomId, - content: {}, - })]); - }); - - it("it should return true", () => { - expect(localRoomModule.isRoomReady(client, localRoom)).toBe(true); - }); - }); - }); - }); - }); - }); - }); - }); - describe("waitForRoomReadyAndApplyAfterCreateCallbacks", () => { let localRoomCallbackRoomId: string; @@ -190,7 +104,7 @@ describe("local-room", () => { describe("for an immediate ready room", () => { beforeEach(() => { - jest.spyOn(localRoomModule, "isRoomReady").mockReturnValue(true); + mocked(isRoomReady).mockReturnValue(true); }); it("should invoke the callbacks, set the room state to created and return the actual room id", async () => { @@ -203,7 +117,7 @@ describe("local-room", () => { describe("for a room running into the create timeout", () => { beforeEach(() => { - jest.spyOn(localRoomModule, "isRoomReady").mockReturnValue(false); + mocked(isRoomReady).mockReturnValue(false); }); it("should invoke the callbacks, set the room state to created and return the actual room id", (done) => { @@ -221,12 +135,12 @@ describe("local-room", () => { describe("for a room that is ready after a while", () => { beforeEach(() => { - jest.spyOn(localRoomModule, "isRoomReady").mockReturnValue(false); + mocked(isRoomReady).mockReturnValue(false); }); it("should invoke the callbacks, set the room state to created and return the actual room id", (done) => { const prom = localRoomModule.waitForRoomReadyAndApplyAfterCreateCallbacks(client, localRoom); - jest.spyOn(localRoomModule, "isRoomReady").mockReturnValue(true); + mocked(isRoomReady).mockReturnValue(true); jest.advanceTimersByTime(500); prom.then((roomId: string) => { expect(localRoom.state).toBe(LocalRoomState.CREATED); diff --git a/test/utils/localRoom/isRoomReady-test.ts b/test/utils/localRoom/isRoomReady-test.ts new file mode 100644 index 0000000000..962db3896c --- /dev/null +++ b/test/utils/localRoom/isRoomReady-test.ts @@ -0,0 +1,126 @@ +/* +Copyright 2022 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 { mocked } from "jest-mock"; +import { EventType, MatrixClient, Room } from "matrix-js-sdk/src/matrix"; + +import { LocalRoom, LOCAL_ROOM_ID_PREFIX } from "../../../src/models/LocalRoom"; +import { DirectoryMember } from "../../../src/utils/direct-messages"; +import { isRoomReady } from "../../../src/utils/localRoom/isRoomReady"; +import { createTestClient, makeMembershipEvent, mkEvent } from "../../test-utils"; + +describe("isRoomReady", () => { + const userId1 = "@user1:example.com"; + const member1 = new DirectoryMember({ user_id: userId1 }); + const userId2 = "@user2:example.com"; + let room1: Room; + let localRoom: LocalRoom; + let client: MatrixClient; + + beforeEach(() => { + client = createTestClient(); + room1 = new Room("!room1:example.com", client, userId1); + room1.getMyMembership = () => "join"; + localRoom = new LocalRoom(LOCAL_ROOM_ID_PREFIX + "test", client, "@test:example.com"); + }); + + beforeEach(() => { + localRoom.targets = [member1]; + }); + + it("should return false if the room has no actual room id", () => { + expect(isRoomReady(client, localRoom)).toBe(false); + }); + + describe("for a room with an actual room id", () => { + beforeEach(() => { + localRoom.actualRoomId = room1.roomId; + mocked(client.getRoom).mockReturnValue(null); + }); + + it("it should return false", () => { + expect(isRoomReady(client, localRoom)).toBe(false); + }); + + describe("and the room is known to the client", () => { + beforeEach(() => { + mocked(client.getRoom).mockImplementation((roomId: string) => { + if (roomId === room1.roomId) return room1; + }); + }); + + it("it should return false", () => { + expect(isRoomReady(client, localRoom)).toBe(false); + }); + + describe("and all members have been invited or joined", () => { + beforeEach(() => { + room1.currentState.setStateEvents([ + makeMembershipEvent(room1.roomId, userId1, "join"), + makeMembershipEvent(room1.roomId, userId2, "invite"), + ]); + }); + + it("it should return false", () => { + expect(isRoomReady(client, localRoom)).toBe(false); + }); + + describe("and a RoomHistoryVisibility event", () => { + beforeEach(() => { + room1.currentState.setStateEvents([mkEvent({ + user: userId1, + event: true, + type: EventType.RoomHistoryVisibility, + room: room1.roomId, + content: {}, + })]); + }); + + it("it should return true", () => { + expect(isRoomReady(client, localRoom)).toBe(true); + }); + + describe("and an encrypted room", () => { + beforeEach(() => { + localRoom.encrypted = true; + }); + + it("it should return false", () => { + expect(isRoomReady(client, localRoom)).toBe(false); + }); + + describe("and a room encryption state event", () => { + beforeEach(() => { + room1.currentState.setStateEvents([mkEvent({ + user: userId1, + event: true, + type: EventType.RoomEncryption, + room: room1.roomId, + content: {}, + })]); + }); + + it("it should return true", () => { + expect(isRoomReady(client, localRoom)).toBe(true); + }); + }); + }); + }); + }); + }); + }); +}); +