mv element.io @types __mocks__/ debian docker module_system/ playwright res src test webapp Dockerfile .dockerignore .eslintignore .stylelintrc.cjs babel.config.cjs recorder-worklet-loader.cjs .modernizr.json components.json config.json config.sample.json package.json project.json tsconfig.json tsconfig.module_system.json jest.config.ts playwright.config.ts webpack.config.ts build_config.sample.yaml apps/web/

mkdir apps/web/scripts
mv scripts/{cleanup.sh,ci_package.sh,copy-res.ts,deploy.py,package.sh} apps/web/scripts

And a couple of gitignore tweaks

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
Michael Telatynski
2026-02-24 15:43:58 +00:00
parent e7509c92a1
commit 91a3cb03c1
3408 changed files with 28 additions and 32 deletions
@@ -0,0 +1,276 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 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 from "react";
import { fireEvent, getByLabelText, getByText, render, screen, waitFor } from "jest-matrix-react";
import { type EventTimeline, JoinRule, Room } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { SDKContext, SdkContextClass } from "../../../../../../src/contexts/SDKContext";
import { getMockClientWithEventEmitter, mockClientMethodsUser } from "../../../../../test-utils";
import {
CallGuestLinkButton,
JoinRuleDialog,
} from "../../../../../../src/components/views/rooms/RoomHeader/CallGuestLinkButton";
import Modal from "../../../../../../src/Modal";
import SdkConfig from "../../../../../../src/SdkConfig";
import { ShareDialog } from "../../../../../../src/components/views/dialogs/ShareDialog";
import { _t } from "../../../../../../src/languageHandler";
import SettingsStore from "../../../../../../src/settings/SettingsStore";
describe("<CallGuestLinkButton />", () => {
const roomId = "!room:server.org";
let sdkContext!: SdkContextClass;
let modalSpy: jest.SpyInstance;
let modalResolve: (value: unknown[] | PromiseLike<unknown[]>) => void;
let room: Room;
const targetUnencrypted =
"https://guest_spa_url.com/room/#/!room:server.org?roomId=%21room%3Aserver.org&viaServers=example.org";
const targetEncrypted =
"https://guest_spa_url.com/room/#/!room:server.org?roomId=%21room%3Aserver.org&perParticipantE2EE=true&viaServers=example.org";
const expectedShareDialogProps = {
target: targetEncrypted,
customTitle: "Conference invite link",
subtitle: "Link for external users to join the call without a matrix account:",
};
/**
* Create a room using mocked client
* And mock isElementVideoRoom
*/
const makeRoom = (isVideoRoom = true): Room => {
const room = new Room(roomId, sdkContext.client!, sdkContext.client!.getSafeUserId());
sdkContext.client!.getRoomDirectoryVisibility = jest.fn().mockResolvedValue("public");
jest.spyOn(room, "isElementVideoRoom").mockReturnValue(isVideoRoom);
// stub
jest.spyOn(room, "getPendingEvents").mockReturnValue([]);
jest.spyOn(room, "getVersion").mockReturnValue("9");
return room;
};
function mockRoomMembers(room: Room, count: number) {
const members = Array(count)
.fill(0)
.map((_, index) => ({
userId: `@user-${index}:example.org`,
roomId: room.roomId,
membership: KnownMembership.Join,
}));
room.currentState.setJoinedMemberCount(members.length);
room.getJoinedMembers = jest.fn().mockReturnValue(members);
}
const getComponent = (room: Room) =>
render(<CallGuestLinkButton room={room} />, {
wrapper: ({ children }) => <SDKContext.Provider value={sdkContext}>{children}</SDKContext.Provider>,
});
const oldGet = SdkConfig.get;
beforeEach(() => {
const client = getMockClientWithEventEmitter({
...mockClientMethodsUser(),
sendStateEvent: jest.fn(),
});
sdkContext = new SdkContextClass();
sdkContext.client = client;
const modalPromise = new Promise<unknown[]>((resolve) => {
modalResolve = resolve;
});
modalSpy = jest.spyOn(Modal, "createDialog").mockReturnValue({ finished: modalPromise, close: jest.fn() });
room = makeRoom();
mockRoomMembers(room, 3);
jest.spyOn(SdkConfig, "get").mockImplementation((key) => {
if (key === "element_call") {
return { guest_spa_url: "https://guest_spa_url.com", url: "https://spa_url.com" };
}
return oldGet(key);
});
jest.spyOn(room, "hasEncryptionStateEvent").mockReturnValue(true);
jest.spyOn(SdkContextClass.instance.roomViewStore, "isViewingCall").mockReturnValue(true);
});
afterEach(() => {
jest.restoreAllMocks();
});
it("shows the JoinRuleDialog on click with private join rules", async () => {
getComponent(room);
fireEvent.click(screen.getByRole("button", { name: "Share call link" }));
expect(modalSpy).toHaveBeenCalledWith(JoinRuleDialog, { room, canInvite: false });
// pretend public was selected
jest.spyOn(room, "getJoinRule").mockReturnValue(JoinRule.Public);
modalResolve([]);
await new Promise(process.nextTick);
const callParams = modalSpy.mock.calls[1];
expect(callParams[0]).toEqual(ShareDialog);
expect(callParams[1].target.toString()).toEqual(expectedShareDialogProps.target);
expect(callParams[1].subtitle).toEqual(expectedShareDialogProps.subtitle);
expect(callParams[1].customTitle).toEqual(expectedShareDialogProps.customTitle);
});
it("shows the ShareDialog on click with public join rules", () => {
jest.spyOn(room, "getJoinRule").mockReturnValue(JoinRule.Public);
getComponent(room);
fireEvent.click(screen.getByRole("button", { name: "Share call link" }));
const callParams = modalSpy.mock.calls[0];
expect(callParams[0]).toEqual(ShareDialog);
expect(callParams[1].target.toString()).toEqual(expectedShareDialogProps.target);
expect(callParams[1].subtitle).toEqual(expectedShareDialogProps.subtitle);
expect(callParams[1].customTitle).toEqual(expectedShareDialogProps.customTitle);
});
it("shows the ShareDialog on click with knock join rules", () => {
jest.spyOn(room, "getJoinRule").mockReturnValue(JoinRule.Knock);
jest.spyOn(room, "canInvite").mockReturnValue(true);
getComponent(room);
fireEvent.click(screen.getByRole("button", { name: "Share call link" }));
const callParams = modalSpy.mock.calls[0];
expect(callParams[0]).toEqual(ShareDialog);
expect(callParams[1].target.toString()).toEqual(expectedShareDialogProps.target);
expect(callParams[1].subtitle).toEqual(expectedShareDialogProps.subtitle);
expect(callParams[1].customTitle).toEqual(expectedShareDialogProps.customTitle);
});
it("don't show external conference button if room not public nor knock and the user cannot change join rules", () => {
// preparation for if we refactor the related code to not use currentState.
jest.spyOn(room, "getLiveTimeline").mockReturnValue({
getState: jest.fn().mockReturnValue({
maySendStateEvent: jest.fn().mockReturnValue(false),
}),
} as unknown as EventTimeline);
jest.spyOn(room.currentState, "maySendStateEvent").mockReturnValue(false);
getComponent(room);
expect(screen.queryByLabelText("Share call link")).not.toBeInTheDocument();
});
it("don't show external conference button if now guest spa link is configured", () => {
jest.spyOn(room, "getJoinRule").mockReturnValue(JoinRule.Public);
jest.spyOn(SdkContextClass.instance.roomViewStore, "isViewingCall").mockReturnValue(true);
jest.spyOn(SdkConfig, "get").mockImplementation((key) => {
if (key === "element_call") {
return { url: "https://example2.com" };
}
return oldGet(key);
});
getComponent(room);
// We only change the SdkConfig and show that this everything else is
// configured so that the call link button is shown.
expect(screen.queryByLabelText("Share call link")).not.toBeInTheDocument();
jest.spyOn(SdkConfig, "get").mockImplementation((key) => {
if (key === "element_call") {
return { guest_spa_url: "https://guest_spa_url.com", url: "https://example2.com" };
}
return oldGet(key);
});
getComponent(room);
expect(getByLabelText(document.body, "Share call link")).toBeInTheDocument();
});
it("opens the share dialog with the correct share link in an encrypted room", () => {
jest.spyOn(room, "getJoinRule").mockReturnValue(JoinRule.Public);
jest.spyOn(SdkContextClass.instance.roomViewStore, "isViewingCall").mockReturnValue(true);
getComponent(room);
const modalSpy = jest.spyOn(Modal, "createDialog");
fireEvent.click(getByLabelText(document.body, _t("voip|get_call_link")));
// const target =
// "https://guest_spa_url.com/room/#/!room:server.org?roomId=%21room%3Aserver.org&perParticipantE2EE=true&viaServers=example.org";
expect(modalSpy).toHaveBeenCalled();
const arg0 = modalSpy.mock.calls[0][0];
const arg1 = modalSpy.mock.calls[0][1] as any;
expect(arg0).toEqual(ShareDialog);
const { customTitle, subtitle } = arg1;
expect({ customTitle, subtitle }).toEqual({
customTitle: "Conference invite link",
subtitle: _t("share|share_call_subtitle"),
});
expect(arg1.target.toString()).toEqual(targetEncrypted);
});
it("share dialog has correct link in an unencrypted room", () => {
jest.spyOn(room, "getJoinRule").mockReturnValue(JoinRule.Public);
jest.spyOn(room, "hasEncryptionStateEvent").mockReturnValue(false);
jest.spyOn(SdkContextClass.instance.roomViewStore, "isViewingCall").mockReturnValue(true);
getComponent(room);
const modalSpy = jest.spyOn(Modal, "createDialog");
fireEvent.click(getByLabelText(document.body, _t("voip|get_call_link")));
const arg1 = modalSpy.mock.calls[0][1] as any;
expect(arg1.target.toString()).toEqual(targetUnencrypted);
});
describe("<JoinRuleDialog />", () => {
const onFinished = jest.fn();
const getComponent = (room: Room, canInvite: boolean = true) =>
render(<JoinRuleDialog room={room} canInvite={canInvite} onFinished={onFinished} />, {
wrapper: ({ children }) => <SDKContext.Provider value={sdkContext}>{children}</SDKContext.Provider>,
});
beforeEach(() => {
// feature_ask_to_join enabled
jest.spyOn(SettingsStore, "getValue").mockReturnValue(true);
});
it("shows ask to join if feature is enabled", () => {
getComponent(room);
expect(screen.getByRole("radio", { name: "Ask to join ( Recommended )" })).toBeInTheDocument();
});
it("dont show ask to join if feature is enabled but cannot invite", () => {
getComponent(room, false);
expect(screen.queryByRole("radio", { name: "Ask to join ( Recommended )" })).not.toBeInTheDocument();
});
it("doesn't show ask to join if feature is disabled", () => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue(false);
getComponent(room);
expect(screen.queryByRole("radio", { name: "Ask to join ( Recommended )" })).not.toBeInTheDocument();
});
it("sends correct state event on click", async () => {
const sendStateSpy = jest.spyOn(sdkContext.client!, "sendStateEvent");
let container;
container = getComponent(room).container;
fireEvent.click(screen.getByRole("radio", { name: "Ask to join ( Recommended )" }));
expect(sendStateSpy).toHaveBeenCalledWith(
"!room:server.org",
"m.room.join_rules",
{ join_rule: "knock" },
"",
);
expect(sendStateSpy).toHaveBeenCalledTimes(1);
await waitFor(() => expect(onFinished).toHaveBeenCalledTimes(1), { timeout: 3000 });
onFinished.mockClear();
sendStateSpy.mockClear();
container = getComponent(room).container;
fireEvent.click(getByText(container, "Public"));
expect(sendStateSpy).toHaveBeenLastCalledWith(
"!room:server.org",
"m.room.join_rules",
{ join_rule: "public" },
"",
);
expect(sendStateSpy).toHaveBeenCalledTimes(1);
container = getComponent(room).container;
await waitFor(() => expect(onFinished).toHaveBeenCalledTimes(1), { timeout: 3000 });
onFinished.mockClear();
sendStateSpy.mockClear();
fireEvent.click(getByText(container, _t("update_room_access_modal|no_change")));
await waitFor(() => expect(onFinished).toHaveBeenCalledTimes(1));
// Don't call sendStateEvent if no change is clicked.
expect(sendStateSpy).toHaveBeenCalledTimes(0);
});
});
});
@@ -0,0 +1,988 @@
/*
Copyright (C) 2025 Element Creations Ltd
Copyright 2024, 2025 New Vector Ltd.
Copyright 2023 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 from "react";
import { CallType, type MatrixCall } from "matrix-js-sdk/src/webrtc/call";
import {
EventType,
JoinRule,
MatrixEvent,
PendingEventOrdering,
Room,
RoomStateEvent,
RoomMember,
type MatrixClient,
} from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { CryptoEvent, UserVerificationStatus } from "matrix-js-sdk/src/crypto-api";
import {
act,
createEvent,
fireEvent,
getAllByLabelText,
getByLabelText,
getByText,
queryByLabelText,
render,
type RenderOptions,
screen,
waitFor,
} from "jest-matrix-react";
import { type ViewRoomOpts } from "@matrix-org/react-sdk-module-api/lib/lifecycles/RoomViewLifecycle";
import { mocked } from "jest-mock";
import userEvent from "@testing-library/user-event";
import { filterConsole, setupAsyncStoreWithClient, stubClient } from "../../../../../test-utils";
import RoomHeader from "../../../../../../src/components/views/rooms/RoomHeader/RoomHeader";
import DMRoomMap from "../../../../../../src/utils/DMRoomMap";
import { MatrixClientPeg } from "../../../../../../src/MatrixClientPeg";
import { ScopedRoomContextProvider } from "../../../../../../src/contexts/ScopedRoomContext";
import RoomContext, { type RoomContextType } from "../../../../../../src/contexts/RoomContext";
import RightPanelStore from "../../../../../../src/stores/right-panel/RightPanelStore";
import { RightPanelPhases } from "../../../../../../src/stores/right-panel/RightPanelStorePhases";
import LegacyCallHandler from "../../../../../../src/LegacyCallHandler";
import SettingsStore from "../../../../../../src/settings/SettingsStore";
import SdkConfig from "../../../../../../src/SdkConfig";
import dispatcher from "../../../../../../src/dispatcher/dispatcher";
import { CallStore } from "../../../../../../src/stores/CallStore";
import { type Call } from "../../../../../../src/models/Call";
import * as ShieldUtils from "../../../../../../src/utils/ShieldUtils";
import { Container, WidgetLayoutStore } from "../../../../../../src/stores/widgets/WidgetLayoutStore";
import MatrixClientContext from "../../../../../../src/contexts/MatrixClientContext";
import { _t } from "../../../../../../src/languageHandler";
import WidgetStore, { type IApp } from "../../../../../../src/stores/WidgetStore";
import { UIFeature } from "../../../../../../src/settings/UIFeature";
import { SettingLevel } from "../../../../../../src/settings/SettingLevel";
import { ElementCallMemberEventType } from "../../../../../../src/call-types";
import { defaultWatchManager } from "../../../../../../src/settings/Settings.tsx";
jest.mock("../../../../../../src/utils/ShieldUtils");
jest.mock("../../../../../../src/hooks/right-panel/useCurrentPhase", () => ({
useCurrentPhase: () => {
return { currentPhase: "foo", isOpen: false };
},
}));
describe("RoomHeader", () => {
filterConsole(
"[getType] Room !1:example.org does not have an m.room.create event",
"Age for event was not available, using `now - origin_server_ts` as a fallback. If the device clock is not correct issues might occur.",
);
let room: Room;
const ROOM_ID = "!1:example.org";
let setCardSpy: jest.SpyInstance | undefined;
const mockRoomViewStore = {
isViewingCall: jest.fn().mockReturnValue(false),
on: jest.fn(),
off: jest.fn(),
emit: jest.fn(),
};
let client: MatrixClient;
let roomContext: RoomContextType;
function getWrapper(): RenderOptions {
return {
wrapper: ({ children }) => (
<MatrixClientContext.Provider value={client}>
<ScopedRoomContextProvider {...roomContext}>{children}</ScopedRoomContextProvider>
</MatrixClientContext.Provider>
),
};
}
beforeEach(() => {
client = stubClient();
room = new Room(ROOM_ID, client, "@alice:example.org", {
pendingEventOrdering: PendingEventOrdering.Detached,
});
DMRoomMap.setShared({
getUserIdForRoomId: jest.fn(),
} as unknown as DMRoomMap);
setCardSpy = jest.spyOn(RightPanelStore.instance, "setCard");
jest.spyOn(ShieldUtils, "shieldStatusForRoom").mockResolvedValue(ShieldUtils.E2EStatus.Normal);
// Mock CallStore.instance.getCall to return null by default
// Individual tests can override this when they need a specific Call object
jest.spyOn(CallStore.instance, "getCall").mockReturnValue(null);
// Reset the mock RoomViewStore
mockRoomViewStore.isViewingCall.mockReturnValue(false);
// Create a stable room context for this test
roomContext = {
...RoomContext,
roomId: ROOM_ID,
roomViewStore: mockRoomViewStore,
} as unknown as RoomContextType;
});
afterEach(() => {
jest.restoreAllMocks();
SettingsStore.reset();
});
it("renders the room header", () => {
const { container } = render(<RoomHeader room={room} />, getWrapper());
expect(container).toHaveTextContent(ROOM_ID);
});
it("opens the room summary", async () => {
const user = userEvent.setup();
const { container } = render(<RoomHeader room={room} />, getWrapper());
await user.click(getByText(container, ROOM_ID));
expect(setCardSpy).toHaveBeenCalledWith({ phase: RightPanelPhases.RoomSummary });
});
it("shows a face pile for rooms", async () => {
const user = userEvent.setup();
const members = [
{
userId: "@me:example.org",
name: "Member",
rawDisplayName: "Member",
roomId: room.roomId,
membership: KnownMembership.Join,
getAvatarUrl: () => "mxc://avatar.url/image.png",
getMxcAvatarUrl: () => "mxc://avatar.url/image.png",
},
{
userId: "@you:example.org",
name: "Member",
rawDisplayName: "Member",
roomId: room.roomId,
membership: KnownMembership.Join,
getAvatarUrl: () => "mxc://avatar.url/image.png",
getMxcAvatarUrl: () => "mxc://avatar.url/image.png",
},
{
userId: "@them:example.org",
name: "Member",
rawDisplayName: "Member",
roomId: room.roomId,
membership: KnownMembership.Join,
getAvatarUrl: () => "mxc://avatar.url/image.png",
getMxcAvatarUrl: () => "mxc://avatar.url/image.png",
},
{
userId: "@bot:example.org",
name: "Bot user",
rawDisplayName: "Bot user",
roomId: room.roomId,
membership: KnownMembership.Join,
getAvatarUrl: () => "mxc://avatar.url/image.png",
getMxcAvatarUrl: () => "mxc://avatar.url/image.png",
},
];
room.currentState.setJoinedMemberCount(members.length);
room.getJoinedMembers = jest.fn().mockReturnValue(members);
const { container } = render(<RoomHeader room={room} />, getWrapper());
expect(container).toHaveTextContent("4");
const facePile = getByLabelText(document.body, "4 members");
expect(facePile).toHaveTextContent("4");
await user.click(facePile);
expect(setCardSpy).toHaveBeenCalledWith({ phase: RightPanelPhases.MemberList });
});
it("has room info icon that opens the room info panel", async () => {
const user = userEvent.setup();
const { getAllByRole } = render(<RoomHeader room={room} />, getWrapper());
const infoButton = getAllByRole("button", { name: "Room info" })[1];
await user.click(infoButton);
expect(setCardSpy).toHaveBeenCalledWith({ phase: RightPanelPhases.RoomSummary });
});
it("opens the thread panel", async () => {
const user = userEvent.setup();
render(<RoomHeader room={room} />, getWrapper());
await user.click(getByLabelText(document.body, "Threads"));
expect(setCardSpy).toHaveBeenCalledWith({ phase: RightPanelPhases.ThreadPanel });
});
it("opens the notifications panel", async () => {
const user = userEvent.setup();
SettingsStore.setValue("feature_notifications", null, SettingLevel.DEVICE, true);
render(<RoomHeader room={room} />, getWrapper());
await user.click(getByLabelText(document.body, "Notifications"));
expect(setCardSpy).toHaveBeenCalledWith({ phase: RightPanelPhases.NotificationPanel });
});
it("should show both call buttons in rooms smaller than 3 members", async () => {
mockRoomMembers(room, 2);
render(<RoomHeader room={room} />, getWrapper());
const voiceButton = screen.getByRole("button", { name: "Voice call" });
const videoButton = screen.getByRole("button", { name: "Video call" });
expect(videoButton).toBeInTheDocument();
expect(voiceButton).toBeInTheDocument();
});
it("should not show voice call button in managed hybrid environments", async () => {
mockRoomMembers(room, 2);
jest.spyOn(SdkConfig, "get").mockReturnValue({ widget_build_url: "https://widget.build.url" });
render(<RoomHeader room={room} />, getWrapper());
const videoButton = screen.getByRole("button", { name: "Video call" });
expect(videoButton).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Voice call" })).not.toBeInTheDocument();
});
it("should not show voice call button in rooms larger than 2 members", async () => {
mockRoomMembers(room, 3);
render(<RoomHeader room={room} />, getWrapper());
const videoButton = screen.getByRole("button", { name: "Video call" });
expect(videoButton).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Voice call" })).not.toBeInTheDocument();
});
describe("UIFeature.Voip disabled", () => {
beforeEach(() => {
SdkConfig.put({
setting_defaults: {
[UIFeature.Voip]: false,
},
});
});
afterEach(() => {
SdkConfig.reset();
jest.restoreAllMocks();
});
it("should not show call buttons in rooms smaller than 3 members", async () => {
mockRoomMembers(room, 2);
render(<RoomHeader room={room} />, getWrapper());
expect(screen.queryByRole("button", { name: "Video call" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Voice call" })).not.toBeInTheDocument();
});
it("should not show call button in rooms larger than 2 members", async () => {
mockRoomMembers(room, 3);
render(<RoomHeader room={room} />, getWrapper());
expect(screen.queryByRole("button", { name: "Video call" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Voice call" })).not.toBeInTheDocument();
});
});
describe("UIFeature.Widgets enabled (default)", () => {
beforeEach(() => {
SdkConfig.put({
setting_defaults: {
[UIFeature.Widgets]: true,
},
});
});
afterEach(() => {
SdkConfig.reset();
});
it("should show call buttons in a room with 2 members", () => {
mockRoomMembers(room, 2);
render(<RoomHeader room={room} />, getWrapper());
const videoButton = screen.getByRole("button", { name: "Video call" });
expect(videoButton).toBeInTheDocument();
});
it("should show call buttons in a room with more than 2 members", () => {
mockRoomMembers(room, 3);
render(<RoomHeader room={room} />, getWrapper());
const videoButton = screen.getByRole("button", { name: "Video call" });
expect(videoButton).toBeInTheDocument();
});
});
describe("UIFeature.Widgets disabled", () => {
beforeEach(() => {
SdkConfig.put({
setting_defaults: {
[UIFeature.Widgets]: false,
},
});
});
afterEach(() => {
SdkConfig.reset();
});
it("should show call buttons in a room with 2 members", () => {
mockRoomMembers(room, 2);
render(<RoomHeader room={room} />, getWrapper());
const videoButton = screen.getByRole("button", { name: "Video call" });
expect(videoButton).toBeInTheDocument();
});
it("should not show call buttons in a room with more than 2 members", () => {
mockRoomMembers(room, 3);
const { container } = render(<RoomHeader room={room} />, getWrapper());
expect(queryByLabelText(container, "Video call")).not.toBeInTheDocument();
});
});
describe("groups call disabled", () => {
beforeEach(() => {
SdkConfig.put({
setting_defaults: {
[UIFeature.Widgets]: true,
},
});
});
afterEach(() => {
SdkConfig.reset();
});
it("you can call when you're two in the room", async () => {
const user = userEvent.setup();
mockRoomMembers(room, 2);
render(<RoomHeader room={room} />, getWrapper());
const voiceButton = screen.getByRole("button", { name: "Voice call" });
const videoButton = screen.getByRole("button", { name: "Video call" });
expect(voiceButton).not.toHaveAttribute("aria-disabled", "true");
expect(videoButton).not.toHaveAttribute("aria-disabled", "true");
const placeCallSpy = jest.spyOn(LegacyCallHandler.instance, "placeCall");
await user.click(voiceButton);
expect(placeCallSpy).toHaveBeenLastCalledWith(room.roomId, CallType.Voice);
await user.click(videoButton);
expect(placeCallSpy).toHaveBeenLastCalledWith(room.roomId, CallType.Video);
});
it("you can't call if there's already a call", () => {
mockRoomMembers(room, 2);
jest.spyOn(LegacyCallHandler.instance, "getCallForRoom").mockReturnValue(
// The JS-SDK does not export the class `MatrixCall` only the type
{} as MatrixCall,
);
const { container } = render(<RoomHeader room={room} />, getWrapper());
for (const button of getAllByLabelText(container, "Ongoing call")) {
expect(button).toHaveAttribute("aria-disabled", "true");
}
});
it("can call in large rooms if able to edit widgets", () => {
mockRoomMembers(room, 10);
jest.spyOn(room.currentState, "mayClientSendStateEvent").mockReturnValue(true);
render(<RoomHeader room={room} />, getWrapper());
const videoCallButton = screen.getByRole("button", { name: "Video call" });
expect(videoCallButton).not.toHaveAttribute("aria-disabled", "true");
});
it("disable calls in large rooms by default", () => {
mockRoomMembers(room, 10);
jest.spyOn(room.currentState, "mayClientSendStateEvent").mockReturnValue(false);
render(<RoomHeader room={room} />, getWrapper());
expect(
getByLabelText(document.body, "You do not have permission to start video calls", {
selector: "button",
}),
).toHaveAttribute("aria-disabled", "true");
});
});
describe("group call enabled", () => {
beforeEach(async () => {
SdkConfig.put({
features: {
feature_group_calls: true,
},
});
// Enable Element Call
client._unstable_getRTCTransports = jest
.fn()
.mockResolvedValue([{ type: "livekit", livekit_service_url: "https://example.org" }]);
// And ensure the CallStore has the transports configured.
await setupAsyncStoreWithClient(CallStore.instance, client);
});
afterEach(() => {
SdkConfig.reset();
jest.restoreAllMocks();
});
it("renders only the video call element", async () => {
const user = userEvent.setup();
mockRoomMembers(room, 3);
SdkConfig.add({
element_call: {
use_exclusively: true,
},
});
// allow element calls
jest.spyOn(room.currentState, "mayClientSendStateEvent").mockReturnValue(true);
render(<RoomHeader room={room} />, getWrapper());
expect(screen.queryByTitle("Voice call")).toBeNull();
const videoCallButton = screen.getByRole("button", { name: "Video call" });
expect(videoCallButton).not.toHaveAttribute("aria-disabled", "true");
const dispatcherSpy = jest.spyOn(dispatcher, "dispatch").mockImplementation();
await user.click(videoCallButton);
expect(dispatcherSpy).toHaveBeenCalledWith(expect.objectContaining({ view_call: true }));
});
it("can't call if there's an ongoing (pinned) call", () => {
SdkConfig.add({
element_call: {
use_exclusively: true,
},
});
// allow element calls
jest.spyOn(room.currentState, "mayClientSendStateEvent").mockReturnValue(true);
jest.spyOn(WidgetLayoutStore.instance, "isInContainer").mockReturnValue(true);
const widget = { type: "m.jitsi" } as IApp;
jest.spyOn(CallStore.instance, "getCall").mockReturnValue({
widget,
on: () => {},
off: () => {},
} as unknown as Call);
jest.spyOn(WidgetStore.instance, "getApps").mockReturnValue([widget]);
render(<RoomHeader room={room} />, getWrapper());
// Voice and video
for (const button of screen.getAllByRole("button", { name: "Ongoing call" })) {
expect(button).toHaveAttribute("aria-disabled", "true");
}
});
it("clicking on ongoing (unpinned) call re-pins it", async () => {
const user = userEvent.setup();
mockRoomMembers(room, 3);
SdkConfig.add({
setting_defaults: {
[UIFeature.Widgets]: true,
},
features: {
feature_group_calls: false,
},
});
// allow calls
jest.spyOn(room.currentState, "mayClientSendStateEvent").mockReturnValue(true);
jest.spyOn(WidgetLayoutStore.instance, "isInContainer").mockReturnValue(false);
const spy = jest.spyOn(WidgetLayoutStore.instance, "moveToContainer");
const widget = { type: "m.jitsi" } as IApp;
jest.spyOn(CallStore.instance, "getCall").mockReturnValue({
widget,
on: () => {},
off: () => {},
} as unknown as Call);
jest.spyOn(WidgetStore.instance, "getApps").mockReturnValue([widget]);
render(<RoomHeader room={room} />, getWrapper());
const videoButton = screen.getByRole("button", { name: "Video call" });
expect(videoButton).not.toHaveAttribute("aria-disabled", "true");
await user.click(videoButton);
expect(spy).toHaveBeenCalledWith(room, widget, Container.Top);
});
it("disables calling if there's a jitsi call", () => {
mockRoomMembers(room, 2);
jest.spyOn(LegacyCallHandler.instance, "getCallForRoom").mockReturnValue(
// The JS-SDK does not export the class `MatrixCall` only the type
{} as MatrixCall,
);
const { container } = render(<RoomHeader room={room} />, getWrapper());
for (const button of getAllByLabelText(container, "Ongoing call")) {
expect(button).toHaveAttribute("aria-disabled", "true");
}
});
it("calls using legacy or jitsi", async () => {
const user = userEvent.setup();
mockRoomMembers(room, 2);
jest.spyOn(room.currentState, "mayClientSendStateEvent").mockImplementation((key) => {
if (key === "im.vector.modular.widgets") return true;
return false;
});
render(<RoomHeader room={room} />, getWrapper());
const voiceButton = screen.getByRole("button", { name: "Voice call" });
const videoButton = screen.getByRole("button", { name: "Video call" });
expect(voiceButton).not.toHaveAttribute("aria-disabled", "true");
expect(videoButton).not.toHaveAttribute("aria-disabled", "true");
const placeCallSpy = jest.spyOn(LegacyCallHandler.instance, "placeCall");
await user.click(voiceButton);
expect(placeCallSpy).toHaveBeenLastCalledWith(room.roomId, CallType.Voice);
await user.click(videoButton);
expect(placeCallSpy).toHaveBeenLastCalledWith(room.roomId, CallType.Video);
});
it("calls using legacy or jitsi for large rooms", async () => {
const user = userEvent.setup();
mockRoomMembers(room, 3);
jest.spyOn(room.currentState, "mayClientSendStateEvent").mockImplementation((key) => {
if (key === "im.vector.modular.widgets") return true;
return false;
});
render(<RoomHeader room={room} />, getWrapper());
const videoButton = screen.getByRole("button", { name: "Video call" });
expect(videoButton).not.toHaveAttribute("aria-disabled", "true");
const placeCallSpy = jest.spyOn(LegacyCallHandler.instance, "placeCall");
await user.click(videoButton);
expect(placeCallSpy).toHaveBeenLastCalledWith(room.roomId, CallType.Video);
});
it("calls using element call for large rooms", async () => {
const user = userEvent.setup();
mockRoomMembers(room, 3);
jest.spyOn(room.currentState, "mayClientSendStateEvent").mockImplementation((key) => {
if (key === ElementCallMemberEventType.name) return true;
return false;
});
render(<RoomHeader room={room} />, getWrapper());
const videoButton = screen.getByRole("button", { name: "Video call" });
expect(videoButton).not.toHaveAttribute("aria-disabled", "true");
const dispatcherSpy = jest.spyOn(dispatcher, "dispatch").mockImplementation();
await user.click(videoButton);
expect(dispatcherSpy).toHaveBeenCalledWith(expect.objectContaining({ view_call: true }));
});
it("buttons are disabled if there is an ongoing call", async () => {
mockRoomMembers(room, 3);
jest.spyOn(CallStore.prototype, "connectedCalls", "get").mockReturnValue(
new Set([{ roomId: "some_other_room" } as Call]),
);
const { container } = render(<RoomHeader room={room} />, getWrapper());
const [videoButton] = getAllByLabelText(container, "Ongoing call");
expect(videoButton).toHaveAttribute("aria-disabled", "true");
});
it("join video call button is shown if there is an ongoing call", async () => {
mockRoomMembers(room, 3);
// Mock CallStore to return a call with 3 participants
jest.spyOn(CallStore.instance, "getCall").mockReturnValue(createMockCall(ROOM_ID, 3));
render(<RoomHeader room={room} />, getWrapper());
const joinButton = getByLabelText(document.body, "Join video call");
expect(joinButton).not.toHaveAttribute("aria-disabled", "true");
});
it("join voice call button is shown if there is an ongoing call", async () => {
mockRoomMembers(room, 3);
// Mock CallStore to return a call with 3 participants
jest.spyOn(CallStore.instance, "getCall").mockReturnValue(createMockCall(ROOM_ID, 3, CallType.Voice));
render(<RoomHeader room={room} />, getWrapper());
const joinButton = getByLabelText(document.body, "Join voice call");
expect(joinButton).not.toHaveAttribute("aria-disabled", "true");
});
it("join button is disabled if there is an other ongoing call", async () => {
mockRoomMembers(room, 3);
// Mock CallStore to return a call with 3 participants
jest.spyOn(CallStore.instance, "getCall").mockReturnValue(createMockCall(ROOM_ID, 3));
jest.spyOn(CallStore.prototype, "connectedCalls", "get").mockReturnValue(
new Set([{ roomId: "some_other_room" } as Call]),
);
render(<RoomHeader room={room} />, getWrapper());
const joinButton = getByLabelText(document.body, "Ongoing call");
expect(joinButton).toHaveAttribute("aria-disabled", "true");
});
it("close lobby button is shown", async () => {
mockRoomMembers(room, 3);
mockRoomViewStore.isViewingCall.mockReturnValue(true);
render(<RoomHeader room={room} />, getWrapper());
getByLabelText(document.body, "Close lobby");
});
it("close lobby button is shown if there is an ongoing call but we are viewing the lobby", async () => {
mockRoomMembers(room, 3);
// Mock CallStore to return a call with 3 participants
jest.spyOn(CallStore.instance, "getCall").mockReturnValue(createMockCall(ROOM_ID, 3));
mockRoomViewStore.isViewingCall.mockReturnValue(true);
render(<RoomHeader room={room} />, getWrapper());
getByLabelText(document.body, "Close lobby");
});
it("don't show external conference button if the call is not shown", () => {
mockRoomViewStore.isViewingCall.mockReturnValue(false);
jest.spyOn(SdkConfig, "get").mockImplementation((key) => {
return { guest_spa_url: "https://guest_spa_url.com", url: "https://spa_url.com" };
});
render(<RoomHeader room={room} />, getWrapper());
expect(screen.queryByLabelText(_t("voip|get_call_link"))).not.toBeInTheDocument();
mockRoomViewStore.isViewingCall.mockReturnValue(true);
render(<RoomHeader room={room} />, getWrapper());
expect(getByLabelText(document.body, _t("voip|get_call_link"))).toBeInTheDocument();
});
it("gives the option of element call or legacy calling for video", async () => {
const user = userEvent.setup();
mockRoomMembers(room, 2);
jest.spyOn(room.currentState, "mayClientSendStateEvent").mockImplementation((key) => {
if (key === ElementCallMemberEventType.name) return true;
return false;
});
render(<RoomHeader room={room} />, getWrapper());
const button = screen.getByRole("button", { name: "Video call" });
expect(button).not.toHaveAttribute("aria-disabled", "true");
await user.click(button);
const elementCallButton = screen.getByRole("menuitem", { name: "Element Call" });
const legacyCallButton = screen.getByRole("menuitem", { name: "Legacy Call" });
expect(elementCallButton).toBeInTheDocument();
expect(legacyCallButton).toBeInTheDocument();
});
it("gives the option of element call or legacy calling for voice in DM rooms", async () => {
const user = userEvent.setup();
mockRoomMembers(room, 2);
jest.spyOn(room.currentState, "mayClientSendStateEvent").mockImplementation((key) => {
if (key === ElementCallMemberEventType.name) return true;
return false;
});
render(<RoomHeader room={room} />, getWrapper());
const button = screen.getByRole("button", { name: "Voice call" });
expect(button).not.toHaveAttribute("aria-disabled", "true");
await user.click(button);
const elementCallButton = screen.getByRole("menuitem", { name: "Element Call" });
const legacyCallButton = screen.getByRole("menuitem", { name: "Legacy Call" });
expect(elementCallButton).toBeInTheDocument();
expect(legacyCallButton).toBeInTheDocument();
});
});
describe("public room", () => {
it("shows a globe", () => {
const joinRuleEvent = new MatrixEvent({
type: EventType.RoomJoinRules,
content: { join_rule: JoinRule.Public },
sender: MatrixClientPeg.get()!.getSafeUserId(),
state_key: "",
room_id: room.roomId,
});
room.addLiveEvents([joinRuleEvent], { addToState: true });
render(<RoomHeader room={room} />, getWrapper());
expect(getByLabelText(document.body, "Public room")).toBeInTheDocument();
});
});
it("shows a history icon if the room is encrypted and has shared history", async () => {
mocked(client.getCrypto()!).isEncryptionEnabledInRoom.mockResolvedValue(true);
await room.addLiveEvents(
[
new MatrixEvent({
type: "m.room.history_visibility",
content: { history_visibility: "shared" },
sender: MatrixClientPeg.get()!.getSafeUserId(),
state_key: "",
room_id: room.roomId,
}),
],
{ addToState: true },
);
let featureEnabled = true;
jest.spyOn(SettingsStore, "getValue").mockImplementation(
(flag) => flag === "feature_share_history_on_invite" && featureEnabled,
);
render(<RoomHeader room={room} />, getWrapper());
await waitFor(() => getByLabelText(document.body, "New members see history"));
// Disable the labs flag and check the icon disappears
featureEnabled = false;
act(() =>
defaultWatchManager.notifyUpdate(
"feature_share_history_on_invite",
null,
SettingLevel.DEVICE,
featureEnabled,
),
);
expect(queryByLabelText(document.body, "New members see history")).not.toBeInTheDocument();
});
it("shows a user icon if the room is encrypted and has world readable history", async () => {
mocked(client.getCrypto()!).isEncryptionEnabledInRoom.mockResolvedValue(true);
await room.addLiveEvents(
[
new MatrixEvent({
type: "m.room.history_visibility",
content: { history_visibility: "world_readable" },
sender: MatrixClientPeg.get()!.getSafeUserId(),
state_key: "",
room_id: room.roomId,
}),
],
{ addToState: true },
);
const featureEnabled = true;
jest.spyOn(SettingsStore, "getValue").mockImplementation(
(flag) => flag === "feature_share_history_on_invite" && featureEnabled,
);
render(<RoomHeader room={room} />, getWrapper());
await waitFor(() => getByLabelText(document.body, "Anyone can see history"));
});
describe("dm", () => {
beforeEach(() => {
// Make the mocked room a DM
mocked(DMRoomMap.shared().getUserIdForRoomId).mockImplementation((roomId) => {
if (roomId === room.roomId) return "@user:example.com";
});
room.getMember = jest.fn((userId) => new RoomMember(room.roomId, userId));
room.getJoinedMembers = jest.fn().mockReturnValue([
{
userId: "@me:example.org",
name: "Member",
rawDisplayName: "Member",
roomId: room.roomId,
membership: KnownMembership.Join,
getAvatarUrl: () => "mxc://avatar.url/image.png",
getMxcAvatarUrl: () => "mxc://avatar.url/image.png",
},
{
userId: "@bob:example.org",
name: "Other Member",
rawDisplayName: "Other Member",
roomId: room.roomId,
membership: KnownMembership.Join,
getAvatarUrl: () => "mxc://avatar.url/image.png",
getMxcAvatarUrl: () => "mxc://avatar.url/image.png",
},
]);
});
afterEach(() => {
SdkConfig.reset();
});
it.each([
[ShieldUtils.E2EStatus.Verified, "Verified"],
[ShieldUtils.E2EStatus.Warning, "Untrusted"],
])("shows the %s icon", async (value: ShieldUtils.E2EStatus, expectedLabel: string) => {
jest.spyOn(ShieldUtils, "shieldStatusForRoom").mockResolvedValue(value);
render(<RoomHeader room={room} />, getWrapper());
await waitFor(() => expect(getByLabelText(document.body, expectedLabel)).toBeInTheDocument());
});
it("does not show the face pile for DMs", () => {
SdkConfig.put({
features: {
feature_notifications: false,
},
});
const { asFragment } = render(<RoomHeader room={room} />, getWrapper());
expect(asFragment()).toMatchSnapshot();
});
it("updates the icon when the encryption status changes", async () => {
// The room starts verified
jest.spyOn(ShieldUtils, "shieldStatusForRoom").mockResolvedValue(ShieldUtils.E2EStatus.Verified);
render(<RoomHeader room={room} />, getWrapper());
await waitFor(() => expect(getByLabelText(document.body, "Verified")).toBeInTheDocument());
// A new member joins, and the room becomes unverified
jest.spyOn(ShieldUtils, "shieldStatusForRoom").mockResolvedValue(ShieldUtils.E2EStatus.Warning);
act(() => {
room.emit(
RoomStateEvent.Members,
new MatrixEvent({
event_id: "$event_id",
type: EventType.RoomMember,
state_key: "@alice:example.org",
content: {
membership: "join",
},
room_id: ROOM_ID,
sender: "@alice:example.org",
}),
room.currentState,
new RoomMember(room.roomId, "@alice:example.org"),
);
});
await waitFor(() => expect(getByLabelText(document.body, "Untrusted")).toBeInTheDocument());
// The user becomes verified
jest.spyOn(ShieldUtils, "shieldStatusForRoom").mockResolvedValue(ShieldUtils.E2EStatus.Verified);
act(() => {
MatrixClientPeg.get()!.emit(
CryptoEvent.UserTrustStatusChanged,
"@alice:example.org",
new UserVerificationStatus(true, true, true, false),
);
});
await waitFor(() => expect(getByLabelText(document.body, "Verified")).toBeInTheDocument());
// An unverified device is added
jest.spyOn(ShieldUtils, "shieldStatusForRoom").mockResolvedValue(ShieldUtils.E2EStatus.Warning);
act(() => {
MatrixClientPeg.get()!.emit(CryptoEvent.DevicesUpdated, ["@alice:example.org"], false);
});
await waitFor(() => expect(getByLabelText(document.body, "Untrusted")).toBeInTheDocument());
});
});
it("renders additionalButtons", async () => {
const additionalButtons: ViewRoomOpts["buttons"] = [
{
icon: () => <>test-icon</>,
id: "test-id",
label: () => "test-label",
onClick: () => {},
},
];
render(<RoomHeader room={room} additionalButtons={additionalButtons} />, getWrapper());
expect(screen.getByRole("button", { name: "test-label" })).toBeInTheDocument();
});
it("calls onClick-callback on additionalButtons", () => {
const callback = jest.fn();
const additionalButtons: ViewRoomOpts["buttons"] = [
{
icon: () => <>test-icon</>,
id: "test-id",
label: () => "test-label",
onClick: callback,
},
];
render(<RoomHeader room={room} additionalButtons={additionalButtons} />, getWrapper());
const button = screen.getByRole("button", { name: "test-label" });
const event = createEvent.click(button);
event.stopPropagation = jest.fn();
fireEvent(button, event);
expect(callback).toHaveBeenCalled();
expect(event.stopPropagation).toHaveBeenCalled();
});
describe("ask to join disabled", () => {
it("does not render the RoomKnocksBar", () => {
render(<RoomHeader room={room} />, getWrapper());
expect(screen.queryByRole("heading", { name: "Asking to join" })).not.toBeInTheDocument();
});
});
describe("ask to join enabled", () => {
it("does render the RoomKnocksBar", () => {
SettingsStore.setValue("feature_ask_to_join", null, SettingLevel.DEVICE, true);
jest.spyOn(room, "canInvite").mockReturnValue(true);
jest.spyOn(room, "getJoinRule").mockReturnValue(JoinRule.Knock);
jest.spyOn(room, "getMembersWithMembership").mockReturnValue([new RoomMember(room.roomId, "@foo")]);
render(<RoomHeader room={room} />, getWrapper());
expect(screen.getByRole("heading", { name: "Asking to join" })).toBeInTheDocument();
});
});
it("should open room settings when clicking the room avatar", async () => {
const user = userEvent.setup();
render(<RoomHeader room={room} />, getWrapper());
const dispatcherSpy = jest.spyOn(dispatcher, "dispatch");
await user.click(getByLabelText(document.body, "Open room settings"));
expect(dispatcherSpy).toHaveBeenCalledWith(expect.objectContaining({ action: "open_room_settings" }));
});
});
/**
* Creates a mock Call object with stable participants to prevent React dependency errors
*/
function createMockCall(
roomId: string = "!1:example.org",
participantCount: number = 0,
callType: CallType = CallType.Video,
): Call {
const participants = new Map();
// Create mock participants with devices
for (let i = 0; i < participantCount; i++) {
const mockMember = {
userId: `@user-${i}:example.org`,
name: `Member ${i}`,
} as RoomMember;
const deviceSet = new Set([`device-${i}`]);
participants.set(mockMember, deviceSet);
}
return {
roomId,
participants,
widget: { id: "test-widget" },
connectionState: "disconnected",
callType,
on: jest.fn(),
off: jest.fn(),
emit: jest.fn(),
} as unknown as Call;
}
/**
*
* @param count the number of users to create
*/
function mockRoomMembers(room: Room, count: number) {
const members = Array(count)
.fill(0)
.map((_, index) => ({
userId: `@user-${index}:example.org`,
name: `Member ${index}`,
rawDisplayName: `Member ${index}`,
roomId: room.roomId,
membership: KnownMembership.Join,
getAvatarUrl: () => `mxc://avatar.url/user-${index}.png`,
getMxcAvatarUrl: () => `mxc://avatar.url/user-${index}.png`,
}));
room.currentState.setJoinedMemberCount(members.length);
room.getJoinedMembers = jest.fn().mockReturnValue(members);
}
@@ -0,0 +1,126 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 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 from "react";
import { type MockedObject } from "jest-mock";
import { Room } from "matrix-js-sdk/src/matrix";
import { fireEvent, render, screen, waitFor } from "jest-matrix-react";
import { VideoRoomChatButton } from "../../../../../../src/components/views/rooms/RoomHeader/VideoRoomChatButton";
import { SDKContext, SdkContextClass } from "../../../../../../src/contexts/SDKContext";
import type RightPanelStore from "../../../../../../src/stores/right-panel/RightPanelStore";
import { getMockClientWithEventEmitter, mockClientMethodsUser } from "../../../../../test-utils";
import { RoomNotificationState } from "../../../../../../src/stores/notifications/RoomNotificationState";
import { NotificationLevel } from "../../../../../../src/stores/notifications/NotificationLevel";
import { NotificationStateEvents } from "../../../../../../src/stores/notifications/NotificationState";
import { RightPanelPhases } from "../../../../../../src/stores/right-panel/RightPanelStorePhases";
describe("<VideoRoomChatButton />", () => {
const roomId = "!room:server.org";
let sdkContext!: SdkContextClass;
let rightPanelStore!: MockedObject<RightPanelStore>;
/**
* Create a room using mocked client
* And mock isElementVideoRoom
*/
const makeRoom = (isVideoRoom = true): Room => {
const room = new Room(roomId, sdkContext.client!, sdkContext.client!.getSafeUserId());
jest.spyOn(room, "isElementVideoRoom").mockReturnValue(isVideoRoom);
// stub
jest.spyOn(room, "getPendingEvents").mockReturnValue([]);
return room;
};
const mockRoomNotificationState = (room: Room, level: NotificationLevel): RoomNotificationState => {
const roomNotificationState = new RoomNotificationState(room, false);
// @ts-ignore ugly mocking
roomNotificationState._level = level;
jest.spyOn(sdkContext.roomNotificationStateStore, "getRoomState").mockReturnValue(roomNotificationState);
return roomNotificationState;
};
const getComponent = (room: Room) =>
render(<VideoRoomChatButton room={room} />, {
wrapper: ({ children }) => <SDKContext.Provider value={sdkContext}>{children}</SDKContext.Provider>,
});
beforeEach(() => {
const client = getMockClientWithEventEmitter({
...mockClientMethodsUser(),
});
rightPanelStore = {
showOrHidePhase: jest.fn(),
} as unknown as MockedObject<RightPanelStore>;
sdkContext = new SdkContextClass();
sdkContext.client = client;
jest.spyOn(sdkContext, "rightPanelStore", "get").mockReturnValue(rightPanelStore);
});
afterEach(() => {
jest.restoreAllMocks();
});
it("toggles timeline in right panel on click", () => {
const room = makeRoom();
getComponent(room);
fireEvent.click(screen.getByRole("button", { name: "Chat" }));
expect(sdkContext.rightPanelStore.showOrHidePhase).toHaveBeenCalledWith(RightPanelPhases.Timeline);
});
it("renders button with an unread marker when room is unread", () => {
const room = makeRoom();
mockRoomNotificationState(room, NotificationLevel.Activity);
getComponent(room);
// snapshot includes `data-indicator` attribute
expect(screen.getByRole("button", { name: "Chat" })).toMatchSnapshot();
expect(screen.getByRole("button", { name: "Chat" }).hasAttribute("data-indicator")).toBeTruthy();
});
it("adds unread marker when room notification state changes to unread", async () => {
const room = makeRoom();
// start in read state
const notificationState = mockRoomNotificationState(room, NotificationLevel.None);
getComponent(room);
// no unread marker
expect(screen.getByRole("button", { name: "Chat" }).hasAttribute("data-indicator")).toBeFalsy();
// @ts-ignore ugly mocking
notificationState._level = NotificationLevel.Highlight;
notificationState.emit(NotificationStateEvents.Update);
// unread marker
await waitFor(() =>
expect(screen.getByRole("button", { name: "Chat" }).hasAttribute("data-indicator")).toBeTruthy(),
);
});
it("clears unread marker when room notification state changes to read", async () => {
const room = makeRoom();
// start in unread state
const notificationState = mockRoomNotificationState(room, NotificationLevel.Highlight);
getComponent(room);
// unread marker
expect(screen.getByRole("button", { name: "Chat" }).hasAttribute("data-indicator")).toBeTruthy();
// @ts-ignore ugly mocking
notificationState._level = NotificationLevel.None;
notificationState.emit(NotificationStateEvents.Update);
// unread marker cleared
await waitFor(() =>
expect(screen.getByRole("button", { name: "Chat" }).hasAttribute("data-indicator")).toBeFalsy(),
);
});
});
@@ -0,0 +1,155 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`RoomHeader dm does not show the face pile for DMs 1`] = `
<DocumentFragment>
<header
class="_flex_4dswl_9 mx_RoomHeader light-panel"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x); --mx-flex-wrap: nowrap;"
>
<button
aria-label="Open room settings"
aria-live="off"
class="_avatar_zysgz_8 mx_BaseAvatar _avatar-imageless_zysgz_55"
data-color="3"
data-testid="avatar-img"
data-type="round"
role="button"
style="--cpd-avatar-size: 40px;"
tabindex="-1"
>
!
</button>
<button
aria-label="Room info"
class="mx_RoomHeader_infoWrapper"
tabindex="0"
>
<div
class="mx_RoomHeader_info _box-flex_1odfs_9"
style="--mx-box-flex: 1;"
>
<div
aria-level="1"
class="_typography_6v6n8_153 _font-body-lg-semibold_6v6n8_74 mx_RoomHeader_heading"
dir="auto"
role="heading"
>
<span
class="mx_RoomHeader_truncated mx_lineClamp"
>
!1:example.org
</span>
</div>
</div>
</button>
<button
aria-disabled="false"
aria-label="Video call"
class="_icon-button_1215g_8"
data-kind="primary"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
aria-labelledby="_r_13s_"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 4h10a2 2 0 0 1 2 2v4.286l3.35-2.871a1 1 0 0 1 1.65.76v7.65a1 1 0 0 1-1.65.76L18 13.715V18a2 2 0 0 1-2 2H6a4 4 0 0 1-4-4V8a4 4 0 0 1 4-4"
/>
</svg>
</div>
</button>
<button
aria-disabled="false"
aria-label="Voice call"
class="_icon-button_1215g_8"
data-kind="primary"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
aria-labelledby="_r_141_"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m20.958 16.374.039 3.527q0 .427-.33.756-.33.33-.756.33a16 16 0 0 1-6.57-1.105 16.2 16.2 0 0 1-5.563-3.663 16.1 16.1 0 0 1-3.653-5.573 16.3 16.3 0 0 1-1.115-6.56q0-.427.33-.757T4.095 3l3.528.039a1.07 1.07 0 0 1 1.085.93l.543 3.954q.039.271-.039.504a1.1 1.1 0 0 1-.271.426l-1.64 1.64q.505 1.008 1.154 1.909c.433.6 1.444 1.696 1.444 1.696s1.095 1.01 1.696 1.444q.9.65 1.909 1.153l1.64-1.64q.193-.193.426-.27t.504-.04l3.954.543q.406.059.668.359t.262.727"
/>
</svg>
</div>
</button>
<button
aria-label="Threads"
aria-labelledby="_r_146_"
class="_icon-button_1215g_8"
data-kind="primary"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
class=""
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4 3h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6l-2.293 2.293c-.63.63-1.707.184-1.707-.707V5a2 2 0 0 1 2-2m3 7h10q.424 0 .712-.287A.97.97 0 0 0 18 9a.97.97 0 0 0-.288-.713A.97.97 0 0 0 17 8H7a.97.97 0 0 0-.713.287A.97.97 0 0 0 6 9q0 .424.287.713Q6.576 10 7 10m0 4h6q.424 0 .713-.287A.97.97 0 0 0 14 13a.97.97 0 0 0-.287-.713A.97.97 0 0 0 13 12H7a.97.97 0 0 0-.713.287A.97.97 0 0 0 6 13q0 .424.287.713Q6.576 14 7 14"
/>
</svg>
</div>
</button>
<button
aria-label="Room info"
aria-labelledby="_r_14b_"
class="_icon-button_1215g_8"
data-kind="primary"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
class=""
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 17q.424 0 .713-.288A.97.97 0 0 0 13 16v-4a.97.97 0 0 0-.287-.713A.97.97 0 0 0 12 11a.97.97 0 0 0-.713.287A.97.97 0 0 0 11 12v4q0 .424.287.712.288.288.713.288m0-8q.424 0 .713-.287A.97.97 0 0 0 13 8a.97.97 0 0 0-.287-.713A.97.97 0 0 0 12 7a.97.97 0 0 0-.713.287A.97.97 0 0 0 11 8q0 .424.287.713Q11.576 9 12 9m0 13a9.7 9.7 0 0 1-3.9-.788 10.1 10.1 0 0 1-3.175-2.137q-1.35-1.35-2.137-3.175A9.7 9.7 0 0 1 2 12q0-2.075.788-3.9a10.1 10.1 0 0 1 2.137-3.175q1.35-1.35 3.175-2.137A9.7 9.7 0 0 1 12 2q2.075 0 3.9.788a10.1 10.1 0 0 1 3.175 2.137q1.35 1.35 2.137 3.175A9.7 9.7 0 0 1 22 12a9.7 9.7 0 0 1-.788 3.9 10.1 10.1 0 0 1-2.137 3.175q-1.35 1.35-3.175 2.137A9.7 9.7 0 0 1 12 22"
/>
</svg>
</div>
</button>
</header>
</DocumentFragment>
`;
@@ -0,0 +1,33 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`<VideoRoomChatButton /> renders button with an unread marker when room is unread 1`] = `
<button
aria-label="Chat"
aria-labelledby="_r_6_"
class="_icon-button_1215g_8"
data-indicator="default"
data-kind="primary"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
>
<div
class="_indicator-icon_147l5_17"
data-indicator="default"
style="--cpd-icon-button-size: 100%;"
>
<svg
class=""
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2.95 16.3 1.5 21.25a.94.94 0 0 0 .25 1 .94.94 0 0 0 1 .25l4.95-1.45a10.2 10.2 0 0 0 2.1.712Q10.875 22 12 22a9.7 9.7 0 0 0 3.9-.788 10.1 10.1 0 0 0 3.175-2.137q1.35-1.35 2.137-3.175A9.7 9.7 0 0 0 22 12a9.7 9.7 0 0 0-.788-3.9 10.1 10.1 0 0 0-2.137-3.175q-1.35-1.35-3.175-2.137A9.7 9.7 0 0 0 12 2a9.7 9.7 0 0 0-3.9.788 10.1 10.1 0 0 0-3.175 2.137Q3.575 6.275 2.788 8.1A9.7 9.7 0 0 0 2 12q0 1.125.238 2.2.237 1.076.712 2.1"
/>
</svg>
</div>
</button>
`;