Refactor UnreadNotificationBadge to shared MVVM view (#33672)

* Refactor notification badge to shared MVVM view

* Bage icons Images

* Narrow unread notification badge refactor scope

* Align unread badge MVVM pattern

* Reduce unread badge viewmodel duplication

* Document unread badge viewmodel props

* Remove legacy badge classes from shared view

* Update css for knocked door

* Update thread badge e2e selector

* Update read receipt badge e2e selectors
This commit is contained in:
Zack
2026-06-15 10:20:27 +00:00
committed by GitHub
parent 5bcec9112e
commit 0f0f8c6ba2
19 changed files with 655 additions and 24 deletions
@@ -557,7 +557,7 @@ class Helpers {
*/
async assertReadThread(rootMessage: string) {
const tile = await this.getThreadListTile(rootMessage);
await expect(tile.locator(".mx_NotificationBadge")).not.toBeVisible();
await expect(tile.getByTestId("notification-badge")).not.toBeVisible();
}
/**
@@ -566,7 +566,7 @@ class Helpers {
*/
async assertUnreadThread(rootMessage: string) {
const tile = await this.getThreadListTile(rootMessage);
await expect(tile.locator(".mx_NotificationBadge")).toBeVisible();
await expect(tile.getByTestId("notification-badge")).toBeVisible();
}
/**
@@ -269,7 +269,9 @@ test.describe("Threads", () => {
// Check the number of the replies
await expect(locator.locator(".mx_ThreadPanel_replies_amount").getByText("2")).toBeAttached();
// Make sure the notification dot is visible
await expect(locator.locator(".mx_NotificationBadge_visible")).toBeVisible();
const notificationBadge = locator.getByTestId("notification-badge");
await expect(notificationBadge).toBeVisible();
await expect(notificationBadge).toHaveAttribute("data-badge-type", "dot");
// User opens thread via threads list
await locator.locator(".mx_EventTile_line").click();
@@ -7,10 +7,10 @@ Please see LICENSE files in the repository root for full details.
*/
import { type Room } from "matrix-js-sdk/src/matrix";
import React, { type JSX } from "react";
import React, { type JSX, useEffect } from "react";
import { NotificationBadgeView, useCreateAutoDisposedViewModel } from "@element-hq/web-shared-components";
import { useUnreadNotifications } from "../../../../hooks/useUnreadNotifications";
import { StatelessNotificationBadge } from "./StatelessNotificationBadge";
import { UnreadNotificationBadgeViewModel } from "../../../../viewmodels/room/notification-badge/UnreadNotificationBadgeViewModel";
interface Props {
room?: Room;
@@ -23,7 +23,26 @@ interface Props {
}
export function UnreadNotificationBadge({ room, threadId, forceDot }: Props): JSX.Element {
const { symbol, count, level } = useUnreadNotifications(room, threadId);
const vm = useCreateAutoDisposedViewModel(
() =>
new UnreadNotificationBadgeViewModel({
room,
threadId,
forceDot,
}),
);
return <StatelessNotificationBadge symbol={symbol} count={count} level={level} forceDot={forceDot} />;
useEffect(() => {
vm.setRoom(room);
}, [room, vm]);
useEffect(() => {
vm.setThreadId(threadId);
}, [threadId, vm]);
useEffect(() => {
vm.setForceDot(forceDot);
}, [forceDot, vm]);
return <NotificationBadgeView vm={vm} />;
}
@@ -0,0 +1,184 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { type NotificationCount, type Room, RoomEvent } from "matrix-js-sdk/src/matrix";
import {
BaseViewModel,
type NotificationBadgeType,
type NotificationBadgeViewSnapshot,
type NotificationBadgeViewModel as NotificationBadgeViewModelInterface,
} from "@element-hq/web-shared-components";
import { determineUnreadState } from "../../../RoomNotifs";
import { NotificationLevel } from "../../../stores/notifications/NotificationLevel";
import SettingsStore from "../../../settings/SettingsStore";
import { formatCount } from "../../../utils/FormattingUtils";
const notificationChangeEvents = [
RoomEvent.Receipt,
RoomEvent.Timeline,
RoomEvent.Redaction,
RoomEvent.LocalEchoUpdated,
RoomEvent.MyMembership,
];
function getBadgeType(level: NotificationLevel, symbol: string | null, forceDot?: boolean): NotificationBadgeType {
if (forceDot || level <= NotificationLevel.Activity) {
return "dot";
}
return symbol && symbol.length >= 3 ? "badge_3char" : "badge_2char";
}
export interface UnreadNotificationBadgeViewModelProps {
/**
* Room whose unread state should be represented by the badge.
*/
room?: Room;
/**
* Thread to read unread state from. Omit for room-level unread state.
*/
threadId?: string;
/**
* Show a dot instead of a numeric/symbol badge whenever the badge would otherwise render.
*/
forceDot?: boolean;
}
interface InternalProps extends UnreadNotificationBadgeViewModelProps {
hideBold: boolean;
}
export class UnreadNotificationBadgeViewModel
extends BaseViewModel<NotificationBadgeViewSnapshot, InternalProps>
implements NotificationBadgeViewModelInterface
{
private listenerCleanups: Array<() => void> = [];
private static readonly computeSnapshot = (props: InternalProps): NotificationBadgeViewSnapshot => {
const { symbol, count, level } = determineUnreadState(props.room, props.threadId, false);
const displaySymbol = symbol ?? (count > 0 ? formatCount(count) : null);
const hasUnreadCount = level >= NotificationLevel.Notification && (count > 0 || symbol !== null);
const isSuppressedActivity = props.hideBold && level === NotificationLevel.Activity;
return {
shouldRender: level !== NotificationLevel.None && !isSuppressedActivity,
isVisible: symbol === null && count === 0 ? true : hasUnreadCount,
isNotification: level === NotificationLevel.Notification,
isHighlight: level >= NotificationLevel.Highlight,
isKnocked: false,
badgeType: getBadgeType(level, displaySymbol, props.forceDot),
symbol: displaySymbol,
};
};
public constructor(props: UnreadNotificationBadgeViewModelProps) {
const internalProps: InternalProps = {
...props,
hideBold: SettingsStore.getValue("feature_hidebold"),
};
super(internalProps, UnreadNotificationBadgeViewModel.computeSnapshot(internalProps));
this.setupListeners();
const hideBoldWatcherRef = SettingsStore.watchSetting("feature_hidebold", null, this.onHideBoldSettingChanged);
this.disposables.track(() => SettingsStore.unwatchSetting(hideBoldWatcherRef));
}
public dispose(): void {
this.teardownListeners();
super.dispose();
}
public setRoom(room?: Room): void {
if (this.props.room === room) return;
this.props = {
...this.props,
room,
};
this.setupListeners();
this.updateSnapshotFromProps();
}
public setThreadId(threadId?: string): void {
if (this.props.threadId === threadId) return;
this.props = {
...this.props,
threadId,
};
this.updateSnapshotFromProps();
}
public setForceDot(forceDot?: boolean): void {
if (this.props.forceDot === forceDot) return;
this.props = {
...this.props,
forceDot,
};
this.updateSnapshotFromProps();
}
private setHideBold(hideBold: boolean): void {
if (this.props.hideBold === hideBold) return;
this.props = {
...this.props,
hideBold,
};
this.updateSnapshotFromProps();
}
private updateSnapshotFromProps(): void {
this.snapshot.merge(UnreadNotificationBadgeViewModel.computeSnapshot(this.props));
}
private setupListeners(): void {
this.teardownListeners();
const { room } = this.props;
if (!room) return;
room.on(RoomEvent.UnreadNotifications, this.onRoomUnreadNotifications);
for (const eventName of notificationChangeEvents) {
room.on(eventName, this.onNotificationChanged);
}
this.listenerCleanups.push(() => {
room.off(RoomEvent.UnreadNotifications, this.onRoomUnreadNotifications);
for (const eventName of notificationChangeEvents) {
room.off(eventName, this.onNotificationChanged);
}
});
}
private teardownListeners(): void {
for (const cleanup of this.listenerCleanups) {
cleanup();
}
this.listenerCleanups = [];
}
private readonly onRoomUnreadNotifications = (
_unreadNotifications?: NotificationCount,
evtThreadId?: string,
): void => {
if (this.props.threadId && this.props.threadId !== evtThreadId) return;
this.updateSnapshotFromProps();
};
private readonly onNotificationChanged = (): void => {
this.updateSnapshotFromProps();
};
private readonly onHideBoldSettingChanged = (): void => {
this.setHideBold(SettingsStore.getValue("feature_hidebold"));
};
}
@@ -1007,21 +1007,25 @@ describe("EventTile", () => {
const { container } = getComponent({}, TimelineRenderingType.ThreadsList);
// By default, the thread will assume it is read.
expect(container.getElementsByClassName("mx_NotificationBadge")).toHaveLength(0);
expect(container.querySelectorAll('[data-testid="notification-badge"]')).toHaveLength(0);
act(() => {
room.setThreadUnreadNotificationCount(mxEvent.getId()!, NotificationCountType.Total, 3);
});
expect(container.getElementsByClassName("mx_NotificationBadge")).toHaveLength(1);
expect(container.getElementsByClassName("mx_NotificationBadge_level_highlight")).toHaveLength(0);
let badges = container.querySelectorAll('[data-testid="notification-badge"]');
expect(badges).toHaveLength(1);
expect(badges[0]).toHaveAttribute("data-badge-type", "dot");
expect(badges[0]).toHaveAttribute("data-notification-level", "notification");
act(() => {
room.setThreadUnreadNotificationCount(mxEvent.getId()!, NotificationCountType.Highlight, 1);
});
expect(container.getElementsByClassName("mx_NotificationBadge")).toHaveLength(1);
expect(container.getElementsByClassName("mx_NotificationBadge_level_highlight")).toHaveLength(1);
badges = container.querySelectorAll('[data-testid="notification-badge"]');
expect(badges).toHaveLength(1);
expect(badges[0]).toHaveAttribute("data-badge-type", "dot");
expect(badges[0]).toHaveAttribute("data-notification-level", "highlight");
});
});
@@ -38,6 +38,10 @@ describe("UnreadNotificationBadge", () => {
return <UnreadNotificationBadge room={room} threadId={threadId} />;
}
function getBadge(container: HTMLElement): Element | null {
return container.querySelector('[data-testid="notification-badge"]');
}
beforeEach(() => {
client = stubClient();
client.supportsThreads = () => true;
@@ -84,27 +88,27 @@ describe("UnreadNotificationBadge", () => {
it("renders unread notification badge", () => {
const { container } = render(getComponent());
expect(container.querySelector(".mx_NotificationBadge_visible")).toBeTruthy();
expect(container.querySelector(".mx_NotificationBadge_level_highlight")).toBeFalsy();
expect(getBadge(container)).toHaveTextContent("1");
expect(getBadge(container)).toHaveAttribute("data-notification-level", "notification");
act(() => {
room.setUnreadNotificationCount(NotificationCountType.Highlight, 1);
});
expect(container.querySelector(".mx_NotificationBadge_level_highlight")).toBeTruthy();
expect(getBadge(container)).toHaveAttribute("data-notification-level", "highlight");
});
it("renders unread thread notification badge", () => {
const { container } = render(getComponent(THREAD_ID));
expect(container.querySelector(".mx_NotificationBadge_visible")).toBeTruthy();
expect(container.querySelector(".mx_NotificationBadge_level_highlight")).toBeFalsy();
expect(getBadge(container)).toHaveTextContent("1");
expect(getBadge(container)).toHaveAttribute("data-notification-level", "notification");
act(() => {
room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Highlight, 1);
});
expect(container.querySelector(".mx_NotificationBadge_level_highlight")).toBeTruthy();
expect(getBadge(container)).toHaveAttribute("data-notification-level", "highlight");
});
it("hides unread notification badge", () => {
@@ -112,7 +116,7 @@ describe("UnreadNotificationBadge", () => {
room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Total, 0);
room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Highlight, 0);
const { container } = render(getComponent(THREAD_ID));
expect(container.querySelector(".mx_NotificationBadge_visible")).toBeFalsy();
expect(getBadge(container)).toBeNull();
});
});
@@ -142,7 +146,7 @@ describe("UnreadNotificationBadge", () => {
muteRoom(room);
const { container } = render(getComponent());
expect(container.querySelector(".mx_NotificationBadge")).toBeNull();
expect(getBadge(container)).toBeNull();
});
it("activity renders unread notification badge", () => {
@@ -168,8 +172,8 @@ describe("UnreadNotificationBadge", () => {
room.addLiveEvents([event], { addToState: true });
const { container } = render(getComponent(THREAD_ID));
expect(container.querySelector(".mx_NotificationBadge_dot")).toBeTruthy();
expect(container.querySelector(".mx_NotificationBadge_visible")).toBeTruthy();
expect(container.querySelector(".mx_NotificationBadge_level_highlight")).toBeFalsy();
expect(getBadge(container)).toBeInTheDocument();
expect(getBadge(container)).toHaveAttribute("data-badge-type", "dot");
expect(getBadge(container)).not.toHaveAttribute("data-notification-level", "highlight");
});
});
@@ -0,0 +1,117 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { NotificationCountType, PendingEventOrdering, Room, RoomEvent } from "matrix-js-sdk/src/matrix";
import type { MatrixClient } from "matrix-js-sdk/src/matrix";
import { stubClient } from "../../../test-utils/test-utils";
import { UnreadNotificationBadgeViewModel } from "../../../../src/viewmodels/room/notification-badge/UnreadNotificationBadgeViewModel";
describe("UnreadNotificationBadgeViewModel", () => {
let client: MatrixClient;
let room: Room;
const trackedRoomEvents = [
RoomEvent.UnreadNotifications,
RoomEvent.Receipt,
RoomEvent.Timeline,
RoomEvent.Redaction,
RoomEvent.LocalEchoUpdated,
RoomEvent.MyMembership,
];
beforeEach(() => {
client = stubClient();
room = new Room("!room:example.org", client, "@user:example.org", {
pendingEventOrdering: PendingEventOrdering.Detached,
});
});
function setUnreads(greys: number, reds: number): void {
room.setUnreadNotificationCount(NotificationCountType.Total, greys);
room.setUnreadNotificationCount(NotificationCountType.Highlight, reds);
}
it("computes the initial snapshot from unread state", () => {
setUnreads(12, 0);
const vm = new UnreadNotificationBadgeViewModel({ room });
expect(vm.getSnapshot()).toMatchObject({
shouldRender: true,
isVisible: true,
isNotification: true,
isHighlight: false,
badgeType: "badge_2char",
symbol: "12",
});
vm.dispose();
});
it("updates when the room unread state changes", () => {
const vm = new UnreadNotificationBadgeViewModel({ room });
const listener = jest.fn();
vm.subscribe(listener);
setUnreads(0, 2);
expect(vm.getSnapshot()).toMatchObject({
isHighlight: true,
symbol: "2",
});
expect(listener).toHaveBeenCalled();
vm.dispose();
});
it("skips unchanged force-dot setter updates", () => {
setUnreads(1, 0);
const vm = new UnreadNotificationBadgeViewModel({ room, forceDot: false });
const listener = jest.fn();
vm.subscribe(listener);
vm.setForceDot(false);
expect(listener).not.toHaveBeenCalled();
vm.setForceDot(true);
expect(listener).toHaveBeenCalledTimes(1);
expect(vm.getSnapshot().badgeType).toBe("dot");
vm.dispose();
});
it("moves room event listeners when the room changes", () => {
const nextRoom = new Room("!next:example.org", client, "@user:example.org", {
pendingEventOrdering: PendingEventOrdering.Detached,
});
const initialRoomListenerCounts = new Map(
trackedRoomEvents.map((eventName) => [eventName, room.listenerCount(eventName)]),
);
const initialNextRoomListenerCounts = new Map(
trackedRoomEvents.map((eventName) => [eventName, nextRoom.listenerCount(eventName)]),
);
const vm = new UnreadNotificationBadgeViewModel({ room });
for (const eventName of trackedRoomEvents) {
expect(room.listenerCount(eventName)).toBe(initialRoomListenerCounts.get(eventName)! + 1);
}
vm.setRoom(nextRoom);
for (const eventName of trackedRoomEvents) {
expect(room.listenerCount(eventName)).toBe(initialRoomListenerCounts.get(eventName));
expect(nextRoom.listenerCount(eventName)).toBe(initialNextRoomListenerCounts.get(eventName)! + 1);
}
vm.dispose();
for (const eventName of trackedRoomEvents) {
expect(nextRoom.listenerCount(eventName)).toBe(initialNextRoomListenerCounts.get(eventName));
}
});
});