diff --git a/package.json b/package.json index da69d64e73..14381c0b96 100644 --- a/package.json +++ b/package.json @@ -93,7 +93,7 @@ "@types/png-chunks-extract": "^1.0.2", "@types/react-virtualized": "^9.21.30", "@vector-im/compound-design-tokens": "^4.0.0", - "@vector-im/compound-web": "^8.0.0", + "@vector-im/compound-web": "^8.1.2", "@vector-im/matrix-wysiwyg": "2.38.3", "@zxcvbn-ts/core": "^3.0.4", "@zxcvbn-ts/language-common": "^3.0.4", 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 index 0567b8a162..f4757e2b42 100644 --- a/playwright/e2e/left-panel/room-list-panel/room-list.spec.ts +++ b/playwright/e2e/left-panel/room-list-panel/room-list.spec.ts @@ -60,6 +60,12 @@ test.describe("Room list", () => { await expect(page.getByRole("heading", { name: "room29", level: 1 })).toBeVisible(); }); + test("should open the context menu", { tag: "@screenshot" }, async ({ page, app, user }) => { + const roomListView = getRoomList(page); + await roomListView.getByRole("gridcell", { name: "Open room room29" }).click({ button: "right" }); + await expect(page.getByRole("menu", { name: "More Options" })).toBeVisible(); + }); + test("should open the more options menu", { tag: "@screenshot" }, async ({ page, app, user }) => { const roomListView = getRoomList(page); const roomItem = roomListView.getByRole("gridcell", { name: "Open room room29" }); @@ -223,17 +229,17 @@ test.describe("Room list", () => { await expect(notificationButton).toBeFocused(); // Open the menu - await notificationButton.click(); + await page.keyboard.press("Enter"); // Wait for the menu to be open await expect(page.getByRole("menuitem", { name: "Match default settings" })).toHaveAttribute( "aria-selected", "true", ); - // Close the menu + await page.keyboard.press("ArrowDown"); await page.keyboard.press("Escape"); - // Focus should be back on the room list item - await expect(room29).toBeFocused(); + // Focus should be back on the notification button + await expect(notificationButton).toBeFocused(); }); }); }); diff --git a/src/components/viewmodels/roomlist/RoomListItemViewModel.tsx b/src/components/viewmodels/roomlist/RoomListItemViewModel.tsx index e009033875..107d1a4c60 100644 --- a/src/components/viewmodels/roomlist/RoomListItemViewModel.tsx +++ b/src/components/viewmodels/roomlist/RoomListItemViewModel.tsx @@ -30,6 +30,10 @@ export interface RoomListItemViewState { * The name of the room. */ name: string; + /** + * Whether the context menu should be shown. + */ + showContextMenu: boolean; /** * Whether the hover menu should be shown. */ @@ -105,12 +109,12 @@ export function useRoomListItemViewModel(room: Room): RoomListItemViewState { setNotificationValues(getNotificationValues(notificationState)); }, [notificationState]); - // We don't want to show the hover menu if + // We don't want to show the menus if // - there is an invitation for this room - // - the user doesn't have access to both notification and more options menus + // - the user doesn't have access to notification and more options menus + const showContextMenu = !invited && hasAccessToOptionsMenu(room); const showHoverMenu = - !invited && - (hasAccessToOptionsMenu(room) || hasAccessToNotificationMenu(room, matrixClient.isGuest(), isArchived)); + !invited && (showContextMenu || hasAccessToNotificationMenu(room, matrixClient.isGuest(), isArchived)); const messagePreview = useRoomMessagePreview(room); @@ -137,6 +141,7 @@ export function useRoomListItemViewModel(room: Room): RoomListItemViewState { return { name, notificationState, + showContextMenu, showHoverMenu, openRoom, a11yLabel, diff --git a/src/components/views/rooms/RoomListPanel/RoomListItemContextMenuView.tsx b/src/components/views/rooms/RoomListPanel/RoomListItemContextMenuView.tsx new file mode 100644 index 0000000000..0769d9e40a --- /dev/null +++ b/src/components/views/rooms/RoomListPanel/RoomListItemContextMenuView.tsx @@ -0,0 +1,50 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { type Room } from "matrix-js-sdk/src/matrix"; +import { type JSX, type PropsWithChildren } from "react"; +import { ContextMenu } from "@vector-im/compound-web"; +import React from "react"; + +import { _t } from "../../../../languageHandler"; +import { MoreOptionContent } from "./RoomListItemMenuView"; +import { useRoomListItemMenuViewModel } from "../../../viewmodels/roomlist/RoomListItemMenuViewModel"; + +interface RoomListItemContextMenuViewProps { + /** + * The room to display the menu for. + */ + room: Room; + /** + * Set the menu open state. + */ + setMenuOpen: (isOpen: boolean) => void; +} + +/** + * A view for the room list item context menu. + */ +export function RoomListItemContextMenuView({ + room, + setMenuOpen, + children, +}: PropsWithChildren): JSX.Element { + const vm = useRoomListItemMenuViewModel(room); + + return ( + + + + ); +} diff --git a/src/components/views/rooms/RoomListPanel/RoomListItemMenuView.tsx b/src/components/views/rooms/RoomListPanel/RoomListItemMenuView.tsx index fa7a85b54f..a901003342 100644 --- a/src/components/views/rooms/RoomListPanel/RoomListItemMenuView.tsx +++ b/src/components/views/rooms/RoomListPanel/RoomListItemMenuView.tsx @@ -35,7 +35,6 @@ interface RoomListItemMenuViewProps { room: Room; /** * Set the menu open state. - * @param isOpen */ setMenuOpen: (isOpen: boolean) => void; } @@ -84,6 +83,21 @@ function MoreOptionsMenu({ vm, setMenuOpen }: MoreOptionsMenuProps): JSX.Element align="start" trigger={} > + + + ); +} + +interface MoreOptionContentProps { + /** + * The view model state for the menu. + */ + vm: RoomListItemMenuViewState; +} + +export function MoreOptionContent({ vm }: MoreOptionContentProps): JSX.Element { + return ( + <> {vm.canMarkAsRead && ( evt.stopPropagation()} hideChevron={true} /> - + ); } @@ -154,7 +168,7 @@ interface MoreOptionsButtonProps extends ComponentProps { /** * A button to trigger the more options menu. */ -export const MoreOptionsButton = function MoreOptionsButton(props: MoreOptionsButtonProps): JSX.Element { +const MoreOptionsButton = function MoreOptionsButton(props: MoreOptionsButtonProps): JSX.Element { return ( @@ -244,7 +258,7 @@ interface NotificationButtonProps extends ComponentProps { /** * A button to trigger the notification menu. */ -export const NotificationButton = function MoreOptionsButton({ +const NotificationButton = function MoreOptionsButton({ isRoomMuted, ref, ...props diff --git a/src/components/views/rooms/RoomListPanel/RoomListItemView.tsx b/src/components/views/rooms/RoomListPanel/RoomListItemView.tsx index ac4d72ec57..cabb034975 100644 --- a/src/components/views/rooms/RoomListPanel/RoomListItemView.tsx +++ b/src/components/views/rooms/RoomListPanel/RoomListItemView.tsx @@ -15,6 +15,7 @@ import { RoomListItemMenuView } from "./RoomListItemMenuView"; import { NotificationDecoration } from "../NotificationDecoration"; import { RoomAvatarView } from "../../avatars/RoomAvatarView"; import { useRovingTabIndex } from "../../../../accessibility/RovingTabIndex"; +import { RoomListItemContextMenuView } from "./RoomListItemContextMenuView"; interface RoomListItemViewProps extends React.HTMLAttributes { /** @@ -47,7 +48,13 @@ export const RoomListItemView = memo(function RoomListItemView({ const showHoverDecoration = isMenuOpen || isHover; const showHoverMenu = showHoverDecoration && vm.showHoverMenu; - return ( + const closeMenu = useCallback(() => { + // To avoid icon blinking when closing the menu, we delay the state update + // Also, let the focus move to the menu trigger before closing the menu + setTimeout(() => setIsMenuOpen(false), 10); + }, []); + + const content = ( ); + + if (!vm.showContextMenu) return content; + + return ( + { + if (isOpen) { + // To avoid icon blinking when the context menu is re-opened + setTimeout(() => setIsMenuOpen(true), 0); + } else { + closeMenu(); + } + }} + > + {content} + + ); }); /** diff --git a/test/unit-tests/components/viewmodels/roomlist/RoomListItemViewModel-test.tsx b/test/unit-tests/components/viewmodels/roomlist/RoomListItemViewModel-test.tsx index c58ae4168c..96bc53016e 100644 --- a/test/unit-tests/components/viewmodels/roomlist/RoomListItemViewModel-test.tsx +++ b/test/unit-tests/components/viewmodels/roomlist/RoomListItemViewModel-test.tsx @@ -73,6 +73,15 @@ describe("RoomListItemViewModel", () => { ); }); + it("should show context menu if user has access to options menu", async () => { + mocked(hasAccessToOptionsMenu).mockReturnValue(true); + const { result: vm } = renderHook( + () => useRoomListItemViewModel(room), + withClientContextRenderOptions(room.client), + ); + expect(vm.current.showContextMenu).toBe(true); + }); + it("should show hover menu if user has access to options menu", async () => { mocked(hasAccessToOptionsMenu).mockReturnValue(true); const { result: vm } = renderHook( diff --git a/test/unit-tests/components/views/rooms/RoomListPanel/RoomList-test.tsx b/test/unit-tests/components/views/rooms/RoomListPanel/RoomList-test.tsx index d5229ff667..983df2168e 100644 --- a/test/unit-tests/components/views/rooms/RoomListPanel/RoomList-test.tsx +++ b/test/unit-tests/components/views/rooms/RoomListPanel/RoomList-test.tsx @@ -10,7 +10,7 @@ import { type MatrixClient } from "matrix-js-sdk/src/matrix"; import { render } from "jest-matrix-react"; import { fireEvent } from "@testing-library/dom"; -import { mkRoom, stubClient } from "../../../../../test-utils"; +import { mkRoom, stubClient, withClientContextRenderOptions } 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"; @@ -44,7 +44,7 @@ describe("", () => { }); it("should render a room list", () => { - const { asFragment } = render(); + const { asFragment } = render(, withClientContextRenderOptions(matrixClient)); expect(asFragment()).toMatchSnapshot(); }); @@ -53,7 +53,7 @@ describe("", () => { { shortcut: { key: "F6", ctrlKey: true }, isPreviousLandmark: false, label: "NextLandmark" }, ])("should navigate to the landmark on NextLandmark.$label action", ({ shortcut, isPreviousLandmark }) => { const spyFindLandmark = jest.spyOn(LandmarkNavigation, "findAndFocusNextLandmark").mockReturnValue(); - const { getByTestId } = render(); + const { getByTestId } = render(, withClientContextRenderOptions(matrixClient)); const roomList = getByTestId("room-list"); fireEvent.keyDown(roomList, shortcut); diff --git a/test/unit-tests/components/views/rooms/RoomListPanel/RoomListItemView-test.tsx b/test/unit-tests/components/views/rooms/RoomListPanel/RoomListItemView-test.tsx index 914d33ee18..7e4af3467a 100644 --- a/test/unit-tests/components/views/rooms/RoomListPanel/RoomListItemView-test.tsx +++ b/test/unit-tests/components/views/rooms/RoomListPanel/RoomListItemView-test.tsx @@ -42,6 +42,7 @@ describe("", () => { defaultValue = { openRoom: jest.fn(), + showContextMenu: false, showHoverMenu: false, notificationState, a11yLabel: "Open room room1", @@ -136,4 +137,21 @@ describe("", () => { expect(screen.queryByRole("notification-decoration")).toBeNull(); }); + + test("should render the context menu", async () => { + const user = userEvent.setup(); + + mocked(useRoomListItemViewModel).mockReturnValue({ + ...defaultValue, + showContextMenu: true, + }); + + render(, withClientContextRenderOptions(matrixClient)); + const button = screen.getByRole("button", { name: `Open room ${room.name}` }); + await user.pointer([{ target: button }, { keys: "[MouseRight]", target: button }]); + await waitFor(() => expect(screen.getByRole("menu")).toBeInTheDocument()); + // Menu should close + await user.keyboard("{Escape}"); + expect(screen.queryByRole("menu")).toBeNull(); + }); }); 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 index 3ab510bfca..acd5569702 100644 --- 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 @@ -23,9 +23,11 @@ exports[` should render a room list 1`] = ` style="width: auto; height: 480px; max-width: 1500px; max-height: 480px; overflow: hidden; position: relative;" >