/*
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 OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/

import React, { createRef, type RefObject } from "react";
import { mocked, type MockedObject } from "jest-mock";
import {
    EventTimeline,
    EventType,
    type IEvent,
    JoinRule,
    type MatrixClient,
    MatrixError,
    MatrixEvent,
    Room,
    RoomEvent,
    RoomMember,
    RoomStateEvent,
    SearchResult,
} from "matrix-js-sdk/src/matrix";
import { type CryptoApi, CryptoEvent, UserVerificationStatus } from "matrix-js-sdk/src/crypto-api";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { act, cleanup, fireEvent, render, type RenderResult, screen, waitFor, findByRole } from "jest-matrix-react";
import userEvent from "@testing-library/user-event";

import {
    createTestClient,
    emitPromise,
    filterConsole,
    flushPromises,
    mkEvent,
    mkRoomMemberJoinEvent,
    mkThirdPartyInviteEvent,
    mockPlatformPeg,
    setupAsyncStoreWithClient,
    stubClient,
    unmockPlatformPeg,
    untilDispatch,
} from "../../../test-utils";
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
import { Action } from "../../../../src/dispatcher/actions";
import defaultDispatcher from "../../../../src/dispatcher/dispatcher";
import { type ViewRoomPayload } from "../../../../src/dispatcher/payloads/ViewRoomPayload";
import { RoomView } from "../../../../src/components/structures/RoomView";
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 { type 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 WidgetUtils from "../../../../src/utils/WidgetUtils";
import { WidgetType } from "../../../../src/widgets/WidgetType";
import WidgetStore from "../../../../src/stores/WidgetStore";
import { type 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";
import { type ViewUserPayload } from "../../../../src/dispatcher/payloads/ViewUserPayload.ts";
import { CallStore } from "../../../../src/stores/CallStore.ts";
import MediaDeviceHandler, { MediaDeviceKindEnum } from "../../../../src/MediaDeviceHandler.ts";
import Modal, { type ComponentProps } from "../../../../src/Modal.tsx";
import ErrorDialog from "../../../../src/components/views/dialogs/ErrorDialog.tsx";
import * as pinnedEventHooks from "../../../../src/hooks/usePinnedEvents";
import { TimelineRenderingType } from "../../../../src/contexts/RoomContext";
import { ModuleApi } from "../../../../src/modules/Api";
import MatrixClientBackedController from "../../../../src/settings/controllers/MatrixClientBackedController.ts";
import { type ComposerInsertPayload, ComposerType } from "../../../../src/dispatcher/payloads/ComposerInsertPayload.ts";

// Used by group calls
jest.spyOn(MediaDeviceHandler, "getDevices").mockResolvedValue({
    [MediaDeviceKindEnum.AudioInput]: [],
    [MediaDeviceKindEnum.VideoInput]: [],
    [MediaDeviceKindEnum.AudioOutput]: [],
});

describe("RoomView", () => {
    let cli: MockedObject<MatrixClient>;
    let room: Room;
    let rooms: Map<string, Room>;
    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());
        MatrixClientBackedController.matrixClient = cli;

        const roomName = (expect.getState().currentTestName ?? "").replace(/[^a-zA-Z0-9]/g, "").toLowerCase();

        room = new Room(`!${roomName}: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);
        cli.getRooms.mockImplementation(() => [...rooms.values()]);
        // 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);

        crypto = cli.getCrypto()!;
        jest.spyOn(cli, "getCrypto").mockReturnValue(undefined);
    });

    afterEach(() => {
        unmockPlatformPeg();
        jest.clearAllMocks();

        // Can't jest.restoreAllMocks() because some tests will break
        jest.spyOn(pinnedEventHooks, "usePinnedEvents").mockRestore();
        jest.spyOn(pinnedEventHooks, "useSortedFetchedPinnedEvents").mockRestore();

        cleanup();
    });

    const mountRoomView = async (
        ref?: RefObject<RoomView | null>,
        props?: Partial<ComponentProps<typeof RoomView>>,
    ): Promise<RenderResult> => {
        if (stores.roomViewStore.getRoomId() !== room.roomId) {
            const switchedRoom = new Promise<void>((resolve) => {
                const subFn = () => {
                    if (stores.roomViewStore.getRoomId()) {
                        stores.roomViewStore.off(UPDATE_EVENT, subFn);
                        resolve();
                    }
                };
                stores.roomViewStore.on(UPDATE_EVENT, subFn);
            });

            act(() =>
                defaultDispatcher.dispatch<ViewRoomPayload>({
                    action: Action.ViewRoom,
                    room_id: room.roomId,
                    metricsTrigger: undefined,
                }),
            );

            await switchedRoom;
        }

        const roomView = render(
            <RoomView
                // threepidInvite should be optional on RoomView props
                // it is treated as optional in RoomView
                threepidInvite={undefined as any}
                forceTimeline={false}
                ref={ref}
                {...props}
            />,
            {
                wrapper: ({ children }) => (
                    <MatrixClientContext.Provider value={cli}>
                        <SDKContext.Provider value={stores}>{children}</SDKContext.Provider>
                    </MatrixClientContext.Provider>
                ),
            },
        );
        await flushPromises();
        return roomView;
    };

    const renderRoomView = async (switchRoom = true): Promise<ReturnType<typeof render>> => {
        if (switchRoom && stores.roomViewStore.getRoomId() !== room.roomId) {
            const switchedRoom = new Promise<void>((resolve) => {
                const subFn = () => {
                    if (stores.roomViewStore.getRoomId()) {
                        stores.roomViewStore.off(UPDATE_EVENT, subFn);
                        resolve();
                    }
                };
                stores.roomViewStore.on(UPDATE_EVENT, subFn);
            });

            defaultDispatcher.dispatch<ViewRoomPayload>({
                action: Action.ViewRoom,
                room_id: room.roomId,
                metricsTrigger: undefined,
            });

            await switchedRoom;
        }

        const roomView = render(
            <MatrixClientContext.Provider value={cli}>
                <SDKContext.Provider value={stores}>
                    <RoomView
                        // threepidInvite should be optional on RoomView props
                        // it is treated as optional in RoomView
                        threepidInvite={undefined}
                        forceTimeline={false}
                        onRegistered={jest.fn()}
                    />
                </SDKContext.Provider>
            </MatrixClientContext.Provider>,
        );
        await flushPromises();
        return roomView;
    };
    const getRoomViewInstance = async (): Promise<RoomView> => {
        const ref = createRef<RoomView>();
        await mountRoomView(ref);
        return ref.current!;
    };

    it("gets a room view store from MultiRoomViewStore when given a room ID", async () => {
        stores.multiRoomViewStore.getRoomViewStoreForRoom = jest.fn().mockReturnValue(stores.roomViewStore);

        const ref = createRef<RoomView>();
        render(
            <MatrixClientContext.Provider value={cli}>
                <SDKContext.Provider value={stores}>
                    <RoomView
                        threepidInvite={undefined as any}
                        forceTimeline={false}
                        ref={ref}
                        roomId="!room:example.dummy"
                    />
                </SDKContext.Provider>
            </MatrixClientContext.Provider>,
        );

        expect(stores.multiRoomViewStore.getRoomViewStoreForRoom).toHaveBeenCalledWith("!room:example.dummy");
    });

    it("should show member list right panel phase on Action.ViewUser without `payload.member`", async () => {
        const spy = jest.spyOn(stores.rightPanelStore, "showOrHidePhase");
        await renderRoomView(false);

        defaultDispatcher.dispatch<ViewUserPayload>(
            {
                action: Action.ViewUser,
                member: undefined,
            },
            true,
        );

        expect(spy).toHaveBeenCalledWith(RightPanelPhases.MemberList);
    });

    it("when there is no room predecessor, getHiddenHighlightCount should return 0", async () => {
        const instance = await getRoomViewInstance();
        expect(instance.getHiddenHighlightCount()).toBe(0);
    });

    it("should hide the composer when hideComposer=true", async () => {
        // Join the room
        jest.spyOn(room, "getMyMembership").mockReturnValue(KnownMembership.Join);
        const { asFragment } = await mountRoomView(undefined, { hideComposer: true });

        expect(screen.queryByRole("textbox", { name: "Send an unencrypted message…" })).not.toBeInTheDocument();
        expect(asFragment()).toMatchSnapshot();
    });

    it("should hide the header when hideHeader=true", async () => {
        // Join the room
        jest.spyOn(room, "getMyMembership").mockReturnValue(KnownMembership.Join);
        const { asFragment } = await mountRoomView(undefined, { hideHeader: true });

        // Check that the room name button in the header is not rendered
        expect(screen.queryByRole("button", { name: room.name })).not.toBeInTheDocument();
        expect(asFragment()).toMatchSnapshot();
    });

    it("should hide the right panel when hideRightPanel=true", async () => {
        // Join the room
        jest.spyOn(room, "getMyMembership").mockReturnValue(KnownMembership.Join);
        const { asFragment, rerender } = await mountRoomView(undefined);

        defaultDispatcher.dispatch<ViewUserPayload>(
            {
                action: Action.ViewUser,
                member: undefined,
            },
            true,
        );

        // Check that the right panel is rendered
        await expect(screen.findByTestId("right-panel")).resolves.toBeTruthy();
        // Now rerender with hideRightPanel=true
        rerender(<RoomView threepidInvite={undefined} forceTimeline={false} hideRightPanel={true} />);
        // Check that the right panel is not rendered
        await expect(screen.findByTestId("right-panel")).rejects.toThrow();
        expect(asFragment()).toMatchSnapshot();
    });

    it("should hide the pinned message banner when hidePinnedMessageBanner=true", async () => {
        // Join the room
        jest.spyOn(room, "getMyMembership").mockReturnValue(KnownMembership.Join);

        const pinnedEvent = new MatrixEvent({
            type: EventType.RoomMessage,
            sender: "@alice:example.org",
            content: {
                body: "First pinned message",
                msgtype: "m.text",
            },
            room_id: room.roomId,
            origin_server_ts: 0,
            event_id: "$eventId",
        });

        jest.spyOn(pinnedEventHooks, "usePinnedEvents").mockReturnValue([pinnedEvent.getId()!]);
        jest.spyOn(pinnedEventHooks, "useSortedFetchedPinnedEvents").mockReturnValue([pinnedEvent]);

        const { asFragment, rerender } = await mountRoomView(undefined);
        // Check that the pinned message banner is rendered
        await expect(screen.findByTestId("pinned-message-banner")).resolves.toBeTruthy();
        // Now rerender with hidePinnedMessagesBanner=true
        rerender(<RoomView threepidInvite={undefined} forceTimeline={false} hidePinnedMessageBanner={true} />);
        // Check that the pinned message banner is not rendered
        await expect(screen.findByTestId("pinned-message-banner")).rejects.toThrow();
        expect(asFragment()).toMatchSnapshot();
    });

    describe("enableReadReceiptsAndMarkersOnActivity", () => {
        it.each([
            {
                enabled: false,
                testName: "should send read receipts and update read marker on focus when disabled",
                checkCall: (sendReadReceiptsSpy: jest.Mock, updateReadMarkerSpy: jest.Mock) => {
                    expect(sendReadReceiptsSpy).toHaveBeenCalled();
                    expect(updateReadMarkerSpy).toHaveBeenCalled();
                },
            },
            {
                enabled: true,
                testName: "should not send read receipts and update read marker on focus when enabled",
                checkCall: (sendReadReceiptsSpy: jest.Mock, updateReadMarkerSpy: jest.Mock) => {
                    expect(sendReadReceiptsSpy).not.toHaveBeenCalled();
                    expect(updateReadMarkerSpy).not.toHaveBeenCalled();
                },
            },
        ])("$testName", async ({ enabled, checkCall }) => {
            // Join the room
            jest.spyOn(room, "getMyMembership").mockReturnValue(KnownMembership.Join);
            const ref = createRef<RoomView>();
            await mountRoomView(ref, {
                enableReadReceiptsAndMarkersOnActivity: enabled,
            });

            // Wait for the timeline to be rendered
            await waitFor(() => expect(screen.getByTestId("timeline")).not.toBeNull());

            // Get the RoomView instance and mock the messagePanel methods
            const instance = ref.current!;
            const sendReadReceiptsSpy = jest.fn();
            const updateReadMarkerSpy = jest.fn();
            // @ts-ignore - accessing private property for testing
            instance.messagePanel = {
                sendReadReceipts: sendReadReceiptsSpy,
                updateReadMarker: updateReadMarkerSpy,
            };

            // Find the main RoomView div and trigger focus
            const timeline = screen.getByTestId("timeline");
            fireEvent.focus(timeline);

            // Verify that sendReadReceipts and updateReadMarker were called or not based on the enabled state
            checkCall(sendReadReceiptsSpy, updateReadMarkerSpy);
        });
    });

    describe("invites", () => {
        beforeEach(() => {
            const member = new RoomMember(room.roomId, cli.getSafeUserId());
            member.membership = KnownMembership.Invite;
            member.events.member = new MatrixEvent({
                sender: "@bob:example.org",
                content: { membership: KnownMembership.Invite },
            });
            room.getMyMembership = jest.fn().mockReturnValue(KnownMembership.Invite);
            room.getMember = jest.fn().mockReturnValue(member);
        });

        it("renders an invite room", async () => {
            const { asFragment } = await mountRoomView();
            expect(asFragment()).toMatchSnapshot();
        });

        it("handles accepting an invite", async () => {
            const { getByRole } = await mountRoomView();

            await fireEvent.click(getByRole("button", { name: "Accept" }));

            await untilDispatch(Action.JoinRoomReady, defaultDispatcher);
        });
        it("handles declining an invite", async () => {
            const { getByRole } = await mountRoomView();
            jest.spyOn(Modal, "createDialog").mockReturnValue({
                finished: Promise.resolve([true, false, false]),
                close: jest.fn(),
            });
            await fireEvent.click(getByRole("button", { name: "Decline" }));
            await waitFor(() => expect(cli.leave).toHaveBeenCalledWith(room.roomId));
            expect(cli.setIgnoredUsers).not.toHaveBeenCalled();
        });
        it("handles declining an invite and ignoring the user", async () => {
            const { getByRole } = await mountRoomView();
            cli.getIgnoredUsers.mockReturnValue(["@carol:example.org"]);
            jest.spyOn(Modal, "createDialog").mockReturnValue({
                finished: Promise.resolve([true, true, false]),
                close: jest.fn(),
            });
            await act(() => fireEvent.click(getByRole("button", { name: "Decline and block" })));
            expect(cli.leave).toHaveBeenCalledWith(room.roomId);
            expect(cli.setIgnoredUsers).toHaveBeenCalledWith(["@carol:example.org", "@bob:example.org"]);
        });
        it("prevents ignoring own user", async () => {
            const member = new RoomMember(room.roomId, cli.getSafeUserId());
            member.membership = KnownMembership.Invite;
            member.events.member = new MatrixEvent({
                /*
                It doesn't matter that this is an invite event coming from own user, we just
                want to simulate a situation where the sender of the membership event somehow
                ends up being own user.
                 */
                sender: cli.getSafeUserId(),
                content: { membership: KnownMembership.Invite },
            });
            jest.spyOn(room, "getMyMembership").mockReturnValue(KnownMembership.Invite);
            jest.spyOn(room, "getMember").mockReturnValue(member);

            const { getByRole } = await mountRoomView();
            cli.getIgnoredUsers.mockReturnValue(["@carol:example.org"]);
            jest.spyOn(Modal, "createDialog").mockReturnValue({
                finished: Promise.resolve([true, true, false]),
                close: jest.fn(),
            });

            await act(() => fireEvent.click(getByRole("button", { name: "Decline and block" })));

            // Should show error in a modal dialog
            await waitFor(() => {
                expect(Modal.createDialog).toHaveBeenLastCalledWith(ErrorDialog, {
                    title: "Failed to reject invite",
                    description: "Cannot determine which user to ignore since the member event has changed.",
                });
            });

            // The ignore call should not go through
            expect(cli.setIgnoredUsers).not.toHaveBeenCalled();
        });
        it("handles declining an invite and reporting the room", async () => {
            const { getByRole } = await mountRoomView();
            jest.spyOn(Modal, "createDialog").mockReturnValue({
                finished: Promise.resolve([true, false, "with a reason"]),
                close: jest.fn(),
            });
            await fireEvent.click(getByRole("button", { name: "Decline and block" }));
            expect(cli.leave).toHaveBeenCalledWith(room.roomId);
            expect(cli.reportRoom).toHaveBeenCalledWith(room.roomId, "with a reason");
        });
    });

    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 = Promise.withResolvers<boolean>();
        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<string, any>()]]),
        );

        const { container } = await renderRoomView();
        // We no longer show the grey shield for encrypted rooms, so it should not be there.
        await waitFor(() => expect(container.querySelector(".mx_E2EIcon")).not.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")).toHaveAccessibleName("Everyone in this room is verified"),
        );
    });

    describe("video rooms", () => {
        beforeEach(async () => {
            await setupAsyncStoreWithClient(CallStore.instance, MatrixClientPeg.safeGet());
            // 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();
        });

        it("should open timeline card when navigating to permalink", async () => {
            jest.spyOn(room, "getMyMembership").mockReturnValue(KnownMembership.Join);
            await mountRoomView();

            stores.rightPanelStore.setCard({ phase: RightPanelPhases.RoomSummary });

            expect(stores.rightPanelStore.isOpen).toEqual(true);
            expect(stores.rightPanelStore.currentCard.phase).not.toEqual(RightPanelPhases.Timeline);

            await stores.roomViewStore.viewRoom({
                action: Action.ViewRoom,
                room_id: stores.roomViewStore.getRoomId()!,
                event_id: "$eventId",
                metricsTrigger: undefined,
            });

            expect(stores.rightPanelStore.isOpen).toEqual(true);
            expect(stores.rightPanelStore.currentCard.phase).toEqual(RightPanelPhases.Timeline);
        });
    });

    describe("group calls", () => {
        beforeEach(async () => {
            await setupAsyncStoreWithClient(CallStore.instance, MatrixClientPeg.safeGet());
            jest.spyOn(room, "getMyMembership").mockReturnValue(KnownMembership.Join);
        });

        it("hides the right panel chat when closing a call", async () => {
            await mountRoomView();

            // Open the call
            await act(() =>
                stores.roomViewStore.viewRoom({
                    action: Action.ViewRoom,
                    room_id: stores.roomViewStore.getRoomId()!,
                    event_id: "$eventId",
                    metricsTrigger: undefined,
                    view_call: true,
                }),
            );
            // Open the chat in the right panel
            act(() => stores.rightPanelStore.setCard({ phase: RightPanelPhases.Timeline }));
            // Chat should be visible in the right panel
            await findByRole(await screen.findByRole("complementary"), "heading", { name: "Chat" });

            // Close the call
            await act(() =>
                stores.roomViewStore.viewRoom({
                    action: Action.ViewRoom,
                    room_id: stores.roomViewStore.getRoomId()!,
                    event_id: "$eventId",
                    metricsTrigger: undefined,
                    view_call: false,
                }),
            );
            // Right panel should be gone
            expect(screen.queryByRole("complementary")).toBe(null);
            // Opening the right panel again should just show the room summary
            act(() => stores.rightPanelStore.show(room.roomId));
            await findByRole(await screen.findByRole("complementary"), "heading", { name: room.roomId });
        });

        it("hides the right panel chat when returning to a room that previously showed a call", async () => {
            const room2 = new Room(`!roomswitchtest:example.org`, cli, "@alice:example.org");
            rooms.set(room2.roomId, room2);
            await mountRoomView();

            // Open the call
            await act(() =>
                stores.roomViewStore.viewRoom({
                    action: Action.ViewRoom,
                    room_id: room.roomId,
                    event_id: "$eventId",
                    metricsTrigger: undefined,
                    view_call: true,
                }),
            );
            // Open the chat in the right panel
            act(() => stores.rightPanelStore.setCard({ phase: RightPanelPhases.Timeline }));
            // Chat should be visible in the right panel
            await findByRole(await screen.findByRole("complementary"), "heading", { name: "Chat" });

            // Navigate away to another room
            await act(() =>
                stores.roomViewStore.viewRoom({
                    action: Action.ViewRoom,
                    room_id: room2.roomId,
                    event_id: "$eventId",
                    metricsTrigger: undefined,
                }),
            );
            // Navigate back to the original room
            await act(() =>
                stores.roomViewStore.viewRoom({
                    action: Action.ViewRoom,
                    room_id: room.roomId,
                    event_id: "$eventId",
                    metricsTrigger: undefined,
                }),
            );
            // Right panel should be gone
            expect(screen.queryByRole("complementary")).toBe(null);
        });
    });

    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, false, 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 a message…")).not.toBeInTheDocument();
            expect(screen.queryByText("Send an unencrypted 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<ViewRoomErrorPayload>({
                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,
            });
        });
    });

    describe("message search", () => {
        it("should close search results when edit is clicked", async () => {
            room.getMyMembership = jest.fn().mockReturnValue(KnownMembership.Join);

            const eventMapper = (obj: Partial<IEvent>) => new MatrixEvent(obj);

            const roomViewRef = createRef<RoomView>();
            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 searchResultTile = getByText("search term").closest(".mx_EventTile");
            expect(searchResultTile).not.toBeNull();

            await userEvent.hover(searchResultTile!);
            await userEvent.click(await findByLabelText("Edit"));

            await waitFor(() => {
                expect(container.querySelector(".mx_RoomView_searchResultsPanel")).not.toBeInTheDocument();
            });
        });

        it("should switch rooms when edit is clicked on a search result for a different room", async () => {
            const room2 = new Room(`!roomswitchtest:example.org`, cli, "@alice:example.org");
            rooms.set(room2.roomId, room2);

            room.getMyMembership = jest.fn().mockReturnValue(KnownMembership.Join);

            const eventMapper = (obj: Partial<IEvent>) => new MatrixEvent(obj);

            const roomViewRef = createRef<RoomView>();
            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);

            const searchResultTile = getByText("search term").closest(".mx_EventTile");
            expect(searchResultTile).not.toBeNull();

            await userEvent.hover(searchResultTile!);
            await userEvent.click(await findByLabelText("Edit"));

            await expect(prom).resolves.toEqual(expect.objectContaining({ room_id: room2.roomId }));
        });

        it("should pre-fill search field on FocusMessageSearch dispatch", async () => {
            room.getMyMembership = jest.fn().mockReturnValue(KnownMembership.Join);

            const roomViewRef = createRef<RoomView>();
            const { findByPlaceholderText } = await mountRoomView(roomViewRef);
            await waitFor(() => expect(roomViewRef.current).toBeTruthy());

            act(() =>
                defaultDispatcher.dispatch({
                    action: Action.FocusMessageSearch,
                    initialText: "search term",
                }),
            );

            await expect(findByPlaceholderText("Search messages…")).resolves.toHaveValue("search term");
        });
    });

    it("fires Action.RoomLoaded", async () => {
        jest.spyOn(defaultDispatcher, "dispatch");
        await mountRoomView();
        expect(defaultDispatcher.dispatch).toHaveBeenCalledWith({ action: Action.RoomLoaded });
    });

    // Regression test for https://github.com/element-hq/element-web/issues/29072
    it("does not force a reload on sync unless the client is coming back online", async () => {
        cli.isInitialSyncComplete.mockReturnValue(false);

        const instance = await getRoomViewInstance();
        const onRoomViewUpdateMock = jest.fn();
        (instance as any).onRoomViewStoreUpdate = onRoomViewUpdateMock;

        act(() => {
            // As if a connectivity check happened (we are still offline)
            defaultDispatcher.dispatch({ action: "MatrixActions.sync" }, true);
            // ...so it still should not force a reload
            expect(onRoomViewUpdateMock).not.toHaveBeenCalledWith(true);
        });

        act(() => {
            // set us to online again
            cli.isInitialSyncComplete.mockReturnValue(true);
            defaultDispatcher.dispatch({ action: "MatrixActions.sync" }, true);
        });

        // It should now force a reload
        expect(onRoomViewUpdateMock).toHaveBeenCalledWith(true);
    });

    describe("handles Action.ComposerInsert", () => {
        it("redispatches an empty composerType, timelineRenderingType with the current state", async () => {
            await mountRoomView();
            const promise = untilDispatch((payload) => {
                try {
                    expect(payload).toEqual({
                        action: Action.ComposerInsert,
                        text: "Hello world",
                        timelineRenderingType: TimelineRenderingType.Room,
                        composerType: ComposerType.Send,
                    });
                } catch {
                    return false;
                }
                return true;
            }, defaultDispatcher);
            defaultDispatcher.dispatch({
                action: Action.ComposerInsert,
                text: "Hello world",
            } satisfies ComposerInsertPayload);
            await promise;
        });
        it("redispatches an empty composerType with the current state", async () => {
            await mountRoomView();
            const promise = untilDispatch((payload) => {
                try {
                    expect(payload).toEqual({
                        action: Action.ComposerInsert,
                        text: "Hello world",
                        timelineRenderingType: TimelineRenderingType.Room,
                        composerType: ComposerType.Send,
                    });
                } catch {
                    return false;
                }
                return true;
            }, defaultDispatcher);
            defaultDispatcher.dispatch({
                action: Action.ComposerInsert,
                text: "Hello world",
                timelineRenderingType: TimelineRenderingType.Room,
            } satisfies ComposerInsertPayload);
            await promise;
        });
        it("ignores payloads with a timelineRenderingType != TimelineRenderingType.Thread", async () => {
            await mountRoomView();
            const promise = untilDispatch(
                (payload) => {
                    try {
                        expect(payload).toStrictEqual({
                            action: Action.ComposerInsert,
                            text: "Hello world",
                            timelineRenderingType: TimelineRenderingType.Thread,
                            composerType: ComposerType.Send,
                        });
                    } catch {
                        return false;
                    }
                    return true;
                },
                defaultDispatcher,
                500,
            );
            defaultDispatcher.dispatch({
                action: Action.ComposerInsert,
                text: "Hello world",
                composerType: ComposerType.Send,
                timelineRenderingType: TimelineRenderingType.Room,
                viaTest: true,
            } satisfies ComposerInsertPayload);
            await expect(promise).rejects.toThrow();
        });
    });

    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<void> => {
            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();
            });
        });
    });

    it("should not change room when editing event in a room displayed in module", async () => {
        const room2 = new Room("!room2:example.org", cli, "@alice:example.org");
        rooms.set(room2.roomId, room2);
        room.getMyMembership = jest.fn().mockReturnValue(KnownMembership.Join);
        room2.getMyMembership = jest.fn().mockReturnValue(KnownMembership.Join);

        await mountRoomView();

        // Mock the spaceStore activeSpace and ModuleApi setup
        jest.spyOn(stores.spaceStore, "activeSpace", "get").mockReturnValue("space1");
        // Mock that room2 is displayed in a module
        ModuleApi.instance.extras.getVisibleRoomBySpaceKey("space1", () => [room2.roomId]);

        // Mock the roomViewStore method
        jest.spyOn(stores.roomViewStore, "isRoomDisplayedInModule").mockReturnValue(true);

        // Create an event in room2 to edit
        const eventInRoom2 = new MatrixEvent({
            type: "m.room.message",
            event_id: "$edit-event:example.org",
            room_id: room2.roomId,
            sender: "@alice:example.org",
            content: {
                body: "Original message",
                msgtype: "m.text",
            },
        });

        const dispatchSpy = jest.spyOn(defaultDispatcher, "dispatch");

        // Dispatch EditEvent for event in room2 (which is displayed in module)
        defaultDispatcher.dispatch({
            action: Action.EditEvent,
            event: eventInRoom2,
            timelineRenderingType: TimelineRenderingType.Room,
        });

        await flushPromises();

        // Should not dispatch ViewRoom action since room2 is displayed in module
        expect(dispatchSpy).not.toHaveBeenCalledWith(
            expect.objectContaining({
                action: Action.ViewRoom,
                room_id: room2.roomId,
            }),
        );
    });
});
