RoomList: move room list header to shared components (#31675)

* chore: ignore jest-sonar.xml in gitconfig

* chore: add missing rtl types to shared component

* chore: add `symbol` to `Disposables.trackListener`

* feat: add room list header view to shared components

* fix: change `Space Settings` to `Space settings`

* feat: add room list header view model

* chore: remove old room list header

* chore: update i18n

* test: fix Room-test

* test: update playwright screenshot

* fix: remove extra margin at the top of Sort title in room options

* test: fix room status bar test

* fix: change for correct copyright

* refactor: use `Disposables#track` instead of manually disposing the listener

* refactor: avoid to recompute all the snapshot of `RoomListHeaderViewModel`

* wip

* fix: make header buttons the same size than figma

* test: update shared component snapshots

* test: update shared component screenshots

* test: update EW screenshots
This commit is contained in:
Florian Duros
2026-01-21 10:06:01 +01:00
committed by GitHub
parent d2d61a3203
commit 9edddce149
55 changed files with 1831 additions and 1688 deletions

View File

@@ -658,6 +658,9 @@ export function mkStubRoom(
getEvents: (): MatrixEvent[] => [],
getState: (): RoomState | undefined => state,
} as unknown as EventTimeline;
const eventEmitter = new EventEmitter();
return {
canInvite: jest.fn().mockReturnValue(false),
client,
@@ -728,9 +731,11 @@ export function mkStubRoom(
myUserId: client?.getUserId(),
name,
normalizedName: normalize(name || ""),
off: jest.fn(),
on: jest.fn(),
removeListener: jest.fn(),
on: eventEmitter.on.bind(eventEmitter),
once: eventEmitter.once.bind(eventEmitter),
off: eventEmitter.off.bind(eventEmitter),
removeListener: eventEmitter.removeListener.bind(eventEmitter),
emit: eventEmitter.emit.bind(eventEmitter),
roomId,
setBlacklistUnverifiedDevices: jest.fn(),
setUnreadNotificationCount: jest.fn(),

View File

@@ -1,243 +0,0 @@
/*
* Copyright 2025 New Vector Ltd.
*
* 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 { renderHook, act } from "jest-matrix-react";
import { JoinRule, type MatrixClient, type Room, RoomType } from "matrix-js-sdk/src/matrix";
import { mocked } from "jest-mock";
import { range } from "lodash";
import { useRoomListHeaderViewModel } from "../../../../../src/components/viewmodels/roomlist/RoomListHeaderViewModel";
import SpaceStore from "../../../../../src/stores/spaces/SpaceStore";
import { mkStubRoom, stubClient, withClientContextRenderOptions } from "../../../../test-utils";
import SettingsStore from "../../../../../src/settings/SettingsStore";
import defaultDispatcher from "../../../../../src/dispatcher/dispatcher";
import { Action } from "../../../../../src/dispatcher/actions";
import {
shouldShowSpaceSettings,
showCreateNewRoom,
showSpaceInvite,
showSpacePreferences,
showSpaceSettings,
} from "../../../../../src/utils/space";
import { createRoom, hasCreateRoomRights } from "../../../../../src/components/viewmodels/roomlist/utils";
import RoomListStoreV3 from "../../../../../src/stores/room-list-v3/RoomListStoreV3";
import { SortOption } from "../../../../../src/components/viewmodels/roomlist/useSorter";
import { SortingAlgorithm } from "../../../../../src/stores/room-list-v3/skip-list/sorters";
jest.mock("../../../../../src/components/viewmodels/roomlist/utils", () => ({
hasCreateRoomRights: jest.fn().mockReturnValue(false),
createRoom: jest.fn(),
}));
jest.mock("../../../../../src/utils/space", () => ({
shouldShowSpaceSettings: jest.fn(),
showCreateNewRoom: jest.fn(),
showSpaceInvite: jest.fn(),
showSpacePreferences: jest.fn(),
showSpaceSettings: jest.fn(),
}));
describe("useRoomListHeaderViewModel", () => {
let matrixClient: MatrixClient;
let space: Room;
beforeEach(() => {
matrixClient = stubClient();
space = mkStubRoom("spaceId", "spaceName", matrixClient);
jest.spyOn(SettingsStore, "getValue").mockImplementation((name: string): any => {
if (name === "RoomList.preferredSorting") return SortingAlgorithm.Recency;
});
});
afterEach(() => {
jest.resetAllMocks();
});
function render() {
return renderHook(() => useRoomListHeaderViewModel(), withClientContextRenderOptions(matrixClient));
}
describe("title", () => {
it("should return Home as title", () => {
const { result } = render();
expect(result.current.title).toStrictEqual("Home");
});
it("should return the current space name as title", () => {
jest.spyOn(SpaceStore.instance, "activeSpaceRoom", "get").mockReturnValue(space);
const { result } = render();
expect(result.current.title).toStrictEqual("spaceName");
});
});
it("should be displayComposeMenu=true and canCreateRoom=true if the user can creates room", () => {
mocked(hasCreateRoomRights).mockReturnValue(false);
const { result, rerender } = render();
expect(result.current.displayComposeMenu).toBe(false);
expect(result.current.canCreateRoom).toBe(false);
mocked(hasCreateRoomRights).mockReturnValue(true);
rerender();
expect(result.current.displayComposeMenu).toBe(true);
expect(result.current.canCreateRoom).toBe(true);
});
it("should be displaySpaceMenu=true if the user is in a space", () => {
jest.spyOn(SpaceStore.instance, "activeSpaceRoom", "get").mockReturnValue(space);
const { result } = render();
expect(result.current.displaySpaceMenu).toBe(true);
});
it("should be canInviteInSpace=true if the space join rule is public", () => {
jest.spyOn(SpaceStore.instance, "activeSpaceRoom", "get").mockReturnValue(space);
jest.spyOn(space, "getJoinRule").mockReturnValue(JoinRule.Public);
const { result } = render();
expect(result.current.displaySpaceMenu).toBe(true);
});
it("should be canInviteInSpace=true if the user has the right", () => {
jest.spyOn(SpaceStore.instance, "activeSpaceRoom", "get").mockReturnValue(space);
jest.spyOn(space, "canInvite").mockReturnValue(true);
const { result } = render();
expect(result.current.displaySpaceMenu).toBe(true);
});
it("should be canAccessSpaceSettings=true if the user has the right", () => {
jest.spyOn(SpaceStore.instance, "activeSpaceRoom", "get").mockReturnValue(space);
mocked(shouldShowSpaceSettings).mockReturnValue(true);
const { result } = render();
expect(result.current.canAccessSpaceSettings).toBe(true);
});
it("should be canCreateVideoRoom=true if feature_video_rooms is enabled and can create room", () => {
mocked(hasCreateRoomRights).mockReturnValue(true);
jest.spyOn(SettingsStore, "getValue").mockReturnValue(true);
const { result } = render();
expect(result.current.canCreateVideoRoom).toBe(true);
});
it("should fire Action.CreateChat when createChatRoom is called", () => {
const spy = jest.spyOn(defaultDispatcher, "fire");
const { result } = render();
result.current.createChatRoom(new Event("click"));
expect(spy).toHaveBeenCalledWith(Action.CreateChat);
});
it("should call createRoom from utils when createRoom is called", () => {
const { result } = render();
result.current.createRoom(new Event("click"));
expect(createRoom).toHaveBeenCalled();
});
it("should call createRoom from utils when createRoom is called in a space", () => {
jest.spyOn(SpaceStore.instance, "activeSpaceRoom", "get").mockReturnValue(space);
const { result } = render();
result.current.createRoom(new Event("click"));
expect(createRoom).toHaveBeenCalledWith(space);
});
it("should fire Action.CreateRoom with RoomType.UnstableCall when createVideoRoom is called and feature_element_call_video_rooms is enabled", () => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue(true);
const spy = jest.spyOn(defaultDispatcher, "dispatch");
const { result } = render();
result.current.createVideoRoom();
expect(spy).toHaveBeenCalledWith({ action: Action.CreateRoom, type: RoomType.UnstableCall });
});
it("should fire Action.CreateRoom with RoomType.ElementVideo when createVideoRoom is called and feature_element_call_video_rooms is disabled", () => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue(false);
const spy = jest.spyOn(defaultDispatcher, "dispatch");
const { result } = render();
result.current.createVideoRoom();
expect(spy).toHaveBeenCalledWith({ action: Action.CreateRoom, type: RoomType.ElementVideo });
});
it("should call showCreateNewRoom when createVideoRoom is called in a space", () => {
jest.spyOn(SpaceStore.instance, "activeSpaceRoom", "get").mockReturnValue(space);
const { result } = render();
result.current.createVideoRoom();
expect(showCreateNewRoom).toHaveBeenCalledWith(space, RoomType.ElementVideo);
});
it("should fire Action.ViewRoom when openSpaceHome is called in a space", () => {
jest.spyOn(SpaceStore.instance, "activeSpaceRoom", "get").mockReturnValue(space);
const spy = jest.spyOn(defaultDispatcher, "dispatch");
const { result } = render();
result.current.openSpaceHome();
expect(spy).toHaveBeenCalledWith({ action: Action.ViewRoom, room_id: space.roomId, metricsTrigger: undefined });
});
it("should call showSpaceInvite when inviteInSpace is called in a space", () => {
jest.spyOn(SpaceStore.instance, "activeSpaceRoom", "get").mockReturnValue(space);
const { result } = render();
result.current.inviteInSpace();
expect(showSpaceInvite).toHaveBeenCalledWith(space);
});
it("should call showSpacePreferences when openSpacePreferences is called in a space", () => {
jest.spyOn(SpaceStore.instance, "activeSpaceRoom", "get").mockReturnValue(space);
const { result } = render();
result.current.openSpacePreferences();
expect(showSpacePreferences).toHaveBeenCalledWith(space);
});
it("should call showSpaceSettings when openSpaceSettings is called in a space", () => {
jest.spyOn(SpaceStore.instance, "activeSpaceRoom", "get").mockReturnValue(space);
const { result } = render();
result.current.openSpaceSettings();
expect(showSpaceSettings).toHaveBeenCalledWith(space);
});
describe("Sorting", () => {
function mockAndCreateRooms() {
const rooms = range(10).map((i) => mkStubRoom(`foo${i}:matrix.org`, `Foo ${i}`, undefined));
const fn = jest
.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace")
.mockImplementation(() => ({ spaceId: "home", rooms: [...rooms] }));
return { rooms, fn };
}
it("should change sort order", () => {
mockAndCreateRooms();
const { result: vm } = render();
const resort = jest.spyOn(RoomListStoreV3.instance, "resort").mockImplementation(() => {});
// Change the sort option
act(() => {
vm.current.sort(SortOption.AToZ);
});
// Resort method in RLS must have been called
expect(resort).toHaveBeenCalledWith(SortingAlgorithm.Alphabetic);
});
it("should set activeSortOption based on value from settings", () => {
// Let's say that the user's preferred sorting is alphabetic
jest.spyOn(SettingsStore, "getValue").mockImplementation(() => SortingAlgorithm.Alphabetic);
mockAndCreateRooms();
const { result: vm } = render();
expect(vm.current.activeSortOption).toEqual(SortOption.AToZ);
});
});
});

View File

@@ -1,166 +0,0 @@
/*
* Copyright 2025 New Vector Ltd.
*
* 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 { mocked } from "jest-mock";
import { render, screen } from "jest-matrix-react";
import userEvent from "@testing-library/user-event";
import {
type RoomListHeaderViewState,
useRoomListHeaderViewModel,
} from "../../../../../../src/components/viewmodels/roomlist/RoomListHeaderViewModel";
import { RoomListHeaderView } from "../../../../../../src/components/views/rooms/RoomListPanel/RoomListHeaderView";
import { SortOption } from "../../../../../../src/components/viewmodels/roomlist/useSorter";
jest.mock("../../../../../../src/components/viewmodels/roomlist/RoomListHeaderViewModel", () => ({
useRoomListHeaderViewModel: jest.fn(),
}));
describe("<RoomListHeaderView />", () => {
const defaultValue: RoomListHeaderViewState = {
title: "title",
displayComposeMenu: true,
displaySpaceMenu: true,
canCreateRoom: true,
canCreateVideoRoom: true,
canInviteInSpace: true,
canAccessSpaceSettings: true,
sort: jest.fn(),
activeSortOption: SortOption.Activity,
createRoom: jest.fn(),
createVideoRoom: jest.fn(),
createChatRoom: jest.fn(),
openSpaceHome: jest.fn(),
inviteInSpace: jest.fn(),
openSpacePreferences: jest.fn(),
openSpaceSettings: jest.fn(),
};
afterEach(() => {
jest.resetAllMocks();
});
it("should render 'room options' button", async () => {
mocked(useRoomListHeaderViewModel).mockReturnValue(defaultValue);
const { asFragment } = render(<RoomListHeaderView />);
expect(screen.getByRole("button", { name: "Room Options" })).toBeInTheDocument();
expect(asFragment()).toMatchSnapshot();
});
describe("compose menu", () => {
it("should display the compose menu", () => {
mocked(useRoomListHeaderViewModel).mockReturnValue(defaultValue);
const { asFragment } = render(<RoomListHeaderView />);
expect(screen.queryByRole("button", { name: "New conversation" })).toBeInTheDocument();
expect(asFragment()).toMatchSnapshot();
});
it("should not display the compose menu", async () => {
const user = userEvent.setup();
mocked(useRoomListHeaderViewModel).mockReturnValue({ ...defaultValue, displayComposeMenu: false });
const { asFragment } = render(<RoomListHeaderView />);
expect(screen.queryByRole("button", { name: "New conversation" })).toBeInTheDocument();
expect(asFragment()).toMatchSnapshot();
await user.click(screen.getByRole("button", { name: "New conversation" }));
expect(defaultValue.createChatRoom).toHaveBeenCalled();
});
it("should display all the buttons when the menu is opened", async () => {
const user = userEvent.setup();
mocked(useRoomListHeaderViewModel).mockReturnValue(defaultValue);
render(<RoomListHeaderView />);
const openMenu = screen.getByRole("button", { name: "New conversation" });
await user.click(openMenu);
await user.click(screen.getByRole("menuitem", { name: "Start chat" }));
expect(defaultValue.createChatRoom).toHaveBeenCalled();
await user.click(openMenu);
await user.click(screen.getByRole("menuitem", { name: "New room" }));
expect(defaultValue.createRoom).toHaveBeenCalled();
await user.click(openMenu);
await user.click(screen.getByRole("menuitem", { name: "New video room" }));
expect(defaultValue.createVideoRoom).toHaveBeenCalled();
});
it("should display only the new message button", async () => {
const user = userEvent.setup();
mocked(useRoomListHeaderViewModel).mockReturnValue({
...defaultValue,
canCreateRoom: false,
canCreateVideoRoom: false,
});
render(<RoomListHeaderView />);
await user.click(screen.getByRole("button", { name: "New conversation" }));
expect(screen.queryByRole("menuitem", { name: "New room" })).toBeNull();
expect(screen.queryByRole("menuitem", { name: "New video room" })).toBeNull();
});
});
describe("space menu", () => {
it("should display the space menu", () => {
mocked(useRoomListHeaderViewModel).mockReturnValue(defaultValue);
const { asFragment } = render(<RoomListHeaderView />);
expect(screen.queryByRole("button", { name: "Open space menu" })).toBeInTheDocument();
expect(asFragment()).toMatchSnapshot();
});
it("should not display the space menu", () => {
mocked(useRoomListHeaderViewModel).mockReturnValue({ ...defaultValue, displaySpaceMenu: false });
const { asFragment } = render(<RoomListHeaderView />);
expect(screen.queryByRole("button", { name: "Open space menu" })).toBeNull();
expect(asFragment()).toMatchSnapshot();
});
it("should display all the buttons when the space menu is opened", async () => {
const user = userEvent.setup();
mocked(useRoomListHeaderViewModel).mockReturnValue(defaultValue);
render(<RoomListHeaderView />);
const openMenu = screen.getByRole("button", { name: "Open space menu" });
await user.click(openMenu);
await user.click(screen.getByRole("menuitem", { name: "Space home" }));
expect(defaultValue.openSpaceHome).toHaveBeenCalled();
await user.click(openMenu);
await user.click(screen.getByRole("menuitem", { name: "Invite" }));
expect(defaultValue.inviteInSpace).toHaveBeenCalled();
await user.click(openMenu);
await user.click(screen.getByRole("menuitem", { name: "Preferences" }));
expect(defaultValue.openSpacePreferences).toHaveBeenCalled();
await user.click(openMenu);
await user.click(screen.getByRole("menuitem", { name: "Space Settings" }));
expect(defaultValue.openSpaceSettings).toHaveBeenCalled();
});
it("should display only the home and preference buttons", async () => {
const user = userEvent.setup();
mocked(useRoomListHeaderViewModel).mockReturnValue({
...defaultValue,
canInviteInSpace: false,
canAccessSpaceSettings: false,
});
render(<RoomListHeaderView />);
await user.click(screen.getByRole("button", { name: "Open space menu" }));
expect(screen.queryByRole("menuitem", { name: "Invite" })).toBeNull();
expect(screen.queryByRole("menuitem", { name: "Space Setting" })).toBeNull();
});
});
});

View File

@@ -1,94 +0,0 @@
/*
* Copyright 2025 New Vector Ltd.
*
* 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 { render, screen } from "jest-matrix-react";
import userEvent from "@testing-library/user-event";
import { RoomListOptionsMenu } from "../../../../../../src/components/views/rooms/RoomListPanel/RoomListOptionsMenu";
import { type RoomListHeaderViewState } from "../../../../../../src/components/viewmodels/roomlist/RoomListHeaderViewModel";
describe("<RoomListOptionsMenu />", () => {
it("should match snapshot", () => {
const vm = {
sort: jest.fn(),
} as unknown as RoomListHeaderViewState;
const { asFragment } = render(<RoomListOptionsMenu vm={vm} />);
expect(asFragment()).toMatchSnapshot();
});
it("should show A to Z selected if activeSortOption is Alphabetic", async () => {
const user = userEvent.setup();
const vm = {
sort: jest.fn(),
activeSortOption: "Alphabetic",
} as unknown as RoomListHeaderViewState;
render(<RoomListOptionsMenu vm={vm} />);
// Open the menu
const button = screen.getByRole("button", { name: "Room Options" });
await user.click(button);
expect(screen.getByRole("menuitemradio", { name: "A-Z" })).toBeChecked();
expect(screen.getByRole("menuitemradio", { name: "Activity" })).not.toBeChecked();
});
it("should show Activity selected if activeSortOption is Recency", async () => {
const user = userEvent.setup();
const vm = {
sort: jest.fn(),
activeSortOption: "Recency",
} as unknown as RoomListHeaderViewState;
render(<RoomListOptionsMenu vm={vm} />);
// Open the menu
const button = screen.getByRole("button", { name: "Room Options" });
await user.click(button);
expect(screen.getByRole("menuitemradio", { name: "A-Z" })).not.toBeChecked();
expect(screen.getByRole("menuitemradio", { name: "Activity" })).toBeChecked();
});
it("should sort A to Z", async () => {
const user = userEvent.setup();
const vm = {
sort: jest.fn(),
} as unknown as RoomListHeaderViewState;
render(<RoomListOptionsMenu vm={vm} />);
await user.click(screen.getByRole("button", { name: "Room Options" }));
await user.click(screen.getByRole("menuitemradio", { name: "A-Z" }));
expect(vm.sort).toHaveBeenCalledWith("Alphabetic");
});
it("should sort by activity", async () => {
const user = userEvent.setup();
const vm = {
sort: jest.fn(),
activeSortOption: "Alphabetic",
} as unknown as RoomListHeaderViewState;
render(<RoomListOptionsMenu vm={vm} />);
await user.click(screen.getByRole("button", { name: "Room Options" }));
await user.click(screen.getByRole("menuitemradio", { name: "Activity" }));
expect(vm.sort).toHaveBeenCalledWith("Recency");
});
});

View File

@@ -1,653 +0,0 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`<RoomListHeaderView /> compose menu should display the compose menu 1`] = `
<DocumentFragment>
<header
aria-label="Room options"
class="_flex_4dswl_9 mx_RoomListHeaderView"
data-testid="room-list-header"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: space-between; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<div
class="_flex_4dswl_9 mx_RoomListHeaderView_title"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
>
<h1
title="title"
>
title
</h1>
<button
aria-disabled="false"
aria-expanded="false"
aria-haspopup="menu"
aria-label="Open space menu"
class="_icon-button_1215g_8 mx_SpaceMenu_button"
data-kind="primary"
data-state="closed"
id="radix-_r_i_"
role="button"
style="--cpd-icon-button-size: 20px;"
tabindex="0"
type="button"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
color="var(--cpd-color-icon-secondary)"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 14.95q-.2 0-.375-.062a.9.9 0 0 1-.325-.213l-4.6-4.6a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l3.9 3.9 3.9-3.9a.95.95 0 0 1 .7-.275q.425 0 .7.275a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7l-4.6 4.6q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
/>
</svg>
</div>
</button>
</div>
<div
class="_flex_4dswl_9"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
>
<div
class="mx_RoomListHeaderView_ReleaseAnnouncementAnchor"
>
<button
aria-disabled="false"
aria-expanded="false"
aria-haspopup="menu"
aria-label="Room Options"
aria-labelledby="_r_m_"
class="_icon-button_1215g_8"
data-kind="primary"
data-state="closed"
id="radix-_r_k_"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
type="button"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
color="var(--cpd-color-icon-secondary)"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
/>
</svg>
</div>
</button>
</div>
<div
class="mx_RoomListHeaderView_ReleaseAnnouncementAnchor"
>
<button
aria-disabled="false"
aria-expanded="false"
aria-haspopup="menu"
aria-labelledby="_r_t_"
class="_icon-button_1215g_8"
data-kind="primary"
data-state="closed"
id="radix-_r_r_"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
type="button"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
aria-hidden="true"
color="var(--cpd-color-icon-secondary)"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
clip-rule="evenodd"
d="M16.937 2.82a2 2 0 0 1 2.828 0l1.415 1.414a2 2 0 0 1 0 2.829l-7.071 7.07c-.195.196-.42.342-.66.44a1 1 0 0 1-.168.072l-3.993 1.331a1 1 0 0 1-1.265-1.265l1.331-3.992q.03-.09.073-.168m10.338-4.903-6.717 6.718-1.414-1.414 6.717-6.718z"
fill-rule="evenodd"
/>
<path
d="M3 5a2 2 0 0 1 2-2h6a1 1 0 1 1 0 2H5v14h14v-6a1 1 0 1 1 2 0v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"
/>
</svg>
</div>
</button>
</div>
</div>
</header>
</DocumentFragment>
`;
exports[`<RoomListHeaderView /> compose menu should not display the compose menu 1`] = `
<DocumentFragment>
<header
aria-label="Room options"
class="_flex_4dswl_9 mx_RoomListHeaderView"
data-testid="room-list-header"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: space-between; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<div
class="_flex_4dswl_9 mx_RoomListHeaderView_title"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
>
<h1
title="title"
>
title
</h1>
<button
aria-disabled="false"
aria-expanded="false"
aria-haspopup="menu"
aria-label="Open space menu"
class="_icon-button_1215g_8 mx_SpaceMenu_button"
data-kind="primary"
data-state="closed"
id="radix-_r_14_"
role="button"
style="--cpd-icon-button-size: 20px;"
tabindex="0"
type="button"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
color="var(--cpd-color-icon-secondary)"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 14.95q-.2 0-.375-.062a.9.9 0 0 1-.325-.213l-4.6-4.6a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l3.9 3.9 3.9-3.9a.95.95 0 0 1 .7-.275q.425 0 .7.275a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7l-4.6 4.6q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
/>
</svg>
</div>
</button>
</div>
<div
class="_flex_4dswl_9"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
>
<div
class="mx_RoomListHeaderView_ReleaseAnnouncementAnchor"
>
<button
aria-disabled="false"
aria-expanded="false"
aria-haspopup="menu"
aria-label="Room Options"
aria-labelledby="_r_18_"
class="_icon-button_1215g_8"
data-kind="primary"
data-state="closed"
id="radix-_r_16_"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
type="button"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
color="var(--cpd-color-icon-secondary)"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
/>
</svg>
</div>
</button>
</div>
<div
class="mx_RoomListHeaderView_ReleaseAnnouncementAnchor"
>
<button
aria-labelledby="_r_1d_"
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-hidden="true"
color="var(--cpd-color-icon-secondary)"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
clip-rule="evenodd"
d="M16.937 2.82a2 2 0 0 1 2.828 0l1.415 1.414a2 2 0 0 1 0 2.829l-7.071 7.07c-.195.196-.42.342-.66.44a1 1 0 0 1-.168.072l-3.993 1.331a1 1 0 0 1-1.265-1.265l1.331-3.992q.03-.09.073-.168m10.338-4.903-6.717 6.718-1.414-1.414 6.717-6.718z"
fill-rule="evenodd"
/>
<path
d="M3 5a2 2 0 0 1 2-2h6a1 1 0 1 1 0 2H5v14h14v-6a1 1 0 1 1 2 0v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"
/>
</svg>
</div>
</button>
</div>
</div>
</header>
</DocumentFragment>
`;
exports[`<RoomListHeaderView /> should render 'room options' button 1`] = `
<DocumentFragment>
<header
aria-label="Room options"
class="_flex_4dswl_9 mx_RoomListHeaderView"
data-testid="room-list-header"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: space-between; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<div
class="_flex_4dswl_9 mx_RoomListHeaderView_title"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
>
<h1
title="title"
>
title
</h1>
<button
aria-disabled="false"
aria-expanded="false"
aria-haspopup="menu"
aria-label="Open space menu"
class="_icon-button_1215g_8 mx_SpaceMenu_button"
data-kind="primary"
data-state="closed"
id="radix-_r_0_"
role="button"
style="--cpd-icon-button-size: 20px;"
tabindex="0"
type="button"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
color="var(--cpd-color-icon-secondary)"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 14.95q-.2 0-.375-.062a.9.9 0 0 1-.325-.213l-4.6-4.6a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l3.9 3.9 3.9-3.9a.95.95 0 0 1 .7-.275q.425 0 .7.275a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7l-4.6 4.6q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
/>
</svg>
</div>
</button>
</div>
<div
class="_flex_4dswl_9"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
>
<div
class="mx_RoomListHeaderView_ReleaseAnnouncementAnchor"
>
<button
aria-disabled="false"
aria-expanded="false"
aria-haspopup="menu"
aria-label="Room Options"
aria-labelledby="_r_4_"
class="_icon-button_1215g_8"
data-kind="primary"
data-state="closed"
id="radix-_r_2_"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
type="button"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
color="var(--cpd-color-icon-secondary)"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
/>
</svg>
</div>
</button>
</div>
<div
class="mx_RoomListHeaderView_ReleaseAnnouncementAnchor"
>
<button
aria-disabled="false"
aria-expanded="false"
aria-haspopup="menu"
aria-labelledby="_r_b_"
class="_icon-button_1215g_8"
data-kind="primary"
data-state="closed"
id="radix-_r_9_"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
type="button"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
aria-hidden="true"
color="var(--cpd-color-icon-secondary)"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
clip-rule="evenodd"
d="M16.937 2.82a2 2 0 0 1 2.828 0l1.415 1.414a2 2 0 0 1 0 2.829l-7.071 7.07c-.195.196-.42.342-.66.44a1 1 0 0 1-.168.072l-3.993 1.331a1 1 0 0 1-1.265-1.265l1.331-3.992q.03-.09.073-.168m10.338-4.903-6.717 6.718-1.414-1.414 6.717-6.718z"
fill-rule="evenodd"
/>
<path
d="M3 5a2 2 0 0 1 2-2h6a1 1 0 1 1 0 2H5v14h14v-6a1 1 0 1 1 2 0v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"
/>
</svg>
</div>
</button>
</div>
</div>
</header>
</DocumentFragment>
`;
exports[`<RoomListHeaderView /> space menu should display the space menu 1`] = `
<DocumentFragment>
<header
aria-label="Room options"
class="_flex_4dswl_9 mx_RoomListHeaderView"
data-testid="room-list-header"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: space-between; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<div
class="_flex_4dswl_9 mx_RoomListHeaderView_title"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
>
<h1
title="title"
>
title
</h1>
<button
aria-disabled="false"
aria-expanded="false"
aria-haspopup="menu"
aria-label="Open space menu"
class="_icon-button_1215g_8 mx_SpaceMenu_button"
data-kind="primary"
data-state="closed"
id="radix-_r_36_"
role="button"
style="--cpd-icon-button-size: 20px;"
tabindex="0"
type="button"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
color="var(--cpd-color-icon-secondary)"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 14.95q-.2 0-.375-.062a.9.9 0 0 1-.325-.213l-4.6-4.6a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l3.9 3.9 3.9-3.9a.95.95 0 0 1 .7-.275q.425 0 .7.275a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7l-4.6 4.6q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
/>
</svg>
</div>
</button>
</div>
<div
class="_flex_4dswl_9"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
>
<div
class="mx_RoomListHeaderView_ReleaseAnnouncementAnchor"
>
<button
aria-disabled="false"
aria-expanded="false"
aria-haspopup="menu"
aria-label="Room Options"
aria-labelledby="_r_3a_"
class="_icon-button_1215g_8"
data-kind="primary"
data-state="closed"
id="radix-_r_38_"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
type="button"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
color="var(--cpd-color-icon-secondary)"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
/>
</svg>
</div>
</button>
</div>
<div
class="mx_RoomListHeaderView_ReleaseAnnouncementAnchor"
>
<button
aria-disabled="false"
aria-expanded="false"
aria-haspopup="menu"
aria-labelledby="_r_3h_"
class="_icon-button_1215g_8"
data-kind="primary"
data-state="closed"
id="radix-_r_3f_"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
type="button"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
aria-hidden="true"
color="var(--cpd-color-icon-secondary)"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
clip-rule="evenodd"
d="M16.937 2.82a2 2 0 0 1 2.828 0l1.415 1.414a2 2 0 0 1 0 2.829l-7.071 7.07c-.195.196-.42.342-.66.44a1 1 0 0 1-.168.072l-3.993 1.331a1 1 0 0 1-1.265-1.265l1.331-3.992q.03-.09.073-.168m10.338-4.903-6.717 6.718-1.414-1.414 6.717-6.718z"
fill-rule="evenodd"
/>
<path
d="M3 5a2 2 0 0 1 2-2h6a1 1 0 1 1 0 2H5v14h14v-6a1 1 0 1 1 2 0v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"
/>
</svg>
</div>
</button>
</div>
</div>
</header>
</DocumentFragment>
`;
exports[`<RoomListHeaderView /> space menu should not display the space menu 1`] = `
<DocumentFragment>
<header
aria-label="Room options"
class="_flex_4dswl_9 mx_RoomListHeaderView"
data-testid="room-list-header"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: space-between; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<div
class="_flex_4dswl_9 mx_RoomListHeaderView_title"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
>
<h1
title="title"
>
title
</h1>
</div>
<div
class="_flex_4dswl_9"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
>
<div
class="mx_RoomListHeaderView_ReleaseAnnouncementAnchor"
>
<button
aria-disabled="false"
aria-expanded="false"
aria-haspopup="menu"
aria-label="Room Options"
aria-labelledby="_r_3q_"
class="_icon-button_1215g_8"
data-kind="primary"
data-state="closed"
id="radix-_r_3o_"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
type="button"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
color="var(--cpd-color-icon-secondary)"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
/>
</svg>
</div>
</button>
</div>
<div
class="mx_RoomListHeaderView_ReleaseAnnouncementAnchor"
>
<button
aria-disabled="false"
aria-expanded="false"
aria-haspopup="menu"
aria-labelledby="_r_41_"
class="_icon-button_1215g_8"
data-kind="primary"
data-state="closed"
id="radix-_r_3v_"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
type="button"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
aria-hidden="true"
color="var(--cpd-color-icon-secondary)"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
clip-rule="evenodd"
d="M16.937 2.82a2 2 0 0 1 2.828 0l1.415 1.414a2 2 0 0 1 0 2.829l-7.071 7.07c-.195.196-.42.342-.66.44a1 1 0 0 1-.168.072l-3.993 1.331a1 1 0 0 1-1.265-1.265l1.331-3.992q.03-.09.073-.168m10.338-4.903-6.717 6.718-1.414-1.414 6.717-6.718z"
fill-rule="evenodd"
/>
<path
d="M3 5a2 2 0 0 1 2-2h6a1 1 0 1 1 0 2H5v14h14v-6a1 1 0 1 1 2 0v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"
/>
</svg>
</div>
</button>
</div>
</div>
</header>
</DocumentFragment>
`;

View File

@@ -1,39 +0,0 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`<RoomListOptionsMenu /> should match snapshot 1`] = `
<DocumentFragment>
<button
aria-disabled="false"
aria-expanded="false"
aria-haspopup="menu"
aria-label="Room Options"
aria-labelledby="_r_2_"
class="_icon-button_1215g_8"
data-kind="primary"
data-state="closed"
id="radix-_r_0_"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
type="button"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
color="var(--cpd-color-icon-secondary)"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
/>
</svg>
</div>
</button>
</DocumentFragment>
`;

View File

@@ -39,12 +39,14 @@ describe("Room", () => {
const room = new Room(sdkRoom);
const fn = jest.fn();
const onSpy = jest.spyOn(sdkRoom, "on");
const offSpy = jest.spyOn(sdkRoom, "off");
room.name.watch(fn);
expect(sdkRoom.on).toHaveBeenCalledTimes(1);
expect(onSpy).toHaveBeenCalledTimes(1);
room.name.unwatch(fn);
expect(sdkRoom.off).toHaveBeenCalledTimes(1);
expect(offSpy).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -0,0 +1,272 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* 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 { mocked } from "jest-mock";
import { JoinRule, type MatrixClient, type Room, RoomEvent, RoomType } from "matrix-js-sdk/src/matrix";
import { RoomListHeaderViewModel } from "../../../src/viewmodels/room-list/RoomListHeaderViewModel";
import { MetaSpace, UPDATE_HOME_BEHAVIOUR, UPDATE_SELECTED_SPACE } from "../../../src/stores/spaces";
import SpaceStore from "../../../src/stores/spaces/SpaceStore";
import defaultDispatcher from "../../../src/dispatcher/dispatcher";
import { Action } from "../../../src/dispatcher/actions";
import SettingsStore from "../../../src/settings/SettingsStore";
import { SortingAlgorithm } from "../../../src/stores/room-list-v3/skip-list/sorters";
import RoomListStoreV3 from "../../../src/stores/room-list-v3/RoomListStoreV3";
import {
shouldShowSpaceSettings,
showCreateNewRoom,
showSpaceInvite,
showSpacePreferences,
showSpaceSettings,
} from "../../../src/utils/space";
import { createRoom, hasCreateRoomRights } from "../../../src/components/viewmodels/roomlist/utils";
import { createTestClient, mkSpace } from "../../test-utils";
jest.mock("../../../src/PosthogTrackers", () => ({
trackInteraction: jest.fn(),
}));
jest.mock("../../../src/utils/space", () => ({
shouldShowSpaceSettings: jest.fn(),
showCreateNewRoom: jest.fn(),
showSpaceInvite: jest.fn(),
showSpacePreferences: jest.fn(),
showSpaceSettings: jest.fn(),
}));
jest.mock("../../../src/components/viewmodels/roomlist/utils", () => ({
createRoom: jest.fn(),
hasCreateRoomRights: jest.fn(),
}));
describe("RoomListHeaderViewModel", () => {
let matrixClient: MatrixClient;
let mockSpace: Room;
let vm: RoomListHeaderViewModel;
beforeEach(() => {
matrixClient = createTestClient();
mockSpace = mkSpace(matrixClient, "!space:server");
mocked(hasCreateRoomRights).mockReturnValue(true);
mocked(shouldShowSpaceSettings).mockReturnValue(true);
jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName: string) => {
if (settingName === "RoomList.preferredSorting") return SortingAlgorithm.Recency;
if (settingName === "feature_video_rooms") return true;
if (settingName === "feature_element_call_video_rooms") return true;
return false;
});
});
afterEach(() => {
jest.restoreAllMocks();
vm.dispose();
});
describe("snapshot", () => {
it("should compute snapshot for Home space", () => {
jest.spyOn(SpaceStore.instance, "activeSpace", "get").mockReturnValue(MetaSpace.Home);
jest.spyOn(SpaceStore.instance, "activeSpaceRoom", "get").mockReturnValue(null);
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
const snapshot = vm.getSnapshot();
expect(snapshot.title).toBe("Home");
expect(snapshot.displayComposeMenu).toBe(true);
expect(snapshot.displaySpaceMenu).toBe(false);
expect(snapshot.canCreateRoom).toBe(true);
expect(snapshot.canCreateVideoRoom).toBe(true);
expect(snapshot.activeSortOption).toBe("recent");
});
it("should compute snapshot for active space", () => {
jest.spyOn(SpaceStore.instance, "activeSpace", "get").mockReturnValue(mockSpace.roomId);
jest.spyOn(SpaceStore.instance, "activeSpaceRoom", "get").mockReturnValue(mockSpace);
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
const snapshot = vm.getSnapshot();
expect(snapshot.title).toBe(mockSpace.roomId);
});
it("should hide video room option when feature is disabled", () => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName: string) => {
if (settingName === "feature_video_rooms") return false;
return false;
});
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
expect(vm.getSnapshot().canCreateVideoRoom).toBe(false);
});
it("should show alphabetical sort option when RoomList.preferredSorting is Alphabetic", () => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName: string) => {
if (settingName === "RoomList.preferredSorting") return SortingAlgorithm.Alphabetic;
return false;
});
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
expect(vm.getSnapshot().activeSortOption).toBe("alphabetical");
});
it("should hide compose menu when user cannot create rooms", () => {
mocked(hasCreateRoomRights).mockReturnValue(false);
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
const snapshot = vm.getSnapshot();
expect(snapshot.displayComposeMenu).toBe(false);
expect(snapshot.canCreateRoom).toBe(false);
});
it("should show invite option when space is public", () => {
jest.spyOn(SpaceStore.instance, "activeSpace", "get").mockReturnValue(mockSpace.roomId);
jest.spyOn(SpaceStore.instance, "activeSpaceRoom", "get").mockReturnValue(mockSpace);
jest.spyOn(mockSpace, "getJoinRule").mockReturnValue(JoinRule.Public);
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
expect(vm.getSnapshot().canInviteInSpace).toBe(true);
});
it("should hide invite option when user cannot invite", () => {
mocked(mockSpace.canInvite).mockReturnValue(false);
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
expect(vm.getSnapshot().canInviteInSpace).toBe(false);
});
it("should hide space settings when user cannot access them", () => {
jest.spyOn(SpaceStore.instance, "activeSpace", "get").mockReturnValue(mockSpace.roomId);
mocked(shouldShowSpaceSettings).mockReturnValue(false);
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
expect(vm.getSnapshot().canAccessSpaceSettings).toBe(false);
});
});
describe("event listeners", () => {
it.each([UPDATE_SELECTED_SPACE, UPDATE_HOME_BEHAVIOUR])(
"should update snapshot when %s event is emitted",
(event) => {
jest.spyOn(SpaceStore.instance, "activeSpace", "get").mockReturnValue(MetaSpace.Home);
jest.spyOn(SpaceStore.instance, "activeSpaceRoom", "get").mockReturnValue(null);
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
jest.spyOn(SpaceStore.instance, "activeSpace", "get").mockReturnValue(mockSpace.roomId);
jest.spyOn(SpaceStore.instance, "activeSpaceRoom", "get").mockReturnValue(mockSpace);
SpaceStore.instance.emit(event);
expect(vm.getSnapshot().title).toBe(mockSpace.roomId);
},
);
it("should update snapshot when space name changes", () => {
jest.spyOn(SpaceStore.instance, "activeSpace", "get").mockReturnValue(mockSpace.roomId);
jest.spyOn(SpaceStore.instance, "activeSpaceRoom", "get").mockReturnValue(mockSpace);
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
mockSpace.name = "new name";
mockSpace.emit(RoomEvent.Name, mockSpace);
expect(vm.getSnapshot().title).toBe("new name");
});
});
describe("actions", () => {
beforeEach(() => {
jest.spyOn(SpaceStore.instance, "activeSpace", "get").mockReturnValue(mockSpace.roomId);
jest.spyOn(SpaceStore.instance, "activeSpaceRoom", "get").mockReturnValue(mockSpace);
});
it("should fire CreateChat action when createChatRoom is called", () => {
const fireSpy = jest.spyOn(defaultDispatcher, "fire");
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
vm.createChatRoom(new Event("click"));
expect(fireSpy).toHaveBeenCalledWith(Action.CreateChat);
});
it("should call createRoom with active space when in a space", () => {
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
vm.createRoom(new Event("click"));
expect(createRoom).toHaveBeenCalledWith(mockSpace);
});
it("should show create video room dialog for space when createVideoRoom is called", () => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName: string) => {
if (settingName === "feature_element_call_video_rooms") return false;
return false;
});
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
vm.createVideoRoom();
expect(showCreateNewRoom).toHaveBeenCalledWith(mockSpace, RoomType.ElementVideo);
});
it("should use UnstableCall type when element_call_video_rooms is enabled", () => {
jest.spyOn(SpaceStore.instance, "activeSpaceRoom", "get").mockReturnValue(null);
const dispatchSpy = jest.spyOn(defaultDispatcher, "dispatch");
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
vm.createVideoRoom();
expect(dispatchSpy).toHaveBeenCalledWith({
action: Action.CreateRoom,
type: RoomType.UnstableCall,
});
});
it("should dispatch ViewRoom action when openSpaceHome is called", () => {
const dispatchSpy = jest.spyOn(defaultDispatcher, "dispatch");
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
vm.openSpaceHome();
expect(dispatchSpy).toHaveBeenCalledWith({
action: Action.ViewRoom,
room_id: "!space:server",
metricsTrigger: undefined,
});
});
it("should show space invite dialog when inviteInSpace is called", () => {
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
vm.inviteInSpace();
expect(showSpaceInvite).toHaveBeenCalledWith(mockSpace);
});
it("should show space preferences dialog when openSpacePreferences is called", () => {
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
vm.openSpacePreferences();
expect(showSpacePreferences).toHaveBeenCalledWith(mockSpace);
});
it("should show space settings dialog when openSpaceSettings is called", () => {
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
vm.openSpaceSettings();
expect(showSpaceSettings).toHaveBeenCalledWith(mockSpace);
});
it.each([
["recent" as const, SortingAlgorithm.Recency],
["alphabetical" as const, SortingAlgorithm.Alphabetic],
])("should resort when sort is called with '%s'", (option, expectedAlgorithm) => {
const resortSpy = jest.spyOn(RoomListStoreV3.instance, "resort").mockImplementation(jest.fn());
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
vm.sort(option);
expect(resortSpy).toHaveBeenCalledWith(expectedAlgorithm);
});
});
});

View File

@@ -43,7 +43,7 @@ describe("RoomStatusBarViewModel", () => {
beforeEach(() => {
client = stubClient() as MockedObject<MatrixClient>;
room = mkRoom(client, "!example");
room.on.mockImplementationOnce((_event, fn) => {
jest.spyOn(room, "on").mockImplementationOnce((_event, fn) => {
roomEmitFn = fn as any;
return room;
});