Skip to content

Commit f707bb4

Browse files
authored
New room list: add context menu to room list item (#29952)
* chore: update compound-web * chore: remove unused export * feat: export content of more option menu * feat: add context menu * feat: add `showContextMenu` to vm * feat: use context menu in new room list * test: add tests for room list item * test: fix room list test * test: add `showContextMenu` test for `useRoomListItemViewModel` * test: add e2e test for context menu * chore: update compound * test: update snapshots and e2e test * fix: avoid icon blinking when we reopen the context menu * test: add test for menu closing * doc: remove useless tsdoc param * chore: update `@vector-im/compound-web` * refactor: remove manual focus * test(e2e): fix focus after closing notification menu * doc: remove useless jobs
1 parent 52f836a commit f707bb4

File tree

11 files changed

+169
-32
lines changed

11 files changed

+169
-32
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@
9393
"@types/png-chunks-extract": "^1.0.2",
9494
"@types/react-virtualized": "^9.21.30",
9595
"@vector-im/compound-design-tokens": "^4.0.0",
96-
"@vector-im/compound-web": "^8.0.0",
96+
"@vector-im/compound-web": "^8.1.2",
9797
"@vector-im/matrix-wysiwyg": "2.38.3",
9898
"@zxcvbn-ts/core": "^3.0.4",
9999
"@zxcvbn-ts/language-common": "^3.0.4",

playwright/e2e/left-panel/room-list-panel/room-list.spec.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,12 @@ test.describe("Room list", () => {
6060
await expect(page.getByRole("heading", { name: "room29", level: 1 })).toBeVisible();
6161
});
6262

63+
test("should open the context menu", { tag: "@screenshot" }, async ({ page, app, user }) => {
64+
const roomListView = getRoomList(page);
65+
await roomListView.getByRole("gridcell", { name: "Open room room29" }).click({ button: "right" });
66+
await expect(page.getByRole("menu", { name: "More Options" })).toBeVisible();
67+
});
68+
6369
test("should open the more options menu", { tag: "@screenshot" }, async ({ page, app, user }) => {
6470
const roomListView = getRoomList(page);
6571
const roomItem = roomListView.getByRole("gridcell", { name: "Open room room29" });
@@ -223,17 +229,17 @@ test.describe("Room list", () => {
223229
await expect(notificationButton).toBeFocused();
224230

225231
// Open the menu
226-
await notificationButton.click();
232+
await page.keyboard.press("Enter");
227233
// Wait for the menu to be open
228234
await expect(page.getByRole("menuitem", { name: "Match default settings" })).toHaveAttribute(
229235
"aria-selected",
230236
"true",
231237
);
232238

233-
// Close the menu
239+
await page.keyboard.press("ArrowDown");
234240
await page.keyboard.press("Escape");
235-
// Focus should be back on the room list item
236-
await expect(room29).toBeFocused();
241+
// Focus should be back on the notification button
242+
await expect(notificationButton).toBeFocused();
237243
});
238244
});
239245
});

src/components/viewmodels/roomlist/RoomListItemViewModel.tsx

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ export interface RoomListItemViewState {
3030
* The name of the room.
3131
*/
3232
name: string;
33+
/**
34+
* Whether the context menu should be shown.
35+
*/
36+
showContextMenu: boolean;
3337
/**
3438
* Whether the hover menu should be shown.
3539
*/
@@ -105,12 +109,12 @@ export function useRoomListItemViewModel(room: Room): RoomListItemViewState {
105109
setNotificationValues(getNotificationValues(notificationState));
106110
}, [notificationState]);
107111

108-
// We don't want to show the hover menu if
112+
// We don't want to show the menus if
109113
// - there is an invitation for this room
110-
// - the user doesn't have access to both notification and more options menus
114+
// - the user doesn't have access to notification and more options menus
115+
const showContextMenu = !invited && hasAccessToOptionsMenu(room);
111116
const showHoverMenu =
112-
!invited &&
113-
(hasAccessToOptionsMenu(room) || hasAccessToNotificationMenu(room, matrixClient.isGuest(), isArchived));
117+
!invited && (showContextMenu || hasAccessToNotificationMenu(room, matrixClient.isGuest(), isArchived));
114118

115119
const messagePreview = useRoomMessagePreview(room);
116120

