diff --git a/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-sections.spec.ts b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-sections.spec.ts
index e1ef31e92f..c6510173b0 100644
--- a/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-sections.spec.ts
+++ b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-sections.spec.ts
@@ -217,29 +217,92 @@ test.describe("Room list sections", () => {
});
});
- test("should show unread indicator on section header", async ({ page, app, bot }) => {
- // Create a favourite room
- const favouriteId = await app.client.createRoom({ name: "favourite room" });
- await app.client.evaluate(async (client, roomId) => {
- await client.setRoomTag(roomId, "m.favourite");
- }, favouriteId);
+ test.describe("Section header notification", () => {
+ test("should show unread indicator on section header", async ({ page, app, bot }) => {
+ // Create a favourite room
+ const favouriteId = await app.client.createRoom({ name: "favourite room" });
+ await app.client.evaluate(async (client, roomId) => {
+ await client.setRoomTag(roomId, "m.favourite");
+ }, favouriteId);
- const roomList = getRoomList(page);
+ const roomList = getRoomList(page);
- // Invite the bot and have it send a message to generate an unread
- await app.client.inviteUser(favouriteId, bot.credentials.userId);
- await bot.joinRoom(favouriteId);
- await bot.sendMessage(favouriteId, "Hello from bot!");
+ // Invite the bot and have it send a message to generate an unread
+ await app.client.inviteUser(favouriteId, bot.credentials.userId);
+ await bot.joinRoom(favouriteId);
+ await bot.sendMessage(favouriteId, "Hello from bot!");
- let sectionHeader = getSectionHeader(page, "Favourites", true);
- await expect(sectionHeader).toBeVisible();
+ let sectionHeader = getSectionHeader(page, "Favourites", true);
+ await expect(sectionHeader).toBeVisible();
- // Open the room to mark it as read
- await roomList.getByRole("row", { name: "Open room favourite room" }).click();
+ // Open the room to mark it as read
+ await roomList.getByRole("row", { name: "Open room favourite room" }).click();
- // The section should no longer be unread
- sectionHeader = getSectionHeader(page, "Favourites", false);
- await expect(sectionHeader).toBeVisible();
+ // The section should no longer be unread
+ sectionHeader = getSectionHeader(page, "Favourites", false);
+ await expect(sectionHeader).toBeVisible();
+ });
+
+ test(
+ "should aggregate notification decorations on the collapsed section header",
+ { tag: "@screenshot" },
+ async ({ page, app, user, bot }) => {
+ // A favourite room to keep the room list in section mode (otherwise it renders as a flat list)
+ const favouriteId = await app.client.createRoom({ name: "favourite room" });
+ await app.client.evaluate(async (client, roomId) => {
+ await client.setRoomTag(roomId, "m.favourite");
+ }, favouriteId);
+
+ // A room with a mention, landing in the Chats section
+ const mentionId = await app.client.createRoom({ name: "mention room" });
+ await app.client.inviteUser(mentionId, bot.credentials.userId);
+ await bot.joinRoom(mentionId);
+ 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: mentionId, userId: user.userId },
+ );
+
+ // A room we are invited to, landing in the Chats section
+ await bot.createRoom({
+ name: "invited room",
+ invite: [user.userId],
+ is_direct: true,
+ });
+
+ const roomList = getRoomList(page);
+
+ // Wait for the mention decoration to sync onto the mention room before collapsing, so the
+ // section header aggregation has the room states available.
+ await expect(
+ roomList.getByRole("row", { name: /mention room/ }).getByTestId("notification-decoration"),
+ ).toBeVisible();
+
+ // Collapse the Chats section so the aggregated decoration is displayed on its header
+ const chatsHeader = getSectionHeader(page, "Chats", true);
+ await expect(chatsHeader).toBeVisible();
+ await chatsHeader.click();
+
+ // The header hides its decoration while hovered/focused, so move the pointer away
+ await page.mouse.move(0, 0);
+
+ // The collapsed header aggregates the mention and the invitation
+ await expect(chatsHeader.getByTestId("notification-decoration")).toBeVisible();
+
+ await expect(chatsHeader).toMatchScreenshot("room-list-section-header-notification.png");
+ },
+ );
});
test.describe("Sections and filters interaction", () => {
diff --git a/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list-sections.spec.ts/room-list-section-header-notification-linux.png b/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list-sections.spec.ts/room-list-section-header-notification-linux.png
new file mode 100644
index 0000000000..dda3fd97bc
Binary files /dev/null and b/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list-sections.spec.ts/room-list-section-header-notification-linux.png differ
diff --git a/apps/web/src/viewmodels/room-list/RoomListSectionHeaderViewModel.ts b/apps/web/src/viewmodels/room-list/RoomListSectionHeaderViewModel.ts
index 1e238b9fd9..87630425b6 100644
--- a/apps/web/src/viewmodels/room-list/RoomListSectionHeaderViewModel.ts
+++ b/apps/web/src/viewmodels/room-list/RoomListSectionHeaderViewModel.ts
@@ -6,8 +6,10 @@
*/
import { type Room } from "matrix-js-sdk/src/matrix";
+import { CallType } from "matrix-js-sdk/src/webrtc/call";
import {
BaseViewModel,
+ type NotificationDecorationData,
type RoomListSectionHeaderActions,
type RoomListSectionHeaderViewSnapshot,
} from "@element-hq/web-shared-components";
@@ -19,6 +21,8 @@ import SettingsStore from "../../settings/SettingsStore";
import RoomListStoreV3 from "../../stores/room-list-v3/RoomListStoreV3";
import { getCustomSectionData, isCustomSectionTag, isDefaultSectionTag } from "../../stores/room-list-v3/section";
import PosthogTrackers from "../../PosthogTrackers";
+import { CallStore, CallStoreEvent } from "../../stores/CallStore";
+import { type Call, CallEvent } from "../../models/Call";
interface RoomListSectionHeaderViewModelProps {
tag: string;
@@ -45,6 +49,11 @@ export class RoomListSectionHeaderViewModel
*/
private readonly expandedBySpace = new Map();
+ /**
+ * The calls of the rooms currently in this section that we are listening to, used to aggregate the call decoration.
+ */
+ private currentCalls = new Set();
+
public constructor(props: RoomListSectionHeaderViewModelProps) {
const isDefaultSection = isDefaultSectionTag(props.tag);
super(props, {
@@ -58,6 +67,9 @@ export class RoomListSectionHeaderViewModel
this.onCustomSectionDataChange(),
);
this.disposables.track(() => SettingsStore.unwatchSetting(sectionWatherRef));
+
+ // Recompute the decoration when a call starts or ends in any room
+ this.disposables.trackListener(CallStore.instance, CallStoreEvent.Call, this.onCallChanged);
}
public onClick = (): void => {
@@ -107,7 +119,7 @@ export class RoomListSectionHeaderViewModel
// Unsubscribe from rooms no longer in the section
for (const state of this.roomNotificationStates) {
if (!newStates.has(state)) {
- state.off(NotificationStateEvents.Update, this.updateUnreadState);
+ state.off(NotificationStateEvents.Update, this.updateNotificationState);
}
}
@@ -115,27 +127,112 @@ export class RoomListSectionHeaderViewModel
for (const state of newStates) {
if (!this.roomNotificationStates.has(state)) {
// We don't use trackListener because we don't want to grow the disposables indefinitely as rooms are added and removed from the section
- state.on(NotificationStateEvents.Update, this.updateUnreadState);
+ state.on(NotificationStateEvents.Update, this.updateNotificationState);
}
}
this.roomNotificationStates = newStates;
- this.updateUnreadState();
+ this.updateCallListeners();
+ this.updateNotificationState();
}
/**
- * Update the unread state of the section header based on the notification states of the tracked rooms.
+ * Subscribe to participant/type changes of the calls in the section's rooms, and unsubscribe
+ * from calls that are no longer present. Mirrors the call tracking done per room list item.
*/
- private updateUnreadState = (): void => {
- const isUnread = [...this.roomNotificationStates].some((state) => state.hasAnyNotificationOrActivity);
- this.snapshot.merge({ isUnread });
+ private updateCallListeners(): void {
+ const newCalls = new Set();
+ for (const state of this.roomNotificationStates) {
+ const call = state.room && CallStore.instance.getCall(state.room.roomId);
+ if (call) newCalls.add(call);
+ }
+
+ // Unsubscribe from calls no longer present
+ for (const call of this.currentCalls) {
+ if (!newCalls.has(call)) {
+ call.off(CallEvent.Participants, this.updateNotificationState);
+ call.off(CallEvent.CallTypeChanged, this.updateNotificationState);
+ }
+ }
+
+ // Subscribe to newly added calls
+ for (const call of newCalls) {
+ if (!this.currentCalls.has(call)) {
+ call.on(CallEvent.Participants, this.updateNotificationState);
+ call.on(CallEvent.CallTypeChanged, this.updateNotificationState);
+ }
+ }
+
+ this.currentCalls = newCalls;
+ }
+
+ private onCallChanged = (): void => {
+ this.updateCallListeners();
+ this.updateNotificationState();
+ };
+
+ /**
+ * Update the section header from the notification states of the tracked rooms.
+ * Computes both the unread (bold) state and a merged notification decoration that aggregates
+ * the rooms' notifications. The activity "dot" is intentionally excluded from the decoration.
+ */
+ private updateNotificationState = (): void => {
+ let isUnread = false;
+ let isMention = false;
+ let isNotification = false;
+ let isUnsentMessage = false;
+ let hasUnreadCount = false;
+ let invited = false;
+ let count = 0;
+ let callType: "video" | "voice" | undefined = undefined;
+
+ for (const state of this.roomNotificationStates) {
+ if (state.hasAnyNotificationOrActivity) isUnread = true;
+ if (state.isMention) isMention = true;
+ if (state.isNotification) isNotification = true;
+ if (state.isUnsentMessage) isUnsentMessage = true;
+ if (state.hasUnreadCount) hasUnreadCount = true;
+ if (state.invited) invited = true;
+ // Mention, notification, Mark as unread are aggregated
+ if (state.isMention || state.isNotification) count += state.count || 1;
+
+ // Aggregate active calls, preferring a video call over a voice call
+ const call = state.room && CallStore.instance.getCall(state.room.roomId);
+ if (call && call.participants.size > 0) {
+ if (call.callType === CallType.Video) callType = "video";
+ else if (call.callType === CallType.Voice && callType !== "video") callType = "voice";
+ }
+ }
+
+ const notification: NotificationDecorationData = {
+ // Drives the decoration's early-return: an activity-only section stays bold but shows no badge
+ hasAnyNotificationOrActivity:
+ isMention || isNotification || isUnsentMessage || invited || Boolean(callType),
+ isUnsentMessage,
+ isMention,
+ isNotification,
+ hasUnreadCount,
+ count,
+ invited,
+ callType,
+ // The activity dot and muted bell are intentionally not aggregated onto the section header
+ isActivityNotification: false,
+ muted: false,
+ };
+
+ this.snapshot.merge({ isUnread, notification });
};
public dispose(): void {
for (const state of this.roomNotificationStates) {
- state.off(NotificationStateEvents.Update, this.updateUnreadState);
+ state.off(NotificationStateEvents.Update, this.updateNotificationState);
}
this.roomNotificationStates.clear();
+ for (const call of this.currentCalls) {
+ call.off(CallEvent.Participants, this.updateNotificationState);
+ call.off(CallEvent.CallTypeChanged, this.updateNotificationState);
+ }
+ this.currentCalls.clear();
super.dispose();
}
diff --git a/apps/web/test/viewmodels/room-list/RoomListSectionHeaderViewModel-test.ts b/apps/web/test/viewmodels/room-list/RoomListSectionHeaderViewModel-test.ts
index a68cc188f9..18fef44e8e 100644
--- a/apps/web/test/viewmodels/room-list/RoomListSectionHeaderViewModel-test.ts
+++ b/apps/web/test/viewmodels/room-list/RoomListSectionHeaderViewModel-test.ts
@@ -6,11 +6,14 @@
*/
import { type MatrixClient, type Room } from "matrix-js-sdk/src/matrix";
+import { CallType } from "matrix-js-sdk/src/webrtc/call";
import { RoomListSectionHeaderViewModel } from "../../../src/viewmodels/room-list/RoomListSectionHeaderViewModel";
import { RoomNotificationState } from "../../../src/stores/notifications/RoomNotificationState";
import { RoomNotificationStateStore } from "../../../src/stores/notifications/RoomNotificationStateStore";
import { NotificationStateEvents } from "../../../src/stores/notifications/NotificationState";
+import { CallStore } from "../../../src/stores/CallStore";
+import { type Call } from "../../../src/models/Call";
import { createTestClient, mkRoom } from "../../test-utils";
import SettingsStore from "../../../src/settings/SettingsStore";
import RoomListStoreV3 from "../../../src/stores/room-list-v3/RoomListStoreV3";
@@ -310,6 +313,229 @@ describe("RoomListSectionHeaderViewModel", () => {
expect(vm.getSnapshot().isUnread).toBe(true);
});
+ describe("notification decoration", () => {
+ it("should expose an empty decoration when no room has notifications", () => {
+ const vm = new RoomListSectionHeaderViewModel({
+ tag: "m.favourite",
+ title: "Favourites",
+ spaceId: "!space:server",
+ onToggleExpanded,
+ });
+ vm.setRooms([room]);
+
+ expect(vm.getSnapshot().notification).toEqual(
+ expect.objectContaining({
+ hasAnyNotificationOrActivity: false,
+ isMention: false,
+ isNotification: false,
+ isUnsentMessage: false,
+ isActivityNotification: false,
+ count: 0,
+ }),
+ );
+ });
+
+ it("should not show the activity dot for an activity-only section", () => {
+ jest.spyOn(notificationState, "hasAnyNotificationOrActivity", "get").mockReturnValue(true);
+ jest.spyOn(notificationState, "isActivityNotification", "get").mockReturnValue(true);
+
+ const vm = new RoomListSectionHeaderViewModel({
+ tag: "m.favourite",
+ title: "Favourites",
+ spaceId: "!space:server",
+ onToggleExpanded,
+ });
+ vm.setRooms([room]);
+
+ // Bold, but no badge to display
+ expect(vm.getSnapshot().isUnread).toBe(true);
+ expect(vm.getSnapshot().notification).toEqual(
+ expect.objectContaining({
+ hasAnyNotificationOrActivity: false,
+ isActivityNotification: false,
+ }),
+ );
+ });
+
+ it("should merge mentions, notifications and counts across rooms", () => {
+ const room2 = mkRoom(matrixClient, "!room2:server");
+ const notificationState2 = new RoomNotificationState(room2, false);
+
+ jest.spyOn(RoomNotificationStateStore.instance, "getRoomState")
+ .mockReturnValueOnce(notificationState)
+ .mockReturnValue(notificationState2);
+
+ jest.spyOn(notificationState, "isMention", "get").mockReturnValue(true);
+ jest.spyOn(notificationState, "count", "get").mockReturnValue(3);
+ jest.spyOn(notificationState, "hasUnreadCount", "get").mockReturnValue(true);
+
+ jest.spyOn(notificationState2, "isNotification", "get").mockReturnValue(true);
+ jest.spyOn(notificationState2, "count", "get").mockReturnValue(9);
+ jest.spyOn(notificationState2, "hasUnreadCount", "get").mockReturnValue(true);
+
+ const vm = new RoomListSectionHeaderViewModel({
+ tag: "m.favourite",
+ title: "Favourites",
+ spaceId: "!space:server",
+ onToggleExpanded,
+ });
+ vm.setRooms([room, room2]);
+
+ expect(vm.getSnapshot().notification).toEqual(
+ expect.objectContaining({
+ hasAnyNotificationOrActivity: true,
+ isMention: true,
+ isNotification: true,
+ hasUnreadCount: true,
+ count: 12,
+ isActivityNotification: false,
+ }),
+ );
+ });
+
+ it("should surface an unsent message from any room", () => {
+ jest.spyOn(notificationState, "isUnsentMessage", "get").mockReturnValue(true);
+
+ const vm = new RoomListSectionHeaderViewModel({
+ tag: "m.favourite",
+ title: "Favourites",
+ spaceId: "!space:server",
+ onToggleExpanded,
+ });
+ vm.setRooms([room]);
+
+ expect(vm.getSnapshot().notification).toEqual(
+ expect.objectContaining({
+ hasAnyNotificationOrActivity: true,
+ isUnsentMessage: true,
+ }),
+ );
+ });
+
+ it("should aggregate an invitation from any room", () => {
+ jest.spyOn(notificationState, "invited", "get").mockReturnValue(true);
+
+ const vm = new RoomListSectionHeaderViewModel({
+ tag: "m.favourite",
+ title: "Favourites",
+ spaceId: "!space:server",
+ onToggleExpanded,
+ });
+ vm.setRooms([room]);
+
+ expect(vm.getSnapshot().notification).toEqual(
+ expect.objectContaining({
+ hasAnyNotificationOrActivity: true,
+ invited: true,
+ }),
+ );
+ });
+
+ it("should aggregate an active call, preferring video over voice", () => {
+ const room2 = mkRoom(matrixClient, "!room2:server");
+ const notificationState2 = new RoomNotificationState(room2, false);
+
+ jest.spyOn(RoomNotificationStateStore.instance, "getRoomState")
+ .mockReturnValueOnce(notificationState)
+ .mockReturnValue(notificationState2);
+
+ const voiceCall = {
+ participants: new Map([["@a:server", new Set(["DEVICE"])]]),
+ callType: CallType.Voice,
+ on: jest.fn(),
+ off: jest.fn(),
+ } as unknown as Call;
+ const videoCall = {
+ participants: new Map([["@b:server", new Set(["DEVICE"])]]),
+ callType: CallType.Video,
+ on: jest.fn(),
+ off: jest.fn(),
+ } as unknown as Call;
+ jest.spyOn(CallStore.instance, "getCall").mockImplementation((roomId) =>
+ roomId === room.roomId ? voiceCall : videoCall,
+ );
+
+ const vm = new RoomListSectionHeaderViewModel({
+ tag: "m.favourite",
+ title: "Favourites",
+ spaceId: "!space:server",
+ onToggleExpanded,
+ });
+ vm.setRooms([room, room2]);
+
+ expect(vm.getSnapshot().notification).toEqual(
+ expect.objectContaining({
+ hasAnyNotificationOrActivity: true,
+ callType: "video",
+ }),
+ );
+ });
+
+ it("should ignore a call without participants", () => {
+ const call = {
+ participants: new Map(),
+ callType: CallType.Video,
+ on: jest.fn(),
+ off: jest.fn(),
+ } as unknown as Call;
+ jest.spyOn(CallStore.instance, "getCall").mockReturnValue(call);
+
+ const vm = new RoomListSectionHeaderViewModel({
+ tag: "m.favourite",
+ title: "Favourites",
+ spaceId: "!space:server",
+ onToggleExpanded,
+ });
+ vm.setRooms([room]);
+
+ expect(vm.getSnapshot().notification?.callType).toBeUndefined();
+ });
+
+ it("should show a notification without a count badge for a mark-as-unread room", () => {
+ // "Mark as unread" sets level=Notification with count=0 (no real notification events).
+ jest.spyOn(notificationState, "hasAnyNotificationOrActivity", "get").mockReturnValue(true);
+ jest.spyOn(notificationState, "isNotification", "get").mockReturnValue(true);
+ jest.spyOn(notificationState, "count", "get").mockReturnValue(0);
+ jest.spyOn(notificationState, "hasUnreadCount", "get").mockReturnValue(false);
+
+ const vm = new RoomListSectionHeaderViewModel({
+ tag: "m.favourite",
+ title: "Favourites",
+ spaceId: "!space:server",
+ onToggleExpanded,
+ });
+ vm.setRooms([room]);
+
+ expect(vm.getSnapshot().isUnread).toBe(true);
+ expect(vm.getSnapshot().notification).toEqual(
+ expect.objectContaining({
+ hasAnyNotificationOrActivity: true,
+ isNotification: true,
+ hasUnreadCount: false,
+ // The || 1 fallback gives a count of 1 even though no real count exists
+ count: 1,
+ }),
+ );
+ });
+
+ it("should update the decoration when a notification state update event fires", () => {
+ const vm = new RoomListSectionHeaderViewModel({
+ tag: "m.favourite",
+ title: "Favourites",
+ spaceId: "!space:server",
+ onToggleExpanded,
+ });
+ vm.setRooms([room]);
+
+ expect(vm.getSnapshot().notification?.isMention).toBe(false);
+
+ jest.spyOn(notificationState, "isMention", "get").mockReturnValue(true);
+ notificationState.emit(NotificationStateEvents.Update);
+
+ expect(vm.getSnapshot().notification?.isMention).toBe(true);
+ });
+ });
+
it("should unsubscribe from all notification states on dispose", () => {
jest.spyOn(notificationState, "off");
diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/RoomListSectionHeaderView.stories.tsx/with-notification-decoration-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/RoomListSectionHeaderView.stories.tsx/with-notification-decoration-auto.png
new file mode 100644
index 0000000000..69d727125a
Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/RoomListSectionHeaderView.stories.tsx/with-notification-decoration-auto.png differ
diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/RoomListSectionHeaderView.module.css b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/RoomListSectionHeaderView.module.css
index bedf8e34aa..413f39b23b 100644
--- a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/RoomListSectionHeaderView.module.css
+++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/RoomListSectionHeaderView.module.css
@@ -34,6 +34,12 @@
.menu {
display: initial;
}
+
+ /* When a menu is present and revealed on hover, hide the decoration to avoid clutter.
+ Default sections have no menu, so their decoration stays visible on hover. */
+ .notificationDecoration {
+ display: none;
+ }
}
.chevron {
diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/RoomListSectionHeaderView.stories.tsx b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/RoomListSectionHeaderView.stories.tsx
index c4455dc036..6015984542 100644
--- a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/RoomListSectionHeaderView.stories.tsx
+++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/RoomListSectionHeaderView.stories.tsx
@@ -59,6 +59,17 @@ const meta = {
isExpanded: true,
isFocused: false,
isUnread: false,
+ notification: {
+ hasAnyNotificationOrActivity: false,
+ isUnsentMessage: false,
+ invited: false,
+ isMention: false,
+ isActivityNotification: false,
+ isNotification: false,
+ hasUnreadCount: false,
+ count: 0,
+ muted: false,
+ },
displaySectionMenu: true,
onClick: fn(),
onFocus: fn(),
@@ -119,3 +130,22 @@ export const Unread: Story = {
isUnread: true,
},
};
+
+export const WithNotificationDecoration: Story = {
+ args: {
+ isUnread: true,
+ notification: {
+ hasAnyNotificationOrActivity: true,
+ isUnsentMessage: true,
+ invited: true,
+ isMention: true,
+ isActivityNotification: false,
+ isNotification: true,
+ hasUnreadCount: true,
+ count: 12,
+ muted: false,
+ callType: "video",
+ },
+ isExpanded: false,
+ },
+};
diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/RoomListSectionHeaderView.tsx b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/RoomListSectionHeaderView.tsx
index 820d24df3f..435bd9a457 100644
--- a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/RoomListSectionHeaderView.tsx
+++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/RoomListSectionHeaderView.tsx
@@ -19,6 +19,10 @@ import { Flex } from "../../../core/utils/Flex";
import { useI18n } from "../../../core/i18n/i18nContext";
import { getGroupHeaderAccessibleProps } from "../../../core/VirtualizedList";
import { _t } from "../../../core/i18n/i18n";
+import {
+ NotificationDecoration,
+ type NotificationDecorationData,
+} from "../RoomListItemWrapper/RoomListItemView/NotificationDecoration";
/**
* The observable state snapshot for a room list section header.
@@ -32,6 +36,8 @@ export interface RoomListSectionHeaderViewSnapshot {
isExpanded: boolean;
/** Whether the section is unread (has any unread rooms) */
isUnread: boolean;
+ /** The merged notification decoration aggregating the notifications of the rooms in the section */
+ notification?: NotificationDecorationData;
/** Wether to display the section menu */
displaySectionMenu: boolean;
}
@@ -102,7 +108,7 @@ export const RoomListSectionHeaderView = memo(function RoomListSectionHeaderView
roomCountInSection,
}: Readonly): JSX.Element {
const { translate: _t } = useI18n();
- const { id, title, isExpanded, isUnread, displaySectionMenu } = useViewModel(vm);
+ const { id, title, isExpanded, isUnread, notification, displaySectionMenu } = useViewModel(vm);
const isLastSection = sectionIndex === sectionCount - 1;
const { ref: droppableRef, isDropTarget } = useDroppable({
@@ -177,6 +183,11 @@ export const RoomListSectionHeaderView = memo(function RoomListSectionHeaderView
/>
{title}
+ {!isExpanded && notification && (
+
+
+
+ )}
{displaySectionMenu && }