New room list: add more options menu on room list item (#29445)

* refactor(room list item): rename `RoomListCell` into `RoomListItemView`

* refactor(room list item): move open room action to new room list item view model

* feat(hover menu): add `hasAccessToOptionsMenu`

* feat(hover menu): add to `RoomListItemViewModel` the condition to display or not the hover menu

* feat(hover menu): add view model for the hover menu

* feat(hover menu): add hover menu view

* feat(hover menu): add hover menu to room list item

* feat(hover menu): update i18n

* test(view model list item): update test and add test to `showHoverMenu`

* test(room list): update snapshot

* test(room list item menu): add tests for view model

* test(room list item menu): add tests for view

* test(room list item): add tests

* test(e2e): add tests for more options menu

* chore: update compound web

* test(e2e): fix typo
This commit is contained in:
Florian Duros
2025-03-14 17:22:45 +01:00
committed by GitHub
parent ceba762caf
commit f3dbe81ef4
29 changed files with 1044 additions and 202 deletions

View File

@@ -0,0 +1,180 @@
/*
* 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 { useCallback } from "react";
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 DMRoomMap from "../../../utils/DMRoomMap";
import { DefaultTagID } from "../../../stores/room-list/models";
import { NotificationLevel } from "../../../stores/notifications/NotificationLevel";
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents";
import { UIComponent } from "../../../settings/UIFeature";
import dispatcher from "../../../dispatcher/dispatcher";
import { clearRoomNotification, setMarkedUnreadState } from "../../../utils/notifications";
import PosthogTrackers from "../../../PosthogTrackers";
import { tagRoom } from "../../../utils/room/tagRoom";
export interface RoomListItemMenuViewState {
/**
* Whether the more options menu should be shown.
*/
showMoreOptionsMenu: boolean;
/**
* Whether the room is a favourite room.
*/
isFavourite: boolean;
/**
* Can invite other user's in the room.
*/
canInvite: boolean;
/**
* Can copy the room link.
*/
canCopyRoomLink: boolean;
/**
* Can mark the room as read.
*/
canMarkAsRead: boolean;
/**
* Can mark the room as unread.
*/
canMarkAsUnread: boolean;
/**
* Mark the room as read.
* @param evt
*/
markAsRead: (evt: Event) => void;
/**
* Mark the room as unread.
* @param evt
*/
markAsUnread: (evt: Event) => void;
/**
* Toggle the room as favourite.
* @param evt
*/
toggleFavorite: (evt: Event) => void;
/**
* Toggle the room as low priority.
*/
toggleLowPriority: () => void;
/**
* Invite other users in the room.
* @param evt
*/
invite: (evt: Event) => void;
/**
* Copy the room link in the clipboard.
* @param evt
*/
copyRoomLink: (evt: Event) => void;
/**
* Leave the room.
* @param evt
*/
leaveRoom: (evt: Event) => void;
}
export function useRoomListItemMenuViewModel(room: Room): RoomListItemMenuViewState {
const matrixClient = useMatrixClientContext();
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 canMarkAsRead = notificationLevel > NotificationLevel.None;
const canMarkAsUnread = !canMarkAsRead && !isArchived;
const canInvite =
room.canInvite(matrixClient.getUserId()!) && !isDm && shouldShowComponent(UIComponent.InviteUsers);
const canCopyRoomLink = !isDm;
// Actions
const markAsRead = useCallback(
async (evt: Event): Promise<void> => {
await clearRoomNotification(room, matrixClient);
PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuMarkRead", evt);
},
[room, matrixClient],
);
const markAsUnread = useCallback(
async (evt: Event): Promise<void> => {
await setMarkedUnreadState(room, matrixClient, true);
PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuMarkUnread", evt);
},
[room, matrixClient],
);
const toggleFavorite = useCallback(
(evt: Event): void => {
tagRoom(room, DefaultTagID.Favourite);
PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuFavouriteToggle", evt);
},
[room],
);
const toggleLowPriority = useCallback((): void => tagRoom(room, DefaultTagID.LowPriority), [room]);
const invite = useCallback(
(evt: Event): void => {
dispatcher.dispatch({
action: "view_invite",
roomId: room.roomId,
});
PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuInviteItem", evt);
},
[room],
);
const copyRoomLink = useCallback(
(evt: Event): void => {
dispatcher.dispatch({
action: "copy_room",
room_id: room.roomId,
});
PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuFavouriteToggle", evt);
},
[room],
);
const leaveRoom = useCallback(
(evt: Event): void => {
dispatcher.dispatch({
action: isArchived ? "forget_room" : "leave_room",
room_id: room.roomId,
});
PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuLeaveItem", evt);
},
[room, isArchived],
);
return {
showMoreOptionsMenu,
isFavourite,
canInvite,
canCopyRoomLink,
canMarkAsRead,
canMarkAsUnread,
markAsRead,
markAsUnread,
toggleFavorite,
toggleLowPriority,
invite,
copyRoomLink,
leaveRoom,
};
}

