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
This commit is contained in:
Florian Duros
2026-06-15 20:05:51 +02:00
committed by GitHub
parent 86a89c2aa5
commit 1c8654e394
8 changed files with 460 additions and 27 deletions
@@ -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": `<a href="https://matrix.to/#/${userId}">User</a>`,
"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", () => {
@@ -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<string, boolean>();
/**
* The calls of the rooms currently in this section that we are listening to, used to aggregate the call decoration.
*/
private currentCalls = new Set<Call>();
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<Call>();
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();
}
@@ -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");
@@ -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 {
@@ -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,
},
};
@@ -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<RoomListSectionHeaderViewProps>): 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
/>
<span className={styles.title}>{title}</span>
</Flex>
{!isExpanded && notification && (
<div className={styles.notificationDecoration} aria-hidden={true}>
<NotificationDecoration {...notification} />
</div>
)}
{displaySectionMenu && <MenuComponent vm={vm} />}
</Flex>
</button>