First step to add header to new room list (#29320)

* feat: create new header

* test: add tests to view model

* test: add tests to view

* feat: add header to new room list

* test(e2e): update RoomListView snapshot

* test(e2e): add tests for room list header

* refactor: minor code improvement
This commit is contained in:
Florian Duros
2025-02-20 16:40:19 +01:00
committed by GitHub
parent e8954f08ce
commit fb57924350
14 changed files with 614 additions and 6 deletions

View File

@@ -0,0 +1,58 @@
/*
* 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 { test, expect } from "../../../element-web-test";
import type { Page } from "@playwright/test";
test.describe("Header section of the room list", () => {
test.use({
labsFlags: ["feature_new_room_list"],
});
/**
* Get the header section of the room list
* @param page
*/
function getHeaderSection(page: Page) {
return page.getByTestId("room-list-header");
}
test.beforeEach(async ({ page, app, user }) => {
// The notification toast is displayed above the search section
await app.closeNotificationToast();
});
test("should render the header section", { tag: "@screenshot" }, async ({ page, app, user }) => {
const roomListHeader = getHeaderSection(page);
await expect(roomListHeader).toMatchScreenshot("room-list-header.png");
const composeMenu = roomListHeader.getByRole("button", { name: "Add" });
await composeMenu.click();
await expect(page.getByRole("menu")).toMatchScreenshot("room-list-header-compose-menu.png");
// New message should open the direct messages dialog
await page.getByRole("menuitem", { name: "New message" }).click();
await expect(page.getByRole("heading", { name: "Direct Messages" })).toBeVisible();
await app.closeDialog();
// New room should open the room creation dialog
await composeMenu.click();
await page.getByRole("menuitem", { name: "New room" }).click();
await expect(page.getByRole("heading", { name: "Create a private room" })).toBeVisible();
await app.closeDialog();
});
test("should render the header section for a space", async ({ page, app, user }) => {
await app.client.createSpace({ name: "MySpace" });
await page.getByRole("button", { name: "MySpace" }).click();
const roomListHeader = getHeaderSection(page);
await expect(roomListHeader.getByRole("heading", { name: "MySpace" })).toBeVisible();
await expect(roomListHeader.getByRole("button", { name: "Add" })).not.toBeVisible();
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

View File

@@ -269,6 +269,7 @@
@import "./views/right_panel/_VerificationPanel.pcss";
@import "./views/right_panel/_WidgetCard.pcss";
@import "./views/room_settings/_AliasSettings.pcss";
@import "./views/rooms/RoomListView/_RoomListHeaderView.pcss";
@import "./views/rooms/RoomListView/_RoomListSearch.pcss";
@import "./views/rooms/RoomListView/_RoomListView.pcss";
@import "./views/rooms/_AppsDrawer.pcss";

View File

@@ -0,0 +1,20 @@
/*
* 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.
*/
.mx_RoomListHeaderView {
height: 60px;
padding: 0 var(--cpd-space-3x);
h1 {
all: unset;
font: var(--cpd-font-body-lg-semibold);
}
button {
color: var(--cpd-color-icon-secondary);
}
}

View File

@@ -0,0 +1,128 @@
/*
* 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 { useCallback } from "react";
import { type Room, RoomEvent, RoomType } from "matrix-js-sdk/src/matrix";
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents";
import { UIComponent } from "../../../settings/UIFeature";
import { useFeatureEnabled } from "../../../hooks/useSettings";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import PosthogTrackers from "../../../PosthogTrackers";
import { Action } from "../../../dispatcher/actions";
import { useEventEmitterState, useTypedEventEmitterState } from "../../../hooks/useEventEmitter";
import {
getMetaSpaceName,
type MetaSpace,
type SpaceKey,
UPDATE_HOME_BEHAVIOUR,
UPDATE_SELECTED_SPACE,
} from "../../../stores/spaces";
import SpaceStore from "../../../stores/spaces/SpaceStore";
/**
* Hook to get the active space and its title.
*/
function useSpace(): { activeSpace: Room | null; title: string } {
const [spaceKey, activeSpace] = useEventEmitterState<[SpaceKey, Room | null]>(
SpaceStore.instance,
UPDATE_SELECTED_SPACE,
() => [SpaceStore.instance.activeSpace, SpaceStore.instance.activeSpaceRoom],
);
const spaceName = useTypedEventEmitterState(activeSpace ?? undefined, RoomEvent.Name, () => activeSpace?.name);
const allRoomsInHome = useEventEmitterState(
SpaceStore.instance,
UPDATE_HOME_BEHAVIOUR,
() => SpaceStore.instance.allRoomsInHome,
);
const title = spaceName ?? getMetaSpaceName(spaceKey as MetaSpace, allRoomsInHome);
return {
activeSpace,
title,
};
}
export interface RoomListHeaderViewState {
/**
* The title of the room list
*/
title: string;
/**
* Whether to display the compose menu
* True if the user can create rooms and is not in a Space
*/
displayComposeMenu: boolean;
/**
* Whether the user can create rooms
*/
canCreateRoom: boolean;
/**
* Whether the user can create video rooms
*/
canCreateVideoRoom: boolean;
/**
* Create a chat room
* @param e - The click event
*/
createChatRoom: (e: Event) => void;
/**
* Create a room
* @param e - The click event
*/
createRoom: (e: Event) => void;
/**
* Create a video room
*/
createVideoRoom: () => void;
}
/**
* View model for the RoomListHeader.
* The actions don't work when called in a space yet.
*/
export function useRoomListHeaderViewModel(): RoomListHeaderViewState {
const { activeSpace, title } = useSpace();
const canCreateRoom = shouldShowComponent(UIComponent.CreateRooms);
const canCreateVideoRoom = useFeatureEnabled("feature_video_rooms");
// Temporary: don't display the compose menu when in a Space
const displayComposeMenu = canCreateRoom && !activeSpace;
/* Actions */
const createChatRoom = useCallback((e: Event) => {
defaultDispatcher.fire(Action.CreateChat);
PosthogTrackers.trackInteraction("WebRoomListHeaderPlusMenuCreateChatItem", e);
}, []);
const createRoom = useCallback((e: Event) => {
defaultDispatcher.fire(Action.CreateRoom);
PosthogTrackers.trackInteraction("WebRoomListHeaderPlusMenuCreateRoomItem", e);
}, []);
const elementCallVideoRoomsEnabled = useFeatureEnabled("feature_element_call_video_rooms");
const createVideoRoom = useCallback(
() =>
defaultDispatcher.dispatch({
action: Action.CreateRoom,
type: elementCallVideoRoomsEnabled ? RoomType.UnstableCall : RoomType.ElementVideo,
}),
[elementCallVideoRoomsEnabled],
);
return {
title,
displayComposeMenu,
canCreateRoom,
canCreateVideoRoom,
createChatRoom,
createRoom,
createVideoRoom,
};
}

View File

@@ -0,0 +1,77 @@
/*
* 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, { type JSX, useState } from "react";
import { IconButton, Menu, MenuItem } from "@vector-im/compound-web";
import ComposeIcon from "@vector-im/compound-design-tokens/assets/web/icons/compose";
import UserAddIcon from "@vector-im/compound-design-tokens/assets/web/icons/user-add";
import RoomIcon from "@vector-im/compound-design-tokens/assets/web/icons/room";
import VideoCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/video-call";
import { _t } from "../../../../languageHandler";
import { Flex } from "../../../utils/Flex";
import {
type RoomListHeaderViewState,
useRoomListHeaderViewModel,
} from "../../../viewmodels/roomlist/RoomListHeaderViewModel";
/**
* The header view for the room list
* The space name is displayed and a compose menu is shown if the user can create rooms
*/
export function RoomListHeaderView(): JSX.Element {
const vm = useRoomListHeaderViewModel();
return (
<Flex
as="header"
className="mx_RoomListHeaderView"
aria-label={_t("room|context_menu|title")}
justify="space-between"
align="center"
data-testid="room-list-header"
>
<h1>{vm.title}</h1>
{vm.displayComposeMenu && <ComposeMenu vm={vm} />}
</Flex>
);
}
interface ComposeMenuProps {
/**
* The view model for the room list header
*/
vm: RoomListHeaderViewState;
}
/**
* The compose menu for the room list header
*/
function ComposeMenu({ vm }: ComposeMenuProps): JSX.Element {
const [open, setOpen] = useState(false);
return (
<Menu
open={open}
onOpenChange={setOpen}
showTitle={false}
title={_t("action|open_menu")}
side="right"
align="start"
trigger={
<IconButton aria-label={_t("action|add")}>
<ComposeIcon />
</IconButton>
}
>
<MenuItem Icon={UserAddIcon} label={_t("action|new_message")} onSelect={vm.createChatRoom} />
{vm.canCreateRoom && <MenuItem Icon={RoomIcon} label={_t("action|new_room")} onSelect={vm.createRoom} />}
{vm.canCreateVideoRoom && (
<MenuItem Icon={VideoCallIcon} label={_t("action|new_video_room")} onSelect={vm.createVideoRoom} />
)}
</Menu>
);
}

View File

@@ -10,6 +10,7 @@ import React from "react";
import { shouldShowComponent } from "../../../../customisations/helpers/UIComponents";
import { UIComponent } from "../../../../settings/UIFeature";
import { RoomListSearch } from "./RoomListSearch";
import { RoomListHeaderView } from "./RoomListHeaderView";
type RoomListViewProps = {
/**
@@ -26,8 +27,9 @@ export const RoomListView: React.FC<RoomListViewProps> = ({ activeSpace }) => {
const displayRoomSearch = shouldShowComponent(UIComponent.FilterContainer);
return (
<div className="mx_RoomListView" data-testid="room-list-view">
<section className="mx_RoomListView" data-testid="room-list-view">
{displayRoomSearch && <RoomListSearch activeSpace={activeSpace} />}
</div>
<RoomListHeaderView />
</section>
);
};

View File

@@ -80,12 +80,14 @@
"maximise": "Maximise",
"mention": "Mention",
"minimise": "Minimise",
"new_message": "New message",
"new_room": "New room",
"new_video_room": "New video room",
"next": "Next",
"no": "No",
"ok": "OK",
"open": "Open",
"open_menu": "Open menu",
"pause": "Pause",
"pin": "Pin",
"play": "Play",

View File

@@ -0,0 +1,101 @@
/*
* 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 } from "jest-matrix-react";
import { type MatrixClient, RoomType } from "matrix-js-sdk/src/matrix";
import { mocked } from "jest-mock";
import { useRoomListHeaderViewModel } from "../../../../../src/components/viewmodels/roomlist/RoomListHeaderViewModel";
import SpaceStore from "../../../../../src/stores/spaces/SpaceStore";
import { mkStubRoom, stubClient } from "../../../../test-utils";
import { shouldShowComponent } from "../../../../../src/customisations/helpers/UIComponents";
import SettingsStore from "../../../../../src/settings/SettingsStore";
import defaultDispatcher from "../../../../../src/dispatcher/dispatcher";
import { Action } from "../../../../../src/dispatcher/actions";
jest.mock("../../../../../src/customisations/helpers/UIComponents", () => ({
shouldShowComponent: jest.fn(),
}));
describe("useRoomListHeaderViewModel", () => {
let matrixClient: MatrixClient;
beforeEach(() => {
matrixClient = stubClient();
});
afterEach(() => {
jest.resetAllMocks();
});
describe("title", () => {
it("should return Home as title", () => {
const { result } = renderHook(() => useRoomListHeaderViewModel());
expect(result.current.title).toStrictEqual("Home");
});
it("should return the current space name as title", () => {
const room = mkStubRoom("spaceId", "spaceName", matrixClient);
jest.spyOn(SpaceStore.instance, "activeSpaceRoom", "get").mockReturnValue(room);
const { result } = renderHook(() => useRoomListHeaderViewModel());
expect(result.current.title).toStrictEqual("spaceName");
});
});
it("should be displayComposeMenu=true and canCreateRoom=true if the user can creates room", () => {
mocked(shouldShowComponent).mockReturnValue(false);
const { result, rerender } = renderHook(() => useRoomListHeaderViewModel());
expect(result.current.displayComposeMenu).toBe(false);
expect(result.current.canCreateRoom).toBe(false);
mocked(shouldShowComponent).mockReturnValue(true);
rerender();
expect(result.current.displayComposeMenu).toBe(true);
expect(result.current.canCreateRoom).toBe(true);
});
it("should be canCreateVideoRoom=true if feature_video_rooms is enabled", () => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue(true);
const { result } = renderHook(() => useRoomListHeaderViewModel());
expect(result.current.canCreateVideoRoom).toBe(true);
});
it("should fire Action.CreateChat when createChatRoom is called", () => {
const spy = jest.spyOn(defaultDispatcher, "fire");
const { result } = renderHook(() => useRoomListHeaderViewModel());
result.current.createChatRoom(new Event("click"));
expect(spy).toHaveBeenCalledWith(Action.CreateChat);
});
it("should fire Action.CreateRoom when createRoom is called", () => {
const spy = jest.spyOn(defaultDispatcher, "fire");
const { result } = renderHook(() => useRoomListHeaderViewModel());
result.current.createRoom(new Event("click"));
expect(spy).toHaveBeenCalledWith(Action.CreateRoom);
});
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 } = renderHook(() => useRoomListHeaderViewModel());
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 } = renderHook(() => useRoomListHeaderViewModel());
result.current.createVideoRoom();
expect(spy).toHaveBeenCalledWith({ action: Action.CreateRoom, type: RoomType.ElementVideo });
});
});

View File

@@ -0,0 +1,87 @@
/*
* 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/RoomListView/RoomListHeaderView";
jest.mock("../../../../../../src/components/viewmodels/roomlist/RoomListHeaderViewModel", () => ({
useRoomListHeaderViewModel: jest.fn(),
}));
describe("<RoomListHeaderView />", () => {
const defaultValue: RoomListHeaderViewState = {
title: "title",
displayComposeMenu: true,
canCreateRoom: true,
canCreateVideoRoom: true,
createRoom: jest.fn(),
createVideoRoom: jest.fn(),
createChatRoom: jest.fn(),
};
afterEach(() => {
jest.resetAllMocks();
});
it("should display the compose menu", () => {
mocked(useRoomListHeaderViewModel).mockReturnValue(defaultValue);
const { asFragment } = render(<RoomListHeaderView />);
expect(screen.queryByRole("button", { name: "Add" })).toBeInTheDocument();
expect(asFragment()).toMatchSnapshot();
});
it("should not display the compose menu", () => {
mocked(useRoomListHeaderViewModel).mockReturnValue({ ...defaultValue, displayComposeMenu: false });
const { asFragment } = render(<RoomListHeaderView />);
expect(screen.queryByRole("button", { name: "Add" })).toBeNull();
expect(asFragment()).toMatchSnapshot();
});
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: "Add" });
await user.click(openMenu);
await user.click(screen.getByRole("menuitem", { name: "New message" }));
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: "Add" }));
expect(screen.queryByRole("menuitem", { name: "New room" })).toBeNull();
expect(screen.queryByRole("menuitem", { name: "New video room" })).toBeNull();
});
});

View File

@@ -0,0 +1,71 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<RoomListHeaderView /> should display the compose menu 1`] = `
<DocumentFragment>
<header
aria-label="Room options"
class="mx_Flex 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;"
>
<h1>
title
</h1>
<button
aria-disabled="false"
aria-expanded="false"
aria-haspopup="menu"
aria-label="Add"
class="_icon-button_bh2qc_17"
data-state="closed"
id="radix-:r0:"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
type="button"
>
<div
class="_indicator-icon_133tf_26"
style="--cpd-icon-button-size: 100%;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
clip-rule="evenodd"
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-2V5Z"
fill-rule="evenodd"
/>
<path
d="m8.023 14.711 1.331-3.992a1 1 0 0 1 1.656-.391l2.662 2.662a1 1 0 0 1-.391 1.655l-3.993 1.331a1 1 0 0 1-1.265-1.265Z"
/>
<path
clip-rule="evenodd"
d="M19.765 2.82a2 2 0 0 0-2.828 0L9.866 9.89c-.195.195-.341.42-.439.66a1.024 1.024 0 0 0-.073.168l-1.33 3.992a1 1 0 0 0 1.264 1.265l3.993-1.33c.059-.02.115-.045.167-.074.24-.097.466-.243.66-.438l7.072-7.071a2 2 0 0 0 0-2.829L19.765 2.82Zm-6.717 9.546 6.717-6.718-1.414-1.414-6.717 6.718 1.414 1.414Z"
fill-rule="evenodd"
/>
</svg>
</div>
</button>
</header>
</DocumentFragment>
`;
exports[`<RoomListHeaderView /> should not display the compose menu 1`] = `
<DocumentFragment>
<header
aria-label="Room options"
class="mx_Flex 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;"
>
<h1>
title
</h1>
</header>
</DocumentFragment>
`;

View File

@@ -2,16 +2,27 @@
exports[`<RoomListView /> should not render the RoomListSearch component when UIComponent.FilterContainer is at false 1`] = `
<DocumentFragment>
<div
<section
class="mx_RoomListView"
data-testid="room-list-view"
/>
>
<header
aria-label="Room options"
class="mx_Flex 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;"
>
<h1>
Home
</h1>
</header>
</section>
</DocumentFragment>
`;
exports[`<RoomListView /> should render the RoomListSearch component when UIComponent.FilterContainer is at true 1`] = `
<DocumentFragment>
<div
<section
class="mx_RoomListView"
data-testid="room-list-view"
>
@@ -71,6 +82,56 @@ exports[`<RoomListView /> should render the RoomListSearch component when UIComp
</svg>
</button>
</div>
</div>
<header
aria-label="Room options"
class="mx_Flex 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;"
>
<h1>
Home
</h1>
<button
aria-disabled="false"
aria-expanded="false"
aria-haspopup="menu"
aria-label="Add"
class="_icon-button_bh2qc_17"
data-state="closed"
id="radix-:r0:"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
type="button"
>
<div
class="_indicator-icon_133tf_26"
style="--cpd-icon-button-size: 100%;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
clip-rule="evenodd"
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-2V5Z"
fill-rule="evenodd"
/>
<path
d="m8.023 14.711 1.331-3.992a1 1 0 0 1 1.656-.391l2.662 2.662a1 1 0 0 1-.391 1.655l-3.993 1.331a1 1 0 0 1-1.265-1.265Z"
/>
<path
clip-rule="evenodd"
d="M19.765 2.82a2 2 0 0 0-2.828 0L9.866 9.89c-.195.195-.341.42-.439.66a1.024 1.024 0 0 0-.073.168l-1.33 3.992a1 1 0 0 0 1.264 1.265l3.993-1.33c.059-.02.115-.045.167-.074.24-.097.466-.243.66-.438l7.072-7.071a2 2 0 0 0 0-2.829L19.765 2.82Zm-6.717 9.546 6.717-6.718-1.414-1.414-6.717 6.718 1.414 1.414Z"
fill-rule="evenodd"
/>
</svg>
</div>
</button>
</header>
</section>
</DocumentFragment>
`;