/* 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 from "react"; import { render, screen, act, fireEvent, waitFor, getByRole, RenderResult } from "@testing-library/react"; import { mocked, Mocked } from "jest-mock"; import { EventType, RoomType, Room, RoomStateEvent, PendingEventOrdering, ISearchResults, IContent, } from "matrix-js-sdk/src/matrix"; import { KnownMembership } from "matrix-js-sdk/src/types"; import { CallType } from "matrix-js-sdk/src/webrtc/call"; import { ClientWidgetApi, Widget } from "matrix-widget-api"; import EventEmitter from "events"; import { setupJestCanvasMock } from "jest-canvas-mock"; import { ViewRoomOpts } from "@matrix-org/react-sdk-module-api/lib/lifecycles/RoomViewLifecycle"; import { MatrixRTCSession, MatrixRTCSessionManagerEvents } from "matrix-js-sdk/src/matrixrtc"; import type { MatrixClient, MatrixEvent, RoomMember } from "matrix-js-sdk/src/matrix"; import type { MatrixCall } from "matrix-js-sdk/src/webrtc/call"; import { stubClient, mkRoomMember, setupAsyncStoreWithClient, resetAsyncStoreWithClient, mockPlatformPeg, mkEvent, filterConsole, } from "../../../test-utils"; import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; import DMRoomMap from "../../../../src/utils/DMRoomMap"; import RoomHeader, { IProps as RoomHeaderProps } from "../../../../src/components/views/rooms/LegacyRoomHeader"; import { E2EStatus } from "../../../../src/utils/ShieldUtils"; import { IRoomState } from "../../../../src/components/structures/RoomView"; import RoomContext from "../../../../src/contexts/RoomContext"; import SdkConfig from "../../../../src/SdkConfig"; import SettingsStore from "../../../../src/settings/SettingsStore"; import { ElementCall, JitsiCall } from "../../../../src/models/Call"; import { CallStore } from "../../../../src/stores/CallStore"; import LegacyCallHandler from "../../../../src/LegacyCallHandler"; import defaultDispatcher from "../../../../src/dispatcher/dispatcher"; import { Action } from "../../../../src/dispatcher/actions"; import WidgetStore from "../../../../src/stores/WidgetStore"; import { WidgetMessagingStore } from "../../../../src/stores/widgets/WidgetMessagingStore"; import MediaDeviceHandler, { MediaDeviceKindEnum } from "../../../../src/MediaDeviceHandler"; import { shouldShowComponent } from "../../../../src/customisations/helpers/UIComponents"; import { UIComponent } from "../../../../src/settings/UIFeature"; import WidgetUtils from "../../../../src/utils/WidgetUtils"; import { ElementWidgetActions } from "../../../../src/stores/widgets/ElementWidgetActions"; import { SearchScope } from "../../../../src/Searching"; jest.mock("../../../../src/customisations/helpers/UIComponents", () => ({ shouldShowComponent: jest.fn(), })); describe("LegacyRoomHeader", () => { let client: Mocked; let room: Room; let alice: RoomMember; let bob: RoomMember; let carol: RoomMember; filterConsole( "Age for event was not available, using `now - origin_server_ts` as a fallback. If the device clock is not correct issues might occur.", ); beforeEach(async () => { // some of our tests rely on the jest canvas mock, and `afterEach` will have reset the mock, so we need to // restore it. setupJestCanvasMock(); mockPlatformPeg({ supportsJitsiScreensharing: () => true }); stubClient(); client = mocked(MatrixClientPeg.safeGet()); client.getUserId.mockReturnValue("@alice:example.org"); room = new Room("!1:example.org", client, "@alice:example.org", { pendingEventOrdering: PendingEventOrdering.Detached, }); room.currentState.setStateEvents([mkCreationEvent(room.roomId, "@alice:example.org")]); client.getRoom.mockImplementation((roomId) => (roomId === room.roomId ? room : null)); client.getRooms.mockReturnValue([room]); client.reEmitter.reEmit(room, [RoomStateEvent.Events]); client.sendStateEvent.mockImplementation(async (roomId, eventType, content, stateKey = "") => { if (roomId !== room.roomId) throw new Error("Unknown room"); const event = mkEvent({ event: true, type: eventType, room: roomId, user: alice.userId, skey: stateKey, content: content as IContent, }); room.addLiveEvents([event]); return { event_id: event.getId()! }; }); alice = mkRoomMember(room.roomId, "@alice:example.org"); bob = mkRoomMember(room.roomId, "@bob:example.org"); carol = mkRoomMember(room.roomId, "@carol:example.org"); client.getRoom.mockImplementation((roomId) => (roomId === room.roomId ? room : null)); client.getRooms.mockReturnValue([room]); client.reEmitter.reEmit(room, [RoomStateEvent.Events]); await Promise.all( [CallStore.instance, WidgetStore.instance].map((store) => setupAsyncStoreWithClient(store, client)), ); jest.spyOn(MediaDeviceHandler, "getDevices").mockResolvedValue({ [MediaDeviceKindEnum.AudioInput]: [], [MediaDeviceKindEnum.VideoInput]: [], [MediaDeviceKindEnum.AudioOutput]: [], }); DMRoomMap.makeShared(client); jest.spyOn(DMRoomMap.shared(), "getUserIdForRoomId").mockReturnValue(carol.userId); }); afterEach(async () => { await Promise.all([CallStore.instance, WidgetStore.instance].map(resetAsyncStoreWithClient)); client.reEmitter.stopReEmitting(room, [RoomStateEvent.Events]); jest.restoreAllMocks(); SdkConfig.reset(); }); const mockRoomType = (type: string) => { jest.spyOn(room, "getType").mockReturnValue(type); }; const mockRoomMembers = (members: RoomMember[]) => { jest.spyOn(room, "getJoinedMembers").mockReturnValue(members); jest.spyOn(room, "getMember").mockImplementation( (userId) => members.find((member) => member.userId === userId) ?? null, ); }; const mockEnabledSettings = (settings: string[]) => { jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName) => settings.includes(settingName)); }; const mockEventPowerLevels = (events: { [eventType: string]: number }) => { room.currentState.setStateEvents([ mkEvent({ event: true, type: EventType.RoomPowerLevels, room: room.roomId, user: alice.userId, skey: "", content: { events, state_default: 0 }, }), ]); }; const mockLegacyCall = () => { jest.spyOn(LegacyCallHandler.instance, "getCallForRoom").mockReturnValue({} as unknown as MatrixCall); }; const withCall = async (fn: (call: ElementCall) => void | Promise): Promise => { await ElementCall.create(room); const call = CallStore.instance.getCall(room.roomId); if (!(call instanceof ElementCall)) throw new Error("Failed to create call"); const widget = new Widget(call.widget); const eventEmitter = new EventEmitter(); const messaging = { on: eventEmitter.on.bind(eventEmitter), off: eventEmitter.off.bind(eventEmitter), once: eventEmitter.once.bind(eventEmitter), emit: eventEmitter.emit.bind(eventEmitter), stop: jest.fn(), transport: { send: jest.fn(), reply: jest.fn(), }, } as unknown as Mocked; WidgetMessagingStore.instance.storeMessaging(widget, call.roomId, messaging); await fn(call); call.destroy(); WidgetMessagingStore.instance.stopMessaging(widget, call.roomId); }; const renderHeader = (props: Partial = {}, roomContext: Partial = {}) => { render( {}} onInviteClick={null} onForgetClick={() => {}} onAppsClick={() => {}} e2eStatus={E2EStatus.Normal} appsShown={true} searchInfo={{ searchId: Math.random(), promise: new Promise(() => {}), term: "", scope: SearchScope.Room, count: 0, }} viewingCall={false} activeCall={null} {...props} /> , ); }; it("hides call buttons in video rooms", () => { mockRoomType(RoomType.UnstableCall); mockEnabledSettings(["showCallButtonsInComposer", "feature_video_rooms", "feature_element_call_video_rooms"]); renderHeader(); expect(screen.queryByRole("button", { name: /call/i })).toBeNull(); }); it("hides call buttons if showCallButtonsInComposer is disabled", () => { mockEnabledSettings([]); renderHeader(); expect(screen.queryByRole("button", { name: /call/i })).toBeNull(); }); it( "hides the voice call button and disables the video call button if configured to use Element Call exclusively " + "and there's an ongoing call", async () => { mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]); SdkConfig.put({ element_call: { url: "https://call.element.io", use_exclusively: true, brand: "Element Call" }, }); await ElementCall.create(room); renderHeader(); expect(screen.queryByRole("button", { name: "Voice call" })).toBeNull(); expect(screen.getByRole("button", { name: "Video call" })).toHaveAttribute("aria-disabled", "true"); }, ); it( "hides the voice call button and starts an Element call when the video call button is pressed if configured to " + "use Element Call exclusively", async () => { mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]); SdkConfig.put({ element_call: { url: "https://call.element.io", use_exclusively: true, brand: "Element Call" }, }); renderHeader(); expect(screen.queryByRole("button", { name: "Voice call" })).toBeNull(); const dispatcherSpy = jest.fn(); const dispatcherRef = defaultDispatcher.register(dispatcherSpy); fireEvent.click(screen.getByRole("button", { name: "Video call" })); await waitFor(() => expect(dispatcherSpy).toHaveBeenCalledWith({ action: Action.ViewRoom, room_id: room.roomId, skipLobby: false, view_call: true, }), ); defaultDispatcher.unregister(dispatcherRef); }, ); it( "hides the voice call button and disables the video call button if configured to use Element Call exclusively " + "and the user lacks permission", () => { mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]); SdkConfig.put({ element_call: { url: "https://call.element.io", use_exclusively: true, brand: "Element Call" }, }); mockEventPowerLevels({ [ElementCall.CALL_EVENT_TYPE.name]: 100 }); renderHeader(); expect(screen.queryByRole("button", { name: "Voice call" })).toBeNull(); expect(screen.getByRole("button", { name: "Video call" })).toHaveAttribute("aria-disabled", "true"); }, ); it("disables call buttons in the new group call experience if there's an ongoing Element call", async () => { mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]); await ElementCall.create(room); renderHeader(); expect(screen.getByRole("button", { name: "Voice call" })).toHaveAttribute("aria-disabled", "true"); expect(screen.getByRole("button", { name: "Video call" })).toHaveAttribute("aria-disabled", "true"); }); it("disables call buttons in the new group call experience if there's an ongoing legacy 1:1 call", () => { mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]); mockLegacyCall(); renderHeader(); expect(screen.getByRole("button", { name: "Voice call" })).toHaveAttribute("aria-disabled", "true"); expect(screen.getByRole("button", { name: "Video call" })).toHaveAttribute("aria-disabled", "true"); }); it("disables call buttons in the new group call experience if there's an existing Jitsi widget", async () => { mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]); await JitsiCall.create(room); renderHeader(); expect(screen.getByRole("button", { name: "Voice call" })).toHaveAttribute("aria-disabled", "true"); expect(screen.getByRole("button", { name: "Video call" })).toHaveAttribute("aria-disabled", "true"); }); it("disables call buttons in the new group call experience if there's no other members", () => { mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]); renderHeader(); expect(screen.getByRole("button", { name: "Voice call" })).toHaveAttribute("aria-disabled", "true"); expect(screen.getByRole("button", { name: "Video call" })).toHaveAttribute("aria-disabled", "true"); }); it( "starts a legacy 1:1 call when call buttons are pressed in the new group call experience if there's 1 other " + "member", async () => { mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]); mockRoomMembers([alice, bob]); renderHeader(); const placeCallSpy = jest.spyOn(LegacyCallHandler.instance, "placeCall").mockResolvedValue(undefined); fireEvent.click(screen.getByRole("button", { name: "Voice call" })); await act(() => Promise.resolve()); // Allow effects to settle expect(placeCallSpy).toHaveBeenCalledWith(room.roomId, CallType.Voice); placeCallSpy.mockClear(); fireEvent.click(screen.getByRole("button", { name: "Video call" })); await act(() => Promise.resolve()); // Allow effects to settle fireEvent.click(screen.getByRole("menuitem", { name: "Legacy video call" })); await act(() => Promise.resolve()); // Allow effects to settle expect(placeCallSpy).toHaveBeenCalledWith(room.roomId, CallType.Video); }, ); it( "creates a Jitsi widget when call buttons are pressed in the new group call experience if the user lacks " + "permission to start Element calls", async () => { mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]); mockRoomMembers([alice, bob, carol]); mockEventPowerLevels({ [ElementCall.CALL_EVENT_TYPE.name]: 100 }); renderHeader(); const placeCallSpy = jest.spyOn(LegacyCallHandler.instance, "placeCall").mockResolvedValue(undefined); fireEvent.click(screen.getByRole("button", { name: "Voice call" })); await act(() => Promise.resolve()); // Allow effects to settle expect(placeCallSpy).toHaveBeenCalledWith(room.roomId, CallType.Voice); placeCallSpy.mockClear(); fireEvent.click(screen.getByRole("button", { name: "Video call" })); await act(() => Promise.resolve()); // Allow effects to settle expect(placeCallSpy).toHaveBeenCalledWith(room.roomId, CallType.Video); }, ); it( "creates a Jitsi widget when the voice call button is pressed and shows a menu when the video call button is " + "pressed in the new group call experience", async () => { mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]); mockRoomMembers([alice, bob, carol]); renderHeader(); const placeCallSpy = jest.spyOn(LegacyCallHandler.instance, "placeCall").mockResolvedValue(undefined); fireEvent.click(screen.getByRole("button", { name: "Voice call" })); await act(() => Promise.resolve()); // Allow effects to settle expect(placeCallSpy).toHaveBeenCalledWith(room.roomId, CallType.Voice); // First try creating a Jitsi widget from the menu placeCallSpy.mockClear(); fireEvent.click(screen.getByRole("button", { name: "Video call" })); fireEvent.click(getByRole(screen.getByRole("menu"), "menuitem", { name: /jitsi/i })); await act(() => Promise.resolve()); // Allow effects to settle expect(placeCallSpy).toHaveBeenCalledWith(room.roomId, CallType.Video); // Then try starting an Element call from the menu const dispatcherSpy = jest.fn(); const dispatcherRef = defaultDispatcher.register(dispatcherSpy); fireEvent.click(screen.getByRole("button", { name: "Video call" })); fireEvent.click(getByRole(screen.getByRole("menu"), "menuitem", { name: /element/i })); await waitFor(() => expect(dispatcherSpy).toHaveBeenCalledWith({ action: Action.ViewRoom, room_id: room.roomId, skipLobby: false, view_call: true, }), ); defaultDispatcher.unregister(dispatcherRef); }, ); it( "disables the voice call button and starts an Element call when the video call button is pressed in the new " + "group call experience if the user lacks permission to edit widgets", async () => { mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]); mockRoomMembers([alice, bob, carol]); mockEventPowerLevels({ "im.vector.modular.widgets": 100 }); renderHeader(); expect(screen.getByRole("button", { name: "Voice call" })).toHaveAttribute("aria-disabled", "true"); const dispatcherSpy = jest.fn(); const dispatcherRef = defaultDispatcher.register(dispatcherSpy); fireEvent.click(screen.getByRole("button", { name: "Video call" })); await waitFor(() => expect(dispatcherSpy).toHaveBeenCalledWith({ action: Action.ViewRoom, room_id: room.roomId, skipLobby: false, view_call: true, }), ); defaultDispatcher.unregister(dispatcherRef); }, ); it("disables call buttons in the new group call experience if the user lacks permission", () => { mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]); mockRoomMembers([alice, bob, carol]); mockEventPowerLevels({ [ElementCall.CALL_EVENT_TYPE.name]: 100, "im.vector.modular.widgets": 100 }); renderHeader(); expect(screen.getByRole("button", { name: "Voice call" })).toHaveAttribute("aria-disabled", "true"); expect(screen.getByRole("button", { name: "Video call" })).toHaveAttribute("aria-disabled", "true"); }); it("disables call buttons if there's an ongoing legacy 1:1 call", () => { mockEnabledSettings(["showCallButtonsInComposer"]); mockLegacyCall(); renderHeader(); expect(screen.getByRole("button", { name: "Voice call" })).toHaveAttribute("aria-disabled", "true"); expect(screen.getByRole("button", { name: "Video call" })).toHaveAttribute("aria-disabled", "true"); }); it("disables call buttons if there's an existing Jitsi widget", async () => { mockEnabledSettings(["showCallButtonsInComposer"]); await JitsiCall.create(room); renderHeader(); expect(screen.getByRole("button", { name: "Voice call" })).toHaveAttribute("aria-disabled", "true"); expect(screen.getByRole("button", { name: "Video call" })).toHaveAttribute("aria-disabled", "true"); }); it("disables call buttons if there's no other members", () => { mockEnabledSettings(["showCallButtonsInComposer"]); renderHeader(); expect(screen.getByRole("button", { name: "Voice call" })).toHaveAttribute("aria-disabled", "true"); expect(screen.getByRole("button", { name: "Video call" })).toHaveAttribute("aria-disabled", "true"); }); it("starts a legacy 1:1 call when call buttons are pressed if there's 1 other member", async () => { mockEnabledSettings(["showCallButtonsInComposer"]); mockRoomMembers([alice, bob]); mockEventPowerLevels({ "im.vector.modular.widgets": 100 }); // Just to verify that it doesn't try to use Jitsi renderHeader(); const placeCallSpy = jest.spyOn(LegacyCallHandler.instance, "placeCall").mockResolvedValue(undefined); fireEvent.click(screen.getByRole("button", { name: "Voice call" })); await act(() => Promise.resolve()); // Allow effects to settle expect(placeCallSpy).toHaveBeenCalledWith(room.roomId, CallType.Voice); placeCallSpy.mockClear(); fireEvent.click(screen.getByRole("button", { name: "Video call" })); await act(() => Promise.resolve()); // Allow effects to settle expect(placeCallSpy).toHaveBeenCalledWith(room.roomId, CallType.Video); }); it("creates a Jitsi widget when call buttons are pressed", async () => { mockEnabledSettings(["showCallButtonsInComposer"]); mockRoomMembers([alice, bob, carol]); renderHeader(); const placeCallSpy = jest.spyOn(LegacyCallHandler.instance, "placeCall").mockResolvedValue(undefined); fireEvent.click(screen.getByRole("button", { name: "Voice call" })); await act(() => Promise.resolve()); // Allow effects to settle expect(placeCallSpy).toHaveBeenCalledWith(room.roomId, CallType.Voice); placeCallSpy.mockClear(); fireEvent.click(screen.getByRole("button", { name: "Video call" })); await act(() => Promise.resolve()); // Allow effects to settle expect(placeCallSpy).toHaveBeenCalledWith(room.roomId, CallType.Video); }); it("disables call buttons if the user lacks permission", () => { mockEnabledSettings(["showCallButtonsInComposer"]); mockRoomMembers([alice, bob, carol]); mockEventPowerLevels({ "im.vector.modular.widgets": 100 }); renderHeader(); expect(screen.getByRole("button", { name: "Voice call" })).toHaveAttribute("aria-disabled", "true"); expect(screen.getByRole("button", { name: "Video call" })).toHaveAttribute("aria-disabled", "true"); }); it("shows a close button when viewing a call lobby that returns to the timeline when pressed", async () => { mockEnabledSettings(["feature_group_calls"]); renderHeader({ viewingCall: true }); const dispatcherSpy = jest.fn(); const dispatcherRef = defaultDispatcher.register(dispatcherSpy); fireEvent.click(screen.getByRole("button", { name: /close/i })); await waitFor(() => expect(dispatcherSpy).toHaveBeenCalledWith({ action: Action.ViewRoom, room_id: room.roomId, view_call: false, }), ); defaultDispatcher.unregister(dispatcherRef); }); it("shows a reduce button when viewing a call that returns to the timeline when pressed", async () => { mockEnabledSettings(["feature_group_calls"]); await withCall(async (call) => { renderHeader({ viewingCall: true, activeCall: call }); const dispatcherSpy = jest.fn(); const dispatcherRef = defaultDispatcher.register(dispatcherSpy); fireEvent.click(screen.getByRole("button", { name: /timeline/i })); await waitFor(() => expect(dispatcherSpy).toHaveBeenCalledWith({ action: Action.ViewRoom, room_id: room.roomId, view_call: false, }), ); defaultDispatcher.unregister(dispatcherRef); }); }); it("shows a layout button when viewing a call that shows a menu when pressed", async () => { mockEnabledSettings(["feature_group_calls"]); await withCall(async (call) => { // We set the call to skip lobby because otherwise the connection will wait until // the user clicks the "join" button, inside the widget lobby which is hard to mock. call.widget.data = { ...call.widget.data, skipLobby: true }; // The connect method will wait until the session actually connected. Otherwise it will timeout. // Emitting SessionStarted will trigger the connect method to resolve. setTimeout( () => client.matrixRTC.emit(MatrixRTCSessionManagerEvents.SessionStarted, room.roomId, { room, } as MatrixRTCSession), 100, ); await call.start(); const messaging = WidgetMessagingStore.instance.getMessagingForUid(WidgetUtils.getWidgetUid(call.widget))!; renderHeader({ viewingCall: true, activeCall: call }); // Should start with Freedom selected fireEvent.click(screen.getByRole("button", { name: /layout/i })); screen.getByRole("menuitemradio", { name: "Freedom", checked: true }); // Clicking Spotlight should tell the widget to switch and close the menu fireEvent.click(screen.getByRole("menuitemradio", { name: "Spotlight" })); expect(mocked(messaging.transport).send).toHaveBeenCalledWith(ElementWidgetActions.SpotlightLayout, {}); expect(screen.queryByRole("menu")).toBeNull(); // When the widget responds and the user reopens the menu, they should see Spotlight selected act(() => { messaging.emit( `action:${ElementWidgetActions.SpotlightLayout}`, new CustomEvent("widgetapirequest", { detail: { data: {} } }), ); }); fireEvent.click(screen.getByRole("button", { name: /layout/i })); screen.getByRole("menuitemradio", { name: "Spotlight", checked: true }); // Now try switching back to Freedom fireEvent.click(screen.getByRole("menuitemradio", { name: "Freedom" })); expect(mocked(messaging.transport).send).toHaveBeenCalledWith(ElementWidgetActions.TileLayout, {}); expect(screen.queryByRole("menu")).toBeNull(); // When the widget responds and the user reopens the menu, they should see Freedom selected act(() => { messaging.emit( `action:${ElementWidgetActions.TileLayout}`, new CustomEvent("widgetapirequest", { detail: { data: {} } }), ); }); fireEvent.click(screen.getByRole("button", { name: /layout/i })); screen.getByRole("menuitemradio", { name: "Freedom", checked: true }); }); }); it("shows an invite button in video rooms", () => { mockEnabledSettings(["feature_video_rooms", "feature_element_call_video_rooms"]); mockRoomType(RoomType.UnstableCall); const onInviteClick = jest.fn(); renderHeader({ onInviteClick, viewingCall: true }); fireEvent.click(screen.getByRole("button", { name: /invite/i })); expect(onInviteClick).toHaveBeenCalled(); }); it("hides the invite button in non-video rooms when viewing a call", () => { renderHeader({ onInviteClick: () => {}, viewingCall: true }); expect(screen.queryByRole("button", { name: /invite/i })).toBeNull(); }); it("shows the room avatar in a room with only ourselves", () => { // When we render a non-DM room with 1 person in it const room = createRoom({ name: "X Room", isDm: false, userIds: [] }); const rendered = mountHeader(room); // Then the room's avatar is the initial of its name const initial = rendered.container.querySelector(".mx_BaseAvatar"); expect(initial).toHaveTextContent("X"); }); it("shows the room avatar in a room with 2 people", () => { // When we render a non-DM room with 2 people in it const room = createRoom({ name: "Y Room", isDm: false, userIds: ["other"] }); const rendered = mountHeader(room); // Then the room's avatar is the initial of its name const initial = rendered.container.querySelector(".mx_BaseAvatar"); expect(initial).toHaveTextContent("Y"); }); it("shows the room avatar in a room with >2 people", () => { // When we render a non-DM room with 3 people in it const room = createRoom({ name: "Z Room", isDm: false, userIds: ["other1", "other2"] }); const rendered = mountHeader(room); // Then the room's avatar is the initial of its name const initial = rendered.container.querySelector(".mx_BaseAvatar"); expect(initial).toHaveTextContent("Z"); }); it("shows the room avatar in a DM with only ourselves", () => { // When we render a non-DM room with 1 person in it const room = createRoom({ name: "Z Room", isDm: true, userIds: [] }); const rendered = mountHeader(room); // Then the room's avatar is the initial of its name const initial = rendered.container.querySelector(".mx_BaseAvatar"); expect(initial).toHaveTextContent("Z"); }); it("shows the user avatar in a DM with 2 people", () => { // Note: this is the interesting case - this is the ONLY // time we should use the user's avatar. // When we render a DM room with only 2 people in it const room = createRoom({ name: "Y Room", isDm: true, userIds: ["other"] }); const rendered = mountHeader(room); // Then we use the other user's avatar as our room's image avatar const image = rendered.container.querySelector(".mx_BaseAvatar img"); expect(image).toHaveAttribute("src", "http://this.is.a.url/example.org/other"); }); it("shows the room avatar in a DM with >2 people", () => { // When we render a DM room with 3 people in it const room = createRoom({ name: "Z Room", isDm: true, userIds: ["other1", "other2"], }); const rendered = mountHeader(room); // Then the room's avatar is the initial of its name const initial = rendered.container.querySelector(".mx_BaseAvatar"); expect(initial).toHaveTextContent("Z"); }); it("renders call buttons normally", () => { const room = createRoom({ name: "Room", isDm: false, userIds: ["other"] }); const wrapper = mountHeader(room); expect(wrapper.container.querySelector('[aria-label="Voice call"]')).toBeDefined(); expect(wrapper.container.querySelector('[aria-label="Video call"]')).toBeDefined(); }); it("hides call buttons when the room is tombstoned", () => { const room = createRoom({ name: "Room", isDm: false, userIds: [] }); const wrapper = mountHeader( room, {}, { tombstone: mkEvent({ event: true, type: "m.room.tombstone", room: room.roomId, user: "@user1:server", skey: "", content: {}, ts: Date.now(), }), }, ); expect(wrapper.container.querySelector('[aria-label="Voice call"]')).toBeFalsy(); expect(wrapper.container.querySelector('[aria-label="Video call"]')).toBeFalsy(); }); it("should render buttons if not passing showButtons (default true)", () => { const room = createRoom({ name: "Room", isDm: false, userIds: [] }); const wrapper = mountHeader(room); expect(wrapper.container.querySelector(".mx_LegacyRoomHeader_button")).toBeDefined(); }); it("should not render buttons if passing showButtons = false", () => { const room = createRoom({ name: "Room", isDm: false, userIds: [] }); const wrapper = mountHeader(room, { showButtons: false }); expect(wrapper.container.querySelector(".mx_LegacyRoomHeader_button")).toBeFalsy(); }); it("should render the room options context menu if not passing enableRoomOptionsMenu (default true) and UIComponent customisations room options enabled", () => { mocked(shouldShowComponent).mockReturnValue(true); const room = createRoom({ name: "Room", isDm: false, userIds: [] }); const wrapper = mountHeader(room); expect(shouldShowComponent).toHaveBeenCalledWith(UIComponent.RoomOptionsMenu); expect(wrapper.container.querySelector(".mx_LegacyRoomHeader_name.mx_AccessibleButton")).toBeDefined(); }); it.each([ [false, true], [true, false], ])( "should not render the room options context menu if passing enableRoomOptionsMenu = %s and UIComponent customisations room options enable = %s", (enableRoomOptionsMenu, showRoomOptionsMenu) => { mocked(shouldShowComponent).mockReturnValue(showRoomOptionsMenu); const room = createRoom({ name: "Room", isDm: false, userIds: [] }); const wrapper = mountHeader(room, { enableRoomOptionsMenu }); expect(wrapper.container.querySelector(".mx_LegacyRoomHeader_name.mx_AccessibleButton")).toBeFalsy(); }, ); it("renders additionalButtons", async () => { const additionalButtons: ViewRoomOpts["buttons"] = [ { icon: () => <>test-icon, id: "test-id", label: () => "test-label", onClick: () => {}, }, ]; renderHeader({ additionalButtons }); expect(screen.getByRole("button", { name: "test-icon" })).toBeInTheDocument(); }); it("calls onClick-callback on additionalButtons", () => { const callback = jest.fn(); const additionalButtons: ViewRoomOpts["buttons"] = [ { icon: () => <>test-icon, id: "test-id", label: () => "test-label", onClick: callback, }, ]; renderHeader({ additionalButtons }); fireEvent.click(screen.getByRole("button", { name: "test-icon" })); expect(callback).toHaveBeenCalled(); }); }); interface IRoomCreationInfo { name: string; isDm: boolean; userIds: string[]; } function createRoom(info: IRoomCreationInfo) { stubClient(); const client: MatrixClient = MatrixClientPeg.safeGet(); const roomId = "!1234567890:domain"; const userId = client.getUserId()!; if (info.isDm) { client.getAccountData = (eventType) => { if (eventType === "m.direct") { return mkDirectEvent(roomId, userId, info.userIds); } else { return undefined; } }; } DMRoomMap.makeShared(client).start(); const room = new Room(roomId, client, userId, { pendingEventOrdering: PendingEventOrdering.Detached, }); const otherJoinEvents: MatrixEvent[] = []; for (const otherUserId of info.userIds) { otherJoinEvents.push(mkJoinEvent(roomId, otherUserId)); } room.currentState.setStateEvents([ mkCreationEvent(roomId, userId), mkNameEvent(roomId, userId, info.name), mkJoinEvent(roomId, userId), ...otherJoinEvents, ]); room.recalculate(); return room; } function mountHeader(room: Room, propsOverride = {}, roomContext?: Partial): RenderResult { const props: RoomHeaderProps = { room, inRoom: true, onSearchClick: () => {}, onInviteClick: null, onForgetClick: () => {}, onAppsClick: () => {}, e2eStatus: E2EStatus.Normal, appsShown: true, searchInfo: { searchId: Math.random(), promise: new Promise(() => {}), term: "", scope: SearchScope.Room, count: 0, }, viewingCall: false, activeCall: null, ...propsOverride, }; return render( , ); } function mkCreationEvent(roomId: string, userId: string): MatrixEvent { return mkEvent({ event: true, type: "m.room.create", room: roomId, user: userId, content: { creator: userId, room_version: "5", predecessor: { room_id: "!prevroom", event_id: "$someevent", }, }, }); } function mkNameEvent(roomId: string, userId: string, name: string): MatrixEvent { return mkEvent({ event: true, type: "m.room.name", room: roomId, user: userId, content: { name }, }); } function mkJoinEvent(roomId: string, userId: string) { const ret = mkEvent({ event: true, type: "m.room.member", room: roomId, user: userId, content: { membership: KnownMembership.Join, avatar_url: "mxc://example.org/" + userId, }, }); ret.event.state_key = userId; return ret; } function mkDirectEvent(roomId: string, userId: string, otherUsers: string[]): MatrixEvent { const content: Record = {}; for (const otherUserId of otherUsers) { content[otherUserId] = [roomId]; } return mkEvent({ event: true, type: "m.direct", room: roomId, user: userId, content, }); }