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
This commit is contained in:
Florian Duros
2025-04-02 14:30:27 +02:00
committed by GitHub
parent 31a59a5fa3
commit 817d7b78b8
16 changed files with 448 additions and 23 deletions

View File

@@ -11,7 +11,7 @@ import { type Room, RoomEvent } from "matrix-js-sdk/src/matrix";
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
import { useEventEmitterState } from "../../../hooks/useEventEmitter";
import { useUnreadNotifications } from "../../../hooks/useUnreadNotifications";
import { hasAccessToOptionsMenu } from "./utils";
import { hasAccessToNotificationMenu, hasAccessToOptionsMenu } from "./utils";
import DMRoomMap from "../../../utils/DMRoomMap";
import { DefaultTagID } from "../../../stores/room-list/models";
import { NotificationLevel } from "../../../stores/notifications/NotificationLevel";
@@ -21,12 +21,18 @@ import dispatcher from "../../../dispatcher/dispatcher";
import { clearRoomNotification, setMarkedUnreadState } from "../../../utils/notifications";
import PosthogTrackers from "../../../PosthogTrackers";
import { tagRoom } from "../../../utils/room/tagRoom";
import { RoomNotifState } from "../../../RoomNotifs";
import { useNotificationState } from "../../../hooks/useRoomNotificationState";
export interface RoomListItemMenuViewState {
/**
* Whether the more options menu should be shown.
*/
showMoreOptionsMenu: boolean;
/**
* Whether the notification menu should be shown.
*/
showNotificationMenu: boolean;
/**
* Whether the room is a favourite room.
*/
@@ -47,6 +53,22 @@ export interface RoomListItemMenuViewState {
* Can mark the room as unread.
*/
canMarkAsUnread: boolean;
/**
* Whether the notification is set to all messages.
*/
isNotificationAllMessage: boolean;
/**
* Whether the notification is set to all messages loud.
*/
isNotificationAllMessageLoud: boolean;
/**
* Whether the notification is set to mentions and keywords only.
*/
isNotificationMentionOnly: boolean;
/**
* Whether the notification is muted.
*/
isNotificationMute: boolean;
/**
* Mark the room as read.
* @param evt
@@ -81,6 +103,11 @@ export interface RoomListItemMenuViewState {
* @param evt
*/
leaveRoom: (evt: Event) => void;
/**
* Set the room notification state.
* @param state
*/
setRoomNotifState: (state: RoomNotifState) => void;
}
export function useRoomListItemMenuViewModel(room: Room): RoomListItemMenuViewState {
@@ -88,12 +115,13 @@ export function useRoomListItemMenuViewModel(room: Room): RoomListItemMenuViewSt
const roomTags = useEventEmitterState(room, RoomEvent.Tags, () => room.tags);
const { level: notificationLevel } = useUnreadNotifications(room);
const showMoreOptionsMenu = hasAccessToOptionsMenu(room);
const isDm = Boolean(DMRoomMap.shared().getUserIdForRoomId(room.roomId));
const isFavourite = Boolean(roomTags[DefaultTagID.Favourite]);
const isArchived = Boolean(roomTags[DefaultTagID.Archived]);
const showMoreOptionsMenu = hasAccessToOptionsMenu(room);
const showNotificationMenu = hasAccessToNotificationMenu(room, matrixClient.isGuest(), isArchived);
const canMarkAsRead = notificationLevel > NotificationLevel.None;
const canMarkAsUnread = !canMarkAsRead && !isArchived;
@@ -101,6 +129,12 @@ export function useRoomListItemMenuViewModel(room: Room): RoomListItemMenuViewSt
room.canInvite(matrixClient.getUserId()!) && !isDm && shouldShowComponent(UIComponent.InviteUsers);
const canCopyRoomLink = !isDm;
const [roomNotifState, setRoomNotifState] = useNotificationState(room);
const isNotificationAllMessage = roomNotifState === RoomNotifState.AllMessages;
const isNotificationAllMessageLoud = roomNotifState === RoomNotifState.AllMessagesLoud;
const isNotificationMentionOnly = roomNotifState === RoomNotifState.MentionsOnly;
const isNotificationMute = roomNotifState === RoomNotifState.Mute;
// Actions
const markAsRead = useCallback(
@@ -164,11 +198,16 @@ export function useRoomListItemMenuViewModel(room: Room): RoomListItemMenuViewSt
return {
showMoreOptionsMenu,
showNotificationMenu,
isFavourite,
canInvite,
canCopyRoomLink,
canMarkAsRead,
canMarkAsUnread,
isNotificationAllMessage,
isNotificationAllMessageLoud,
isNotificationMentionOnly,
isNotificationMute,
markAsRead,
markAsUnread,
toggleFavorite,
@@ -176,5 +215,6 @@ export function useRoomListItemMenuViewModel(room: Room): RoomListItemMenuViewSt
invite,
copyRoomLink,
leaveRoom,
setRoomNotifState,
};
}

View File

@@ -6,15 +6,18 @@
*/
import { useCallback, useMemo } from "react";
import { type Room } from "matrix-js-sdk/src/matrix";
import { type Room, RoomEvent } from "matrix-js-sdk/src/matrix";
import dispatcher from "../../../dispatcher/dispatcher";
import type { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import { Action } from "../../../dispatcher/actions";
import { hasAccessToOptionsMenu } from "./utils";
import { hasAccessToNotificationMenu, hasAccessToOptionsMenu } from "./utils";
import { _t } from "../../../languageHandler";
import { type RoomNotificationState } from "../../../stores/notifications/RoomNotificationState";
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
import { useEventEmitterState } from "../../../hooks/useEventEmitter";
import { DefaultTagID } from "../../../stores/room-list/models";
export interface RoomListItemViewState {
/**
@@ -40,8 +43,12 @@ export interface RoomListItemViewState {
* @see {@link RoomListItemViewState} for more information about what this view model returns.
*/
export function useRoomListItemViewModel(room: Room): RoomListItemViewState {
// incoming: Check notification menu rights
const showHoverMenu = hasAccessToOptionsMenu(room);
const matrixClient = useMatrixClientContext();
const roomTags = useEventEmitterState(room, RoomEvent.Tags, () => room.tags);
const isArchived = Boolean(roomTags[DefaultTagID.Archived]);
const showHoverMenu =
hasAccessToOptionsMenu(room) || hasAccessToNotificationMenu(room, matrixClient.isGuest(), isArchived);
const notificationState = useMemo(() => RoomNotificationStateStore.instance.getRoomState(room), [room]);
const a11yLabel = getA11yLabel(room, notificationState);

View File

@@ -27,6 +27,16 @@ export function hasAccessToOptionsMenu(room: Room): boolean {
);
}
/**
* Check if the user has access to the notification menu.
* @param room
* @param isGuest
* @param isArchived
*/
export function hasAccessToNotificationMenu(room: Room, isGuest: boolean, isArchived: boolean): boolean {
return !isGuest && !isArchived && hasAccessToOptionsMenu(room);
}
/**
* Create a room
* @param space - The space to create the room in

View File

@@ -15,6 +15,9 @@ import UserAddIcon from "@vector-im/compound-design-tokens/assets/web/icons/user
import LinkIcon from "@vector-im/compound-design-tokens/assets/web/icons/link";
import LeaveIcon from "@vector-im/compound-design-tokens/assets/web/icons/leave";
import OverflowIcon from "@vector-im/compound-design-tokens/assets/web/icons/overflow-horizontal";
import NotificationIcon from "@vector-im/compound-design-tokens/assets/web/icons/notifications-solid";
import NotificationOffIcon from "@vector-im/compound-design-tokens/assets/web/icons/notifications-off-solid";
import CheckIcon from "@vector-im/compound-design-tokens/assets/web/icons/check";
import { type Room } from "matrix-js-sdk/src/matrix";
import { _t } from "../../../../languageHandler";
@@ -23,6 +26,7 @@ import {
type RoomListItemMenuViewState,
useRoomListItemMenuViewModel,
} from "../../../viewmodels/roomlist/RoomListItemMenuViewModel";
import { RoomNotifState } from "../../../../RoomNotifs";
interface RoomListItemMenuViewProps {
/**
@@ -45,6 +49,7 @@ export function RoomListItemMenuView({ room, setMenuOpen }: RoomListItemMenuView
return (
<Flex className="mx_RoomListItemMenuView" align="center" gap="var(--cpd-space-0-5x)">
{vm.showMoreOptionsMenu && <MoreOptionsMenu setMenuOpen={setMenuOpen} vm={vm} />}
{vm.showNotificationMenu && <NotificationMenu setMenuOpen={setMenuOpen} vm={vm} />}
</Flex>
);
}
@@ -152,3 +157,93 @@ export const MoreOptionsButton = forwardRef<HTMLButtonElement, MoreOptionsButton
);
},
);
interface NotificationMenuProps {
/**
* The view model state for the menu.
*/
vm: RoomListItemMenuViewState;
/**
* Set the menu open state.
* @param isOpen
*/
setMenuOpen: (isOpen: boolean) => void;
}
function NotificationMenu({ vm, setMenuOpen }: NotificationMenuProps): JSX.Element {
const [open, setOpen] = useState(false);
return (
<Menu
open={open}
onOpenChange={(isOpen) => {
setOpen(isOpen);
setMenuOpen(isOpen);
}}
title={_t("room_list|notification_options")}
showTitle={false}
align="start"
trigger={<NotificationButton isRoomMuted={vm.isNotificationMute} size="24px" />}
>
<MenuItem
aria-selected={vm.isNotificationAllMessage}
hideChevron={true}
label={_t("notifications|default_settings")}
onSelect={() => vm.setRoomNotifState(RoomNotifState.AllMessages)}
onClick={(evt) => evt.stopPropagation()}
>
{vm.isNotificationAllMessage && <CheckIcon width="24px" height="24px" />}
</MenuItem>
<MenuItem
aria-selected={vm.isNotificationAllMessageLoud}
hideChevron={true}
label={_t("notifications|all_messages")}
onSelect={() => vm.setRoomNotifState(RoomNotifState.AllMessagesLoud)}
onClick={(evt) => evt.stopPropagation()}
>
{vm.isNotificationAllMessageLoud && <CheckIcon width="24px" height="24px" />}
</MenuItem>
<MenuItem
aria-selected={vm.isNotificationMentionOnly}
hideChevron={true}
label={_t("notifications|mentions_keywords")}
onSelect={() => vm.setRoomNotifState(RoomNotifState.MentionsOnly)}
onClick={(evt) => evt.stopPropagation()}
>
{vm.isNotificationMentionOnly && <CheckIcon width="24px" height="24px" />}
</MenuItem>
<MenuItem
aria-selected={vm.isNotificationMute}
hideChevron={true}
label={_t("notifications|mute_room")}
onSelect={() => vm.setRoomNotifState(RoomNotifState.Mute)}
onClick={(evt) => evt.stopPropagation()}
>
{vm.isNotificationMute && <CheckIcon width="24px" height="24px" />}
</MenuItem>
</Menu>
);
}
interface NotificationButtonProps extends ComponentProps<typeof IconButton> {
/**
* Whether the room is muted.
*/
isRoomMuted: boolean;
}
/**
* A button to trigger the notification menu.
*/
export const NotificationButton = forwardRef<HTMLButtonElement, NotificationButtonProps>(function MoreOptionsButton(
{ isRoomMuted, ...props },
ref,
) {
return (
<Tooltip label={_t("room_list|notification_options")}>
<IconButton aria-label={_t("room_list|notification_options")} {...props} ref={ref}>
{isRoomMuted ? <NotificationOffIcon /> : <NotificationIcon />}
</IconButton>
</Tooltip>
);
});

View File

@@ -1677,6 +1677,7 @@
"class_global": "Global",
"class_other": "Other",
"default": "Default",
"default_settings": "Match default settings",
"email_pusher_app_display_name": "Email Notifications",
"enable_prompt_toast_description": "Enable desktop notifications",
"enable_prompt_toast_title": "Notifications",
@@ -1693,9 +1694,10 @@
"mark_all_read": "Mark all as read",
"mentions_and_keywords": "@mentions & keywords",
"mentions_and_keywords_description": "Get notified only with mentions and keywords as set up in your <a>settings</a>",
"mentions_keywords": "Mentions & keywords",
"mentions_keywords": "Mentions and keywords",
"message_didnt_send": "Message didn't send. Click for info.",
"mute_description": "You won't get any notifications"
"mute_description": "You won't get any notifications",
"mute_room": "Mute room"
},
"notifier": {
"m.key.verification.request": "%(name)s is requesting verification"