diff --git a/src/components/views/dialogs/InviteDialog.js b/src/components/views/dialogs/InviteDialog.js index 8fec2437f6..8c9c5f75ef 100644 --- a/src/components/views/dialogs/InviteDialog.js +++ b/src/components/views/dialogs/InviteDialog.js @@ -31,7 +31,7 @@ import dis from "../../../dispatcher"; import IdentityAuthClient from "../../../IdentityAuthClient"; import Modal from "../../../Modal"; import {humanizeTime} from "../../../utils/humanize"; -import createRoom from "../../../createRoom"; +import createRoom, {canEncryptToAllUsers} from "../../../createRoom"; import {inviteMultipleToRoom} from "../../../RoomInvite"; import SettingsStore from '../../../settings/SettingsStore'; @@ -535,11 +535,7 @@ export default class InviteDialog extends React.PureComponent { // Check whether all users have uploaded device keys before. // If so, enable encryption in the new room. const client = MatrixClientPeg.get(); - const usersToDevicesMap = await client.downloadKeys(targetIds); - const allHaveDeviceKeys = Object.values(usersToDevicesMap).every(devices => { - // `devices` is an object of the form { deviceId: deviceInfo, ... }. - return Object.keys(devices).length > 0; - }); + const allHaveDeviceKeys = await canEncryptToAllUsers(client, targetIds); if (allHaveDeviceKeys) { createRoomOptions.encryption = true; } diff --git a/src/createRoom.js b/src/createRoom.js index d4575633b3..07eaee3e8f 100644 --- a/src/createRoom.js +++ b/src/createRoom.js @@ -23,6 +23,7 @@ import dis from "./dispatcher"; import * as Rooms from "./Rooms"; import DMRoomMap from "./utils/DMRoomMap"; import {getAddressType} from "./UserAddress"; +import SettingsStore from "./settings/SettingsStore"; /** * Create a new room, and switch to it. @@ -174,13 +175,55 @@ export function findDMForUser(client, userId) { } } +/* + * Try to ensure the user is already in the megolm session before continuing + * NOTE: this assumes you've just created the room and there's not been an opportunity + * for other code to run, so we shouldn't miss RoomState.newMember when it comes by. + */ +export async function _waitForMember(client, roomId, userId, opts = { timeout: 1500 }) { + const { timeout } = opts; + let handler; + return new Promise((resolve) => { + handler = function(_event, _roomstate, member) { + if (member.userId !== userId) return; + if (member.roomId !== roomId) return; + resolve(true); + }; + client.on("RoomState.newMember", handler); + + /* We don't want to hang if this goes wrong, so we proceed and hope the other + user is already in the megolm session */ + setTimeout(resolve, timeout, false); + }).finally(() => { + client.removeListener("RoomState.newMember", handler); + }); +} + +/* + * Ensure that for every user in a room, there is at least one device that we + * can encrypt to. + */ +export async function canEncryptToAllUsers(client, userIds) { + const usersDeviceMap = await client.downloadKeys(userIds); + // { "@user:host": { "DEVICE": {...}, ... }, ... } + return Object.values(usersDeviceMap).every((userDevices) => + // { "DEVICE": {...}, ... } + Object.keys(userDevices).length > 0, + ); +} + export async function ensureDMExists(client, userId) { const existingDMRoom = findDMForUser(client, userId); let roomId; if (existingDMRoom) { roomId = existingDMRoom.roomId; } else { - roomId = await createRoom({dmUserId: userId, spinner: false, andView: false}); + let encryption; + if (SettingsStore.isFeatureEnabled("feature_cross_signing")) { + encryption = canEncryptToAllUsers(client, [userId]); + } + roomId = await createRoom({encryption, dmUserId: userId, spinner: false, andView: false}); + await _waitForMember(client, roomId, userId); } return roomId; } diff --git a/test/createRoom-test.js b/test/createRoom-test.js new file mode 100644 index 0000000000..f7e8617c3f --- /dev/null +++ b/test/createRoom-test.js @@ -0,0 +1,72 @@ +import {_waitForMember, canEncryptToAllUsers} from '../src/createRoom'; +import {EventEmitter} from 'events'; + +/* Shorter timeout, we've got tests to run */ +const timeout = 30; + +describe("waitForMember", () => { + let client; + + beforeEach(() => { + client = new EventEmitter(); + }); + + it("resolves with false if the timeout is reached", (done) => { + _waitForMember(client, "", "", { timeout: 0 }).then((r) => { + expect(r).toBe(false); + done(); + }); + }); + + it("resolves with false if the timeout is reached, even if other RoomState.newMember events fire", (done) => { + const roomId = "!roomId:domain"; + const userId = "@clientId:domain"; + _waitForMember(client, roomId, userId, { timeout }).then((r) => { + expect(r).toBe(false); + done(); + }); + client.emit("RoomState.newMember", undefined, undefined, { roomId, userId: "@anotherClient:domain" }); + }); + + it("resolves with true if RoomState.newMember fires", (done) => { + const roomId = "!roomId:domain"; + const userId = "@clientId:domain"; + _waitForMember(client, roomId, userId, { timeout }).then((r) => { + expect(r).toBe(true); + expect(client.listeners("RoomState.newMember").length).toBe(0); + done(); + }); + client.emit("RoomState.newMember", undefined, undefined, { roomId, userId }); + }); +}); + +describe("canEncryptToAllUsers", () => { + const trueUser = { + "@goodUser:localhost": { + "DEV1": {}, + "DEV2": {}, + }, + }; + const falseUser = { + "@badUser:localhost": {}, + }; + + it("returns true if all devices have crypto", async (done) => { + const client = { + downloadKeys: async function(userIds) { return trueUser; }, + }; + const response = await canEncryptToAllUsers(client, ["@goodUser:localhost"]); + expect(response).toBe(true); + done(); + }); + + + it("returns false if not all users have crypto", async (done) => { + const client = { + downloadKeys: async function(userIds) { return {...trueUser, ...falseUser}; }, + }; + const response = await canEncryptToAllUsers(client, ["@goodUser:localhost", "@badUser:localhost"]); + expect(response).toBe(false); + done(); + }); +});