Florian Duros 242f2deb64
Add option to enable read receipt and marker when user interact with UI (#31353)
* feat(room view): add `enableReadReceiptsAndMarkersOnActivity` props

For the multiroom module, we display several room views at the same
time. In order to avoid all the rooms to send read receipts and markers
automatically when we are interacting with the UI, we add
`enableReadReceiptsAndMarkersOnActivity`props.

When at false, the timeline doesn't listen to user activity to send
these receipts. Only when the room is focused, marker and read receipts
are updated.

* test(room view): add test for `enableReadReceiptsAndMarkersOnActivity`

* build(ew-api): update `@element-hq/element-web-module-api` to `v1.9.0`
2025-12-05 11:52:41 +00:00

1108 lines
47 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, { 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";
// 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();
// 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_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();
});
});
});
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,
}),
);
});
});