From bbd798ef36efdb4e0c33f1e473cad4d5e67c7316 Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Wed, 26 Mar 2025 14:32:02 +0100 Subject: [PATCH] New room list: add notification decoration (#29552) * chore: update @compound-web * feat(notification decoration): add NotificationDecoration component * feat(room list item): get notification state in view model * feat(room list item): use notification decoration in RoomListItemView * test(notification decoration): add tests * test(room list item view model): add a11yLabel tests * test(room list item): update tests * test(e2e): add decoration tests --- package.json | 2 +- .../room-list-panel/room-list.spec.ts | 231 ++++++++++++++---- .../room-list-item-activity-linux.png | Bin 0 -> 2372 bytes .../room-list-item-invited-linux.png | Bin 0 -> 2911 bytes .../room-list-item-mark-as-unread-linux.png | Bin 0 -> 3237 bytes .../room-list-item-mention-linux.png | Bin 0 -> 3321 bytes .../room-list-item-notification-linux.png | Bin 0 -> 3362 bytes .../room-list-item-silent-linux.png | Bin 0 -> 2378 bytes .../roomlist/RoomListItemViewModel.tsx | 46 +++- .../views/rooms/NotificationDecoration.tsx | 59 +++++ .../rooms/RoomListPanel/RoomListItemView.tsx | 11 +- src/i18n/strings/en_EN.json | 10 + .../roomlist/RoomListItemViewModel-test.tsx | 47 ++++ .../rooms/NotificationDecoration-test.tsx | 62 +++++ .../RoomListPanel/RoomListItemView-test.tsx | 16 +- .../NotificationDecoration-test.tsx.snap | 137 +++++++++++ yarn.lock | 8 +- 17 files changed, 563 insertions(+), 66 deletions(-) create mode 100644 playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-activity-linux.png create mode 100644 playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-invited-linux.png create mode 100644 playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-mark-as-unread-linux.png create mode 100644 playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-mention-linux.png create mode 100644 playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-notification-linux.png create mode 100644 playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-silent-linux.png create mode 100644 src/components/views/rooms/NotificationDecoration.tsx create mode 100644 test/unit-tests/components/views/rooms/NotificationDecoration-test.tsx create mode 100644 test/unit-tests/components/views/rooms/__snapshots__/NotificationDecoration-test.tsx.snap diff --git a/package.json b/package.json index d8c0a6b1ba..53886be6bd 100644 --- a/package.json +++ b/package.json @@ -92,7 +92,7 @@ "@types/png-chunks-extract": "^1.0.2", "@types/react-virtualized": "^9.21.30", "@vector-im/compound-design-tokens": "^4.0.0", - "@vector-im/compound-web": "^7.7.2", + "@vector-im/compound-web": "^7.9.0", "@vector-im/matrix-wysiwyg": "2.38.2", "@zxcvbn-ts/core": "^3.0.4", "@zxcvbn-ts/language-common": "^3.0.4", diff --git a/playwright/e2e/left-panel/room-list-panel/room-list.spec.ts b/playwright/e2e/left-panel/room-list-panel/room-list.spec.ts index ed402bc625..49966ecca1 100644 --- a/playwright/e2e/left-panel/room-list-panel/room-list.spec.ts +++ b/playwright/e2e/left-panel/room-list-panel/room-list.spec.ts @@ -13,6 +13,9 @@ test.describe("Room list", () => { test.use({ displayName: "Alice", labsFlags: ["feature_new_room_list"], + botCreateOpts: { + displayName: "BotBob", + }, }); /** @@ -26,71 +29,195 @@ test.describe("Room list", () => { test.beforeEach(async ({ page, app, user }) => { // The notification toast is displayed above the search section await app.closeNotificationToast(); - for (let i = 0; i < 30; i++) { - await app.client.createRoom({ name: `room${i}` }); - } }); - test("should render the room list", { tag: "@screenshot" }, async ({ page, app, user }) => { - const roomListView = getRoomList(page); - await expect(roomListView.getByRole("gridcell", { name: "Open room room29" })).toBeVisible(); - await expect(roomListView).toMatchScreenshot("room-list.png"); + test.describe("Room list", () => { + test.beforeEach(async ({ page, app, user }) => { + for (let i = 0; i < 30; i++) { + await app.client.createRoom({ name: `room${i}` }); + } + }); - await roomListView.hover(); - // Scroll to the end of the room list - await page.mouse.wheel(0, 1000); - await expect(roomListView.getByRole("gridcell", { name: "Open room room0" })).toBeVisible(); - await expect(roomListView).toMatchScreenshot("room-list-scrolled.png"); + test("should render the room list", { tag: "@screenshot" }, async ({ page, app, user }) => { + const roomListView = getRoomList(page); + await expect(roomListView.getByRole("gridcell", { name: "Open room room29" })).toBeVisible(); + await expect(roomListView).toMatchScreenshot("room-list.png"); + + await roomListView.hover(); + // Scroll to the end of the room list + await page.mouse.wheel(0, 1000); + await expect(roomListView.getByRole("gridcell", { name: "Open room room0" })).toBeVisible(); + await expect(roomListView).toMatchScreenshot("room-list-scrolled.png"); + }); + + test("should open the room when it is clicked", async ({ page, app, user }) => { + const roomListView = getRoomList(page); + await roomListView.getByRole("gridcell", { name: "Open room room29" }).click(); + await expect(page.getByRole("heading", { name: "room29", level: 1 })).toBeVisible(); + }); + + test("should open the more options menu", { tag: "@screenshot" }, async ({ page, app, user }) => { + const roomListView = getRoomList(page); + const roomItem = roomListView.getByRole("gridcell", { name: "Open room room29" }); + await roomItem.hover(); + + await expect(roomItem).toMatchScreenshot("room-list-item-hover.png"); + const roomItemMenu = roomItem.getByRole("button", { name: "More Options" }); + await roomItemMenu.click(); + await expect(page).toMatchScreenshot("room-list-item-open-more-options.png"); + + // It should make the room favourited + await page.getByRole("menuitemcheckbox", { name: "Favourited" }).click(); + + // Check that the room is favourited + await roomItem.hover(); + await roomItemMenu.click(); + await expect(page.getByRole("menuitemcheckbox", { name: "Favourited" })).toBeChecked(); + // It should show the invite dialog + await page.getByRole("menuitem", { name: "invite" }).click(); + await expect(page.getByRole("heading", { name: "Invite to room29" })).toBeVisible(); + await app.closeDialog(); + + // It should leave the room + await roomItem.hover(); + await roomItemMenu.click(); + await page.getByRole("menuitem", { name: "leave room" }).click(); + await expect(roomItem).not.toBeVisible(); + }); + + test("should scroll to the current room", async ({ page, app, user }) => { + const roomListView = getRoomList(page); + await roomListView.hover(); + // Scroll to the end of the room list + await page.mouse.wheel(0, 1000); + + await roomListView.getByRole("gridcell", { name: "Open room room0" }).click(); + + const filters = page.getByRole("listbox", { name: "Room list filters" }); + await filters.getByRole("option", { name: "People" }).click(); + await expect(roomListView.getByRole("gridcell", { name: "Open room room0" })).not.toBeVisible(); + + await filters.getByRole("option", { name: "People" }).click(); + await expect(roomListView.getByRole("gridcell", { name: "Open room room0" })).toBeVisible(); + }); }); - test("should open the room when it is clicked", async ({ page, app, user }) => { - const roomListView = getRoomList(page); - await roomListView.getByRole("gridcell", { name: "Open room room29" }).click(); - await expect(page.getByRole("heading", { name: "room29", level: 1 })).toBeVisible(); - }); + test.describe("Notification decoration", () => { + test("should render the invitation decoration", { tag: "@screenshot" }, async ({ page, app, user, bot }) => { + const roomListView = getRoomList(page); - test("should open the more options menu", { tag: "@screenshot" }, async ({ page, app, user }) => { - const roomListView = getRoomList(page); - const roomItem = roomListView.getByRole("gridcell", { name: "Open room room29" }); - await roomItem.hover(); + await bot.createRoom({ + name: "invited room", + invite: [user.userId], + is_direct: true, + }); + const invitedRoom = roomListView.getByRole("gridcell", { name: "invited room" }); + await expect(invitedRoom).toBeVisible(); + await expect(invitedRoom).toMatchScreenshot("room-list-item-invited.png"); + }); - await expect(roomItem).toMatchScreenshot("room-list-item-hover.png"); - const roomItemMenu = roomItem.getByRole("button", { name: "More Options" }); - await roomItemMenu.click(); - await expect(page).toMatchScreenshot("room-list-item-open-more-options.png"); + test("should render the regular decoration", { tag: "@screenshot" }, async ({ page, app, user, bot }) => { + const roomListView = getRoomList(page); - // It should make the room favourited - await page.getByRole("menuitemcheckbox", { name: "Favourited" }).click(); + const roomId = await app.client.createRoom({ name: "2 notifications" }); + await app.client.inviteUser(roomId, bot.credentials.userId); + await bot.joinRoom(roomId); - // Check that the room is favourited - await roomItem.hover(); - await roomItemMenu.click(); - await expect(page.getByRole("menuitemcheckbox", { name: "Favourited" })).toBeChecked(); - // It should show the invite dialog - await page.getByRole("menuitem", { name: "invite" }).click(); - await expect(page.getByRole("heading", { name: "Invite to room29" })).toBeVisible(); - await app.closeDialog(); + await bot.sendMessage(roomId, "I am a robot. Beep."); + await bot.sendMessage(roomId, "I am a robot. Beep."); - // It should leave the room - await roomItem.hover(); - await roomItemMenu.click(); - await page.getByRole("menuitem", { name: "leave room" }).click(); - await expect(roomItem).not.toBeVisible(); - }); + const room = roomListView.getByRole("gridcell", { name: "2 notifications" }); + await expect(room).toBeVisible(); + await expect(room.getByTestId("notification-decoration")).toHaveText("2"); + await expect(room).toMatchScreenshot("room-list-item-notification.png"); + }); - test("should scroll to the current room", async ({ page, app, user }) => { - const roomListView = getRoomList(page); - await roomListView.hover(); - // Scroll to the end of the room list - await page.mouse.wheel(0, 1000); + test("should render the mention decoration", { tag: "@screenshot" }, async ({ page, app, user, bot }) => { + const roomListView = getRoomList(page); - await roomListView.getByRole("gridcell", { name: "Open room room0" }).click(); + const roomId = await app.client.createRoom({ name: "mention" }); + await app.client.inviteUser(roomId, bot.credentials.userId); + await bot.joinRoom(roomId); - const filters = page.getByRole("listbox", { name: "Room list filters" }); - await filters.getByRole("option", { name: "People" }).click(); - await expect(roomListView.getByRole("gridcell", { name: "Open room room0" })).not.toBeVisible(); + const clientBot = await bot.prepareClient(); + await clientBot.evaluate( + async (client, { roomId, userId }) => { + await client.sendMessage(roomId, { + // @ts-ignore ignore usage of MsgType.text + "msgtype": "m.text", + "body": "User", + "format": "org.matrix.custom.html", + "formatted_body": `User`, + "m.mentions": { + user_ids: [userId], + }, + }); + }, + { roomId, userId: user.userId }, + ); + await bot.sendMessage(roomId, "I am a robot. Beep."); - await filters.getByRole("option", { name: "People" }).click(); - await expect(roomListView.getByRole("gridcell", { name: "Open room room0" })).toBeVisible(); + const room = roomListView.getByRole("gridcell", { name: "mention" }); + await expect(room).toBeVisible(); + await expect(room).toMatchScreenshot("room-list-item-mention.png"); + }); + + test("should render an activity decoration", { tag: "@screenshot" }, async ({ page, app, user, bot }) => { + const roomListView = getRoomList(page); + + const roomId = await app.client.createRoom({ name: "activity" }); + await app.client.inviteUser(roomId, bot.credentials.userId); + await bot.joinRoom(roomId); + + await app.viewRoomById(roomId); + await app.settings.openRoomSettings("Notifications"); + await page.getByText("@mentions & keywords").click(); + await app.settings.closeDialog(); + + await app.settings.openUserSettings("Notifications"); + await page.getByText("Show all activity in the room list (dots or number of unread messages)").click(); + await app.settings.closeDialog(); + + await bot.sendMessage(roomId, "I am a robot. Beep."); + + const room = roomListView.getByRole("gridcell", { name: "activity" }); + await expect(room.getByTestId("notification-decoration")).toBeVisible(); + await expect(room).toMatchScreenshot("room-list-item-activity.png"); + }); + + test("should render a mark as unread decoration", { tag: "@screenshot" }, async ({ page, app, user, bot }) => { + const roomListView = getRoomList(page); + + const roomId = await app.client.createRoom({ name: "mark as unread" }); + await app.client.inviteUser(roomId, bot.credentials.userId); + await bot.joinRoom(roomId); + + const room = roomListView.getByRole("gridcell", { name: "mark as unread" }); + await room.hover(); + await room.getByRole("button", { name: "More Options" }).click(); + await page.getByRole("menuitem", { name: "mark as unread" }).click(); + + // Remove hover on the room list item + await roomListView.hover(); + + await expect(room).toMatchScreenshot("room-list-item-mark-as-unread.png"); + }); + + test("should render silent decoration", { tag: "@screenshot" }, async ({ page, app, user, bot }) => { + const roomListView = getRoomList(page); + + const roomId = await app.client.createRoom({ name: "silent" }); + await app.client.inviteUser(roomId, bot.credentials.userId); + await bot.joinRoom(roomId); + + await app.viewRoomById(roomId); + await app.settings.openRoomSettings("Notifications"); + await page.getByText("Off").click(); + await app.settings.closeDialog(); + + const room = roomListView.getByRole("gridcell", { name: "silent" }); + await expect(room.getByTestId("notification-decoration")).toBeVisible(); + await expect(room).toMatchScreenshot("room-list-item-silent.png"); + }); }); }); diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-activity-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-activity-linux.png new file mode 100644 index 0000000000000000000000000000000000000000..436fe129c49fa00208da85f0738ad3d27e1f8fca GIT binary patch literal 2372 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 literal 0 HcmV?d00001 diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-invited-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-invited-linux.png new file mode 100644 index 0000000000000000000000000000000000000000..c6d11fc65cb7087af6ed0876a2d1d47dc3639a0a GIT binary patch literal 2911 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 literal 0 HcmV?d00001 diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-mark-as-unread-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-mark-as-unread-linux.png new file mode 100644 index 0000000000000000000000000000000000000000..784ac1b1bee1b4018c4444b20e40e826d9d6e5a8 GIT binary patch literal 3237 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 D5=3o( literal 0 HcmV?d00001 diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-notification-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-notification-linux.png new file mode 100644 index 0000000000000000000000000000000000000000..f5a542c93bcf7f4ebc657d37d775104a51ba52f5 GIT binary patch literal 3362 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 literal 0 HcmV?d00001 diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-silent-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-silent-linux.png new file mode 100644 index 0000000000000000000000000000000000000000..7922675ba3146a9e59e81d0022e58e7c68587d4c GIT binary patch literal 2378 zcmV-Q3AOf#P)Px;1W80eRCt{2-Fr||*&PS)a}(a-5nic)I0ZpbC~93L&=qzSP(V=>QM&HzY!&KQ z?NZn7?5r$Pfx5dY+o>X3cS^N(I@&IR4+duE!Gv>v_ux#vIXU;{a&x05H2SUUdfm0J9F{0suf4 zPx+cF}|ZOmyL3jmlIdgnsV6Zx5hT**^8?Nd5?+JA5=#(BO2?Fs;ZmrB3sz-Kb$ z>0K8L2?d0ZG&4SxKK-@`m`!@>r~ixKumxjoSd8&FIqI$eJ()|hV+EgYR{cJw=HhFutjJ{ z9ROz1s-P8bbC-8@OY-sy84O~}hILMk_9MSis?;}c->s;up-~oVywF_Us>PTiq)4hF zr?&R`LyCO9W+bVl#LdzP2MZ&HHQMI#R^M=Mefs<(3l?~%rKcf;)G8J2A^^O;ygYu+ zFR$zrcjx6_k69lc8y%5znb-4T)Nrp#;J?_%(o(-$jj_fHO}AQbno$($ej=+WXso)@ zF!Haaps}+_VwB>DqVCt!?Ai0Xz`&rDE4h33?rUslLzo|eBQI*TMQIvI!f@WwI~6{PND-JMr<`lamiRIy$7K|M||H zyNIGFihA7KTvu2B$tQn2{f}%nSC`|*GbKIJl$7M;3PE`>zDxWohpa zkN(`z)qSy`kjZ4mMn{ZBLs!={pUvZ8eUBLI!MZ1s!6c$TeJA?UcSc{4i~3Lf=DU_E zk>SNvDwR^H3JzYy4G1tXHC?!1p-QDj78d3fOa^0U$oN-)0hl4R6vq?-#}=D^%q9z%sWyPQY$pw%~GQjH#IYhkB<)v4de6qSFYse<{bO;UT|03iQz#UgnVRafWHOoV z?(PIZxVgG4UA7D%q)}^X>uT9-JEn;-a}a>ptfNbO`P$8m5uxkW1nYY&*1V&&6V_Xo z9?arGVe7u%CnYC;E)WQ+s;UbLcuXeK&em>xOM)N*xm-U#zavME@p!z}*4DpgXMg`-=w6EyuG~y0>Svk zwsy8jNgtg&`OU$D$!fL6&CNA2F=4~{Cx>{#GJWy|F4W# z5P&yadq-DMNf|-T?GKi-oTeE9 z0RZs%FJ3s;bq+$vfo-QV*Uxcw#IK{rdQIVf2y*dp!f!t{cmFx7_i~L@4FE85$%u;mrl~Kx7rxS98Rdm*X^$MD|<3u!s0syla-Tf&^>fU#C*D^{6 zu@`Q&cC=i(f7Qxuzd#Ila0!4}&7gg2)q|R%%<{5-S5LcdEjND2>b+c;PypbyU{EfM z(5kf$`7QVJ8@rod>~l}O*yrBM(LZXTPlzY%a}U64#0a?n0QiZ4k8uEiF31G{fG)@d w0DvyY1pt69$OQm^F31G{fG)@d0D!Ll0kKw?mCu1s0000007*qoM6N<$g5~jys{jB1 literal 0 HcmV?d00001 diff --git a/src/components/viewmodels/roomlist/RoomListItemViewModel.tsx b/src/components/viewmodels/roomlist/RoomListItemViewModel.tsx index 9e38e6e8d8..1d5d9aba11 100644 --- a/src/components/viewmodels/roomlist/RoomListItemViewModel.tsx +++ b/src/components/viewmodels/roomlist/RoomListItemViewModel.tsx @@ -5,13 +5,16 @@ * Please see LICENSE files in the repository root for full details. */ -import { useCallback } from "react"; +import { useCallback, useMemo } 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"; +import { _t } from "../../../languageHandler"; +import { type RoomNotificationState } from "../../../stores/notifications/RoomNotificationState"; +import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore"; export interface RoomListItemViewState { /** @@ -22,6 +25,14 @@ export interface RoomListItemViewState { * Open the room having given roomId. */ openRoom: () => void; + /** + * The a11y label for the room list item. + */ + a11yLabel: string; + /** + * The notification state of the room. + */ + notificationState: RoomNotificationState; } /** @@ -31,6 +42,8 @@ export interface RoomListItemViewState { export function useRoomListItemViewModel(room: Room): RoomListItemViewState { // incoming: Check notification menu rights const showHoverMenu = hasAccessToOptionsMenu(room); + const notificationState = useMemo(() => RoomNotificationStateStore.instance.getRoomState(room), [room]); + const a11yLabel = getA11yLabel(room, notificationState); // Actions @@ -43,7 +56,38 @@ export function useRoomListItemViewModel(room: Room): RoomListItemViewState { }, [room]); return { + notificationState, showHoverMenu, openRoom, + a11yLabel, }; } + +/** + * Get the a11y label for the room list item + * @param room + * @param notificationState + */ +function getA11yLabel(room: Room, notificationState: RoomNotificationState): string { + if (notificationState.isUnsetMessage) { + return _t("a11y|room_messsage_not_sent", { + roomName: room.name, + }); + } else if (notificationState.invited) { + return _t("a11y|room_n_unread_invite", { + roomName: room.name, + }); + } else if (notificationState.isMention) { + return _t("a11y|room_n_unread_messages_mentions", { + roomName: room.name, + count: notificationState.count, + }); + } else if (notificationState.hasUnreadCount) { + return _t("a11y|room_n_unread_messages", { + roomName: room.name, + count: notificationState.count, + }); + } else { + return _t("room_list|room|open_room", { roomName: room.name }); + } +} diff --git a/src/components/views/rooms/NotificationDecoration.tsx b/src/components/views/rooms/NotificationDecoration.tsx new file mode 100644 index 0000000000..2d993c2e82 --- /dev/null +++ b/src/components/views/rooms/NotificationDecoration.tsx @@ -0,0 +1,59 @@ +/* + * 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 HTMLProps, type JSX } from "react"; +import MentionIcon from "@vector-im/compound-design-tokens/assets/web/icons/mention"; +import ErrorIcon from "@vector-im/compound-design-tokens/assets/web/icons/error"; +import NotificationOffIcon from "@vector-im/compound-design-tokens/assets/web/icons/notifications-off-solid"; +import { UnreadCounter, Unread } from "@vector-im/compound-web"; + +import { Flex } from "../../utils/Flex"; +import { type RoomNotificationState } from "../../../stores/notifications/RoomNotificationState"; + +interface NotificationDecorationProps extends HTMLProps { + /** + * The notification state of the room or thread. + */ + notificationState: RoomNotificationState; +} + +/** + * Displays the notification decoration for a room or a thread. + */ +export function NotificationDecoration({ + notificationState, + ...props +}: NotificationDecorationProps): JSX.Element | null { + const { + hasAnyNotificationOrActivity, + isUnsetMessage, + invited, + isMention, + isActivityNotification, + isNotification, + count, + muted, + } = notificationState; + if (!hasAnyNotificationOrActivity && !muted) return null; + + return ( + + {isUnsetMessage && } + {invited && } + {isMention && } + {(isMention || isNotification) && } + {isActivityNotification && } + {muted && } + + ); +} diff --git a/src/components/views/rooms/RoomListPanel/RoomListItemView.tsx b/src/components/views/rooms/RoomListPanel/RoomListItemView.tsx index 37ad4ec848..151ca416e2 100644 --- a/src/components/views/rooms/RoomListPanel/RoomListItemView.tsx +++ b/src/components/views/rooms/RoomListPanel/RoomListItemView.tsx @@ -12,8 +12,8 @@ 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"; +import { NotificationDecoration } from "../NotificationDecoration"; interface RoomListItemViewPropsProps extends React.HTMLAttributes { /** @@ -46,7 +46,7 @@ export function RoomListItemView({ room, isSelected, ...props }: RoomListItemVie })} type="button" aria-selected={isSelected} - aria-label={_t("room_list|room|open_room", { roomName: room.name })} + aria-label={vm.a11yLabel} onClick={() => vm.openRoom()} onMouseOver={() => setIsHover(true)} onMouseOut={() => setIsHover(false)} @@ -65,7 +65,7 @@ export function RoomListItemView({ room, isSelected, ...props }: RoomListItemVie > {/* We truncate the room name when too long. Title here is to show the full name on hover */} {room.name} - {showHoverDecoration && ( + {showHoverDecoration ? ( { @@ -74,6 +74,11 @@ export function RoomListItemView({ room, isSelected, ...props }: RoomListItemVie else setTimeout(() => setIsMenuOpen(isOpen), 0); }} /> + ) : ( + <> + {/* aria-hidden because we summarise the unread count/notification status in a11yLabel variable */} + + )} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index ccf14fa4ba..b7339fd9f4 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -12,6 +12,16 @@ "other": "%(count)s unread messages including mentions." }, "recent_rooms": "Recent rooms", + "room_messsage_not_sent": "Open room %(roomName)s with an unset message.", + "room_n_unread_invite": "Open room %(roomName)s invitation.", + "room_n_unread_messages": { + "one": "Open room %(roomName)s with 1 unread message.", + "other": "Open room %(roomName)s with %(count)s unread messages." + }, + "room_n_unread_messages_mentions": { + "one": "Open room %(roomName)s with 1 unread mention.", + "other": "Open room %(roomName)s with %(count)s unread messages including mentions." + }, "room_name": "Room %(name)s", "room_status_bar": "Room status bar", "seek_bar_label": "Audio seek bar", diff --git a/test/unit-tests/components/viewmodels/roomlist/RoomListItemViewModel-test.tsx b/test/unit-tests/components/viewmodels/roomlist/RoomListItemViewModel-test.tsx index 2854c433e7..caba9abd1e 100644 --- a/test/unit-tests/components/viewmodels/roomlist/RoomListItemViewModel-test.tsx +++ b/test/unit-tests/components/viewmodels/roomlist/RoomListItemViewModel-test.tsx @@ -14,6 +14,8 @@ 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 { 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), @@ -46,4 +48,49 @@ describe("RoomListItemViewModel", () => { const { result: vm } = renderHook(() => useRoomListItemViewModel(room)); expect(vm.current.showHoverMenu).toBe(true); }); + + describe("a11yLabel", () => { + let notificationState: RoomNotificationState; + beforeEach(() => { + notificationState = new RoomNotificationState(room, false); + jest.spyOn(RoomNotificationStateStore.instance, "getRoomState").mockReturnValue(notificationState); + }); + + it.each([ + { + label: "unset message", + mock: () => jest.spyOn(notificationState, "isUnsetMessage", "get").mockReturnValue(true), + expected: "Open room roomName with an unset message.", + }, + { + label: "invitation", + mock: () => jest.spyOn(notificationState, "invited", "get").mockReturnValue(true), + expected: "Open room roomName invitation.", + }, + { + label: "mention", + mock: () => { + jest.spyOn(notificationState, "isMention", "get").mockReturnValue(true); + jest.spyOn(notificationState, "count", "get").mockReturnValue(3); + }, + expected: "Open room roomName with 3 unread messages including mentions.", + }, + { + label: "unread", + mock: () => { + jest.spyOn(notificationState, "hasUnreadCount", "get").mockReturnValue(true); + jest.spyOn(notificationState, "count", "get").mockReturnValue(3); + }, + expected: "Open room roomName with 3 unread messages.", + }, + { + label: "default", + expected: "Open room roomName", + }, + ])("should return the $label label", ({ mock, expected }) => { + mock?.(); + const { result: vm } = renderHook(() => useRoomListItemViewModel(room)); + expect(vm.current.a11yLabel).toBe(expected); + }); + }); }); diff --git a/test/unit-tests/components/views/rooms/NotificationDecoration-test.tsx b/test/unit-tests/components/views/rooms/NotificationDecoration-test.tsx new file mode 100644 index 0000000000..dd3ae7bc31 --- /dev/null +++ b/test/unit-tests/components/views/rooms/NotificationDecoration-test.tsx @@ -0,0 +1,62 @@ +/* + * 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 from "react"; +import { render, screen } from "jest-matrix-react"; + +import { type RoomNotificationState } from "../../../../../src/stores/notifications/RoomNotificationState"; +import { NotificationDecoration } from "../../../../../src/components/views/rooms/NotificationDecoration"; + +describe("", () => { + it("should not render if RoomNotificationState.isSilent=true", () => { + const state = { hasAnyNotificationOrActivity: false } as RoomNotificationState; + render(); + expect(screen.queryByTestId("notification-decoration")).toBeNull(); + }); + + it("should render the unset message decoration", () => { + const state = { hasAnyNotificationOrActivity: true, isUnsetMessage: true } as RoomNotificationState; + const { asFragment } = render(); + expect(asFragment()).toMatchSnapshot(); + }); + + it("should render the invitation decoration", () => { + const state = { hasAnyNotificationOrActivity: true, invited: true } as RoomNotificationState; + const { asFragment } = render(); + expect(asFragment()).toMatchSnapshot(); + }); + + it("should render the mention decoration", () => { + const state = { hasAnyNotificationOrActivity: true, isMention: true, count: 1 } as RoomNotificationState; + const { asFragment } = render(); + expect(asFragment()).toMatchSnapshot(); + }); + + it("should render the notification decoration", () => { + const state = { hasAnyNotificationOrActivity: true, isNotification: true, count: 1 } as RoomNotificationState; + const { asFragment } = render(); + expect(asFragment()).toMatchSnapshot(); + }); + + it("should render the notification decoration without count", () => { + const state = { hasAnyNotificationOrActivity: true, isNotification: true, count: 0 } as RoomNotificationState; + const { asFragment } = render(); + expect(asFragment()).toMatchSnapshot(); + }); + + it("should render the activity decoration", () => { + const state = { hasAnyNotificationOrActivity: true, isActivityNotification: true } as RoomNotificationState; + const { asFragment } = render(); + expect(asFragment()).toMatchSnapshot(); + }); + + it("should render the muted decoration", () => { + const state = { hasAnyNotificationOrActivity: true, muted: true } as RoomNotificationState; + const { asFragment } = render(); + expect(asFragment()).toMatchSnapshot(); + }); +}); diff --git a/test/unit-tests/components/views/rooms/RoomListPanel/RoomListItemView-test.tsx b/test/unit-tests/components/views/rooms/RoomListPanel/RoomListItemView-test.tsx index 217015451c..d08b24667b 100644 --- a/test/unit-tests/components/views/rooms/RoomListPanel/RoomListItemView-test.tsx +++ b/test/unit-tests/components/views/rooms/RoomListPanel/RoomListItemView-test.tsx @@ -18,26 +18,32 @@ import { type RoomListItemViewState, useRoomListItemViewModel, } from "../../../../../../src/components/viewmodels/roomlist/RoomListItemViewModel"; +import { RoomNotificationState } from "../../../../../../src/stores/notifications/RoomNotificationState"; jest.mock("../../../../../../src/components/viewmodels/roomlist/RoomListItemViewModel", () => ({ useRoomListItemViewModel: jest.fn(), })); describe("", () => { - const defaultValue: RoomListItemViewState = { - openRoom: jest.fn(), - showHoverMenu: false, - }; + let defaultValue: RoomListItemViewState; let matrixClient: MatrixClient; let room: Room; beforeEach(() => { - mocked(useRoomListItemViewModel).mockReturnValue(defaultValue); matrixClient = stubClient(); room = mkRoom(matrixClient, "room1"); DMRoomMap.makeShared(matrixClient); jest.spyOn(DMRoomMap.shared(), "getUserIdForRoomId").mockReturnValue(null); + + defaultValue = { + openRoom: jest.fn(), + showHoverMenu: false, + notificationState: new RoomNotificationState(room, false), + a11yLabel: "Open room room1", + }; + + mocked(useRoomListItemViewModel).mockReturnValue(defaultValue); }); test("should render a room item", () => { diff --git a/test/unit-tests/components/views/rooms/__snapshots__/NotificationDecoration-test.tsx.snap b/test/unit-tests/components/views/rooms/__snapshots__/NotificationDecoration-test.tsx.snap new file mode 100644 index 0000000000..ee0818e232 --- /dev/null +++ b/test/unit-tests/components/views/rooms/__snapshots__/NotificationDecoration-test.tsx.snap @@ -0,0 +1,137 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` should render the activity decoration 1`] = ` + +

+
+
+
+
+ +`; + +exports[` should render the invitation decoration 1`] = ` + +
+ + 1 + +
+
+`; + +exports[` should render the mention decoration 1`] = ` + +
+ + + + + 1 + +
+
+`; + +exports[` should render the muted decoration 1`] = ` + +
+ + + + +
+
+`; + +exports[` should render the notification decoration 1`] = ` + +
+ + 1 + +
+
+`; + +exports[` should render the notification decoration without count 1`] = ` + +
+
+
+ +`; + +exports[` should render the unset message decoration 1`] = ` + +
+ + + +
+
+`; diff --git a/yarn.lock b/yarn.lock index d5a5f6d701..cd09507552 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3597,10 +3597,10 @@ resolved "https://registry.yarnpkg.com/@vector-im/compound-design-tokens/-/compound-design-tokens-4.0.1.tgz#5c4ea7ad664d8e6206dc42e41649c80ef060a760" integrity sha512-V4AsK1FVFxZ6DmmCoeAi8FyvE7ODMlXPWjqRGotcnVaoGNrDQrVz2ZGV85DCz5ISxB3iynYASe6OXsDVXT1zFA== -"@vector-im/compound-web@^7.7.2": - version "7.7.2" - resolved "https://registry.yarnpkg.com/@vector-im/compound-web/-/compound-web-7.7.2.tgz#07e04a546b86e568b13263092b324efc76398487" - integrity sha512-RhPyKzfPo1HRyFi3wy8oc25IXbLLzTmw6A5QvPJgRlMW+LidwqCCYqmFeZrvWxK3pZPqE7hTJbHgUhGe7kxznw== +"@vector-im/compound-web@^7.9.0": + version "7.9.0" + resolved "https://registry.yarnpkg.com/@vector-im/compound-web/-/compound-web-7.9.0.tgz#72eccdd501e54f7b88317ba927bfeca61af72de0" + integrity sha512-2rBD+1Mit+kOd7+ZPUxdH7y6V1mi7Fga85cyC2cvUeL/sXBn0Q5HuyJ8whmdgLmgZiI4LkLriCFaeogYipKE+Q== dependencies: "@floating-ui/react" "^0.27.0" "@radix-ui/react-context-menu" "^2.2.1"