mirror of
https://github.com/vector-im/element-web.git
synced 2025-11-11 13:41:09 +01:00
* Module API experiments * Move ResizerNotifier into SDKContext so we don't have to pass it into RoomView * Add the MultiRoomViewStore * Make RoomViewStore able to take a roomId prop * Different interface to add space panel items A bit less flexible but probably simpler and will help keep things actually consistent rather than just allowing modules to stick any JSX into the space panel (which means they also have to worry about styling if they *do* want it to be consistent). * Allow space panel items to be updated and manage which one is selected, allowing module "spaces" to be considered spaces * Remove fetchRoomFn from SpaceNotificationStore which didn't really seem to have any point as it was only called from one place * Switch to using module api via .instance * Fairly awful workaround to actually break the dependency nightmare * Add test for multiroomviewstore * add test * Make room names deterministic So the tests don't fail if you add other tests or run them individually * Add test for builtinsapi * Update module api * RVS is not needed as prop anymore Since it's passed through context * Add roomId to prop * Remove RoomViewStore from state This is now accessed through class field * Fix test * No need to pass RVS from LoggedInView * Add RoomContextType * Implement new builtins api * Add tests * Fix import * Fix circular dependency issue * Fix import * Add more tests * Improve comment * room-id is optional * Update license * Add implementation for AccountDataApi * Add implementation for Room * Add implementation for ClientApi * Create ClientApi in Api.ts * Write tests * Use nullish coalescing assignment * Implement openRoom in NavigationApi * Write tests * Add implementation for StoresApi * Write tests * Fix circular dependency * Add comments in lieu of type and fix else block * Change to class field --------- Co-authored-by: R Midhun Suresh <hi@midhun.dev>
927 lines
40 KiB
TypeScript
927 lines
40 KiB
TypeScript
/*
|
|
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, UserVerificationStatus, CryptoEvent } from "matrix-js-sdk/src/crypto-api";
|
|
import { KnownMembership } from "matrix-js-sdk/src/types";
|
|
import {
|
|
fireEvent,
|
|
render,
|
|
screen,
|
|
type RenderResult,
|
|
waitForElementToBeRemoved,
|
|
waitFor,
|
|
act,
|
|
cleanup,
|
|
} from "jest-matrix-react";
|
|
import userEvent from "@testing-library/user-event";
|
|
|
|
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 { 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 from "../../../../src/Modal.tsx";
|
|
import ErrorDialog from "../../../../src/components/views/dialogs/ErrorDialog.tsx";
|
|
|
|
// 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());
|
|
|
|
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();
|
|
cleanup();
|
|
});
|
|
|
|
const mountRoomView = async (ref?: RefObject<RoomView | null>): 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(
|
|
<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 as any}
|
|
forceTimeline={false}
|
|
ref={ref}
|
|
/>
|
|
</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);
|
|
});
|
|
|
|
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_normal")).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_verified")).toBeInTheDocument());
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|
|
|
|
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 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(`!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);
|
|
|
|
await userEvent.hover(getByText("search term"));
|
|
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("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();
|
|
});
|
|
});
|
|
});
|
|
});
|