diff --git a/src/CallHandler.tsx b/src/CallHandler.tsx index b4e8ae75e6..a8082e668b 100644 --- a/src/CallHandler.tsx +++ b/src/CallHandler.tsx @@ -54,12 +54,13 @@ import { Action } from './dispatcher/actions'; import VoipUserMapper from './VoipUserMapper'; import { addManagedHybridWidget, isManagedHybridWidgetEnabled } from './widgets/ManagedHybrid'; import SdkConfig from './SdkConfig'; -import { ensureDMExists, findDMForUser } from './createRoom'; +import { ensureDMExists } from './createRoom'; import { Container, WidgetLayoutStore } from './stores/widgets/WidgetLayoutStore'; import IncomingCallToast, { getIncomingCallToastKey } from './toasts/IncomingCallToast'; import ToastStore from './stores/ToastStore'; import Resend from './Resend'; import { ViewRoomPayload } from "./dispatcher/payloads/ViewRoomPayload"; +import { findDMForUser } from "./utils/direct-messages"; 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 90f920bc9b..f7026da394 100644 --- a/src/VoipUserMapper.ts +++ b/src/VoipUserMapper.ts @@ -18,11 +18,12 @@ import { Room } from 'matrix-js-sdk/src/models/room'; import { logger } from "matrix-js-sdk/src/logger"; import { EventType } from 'matrix-js-sdk/src/@types/event'; -import { ensureVirtualRoomExists, findDMForUser } from './createRoom'; +import { ensureVirtualRoomExists } from './createRoom'; 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"; // 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/CreateRoomDialog.tsx b/src/components/views/dialogs/CreateRoomDialog.tsx index da6d0212f3..fdf916d6a4 100644 --- a/src/components/views/dialogs/CreateRoomDialog.tsx +++ b/src/components/views/dialogs/CreateRoomDialog.tsx @@ -25,7 +25,7 @@ import SettingsStore from "../../../settings/SettingsStore"; import withValidation, { IFieldState } from '../elements/Validation'; import { _t } from '../../../languageHandler'; import { MatrixClientPeg } from '../../../MatrixClientPeg'; -import { IOpts, privateShouldBeEncrypted } from "../../../createRoom"; +import { IOpts } from "../../../createRoom"; import Heading from "../typography/Heading"; import Field from "../elements/Field"; import StyledRadioGroup from "../elements/StyledRadioGroup"; @@ -37,6 +37,7 @@ import SpaceStore from "../../../stores/spaces/SpaceStore"; import JoinRuleDropdown from "../elements/JoinRuleDropdown"; import { getKeyBindingsManager } from "../../../KeyBindingsManager"; import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; +import { privateShouldBeEncrypted } from "../../../utils/rooms"; interface IProps { defaultPublic?: boolean; diff --git a/src/components/views/dialogs/InviteDialog.tsx b/src/components/views/dialogs/InviteDialog.tsx index cf58a372ba..23a8549343 100644 --- a/src/components/views/dialogs/InviteDialog.tsx +++ b/src/components/views/dialogs/InviteDialog.tsx @@ -34,11 +34,7 @@ import dis from "../../../dispatcher/dispatcher"; import IdentityAuthClient from "../../../IdentityAuthClient"; import Modal from "../../../Modal"; import { humanizeTime } from "../../../utils/humanize"; -import createRoom, { - canEncryptToAllUsers, - findDMForUser, - privateShouldBeEncrypted, -} from "../../../createRoom"; +import createRoom, { canEncryptToAllUsers } from "../../../createRoom"; import { IInviteResult, inviteMultipleToRoom, @@ -68,6 +64,8 @@ import { ScreenName } from '../../../PosthogTrackers'; import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; import { getKeyBindingsManager } from "../../../KeyBindingsManager"; +import { privateShouldBeEncrypted } from "../../../utils/rooms"; +import { findDMForUser } from "../../../utils/direct-messages"; // 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/right_panel/UserInfo.tsx b/src/components/views/right_panel/UserInfo.tsx index 263a64c609..01da603300 100644 --- a/src/components/views/right_panel/UserInfo.tsx +++ b/src/components/views/right_panel/UserInfo.tsx @@ -33,7 +33,7 @@ import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state"; import dis from '../../../dispatcher/dispatcher'; import Modal from '../../../Modal'; import { _t } from '../../../languageHandler'; -import createRoom, { findDMForUser, privateShouldBeEncrypted } from '../../../createRoom'; +import createRoom from '../../../createRoom'; import DMRoomMap from '../../../utils/DMRoomMap'; import AccessibleButton, { ButtonEvent } from '../elements/AccessibleButton'; import SdkConfig from '../../../SdkConfig'; @@ -79,6 +79,8 @@ import { useUserStatusMessage } from "../../../hooks/useUserStatusMessage"; 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"; export interface IDevice { deviceId: string; diff --git a/src/components/views/rooms/NewRoomIntro.tsx b/src/components/views/rooms/NewRoomIntro.tsx index 667145d0a9..363e687c9f 100644 --- a/src/components/views/rooms/NewRoomIntro.tsx +++ b/src/components/views/rooms/NewRoomIntro.tsx @@ -33,12 +33,12 @@ import { ViewUserPayload } from "../../../dispatcher/payloads/ViewUserPayload"; import { Action } from "../../../dispatcher/actions"; import SpaceStore from "../../../stores/spaces/SpaceStore"; import { showSpaceInvite } from "../../../utils/space"; -import { privateShouldBeEncrypted } from "../../../createRoom"; import EventTileBubble from "../messages/EventTileBubble"; import { ROOM_SECURITY_TAB } from "../dialogs/RoomSettingsDialog"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { shouldShowComponent } from "../../../customisations/helpers/UIComponents"; import { UIComponent } from "../../../settings/UIFeature"; +import { privateShouldBeEncrypted } from "../../../utils/rooms"; function hasExpectedEncryptionSettings(matrixClient: MatrixClient, room: Room): boolean { const isEncrypted: boolean = matrixClient.isRoomEncrypted(room.roomId); diff --git a/src/components/views/settings/tabs/user/SecurityUserSettingsTab.tsx b/src/components/views/settings/tabs/user/SecurityUserSettingsTab.tsx index a88e244fe5..e577cd873f 100644 --- a/src/components/views/settings/tabs/user/SecurityUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/SecurityUserSettingsTab.tsx @@ -24,7 +24,6 @@ import { MatrixClientPeg } from "../../../../../MatrixClientPeg"; import AccessibleButton from "../../../elements/AccessibleButton"; import Analytics from "../../../../../Analytics"; import dis from "../../../../../dispatcher/dispatcher"; -import { privateShouldBeEncrypted } from "../../../../../createRoom"; import { SettingLevel } from "../../../../../settings/SettingLevel"; import SecureBackupPanel from "../../SecureBackupPanel"; import SettingsStore from "../../../../../settings/SettingsStore"; @@ -39,6 +38,7 @@ import EventIndexPanel from "../../EventIndexPanel"; import InlineSpinner from "../../../elements/InlineSpinner"; import { PosthogAnalytics } from "../../../../../PosthogAnalytics"; import { showDialog as showAnalyticsLearnMoreDialog } from "../../../dialogs/AnalyticsLearnMoreDialog"; +import { privateShouldBeEncrypted } from "../../../../../utils/rooms"; interface IIgnoredUserProps { userId: string; diff --git a/src/createRoom.ts b/src/createRoom.ts index 92efb9477d..30f495e3bd 100644 --- a/src/createRoom.ts +++ b/src/createRoom.ts @@ -17,7 +17,6 @@ limitations under the License. import { MatrixClient } from "matrix-js-sdk/src/client"; import { Room } from "matrix-js-sdk/src/models/room"; -import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import { EventType, RoomCreateTypeField, RoomType } from "matrix-js-sdk/src/@types/event"; import { ICreateRoomOpts } from "matrix-js-sdk/src/@types/requests"; import { @@ -28,17 +27,13 @@ import { Visibility, } from "matrix-js-sdk/src/@types/partials"; import { logger } from "matrix-js-sdk/src/logger"; -import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state"; import { MatrixClientPeg } from './MatrixClientPeg'; import Modal from './Modal'; import { _t } from './languageHandler'; import dis from "./dispatcher/dispatcher"; import * as Rooms from "./Rooms"; -import DMRoomMap from "./utils/DMRoomMap"; import { getAddressType } from "./UserAddress"; -import { getE2EEWellKnown } from "./utils/WellKnownUtils"; -import { isJoinedOrNearlyJoined } from "./utils/membership"; import { VIRTUAL_ROOM_EVENT_TYPE } from "./call-types"; import SpaceStore from "./stores/spaces/SpaceStore"; import { makeSpaceParentEvent } from "./utils/space"; @@ -47,6 +42,9 @@ 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 { privateShouldBeEncrypted } from "./utils/rooms"; +import { waitForMember } from "./utils/membership"; // we define a number of interfaces which take their names from the js-sdk /* eslint-disable camelcase */ @@ -313,55 +311,6 @@ export default async function createRoom(opts: IOpts): Promise { }); } -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]; - } -} - -/* - * 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: MatrixClient, roomId: string, userId: string, opts = { timeout: 1500 }) { - const { timeout } = opts; - let handler; - return new Promise((resolve) => { - handler = function(_, __, member: RoomMember) { // eslint-disable-line @typescript-eslint/naming-convention - if (member.userId !== userId) return; - if (member.roomId !== roomId) return; - resolve(true); - }; - client.on(RoomStateEvent.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(RoomStateEvent.NewMember, handler); - }); -} - /* * Ensure that for every user in a room, there is at least one device that we * can encrypt to. @@ -424,12 +373,3 @@ export async function ensureDMExists(client: MatrixClient, userId: string): Prom } return roomId; } - -export function privateShouldBeEncrypted(): boolean { - const e2eeWellKnown = getE2EEWellKnown(); - if (e2eeWellKnown) { - const defaultDisabled = e2eeWellKnown["default"] === false; - return !defaultDisabled; - } - return true; -} diff --git a/src/utils/direct-messages.ts b/src/utils/direct-messages.ts new file mode 100644 index 0000000000..6d187b8b7a --- /dev/null +++ b/src/utils/direct-messages.ts @@ -0,0 +1,46 @@ +/* +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 } from "matrix-js-sdk/src/client"; +import { Room } from "matrix-js-sdk/src/models/room"; + +import DMRoomMap from "./DMRoomMap"; +import { isJoinedOrNearlyJoined } from "./membership"; + +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]; + } +} diff --git a/src/utils/membership.ts b/src/utils/membership.ts index 394db19cc9..1a48f1261c 100644 --- a/src/utils/membership.ts +++ b/src/utils/membership.ts @@ -15,6 +15,9 @@ limitations under the License. */ import { Room } from "matrix-js-sdk/src/models/room"; +import { MatrixClient } from "matrix-js-sdk/src/client"; +import { RoomMember } from "matrix-js-sdk/src/models/room-member"; +import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state"; /** * Approximation of a membership status for a given room. @@ -75,3 +78,27 @@ export function isJoinedOrNearlyJoined(membership: string): boolean { const effective = getEffectiveMembership(membership); return effective === EffectiveMembership.Join || effective === EffectiveMembership.Invite; } + +/** + * 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: MatrixClient, roomId: string, userId: string, opts = { timeout: 1500 }) { + const { timeout } = opts; + let handler; + return new Promise((resolve) => { + handler = function(_, __, member: RoomMember) { // eslint-disable-line @typescript-eslint/naming-convention + if (member.userId !== userId) return; + if (member.roomId !== roomId) return; + resolve(true); + }; + client.on(RoomStateEvent.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(RoomStateEvent.NewMember, handler); + }); +} diff --git a/src/utils/rooms.ts b/src/utils/rooms.ts new file mode 100644 index 0000000000..bd6fcf9d97 --- /dev/null +++ b/src/utils/rooms.ts @@ -0,0 +1,26 @@ +/* +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 { getE2EEWellKnown } from "./WellKnownUtils"; + +export function privateShouldBeEncrypted(): boolean { + const e2eeWellKnown = getE2EEWellKnown(); + if (e2eeWellKnown) { + const defaultDisabled = e2eeWellKnown["default"] === false; + return !defaultDisabled; + } + return true; +} diff --git a/src/verification.ts b/src/verification.ts index 5b3d72cb00..197ae051dd 100644 --- a/src/verification.ts +++ b/src/verification.ts @@ -22,13 +22,13 @@ import { MatrixClientPeg } from './MatrixClientPeg'; import dis from "./dispatcher/dispatcher"; import Modal from './Modal'; import { RightPanelPhases } from "./stores/right-panel/RightPanelStorePhases"; -import { findDMForUser } from './createRoom'; import { accessSecretStorage } from './SecurityManager'; import UntrustedDeviceDialog from "./components/views/dialogs/UntrustedDeviceDialog"; 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"; async function enable4SIfNeeded() { const cli = MatrixClientPeg.get(); diff --git a/test/createRoom-test.js b/test/createRoom-test.js index 84eaabdafc..05cc2ebb1a 100644 --- a/test/createRoom-test.js +++ b/test/createRoom-test.js @@ -1,45 +1,4 @@ -import { EventEmitter } from 'events'; - -import { waitForMember, canEncryptToAllUsers } from '../src/createRoom'; - -/* 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 }); - }); -}); +import { canEncryptToAllUsers } from '../src/createRoom'; describe("canEncryptToAllUsers", () => { const trueUser = { diff --git a/test/utils/membership-test.ts b/test/utils/membership-test.ts new file mode 100644 index 0000000000..84fa13580c --- /dev/null +++ b/test/utils/membership-test.ts @@ -0,0 +1,58 @@ +/* +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 { EventEmitter } from "events"; + +import { waitForMember } from "../../src/utils/membership"; + +/* 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 }); + }); +});