diff --git a/apps/web/playwright/e2e/read-receipts/index.ts b/apps/web/playwright/e2e/read-receipts/index.ts index bd04be36a8..6ff5789795 100644 --- a/apps/web/playwright/e2e/read-receipts/index.ts +++ b/apps/web/playwright/e2e/read-receipts/index.ts @@ -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(); } /** diff --git a/apps/web/playwright/e2e/threads/threads.spec.ts b/apps/web/playwright/e2e/threads/threads.spec.ts index b143c4a83a..1ace1c4499 100644 --- a/apps/web/playwright/e2e/threads/threads.spec.ts +++ b/apps/web/playwright/e2e/threads/threads.spec.ts @@ -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(); diff --git a/apps/web/src/components/views/rooms/NotificationBadge/UnreadNotificationBadge.tsx b/apps/web/src/components/views/rooms/NotificationBadge/UnreadNotificationBadge.tsx index 0677278054..8e3912bf1c 100644 --- a/apps/web/src/components/views/rooms/NotificationBadge/UnreadNotificationBadge.tsx +++ b/apps/web/src/components/views/rooms/NotificationBadge/UnreadNotificationBadge.tsx @@ -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 ; + useEffect(() => { + vm.setRoom(room); + }, [room, vm]); + + useEffect(() => { + vm.setThreadId(threadId); + }, [threadId, vm]); + + useEffect(() => { + vm.setForceDot(forceDot); + }, [forceDot, vm]); + + return ; } diff --git a/apps/web/src/viewmodels/room/notification-badge/UnreadNotificationBadgeViewModel.ts b/apps/web/src/viewmodels/room/notification-badge/UnreadNotificationBadgeViewModel.ts new file mode 100644 index 0000000000..bc02f3e1eb --- /dev/null +++ b/apps/web/src/viewmodels/room/notification-badge/UnreadNotificationBadgeViewModel.ts @@ -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 + 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")); + }; +} diff --git a/apps/web/test/unit-tests/components/views/rooms/EventTile-test.tsx b/apps/web/test/unit-tests/components/views/rooms/EventTile-test.tsx index 4677b6bdea..f7ea551ecd 100644 --- a/apps/web/test/unit-tests/components/views/rooms/EventTile-test.tsx +++ b/apps/web/test/unit-tests/components/views/rooms/EventTile-test.tsx @@ -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"); }); }); diff --git a/apps/web/test/unit-tests/components/views/rooms/NotificationBadge/UnreadNotificationBadge-test.tsx b/apps/web/test/unit-tests/components/views/rooms/NotificationBadge/UnreadNotificationBadge-test.tsx index 6628889dc7..b1e9a2835c 100644 --- a/apps/web/test/unit-tests/components/views/rooms/NotificationBadge/UnreadNotificationBadge-test.tsx +++ b/apps/web/test/unit-tests/components/views/rooms/NotificationBadge/UnreadNotificationBadge-test.tsx @@ -38,6 +38,10 @@ describe("UnreadNotificationBadge", () => { return ; } + 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"); }); }); diff --git a/apps/web/test/viewmodels/room/notification-badge/UnreadNotificationBadgeViewModel-test.ts b/apps/web/test/viewmodels/room/notification-badge/UnreadNotificationBadgeViewModel-test.ts new file mode 100644 index 0000000000..05b3497cad --- /dev/null +++ b/apps/web/test/viewmodels/room/notification-badge/UnreadNotificationBadgeViewModel-test.ts @@ -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)); + } + }); +}); diff --git a/packages/shared-components/__vis__/linux/__baselines__/notifications/NotificationBadgeView/NotificationBadgeView.stories.tsx/dot-auto.png b/packages/shared-components/__vis__/linux/__baselines__/notifications/NotificationBadgeView/NotificationBadgeView.stories.tsx/dot-auto.png new file mode 100644 index 0000000000..d00dcb7348 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/notifications/NotificationBadgeView/NotificationBadgeView.stories.tsx/dot-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/notifications/NotificationBadgeView/NotificationBadgeView.stories.tsx/hidden-auto.png b/packages/shared-components/__vis__/linux/__baselines__/notifications/NotificationBadgeView/NotificationBadgeView.stories.tsx/hidden-auto.png new file mode 100644 index 0000000000..17fe39394f Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/notifications/NotificationBadgeView/NotificationBadgeView.stories.tsx/hidden-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/notifications/NotificationBadgeView/NotificationBadgeView.stories.tsx/highlight-auto.png b/packages/shared-components/__vis__/linux/__baselines__/notifications/NotificationBadgeView/NotificationBadgeView.stories.tsx/highlight-auto.png new file mode 100644 index 0000000000..4182c6511a Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/notifications/NotificationBadgeView/NotificationBadgeView.stories.tsx/highlight-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/notifications/NotificationBadgeView/NotificationBadgeView.stories.tsx/knocked-auto.png b/packages/shared-components/__vis__/linux/__baselines__/notifications/NotificationBadgeView/NotificationBadgeView.stories.tsx/knocked-auto.png new file mode 100644 index 0000000000..d8b0b93f7e Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/notifications/NotificationBadgeView/NotificationBadgeView.stories.tsx/knocked-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/notifications/NotificationBadgeView/NotificationBadgeView.stories.tsx/notification-auto.png b/packages/shared-components/__vis__/linux/__baselines__/notifications/NotificationBadgeView/NotificationBadgeView.stories.tsx/notification-auto.png new file mode 100644 index 0000000000..7aa23cf638 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/notifications/NotificationBadgeView/NotificationBadgeView.stories.tsx/notification-auto.png differ diff --git a/packages/shared-components/src/index.ts b/packages/shared-components/src/index.ts index 8b22018de6..fd59874ba2 100644 --- a/packages/shared-components/src/index.ts +++ b/packages/shared-components/src/index.ts @@ -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"; diff --git a/packages/shared-components/src/notifications/NotificationBadgeView/NotificationBadgeView.module.css b/packages/shared-components/src/notifications/NotificationBadgeView/NotificationBadgeView.module.css new file mode 100644 index 0000000000..a205f9cdd0 --- /dev/null +++ b/packages/shared-components/src/notifications/NotificationBadgeView/NotificationBadgeView.module.css @@ -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); +} diff --git a/packages/shared-components/src/notifications/NotificationBadgeView/NotificationBadgeView.stories.tsx b/packages/shared-components/src/notifications/NotificationBadgeView/NotificationBadgeView.stories.tsx new file mode 100644 index 0000000000..44a87dfe3a --- /dev/null +++ b/packages/shared-components/src/notifications/NotificationBadgeView/NotificationBadgeView.stories.tsx @@ -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 ; +}; + +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; + +export default meta; +type Story = StoryObj; + +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, + }, +}; diff --git a/packages/shared-components/src/notifications/NotificationBadgeView/NotificationBadgeView.test.tsx b/packages/shared-components/src/notifications/NotificationBadgeView/NotificationBadgeView.test.tsx new file mode 100644 index 0000000000..35f8753631 --- /dev/null +++ b/packages/shared-components/src/notifications/NotificationBadgeView/NotificationBadgeView.test.tsx @@ -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(); + + expect(container).toMatchSnapshot(); + }); + + it("renders a dot badge", () => { + render(); + + expect(screen.getByTestId("notification-badge")).toHaveAttribute("data-badge-type", "dot"); + }); + + it("renders a knocked badge icon", () => { + render(); + + expect(screen.getByLabelText("Request to join sent")).toBeInTheDocument(); + }); + + it("does not render when hidden", () => { + const { container } = render(); + + expect(container.firstChild).toBeNull(); + }); +}); diff --git a/packages/shared-components/src/notifications/NotificationBadgeView/NotificationBadgeView.tsx b/packages/shared-components/src/notifications/NotificationBadgeView/NotificationBadgeView.tsx new file mode 100644 index 0000000000..52b558840e --- /dev/null +++ b/packages/shared-components/src/notifications/NotificationBadgeView/NotificationBadgeView.tsx @@ -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; + +interface NotificationBadgeViewProps { + vm: NotificationBadgeViewModel; +} + +export function NotificationBadgeView({ vm }: Readonly): 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 ? ( + + ) : ( + {symbol} + ); + + return ( +
+ {content} +
+ ); +} diff --git a/packages/shared-components/src/notifications/NotificationBadgeView/__snapshots__/NotificationBadgeView.test.tsx.snap b/packages/shared-components/src/notifications/NotificationBadgeView/__snapshots__/NotificationBadgeView.test.tsx.snap new file mode 100644 index 0000000000..2f5ce14acc --- /dev/null +++ b/packages/shared-components/src/notifications/NotificationBadgeView/__snapshots__/NotificationBadgeView.test.tsx.snap @@ -0,0 +1,17 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`NotificationBadgeView > renders the default notification badge 1`] = ` +
+
+ + 3 + +
+
+`; diff --git a/packages/shared-components/src/notifications/NotificationBadgeView/index.tsx b/packages/shared-components/src/notifications/NotificationBadgeView/index.tsx new file mode 100644 index 0000000000..fbb2fef1ee --- /dev/null +++ b/packages/shared-components/src/notifications/NotificationBadgeView/index.tsx @@ -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";