diff --git a/playwright/e2e/left-panel/room-list-view/room-list-header.spec.ts b/playwright/e2e/left-panel/room-list-view/room-list-header.spec.ts new file mode 100644 index 00000000000..bda87b04621 --- /dev/null +++ b/playwright/e2e/left-panel/room-list-view/room-list-header.spec.ts @@ -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(); + }); +}); diff --git a/playwright/snapshots/left-panel/room-list-view/room-list-header.spec.ts/room-list-header-compose-menu-linux.png b/playwright/snapshots/left-panel/room-list-view/room-list-header.spec.ts/room-list-header-compose-menu-linux.png new file mode 100644 index 00000000000..8f53c47d53c Binary files /dev/null and b/playwright/snapshots/left-panel/room-list-view/room-list-header.spec.ts/room-list-header-compose-menu-linux.png differ diff --git a/playwright/snapshots/left-panel/room-list-view/room-list-header.spec.ts/room-list-header-linux.png b/playwright/snapshots/left-panel/room-list-view/room-list-header.spec.ts/room-list-header-linux.png new file mode 100644 index 00000000000..c139730915a Binary files /dev/null and b/playwright/snapshots/left-panel/room-list-view/room-list-header.spec.ts/room-list-header-linux.png differ diff --git a/playwright/snapshots/left-panel/room-list-view/room-list-view.spec.ts/room-list-view-linux.png b/playwright/snapshots/left-panel/room-list-view/room-list-view.spec.ts/room-list-view-linux.png index f114eff64c9..b1c7960e3b2 100644 Binary files a/playwright/snapshots/left-panel/room-list-view/room-list-view.spec.ts/room-list-view-linux.png and b/playwright/snapshots/left-panel/room-list-view/room-list-view.spec.ts/room-list-view-linux.png differ diff --git a/res/css/_components.pcss b/res/css/_components.pcss index a6adf430d9c..8cab127632a 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -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"; diff --git a/res/css/views/rooms/RoomListView/_RoomListHeaderView.pcss b/res/css/views/rooms/RoomListView/_RoomListHeaderView.pcss new file mode 100644 index 00000000000..0b805710627 --- /dev/null +++ b/res/css/views/rooms/RoomListView/_RoomListHeaderView.pcss @@ -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); + } +} diff --git a/src/components/viewmodels/roomlist/RoomListHeaderViewModel.tsx b/src/components/viewmodels/roomlist/RoomListHeaderViewModel.tsx new file mode 100644 index 00000000000..3284933a9b8 --- /dev/null +++ b/src/components/viewmodels/roomlist/RoomListHeaderViewModel.tsx @@ -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, + }; +} diff --git a/src/components/views/rooms/RoomListView/RoomListHeaderView.tsx b/src/components/views/rooms/RoomListView/RoomListHeaderView.tsx new file mode 100644 index 00000000000..478b9b7e704 --- /dev/null +++ b/src/components/views/rooms/RoomListView/RoomListHeaderView.tsx @@ -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 ( + +

{vm.title}

+ {vm.displayComposeMenu && } +
+ ); +} + +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 ( + + + + } + > + + {vm.canCreateRoom && } + {vm.canCreateVideoRoom && ( + + )} + + ); +} diff --git a/src/components/views/rooms/RoomListView/RoomListView.tsx b/src/components/views/rooms/RoomListView/RoomListView.tsx index f733078a2cf..2aa11269ffb 100644 --- a/src/components/views/rooms/RoomListView/RoomListView.tsx +++ b/src/components/views/rooms/RoomListView/RoomListView.tsx @@ -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 = ({ activeSpace }) => { const displayRoomSearch = shouldShowComponent(UIComponent.FilterContainer); return ( -
+
{displayRoomSearch && } -
+ + ); }; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 2428efc72fa..1b8a504d5ec 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -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", diff --git a/test/unit-tests/components/viewmodels/roomlist/RoomListHeaderViewModel-test.tsx b/test/unit-tests/components/viewmodels/roomlist/RoomListHeaderViewModel-test.tsx new file mode 100644 index 00000000000..95a594e3b65 --- /dev/null +++ b/test/unit-tests/components/viewmodels/roomlist/RoomListHeaderViewModel-test.tsx @@ -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 }); + }); +}); diff --git a/test/unit-tests/components/views/rooms/RoomListView/RoomListHeaderView-test.tsx b/test/unit-tests/components/views/rooms/RoomListView/RoomListHeaderView-test.tsx new file mode 100644 index 00000000000..fa81db04b0d --- /dev/null +++ b/test/unit-tests/components/views/rooms/RoomListView/RoomListHeaderView-test.tsx @@ -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("", () => { + 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(); + 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(); + 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(); + 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(); + 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(); + }); +}); diff --git a/test/unit-tests/components/views/rooms/RoomListView/__snapshots__/RoomListHeaderView-test.tsx.snap b/test/unit-tests/components/views/rooms/RoomListView/__snapshots__/RoomListHeaderView-test.tsx.snap new file mode 100644 index 00000000000..02b806f456d --- /dev/null +++ b/test/unit-tests/components/views/rooms/RoomListView/__snapshots__/RoomListHeaderView-test.tsx.snap @@ -0,0 +1,71 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` should display the compose menu 1`] = ` + +
+

+ title +

+ +
+
+`; + +exports[` should not display the compose menu 1`] = ` + +
+

+ title +

+
+
+`; diff --git a/test/unit-tests/components/views/rooms/RoomListView/__snapshots__/RoomListView-test.tsx.snap b/test/unit-tests/components/views/rooms/RoomListView/__snapshots__/RoomListView-test.tsx.snap index 4ddc9ac5ece..54336c96e2f 100644 --- a/test/unit-tests/components/views/rooms/RoomListView/__snapshots__/RoomListView-test.tsx.snap +++ b/test/unit-tests/components/views/rooms/RoomListView/__snapshots__/RoomListView-test.tsx.snap @@ -2,16 +2,27 @@ exports[` should not render the RoomListSearch component when UIComponent.FilterContainer is at false 1`] = ` -
+ > +
+

+ Home +

+
+ `; exports[` should render the RoomListSearch component when UIComponent.FilterContainer is at true 1`] = ` -
@@ -71,6 +82,56 @@ exports[` should render the RoomListSearch component when UIComp
-
+
+

+ Home +

+ +
+
`;