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
This commit is contained in:
Florian Duros
2026-04-06 19:05:45 +00:00
committed by GitHub
parent e53a148da2
commit 46bff1f9e6
9 changed files with 220 additions and 6 deletions
@@ -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);
@@ -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<RoomListSectionHeaderViewSnapshot, RoomListSectionHeaderViewModelProps>
implements RoomListSectionHeaderActions
{
/**
* The notification states of the rooms currently in this section, used to compute the unread state.
*/
private roomNotificationStates = new Set<RoomNotificationState>();
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();
}
}
@@ -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,
@@ -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));
});
});
});