/* Copyright 2024 New Vector Ltd. Copyright 2022 The Matrix.org Foundation C.I.C. SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ import React, { createRef, RefObject } from "react"; import { mocked, MockedObject } from "jest-mock"; import { ClientEvent, EventTimeline, EventType, IEvent, JoinRule, MatrixClient, MatrixError, MatrixEvent, Room, RoomEvent, RoomStateEvent, SearchResult, } from "matrix-js-sdk/src/matrix"; import { CryptoApi, UserVerificationStatus, CryptoEvent } from "matrix-js-sdk/src/crypto-api"; import { KnownMembership } from "matrix-js-sdk/src/types"; import { fireEvent, render, screen, RenderResult, waitForElementToBeRemoved, waitFor, act, cleanup, } from "jest-matrix-react"; import userEvent from "@testing-library/user-event"; import { defer } from "matrix-js-sdk/src/utils"; import { stubClient, mockPlatformPeg, unmockPlatformPeg, flushPromises, mkEvent, setupAsyncStoreWithClient, filterConsole, mkRoomMemberJoinEvent, mkThirdPartyInviteEvent, emitPromise, createTestClient, untilDispatch, } from "../../../test-utils"; import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; import { Action } from "../../../../src/dispatcher/actions"; import defaultDispatcher from "../../../../src/dispatcher/dispatcher"; import { ViewRoomPayload } from "../../../../src/dispatcher/payloads/ViewRoomPayload"; import { RoomView } from "../../../../src/components/structures/RoomView"; import ResizeNotifier from "../../../../src/utils/ResizeNotifier"; import SettingsStore from "../../../../src/settings/SettingsStore"; import { SettingLevel } from "../../../../src/settings/SettingLevel"; import DMRoomMap from "../../../../src/utils/DMRoomMap"; import { NotificationState } from "../../../../src/stores/notifications/NotificationState"; import { RightPanelPhases } from "../../../../src/stores/right-panel/RightPanelStorePhases"; import { LocalRoom, LocalRoomState } from "../../../../src/models/LocalRoom"; import { DirectoryMember } from "../../../../src/utils/direct-messages"; import { createDmLocalRoom } from "../../../../src/utils/dm/createDmLocalRoom"; import { UPDATE_EVENT } from "../../../../src/stores/AsyncStore"; import { SDKContext, SdkContextClass } from "../../../../src/contexts/SDKContext"; import VoipUserMapper from "../../../../src/VoipUserMapper"; import WidgetUtils from "../../../../src/utils/WidgetUtils"; import { WidgetType } from "../../../../src/widgets/WidgetType"; import WidgetStore from "../../../../src/stores/WidgetStore"; import { ViewRoomErrorPayload } from "../../../../src/dispatcher/payloads/ViewRoomErrorPayload"; import { SearchScope } from "../../../../src/Searching"; import { MEGOLM_ENCRYPTION_ALGORITHM } from "../../../../src/utils/crypto"; import MatrixClientContext from "../../../../src/contexts/MatrixClientContext"; describe("RoomView", () => { let cli: MockedObject; let room: Room; let rooms: Map; let roomCount = 0; let stores: SdkContextClass; let crypto: CryptoApi; // mute some noise filterConsole("RVS update", "does not have an m.room.create event", "Current version: 1", "Version capability"); beforeEach(() => { mockPlatformPeg({ reload: () => {} }); cli = mocked(stubClient()); room = new Room(`!${roomCount++}:example.org`, cli, "@alice:example.org"); jest.spyOn(room, "findPredecessor"); room.getPendingEvents = () => []; rooms = new Map(); rooms.set(room.roomId, room); cli.getRoom.mockImplementation((roomId: string | undefined) => rooms.get(roomId || "") || null); // Re-emit certain events on the mocked client room.on(RoomEvent.Timeline, (...args) => cli.emit(RoomEvent.Timeline, ...args)); room.on(RoomEvent.TimelineReset, (...args) => cli.emit(RoomEvent.TimelineReset, ...args)); DMRoomMap.makeShared(cli); stores = new SdkContextClass(); stores.client = cli; stores.rightPanelStore.useUnitTestClient(cli); jest.spyOn(VoipUserMapper.sharedInstance(), "getVirtualRoomForRoom").mockResolvedValue(undefined); crypto = cli.getCrypto()!; jest.spyOn(cli, "getCrypto").mockReturnValue(undefined); }); afterEach(() => { unmockPlatformPeg(); jest.clearAllMocks(); cleanup(); }); const mountRoomView = async (ref?: RefObject): Promise => { if (stores.roomViewStore.getRoomId() !== room.roomId) { const switchedRoom = new Promise((resolve) => { const subFn = () => { if (stores.roomViewStore.getRoomId()) { stores.roomViewStore.off(UPDATE_EVENT, subFn); resolve(); } }; stores.roomViewStore.on(UPDATE_EVENT, subFn); }); act(() => defaultDispatcher.dispatch({ action: Action.ViewRoom, room_id: room.roomId, metricsTrigger: undefined, }), ); await switchedRoom; } const roomView = render( , ); await flushPromises(); return roomView; }; const renderRoomView = async (switchRoom = true): Promise> => { if (switchRoom && stores.roomViewStore.getRoomId() !== room.roomId) { const switchedRoom = new Promise((resolve) => { const subFn = () => { if (stores.roomViewStore.getRoomId()) { stores.roomViewStore.off(UPDATE_EVENT, subFn); resolve(); } }; stores.roomViewStore.on(UPDATE_EVENT, subFn); }); defaultDispatcher.dispatch({ action: Action.ViewRoom, room_id: room.roomId, metricsTrigger: undefined, }); await switchedRoom; } const roomView = render( , ); await flushPromises(); return roomView; }; const getRoomViewInstance = async (): Promise => { const ref = createRef(); await mountRoomView(ref); return ref.current!; }; it("when there is no room predecessor, getHiddenHighlightCount should return 0", async () => { const instance = await getRoomViewInstance(); expect(instance.getHiddenHighlightCount()).toBe(0); }); describe("when there is an old room", () => { let instance: RoomView; let oldRoom: Room; beforeEach(async () => { instance = await getRoomViewInstance(); oldRoom = new Room("!old:example.com", cli, cli.getSafeUserId()); rooms.set(oldRoom.roomId, oldRoom); jest.spyOn(room, "findPredecessor").mockReturnValue({ roomId: oldRoom.roomId }); }); it("and it has 0 unreads, getHiddenHighlightCount should return 0", async () => { jest.spyOn(oldRoom, "getUnreadNotificationCount").mockReturnValue(0); expect(instance.getHiddenHighlightCount()).toBe(0); // assert that msc3946ProcessDynamicPredecessor is false by default expect(room.findPredecessor).toHaveBeenCalledWith(false); }); it("and it has 23 unreads, getHiddenHighlightCount should return 23", async () => { jest.spyOn(oldRoom, "getUnreadNotificationCount").mockReturnValue(23); expect(instance.getHiddenHighlightCount()).toBe(23); }); describe("and feature_dynamic_room_predecessors is enabled", () => { beforeEach(() => { act(() => instance.setState({ msc3946ProcessDynamicPredecessor: true })); }); afterEach(() => { act(() => instance.setState({ msc3946ProcessDynamicPredecessor: false })); }); it("should pass the setting to findPredecessor", async () => { expect(instance.getHiddenHighlightCount()).toBe(0); expect(room.findPredecessor).toHaveBeenCalledWith(true); }); }); }); it("updates url preview visibility on encryption state change", async () => { room.getMyMembership = jest.fn().mockReturnValue(KnownMembership.Join); jest.spyOn(cli, "getCrypto").mockReturnValue(crypto); // we should be starting unencrypted expect(await cli.getCrypto()?.isEncryptionEnabledInRoom(room.roomId)).toEqual(false); const roomViewInstance = await getRoomViewInstance(); // in a default (non-encrypted room, it should start out with url previews enabled) // This is a white-box test in that we're asserting things about the state, which // is not ideal, but asserting that a URL preview just isn't there could risk the // test being invalid because the previews just hasn't rendered yet. This feels // like the safest way I think? // This also relies on the default settings being URL previews on normally and // off for e2e rooms because 1) it's probably useful to assert this and // 2) SettingsStore is a static class and so very hard to mock out. expect(roomViewInstance.state.showUrlPreview).toBe(true); // now enable encryption jest.spyOn(cli.getCrypto()!, "isEncryptionEnabledInRoom").mockResolvedValue(true); // and fake an encryption event into the room to prompt it to re-check act(() => { const encryptionEvent = new MatrixEvent({ type: EventType.RoomEncryption, sender: cli.getUserId()!, content: {}, event_id: "someid", room_id: room.roomId, }); const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS)!; cli.emit(RoomStateEvent.Events, encryptionEvent, roomState, null); }); // URL previews should now be disabled await waitFor(() => expect(roomViewInstance.state.showUrlPreview).toBe(false)); }); it("should not display the timeline when the room encryption is loading", async () => { jest.spyOn(room, "getMyMembership").mockReturnValue(KnownMembership.Join); jest.spyOn(cli, "getCrypto").mockReturnValue(crypto); const deferred = defer(); jest.spyOn(cli.getCrypto()!, "isEncryptionEnabledInRoom").mockImplementation(() => deferred.promise); const { asFragment, container } = await mountRoomView(); expect(container.querySelector(".mx_RoomView_messagePanel")).toBeNull(); expect(asFragment()).toMatchSnapshot(); deferred.resolve(true); await waitFor(() => expect(container.querySelector(".mx_RoomView_messagePanel")).not.toBeNull()); expect(asFragment()).toMatchSnapshot(); }); it("updates live timeline when a timeline reset happens", async () => { const roomViewInstance = await getRoomViewInstance(); const oldTimeline = roomViewInstance.state.liveTimeline; act(() => room.getUnfilteredTimelineSet().resetLiveTimeline()); expect(roomViewInstance.state.liveTimeline).not.toEqual(oldTimeline); }); it("should update when the e2e status when the user verification changed", async () => { room.currentState.setStateEvents([ mkRoomMemberJoinEvent(cli.getSafeUserId(), room.roomId), mkRoomMemberJoinEvent("user@example.com", room.roomId), ]); room.getMyMembership = jest.fn().mockReturnValue(KnownMembership.Join); // Not all the calls to cli.isRoomEncrypted are migrated, so we need to mock both. mocked(cli.isRoomEncrypted).mockReturnValue(true); jest.spyOn(cli, "getCrypto").mockReturnValue(crypto); jest.spyOn(cli.getCrypto()!, "isEncryptionEnabledInRoom").mockResolvedValue(true); jest.spyOn(cli.getCrypto()!, "getUserVerificationStatus").mockResolvedValue( new UserVerificationStatus(false, false, false), ); jest.spyOn(cli.getCrypto()!, "getUserDeviceInfo").mockResolvedValue( new Map([["user@example.com", new Map()]]), ); const { container } = await renderRoomView(); await waitFor(() => expect(container.querySelector(".mx_E2EIcon_normal")).toBeInTheDocument()); const verificationStatus = new UserVerificationStatus(true, true, false); jest.spyOn(cli.getCrypto()!, "getUserVerificationStatus").mockResolvedValue(verificationStatus); cli.emit(CryptoEvent.UserTrustStatusChanged, cli.getSafeUserId(), verificationStatus); await waitFor(() => expect(container.querySelector(".mx_E2EIcon_verified")).toBeInTheDocument()); }); describe("with virtual rooms", () => { it("checks for a virtual room on initial load", async () => { const { container } = await renderRoomView(); expect(VoipUserMapper.sharedInstance().getVirtualRoomForRoom).toHaveBeenCalledWith(room.roomId); // quick check that rendered without error expect(container.querySelector(".mx_ErrorBoundary")).toBeFalsy(); }); it("checks for a virtual room on room event", async () => { await renderRoomView(); expect(VoipUserMapper.sharedInstance().getVirtualRoomForRoom).toHaveBeenCalledWith(room.roomId); act(() => cli.emit(ClientEvent.Room, room)); // called again after room event expect(VoipUserMapper.sharedInstance().getVirtualRoomForRoom).toHaveBeenCalledTimes(2); }); }); describe("video rooms", () => { beforeEach(async () => { // Make it a video room room.isElementVideoRoom = () => true; await SettingsStore.setValue("feature_video_rooms", null, SettingLevel.DEVICE, true); }); it("normally doesn't open the chat panel", async () => { jest.spyOn(NotificationState.prototype, "isUnread", "get").mockReturnValue(false); await mountRoomView(); expect(stores.rightPanelStore.isOpen).toEqual(false); }); it("opens the chat panel if there are unread messages", async () => { jest.spyOn(NotificationState.prototype, "isUnread", "get").mockReturnValue(true); await mountRoomView(); expect(stores.rightPanelStore.isOpen).toEqual(true); expect(stores.rightPanelStore.currentCard.phase).toEqual(RightPanelPhases.Timeline); }); it("should render joined video room view", async () => { jest.spyOn(room, "getMyMembership").mockReturnValue(KnownMembership.Join); const { asFragment } = await mountRoomView(); expect(asFragment()).toMatchSnapshot(); }); }); describe("for a local room", () => { let localRoom: LocalRoom; beforeEach(async () => { localRoom = room = await createDmLocalRoom(cli, [new DirectoryMember({ user_id: "@user:example.com" })]); rooms.set(localRoom.roomId, localRoom); cli.store.storeRoom(room); }); it("should remove the room from the store on unmount", async () => { const { unmount } = await renderRoomView(); unmount(); expect(cli.store.removeRoom).toHaveBeenCalledWith(room.roomId); }); describe("in state NEW", () => { it("should match the snapshot", async () => { const { container } = await renderRoomView(); expect(container).toMatchSnapshot(); }); describe("that is encrypted", () => { beforeEach(() => { // Not all the calls to cli.isRoomEncrypted are migrated, so we need to mock both. mocked(cli.isRoomEncrypted).mockReturnValue(true); jest.spyOn(cli, "getCrypto").mockReturnValue(crypto); jest.spyOn(cli.getCrypto()!, "isEncryptionEnabledInRoom").mockResolvedValue(true); jest.spyOn(cli.getCrypto()!, "getUserVerificationStatus").mockResolvedValue( new UserVerificationStatus(false, true, false), ); localRoom.encrypted = true; localRoom.currentState.setStateEvents([ new MatrixEvent({ event_id: `~${localRoom.roomId}:${cli.makeTxnId()}`, type: EventType.RoomEncryption, content: { algorithm: MEGOLM_ENCRYPTION_ALGORITHM, }, sender: cli.getUserId()!, state_key: "", room_id: localRoom.roomId, origin_server_ts: Date.now(), }), ]); }); it("should match the snapshot", async () => { const { container } = await renderRoomView(); await waitFor(() => expect(container).toMatchSnapshot()); }); }); }); it("in state CREATING should match the snapshot", async () => { localRoom.state = LocalRoomState.CREATING; const { container } = await renderRoomView(); expect(container).toMatchSnapshot(); }); describe("in state ERROR", () => { beforeEach(async () => { localRoom.state = LocalRoomState.ERROR; }); it("should match the snapshot", async () => { const { container } = await renderRoomView(); expect(container).toMatchSnapshot(); }); it("clicking retry should set the room state to new dispatch a local room event", async () => { jest.spyOn(defaultDispatcher, "dispatch"); const { getByText } = await renderRoomView(); fireEvent.click(getByText("Retry")); expect(localRoom.state).toBe(LocalRoomState.NEW); expect(defaultDispatcher.dispatch).toHaveBeenCalledWith({ action: "local_room_event", roomId: room.roomId, }); }); }); }); describe("when rendering a DM room with a single third-party invite", () => { beforeEach(async () => { room.currentState.setStateEvents([ mkRoomMemberJoinEvent(cli.getSafeUserId(), room.roomId), mkThirdPartyInviteEvent(cli.getSafeUserId(), "user@example.com", room.roomId), ]); jest.spyOn(DMRoomMap.shared(), "getUserIdForRoomId").mockReturnValue(cli.getSafeUserId()); jest.spyOn(DMRoomMap.shared(), "getRoomIds").mockReturnValue(new Set([room.roomId])); jest.spyOn(cli, "getCrypto").mockReturnValue(crypto); jest.spyOn(cli.getCrypto()!, "isEncryptionEnabledInRoom").mockResolvedValue(true); await renderRoomView(); }); it("should render the »waiting for third-party« view", () => { expect(screen.getByText("Waiting for users to join Element")).toBeInTheDocument(); expect( screen.getByText( "Once invited users have joined Element, you will be able to chat and the room will be end-to-end encrypted", ), ).toBeInTheDocument(); // no message composer expect(screen.queryByText("Send an encrypted message…")).not.toBeInTheDocument(); expect(screen.queryByText("Send a message…")).not.toBeInTheDocument(); }); }); it("should show error view if failed to look up room alias", async () => { const { asFragment, findByText } = await renderRoomView(false); act(() => defaultDispatcher.dispatch({ action: Action.ViewRoomError, room_alias: "#addy:server", room_id: null, err: new MatrixError({ errcode: "M_NOT_FOUND" }), }), ); await emitPromise(stores.roomViewStore, UPDATE_EVENT); await findByText("Are you sure you're at the right place?"); expect(asFragment()).toMatchSnapshot(); }); describe("knock rooms", () => { const client = createTestClient(); beforeEach(() => { jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => setting === "feature_ask_to_join"); jest.spyOn(room, "getJoinRule").mockReturnValue(JoinRule.Knock); jest.spyOn(defaultDispatcher, "dispatch"); }); it("allows to request to join", async () => { jest.spyOn(MatrixClientPeg, "safeGet").mockReturnValue(client); jest.spyOn(client, "knockRoom").mockResolvedValue({ room_id: room.roomId }); await mountRoomView(); fireEvent.click(screen.getByRole("button", { name: "Request access" })); await untilDispatch(Action.SubmitAskToJoin, defaultDispatcher); expect(defaultDispatcher.dispatch).toHaveBeenCalledWith({ action: "submit_ask_to_join", roomId: room.roomId, opts: { reason: undefined }, }); }); it("allows to cancel a join request", async () => { jest.spyOn(MatrixClientPeg, "safeGet").mockReturnValue(client); jest.spyOn(client, "leave").mockResolvedValue({}); jest.spyOn(room, "getMyMembership").mockReturnValue(KnownMembership.Knock); await mountRoomView(); fireEvent.click(screen.getByRole("button", { name: "Cancel request" })); await untilDispatch(Action.CancelAskToJoin, defaultDispatcher); expect(defaultDispatcher.dispatch).toHaveBeenCalledWith({ action: "cancel_ask_to_join", roomId: room.roomId, }); }); }); it("should close search results when edit is clicked", async () => { room.getMyMembership = jest.fn().mockReturnValue(KnownMembership.Join); const eventMapper = (obj: Partial) => new MatrixEvent(obj); const roomViewRef = createRef(); const { container, getByText, findByLabelText } = await mountRoomView(roomViewRef); await waitFor(() => expect(roomViewRef.current).toBeTruthy()); // @ts-ignore - triggering a search organically is a lot of work act(() => roomViewRef.current!.setState({ search: { searchId: 1, roomId: room.roomId, term: "search term", scope: SearchScope.Room, promise: Promise.resolve({ results: [ SearchResult.fromJson( { rank: 1, result: { content: { body: "search term", msgtype: "m.text", }, type: "m.room.message", event_id: "$eventId", sender: cli.getSafeUserId(), origin_server_ts: 123456789, room_id: room.roomId, }, context: { events_before: [], events_after: [], profile_info: {}, }, }, eventMapper, ), ], highlights: [], count: 1, }), inProgress: false, count: 1, }, }), ); await waitFor(() => { expect(container.querySelector(".mx_RoomView_searchResultsPanel")).toBeVisible(); }); const prom = waitForElementToBeRemoved(() => container.querySelector(".mx_RoomView_searchResultsPanel")); await userEvent.hover(getByText("search term")); await userEvent.click(await findByLabelText("Edit")); await prom; }); it("should switch rooms when edit is clicked on a search result for a different room", async () => { const room2 = new Room(`!${roomCount++}:example.org`, cli, "@alice:example.org"); rooms.set(room2.roomId, room2); room.getMyMembership = jest.fn().mockReturnValue(KnownMembership.Join); const eventMapper = (obj: Partial) => new MatrixEvent(obj); const roomViewRef = createRef(); const { container, getByText, findByLabelText } = await mountRoomView(roomViewRef); await waitFor(() => expect(roomViewRef.current).toBeTruthy()); // @ts-ignore - triggering a search organically is a lot of work act(() => roomViewRef.current!.setState({ search: { searchId: 1, roomId: room.roomId, term: "search term", scope: SearchScope.All, promise: Promise.resolve({ results: [ SearchResult.fromJson( { rank: 1, result: { content: { body: "search term", msgtype: "m.text", }, type: "m.room.message", event_id: "$eventId", sender: cli.getSafeUserId(), origin_server_ts: 123456789, room_id: room2.roomId, }, context: { events_before: [], events_after: [], profile_info: {}, }, }, eventMapper, ), ], highlights: [], count: 1, }), inProgress: false, count: 1, }, }), ); await waitFor(() => { expect(container.querySelector(".mx_RoomView_searchResultsPanel")).toBeVisible(); }); const prom = untilDispatch(Action.ViewRoom, defaultDispatcher); await userEvent.hover(getByText("search term")); await userEvent.click(await findByLabelText("Edit")); await expect(prom).resolves.toEqual(expect.objectContaining({ room_id: room2.roomId })); }); it("fires Action.RoomLoaded", async () => { jest.spyOn(defaultDispatcher, "dispatch"); await mountRoomView(); expect(defaultDispatcher.dispatch).toHaveBeenCalledWith({ action: Action.RoomLoaded }); }); describe("when there is a RoomView", () => { const widget1Id = "widget1"; const widget2Id = "widget2"; const otherUserId = "@other:example.com"; const addJitsiWidget = async (id: string, user: string, ts?: number): Promise => { const widgetEvent = mkEvent({ event: true, room: room.roomId, user, type: "im.vector.modular.widgets", content: { id, name: "Jitsi", type: WidgetType.JITSI.preferred, url: "https://example.com", }, skey: id, ts, }); room.addLiveEvents([widgetEvent], { addToState: false }); room.currentState.setStateEvents([widgetEvent]); cli.emit(RoomStateEvent.Events, widgetEvent, room.currentState, null); await flushPromises(); }; beforeEach(async () => { jest.spyOn(WidgetUtils, "setRoomWidget"); const widgetStore = WidgetStore.instance; await setupAsyncStoreWithClient(widgetStore, cli); getRoomViewInstance(); }); const itShouldNotRemoveTheLastWidget = (): void => { it("should not remove the last widget", (): void => { expect(WidgetUtils.setRoomWidget).not.toHaveBeenCalledWith(room.roomId, widget2Id); }); }; describe("and there is a Jitsi widget from another user", () => { beforeEach(async () => { await addJitsiWidget(widget1Id, otherUserId, 10_000); }); describe("and the current user adds a Jitsi widget after 10s", () => { beforeEach(async () => { await addJitsiWidget(widget2Id, cli.getSafeUserId(), 20_000); }); it("the last Jitsi widget should be removed", () => { expect(WidgetUtils.setRoomWidget).toHaveBeenCalledWith(cli, room.roomId, widget2Id); }); }); describe("and the current user adds a Jitsi widget after two minutes", () => { beforeEach(async () => { await addJitsiWidget(widget2Id, cli.getSafeUserId(), 130_000); }); itShouldNotRemoveTheLastWidget(); }); describe("and the current user adds a Jitsi widget without timestamp", () => { beforeEach(async () => { await addJitsiWidget(widget2Id, cli.getSafeUserId()); }); itShouldNotRemoveTheLastWidget(); }); }); describe("and there is a Jitsi widget from another user without timestamp", () => { beforeEach(async () => { await addJitsiWidget(widget1Id, otherUserId); }); describe("and the current user adds a Jitsi widget", () => { beforeEach(async () => { await addJitsiWidget(widget2Id, cli.getSafeUserId(), 10_000); }); itShouldNotRemoveTheLastWidget(); }); }); }); });