From 8641a5210bc1c960e27e33a66fa36126b65beefa Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Mon, 11 Jul 2022 07:33:37 +0200 Subject: [PATCH] Add LocalRoom (#9023) --- src/models/LocalRoom.ts | 61 +++++ .../room-list/filters/VisibilityProvider.ts | 6 + src/utils/local-room.ts | 153 +++++++++++ test/models/LocalRoom-test.ts | 90 +++++++ .../filters/VisibilityProvider-test.ts | 124 +++++++++ test/utils/local-room-test.ts | 241 ++++++++++++++++++ 6 files changed, 675 insertions(+) create mode 100644 src/models/LocalRoom.ts create mode 100644 src/utils/local-room.ts create mode 100644 test/models/LocalRoom-test.ts create mode 100644 test/stores/room-list/filters/VisibilityProvider-test.ts create mode 100644 test/utils/local-room-test.ts diff --git a/src/models/LocalRoom.ts b/src/models/LocalRoom.ts new file mode 100644 index 0000000000..ee58dd408f --- /dev/null +++ b/src/models/LocalRoom.ts @@ -0,0 +1,61 @@ +/* +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, PendingEventOrdering } from "matrix-js-sdk/src/matrix"; + +import { Member } from "../utils/direct-messages"; + +export const LOCAL_ROOM_ID_PREFIX = 'local+'; + +export enum LocalRoomState { + NEW, // new local room; only known to the client + CREATING, // real room is being created + CREATED, // real room has been created via API; events applied + ERROR, // error during room creation +} + +/** + * A local room that only exists client side. + * Its main purpose is to be used for temporary rooms when creating a DM. + */ +export class LocalRoom extends Room { + /** Whether the actual room should be encrypted. */ + encrypted = false; + /** If the actual room has been created, this holds its ID. */ + actualRoomId: string; + /** DM chat partner */ + targets: Member[] = []; + /** Callbacks that should be invoked after the actual room has been created. */ + afterCreateCallbacks: Function[] = []; + state: LocalRoomState = LocalRoomState.NEW; + + constructor(roomId: string, client: MatrixClient, myUserId: string) { + super(roomId, client, myUserId, { pendingEventOrdering: PendingEventOrdering.Detached }); + this.name = this.getDefaultRoomName(myUserId); + } + + public get isNew(): boolean { + return this.state === LocalRoomState.NEW; + } + + public get isCreated(): boolean { + return this.state === LocalRoomState.CREATED; + } + + public get isError(): boolean { + return this.state === LocalRoomState.ERROR; + } +} diff --git a/src/stores/room-list/filters/VisibilityProvider.ts b/src/stores/room-list/filters/VisibilityProvider.ts index 24f49ccf92..26bfcd78ea 100644 --- a/src/stores/room-list/filters/VisibilityProvider.ts +++ b/src/stores/room-list/filters/VisibilityProvider.ts @@ -18,6 +18,7 @@ import { Room } from "matrix-js-sdk/src/models/room"; import CallHandler from "../../../CallHandler"; import { RoomListCustomisations } from "../../../customisations/RoomList"; +import { LocalRoom } from "../../../models/LocalRoom"; import VoipUserMapper from "../../../VoipUserMapper"; export class VisibilityProvider { @@ -54,6 +55,11 @@ export class VisibilityProvider { return false; } + if (room instanceof LocalRoom) { + // local rooms shouldn't show up anywhere + return false; + } + const isVisibleFn = RoomListCustomisations.isRoomVisible; if (isVisibleFn) { return isVisibleFn(room); diff --git a/src/utils/local-room.ts b/src/utils/local-room.ts new file mode 100644 index 0000000000..29fab7206d --- /dev/null +++ b/src/utils/local-room.ts @@ -0,0 +1,153 @@ +/* +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 { logger } from "matrix-js-sdk/src/logger"; +import { ClientEvent, EventType, 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"; + +/** + * Does a room action: + * For non-local rooms it calls fn directly. + * For local rooms it adds the callback function to the room's afterCreateCallbacks and + * dispatches a "local_room_event". + * + * @async + * @template T + * @param {string} roomId Room ID of the target room + * @param {(actualRoomId: string) => Promise} fn Callback to be called directly or collected at the local room + * @param {MatrixClient} [client] + * @returns {Promise} Promise that gets resolved after the callback has finished + */ +export async function doMaybeLocalRoomAction( + roomId: string, + fn: (actualRoomId: string) => Promise, + client?: MatrixClient, +): Promise { + if (roomId.startsWith(LOCAL_ROOM_ID_PREFIX)) { + client = client ?? MatrixClientPeg.get(); + const room = client.getRoom(roomId) as LocalRoom; + + if (room.isCreated) { + return fn(room.actualRoomId); + } + + return new Promise((resolve, reject) => { + room.afterCreateCallbacks.push((newRoomId: string) => { + fn(newRoomId).then(resolve).catch(reject); + }); + defaultDispatcher.dispatch({ + action: "local_room_event", + roomId: room.roomId, + }); + }); + } + + 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. + * + * @see isRoomReady + * @async + * @param {MatrixClient} client + * @param {LocalRoom} localRoom + * @returns {Promise} Resolved to the actual room id + */ +export async function waitForRoomReadyAndApplyAfterCreateCallbacks( + client: MatrixClient, + localRoom: LocalRoom, +): Promise { + if (thisModule.isRoomReady(client, localRoom)) { + return applyAfterCreateCallbacks(localRoom, localRoom.actualRoomId).then(() => { + localRoom.state = LocalRoomState.CREATED; + client.emit(ClientEvent.Room, localRoom); + return Promise.resolve(localRoom.actualRoomId); + }); + } + + return new Promise((resolve) => { + const finish = () => { + if (checkRoomStateIntervalHandle) clearInterval(checkRoomStateIntervalHandle); + if (stopgapTimeoutHandle) clearTimeout(stopgapTimeoutHandle); + + applyAfterCreateCallbacks(localRoom, localRoom.actualRoomId).then(() => { + localRoom.state = LocalRoomState.CREATED; + client.emit(ClientEvent.Room, localRoom); + resolve(localRoom.actualRoomId); + }); + }; + + const stopgapFinish = () => { + logger.warn(`Assuming local room ${localRoom.roomId} is ready after hitting timeout`); + finish(); + }; + + const checkRoomStateIntervalHandle = setInterval(() => { + if (thisModule.isRoomReady(client, localRoom)) finish(); + }, 500); + const stopgapTimeoutHandle = setTimeout(stopgapFinish, 5000); + }); +} + +/** + * Applies the after-create callback of a local room. + * + * @async + * @param {LocalRoom} localRoom + * @param {string} roomId + * @returns {Promise} Resolved after all callbacks have been called + */ +async function applyAfterCreateCallbacks(localRoom: LocalRoom, roomId: string): Promise { + for (const afterCreateCallback of localRoom.afterCreateCallbacks) { + await afterCreateCallback(roomId); + } + + localRoom.afterCreateCallbacks = []; +} diff --git a/test/models/LocalRoom-test.ts b/test/models/LocalRoom-test.ts new file mode 100644 index 0000000000..00ba11eb0b --- /dev/null +++ b/test/models/LocalRoom-test.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 { MatrixClient } from "matrix-js-sdk/src/matrix"; + +import { LocalRoom, LocalRoomState, LOCAL_ROOM_ID_PREFIX } from "../../src/models/LocalRoom"; +import { createTestClient } from "../test-utils"; + +const stateTestData = [ + { + name: "NEW", + state: LocalRoomState.NEW, + isNew: true, + isCreated: false, + isError: false, + }, + { + name: "CREATING", + state: LocalRoomState.CREATING, + isNew: false, + isCreated: false, + isError: false, + }, + { + name: "CREATED", + state: LocalRoomState.CREATED, + isNew: false, + isCreated: true, + isError: false, + }, + { + name: "ERROR", + state: LocalRoomState.ERROR, + isNew: false, + isCreated: false, + isError: true, + }, +]; + +describe("LocalRoom", () => { + let room: LocalRoom; + let client: MatrixClient; + + beforeEach(() => { + client = createTestClient(); + room = new LocalRoom(LOCAL_ROOM_ID_PREFIX + "test", client, "@test:localhost"); + }); + + it("should not raise an error on getPendingEvents (implicitly check for pendingEventOrdering: detached)", () => { + room.getPendingEvents(); + expect(true).toBe(true); + }); + + it("should not have after create callbacks", () => { + expect(room.afterCreateCallbacks).toHaveLength(0); + }); + + stateTestData.forEach((stateTestDatum) => { + describe(`in state ${stateTestDatum.name}`, () => { + beforeEach(() => { + room.state = stateTestDatum.state; + }); + + it(`isNew should return ${stateTestDatum.isNew}`, () => { + expect(room.isNew).toBe(stateTestDatum.isNew); + }); + + it(`isCreated should return ${stateTestDatum.isCreated}`, () => { + expect(room.isCreated).toBe(stateTestDatum.isCreated); + }); + + it(`isError should return ${stateTestDatum.isError}`, () => { + expect(room.isError).toBe(stateTestDatum.isError); + }); + }); + }); +}); diff --git a/test/stores/room-list/filters/VisibilityProvider-test.ts b/test/stores/room-list/filters/VisibilityProvider-test.ts new file mode 100644 index 0000000000..596e815d36 --- /dev/null +++ b/test/stores/room-list/filters/VisibilityProvider-test.ts @@ -0,0 +1,124 @@ +/* +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 { Room } from "matrix-js-sdk/src/matrix"; + +import { VisibilityProvider } from "../../../../src/stores/room-list/filters/VisibilityProvider"; +import CallHandler from "../../../../src/CallHandler"; +import VoipUserMapper from "../../../../src/VoipUserMapper"; +import { LocalRoom, LOCAL_ROOM_ID_PREFIX } from "../../../../src/models/LocalRoom"; +import { RoomListCustomisations } from "../../../../src/customisations/RoomList"; +import { createTestClient } from "../../../test-utils"; + +jest.mock("../../../../src/VoipUserMapper", () => ({ + sharedInstance: jest.fn(), +})); + +jest.mock("../../../../src/CallHandler", () => ({ + instance: { + getSupportsVirtualRooms: jest.fn(), + }, +})); + +jest.mock("../../../../src/customisations/RoomList", () => ({ + RoomListCustomisations: { + isRoomVisible: jest.fn(), + }, +})); + +const createRoom = (isSpaceRoom = false): Room => { + return { + isSpaceRoom: () => isSpaceRoom, + } as unknown as Room; +}; + +const createLocalRoom = (): LocalRoom => { + const room = new LocalRoom(LOCAL_ROOM_ID_PREFIX + "test", createTestClient(), "@test:example.com"); + room.isSpaceRoom = () => false; + return room; +}; + +describe("VisibilityProvider", () => { + let mockVoipUserMapper: VoipUserMapper; + + beforeEach(() => { + mockVoipUserMapper = { + onNewInvitedRoom: jest.fn(), + isVirtualRoom: jest.fn(), + } as unknown as VoipUserMapper; + mocked(VoipUserMapper.sharedInstance).mockReturnValue(mockVoipUserMapper); + }); + + describe("instance", () => { + it("should return an instance", () => { + const visibilityProvider = VisibilityProvider.instance; + expect(visibilityProvider).toBeInstanceOf(VisibilityProvider); + expect(VisibilityProvider.instance).toBe(visibilityProvider); + }); + }); + + describe("onNewInvitedRoom", () => { + it("should call onNewInvitedRoom on VoipUserMapper.sharedInstance", async () => { + const room = {} as unknown as Room; + await VisibilityProvider.instance.onNewInvitedRoom(room); + expect(mockVoipUserMapper.onNewInvitedRoom).toHaveBeenCalledWith(room); + }); + }); + + describe("isRoomVisible", () => { + describe("for a virtual room", () => { + beforeEach(() => { + mocked(CallHandler.instance.getSupportsVirtualRooms).mockReturnValue(true); + mocked(mockVoipUserMapper.isVirtualRoom).mockReturnValue(true); + }); + + it("should return return false", () => { + const room = createRoom(); + expect(VisibilityProvider.instance.isRoomVisible(room)).toBe(false); + expect(mockVoipUserMapper.isVirtualRoom).toHaveBeenCalledWith(room); + }); + }); + + it("should return false without room", () => { + expect(VisibilityProvider.instance.isRoomVisible()).toBe(false); + }); + + it("should return false for a space room", () => { + const room = createRoom(true); + expect(VisibilityProvider.instance.isRoomVisible(room)).toBe(false); + }); + + it("should return false for a local room", () => { + const room = createLocalRoom(); + expect(VisibilityProvider.instance.isRoomVisible(room)).toBe(false); + }); + + it("should return false if visibility customisation returns false", () => { + mocked(RoomListCustomisations.isRoomVisible).mockReturnValue(false); + const room = createRoom(); + expect(VisibilityProvider.instance.isRoomVisible(room)).toBe(false); + expect(RoomListCustomisations.isRoomVisible).toHaveBeenCalledWith(room); + }); + + it("should return true if visibility customisation returns true", () => { + mocked(RoomListCustomisations.isRoomVisible).mockReturnValue(true); + const room = createRoom(); + expect(VisibilityProvider.instance.isRoomVisible(room)).toBe(true); + expect(RoomListCustomisations.isRoomVisible).toHaveBeenCalledWith(room); + }); + }); +}); diff --git a/test/utils/local-room-test.ts b/test/utils/local-room-test.ts new file mode 100644 index 0000000000..de752694c8 --- /dev/null +++ b/test/utils/local-room-test.ts @@ -0,0 +1,241 @@ +/* +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, 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"; + +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; + + 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"); + mocked(client.getRoom).mockImplementation((roomId: string) => { + if (roomId === localRoom.roomId) { + return localRoom; + } + return null; + }); + }); + + describe("doMaybeLocalRoomAction", () => { + let callback: jest.Mock; + + beforeEach(() => { + callback = jest.fn(); + callback.mockReturnValue(Promise.resolve()); + localRoom.actualRoomId = "@new:example.com"; + }); + + it("should invoke the callback for a non-local room", () => { + localRoomModule.doMaybeLocalRoomAction("!room:example.com", callback, client); + expect(callback).toHaveBeenCalled(); + }); + + it("should invoke the callback with the new room ID for a created room", () => { + localRoom.state = LocalRoomState.CREATED; + localRoomModule.doMaybeLocalRoomAction(localRoom.roomId, callback, client); + expect(callback).toHaveBeenCalledWith(localRoom.actualRoomId); + }); + + describe("for a local room", () => { + let prom; + + beforeEach(() => { + jest.spyOn(defaultDispatcher, "dispatch"); + prom = localRoomModule.doMaybeLocalRoomAction(localRoom.roomId, callback, client); + }); + + it("dispatch a local_room_event", () => { + expect(defaultDispatcher.dispatch).toHaveBeenCalledWith({ + action: "local_room_event", + roomId: localRoom.roomId, + }); + }); + + it("should resolve the promise after invoking the callback", async () => { + localRoom.afterCreateCallbacks.forEach((callback) => { + callback(localRoom.actualRoomId); + }); + await prom; + }); + }); + }); + + 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; + + beforeEach(() => { + localRoom.actualRoomId = room1.roomId; + localRoom.afterCreateCallbacks.push((roomId: string) => { + localRoomCallbackRoomId = roomId; + return Promise.resolve(); + }); + jest.useFakeTimers(); + }); + + describe("for an immediate ready room", () => { + beforeEach(() => { + jest.spyOn(localRoomModule, "isRoomReady").mockReturnValue(true); + }); + + it("should invoke the callbacks, set the room state to created and return the actual room id", async () => { + const result = await localRoomModule.waitForRoomReadyAndApplyAfterCreateCallbacks(client, localRoom); + expect(localRoom.state).toBe(LocalRoomState.CREATED); + expect(localRoomCallbackRoomId).toBe(room1.roomId); + expect(result).toBe(room1.roomId); + }); + }); + + describe("for a room running into the create timeout", () => { + beforeEach(() => { + jest.spyOn(localRoomModule, "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.advanceTimersByTime(5000); + prom.then((roomId: string) => { + expect(localRoom.state).toBe(LocalRoomState.CREATED); + expect(localRoomCallbackRoomId).toBe(room1.roomId); + expect(roomId).toBe(room1.roomId); + expect(jest.getTimerCount()).toBe(0); + done(); + }); + }); + }); + + describe("for a room that is ready after a while", () => { + beforeEach(() => { + jest.spyOn(localRoomModule, "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); + jest.advanceTimersByTime(500); + prom.then((roomId: string) => { + expect(localRoom.state).toBe(LocalRoomState.CREATED); + expect(localRoomCallbackRoomId).toBe(room1.roomId); + expect(roomId).toBe(room1.roomId); + expect(jest.getTimerCount()).toBe(0); + done(); + }); + }); + }); + }); +});