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:
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+15
-11
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
+117
@@ -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));
|
||||
}
|
||||
});
|
||||
});
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
@@ -16,6 +16,7 @@ export * from "./room/composer/Banner";
|
||||
export * from "./room/composer/UploadButton";
|
||||
export * from "./crypto/SasEmoji";
|
||||
export * from "./menus/UserMenu";
|
||||
export * from "./notifications/NotificationBadgeView";
|
||||
export * from "./room/timeline/ReadMarker";
|
||||
export * from "./room/timeline/EventPresentation";
|
||||
export * from "./room/timeline/event-tile/body/EventContentBodyView";
|
||||
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
.notificationBadge:not(.visible) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.notificationBadge.visible {
|
||||
background-color: var(--roomtile-default-badge-bg-color, var(--cpd-color-icon-secondary));
|
||||
color: var(--cpd-color-text-on-solid-primary);
|
||||
outline: 1px solid transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.notificationBadge.visible.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 8px;
|
||||
background-color: var(--cpd-color-icon-primary);
|
||||
}
|
||||
|
||||
.notificationBadge.visible.dot .count {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.notificationBadge.visible.dot.notification {
|
||||
background-color: var(--cpd-color-icon-success-primary);
|
||||
}
|
||||
|
||||
.notificationBadge.visible.highlight {
|
||||
background-color: var(--cpd-color-icon-critical-primary);
|
||||
}
|
||||
|
||||
.notificationBadge.visible > svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.notificationBadge.visible.badge2Char {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
.notificationBadge.visible.badge3Char {
|
||||
width: 1.625rem;
|
||||
height: 1rem;
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
.notificationBadge.visible .count {
|
||||
font-size: 0.625rem;
|
||||
line-height: 0.875rem;
|
||||
font-weight: var(--cpd-font-weight-semibold);
|
||||
color: var(--cpd-color-text-on-solid-primary);
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* 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 React, { type JSX } from "react";
|
||||
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { useMockedViewModel } from "../../core/viewmodel";
|
||||
import { withViewDocs } from "../../../.storybook/withViewDocs";
|
||||
import { NotificationBadgeView, type NotificationBadgeViewSnapshot } from "./NotificationBadgeView";
|
||||
|
||||
type WrapperProps = NotificationBadgeViewSnapshot;
|
||||
|
||||
const NotificationBadgeViewWrapperImpl = ({ ...snapshotProps }: WrapperProps): JSX.Element => {
|
||||
const vm = useMockedViewModel(snapshotProps, {});
|
||||
|
||||
return <NotificationBadgeView vm={vm} />;
|
||||
};
|
||||
|
||||
const NotificationBadgeViewWrapper = withViewDocs(NotificationBadgeViewWrapperImpl, NotificationBadgeView);
|
||||
|
||||
const meta = {
|
||||
title: "Notifications/NotificationBadgeView",
|
||||
component: NotificationBadgeViewWrapper,
|
||||
tags: ["autodocs"],
|
||||
args: {
|
||||
shouldRender: true,
|
||||
isVisible: true,
|
||||
isNotification: false,
|
||||
isHighlight: false,
|
||||
isKnocked: false,
|
||||
badgeType: "badge_2char",
|
||||
symbol: "3",
|
||||
knockLabel: "Request to join sent",
|
||||
},
|
||||
} satisfies Meta<typeof NotificationBadgeViewWrapper>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Notification: Story = {};
|
||||
|
||||
export const Dot: Story = {
|
||||
args: {
|
||||
badgeType: "dot",
|
||||
symbol: null,
|
||||
isNotification: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const Highlight: Story = {
|
||||
args: {
|
||||
isHighlight: true,
|
||||
symbol: "!",
|
||||
},
|
||||
};
|
||||
|
||||
export const Knocked: Story = {
|
||||
args: {
|
||||
isKnocked: true,
|
||||
symbol: "!",
|
||||
},
|
||||
};
|
||||
|
||||
export const Hidden: Story = {
|
||||
args: {
|
||||
shouldRender: false,
|
||||
},
|
||||
};
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* 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 { render, screen } from "@test-utils";
|
||||
import { composeStories } from "@storybook/react-vite";
|
||||
import React from "react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import * as stories from "./NotificationBadgeView.stories";
|
||||
|
||||
const { Notification, Dot, Knocked, Hidden } = composeStories(stories);
|
||||
|
||||
describe("NotificationBadgeView", () => {
|
||||
it("renders the default notification badge", () => {
|
||||
const { container } = render(<Notification />);
|
||||
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders a dot badge", () => {
|
||||
render(<Dot />);
|
||||
|
||||
expect(screen.getByTestId("notification-badge")).toHaveAttribute("data-badge-type", "dot");
|
||||
});
|
||||
|
||||
it("renders a knocked badge icon", () => {
|
||||
render(<Knocked />);
|
||||
|
||||
expect(screen.getByLabelText("Request to join sent")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render when hidden", () => {
|
||||
const { container } = render(<Hidden />);
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
});
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
/*
|
||||
* 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 React, { type JSX } from "react";
|
||||
import classNames from "classnames";
|
||||
import { AskToJoinIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
|
||||
import { type ViewModel, useViewModel } from "../../core/viewmodel";
|
||||
import styles from "./NotificationBadgeView.module.css";
|
||||
|
||||
export type NotificationBadgeType = "dot" | "badge_2char" | "badge_3char";
|
||||
|
||||
export interface NotificationBadgeViewSnapshot {
|
||||
/**
|
||||
* Controls whether the badge root should render.
|
||||
*/
|
||||
shouldRender: boolean;
|
||||
/**
|
||||
* Controls whether the badge receives the visible styling class.
|
||||
*/
|
||||
isVisible: boolean;
|
||||
/**
|
||||
* Marks the badge as a regular notification.
|
||||
*/
|
||||
isNotification: boolean;
|
||||
/**
|
||||
* Marks the badge as a highlight notification.
|
||||
*/
|
||||
isHighlight: boolean;
|
||||
/**
|
||||
* Marks the badge as representing a knock request.
|
||||
*/
|
||||
isKnocked: boolean;
|
||||
/**
|
||||
* Controls the visual badge shape.
|
||||
*/
|
||||
badgeType: NotificationBadgeType;
|
||||
/**
|
||||
* Display text for non-knock badges.
|
||||
*/
|
||||
symbol: string | null;
|
||||
/**
|
||||
* Accessible label for the knock icon.
|
||||
*/
|
||||
knockLabel?: string;
|
||||
}
|
||||
|
||||
export type NotificationBadgeViewModel = ViewModel<NotificationBadgeViewSnapshot>;
|
||||
|
||||
interface NotificationBadgeViewProps {
|
||||
vm: NotificationBadgeViewModel;
|
||||
}
|
||||
|
||||
export function NotificationBadgeView({ vm }: Readonly<NotificationBadgeViewProps>): JSX.Element {
|
||||
const { shouldRender, isVisible, isNotification, isHighlight, isKnocked, badgeType, symbol, knockLabel } =
|
||||
useViewModel(vm);
|
||||
|
||||
if (!shouldRender) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const classes = classNames(styles.notificationBadge, {
|
||||
[styles.visible]: isVisible,
|
||||
[styles.notification]: isNotification,
|
||||
[styles.highlight]: isHighlight,
|
||||
[styles.dot]: badgeType === "dot",
|
||||
[styles.badge2Char]: badgeType === "badge_2char",
|
||||
[styles.badge3Char]: badgeType === "badge_3char",
|
||||
// Badges with text should always use light colors.
|
||||
"cpd-theme-light": badgeType !== "dot",
|
||||
});
|
||||
const notificationLevel = isHighlight ? "highlight" : isNotification ? "notification" : undefined;
|
||||
|
||||
const content =
|
||||
isKnocked && knockLabel ? (
|
||||
<AskToJoinIcon aria-label={knockLabel} />
|
||||
) : (
|
||||
<span className={styles.count}>{symbol}</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="notification-badge"
|
||||
data-badge-type={badgeType}
|
||||
data-notification-level={notificationLevel}
|
||||
className={classes}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`NotificationBadgeView > renders the default notification badge 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="NotificationBadgeView-module_notificationBadge NotificationBadgeView-module_visible NotificationBadgeView-module_badge2Char cpd-theme-light"
|
||||
data-badge-type="badge_2char"
|
||||
data-testid="notification-badge"
|
||||
>
|
||||
<span
|
||||
class="NotificationBadgeView-module_count"
|
||||
>
|
||||
3
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,13 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export {
|
||||
NotificationBadgeView,
|
||||
type NotificationBadgeType,
|
||||
type NotificationBadgeViewModel,
|
||||
type NotificationBadgeViewSnapshot,
|
||||
} from "./NotificationBadgeView";
|
||||
Reference in New Issue
Block a user