* 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>
868 lines
35 KiB
TypeScript
868 lines
35 KiB
TypeScript
/*
|
|
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,
|
|
} 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, 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";
|
|
|
|
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 roomContext: RoomContextType;
|
|
|
|
function getWrapper(): RenderOptions {
|
|
return {
|
|
wrapper: ({ children }) => (
|
|
<MatrixClientContext.Provider value={MatrixClientPeg.safeGet()}>
|
|
<ScopedRoomContextProvider {...roomContext}>{children}</ScopedRoomContextProvider>
|
|
</MatrixClientContext.Provider>
|
|
),
|
|
};
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
stubClient();
|
|
room = new Room(ROOM_ID, MatrixClientPeg.get()!, "@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(() => {
|
|
SdkConfig.put({
|
|
features: {
|
|
feature_group_calls: true,
|
|
},
|
|
});
|
|
});
|
|
|
|
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());
|
|
expect(screen.getByRole("button", { name: "Ongoing call" })).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 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");
|
|
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();
|
|
});
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|
|
|
|
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): 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",
|
|
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);
|
|
}
|