@@ -137,6 +141,7 @@ export function useRoomListItemViewModel(room: Room): RoomListItemViewState {
137141
return {
138142
name,
139143
notificationState,
144+
showContextMenu,
140145
showHoverMenu,
141146
openRoom,
142147
a11yLabel,
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/*
2+
* Copyright 2025 New Vector Ltd.
3+
*
4+
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
5+
* Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
import { type Room } from "matrix-js-sdk/src/matrix";
9+
import { type JSX, type PropsWithChildren } from "react";
10+
import { ContextMenu } from "@vector-im/compound-web";
11+
import React from "react";
12+
13+
import { _t } from "../../../../languageHandler";
14+
import { MoreOptionContent } from "./RoomListItemMenuView";
15+
import { useRoomListItemMenuViewModel } from "../../../viewmodels/roomlist/RoomListItemMenuViewModel";
16+
17+
interface RoomListItemContextMenuViewProps {
18+
/**
19+
* The room to display the menu for.
20+
*/
21+
room: Room;
22+
/**
23+
* Set the menu open state.
24+
*/
25+
setMenuOpen: (isOpen: boolean) => void;
26+
}
27+
28+
/**
29+
* A view for the room list item context menu.
30+
*/
31+
export function RoomListItemContextMenuView({
32+
room,
33+
setMenuOpen,
34+
children,
35+
}: PropsWithChildren<RoomListItemContextMenuViewProps>): JSX.Element {
36+
const vm = useRoomListItemMenuViewModel(room);
37+
38+
return (
39+
<ContextMenu
40+
title={_t("room_list|room|more_options")}
41+
showTitle={false}
42+
// To not mess with the roving tab index of the button
43+
hasAccessibleAlternative={true}
44+
trigger={children}
45+
onOpenChange={setMenuOpen}
46+
>
47+
<MoreOptionContent vm={vm} />
48+
</ContextMenu>
49+
);
50+
}

src/components/views/rooms/RoomListPanel/RoomListItemMenuView.tsx

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,6 @@ interface RoomListItemMenuViewProps {
3535
room: Room;
3636
/**
3737
* Set the menu open state.
38-
* @param isOpen
3938
*/
4039
setMenuOpen: (isOpen: boolean) => void;
4140
}
@@ -84,6 +83,21 @@ function MoreOptionsMenu({ vm, setMenuOpen }: MoreOptionsMenuProps): JSX.Element
8483
align="start"
8584
trigger={<MoreOptionsButton size="24px" />}
8685
>
86+
<MoreOptionContent vm={vm} />
87+
</Menu>
88+
);
89+
}
90+
91+
interface MoreOptionContentProps {
92+
/**
93+
* The view model state for the menu.
94+
*/
95+
vm: RoomListItemMenuViewState;
96+
}
97+
98+
export function MoreOptionContent({ vm }: MoreOptionContentProps): JSX.Element {
99+
return (
100+
<>
87101
{vm.canMarkAsRead && (
88102
<MenuItem
89103
Icon={MarkAsReadIcon}
@@ -143,7 +157,7 @@ function MoreOptionsMenu({ vm, setMenuOpen }: MoreOptionsMenuProps): JSX.Element
143157
onClick={(evt) => evt.stopPropagation()}
144158
hideChevron={true}
145159
/>
146-
</Menu>
160+
</>
147161
);
148162
}
149163

@@ -154,7 +168,7 @@ interface MoreOptionsButtonProps extends ComponentProps<typeof IconButton> {
154168
/**
155169
* A button to trigger the more options menu.
156170
*/
157-
export const MoreOptionsButton = function MoreOptionsButton(props: MoreOptionsButtonProps): JSX.Element {
171+
const MoreOptionsButton = function MoreOptionsButton(props: MoreOptionsButtonProps): JSX.Element {
158172
return (
159173
<Tooltip label={_t("room_list|room|more_options")}>
160174
<IconButton aria-label={_t("room_list|room|more_options")} {...props}>
@@ -244,7 +258,7 @@ interface NotificationButtonProps extends ComponentProps<typeof IconButton> {
244258
/**
245259
* A button to trigger the notification menu.
246260
*/
247-
export const NotificationButton = function MoreOptionsButton({
261+
const NotificationButton = function MoreOptionsButton({
248262
isRoomMuted,
249263
ref,
250264
...props

src/components/views/rooms/RoomListPanel/RoomListItemView.tsx

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { RoomListItemMenuView } from "./RoomListItemMenuView";
1515
import { NotificationDecoration } from "../NotificationDecoration";
1616
import { RoomAvatarView } from "../../avatars/RoomAvatarView";
1717
import { useRovingTabIndex } from "../../../../accessibility/RovingTabIndex";
18+
import { RoomListItemContextMenuView } from "./RoomListItemContextMenuView";
1819

1920
interface RoomListItemViewProps extends React.HTMLAttributes<HTMLButtonElement> {
2021
/**
@@ -47,7 +48,13 @@ export const RoomListItemView = memo(function RoomListItemView({
4748
const showHoverDecoration = isMenuOpen || isHover;
4849
const showHoverMenu = showHoverDecoration && vm.showHoverMenu;
4950

50-
return (
51+
const closeMenu = useCallback(() => {
52+
// To avoid icon blinking when closing the menu, we delay the state update
53+
// Also, let the focus move to the menu trigger before closing the menu
54+
setTimeout(() => setIsMenuOpen(false), 10);
55+
}, []);
56+
57+
const content = (
5158
<button
5259
ref={ref}
5360
className={classNames("mx_RoomListItemView", {
@@ -92,17 +99,7 @@ export const RoomListItemView = memo(function RoomListItemView({
9299
{showHoverMenu ? (
93100
<RoomListItemMenuView
94101
room={room}
95-
setMenuOpen={(isOpen) => {
96-
if (isOpen) {
97-
setIsMenuOpen(isOpen);
98-
} else {
99-
// To avoid icon blinking when closing the menu, we delay the state update
100-
setTimeout(() => setIsMenuOpen(isOpen), 0);
101-
// After closing the menu, we need to set the focus back to the button
102-
// 10ms because the focus moves to the body and we put back the focus on the button
103-
setTimeout(() => buttonRef.current?.focus(), 10);
104-
}
105-
}}
102+
setMenuOpen={(isOpen) => (isOpen ? setIsMenuOpen(true) : closeMenu())}
106103
/>
107104
) : (
108105
<>
@@ -120,6 +117,24 @@ export const RoomListItemView = memo(function RoomListItemView({
120117
</Flex>
121118
</button>
122119
);
120+
121+
if (!vm.showContextMenu) return content;
122+
123+
return (
124+
<RoomListItemContextMenuView
125+
room={room}
126+
setMenuOpen={(isOpen) => {
127+
if (isOpen) {
128+
// To avoid icon blinking when the context menu is re-opened
129+
setTimeout(() => setIsMenuOpen(true), 0);
130+
} else {
131+
closeMenu();
132+
}
133+
}}
134+
>
135+
{content}
136+
</RoomListItemContextMenuView>
137+
);
123138
});
124139

125140
/**

test/unit-tests/components/viewmodels/roomlist/RoomListItemViewModel-test.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,15 @@ describe("RoomListItemViewModel", () => {
7373
);
7474
});
7575

76+
it("should show context menu if user has access to options menu", async () => {
77+
mocked(hasAccessToOptionsMenu).mockReturnValue(true);
78+
const { result: vm } = renderHook(
79+
() => useRoomListItemViewModel(room),
80+
withClientContextRenderOptions(room.client),
81+
);
82+
expect(vm.current.showContextMenu).toBe(true);
83+
});
84+
7685
it("should show hover menu if user has access to options menu", async () => {
7786
mocked(hasAccessToOptionsMenu).mockReturnValue(true);
7887
const { result: vm } = renderHook(

test/unit-tests/components/views/rooms/RoomListPanel/RoomList-test.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { type MatrixClient } from "matrix-js-sdk/src/matrix";
1010
import { render } from "jest-matrix-react";
1111
import { fireEvent } from "@testing-library/dom";
1212

13-
import { mkRoom, stubClient } from "../../../../../test-utils";
13+
import { mkRoom, stubClient, withClientContextRenderOptions } from "../../../../../test-utils";
1414
import { type RoomListViewState } from "../../../../../../src/components/viewmodels/roomlist/RoomListViewModel";
1515
import { RoomList } from "../../../../../../src/components/views/rooms/RoomListPanel/RoomList";
1616
import DMRoomMap from "../../../../../../src/utils/DMRoomMap";
@@ -44,7 +44,7 @@ describe("<RoomList />", () => {
4444
});
4545

4646
it("should render a room list", () => {
47-
const { asFragment } = render(<RoomList vm={vm} />);
47+
const { asFragment } = render(<RoomList vm={vm} />, withClientContextRenderOptions(matrixClient));
4848
expect(asFragment()).toMatchSnapshot();
4949
});
5050

@@ -53,7 +53,7 @@ describe("<RoomList />", () => {
5353
{ shortcut: { key: "F6", ctrlKey: true }, isPreviousLandmark: false, label: "NextLandmark" },
5454
])("should navigate to the landmark on NextLandmark.$label action", ({ shortcut, isPreviousLandmark }) => {
5555
const spyFindLandmark = jest.spyOn(LandmarkNavigation, "findAndFocusNextLandmark").mockReturnValue();
56-
const { getByTestId } = render(<RoomList vm={vm} />);
56+
const { getByTestId } = render(<RoomList vm={vm} />, withClientContextRenderOptions(matrixClient));
5757
const roomList = getByTestId("room-list");
5858
fireEvent.keyDown(roomList, shortcut);
5959

test/unit-tests/components/views/rooms/RoomListPanel/RoomListItemView-test.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ describe("<RoomListItemView />", () => {
4242

4343
defaultValue = {
4444
openRoom: jest.fn(),
45+
showContextMenu: false,
4546
showHoverMenu: false,
4647
notificationState,
4748
a11yLabel: "Open room room1",
@@ -136,4 +137,21 @@ describe("<RoomListItemView />", () => {
136137

137138
expect(screen.queryByRole("notification-decoration")).toBeNull();
138139
});
140+
141+
test("should render the context menu", async () => {
142+
const user = userEvent.setup();
143+
144+
mocked(useRoomListItemViewModel).mockReturnValue({
145+
...defaultValue,
146+
showContextMenu: true,
147+
});
148+
149+
render(<RoomListItemView room={room} isSelected={false} />, withClientContextRenderOptions(matrixClient));
150+
const button = screen.getByRole("button", { name: `Open room ${room.name}` });
151+
await user.pointer([{ target: button }, { keys: "[MouseRight]", target: button }]);
152+
await waitFor(() => expect(screen.getByRole("menu")).toBeInTheDocument());
153+
// Menu should close
154+
await user.keyboard("{Escape}");
155+
expect(screen.queryByRole("menu")).toBeNull();
156+
});
139157
});

0 commit comments

Comments
 (0)