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,10 @@ import { type MatrixClient, type Room } from "matrix-js-sdk/src/matrix";
import { mkStubRoom, stubClient, withClientContextRenderOptions } from "../../../../test-utils";
import { useRoomListItemMenuViewModel } from "../../../../../src/components/viewmodels/roomlist/RoomListItemMenuViewModel";
import { hasAccessToOptionsMenu } from "../../../../../src/components/viewmodels/roomlist/utils";
import {
hasAccessToNotificationMenu,
hasAccessToOptionsMenu,
} from "../../../../../src/components/viewmodels/roomlist/utils";
import DMRoomMap from "../../../../../src/utils/DMRoomMap";
import { DefaultTagID } from "../../../../../src/stores/room-list/models";
import { useUnreadNotifications } from "../../../../../src/hooks/useUnreadNotifications";
@@ -19,15 +22,22 @@ import { NotificationLevel } from "../../../../../src/stores/notifications/Notif
import { clearRoomNotification, setMarkedUnreadState } from "../../../../../src/utils/notifications";
import { tagRoom } from "../../../../../src/utils/room/tagRoom";
import dispatcher from "../../../../../src/dispatcher/dispatcher";
import { useNotificationState } from "../../../../../src/hooks/useRoomNotificationState";
import { RoomNotifState } from "../../../../../src/RoomNotifs";
jest.mock("../../../../../src/components/viewmodels/roomlist/utils", () => ({
hasAccessToOptionsMenu: jest.fn().mockReturnValue(false),
hasAccessToNotificationMenu: jest.fn().mockReturnValue(false),
}));
jest.mock("../../../../../src/hooks/useUnreadNotifications", () => ({
useUnreadNotifications: jest.fn(),
}));
jest.mock("../../../../../src/hooks/useRoomNotificationState", () => ({
useNotificationState: jest.fn(),
}));
jest.mock("../../../../../src/utils/notifications", () => ({
clearRoomNotification: jest.fn(),
setMarkedUnreadState: jest.fn(),
@@ -49,6 +59,7 @@ describe("RoomListItemMenuViewModel", () => {
jest.spyOn(DMRoomMap.shared(), "getUserIdForRoomId").mockReturnValue(null);
mocked(useUnreadNotifications).mockReturnValue({ symbol: null, count: 0, level: NotificationLevel.None });
mocked(useNotificationState).mockReturnValue([RoomNotifState.AllMessages, jest.fn()]);
jest.spyOn(dispatcher, "dispatch");
});
@@ -76,6 +87,12 @@ describe("RoomListItemMenuViewModel", () => {
expect(result.current.showMoreOptionsMenu).toBe(true);
});
it("should has showNotificationMenu to be true", () => {
mocked(hasAccessToNotificationMenu).mockReturnValue(true);
const { result } = render();
expect(result.current.showNotificationMenu).toBe(true);
});
it("should be able to invite", () => {
jest.spyOn(room, "canInvite").mockReturnValue(true);
const { result } = render();
@@ -106,6 +123,29 @@ describe("RoomListItemMenuViewModel", () => {
expect(result.current.canMarkAsUnread).toBe(false);
});
it("should has isNotificationAllMessage to be true", () => {
const { result } = render();
expect(result.current.isNotificationAllMessage).toBe(true);
});
it("should has isNotificationAllMessageLoud to be true", () => {
mocked(useNotificationState).mockReturnValue([RoomNotifState.AllMessagesLoud, jest.fn()]);
const { result } = render();
expect(result.current.isNotificationAllMessageLoud).toBe(true);
});
it("should has isNotificationMentionOnly to be true", () => {
mocked(useNotificationState).mockReturnValue([RoomNotifState.MentionsOnly, jest.fn()]);
const { result } = render();
expect(result.current.isNotificationMentionOnly).toBe(true);
});
it("should has isNotificationMute to be true", () => {
mocked(useNotificationState).mockReturnValue([RoomNotifState.Mute, jest.fn()]);
const { result } = render();
expect(result.current.isNotificationMute).toBe(true);
});
// Actions
it("should mark as read", () => {
@@ -170,4 +210,12 @@ describe("RoomListItemMenuViewModel", () => {
room_id: room.roomId,
});
});
it("should call setRoomNotifState", () => {
const setRoomNotifState = jest.fn();
mocked(useNotificationState).mockReturnValue([RoomNotifState.AllMessages, setRoomNotifState]);
const { result } = render();
result.current.setRoomNotifState(RoomNotifState.Mute);
expect(setRoomNotifState).toHaveBeenCalledWith(RoomNotifState.Mute);
});
});

View File

@@ -12,13 +12,17 @@ import { mocked } from "jest-mock";
import dispatcher from "../../../../../src/dispatcher/dispatcher";
import { Action } from "../../../../../src/dispatcher/actions";
import { useRoomListItemViewModel } from "../../../../../src/components/viewmodels/roomlist/RoomListItemViewModel";
import { createTestClient, mkStubRoom } from "../../../../test-utils";
import { hasAccessToOptionsMenu } from "../../../../../src/components/viewmodels/roomlist/utils";
import { createTestClient, mkStubRoom, withClientContextRenderOptions } from "../../../../test-utils";
import {
hasAccessToNotificationMenu,
hasAccessToOptionsMenu,
} from "../../../../../src/components/viewmodels/roomlist/utils";
import { RoomNotificationState } from "../../../../../src/stores/notifications/RoomNotificationState";
import { RoomNotificationStateStore } from "../../../../../src/stores/notifications/RoomNotificationStateStore";
jest.mock("../../../../../src/components/viewmodels/roomlist/utils", () => ({
hasAccessToOptionsMenu: jest.fn().mockReturnValue(false),
hasAccessToNotificationMenu: jest.fn().mockReturnValue(false),
}));
describe("RoomListItemViewModel", () => {
@@ -30,7 +34,10 @@ describe("RoomListItemViewModel", () => {
});
it("should dispatch view room action on openRoom", async () => {
const { result: vm } = renderHook(() => useRoomListItemViewModel(room));
const { result: vm } = renderHook(
() => useRoomListItemViewModel(room),
withClientContextRenderOptions(room.client),
);
const fn = jest.spyOn(dispatcher, "dispatch");
vm.current.openRoom();
@@ -45,7 +52,19 @@ describe("RoomListItemViewModel", () => {
it("should show hover menu if user has access to options menu", async () => {
mocked(hasAccessToOptionsMenu).mockReturnValue(true);
const { result: vm } = renderHook(() => useRoomListItemViewModel(room));
const { result: vm } = renderHook(
() => useRoomListItemViewModel(room),
withClientContextRenderOptions(room.client),
);
expect(vm.current.showHoverMenu).toBe(true);
});
it("should show hover menu if user has access to notification menu", async () => {
mocked(hasAccessToNotificationMenu).mockReturnValue(true);
const { result: vm } = renderHook(
() => useRoomListItemViewModel(room),
withClientContextRenderOptions(room.client),
);
expect(vm.current.showHoverMenu).toBe(true);
});

View File

@@ -10,7 +10,11 @@ import { mocked } from "jest-mock";
import type { MatrixClient, Room, RoomState } from "matrix-js-sdk/src/matrix";
import { createTestClient, mkStubRoom } from "../../../../test-utils";
import { shouldShowComponent } from "../../../../../src/customisations/helpers/UIComponents";
import { hasCreateRoomRights, createRoom } from "../../../../../src/components/viewmodels/roomlist/utils";
import {
hasCreateRoomRights,
createRoom,
hasAccessToNotificationMenu,
} from "../../../../../src/components/viewmodels/roomlist/utils";
import defaultDispatcher from "../../../../../src/dispatcher/dispatcher";
import { Action } from "../../../../../src/dispatcher/actions";
import { showCreateNewRoom } from "../../../../../src/utils/space";
@@ -66,4 +70,13 @@ describe("utils", () => {
expect(hasCreateRoomRights(matrixClient)).toBe(true);
});
});
it("hasAccessToNotificationMenu", () => {
mocked(shouldShowComponent).mockReturnValue(true);
const room = mkStubRoom("roomId", "roomName", matrixClient);
const isGuest = false;
const isArchived = false;
expect(hasAccessToNotificationMenu(room, isGuest, isArchived)).toBe(true);
});
});

View File

@@ -17,6 +17,7 @@ import {
import type { MatrixClient, Room } from "matrix-js-sdk/src/matrix";
import { mkRoom, stubClient } from "../../../../../test-utils";
import { RoomListItemMenuView } from "../../../../../../src/components/views/rooms/RoomListPanel/RoomListItemMenuView";
import { RoomNotifState } from "../../../../../../src/RoomNotifs";
jest.mock("../../../../../../src/components/viewmodels/roomlist/RoomListItemMenuViewModel", () => ({
useRoomListItemMenuViewModel: jest.fn(),
@@ -25,11 +26,16 @@ jest.mock("../../../../../../src/components/viewmodels/roomlist/RoomListItemMenu
describe("<RoomListItemMenuView />", () => {
const defaultValue: RoomListItemMenuViewState = {
showMoreOptionsMenu: true,
showNotificationMenu: true,
isFavourite: true,
canInvite: true,
canMarkAsUnread: true,
canMarkAsRead: true,
canCopyRoomLink: true,
isNotificationAllMessage: true,
isNotificationMentionOnly: true,
isNotificationAllMessageLoud: true,
isNotificationMute: true,
copyRoomLink: jest.fn(),
markAsUnread: jest.fn(),
markAsRead: jest.fn(),
@@ -37,6 +43,7 @@ describe("<RoomListItemMenuView />", () => {
toggleLowPriority: jest.fn(),
toggleFavorite: jest.fn(),
invite: jest.fn(),
setRoomNotifState: jest.fn(),
};
let matrixClient: MatrixClient;
@@ -58,22 +65,37 @@ describe("<RoomListItemMenuView />", () => {
expect(asFragment()).toMatchSnapshot();
});
it("should render the notification options menu", () => {
const { asFragment } = renderMenu();
expect(screen.getByRole("button", { name: "Notification options" })).toBeInTheDocument();
expect(asFragment()).toMatchSnapshot();
});
it("should not render the more options menu when showMoreOptionsMenu is false", () => {
mocked(useRoomListItemMenuViewModel).mockReturnValue({ ...defaultValue, showMoreOptionsMenu: false });
renderMenu();
expect(screen.queryByRole("button", { name: "More Options" })).toBeNull();
});
it("should call setMenuOpen when the menu is opened", async () => {
const user = userEvent.setup();
const setMenuOpen = jest.fn();
renderMenu(setMenuOpen);
await user.click(screen.getByRole("button", { name: "More Options" }));
expect(setMenuOpen).toHaveBeenCalledWith(true);
it("should not render the notification options menu when showNotificationMenu is false", () => {
mocked(useRoomListItemMenuViewModel).mockReturnValue({ ...defaultValue, showNotificationMenu: false });
renderMenu();
expect(screen.queryByRole("button", { name: "Notification options" })).toBeNull();
});
it("should display all the buttons and have the actions linked", async () => {
it.each([["More Options"], ["Notification options"]])(
"should call setMenuOpen when the menu is opened for %s menu",
async (label) => {
const user = userEvent.setup();
const setMenuOpen = jest.fn();
renderMenu(setMenuOpen);
await user.click(screen.getByRole("button", { name: label }));
expect(setMenuOpen).toHaveBeenCalledWith(true);
},
);
it("should display all the buttons and have the actions linked for the more options menu", async () => {
const user = userEvent.setup();
renderMenu();
@@ -107,4 +129,27 @@ describe("<RoomListItemMenuView />", () => {
await user.click(screen.getByRole("menuitem", { name: "Leave room" }));
expect(defaultValue.leaveRoom).toHaveBeenCalled();
});
it("should display all the buttons and have the actions linked for the notification options menu", async () => {
const user = userEvent.setup();
renderMenu();
const openMenu = screen.getByRole("button", { name: "Notification options" });
await user.click(openMenu);
await user.click(screen.getByRole("menuitem", { name: "Match default settings" }));
expect(defaultValue.setRoomNotifState).toHaveBeenCalledWith(RoomNotifState.AllMessages);
await user.click(openMenu);
await user.click(screen.getByRole("menuitem", { name: "All messages" }));
expect(defaultValue.setRoomNotifState).toHaveBeenCalledWith(RoomNotifState.AllMessagesLoud);
await user.click(openMenu);
await user.click(screen.getByRole("menuitem", { name: "Mentions and keywords" }));
expect(defaultValue.setRoomNotifState).toHaveBeenCalledWith(RoomNotifState.MentionsOnly);
await user.click(openMenu);
await user.click(screen.getByRole("menuitem", { name: "Mute room" }));
expect(defaultValue.setRoomNotifState).toHaveBeenCalledWith(RoomNotifState.Mute);
});
});

View File

@@ -37,6 +37,115 @@ exports[`<RoomListItemMenuView /> should render the more options menu 1`] = `
</svg>
</div>
</button>
<button
aria-disabled="false"
aria-expanded="false"
aria-haspopup="menu"
aria-label="Notification options"
aria-labelledby=":r9:"
class="_icon-button_m2erp_8"
data-state="closed"
id="radix-:r7:"
role="button"
style="--cpd-icon-button-size: 24px;"
tabindex="0"
type="button"
>
<div
class="_indicator-icon_zr2a0_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m4.917 2.083 17 17a1 1 0 0 1-1.414 1.414L19.006 19H4.414c-.89 0-1.337-1.077-.707-1.707L5 16v-6s0-2.034 1.096-3.91L3.504 3.498a1 1 0 0 1 1.414-1.414M19 13.35 9.136 3.484C9.93 3.181 10.874 3 12 3c7 0 7 7 7 7z"
/>
<path
d="M10 20h4a2 2 0 0 1-4 0"
/>
</svg>
</div>
</button>
</div>
</DocumentFragment>
`;
exports[`<RoomListItemMenuView /> should render the notification options menu 1`] = `
<DocumentFragment>
<div
class="mx_Flex mx_RoomListItemMenuView"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-0-5x); --mx-flex-wrap: nowrap;"
>
<button
aria-disabled="false"
aria-expanded="false"
aria-haspopup="menu"
aria-label="More Options"
aria-labelledby=":ri:"
class="_icon-button_m2erp_8"
data-state="closed"
id="radix-:rg:"
role="button"
style="--cpd-icon-button-size: 24px;"
tabindex="0"
type="button"
>
<div
class="_indicator-icon_zr2a0_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
/>
</svg>
</div>
</button>
<button
aria-disabled="false"
aria-expanded="false"
aria-haspopup="menu"
aria-label="Notification options"
aria-labelledby=":rp:"
class="_icon-button_m2erp_8"
data-state="closed"
id="radix-:rn:"
role="button"
style="--cpd-icon-button-size: 24px;"
tabindex="0"
type="button"
>
<div
class="_indicator-icon_zr2a0_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m4.917 2.083 17 17a1 1 0 0 1-1.414 1.414L19.006 19H4.414c-.89 0-1.337-1.077-.707-1.707L5 16v-6s0-2.034 1.096-3.91L3.504 3.498a1 1 0 0 1 1.414-1.414M19 13.35 9.136 3.484C9.93 3.181 10.874 3 12 3c7 0 7 7 7 7z"
/>
<path
d="M10 20h4a2 2 0 0 1-4 0"
/>
</svg>
</div>
</button>
</div>
</DocumentFragment>
`;