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 0000000000..436fe129c4
Binary files /dev/null and b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-activity-linux.png differ
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 0000000000..c6d11fc65c
Binary files /dev/null and b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-invited-linux.png differ
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 0000000000..784ac1b1be
Binary files /dev/null and b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-mark-as-unread-linux.png differ
diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-mention-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-mention-linux.png
new file mode 100644
index 0000000000..5e04d46ba0
Binary files /dev/null and b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-mention-linux.png differ
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 0000000000..f5a542c93b
Binary files /dev/null and b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-notification-linux.png differ
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 0000000000..7922675ba3
Binary files /dev/null and b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-silent-linux.png differ
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`] = `
+
+
+
+`;
+
+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"