View File

@@ -0,0 +1,49 @@
/*
* 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 { useCallback } from "react";
import { type Room } 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";
export interface RoomListItemViewState {
/**
* Whether the hover menu should be shown.
*/
showHoverMenu: boolean;
/**
* Open the room having given roomId.
*/
openRoom: () => void;
}
/**
* View model for the room list item
* @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);
// Actions
const openRoom = useCallback((): void => {
dispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: room.roomId,
metricsTrigger: "RoomList",
});
}, [room]);
return {
showHoverMenu,
openRoom,
};
}

View File

@@ -5,12 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import { useCallback } from "react";
import type { Room } from "matrix-js-sdk/src/matrix";
import type { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import dispatcher from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions";
import { type PrimaryFilter, type SecondaryFilters, useFilteredRooms } from "./useFilteredRooms";
import { type SortOption, useSorter } from "./useSorter";
@@ -19,12 +14,6 @@ export interface RoomListViewState {
* A list of rooms to be displayed in the left panel.
*/
rooms: Room[];
/**
* Open the room having given roomId.
*/
openRoom: (roomId: string) => void;
/**
* A list of objects that provide the view enough information
* to render primary room filters.
@@ -60,17 +49,8 @@ export function useRoomListViewModel(): RoomListViewState {
const { primaryFilters, rooms, activateSecondaryFilter, activeSecondaryFilter } = useFilteredRooms();
const { activeSortOption, sort } = useSorter();
const openRoom = useCallback((roomId: string): void => {
dispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: roomId,
metricsTrigger: "RoomList",
});
}, []);
return {
rooms,
openRoom,
primaryFilters,
activateSecondaryFilter,
activeSecondaryFilter,

View File

@@ -0,0 +1,25 @@
/*
* 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 Room, KnownMembership } from "matrix-js-sdk/src/matrix";
import { isKnockDenied } from "../../../utils/membership";
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents";
import { UIComponent } from "../../../settings/UIFeature";
/**
* Check if the user has access to the options menu.
* @param room
*/
export function hasAccessToOptionsMenu(room: Room): boolean {
return (
room.getMyMembership() === KnownMembership.Invite ||
(room.getMyMembership() !== KnownMembership.Knock &&
!isKnockDenied(room) &&
shouldShowComponent(UIComponent.RoomOptionsMenu))
);
}

View File

