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 && }