From 46bff1f9e6c342f10385d0084538a0efa3a530e9 Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Mon, 6 Apr 2026 20:05:45 +0100 Subject: [PATCH] Room list: add activity marker to sections (#33024) * feat: add unread status to section view * feat: add unread tracking in room list section * feat: populate rooms into section header vm * test: add units for unread in section view model * test(e2e): add unread tests --- .../room-list-sections.spec.ts | 32 +++++- .../RoomListSectionHeaderViewModel.ts | 55 ++++++++- .../viewmodels/room-list/RoomListViewModel.ts | 5 + .../RoomListSectionHeaderViewModel-test.ts | 108 ++++++++++++++++++ .../unread-auto.png | Bin 0 -> 5195 bytes .../src/i18n/strings/en_EN.json | 3 +- .../RoomListSectionHeaderView.module.css | 5 + .../RoomListSectionHeaderView.stories.tsx | 7 ++ .../RoomListSectionHeaderView.tsx | 11 +- 9 files changed, 220 insertions(+), 6 deletions(-) create mode 100644 packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListSectionHeaderView/RoomListSectionHeaderView.stories.tsx/unread-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 fdc3513792..9bc9bbe2b0 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 @@ -39,9 +39,12 @@ test.describe("Room list sections", () => { * Get a section header toggle button by section name * @param page * @param sectionName The display name of the section (e.g. "Favourites", "Chats", "Low Priority") + * @param isUnread Whether to look for the unread version of the section header */ - function getSectionHeader(page: Page, sectionName: string): Locator { - return getRoomList(page).getByRole("gridcell", { name: `Toggle ${sectionName} section` }); + function getSectionHeader(page: Page, sectionName: string, isUnread = false): Locator { + return getRoomList(page).getByRole("gridcell", { + name: isUnread ? `Toggle ${sectionName} section with unread room(s)` : `Toggle ${sectionName} section`, + }); } test.beforeEach(async ({ page, app, user }) => { @@ -209,6 +212,31 @@ 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); + + 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!"); + + 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(); + + // The section should no longer be unread + sectionHeader = getSectionHeader(page, "Favourites", false); + await expect(sectionHeader).toBeVisible(); + }); + test.describe("Sections and filters interaction", () => { test("should not show Favourite and Low Priority filters when sections are enabled", async ({ page, app }) => { const primaryFilters = getPrimaryFilters(page); diff --git a/apps/web/src/viewmodels/room-list/RoomListSectionHeaderViewModel.ts b/apps/web/src/viewmodels/room-list/RoomListSectionHeaderViewModel.ts index 5b8006e252..08f7335ab8 100644 --- a/apps/web/src/viewmodels/room-list/RoomListSectionHeaderViewModel.ts +++ b/apps/web/src/viewmodels/room-list/RoomListSectionHeaderViewModel.ts @@ -5,12 +5,17 @@ * Please see LICENSE files in the repository root for full details. */ +import { type Room } from "matrix-js-sdk/src/matrix"; import { BaseViewModel, type RoomListSectionHeaderActions, type RoomListSectionHeaderViewSnapshot, } from "@element-hq/web-shared-components"; +import { RoomNotificationStateStore } from "../../stores/notifications/RoomNotificationStateStore"; +import { NotificationStateEvents } from "../../stores/notifications/NotificationState"; +import { type RoomNotificationState } from "../../stores/notifications/RoomNotificationState"; + interface RoomListSectionHeaderViewModelProps { tag: string; title: string; @@ -21,8 +26,13 @@ export class RoomListSectionHeaderViewModel extends BaseViewModel implements RoomListSectionHeaderActions { + /** + * The notification states of the rooms currently in this section, used to compute the unread state. + */ + private roomNotificationStates = new Set(); + public constructor(props: RoomListSectionHeaderViewModelProps) { - super(props, { id: props.tag, title: props.title, isExpanded: true }); + super(props, { id: props.tag, title: props.title, isExpanded: true, isUnread: false }); } public onClick = (): void => { @@ -37,4 +47,47 @@ export class RoomListSectionHeaderViewModel public get isExpanded(): boolean { return this.snapshot.current.isExpanded; } + + /** + * Update the rooms tracked by this section header for unread state computation. + * Only subscribes to new rooms and unsubscribes from rooms no longer in the section. + * @param rooms - The rooms currently in this section + */ + public setRooms(rooms: Room[]): void { + const newStates = new Set(rooms.map((room) => RoomNotificationStateStore.instance.getRoomState(room))); + + // Unsubscribe from rooms no longer in the section + for (const state of this.roomNotificationStates) { + if (!newStates.has(state)) { + state.off(NotificationStateEvents.Update, this.updateUnreadState); + } + } + + // Subscribe to newly added rooms + 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); + } + } + + this.roomNotificationStates = newStates; + this.updateUnreadState(); + } + + /** + * Update the unread state of the section header based on the notification states of the tracked rooms. + */ + private updateUnreadState = (): void => { + const isUnread = [...this.roomNotificationStates].some((state) => state.hasAnyNotificationOrActivity); + this.snapshot.merge({ isUnread }); + }; + + public dispose(): void { + for (const state of this.roomNotificationStates) { + state.off(NotificationStateEvents.Update, this.updateUnreadState); + } + this.roomNotificationStates.clear(); + super.dispose(); + } } diff --git a/apps/web/src/viewmodels/room-list/RoomListViewModel.ts b/apps/web/src/viewmodels/room-list/RoomListViewModel.ts index ffe30748d8..b81bd8725a 100644 --- a/apps/web/src/viewmodels/room-list/RoomListViewModel.ts +++ b/apps/web/src/viewmodels/room-list/RoomListViewModel.ts @@ -491,6 +491,11 @@ export class RoomListViewModel // Track the current active room position for future sticky calculations this.lastActiveRoomPosition = roomId ? this.findRoomPosition(this.roomsResult.sections, roomId) : undefined; + // Update section header view models with current rooms for unread state tracking + for (const section of this.roomsResult.sections) { + this.getSectionHeaderViewModel(section.tag).setRooms(section.rooms); + } + // Build the complete state atomically to ensure consistency const { sections, isFlatList } = computeSections( this.roomsResult, diff --git a/apps/web/test/viewmodels/room-list/RoomListSectionHeaderViewModel-test.ts b/apps/web/test/viewmodels/room-list/RoomListSectionHeaderViewModel-test.ts index 1f2cd4ebb0..0297ee24f3 100644 --- a/apps/web/test/viewmodels/room-list/RoomListSectionHeaderViewModel-test.ts +++ b/apps/web/test/viewmodels/room-list/RoomListSectionHeaderViewModel-test.ts @@ -5,13 +5,25 @@ * Please see LICENSE files in the repository root for full details. */ +import { type MatrixClient, type Room } from "matrix-js-sdk/src/matrix"; + 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 { createTestClient, mkRoom } from "../../test-utils"; describe("RoomListSectionHeaderViewModel", () => { let onToggleExpanded: jest.Mock; + let matrixClient: MatrixClient; beforeEach(() => { onToggleExpanded = jest.fn(); + matrixClient = createTestClient(); + }); + + afterEach(() => { + jest.restoreAllMocks(); }); it("should initialize snapshot from props", () => { @@ -45,4 +57,100 @@ describe("RoomListSectionHeaderViewModel", () => { expect(vm.getSnapshot().isExpanded).toBe(true); expect(onToggleExpanded).toHaveBeenCalledWith(true); }); + + describe("unread status", () => { + let room: Room; + let notificationState: RoomNotificationState; + + beforeEach(() => { + room = mkRoom(matrixClient, "!room:server"); + notificationState = new RoomNotificationState(room, false); + jest.spyOn(RoomNotificationStateStore.instance, "getRoomState").mockReturnValue(notificationState); + }); + + it("should set isUnread to false when no rooms have notifications", () => { + const vm = new RoomListSectionHeaderViewModel({ + tag: "m.favourite", + title: "Favourites", + onToggleExpanded, + }); + vm.setRooms([room]); + + expect(vm.getSnapshot().isUnread).toBe(false); + }); + + it("should set isUnread to true when a room has notifications", () => { + jest.spyOn(notificationState, "hasAnyNotificationOrActivity", "get").mockReturnValue(true); + + const vm = new RoomListSectionHeaderViewModel({ + tag: "m.favourite", + title: "Favourites", + onToggleExpanded, + }); + vm.setRooms([room]); + + expect(vm.getSnapshot().isUnread).toBe(true); + }); + + it("should subscribe to new rooms and unsubscribe from removed 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, "on"); + jest.spyOn(notificationState, "off"); + jest.spyOn(notificationState2, "on"); + + const vm = new RoomListSectionHeaderViewModel({ + tag: "m.favourite", + title: "Favourites", + onToggleExpanded, + }); + vm.setRooms([room]); + + expect(notificationState.on).toHaveBeenCalledWith(NotificationStateEvents.Update, expect.any(Function)); + + vm.setRooms([room2]); + + expect(notificationState.off).toHaveBeenCalledWith(NotificationStateEvents.Update, expect.any(Function)); + expect(notificationState2.on).toHaveBeenCalledWith(NotificationStateEvents.Update, expect.any(Function)); + + // Calling setRooms again with the same room should not re-subscribe + vm.setRooms([room2]); + expect(notificationState2.on).toHaveBeenCalledTimes(1); + }); + + it("should update isUnread when a notification state update event fires", () => { + const vm = new RoomListSectionHeaderViewModel({ + tag: "m.favourite", + title: "Favourites", + onToggleExpanded, + }); + vm.setRooms([room]); + + expect(vm.getSnapshot().isUnread).toBe(false); + + jest.spyOn(notificationState, "hasAnyNotificationOrActivity", "get").mockReturnValue(true); + notificationState.emit(NotificationStateEvents.Update); + + expect(vm.getSnapshot().isUnread).toBe(true); + }); + + it("should unsubscribe from all notification states on dispose", () => { + jest.spyOn(notificationState, "off"); + + const vm = new RoomListSectionHeaderViewModel({ + tag: "m.favourite", + title: "Favourites", + onToggleExpanded, + }); + vm.setRooms([room]); + + vm.dispose(); + expect(notificationState.off).toHaveBeenCalledWith(NotificationStateEvents.Update, expect.any(Function)); + }); + }); }); diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListSectionHeaderView/RoomListSectionHeaderView.stories.tsx/unread-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListSectionHeaderView/RoomListSectionHeaderView.stories.tsx/unread-auto.png new file mode 100644 index 0000000000000000000000000000000000000000..563c7891d9391f1b81a687990cda43c6ae044093 GIT binary patch literal 5195 zcmeI0TTs(y8pi($iY*JZ!w4&<$hHeQltT?P*pOfWp^i#gSxFH{6jlz48kGTp0Rp;` zu?mc}9Bc?wZP&7#q@2wO3=qx%0&+}3A|XJCIY7wiAF{C*d$BX^bnkkR+i%{P@0oXg z&-Z@sbN7p=NV|8P-vt1`?&Rm;X92*f8vu5X@7rxY89z|+8UXeKC&NGc8@_6TmopB@ z%2LDaO2PA{ENP%5@`1g9|-% z;>r5}uyp4j!YqS!VYjnc(k?pyC@k0u6dXTp1sp&0{Vw3p{&BOqeW?Jj&pH4Atp8iq zBQFM-+X~iZGKYb)1^|rbQ%9fk0V-+sNHZHn1;0)~`nFA-eO8q#8fX&Ogn-Q^t%lmz z34UX~6@QF25E-;GLMPr4R}fx_l1dRs(_Jr&+A#c4&d!w%UcRi&)XL!#Kutm|aH(CaxuN9N&>D-H^jTTO< zcKIP>F^z5O+o^aE2}wwA2yCLM{*BPg$_j1V@vDzX$s^hs@5V}GRo0@?>-7B91>q_$ zTY$*ZpUdWa*EVu=cmr%Mz3*e4J>=ODh$HUnQ!$j^0S993H^dGhMWO^L(2%#b89d#&5l9drQtVp%x$-F znXq`UGO5J0*_59YwDlBXWc8l;gt__z!q1%HVUbImF@)Uk0RTMzr_-@Qj7f%bY4@UJ zt$0MbYbGe3Zh2s>t>Z_tL?#a%o6{%J57BZqky>G?51*)c6_=MQDZSbzc4YbExo7yg zUI$dYuD`8mXE3nFu))9@^o3#S#+I>p35D1*+nm%+H2rYOkDN6<9dZmDAB1ST?OWb- zrHFb*85xsDu!ePXwKO8^4i;q9dNr^_VZRFr>V-n$Uuu<9`Sw~_%=znPJCrL#*d$oM)~!9ZC`8hb zKf&D$r9?BQ&RN&h!i(MMJ$k7RADcOSu1NOe!HJaWK=A+$lE=#oiR-*31a+Ggn44=? z+UN8(PEm-ZVFQYCbHXtW92}>1EFR4W(ox8#UE9NW44n^-q5K!QS`txL|Fg zi-Q!gqsMAq2&$>H{I;?h({c*J3i08FJv=wHZ92v{xl@DHG!Q%admWsba3N{bBhh2@ z%kyFr=m$lbx{O6-*I)PBEp%n9ln`a?)*)O%vvLcF|1MkzFh*Bk6QtBM2+Fk6@ zvDJBfI5$~9uZfLK9}Pr*by1gAkq~Tl0;|<9#bp?lZolW2H}$?555+KMo$x)3(kYxE zxwZDM*A<6vNLz#!uVhmc7_D9TR6Sb@05;TxM6;*XB)4urV2_2kgu2x8IZQn=f|$}&j*F1C#6z}~n1 aw*o5MKeOF2lKadjz{wL);f): JSX.Element { const { translate: _t } = useI18n(); - const { id, title, isExpanded } = useViewModel(vm); + const { id, title, isExpanded, isUnread } = useViewModel(vm); const isLastSection = sectionIndex === sectionCount - 1; return ( @@ -104,12 +106,17 @@ export const RoomListSectionHeaderView = memo(function RoomListSectionHeaderView [styles.firstHeader]: sectionIndex === 0, // If the section is collapsed and it's the last one [styles.lastHeader]: !isExpanded && isLastSection, + [styles.unread]: isUnread, })} onClick={vm.onClick} aria-expanded={isExpanded} onFocus={(e) => onFocus(id, e)} tabIndex={isFocused ? 0 : -1} - aria-label={_t("room_list|section_header|toggle", { section: title })} + aria-label={ + isUnread + ? _t("room_list|section_header|toggle_unread", { section: title }) + : _t("room_list|section_header|toggle", { section: title }) + } >