From 1c8654e3944e33a757981176a3a7cfc50a870c6f Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Mon, 15 Jun 2026 20:05:51 +0200 Subject: [PATCH] Room list: add notifications to section headers (#33826) * feat(sc): add notification decoration to header * feat(vm): add notification decoration to vm * test(e2e): add test --- .../room-list-sections.spec.ts | 99 ++++++-- ...list-section-header-notification-linux.png | Bin 0 -> 2646 bytes .../RoomListSectionHeaderViewModel.ts | 113 ++++++++- .../RoomListSectionHeaderViewModel-test.ts | 226 ++++++++++++++++++ .../with-notification-decoration-auto.png | Bin 0 -> 20546 bytes .../RoomListSectionHeaderView.module.css | 6 + .../RoomListSectionHeaderView.stories.tsx | 30 +++ .../RoomListSectionHeaderView.tsx | 13 +- 8 files changed, 460 insertions(+), 27 deletions(-) create mode 100644 apps/web/playwright/snapshots/left-panel/room-list-panel/room-list-sections.spec.ts/room-list-section-header-notification-linux.png create mode 100644 packages/shared-components/__vis__/linux/__baselines__/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/RoomListSectionHeaderView.stories.tsx/with-notification-decoration-auto.png 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 0000000000000000000000000000000000000000..dda3fd97bc737c1a429f2b2a2a7eb341fd97f07d GIT binary patch literal 2646 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 m9HH>=P#Id4eH6I(); + /** + * 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 0000000000000000000000000000000000000000..69d727125a8db1ba8a0c454b237f03b9314afec6 GIT binary patch literal 20546 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�`(X;HQKWvO5gx_`@?H$j$NsD6 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 V@Q3x|9;{GyY&F^vv&rJ({{T?#ra}M! literal 0 HcmV?d00001 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 && }