@@ -10,7 +10,7 @@ import { AutoSizer, List, type ListRowProps } from "react-virtualized";
import { type RoomListViewState } from "../../../viewmodels/roomlist/RoomListViewModel";
import { _t } from "../../../../languageHandler";
import { RoomListCell } from "./RoomListCell";
import { RoomListItemView } from "./RoomListItemView";
interface RoomListProps {
/**
@@ -22,12 +22,10 @@ interface RoomListProps {
/**
* A virtualized list of rooms.
*/
export function RoomList({ vm: { rooms, openRoom } }: RoomListProps): JSX.Element {
export function RoomList({ vm: { rooms } }: RoomListProps): JSX.Element {
const roomRendererMemoized = useCallback(
({ key, index, style }: ListRowProps) => (
<RoomListCell room={rooms[index]} key={key} style={style} onClick={() => openRoom(rooms[index].roomId)} />
),
[rooms, openRoom],
({ key, index, style }: ListRowProps) => <RoomListItemView room={rooms[index]} key={key} style={style} />,
[rooms],
);
// The first div is needed to make the virtualized list take all the remaining space and scroll correctly

View File

@@ -1,44 +0,0 @@
/*
* 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<HTMLButtonElement> {
/**
* The room to display
*/
room: Room;
}
/**
* A cell in the room list
*/
export function RoomListCell({ room, ...props }: RoomListCellProps): JSX.Element {
return (
<button
className="mx_RoomListCell"
type="button"
aria-label={_t("room_list|room|open_room", { roomName: room.name })}
{...props}
>
{/* We need this extra div between the button and the content in order to add a padding which is not messing with the virtualized list */}
<Flex className="mx_RoomListCell_container" gap="var(--cpd-space-3x)" align="center">
<DecoratedRoomAvatar room={room} size="32px" />
<Flex className="mx_RoomListCell_content" align="center">
{/* We truncate the room name when too long. Title here is to show the full name on hover */}
<span title={room.name}>{room.name}</span>
{/* Future hover menu et notification badges */}
</Flex>
</Flex>
</button>
);
}

View File

@@ -0,0 +1,154 @@
/*
* 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 ComponentProps, forwardRef, type JSX, useState } from "react";
import { IconButton, Menu, MenuItem, Separator, ToggleMenuItem, Tooltip } from "@vector-im/compound-web";
import MarkAsReadIcon from "@vector-im/compound-design-tokens/assets/web/icons/mark-as-read";
import MarkAsUnreadIcon from "@vector-im/compound-design-tokens/assets/web/icons/mark-as-unread";
import FavouriteIcon from "@vector-im/compound-design-tokens/assets/web/icons/favourite";
import ArrowDownIcon from "@vector-im/compound-design-tokens/assets/web/icons/arrow-down";
import UserAddIcon from "@vector-im/compound-design-tokens/assets/web/icons/user-add";
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 { type Room } from "matrix-js-sdk/src/matrix";
import { _t } from "../../../../languageHandler";
import { Flex } from "../../../utils/Flex";
import {
type RoomListItemMenuViewState,
useRoomListItemMenuViewModel,
} from "../../../viewmodels/roomlist/RoomListItemMenuViewModel";
interface RoomListItemMenuViewProps {
/**
* The room to display the menu for.
*/
room: Room;
/**
* Set the menu open state.
* @param isOpen
*/
setMenuOpen: (isOpen: boolean) => void;
}
/**
* A view for the room list item menu.
*/
export function RoomListItemMenuView({ room, setMenuOpen }: RoomListItemMenuViewProps): JSX.Element {
const vm = useRoomListItemMenuViewModel(room);
return (
<Flex className="mx_RoomListItemMenuView" align="center" gap="var(--cpd-space-1x)">
{vm.showMoreOptionsMenu && <MoreOptionsMenu setMenuOpen={setMenuOpen} vm={vm} />}
</Flex>
);
}
interface MoreOptionsMenuProps {
/**
* The view model state for the menu.
*/
vm: RoomListItemMenuViewState;
/**
* Set the menu open state.
* @param isOpen
*/
setMenuOpen: (isOpen: boolean) => void;
}
/**
* The more options menu for the room list item.
*/
function MoreOptionsMenu({ vm, setMenuOpen }: MoreOptionsMenuProps): JSX.Element {
const [open, setOpen] = useState(false);
return (
<Menu
open={open}
onOpenChange={(isOpen) => {
setOpen(isOpen);
setMenuOpen(isOpen);
}}
title={_t("room_list|room|more_options")}
showTitle={false}
align="start"
trigger={<MoreOptionsButton />}
>
{vm.canMarkAsRead && (
<MenuItem
Icon={MarkAsReadIcon}
label={_t("room_list|more_options|mark_read")}
onSelect={vm.markAsRead}
onClick={(evt) => evt.stopPropagation()}
/>
)}
{vm.canMarkAsUnread && (
<MenuItem
Icon={MarkAsUnreadIcon}
label={_t("room_list|more_options|mark_unread")}
onSelect={vm.markAsUnread}
onClick={(evt) => evt.stopPropagation()}
/>
)}
<ToggleMenuItem
checked={vm.isFavourite}
Icon={FavouriteIcon}
label={_t("room_list|more_options|favourited")}
onSelect={vm.toggleFavorite}
onClick={(evt) => evt.stopPropagation()}
/>
<MenuItem
Icon={ArrowDownIcon}
label={_t("room_list|more_options|low_priority")}
onSelect={vm.toggleLowPriority}
onClick={(evt) => evt.stopPropagation()}
/>
{vm.canInvite && (
<MenuItem
Icon={UserAddIcon}
label={_t("action|invite")}
onSelect={vm.invite}
onClick={(evt) => evt.stopPropagation()}
/>
)}
{vm.canCopyRoomLink && (
<MenuItem
Icon={LinkIcon}
label={_t("room_list|more_options|copy_link")}
onSelect={vm.copyRoomLink}
onClick={(evt) => evt.stopPropagation()}
/>
)}
<Separator />
<MenuItem
kind="critical"
Icon={LeaveIcon}
label={_t("room_list|more_options|leave_room")}
onSelect={vm.leaveRoom}
onClick={(evt) => evt.stopPropagation()}
/>
</Menu>
);
}
interface MoreOptionsButtonProps extends ComponentProps<typeof IconButton> {}
/**
* A button to trigger the more options menu.
*/
export const MoreOptionsButton = forwardRef<HTMLButtonElement, MoreOptionsButtonProps>(
function MoreOptionsButton(props, ref) {
return (
<Tooltip label={_t("room_list|room|more_options")}>
<IconButton aria-label={_t("room_list|room|more_options")} {...props} ref={ref}>
<OverflowIcon />
</IconButton>
</Tooltip>
);
},
);

View File

@@ -0,0 +1,76 @@
/*
* 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, useState } from "react";
import { type Room } from "matrix-js-sdk/src/matrix";
import classNames from "classnames";
import { useRoomListItemViewModel } from "../../../viewmodels/roomlist/RoomListItemViewModel";
import DecoratedRoomAvatar from "../../avatars/DecoratedRoomAvatar";
import { Flex } from "../../../utils/Flex";
import { _t } from "../../../../languageHandler";
import { RoomListItemMenuView } from "./RoomListItemMenuView";
interface RoomListItemViewPropsProps extends React.HTMLAttributes<HTMLButtonElement> {
/**
* The room to display
*/
room: Room;
}
/**
* An item in the room list
*/
export function RoomListItemView({ room, ...props }: RoomListItemViewPropsProps): JSX.Element {
const vm = useRoomListItemViewModel(room);
const [isHover, setIsHover] = useState(false);
const [isMenuOpen, setIsMenuOpen] = useState(false);
// The compound menu in RoomListItemMenuView needs to be rendered when the hover menu is shown
// Using display: none; and then display:flex when hovered in CSS causes the menu to be misaligned
const showHoverDecoration = (isMenuOpen || isHover) && vm.showHoverMenu;
return (
<button
className={classNames("mx_RoomListItemView", {
mx_RoomListItemView_menu_open: showHoverDecoration,
})}
type="button"
aria-label={_t("room_list|room|open_room", { roomName: room.name })}
onClick={() => vm.openRoom()}
onMouseOver={() => setIsHover(true)}
onMouseOut={() => setIsHover(false)}
onFocus={() => setIsHover(true)}
onBlur={() => setIsHover(false)}
{...props}
>
{/* We need this extra div between the button and the content in order to add a padding which is not messing with the virtualized list */}
<Flex className="mx_RoomListItemView_container" gap="var(--cpd-space-3x)" align="center">
<DecoratedRoomAvatar room={room} size="32px" />
<Flex
className="mx_RoomListItemView_content"
gap="var(--cpd-space-3x)"
align="center"
justify="space-between"
>
{/* We truncate the room name when too long. Title here is to show the full name on hover */}
<span title={room.name}>{room.name}</span>
{showHoverDecoration && (
<RoomListItemMenuView
room={room}
setMenuOpen={(isOpen) => {
if (isOpen) setIsMenuOpen(isOpen);
// To avoid icon blinking when closing the menu, we delay the state update
else setTimeout(() => setIsMenuOpen(isOpen), 0);
}}
/>
)}
</Flex>
</Flex>
</button>
);
}

View File

@@ -2112,6 +2112,14 @@
"other": "Currently joining %(count)s rooms"
},
"list_title": "Room list",
"more_options": {
"copy_link": "Copy room link",
"favourited": "Favourited",
"leave_room": "Leave room",
"low_priority": "Low priority",
"mark_read": "Mark as read",
"mark_unread": "Mark as unread"
},
"notification_options": "Notification options",
"open_space_menu": "Open space menu",
"primary_filters": "Room list filters",
@@ -2120,6 +2128,7 @@
"other": "Currently removing messages in %(count)s rooms"
},
"room": {
"more_options": "More Options",
"open_room": "Open room %(roomName)s"
},
"show_less": "Show less",