Skip to content

Commit 817d7b7

Browse files
authored
New room list: add notification options menu (#29639)
* feat: add `utils.hasAccessToNotificationMenu` * feat(room list item view model): use `hasAccessToNotificationMenu` to compute `showHoverMenu` * feat(room list item menu view model): add notification options menu attributes * feat(room list item menu view): add notification options * test: add tests for `utils.hasAccessToNotificationMenu` * test(room list item view model): add test for `showHoverMenu` * test(room list item menu view model): add tests for new attributes * test(room list item menu view): add tests for notification options menu * chore: update i18n * test(e2e): update screenshots * test(e2e): add tests for notification options menu
1 parent 31a59a5 commit 817d7b7

File tree

16 files changed

+448
-23
lines changed

16 files changed

+448
-23
lines changed

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

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,43 @@ test.describe("Room list", () => {
8585
await expect(roomItem).not.toBeVisible();
8686
});
8787

88+
test("should open the notification options menu", { tag: "@screenshot" }, async ({ page, app, user }) => {
89+
const roomListView = getRoomList(page);
90+
91+
const roomItem = roomListView.getByRole("gridcell", { name: "Open room room29" });
92+
await roomItem.hover();
93+
94+
await expect(roomItem).toMatchScreenshot("room-list-item-hover.png");
95+
let roomItemMenu = roomItem.getByRole("button", { name: "Notification options" });
96+
await roomItemMenu.click();
97+
98+
// Default settings should be selected
99+
await expect(page.getByRole("menuitem", { name: "Match default settings" })).toHaveAttribute(
100+
"aria-selected",
101+
"true",
102+
);
103+
await expect(page).toMatchScreenshot("room-list-item-open-notification-options.png");
104+
105+
// It should make the room muted
106+
await page.getByRole("menuitem", { name: "Mute room" }).click();
107+
108+
// Remove hover on the room list item
109+
await roomListView.hover();
110+
111+
// The room decoration should have the muted icon
112+
await expect(roomItem.getByTestId("notification-decoration")).toBeVisible();
113+
114+
await roomItem.hover();
115+
// On hover, the room should show the muted icon
116+
await expect(roomItem).toMatchScreenshot("room-list-item-hover-silent.png");
117+
118+
roomItemMenu = roomItem.getByRole("button", { name: "Notification options" });
119+
await roomItemMenu.click();
120+
// The Mute room option should be selected
121+
await expect(page.getByRole("menuitem", { name: "Mute room" })).toHaveAttribute("aria-selected", "true");
122+
await expect(page).toMatchScreenshot("room-list-item-open-notification-options-selection.png");
123+
});
124+
88125
test("should scroll to the current room", async ({ page, app, user }) => {
89126
const roomListView = getRoomList(page);
90127
await roomListView.hover();
Loading

src/components/viewmodels/roomlist/RoomListItemMenuViewModel.tsx

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { type Room, RoomEvent } from "matrix-js-sdk/src/matrix";
1111
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
1212
import { useEventEmitterState } from "../../../hooks/useEventEmitter";
1313
import { useUnreadNotifications } from "../../../hooks/useUnreadNotifications";
14-
import { hasAccessToOptionsMenu } from "./utils";
14+
import { hasAccessToNotificationMenu, hasAccessToOptionsMenu } from "./utils";
1515
import DMRoomMap from "../../../utils/DMRoomMap";
1616
import { DefaultTagID } from "../../../stores/room-list/models";
1717
import { NotificationLevel } from "../../../stores/notifications/NotificationLevel";
@@ -21,12 +21,18 @@ import dispatcher from "../../../dispatcher/dispatcher";
2121
import { clearRoomNotification, setMarkedUnreadState } from "../../../utils/notifications";
2222
import PosthogTrackers from "../../../PosthogTrackers";
2323
import { tagRoom } from "../../../utils/room/tagRoom";
24+
import { RoomNotifState } from "../../../RoomNotifs";
25+
import { useNotificationState } from "../../../hooks/useRoomNotificationState";
2426

2527
export interface RoomListItemMenuViewState {
2628
/**
2729
* Whether the more options menu should be shown.
2830
*/
2931
showMoreOptionsMenu: boolean;
32+
/**
33+
* Whether the notification menu should be shown.
34+
*/
35+
showNotificationMenu: boolean;
3036
/**
3137
* Whether the room is a favourite room.
3238
*/
@@ -47,6 +53,22 @@ export interface RoomListItemMenuViewState {
4753
* Can mark the room as unread.
4854
*/
4955
canMarkAsUnread: boolean;
56+
/**
57+
* Whether the notification is set to all messages.
58+
*/
59+
isNotificationAllMessage: boolean;
60+
/**
61+
* Whether the notification is set to all messages loud.
62+
*/
63+
isNotificationAllMessageLoud: boolean;
64+
/**
65+
* Whether the notification is set to mentions and keywords only.
66+
*/
67+
isNotificationMentionOnly: boolean;
68+
/**
69+
* Whether the notification is muted.
70+
*/
71+
isNotificationMute: boolean;
5072
/**
5173
* Mark the room as read.
5274
* @param evt
@@ -81,26 +103,38 @@ export interface RoomListItemMenuViewState {
81103
* @param evt
82104
*/
83105
leaveRoom: (evt: Event) => void;
106+
/**
107+
* Set the room notification state.
108+
* @param state
109+
*/
110+
setRoomNotifState: (state: RoomNotifState) => void;
84111
}
85112

86113
export function useRoomListItemMenuViewModel(room: Room): RoomListItemMenuViewState {
87114
const matrixClient = useMatrixClientContext();
88115
const roomTags = useEventEmitterState(room, RoomEvent.Tags, () => room.tags);
89116
const { level: notificationLevel } = useUnreadNotifications(room);
90117

91-
const showMoreOptionsMenu = hasAccessToOptionsMenu(room);
92-
93118
const isDm = Boolean(DMRoomMap.shared().getUserIdForRoomId(room.roomId));
94119
const isFavourite = Boolean(roomTags[DefaultTagID.Favourite]);
95120
const isArchived = Boolean(roomTags[DefaultTagID.Archived]);
96121

122+
const showMoreOptionsMenu = hasAccessToOptionsMenu(room);
123+
const showNotificationMenu = hasAccessToNotificationMenu(room, matrixClient.isGuest(), isArchived);
124+
97125
const canMarkAsRead = notificationLevel > NotificationLevel.None;
98126
const canMarkAsUnread = !canMarkAsRead && !isArchived;
99127

100128
const canInvite =
101129
room.canInvite(matrixClient.getUserId()!) && !isDm && shouldShowComponent(UIComponent.InviteUsers);
102130
const canCopyRoomLink = !isDm;
103131

132+
const [roomNotifState, setRoomNotifState] = useNotificationState(room);
133+
const isNotificationAllMessage = roomNotifState === RoomNotifState.AllMessages;
134+
const isNotificationAllMessageLoud = roomNotifState === RoomNotifState.AllMessagesLoud;
135+
const isNotificationMentionOnly = roomNotifState === RoomNotifState.MentionsOnly;
136+
const isNotificationMute = roomNotifState === RoomNotifState.Mute;
137+
104138
// Actions
105139

106140
const markAsRead = useCallback(
@@ -164,17 +198,23 @@ export function useRoomListItemMenuViewModel(room: Room): RoomListItemMenuViewSt
164198

165199
return {
166200
showMoreOptionsMenu,
201+
showNotificationMenu,
167202
isFavourite,
168203
canInvite,
169204
canCopyRoomLink,
170205
canMarkAsRead,
171206
canMarkAsUnread,
207+
isNotificationAllMessage,
208+
isNotificationAllMessageLoud,
209+
isNotificationMentionOnly,
210+
isNotificationMute,
172211
markAsRead,
173212
markAsUnread,
174213
toggleFavorite,
175214
toggleLowPriority,
176215
invite,
177216
copyRoomLink,
178217
leaveRoom,
218+
setRoomNotifState,
179219
};
180220
}

src/components/viewmodels/roomlist/RoomListItemViewModel.tsx

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,18 @@
66
*/
77

88
import { useCallback, useMemo } from "react";
9-
import { type Room } from "matrix-js-sdk/src/matrix";
9+
import { type Room, RoomEvent } from "matrix-js-sdk/src/matrix";
1010

1111
import dispatcher from "../../../dispatcher/dispatcher";
1212
import type { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
1313
import { Action } from "../../../dispatcher/actions";
14-
import { hasAccessToOptionsMenu } from "./utils";
14+
import { hasAccessToNotificationMenu, hasAccessToOptionsMenu } from "./utils";
1515
import { _t } from "../../../languageHandler";
1616
import { type RoomNotificationState } from "../../../stores/notifications/RoomNotificationState";
1717
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
18+
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
19+
import { useEventEmitterState } from "../../../hooks/useEventEmitter";
20+
import { DefaultTagID } from "../../../stores/room-list/models";
1821

1922
export interface RoomListItemViewState {
2023
/**
@@ -40,8 +43,12 @@ export interface RoomListItemViewState {
4043
* @see {@link RoomListItemViewState} for more information about what this view model returns.
4144
*/
4245
export function useRoomListItemViewModel(room: Room): RoomListItemViewState {
43-
// incoming: Check notification menu rights
44-
const showHoverMenu = hasAccessToOptionsMenu(room);
46+
const matrixClient = useMatrixClientContext();
47+
const roomTags = useEventEmitterState(room, RoomEvent.Tags, () => room.tags);
48+
const isArchived = Boolean(roomTags[DefaultTagID.Archived]);
49+
50+
const showHoverMenu =
51+
hasAccessToOptionsMenu(room) || hasAccessToNotificationMenu(room, matrixClient.isGuest(), isArchived);
4552
const notificationState = useMemo(() => RoomNotificationStateStore.instance.getRoomState(room), [room]);
4653
const a11yLabel = getA11yLabel(room, notificationState);
4754

src/components/viewmodels/roomlist/utils.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,16 @@ export function hasAccessToOptionsMenu(room: Room): boolean {
2727
);
2828
}
2929

30+
/**
31+
* Check if the user has access to the notification menu.
32+
* @param room
33+
* @param isGuest
34+
* @param isArchived
35+
*/
36+
export function hasAccessToNotificationMenu(room: Room, isGuest: boolean, isArchived: boolean): boolean {
37+
return !isGuest && !isArchived && hasAccessToOptionsMenu(room);
38+
}
39+
3040
/**
3141
* Create a room
3242
* @param space - The space to create the room in

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

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ import UserAddIcon from "@vector-im/compound-design-tokens/assets/web/icons/user
1515
import LinkIcon from "@vector-im/compound-design-tokens/assets/web/icons/link";
1616
import LeaveIcon from "@vector-im/compound-design-tokens/assets/web/icons/leave";
1717
import OverflowIcon from "@vector-im/compound-design-tokens/assets/web/icons/overflow-horizontal";
18+
import NotificationIcon from "@vector-im/compound-design-tokens/assets/web/icons/notifications-solid";
19+
import NotificationOffIcon from "@vector-im/compound-design-tokens/assets/web/icons/notifications-off-solid";
20+
import CheckIcon from "@vector-im/compound-design-tokens/assets/web/icons/check";
1821
import { type Room } from "matrix-js-sdk/src/matrix";
1922

2023
import { _t } from "../../../../languageHandler";
@@ -23,6 +26,7 @@ import {
2326
type RoomListItemMenuViewState,
2427
useRoomListItemMenuViewModel,
2528
} from "../../../viewmodels/roomlist/RoomListItemMenuViewModel";
29+
import { RoomNotifState } from "../../../../RoomNotifs";
2630

2731
interface RoomListItemMenuViewProps {
2832
/**
@@ -45,6 +49,7 @@ export function RoomListItemMenuView({ room, setMenuOpen }: RoomListItemMenuView
4549
return (
4650
<Flex className="mx_RoomListItemMenuView" align="center" gap="var(--cpd-space-0-5x)">
4751
{vm.showMoreOptionsMenu && <MoreOptionsMenu setMenuOpen={setMenuOpen} vm={vm} />}
52+
{vm.showNotificationMenu && <NotificationMenu setMenuOpen={setMenuOpen} vm={vm} />}
4853
</Flex>
4954
);
5055
}
@@ -152,3 +157,93 @@ export const MoreOptionsButton = forwardRef<HTMLButtonElement, MoreOptionsButton
152157
);
153158
},
154159
);
160+
161+
interface NotificationMenuProps {
162+
/**
163+
* The view model state for the menu.
164+
*/
165+
vm: RoomListItemMenuViewState;
166+
/**
167+
* Set the menu open state.
168+
* @param isOpen
169+
*/
170+
setMenuOpen: (isOpen: boolean) => void;
171+
}
172+
173+
function NotificationMenu({ vm, setMenuOpen }: NotificationMenuProps): JSX.Element {
174+
const [open, setOpen] = useState(false);
175+
176+
return (
177+
<Menu
178+
open={open}
179+
onOpenChange={(isOpen) => {
180+
setOpen(isOpen);
181+
setMenuOpen(isOpen);
182+
}}
183+
title={_t("room_list|notification_options")}
184+
showTitle={false}
185+
align="start"
186+
trigger={<NotificationButton isRoomMuted={vm.isNotificationMute} size="24px" />}
187+
>
188+
<MenuItem
189+
aria-selected={vm.isNotificationAllMessage}
190+
hideChevron={true}
191+
label={_t("notifications|default_settings")}
192+
onSelect={() => vm.setRoomNotifState(RoomNotifState.AllMessages)}
193+
onClick={(evt) => evt.stopPropagation()}
194+
>
195+
{vm.isNotificationAllMessage && <CheckIcon width="24px" height="24px" />}
196+
</MenuItem>
197+
<MenuItem
198+
aria-selected={vm.isNotificationAllMessageLoud}
199+
hideChevron={true}
200+
label={_t("notifications|all_messages")}
201+
onSelect={() => vm.setRoomNotifState(RoomNotifState.AllMessagesLoud)}
202+
onClick={(evt) => evt.stopPropagation()}
203+
>
204+
{vm.isNotificationAllMessageLoud && <CheckIcon width="24px" height="24px" />}
205+
</MenuItem>
206+
<MenuItem
207+
aria-selected={vm.isNotificationMentionOnly}
208+
hideChevron={true}
209+
label={_t("notifications|mentions_keywords")}
210+
onSelect={() => vm.setRoomNotifState(RoomNotifState.MentionsOnly)}
211+
onClick={(evt) => evt.stopPropagation()}
212+
>
213+
{vm.isNotificationMentionOnly && <CheckIcon width="24px" height="24px" />}
214+
</MenuItem>
215+
<MenuItem
216+
aria-selected={vm.isNotificationMute}
217+
hideChevron={true}
218+
label={_t("notifications|mute_room")}
219+
onSelect={() => vm.setRoomNotifState(RoomNotifState.Mute)}
220+
onClick={(evt) => evt.stopPropagation()}
221+
>
222+
{vm.isNotificationMute && <CheckIcon width="24px" height="24px" />}
223+
</MenuItem>
224+
</Menu>
225+
);
226+
}
227+
228+
interface NotificationButtonProps extends ComponentProps<typeof IconButton> {
229+
/**
230+
* Whether the room is muted.
231+
*/
232+
isRoomMuted: boolean;
233+
}
234+
235+
/**
236+
* A button to trigger the notification menu.
237+
*/
238+
export const NotificationButton = forwardRef<HTMLButtonElement, NotificationButtonProps>(function MoreOptionsButton(
239+
{ isRoomMuted, ...props },
240+
ref,
241+
) {
242+
return (
243+
<Tooltip label={_t("room_list|notification_options")}>
244+
<IconButton aria-label={_t("room_list|notification_options")} {...props} ref={ref}>
245+
{isRoomMuted ? <NotificationOffIcon /> : <NotificationIcon />}
246+
</IconButton>
247+
</Tooltip>
248+
);
249+
});

0 commit comments

Comments
 (0)