diff --git a/playwright/e2e/left-panel/room-list-panel/room-list-panel.spec.ts b/playwright/e2e/left-panel/room-list-panel/room-list-panel.spec.ts index 53795773ed..2ffbc608c2 100644 --- a/playwright/e2e/left-panel/room-list-panel/room-list-panel.spec.ts +++ b/playwright/e2e/left-panel/room-list-panel/room-list-panel.spec.ts @@ -25,10 +25,17 @@ test.describe("Room list panel", () => { test.beforeEach(async ({ page, app, user }) => { // The notification toast is displayed above the search section await app.closeNotificationToast(); + + // Populate the room list + for (let i = 0; i < 20; i++) { + await app.client.createRoom({ name: `room${i}` }); + } }); test("should render the room list panel", { tag: "@screenshot" }, async ({ page, app, user }) => { const roomListView = getRoomListView(page); + // Wait for the last room to be visible + await expect(roomListView.getByRole("gridcell", { name: "Open room room19" })).toBeVisible(); await expect(roomListView).toMatchScreenshot("room-list-panel.png"); }); }); diff --git a/playwright/e2e/left-panel/room-list-panel/room-list.spec.ts b/playwright/e2e/left-panel/room-list-panel/room-list.spec.ts new file mode 100644 index 0000000000..ff06eda0aa --- /dev/null +++ b/playwright/e2e/left-panel/room-list-panel/room-list.spec.ts @@ -0,0 +1,50 @@ +/* + * 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 { type Page } from "@playwright/test"; + +import { test, expect } from "../../../element-web-test"; + +test.describe("Room list", () => { + test.use({ + labsFlags: ["feature_new_room_list"], + }); + + /** + * Get the room list + * @param page + */ + function getRoomList(page: Page) { + return page.getByTestId("room-list"); + } + + test.beforeEach(async ({ page, app, user }) => { + // The notification toast is displayed above the search section + await app.closeNotificationToast(); + for (let i = 0; i < 30; i++) { + await app.client.createRoom({ name: `room${i}` }); + } + }); + + test("should render the room list", { tag: "@screenshot" }, async ({ page, app, user }) => { + const roomListView = getRoomList(page); + await expect(roomListView.getByRole("gridcell", { name: "Open room room29" })).toBeVisible(); + await expect(roomListView).toMatchScreenshot("room-list.png"); + + await roomListView.hover(); + // Scroll to the end of the room list + await page.mouse.wheel(0, 1000); + await expect(roomListView.getByRole("gridcell", { name: "Open room room0" })).toBeVisible(); + await expect(roomListView).toMatchScreenshot("room-list-scrolled.png"); + }); + + test("should open the room when it is clicked", async ({ page, app, user }) => { + const roomListView = getRoomList(page); + await roomListView.getByRole("gridcell", { name: "Open room room29" }).click(); + await expect(page.getByRole("heading", { name: "room29", level: 1 })).toBeVisible(); + }); +}); diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list-panel.spec.ts/room-list-panel-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list-panel.spec.ts/room-list-panel-linux.png index d03dbf992b..1ebfde5ea7 100644 Binary files a/playwright/snapshots/left-panel/room-list-panel/room-list-panel.spec.ts/room-list-panel-linux.png and b/playwright/snapshots/left-panel/room-list-panel/room-list-panel.spec.ts/room-list-panel-linux.png differ diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-linux.png new file mode 100644 index 0000000000..f9275dd211 Binary files /dev/null and b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-linux.png differ diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-scrolled-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-scrolled-linux.png new file mode 100644 index 0000000000..79c9254f0a Binary files /dev/null and b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-scrolled-linux.png differ diff --git a/res/css/_components.pcss b/res/css/_components.pcss index cd260ca3aa..8501ed7bd1 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -269,6 +269,8 @@ @import "./views/right_panel/_VerificationPanel.pcss"; @import "./views/right_panel/_WidgetCard.pcss"; @import "./views/room_settings/_AliasSettings.pcss"; +@import "./views/rooms/RoomListPanel/_RoomList.pcss"; +@import "./views/rooms/RoomListPanel/_RoomListCell.pcss"; @import "./views/rooms/RoomListPanel/_RoomListHeaderView.pcss"; @import "./views/rooms/RoomListPanel/_RoomListPanel.pcss"; @import "./views/rooms/RoomListPanel/_RoomListSearch.pcss"; diff --git a/res/css/views/rooms/RoomListPanel/_RoomList.pcss b/res/css/views/rooms/RoomListPanel/_RoomList.pcss new file mode 100644 index 0000000000..2563c1b675 --- /dev/null +++ b/res/css/views/rooms/RoomListPanel/_RoomList.pcss @@ -0,0 +1,15 @@ +/* + * 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_RoomList { + height: 100%; + + .mx_RoomList_List { + /* Avoid when on hover, the background color to be on top of the right border */ + padding-right: 1px; + } +} diff --git a/res/css/views/rooms/RoomListPanel/_RoomListCell.pcss b/res/css/views/rooms/RoomListPanel/_RoomListCell.pcss new file mode 100644 index 0000000000..812145a73e --- /dev/null +++ b/res/css/views/rooms/RoomListPanel/_RoomListCell.pcss @@ -0,0 +1,44 @@ +/* + * 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. + */ + +/** + * The RoomCell has the following structure: + * button----------------------------------------| + * | <-12px-> container--------------------------| + * | | room avatar <-12px-> content-----| + * | | | room_name | + * | | | ----------| <-- border + * |---------------------------------------------| + */ +.mx_RoomListCell { + all: unset; + + &:hover { + background-color: var(--cpd-color-bg-action-secondary-hovered); + } + + .mx_RoomListCell_container { + padding-left: var(--cpd-space-3x); + font: var(--cpd-font-body-md-regular); + height: 100%; + + .mx_RoomListCell_content { + height: 100%; + flex: 1; + /* The border is only under the room name and the future hover menu */ + border-bottom: var(--cpd-border-width-0-5) solid var(--cpd-color-bg-subtle-secondary); + box-sizing: border-box; + min-width: 0; + + span { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } + } +} diff --git a/res/css/views/rooms/RoomListPanel/_RoomListHeaderView.pcss b/res/css/views/rooms/RoomListPanel/_RoomListHeaderView.pcss index 8ce4655e58..595f47f9c8 100644 --- a/res/css/views/rooms/RoomListPanel/_RoomListHeaderView.pcss +++ b/res/css/views/rooms/RoomListPanel/_RoomListHeaderView.pcss @@ -6,7 +6,7 @@ */ .mx_RoomListHeaderView { - height: 60px; + flex: 0 0 60px; padding: 0 var(--cpd-space-3x); .mx_RoomListHeaderView_title { diff --git a/res/css/views/rooms/RoomListPanel/_RoomListSearch.pcss b/res/css/views/rooms/RoomListPanel/_RoomListSearch.pcss index f175ab3976..8a97086df8 100644 --- a/res/css/views/rooms/RoomListPanel/_RoomListSearch.pcss +++ b/res/css/views/rooms/RoomListPanel/_RoomListSearch.pcss @@ -7,7 +7,7 @@ .mx_RoomListSearch { /* From figma, this should be aligned with the room header */ - height: 64px; + flex: 0 0 64px; box-sizing: border-box; border-bottom: var(--cpd-border-width-1) solid var(--cpd-color-bg-subtle-primary); padding: 0 var(--cpd-space-3x); diff --git a/src/components/views/rooms/RoomListPanel/RoomList.tsx b/src/components/views/rooms/RoomListPanel/RoomList.tsx new file mode 100644 index 0000000000..3645a72bb9 --- /dev/null +++ b/src/components/views/rooms/RoomListPanel/RoomList.tsx @@ -0,0 +1,51 @@ +/* + * 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, { useCallback, type JSX } from "react"; +import { AutoSizer, List, type ListRowProps } from "react-virtualized"; + +import { type RoomListViewState } from "../../../viewmodels/roomlist/RoomListViewModel"; +import { _t } from "../../../../languageHandler"; +import { RoomListCell } from "./RoomListCell"; + +interface RoomListProps { + /** + * The view model state for the room list. + */ + vm: RoomListViewState; +} + +/** + * A virtualized list of rooms. + */ +export function RoomList({ vm: { rooms, openRoom } }: RoomListProps): JSX.Element { + const roomRendererMemoized = useCallback( + ({ key, index, style }: ListRowProps) => ( + openRoom(rooms[index].roomId)} /> + ), + [rooms, openRoom], + ); + + // The first div is needed to make the virtualized list take all the remaining space and scroll correctly + return ( +
+ + {({ height, width }) => ( + + )} + +
+ ); +} diff --git a/src/components/views/rooms/RoomListPanel/RoomListCell.tsx b/src/components/views/rooms/RoomListPanel/RoomListCell.tsx new file mode 100644 index 0000000000..a5e9cc5df2 --- /dev/null +++ b/src/components/views/rooms/RoomListPanel/RoomListCell.tsx @@ -0,0 +1,44 @@ +/* + * 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 } from "react"; +import { type Room } from "matrix-js-sdk/src/matrix"; + +import { _t } from "../../../../languageHandler"; +import { Flex } from "../../../utils/Flex"; +import DecoratedRoomAvatar from "../../avatars/DecoratedRoomAvatar"; + +interface RoomListCellProps extends React.HTMLAttributes { + /** + * The room to display + */ + room: Room; +} + +/** + * A cell in the room list + */ +export function RoomListCell({ room, ...props }: RoomListCellProps): JSX.Element { + return ( + + ); +} diff --git a/src/components/views/rooms/RoomListPanel/RoomListPanel.tsx b/src/components/views/rooms/RoomListPanel/RoomListPanel.tsx index a52b619651..291794399f 100644 --- a/src/components/views/rooms/RoomListPanel/RoomListPanel.tsx +++ b/src/components/views/rooms/RoomListPanel/RoomListPanel.tsx @@ -6,14 +6,13 @@ Please see LICENSE files in the repository root for full details. */ import React from "react"; -import { AutoSizer, List } from "react-virtualized"; -import type { ListRowProps } from "react-virtualized"; import { shouldShowComponent } from "../../../../customisations/helpers/UIComponents"; import { UIComponent } from "../../../../settings/UIFeature"; import { RoomListSearch } from "./RoomListSearch"; import { RoomListHeaderView } from "./RoomListHeaderView"; -import { useRoomListViewModel } from "../../../viewmodels/roomlist/RoomListViewModel"; +import { RoomListView } from "./RoomListView"; +import { Flex } from "../../../utils/Flex"; type RoomListPanelProps = { /** @@ -28,31 +27,18 @@ type RoomListPanelProps = { */ export const RoomListPanel: React.FC = ({ activeSpace }) => { const displayRoomSearch = shouldShowComponent(UIComponent.FilterContainer); - const { rooms } = useRoomListViewModel(); - - const rowRenderer = ({ key, index, style }: ListRowProps): React.JSX.Element => { - return ( -
- {rooms[index].name} -
- ); - }; return ( -
+ {displayRoomSearch && } - - {({ height, width }) => ( - - )} - -
+ + ); }; diff --git a/src/components/views/rooms/RoomListPanel/RoomListView.tsx b/src/components/views/rooms/RoomListPanel/RoomListView.tsx new file mode 100644 index 0000000000..14b456852c --- /dev/null +++ b/src/components/views/rooms/RoomListPanel/RoomListView.tsx @@ -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. + */ + +import React, { type JSX } from "react"; + +import { useRoomListViewModel } from "../../../viewmodels/roomlist/RoomListViewModel"; +import { RoomList } from "./RoomList"; + +/** + * Host the room list and the (future) room filters + */ +export function RoomListView(): JSX.Element { + const vm = useRoomListViewModel(); + // Room filters will be added soon + return ; +} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 64200527c1..c3dd76a5a2 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -2104,12 +2104,16 @@ "one": "Currently joining %(count)s room", "other": "Currently joining %(count)s rooms" }, + "list_title": "Room list", "notification_options": "Notification options", "open_space_menu": "Open space menu", "redacting_messages_status": { "one": "Currently removing messages in %(count)s room", "other": "Currently removing messages in %(count)s rooms" }, + "room": { + "open_room": "Open room %(roomName)s" + }, "show_less": "Show less", "show_n_more": { "one": "Show %(count)s more", diff --git a/test/unit-tests/components/views/rooms/RoomListPanel/RoomList-test.tsx b/test/unit-tests/components/views/rooms/RoomListPanel/RoomList-test.tsx new file mode 100644 index 0000000000..bbf0edbf5e --- /dev/null +++ b/test/unit-tests/components/views/rooms/RoomListPanel/RoomList-test.tsx @@ -0,0 +1,52 @@ +/* + * 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 { type MatrixClient } from "matrix-js-sdk/src/matrix"; +import { render, screen, waitFor } from "jest-matrix-react"; +import userEvent from "@testing-library/user-event"; + +import { mkRoom, stubClient } from "../../../../../test-utils"; +import { type RoomListViewState } from "../../../../../../src/components/viewmodels/roomlist/RoomListViewModel"; +import { RoomList } from "../../../../../../src/components/views/rooms/RoomListPanel/RoomList"; +import DMRoomMap from "../../../../../../src/utils/DMRoomMap"; + +describe("", () => { + let matrixClient: MatrixClient; + let vm: RoomListViewState; + + beforeEach(() => { + // Needed to render the virtualized list in rtl tests + // https://github.com/bvaughn/react-virtualized/issues/493#issuecomment-640084107 + jest.spyOn(HTMLElement.prototype, "offsetHeight", "get").mockReturnValue(1500); + jest.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockReturnValue(1500); + + matrixClient = stubClient(); + const rooms = Array.from({ length: 10 }, (_, i) => mkRoom(matrixClient, `room${i}`)); + vm = { rooms, openRoom: jest.fn() }; + + // Needed to render a room list cell + DMRoomMap.makeShared(matrixClient); + jest.spyOn(DMRoomMap.shared(), "getUserIdForRoomId").mockReturnValue(null); + }); + + it("should render a room list", () => { + const { asFragment } = render(); + expect(asFragment()).toMatchSnapshot(); + }); + + it("should open the room", async () => { + const user = userEvent.setup(); + + render(); + await waitFor(async () => { + expect(screen.getByRole("gridcell", { name: "Open room room9" })).toBeVisible(); + await user.click(screen.getByRole("gridcell", { name: "Open room room9" })); + }); + expect(vm.openRoom).toHaveBeenCalledWith(vm.rooms[9].roomId); + }); +}); diff --git a/test/unit-tests/components/views/rooms/RoomListPanel/RoomListCell-test.tsx b/test/unit-tests/components/views/rooms/RoomListPanel/RoomListCell-test.tsx new file mode 100644 index 0000000000..3bbde9fb92 --- /dev/null +++ b/test/unit-tests/components/views/rooms/RoomListPanel/RoomListCell-test.tsx @@ -0,0 +1,44 @@ +/* + * 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 { type MatrixClient, type Room } from "matrix-js-sdk/src/matrix"; +import { render, screen } from "jest-matrix-react"; +import userEvent from "@testing-library/user-event"; + +import { mkRoom, stubClient } from "../../../../../test-utils"; +import { RoomListCell } from "../../../../../../src/components/views/rooms/RoomListPanel/RoomListCell"; +import DMRoomMap from "../../../../../../src/utils/DMRoomMap"; + +describe("", () => { + let matrixClient: MatrixClient; + let room: Room; + + beforeEach(() => { + matrixClient = stubClient(); + room = mkRoom(matrixClient, "room1"); + + DMRoomMap.makeShared(matrixClient); + jest.spyOn(DMRoomMap.shared(), "getUserIdForRoomId").mockReturnValue(null); + }); + + test("should render a room cell", () => { + const onClick = jest.fn(); + const { asFragment } = render(); + expect(asFragment()).toMatchSnapshot(); + }); + + test("should call onClick when clicked", async () => { + const user = userEvent.setup(); + + const onClick = jest.fn(); + render(); + + await user.click(screen.getByRole("button", { name: `Open room ${room.name}` })); + expect(onClick).toHaveBeenCalled(); + }); +}); diff --git a/test/unit-tests/components/views/rooms/RoomListPanel/__snapshots__/RoomList-test.tsx.snap b/test/unit-tests/components/views/rooms/RoomListPanel/__snapshots__/RoomList-test.tsx.snap new file mode 100644 index 0000000000..54919fb980 --- /dev/null +++ b/test/unit-tests/components/views/rooms/RoomListPanel/__snapshots__/RoomList-test.tsx.snap @@ -0,0 +1,504 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` should render a room list 1`] = ` + +
+
+
+
+ + + + + + + + + + +
+
+
+
+
+
+
+
+
+
+ +`; diff --git a/test/unit-tests/components/views/rooms/RoomListPanel/__snapshots__/RoomListCell-test.tsx.snap b/test/unit-tests/components/views/rooms/RoomListPanel/__snapshots__/RoomListCell-test.tsx.snap new file mode 100644 index 0000000000..cf7c8b854a --- /dev/null +++ b/test/unit-tests/components/views/rooms/RoomListPanel/__snapshots__/RoomListCell-test.tsx.snap @@ -0,0 +1,50 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` should render a room cell 1`] = ` + + + +`; diff --git a/test/unit-tests/components/views/rooms/RoomListPanel/__snapshots__/RoomListPanel-test.tsx.snap b/test/unit-tests/components/views/rooms/RoomListPanel/__snapshots__/RoomListPanel-test.tsx.snap index 35643e394f..cd1fafd224 100644 --- a/test/unit-tests/components/views/rooms/RoomListPanel/__snapshots__/RoomListPanel-test.tsx.snap +++ b/test/unit-tests/components/views/rooms/RoomListPanel/__snapshots__/RoomListPanel-test.tsx.snap @@ -3,8 +3,9 @@ exports[` should not render the RoomListSearch component when UIComponent.FilterContainer is at false 1`] = `
should not render the RoomListSearch component when U
-
-
-
+ class="resize-triggers" + > +
+
+
+
+
@@ -56,8 +62,9 @@ exports[` should not render the RoomListSearch component when U exports[` should render the RoomListSearch component when UIComponent.FilterContainer is at true 1`] = `
should render the RoomListSearch component when UICom
-
-
-
+ class="resize-triggers" + > +
+
+
+
+