From ce74b5f614cbf27dff76c983f238d470d0a68346 Mon Sep 17 00:00:00 2001 From: Zack Date: Mon, 10 Aug 2026 10:32:23 +0200 Subject: [PATCH] Refactor NotificationBadge to shared MVVM (#33843) * Refactor NotificationBadge to shared MVVM * Added snapshot images * Replace legacy NotificationBadge with adapter * Fix Eslint * Use regular NotificationBadge prop names * Remove legacy NotificationBadge selectors * Update TAC badge Playwright selectors * Rename NotificationBadgeAdapter to NotificationBadge Addresses review feedback: the component is the app-facing badge, not an adapter, so name it NotificationBadge. * Make NotificationBadgeView isClickable a required boolean Addresses review feedback: there is no semantic difference between `isClickable: false` and `isClickable: undefined` (both render the non-interactive variant), so the field should be a plain boolean. The view model always provides it. * Drop redundant comparison guards in NotificationBadge view model setters Addresses review feedback: Snapshot.merge already compares each field with Object.is and only emits when something actually changed, so the manual equality guards in each setter were redundant. Recompute the snapshot unconditionally and let merge dedupe. * Resolve notification badge labels inside NotificationBadgeView Addresses review feedback: static UI labels belong in the view, not the snapshot. The view now resolves the knock and unsent-message strings via useI18n, and the snapshot exposes a showUnsentTooltip boolean instead of a prebuilt tooltipLabel (and drops knockLabel entirely). Adds the room|knock_sent and notifications|message_didnt_send keys to the shared-components i18n catalogue so they resolve in tests and storybook. * Remove unused translation because of shared components movement * Fix typescript issue * Fix lint issue * Remove unused notification badge count setting * Pass notification badge class names explicitly --- .../spaces/threads-activity-centre/index.ts | 7 +- apps/web/res/css/_components.pcss | 1 - apps/web/res/css/structures/_SpacePanel.pcss | 8 +- .../structures/_ThreadsActivityCentre.pcss | 2 +- .../views/avatars/_DecoratedRoomAvatar.pcss | 2 +- .../res/css/views/dialogs/_ForwardDialog.pcss | 2 +- .../css/views/dialogs/_SpotlightDialog.pcss | 2 +- .../css/views/rooms/_NotificationBadge.pcss | 86 --------- .../settings/_NotificationSettings2.pcss | 2 +- .../css/_light-high-contrast.pcss | 2 +- .../views/avatars/DecoratedRoomAvatar.tsx | 4 +- .../views/dialogs/ForwardDialog.tsx | 9 +- .../dialogs/spotlight/SpotlightDialog.tsx | 12 +- .../views/rooms/EventTile/ReceiptAdapter.tsx | 2 +- .../views/rooms/NotificationBadge.tsx | 127 ------------- .../NotificationBadge/NotificationBadge.tsx | 93 +++++++++ .../StatelessNotificationBadge.tsx | 145 ++++++-------- .../UnreadNotificationBadge.tsx | 5 +- .../views/rooms/VoiceRecordComposerTile.tsx | 2 +- .../notifications/NotificationSettings2.tsx | 1 + .../views/spaces/SpaceTreeLevel.tsx | 5 +- .../ThreadsActivityCentre.tsx | 8 +- apps/web/src/i18n/strings/en_EN.json | 1 - apps/web/src/settings/Settings.tsx | 6 - .../NotificationBadgeViewModel.ts | 177 ++++++++++++++++++ .../UnreadNotificationBadgeViewModel.ts | 2 + .../NotificationBadge-test.tsx | 20 +- .../StatelessNotificationBadge-test.tsx | 10 +- .../Notifications2-test.tsx.snap | 14 +- .../views/spaces/SpaceTreeLevel-test.tsx | 2 +- .../spaces/ThreadsActivityCentre-test.tsx | 4 +- .../SpaceTreeLevel-test.tsx.snap | 14 +- .../ThreadsActivityCentre-test.tsx.snap | 35 +++- .../NotificationBadgeViewModel-test.ts | 118 ++++++++++++ .../clickable-auto.png | Bin 0 -> 17081 bytes .../with-tooltip-auto.png | Bin 0 -> 16982 bytes .../src/i18n/strings/en_EN.json | 2 + .../NotificationBadgeView.module.css | 11 ++ .../NotificationBadgeView.stories.tsx | 36 +++- .../NotificationBadgeView.tsx | 86 +++++++-- .../NotificationBadgeView/index.ts | 1 + 41 files changed, 668 insertions(+), 398 deletions(-) delete mode 100644 apps/web/res/css/views/rooms/_NotificationBadge.pcss delete mode 100644 apps/web/src/components/views/rooms/NotificationBadge.tsx create mode 100644 apps/web/src/components/views/rooms/NotificationBadge/NotificationBadge.tsx create mode 100644 apps/web/src/viewmodels/room/notification-badge/NotificationBadgeViewModel.ts create mode 100644 apps/web/test/viewmodels/room/notification-badge/NotificationBadgeViewModel-test.ts create mode 100644 packages/shared-components/__vis__/linux/__baselines__/notifications/NotificationBadgeView/NotificationBadgeView.stories.tsx/clickable-auto.png create mode 100644 packages/shared-components/__vis__/linux/__baselines__/notifications/NotificationBadgeView/NotificationBadgeView.stories.tsx/with-tooltip-auto.png diff --git a/apps/web/playwright/e2e/spaces/threads-activity-centre/index.ts b/apps/web/playwright/e2e/spaces/threads-activity-centre/index.ts index 3b51347673..5e49b95723 100644 --- a/apps/web/playwright/e2e/spaces/threads-activity-centre/index.ts +++ b/apps/web/playwright/e2e/spaces/threads-activity-centre/index.ts @@ -302,11 +302,6 @@ export class Helpers { * @param content - the expected rooms and their notification levels */ async assertRoomsInTac(content: Array<{ room: string; notificationLevel: "highlight" | "notification" }>) { - const getBadgeClass = (notificationLevel: "highlight" | "notification") => - notificationLevel === "highlight" - ? "mx_NotificationBadge_level_highlight" - : "mx_NotificationBadge_level_notification"; - // Ensure that we have the right number of rooms await expect(this.getTacPanel().getByRole("menuitem")).toHaveCount(content.length); @@ -317,7 +312,7 @@ export class Helpers { // Ensure that the room name are correct await expect(roomLocator).toHaveText(new RegExp(room)); // There is no accessibility marker for the StatelessNotificationBadge - await expect(roomLocator.locator(`.${getBadgeClass(notificationLevel)}`)).toBeVisible(); + await expect(roomLocator.locator(`[data-notification-level="${notificationLevel}"]`)).toBeVisible(); } } diff --git a/apps/web/res/css/_components.pcss b/apps/web/res/css/_components.pcss index 3214fe5213..e8311a7fe0 100644 --- a/apps/web/res/css/_components.pcss +++ b/apps/web/res/css/_components.pcss @@ -262,7 +262,6 @@ @import "./views/rooms/_MessageComposer.pcss"; @import "./views/rooms/_MessageComposerFormatBar.pcss"; @import "./views/rooms/_NewRoomIntro.pcss"; -@import "./views/rooms/_NotificationBadge.pcss"; @import "./views/rooms/_OverflowTile.pcss"; @import "./views/rooms/_PinnedEventTile.pcss"; @import "./views/rooms/_PinnedMessageBanner.pcss"; diff --git a/apps/web/res/css/structures/_SpacePanel.pcss b/apps/web/res/css/structures/_SpacePanel.pcss index a7c452c3ca..95cc09eb34 100644 --- a/apps/web/res/css/structures/_SpacePanel.pcss +++ b/apps/web/res/css/structures/_SpacePanel.pcss @@ -253,19 +253,19 @@ Please see LICENSE files in the repository root for full details. right: -3px; top: -3px; - .mx_NotificationBadge { + .mx_SpacePanel_notificationBadge { margin: 0 2px; /* centering */ background-clip: padding-box; } - .mx_NotificationBadge_dot { + [data-badge-type="dot"] { /* make the smaller dot occupy the same width for centering */ margin: 0 -1px 0 0; border: 3px solid $spacePanel-bg-color; } - .mx_NotificationBadge_2char, - .mx_NotificationBadge_3char { + [data-badge-type="badge_2char"], + [data-badge-type="badge_3char"] { margin: -5px -5px 0 0; border: 2px solid $spacePanel-bg-color; } diff --git a/apps/web/res/css/structures/_ThreadsActivityCentre.pcss b/apps/web/res/css/structures/_ThreadsActivityCentre.pcss index ecd5207df7..e9681be1d3 100644 --- a/apps/web/res/css/structures/_ThreadsActivityCentre.pcss +++ b/apps/web/res/css/structures/_ThreadsActivityCentre.pcss @@ -89,7 +89,7 @@ /* Hide the notification badge on hover — compound's `nav-hint ~ *` rule would normally * do this, but the app-web CSS layer overrides compound-web regardless of specificity. */ - &:hover .mx_NotificationBadge { + &:hover .mx_ThreadsActivityCentre_notificationBadge { display: none; } } diff --git a/apps/web/res/css/views/avatars/_DecoratedRoomAvatar.pcss b/apps/web/res/css/views/avatars/_DecoratedRoomAvatar.pcss index 2ad1b174af..2e124d56d2 100644 --- a/apps/web/res/css/views/avatars/_DecoratedRoomAvatar.pcss +++ b/apps/web/res/css/views/avatars/_DecoratedRoomAvatar.pcss @@ -66,7 +66,7 @@ Please see LICENSE files in the repository root for full details. background-color: $presence-busy; } - .mx_NotificationBadge, + .mx_DecoratedRoomAvatar_notificationBadge, .mx_RoomTile_badgeContainer { position: absolute; top: 0; diff --git a/apps/web/res/css/views/dialogs/_ForwardDialog.pcss b/apps/web/res/css/views/dialogs/_ForwardDialog.pcss index 150ad4cbc9..df6777f8ac 100644 --- a/apps/web/res/css/views/dialogs/_ForwardDialog.pcss +++ b/apps/web/res/css/views/dialogs/_ForwardDialog.pcss @@ -141,7 +141,7 @@ Please see LICENSE files in the repository root for full details. color: $accent; } - .mx_NotificationBadge { + .mx_ForwardDialog_notificationBadge { /* Match the failed to send indicator's color with the disabled button */ background-color: $button-danger-disabled-fg-color; } diff --git a/apps/web/res/css/views/dialogs/_SpotlightDialog.pcss b/apps/web/res/css/views/dialogs/_SpotlightDialog.pcss index 5e093f53b7..2a64e84a29 100644 --- a/apps/web/res/css/views/dialogs/_SpotlightDialog.pcss +++ b/apps/web/res/css/views/dialogs/_SpotlightDialog.pcss @@ -318,7 +318,7 @@ Please see LICENSE files in the repository root for full details. } } - .mx_NotificationBadge { + .mx_SpotlightDialog_notificationBadge { margin-left: var(--cpd-space-2x); } diff --git a/apps/web/res/css/views/rooms/_NotificationBadge.pcss b/apps/web/res/css/views/rooms/_NotificationBadge.pcss deleted file mode 100644 index 20945183c3..0000000000 --- a/apps/web/res/css/views/rooms/_NotificationBadge.pcss +++ /dev/null @@ -1,86 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2020 The Matrix.org Foundation C.I.C. - -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. -*/ - -.mx_NotificationBadge { - &:not(.mx_NotificationBadge_visible) { - display: none; - } - - /* Badges are structured a bit weirdly to work around issues with non-monospace */ - /* font styles. The badge pill is actually a background div and the count floats */ - /* within that. For example: */ - /* ( 99+ ) <-- Rounded pill is a _bg class. */ - /* ^- The count is an element floating within that. */ - - &.mx_NotificationBadge_visible { - background-color: var(--cpd-color-icon-secondary); - /* For enhanced visibility under contrast control */ - outline: 1px solid transparent; - - /* Create a flexbox to order the count a bit easier */ - display: flex; - align-items: center; - justify-content: center; - - /* These are the 3 background types */ - - &.mx_NotificationBadge_dot { - width: 8px; - height: 8px; - border-radius: 8px; - background-color: var(--cpd-color-icon-primary); - - .mx_NotificationBadge_count { - display: none; - } - - /* Redundant sounding name, but a notification badge that indicates there is a regular, - * non-highlight notification - * The green colour only applies for notification dot: badges indicating the same notification - * level are the standard grey. - */ - &.mx_NotificationBadge_level_notification { - background-color: var(--cpd-color-icon-success-primary); - } - } - - /* Badges for highlight notifications. Style for notification level - * badges is in _EventTile.scss because it applies only to notification - * dots, not badges. - */ - &.mx_NotificationBadge_level_highlight { - background-color: var(--cpd-color-icon-critical-primary); - } - - & > svg { - width: 16px; - height: 16px; - } - - &.mx_NotificationBadge_2char { - width: $font-16px; - height: $font-16px; - border-radius: $font-16px; - } - - &.mx_NotificationBadge_3char { - width: $font-26px; - height: $font-16px; - border-radius: $font-16px; - } - - /* The following is the floating badge */ - - .mx_NotificationBadge_count { - font-size: $font-10px; - line-height: $font-14px; - font-weight: var(--cpd-font-weight-semibold); - color: var(--cpd-color-text-on-solid-primary); - } - } -} diff --git a/apps/web/res/css/views/settings/_NotificationSettings2.pcss b/apps/web/res/css/views/settings/_NotificationSettings2.pcss index 8a41322615..6d215aa645 100644 --- a/apps/web/res/css/views/settings/_NotificationSettings2.pcss +++ b/apps/web/res/css/views/settings/_NotificationSettings2.pcss @@ -18,7 +18,7 @@ Please see LICENSE files in the repository root for full details. margin-bottom: 20px; .mx_SettingsSubsection_text { - .mx_NotificationBadge { + .mx_NotificationSettings2_notificationBadge { vertical-align: baseline; display: inline-flex; margin: 0 2px; diff --git a/apps/web/res/themes/light-high-contrast/css/_light-high-contrast.pcss b/apps/web/res/themes/light-high-contrast/css/_light-high-contrast.pcss index ce760ddcf0..df91482923 100644 --- a/apps/web/res/themes/light-high-contrast/css/_light-high-contrast.pcss +++ b/apps/web/res/themes/light-high-contrast/css/_light-high-contrast.pcss @@ -177,7 +177,7 @@ $accent-1400: var(--cpd-color-green-1400); } } - .mx_NotificationBadge { + .mx_SpotlightDialog_notificationBadge { background-color: $background !important; } diff --git a/apps/web/src/components/views/avatars/DecoratedRoomAvatar.tsx b/apps/web/src/components/views/avatars/DecoratedRoomAvatar.tsx index ac7206ae59..e1e689384d 100644 --- a/apps/web/src/components/views/avatars/DecoratedRoomAvatar.tsx +++ b/apps/web/src/components/views/avatars/DecoratedRoomAvatar.tsx @@ -22,7 +22,7 @@ import { Tooltip } from "@vector-im/compound-web"; import { PublicIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import RoomAvatar from "./RoomAvatar"; -import NotificationBadge from "../rooms/NotificationBadge"; +import { NotificationBadge } from "../rooms/NotificationBadge/NotificationBadge"; import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore"; import { type NotificationState } from "../../../stores/notifications/NotificationState"; import { isPresenceEnabled } from "../../../utils/presence"; @@ -196,7 +196,7 @@ export default class DecoratedRoomAvatar extends React.PureComponent ); } diff --git a/apps/web/src/components/views/dialogs/ForwardDialog.tsx b/apps/web/src/components/views/dialogs/ForwardDialog.tsx index fac3887319..be88bf79e7 100644 --- a/apps/web/src/components/views/dialogs/ForwardDialog.tsx +++ b/apps/web/src/components/views/dialogs/ForwardDialog.tsx @@ -36,7 +36,7 @@ import EventTile from "../rooms/EventTile"; import SearchBox from "../../structures/SearchBox"; import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar"; import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState"; -import NotificationBadge from "../rooms/NotificationBadge"; +import { NotificationBadge } from "../rooms/NotificationBadge/NotificationBadge"; import { type RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; import { sortRoomsByRecency } from "../../../utils/room/sortRoomsByRecency"; import QueryMatcher from "../../../autocomplete/QueryMatcher"; @@ -138,7 +138,12 @@ const Entry: React.FC> = ({ room, type, content, matrixClient: className = "mx_ForwardList_sendFailed"; disabled = true; title = _t("timeline|send_state_failed"); - icon = ; + icon = ( + + ); } const id = `mx_ForwardDialog_entry_${room.roomId}`; diff --git a/apps/web/src/components/views/dialogs/spotlight/SpotlightDialog.tsx b/apps/web/src/components/views/dialogs/spotlight/SpotlightDialog.tsx index 59d0f2f560..20a8b60d3d 100644 --- a/apps/web/src/components/views/dialogs/spotlight/SpotlightDialog.tsx +++ b/apps/web/src/components/views/dialogs/spotlight/SpotlightDialog.tsx @@ -83,7 +83,7 @@ import { SearchResultAvatar } from "../../avatars/SearchResultAvatar"; import { NetworkDropdown } from "../../directory/NetworkDropdown"; import AccessibleButton, { type ButtonEvent } from "../../elements/AccessibleButton"; import Spinner from "../../elements/Spinner"; -import NotificationBadge from "../../rooms/NotificationBadge"; +import { NotificationBadge } from "../../rooms/NotificationBadge/NotificationBadge"; import BaseDialog from "../BaseDialog"; import { Option } from "./Option"; import { PublicRoomResultDetails } from "./PublicRoomResultDetails"; @@ -667,7 +667,10 @@ const SpotlightDialog: React.FC = ({ initialText = "", initialFilter = n {result.room.name} - + = ({ initialText = "", initialFilter = n tooltipProps={{ tabIndex: -1 }} /> {room.name} - + { - showUnsentTooltip?: boolean; - /** - * If specified will return an AccessibleButton instead of a div. - */ - onClick(ev: React.MouseEvent): void; -} - -interface IState { - showCounts: boolean; // whether to show counts. -} - -export default class NotificationBadge extends React.PureComponent, IState> { - private countWatcherRef?: string; - - public constructor(props: IProps) { - super(props); - - this.state = { - showCounts: SettingsStore.getValue("Notifications.alwaysShowBadgeCounts", this.roomId), - }; - } - - private get roomId(): string | null { - // We should convert this to null for safety with the SettingsStore - return this.props.roomId || null; - } - - public componentDidMount(): void { - this.props.notification.on(NotificationStateEvents.Update, this.onNotificationUpdate); - - this.countWatcherRef = SettingsStore.watchSetting( - "Notifications.alwaysShowBadgeCounts", - this.roomId, - this.countPreferenceChanged, - ); - } - - public componentWillUnmount(): void { - SettingsStore.unwatchSetting(this.countWatcherRef); - this.props.notification.off(NotificationStateEvents.Update, this.onNotificationUpdate); - } - - public componentDidUpdate(prevProps: Readonly): void { - if (prevProps.notification) { - prevProps.notification.off(NotificationStateEvents.Update, this.onNotificationUpdate); - } - - this.props.notification.on(NotificationStateEvents.Update, this.onNotificationUpdate); - } - - private countPreferenceChanged = (): void => { - this.setState({ showCounts: SettingsStore.getValue("Notifications.alwaysShowBadgeCounts", this.roomId) }); - }; - - private onNotificationUpdate = (): void => { - this.forceUpdate(); // notification state changed - update - }; - - public render(): ReactNode { - /* eslint @typescript-eslint/no-unused-vars: ["error", { "ignoreRestSiblings": true }] */ - const { notification, showUnsentTooltip, hideIfDot, onClick, tabIndex } = this.props; - - if (notification.isIdle && !notification.knocked) return null; - if (hideIfDot && notification.level < NotificationLevel.Notification) { - // This would just be a dot and we've been told not to show dots, so don't show it - return null; - } - - const commonProps: React.ComponentProps = { - symbol: notification.symbol, - count: notification.count, - level: notification.level, - knocked: notification.knocked, - }; - - let badge: JSX.Element; - if (onClick) { - badge = ; - } else { - badge = ; - } - - if (showUnsentTooltip && notification.level === NotificationLevel.Unsent) { - return ( - - {badge} - - ); - } - - return badge; - } -} diff --git a/apps/web/src/components/views/rooms/NotificationBadge/NotificationBadge.tsx b/apps/web/src/components/views/rooms/NotificationBadge/NotificationBadge.tsx new file mode 100644 index 0000000000..2639f70212 --- /dev/null +++ b/apps/web/src/components/views/rooms/NotificationBadge/NotificationBadge.tsx @@ -0,0 +1,93 @@ +/* +Copyright 2026 Element Creations Ltd. +Copyright 2024 New Vector Ltd. +Copyright 2020 The Matrix.org Foundation C.I.C. + +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, useEffect, type MouseEventHandler } from "react"; +import { NotificationBadgeView, useCreateAutoDisposedViewModel } from "@element-hq/web-shared-components"; + +import { useSettingValue } from "../../../../hooks/useSettings"; +import { type NotificationState, NotificationStateEvents } from "../../../../stores/notifications/NotificationState"; +import { + notificationBadgeDataFromNotification, + NotificationBadgeViewModel, +} from "../../../../viewmodels/room/notification-badge/NotificationBadgeViewModel"; + +interface Props { + notification: NotificationState; + + /** + * If true, show nothing if the notification would only cause a dot to be shown rather than + * a badge. That is: only display badges and not dots. Default: false. + */ + hideIfDot?: boolean; + + showUnsentTooltip?: boolean; + tabIndex?: number; + ariaLabel?: string; + className?: string; + onClick?: MouseEventHandler; +} + +export function NotificationBadge({ + notification, + hideIfDot, + showUnsentTooltip, + tabIndex, + ariaLabel, + className, + onClick, +}: Readonly): JSX.Element { + const hideBold = useSettingValue("feature_hidebold"); + const vm = useCreateAutoDisposedViewModel( + () => + new NotificationBadgeViewModel({ + ...notificationBadgeDataFromNotification(notification), + hideIfDot, + hideBold, + showUnsentTooltip, + tabIndex, + ariaLabel, + onClick, + }), + ); + + useEffect(() => { + vm.setNotificationData(notificationBadgeDataFromNotification(notification)); + + const onNotificationUpdate = (): void => { + vm.setNotificationData(notificationBadgeDataFromNotification(notification)); + }; + + notification.on(NotificationStateEvents.Update, onNotificationUpdate); + return () => { + notification.off(NotificationStateEvents.Update, onNotificationUpdate); + }; + }, [vm, notification]); + + useEffect(() => { + vm.setHideIfDot(!!hideIfDot); + }, [vm, hideIfDot]); + + useEffect(() => { + vm.setHideBold(!!hideBold); + }, [vm, hideBold]); + + useEffect(() => { + vm.setShowUnsentTooltip(!!showUnsentTooltip); + }, [vm, showUnsentTooltip]); + + useEffect(() => { + vm.setClickOptions({ + onClick, + tabIndex, + ariaLabel, + }); + }, [vm, onClick, tabIndex, ariaLabel]); + + return ; +} diff --git a/apps/web/src/components/views/rooms/NotificationBadge/StatelessNotificationBadge.tsx b/apps/web/src/components/views/rooms/NotificationBadge/StatelessNotificationBadge.tsx index d3dc69b668..5dd8184c4c 100644 --- a/apps/web/src/components/views/rooms/NotificationBadge/StatelessNotificationBadge.tsx +++ b/apps/web/src/components/views/rooms/NotificationBadge/StatelessNotificationBadge.tsx @@ -1,4 +1,5 @@ /* +Copyright 2026 Element Creations Ltd. Copyright 2024 New Vector Ltd. Copyright 2022 The Matrix.org Foundation C.I.C. @@ -6,16 +7,12 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { type Ref, type JSX, type ReactNode } from "react"; -import classNames from "classnames"; -import { AskToJoinIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; +import React, { type JSX, type MouseEventHandler, useEffect } from "react"; +import { NotificationBadgeView, useCreateAutoDisposedViewModel } from "@element-hq/web-shared-components"; -import { formatCount } from "../../../../utils/FormattingUtils"; -import AccessibleButton, { type ButtonEvent } from "../../elements/AccessibleButton"; -import { NotificationLevel } from "../../../../stores/notifications/NotificationLevel"; import { useSettingValue } from "../../../../hooks/useSettings"; -import { type XOR } from "../../../../@types/common"; -import { _t } from "../../../../languageHandler"; +import { type NotificationLevel } from "../../../../stores/notifications/NotificationLevel"; +import { NotificationBadgeViewModel } from "../../../../viewmodels/room/notification-badge/NotificationBadgeViewModel"; interface Props { symbol: string | null; @@ -24,98 +21,66 @@ interface Props { knocked?: boolean; /** * If true, where we would normally show a badge, we instead show a dot. No numeric count will - * be displayed (but may affect whether the the dot is displayed). See class doc - * for the difference between the two. + * be displayed (but may affect whether the dot is displayed). */ forceDot?: boolean; - children?: ReactNode; - ref?: Ref; -} - -interface ClickableProps extends Props { - /** - * If specified will return an AccessibleButton instead of a div. - */ - onClick(this: void, ev: ButtonEvent): void; tabIndex?: number; + ariaLabel?: string; + className?: string; + onClick?: MouseEventHandler; } -/** - * A notification indicator that conveys what activity / notifications the user has in whatever - * context it is being used. - * - * Can either be a 'badge': a small circle with a number in it (the 'count'), or a 'dot': a smaller, empty circle. - * The two can be used to convey the same meaning but in different contexts, for example: for unread - * notifications in the room list, it may have a green badge with the number of unread notifications, - * but somewhere else it may just have a green dot as a more compact representation of the same information. - */ export const StatelessNotificationBadge = ({ symbol, count, level, knocked, - forceDot = false, - ...props -}: XOR): JSX.Element => { + forceDot, + tabIndex, + ariaLabel, + className, + onClick, +}: Readonly): JSX.Element => { const hideBold = useSettingValue("feature_hidebold"); - - // Don't show a badge if we don't need to - if ((level === NotificationLevel.None || (hideBold && level == NotificationLevel.Activity)) && !knocked) { - return <>; - } - - const hasUnreadCount = level >= NotificationLevel.Notification && (!!count || !!symbol); - - const isEmptyBadge = symbol === null && count === 0; - - if (symbol === null && count > 0) { - symbol = formatCount(count); - } - - let icon: JSX.Element | undefined; - if (knocked) { - icon = ; - } - - // We show a dot if either: - // * The props force us to, or - // * It's just an activity-level notification or (in theory) lower and the room isn't knocked - const badgeType = - forceDot || (level <= NotificationLevel.Activity && !icon) - ? "dot" - : !symbol || symbol.length < 3 - ? "badge_2char" - : "badge_3char"; - - const classes = classNames({ - "mx_NotificationBadge": true, - "mx_NotificationBadge_visible": isEmptyBadge || knocked ? true : hasUnreadCount, - "mx_NotificationBadge_level_notification": level == NotificationLevel.Notification, - "mx_NotificationBadge_level_highlight": level >= NotificationLevel.Highlight, - "mx_NotificationBadge_knocked": knocked, - - // Exactly one of mx_NotificationBadge_dot, mx_NotificationBadge_2char, mx_NotificationBadge_3char - "mx_NotificationBadge_dot": badgeType === "dot", - "mx_NotificationBadge_2char": badgeType === "badge_2char", - "mx_NotificationBadge_3char": badgeType === "badge_3char", - // Badges with text should always use light colors - "cpd-theme-light": badgeType !== "dot", - }); - - const content = icon || {symbol}; - - if (props.onClick) { - return ( - - {content} - {props.children} - - ); - } - - return ( -
- {content} -
+ const vm = useCreateAutoDisposedViewModel( + () => + new NotificationBadgeViewModel({ + symbol, + count, + level, + knocked, + forceDot, + hideBold, + tabIndex, + ariaLabel, + onClick, + }), ); + + useEffect(() => { + vm.setNotificationData({ + symbol, + count, + level, + knocked, + }); + }, [vm, symbol, count, level, knocked]); + + useEffect(() => { + vm.setForceDot(!!forceDot); + }, [vm, forceDot]); + + useEffect(() => { + vm.setHideBold(!!hideBold); + }, [vm, hideBold]); + + useEffect(() => { + vm.setClickOptions({ + onClick, + tabIndex, + ariaLabel, + }); + }, [vm, onClick, tabIndex, ariaLabel]); + + return ; }; diff --git a/apps/web/src/components/views/rooms/NotificationBadge/UnreadNotificationBadge.tsx b/apps/web/src/components/views/rooms/NotificationBadge/UnreadNotificationBadge.tsx index 8e3912bf1c..8bc575a683 100644 --- a/apps/web/src/components/views/rooms/NotificationBadge/UnreadNotificationBadge.tsx +++ b/apps/web/src/components/views/rooms/NotificationBadge/UnreadNotificationBadge.tsx @@ -20,9 +20,10 @@ interface Props { * be displayed. */ forceDot?: boolean; + className?: string; } -export function UnreadNotificationBadge({ room, threadId, forceDot }: Props): JSX.Element { +export function UnreadNotificationBadge({ room, threadId, forceDot, className }: Props): JSX.Element { const vm = useCreateAutoDisposedViewModel( () => new UnreadNotificationBadgeViewModel({ @@ -44,5 +45,5 @@ export function UnreadNotificationBadge({ room, threadId, forceDot }: Props): JS vm.setForceDot(forceDot); }, [forceDot, vm]); - return ; + return ; } diff --git a/apps/web/src/components/views/rooms/VoiceRecordComposerTile.tsx b/apps/web/src/components/views/rooms/VoiceRecordComposerTile.tsx index 48e7ff4c85..fdd171b210 100644 --- a/apps/web/src/components/views/rooms/VoiceRecordComposerTile.tsx +++ b/apps/web/src/components/views/rooms/VoiceRecordComposerTile.tsx @@ -23,7 +23,7 @@ import RecordingPlayback, { PlaybackLayout } from "../audio_messages/RecordingPl import Modal from "../../../Modal"; import ErrorDialog from "../dialogs/ErrorDialog"; import MediaDeviceHandler, { MediaDeviceKindEnum } from "../../../MediaDeviceHandler"; -import NotificationBadge from "./NotificationBadge"; +import { NotificationBadge } from "./NotificationBadge/NotificationBadge"; import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState"; import { NotificationLevel } from "../../../stores/notifications/NotificationLevel"; import InlineSpinner from "../elements/InlineSpinner"; diff --git a/apps/web/src/components/views/settings/notifications/NotificationSettings2.tsx b/apps/web/src/components/views/settings/notifications/NotificationSettings2.tsx index fd3849472c..9214f81f2d 100644 --- a/apps/web/src/components/views/settings/notifications/NotificationSettings2.tsx +++ b/apps/web/src/components/views/settings/notifications/NotificationSettings2.tsx @@ -276,6 +276,7 @@ export default function NotificationSettings2(): JSX.Element { symbol="1" count={1} level={NotificationLevel.Notification} + className="mx_NotificationSettings2_notificationBadge" /> ), }, diff --git a/apps/web/src/components/views/spaces/SpaceTreeLevel.tsx b/apps/web/src/components/views/spaces/SpaceTreeLevel.tsx index f76027633b..06dd93aab1 100644 --- a/apps/web/src/components/views/spaces/SpaceTreeLevel.tsx +++ b/apps/web/src/components/views/spaces/SpaceTreeLevel.tsx @@ -30,7 +30,7 @@ import { import RoomAvatar from "../avatars/RoomAvatar"; import { type SpaceKey } from "../../../stores/spaces"; import SpaceTreeLevelLayoutStore from "../../../stores/spaces/SpaceTreeLevelLayoutStore"; -import NotificationBadge from "../rooms/NotificationBadge"; +import { NotificationBadge } from "../rooms/NotificationBadge/NotificationBadge"; import { _t } from "../../../languageHandler"; import defaultDispatcher from "../../../dispatcher/dispatcher"; import { Action } from "../../../dispatcher/actions"; @@ -118,7 +118,8 @@ export const SpaceButton = ({ diff --git a/apps/web/src/components/views/spaces/threads-activity-centre/ThreadsActivityCentre.tsx b/apps/web/src/components/views/spaces/threads-activity-centre/ThreadsActivityCentre.tsx index 286a158d6d..ec84ccfa47 100644 --- a/apps/web/src/components/views/spaces/threads-activity-centre/ThreadsActivityCentre.tsx +++ b/apps/web/src/components/views/spaces/threads-activity-centre/ThreadsActivityCentre.tsx @@ -144,7 +144,13 @@ function ThreadsActivityCentreRow({ room, onClick, notificationLevel }: ThreadsA label={room.name} Icon={} > - + ); } diff --git a/apps/web/src/i18n/strings/en_EN.json b/apps/web/src/i18n/strings/en_EN.json index b04481805f..75af178a49 100644 --- a/apps/web/src/i18n/strings/en_EN.json +++ b/apps/web/src/i18n/strings/en_EN.json @@ -1707,7 +1707,6 @@ "mentions_and_keywords": "@mentions and replies only", "mentions_and_keywords_description": "Get notified only with mentions, replies and keywords as set up in your settings", "mentions_keywords": "Mentions and keywords", - "message_didnt_send": "Message didn't send. Click for info.", "mute_description": "You won't get any notifications" }, "notifier": { diff --git a/apps/web/src/settings/Settings.tsx b/apps/web/src/settings/Settings.tsx index b7848a1975..08aac45fc7 100644 --- a/apps/web/src/settings/Settings.tsx +++ b/apps/web/src/settings/Settings.tsx @@ -248,7 +248,6 @@ export interface Settings { "MessageComposerInput.showStickersButton": IBaseSetting; "MessageComposerInput.showPollsButton": IBaseSetting; "MessageComposerInput.insertTrailingColon": IBaseSetting; - "Notifications.alwaysShowBadgeCounts": IBaseSetting; "Notifications.showbold": IBaseSetting; "Notifications.tac_only_notifications": IBaseSetting; "useCompactLayout": IBaseSetting; @@ -688,11 +687,6 @@ export const SETTINGS: Settings = { displayName: _td("settings|insert_trailing_colon_mentions"), default: true, }, - // TODO: Wire up appropriately to UI (FTUE notifications) - "Notifications.alwaysShowBadgeCounts": { - supportedLevels: LEVELS_ROOM_OR_ACCOUNT, - default: false, - }, // Used to be a feature, name kept for backwards compat "feature_hidebold": { supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG, diff --git a/apps/web/src/viewmodels/room/notification-badge/NotificationBadgeViewModel.ts b/apps/web/src/viewmodels/room/notification-badge/NotificationBadgeViewModel.ts new file mode 100644 index 0000000000..1ec79b86f3 --- /dev/null +++ b/apps/web/src/viewmodels/room/notification-badge/NotificationBadgeViewModel.ts @@ -0,0 +1,177 @@ +/* + * 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 MouseEventHandler } from "react"; +import { + BaseViewModel, + type NotificationBadgeType, + type NotificationBadgeViewModel as NotificationBadgeViewModelInterface, + type NotificationBadgeViewSnapshot, +} from "@element-hq/web-shared-components"; + +import { NotificationLevel } from "../../../stores/notifications/NotificationLevel"; +import { type NotificationState } from "../../../stores/notifications/NotificationState"; +import { formatCount } from "../../../utils/FormattingUtils"; + +export interface NotificationBadgeData { + symbol: string | null; + count: number; + level: NotificationLevel; + knocked?: boolean; +} + +export interface NotificationBadgeViewModelProps extends NotificationBadgeData { + /** + * If true, hide badges that would render as a dot. + */ + hideIfDot?: boolean; + /** + * If true, render a dot even when the badge has a count. + */ + forceDot?: boolean; + /** + * Whether activity-level bold indicators are disabled by the user. + */ + hideBold?: boolean; + /** + * Whether to show the unsent-message tooltip when the level is Unsent. + */ + showUnsentTooltip?: boolean; + /** + * Accessible label for clickable badges. + */ + ariaLabel?: string; + /** + * Tab index for clickable badges. + */ + tabIndex?: number; + /** + * Called when an interactive badge is activated. + */ + onClick?: MouseEventHandler; +} + +function getBadgeType( + level: NotificationLevel, + symbol: string | null, + forceDot?: boolean, + knocked?: boolean, +): NotificationBadgeType { + if (!knocked && (forceDot || level <= NotificationLevel.Activity)) { + return "dot"; + } + + return symbol && symbol.length >= 3 ? "badge_3char" : "badge_2char"; +} + +export function notificationBadgeDataFromNotification(notification: NotificationState): NotificationBadgeData { + return { + symbol: notification.symbol, + count: notification.count, + level: notification.level, + knocked: notification.knocked, + }; +} + +export class NotificationBadgeViewModel + extends BaseViewModel + implements NotificationBadgeViewModelInterface +{ + private static readonly computeSnapshot = ( + props: NotificationBadgeViewModelProps, + ): NotificationBadgeViewSnapshot => { + const knocked = !!props.knocked; + const isIdle = props.level <= NotificationLevel.None; + const hiddenByBoldSetting = !!props.hideBold && props.level === NotificationLevel.Activity; + const hiddenByDotSetting = !!props.hideIfDot && props.level < NotificationLevel.Notification; + const hasUnreadCount = props.level >= NotificationLevel.Notification && (!!props.count || !!props.symbol); + const isEmptyBadge = props.symbol === null && props.count === 0; + const isRendered = !((isIdle || hiddenByBoldSetting) && !knocked) && !hiddenByDotSetting; + const isVisible = isEmptyBadge || knocked || hasUnreadCount; + const displaySymbol = props.symbol === null && props.count > 0 ? formatCount(props.count) : props.symbol; + + return { + shouldRender: isRendered, + isVisible: isRendered && isVisible, + isNotification: props.level === NotificationLevel.Notification, + isHighlight: props.level >= NotificationLevel.Highlight, + isKnocked: knocked, + badgeType: getBadgeType(props.level, displaySymbol, props.forceDot, knocked), + symbol: displaySymbol, + isClickable: !!props.onClick, + ariaLabel: props.ariaLabel, + tabIndex: props.tabIndex, + showUnsentTooltip: !!props.showUnsentTooltip && props.level === NotificationLevel.Unsent, + }; + }; + + public constructor(props: NotificationBadgeViewModelProps) { + super(props, NotificationBadgeViewModel.computeSnapshot(props)); + } + + private updateSnapshotFromProps(): void { + this.snapshot.merge(NotificationBadgeViewModel.computeSnapshot(this.props)); + } + + public setNotificationData(data: NotificationBadgeData): void { + this.props = { + ...this.props, + ...data, + }; + this.updateSnapshotFromProps(); + } + + public setHideIfDot(hideIfDot: boolean): void { + this.props = { + ...this.props, + hideIfDot, + }; + this.updateSnapshotFromProps(); + } + + public setForceDot(forceDot: boolean): void { + this.props = { + ...this.props, + forceDot, + }; + this.updateSnapshotFromProps(); + } + + public setHideBold(hideBold: boolean): void { + this.props = { + ...this.props, + hideBold, + }; + this.updateSnapshotFromProps(); + } + + public setShowUnsentTooltip(showUnsentTooltip: boolean): void { + this.props = { + ...this.props, + showUnsentTooltip, + }; + this.updateSnapshotFromProps(); + } + + public setClickOptions({ + onClick, + ariaLabel, + tabIndex, + }: Pick): void { + this.props = { + ...this.props, + onClick, + ariaLabel, + tabIndex, + }; + this.updateSnapshotFromProps(); + } + + public onClick: MouseEventHandler = (event) => { + this.props.onClick?.(event); + }; +} diff --git a/apps/web/src/viewmodels/room/notification-badge/UnreadNotificationBadgeViewModel.ts b/apps/web/src/viewmodels/room/notification-badge/UnreadNotificationBadgeViewModel.ts index bc02f3e1eb..2e6f8d6515 100644 --- a/apps/web/src/viewmodels/room/notification-badge/UnreadNotificationBadgeViewModel.ts +++ b/apps/web/src/viewmodels/room/notification-badge/UnreadNotificationBadgeViewModel.ts @@ -73,6 +73,8 @@ export class UnreadNotificationBadgeViewModel isKnocked: false, badgeType: getBadgeType(level, displaySymbol, props.forceDot), symbol: displaySymbol, + isClickable: false, + showUnsentTooltip: false, }; }; diff --git a/apps/web/test/unit-tests/components/views/rooms/NotificationBadge/NotificationBadge-test.tsx b/apps/web/test/unit-tests/components/views/rooms/NotificationBadge/NotificationBadge-test.tsx index bcbb4ab712..147ebb78ea 100644 --- a/apps/web/test/unit-tests/components/views/rooms/NotificationBadge/NotificationBadge-test.tsx +++ b/apps/web/test/unit-tests/components/views/rooms/NotificationBadge/NotificationBadge-test.tsx @@ -12,7 +12,7 @@ import React from "react"; import { StatelessNotificationBadge } from "../../../../../../src/components/views/rooms/NotificationBadge/StatelessNotificationBadge"; import SettingsStore from "../../../../../../src/settings/SettingsStore"; import { NotificationLevel } from "../../../../../../src/stores/notifications/NotificationLevel"; -import NotificationBadge from "../../../../../../src/components/views/rooms/NotificationBadge"; +import { NotificationBadge } from "../../../../../../src/components/views/rooms/NotificationBadge/NotificationBadge"; import { NotificationState } from "../../../../../../src/stores/notifications/NotificationState"; class DummyNotificationState extends NotificationState { @@ -26,25 +26,25 @@ describe("NotificationBadge", () => { it("shows a dot if the level is activity", () => { const notif = new DummyNotificationState(NotificationLevel.Activity); - const { container } = render(); - expect(container.querySelector(".mx_NotificationBadge_dot")).toBeInTheDocument(); - expect(container.querySelector(".mx_NotificationBadge")).toBeInTheDocument(); + const { container } = render(); + expect(container.querySelector('[data-badge-type="dot"]')).toBeInTheDocument(); + expect(container.querySelector('[data-testid="notification-badge"]')).toBeInTheDocument(); }); it("does not show a dot if the level is activity and hideIfDot is true", () => { const notif = new DummyNotificationState(NotificationLevel.Activity); - const { container } = render(); - expect(container.querySelector(".mx_NotificationBadge_dot")).not.toBeInTheDocument(); - expect(container.querySelector(".mx_NotificationBadge")).not.toBeInTheDocument(); + const { container } = render(); + expect(container.querySelector('[data-badge-type="dot"]')).not.toBeInTheDocument(); + expect(container.querySelector('[data-testid="notification-badge"]')).not.toBeInTheDocument(); }); it("still shows an empty badge if hideIfDot us true", () => { const notif = new DummyNotificationState(NotificationLevel.Notification); - const { container } = render(); - expect(container.querySelector(".mx_NotificationBadge_dot")).not.toBeInTheDocument(); - expect(container.querySelector(".mx_NotificationBadge")).toBeInTheDocument(); + const { container } = render(); + expect(container.querySelector('[data-badge-type="dot"]')).not.toBeInTheDocument(); + expect(container.querySelector('[data-testid="notification-badge"]')).toBeInTheDocument(); }); describe("StatelessNotificationBadge", () => { diff --git a/apps/web/test/unit-tests/components/views/rooms/NotificationBadge/StatelessNotificationBadge-test.tsx b/apps/web/test/unit-tests/components/views/rooms/NotificationBadge/StatelessNotificationBadge-test.tsx index e6495b78b8..2bb4defbdd 100644 --- a/apps/web/test/unit-tests/components/views/rooms/NotificationBadge/StatelessNotificationBadge-test.tsx +++ b/apps/web/test/unit-tests/components/views/rooms/NotificationBadge/StatelessNotificationBadge-test.tsx @@ -17,14 +17,14 @@ describe("StatelessNotificationBadge", () => { const { container } = render( , ); - expect(container.querySelector(".mx_NotificationBadge_level_highlight")).not.toBe(null); + expect(container.querySelector('[data-notification-level="highlight"]')).toBeInTheDocument(); }); it("has knock style", () => { const { container } = render( , ); - expect(container.querySelector(".mx_NotificationBadge_dot")).not.toBeInTheDocument(); + expect(container.querySelector('[data-badge-type="dot"]')).not.toBeInTheDocument(); expect(container.querySelector("svg")).toHaveAccessibleName("Request to join sent"); }); @@ -32,14 +32,14 @@ describe("StatelessNotificationBadge", () => { const { container } = render( , ); - expect(container.querySelector(".mx_NotificationBadge_dot")).toBeInTheDocument(); + expect(container.querySelector('[data-badge-type="dot"]')).toBeInTheDocument(); }); it("has badge style for notification", () => { const { container } = render( , ); - expect(container.querySelector(".mx_NotificationBadge_dot")).not.toBeInTheDocument(); + expect(container.querySelector('[data-badge-type="dot"]')).not.toBeInTheDocument(); }); it("has dot style for notification when forced", () => { @@ -51,6 +51,6 @@ describe("StatelessNotificationBadge", () => { forceDot={true} />, ); - expect(container.querySelector(".mx_NotificationBadge_dot")).toBeInTheDocument(); + expect(container.querySelector('[data-badge-type="dot"]')).toBeInTheDocument(); }); }); diff --git a/apps/web/test/unit-tests/components/views/settings/notifications/__snapshots__/Notifications2-test.tsx.snap b/apps/web/test/unit-tests/components/views/settings/notifications/__snapshots__/Notifications2-test.tsx.snap index ddfacb3b4f..9ec5bbd968 100644 --- a/apps/web/test/unit-tests/components/views/settings/notifications/__snapshots__/Notifications2-test.tsx.snap +++ b/apps/web/test/unit-tests/components/views/settings/notifications/__snapshots__/Notifications2-test.tsx.snap @@ -640,10 +640,13 @@ exports[` correctly handles the loading/disabled state 1`] = ` Show a badge
1 @@ -1655,10 +1658,13 @@ exports[` matches the snapshot 1`] = ` Show a badge
1 diff --git a/apps/web/test/unit-tests/components/views/spaces/SpaceTreeLevel-test.tsx b/apps/web/test/unit-tests/components/views/spaces/SpaceTreeLevel-test.tsx index 7b3b93c384..c5940c945e 100644 --- a/apps/web/test/unit-tests/components/views/spaces/SpaceTreeLevel-test.tsx +++ b/apps/web/test/unit-tests/components/views/spaces/SpaceTreeLevel-test.tsx @@ -127,7 +127,7 @@ describe("SpaceButton", () => { />, ); - expect(container.querySelector(".mx_NotificationBadge_count")).toHaveTextContent("8"); + expect(getByTestId(container, "notification-badge")).toHaveTextContent("8"); expect(asFragment()).toMatchSnapshot(); }); }); diff --git a/apps/web/test/unit-tests/components/views/spaces/ThreadsActivityCentre-test.tsx b/apps/web/test/unit-tests/components/views/spaces/ThreadsActivityCentre-test.tsx index 2e722eea3d..271b280d7c 100644 --- a/apps/web/test/unit-tests/components/views/spaces/ThreadsActivityCentre-test.tsx +++ b/apps/web/test/unit-tests/components/views/spaces/ThreadsActivityCentre-test.tsx @@ -124,7 +124,7 @@ describe("ThreadsActivityCentre", () => { expect(tacRows.length).toEqual(1); getByText(tacRows[0], "A notification"); - expect(tacRows[0].getElementsByClassName("mx_NotificationBadge_level_notification").length).toEqual(1); + expect(tacRows[0].querySelector('[data-notification-level="notification"]')).toBeInTheDocument(); }); it("should render a room with a highlight notification in the TAC", async () => { @@ -136,7 +136,7 @@ describe("ThreadsActivityCentre", () => { expect(tacRows.length).toEqual(1); getByText(tacRows[0], "This is a real highlight"); - expect(tacRows[0].getElementsByClassName("mx_NotificationBadge_level_highlight").length).toEqual(1); + expect(tacRows[0].querySelector('[data-notification-level="highlight"]')).toBeInTheDocument(); }); it("renders notifications matching the snapshot", async () => { diff --git a/apps/web/test/unit-tests/components/views/spaces/__snapshots__/SpaceTreeLevel-test.tsx.snap b/apps/web/test/unit-tests/components/views/spaces/__snapshots__/SpaceTreeLevel-test.tsx.snap index edecf0f04c..6f9a846f1d 100644 --- a/apps/web/test/unit-tests/components/views/spaces/__snapshots__/SpaceTreeLevel-test.tsx.snap +++ b/apps/web/test/unit-tests/components/views/spaces/__snapshots__/SpaceTreeLevel-test.tsx.snap @@ -25,17 +25,21 @@ exports[`SpaceButton metaspace should render notificationState if one is provide
-
8 -
+
@@ -114,10 +117,13 @@ exports[`ThreadsActivityCentre renders notifications matching the snapshot 1`] = />
@@ -225,10 +231,13 @@ exports[`ThreadsActivityCentre should order the room with the same notification />
@@ -273,10 +282,13 @@ exports[`ThreadsActivityCentre should order the room with the same notification />
@@ -321,10 +333,13 @@ exports[`ThreadsActivityCentre should order the room with the same notification />
diff --git a/apps/web/test/viewmodels/room/notification-badge/NotificationBadgeViewModel-test.ts b/apps/web/test/viewmodels/room/notification-badge/NotificationBadgeViewModel-test.ts new file mode 100644 index 0000000000..2debb3dd8e --- /dev/null +++ b/apps/web/test/viewmodels/room/notification-badge/NotificationBadgeViewModel-test.ts @@ -0,0 +1,118 @@ +/* + * 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 { NotificationBadgeViewModel } from "../../../../src/viewmodels/room/notification-badge/NotificationBadgeViewModel"; +import { NotificationLevel } from "../../../../src/stores/notifications/NotificationLevel"; + +describe("NotificationBadgeViewModel", () => { + it("computes an activity dot snapshot", () => { + const vm = new NotificationBadgeViewModel({ + symbol: null, + count: 0, + level: NotificationLevel.Activity, + }); + + expect(vm.getSnapshot()).toMatchObject({ + shouldRender: true, + isVisible: true, + symbol: null, + isNotification: false, + isHighlight: false, + isKnocked: false, + badgeType: "dot", + isClickable: false, + }); + }); + + it("hides activity dots when hideIfDot changes and skips unchanged setters", () => { + const vm = new NotificationBadgeViewModel({ + symbol: null, + count: 0, + level: NotificationLevel.Activity, + }); + const listener = jest.fn(); + vm.subscribe(listener); + + vm.setHideIfDot(false); + expect(listener).not.toHaveBeenCalled(); + + vm.setHideIfDot(true); + expect(listener).toHaveBeenCalledTimes(1); + expect(vm.getSnapshot().shouldRender).toBe(false); + + vm.setHideIfDot(true); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it("formats counts and maps highlight levels", () => { + const vm = new NotificationBadgeViewModel({ + symbol: null, + count: 3, + level: NotificationLevel.Notification, + }); + + vm.setNotificationData({ + symbol: null, + count: 4, + level: NotificationLevel.Unsent, + }); + + expect(vm.getSnapshot()).toMatchObject({ + shouldRender: true, + isVisible: true, + symbol: "4", + isHighlight: true, + badgeType: "badge_2char", + }); + }); + + it("hides activity badges when hide bold is enabled", () => { + const vm = new NotificationBadgeViewModel({ + symbol: "", + count: 1, + level: NotificationLevel.Activity, + }); + + vm.setHideBold(true); + + expect(vm.getSnapshot().shouldRender).toBe(false); + }); + + it("updates clickable options without emitting for unchanged values", () => { + const onClick = jest.fn(); + const vm = new NotificationBadgeViewModel({ + symbol: "!", + count: 0, + level: NotificationLevel.Highlight, + onClick, + ariaLabel: "Jump to unread", + tabIndex: 0, + }); + const listener = jest.fn(); + vm.subscribe(listener); + + vm.setClickOptions({ + onClick, + ariaLabel: "Jump to unread", + tabIndex: 0, + }); + expect(listener).not.toHaveBeenCalled(); + + vm.setClickOptions({ + onClick, + ariaLabel: "Jump to first unread room", + tabIndex: 0, + }); + + expect(listener).toHaveBeenCalledTimes(1); + expect(vm.getSnapshot()).toMatchObject({ + isClickable: true, + ariaLabel: "Jump to first unread room", + tabIndex: 0, + }); + }); +}); diff --git a/packages/shared-components/__vis__/linux/__baselines__/notifications/NotificationBadgeView/NotificationBadgeView.stories.tsx/clickable-auto.png b/packages/shared-components/__vis__/linux/__baselines__/notifications/NotificationBadgeView/NotificationBadgeView.stories.tsx/clickable-auto.png new file mode 100644 index 0000000000000000000000000000000000000000..7aa23cf6388121f7a47cae4ddf65d085a3d63eb7 GIT binary patch literal 17081 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 literal 0 HcmV?d00001 diff --git a/packages/shared-components/src/i18n/strings/en_EN.json b/packages/shared-components/src/i18n/strings/en_EN.json index 8166de74fe..c2ec670137 100644 --- a/packages/shared-components/src/i18n/strings/en_EN.json +++ b/packages/shared-components/src/i18n/strings/en_EN.json @@ -82,6 +82,7 @@ "all_messages": "All messages", "default_settings": "Match default settings", "mentions_keywords": "Mentions and keywords", + "message_didnt_send": "Message didn't send. Click for info.", "mute_room": "Mute room" }, "release_announcement": { @@ -101,6 +102,7 @@ "jump_to_date": "Jump to date", "jump_to_date_beginning": "The beginning of the room", "jump_to_date_prompt": "Pick a date to jump to", + "knock_sent": "Request to join sent", "pinned_message_badge": "Pinned message", "status_bar": { "delete_all": "Delete all", diff --git a/packages/shared-components/src/notifications/NotificationBadgeView/NotificationBadgeView.module.css b/packages/shared-components/src/notifications/NotificationBadgeView/NotificationBadgeView.module.css index a205f9cdd0..158bae064b 100644 --- a/packages/shared-components/src/notifications/NotificationBadgeView/NotificationBadgeView.module.css +++ b/packages/shared-components/src/notifications/NotificationBadgeView/NotificationBadgeView.module.css @@ -5,6 +5,13 @@ * Please see LICENSE files in the repository root for full details. */ +.notificationBadge { + box-sizing: border-box; + padding: 0; + border: 0; + font: inherit; +} + .notificationBadge:not(.visible) { display: none; } @@ -18,6 +25,10 @@ justify-content: center; } +button.notificationBadge { + cursor: pointer; +} + .notificationBadge.visible.dot { width: 8px; height: 8px; diff --git a/packages/shared-components/src/notifications/NotificationBadgeView/NotificationBadgeView.stories.tsx b/packages/shared-components/src/notifications/NotificationBadgeView/NotificationBadgeView.stories.tsx index 44a87dfe3a..97b0082759 100644 --- a/packages/shared-components/src/notifications/NotificationBadgeView/NotificationBadgeView.stories.tsx +++ b/packages/shared-components/src/notifications/NotificationBadgeView/NotificationBadgeView.stories.tsx @@ -6,16 +6,23 @@ */ import React, { type JSX } from "react"; +import { fn } from "storybook/test"; import type { Meta, StoryObj } from "@storybook/react-vite"; import { useMockedViewModel } from "../../core/viewmodel"; import { withViewDocs } from "../../../.storybook/withViewDocs"; -import { NotificationBadgeView, type NotificationBadgeViewSnapshot } from "./NotificationBadgeView"; +import { + NotificationBadgeView, + type NotificationBadgeViewActions, + type NotificationBadgeViewSnapshot, +} from "./NotificationBadgeView"; -type WrapperProps = NotificationBadgeViewSnapshot; +type WrapperProps = NotificationBadgeViewSnapshot & NotificationBadgeViewActions; -const NotificationBadgeViewWrapperImpl = ({ ...snapshotProps }: WrapperProps): JSX.Element => { - const vm = useMockedViewModel(snapshotProps, {}); +const NotificationBadgeViewWrapperImpl = ({ onClick, ...snapshotProps }: WrapperProps): JSX.Element => { + const vm = useMockedViewModel(snapshotProps, { + onClick: onClick ?? fn(), + }); return ; }; @@ -34,7 +41,10 @@ const meta = { isKnocked: false, badgeType: "badge_2char", symbol: "3", - knockLabel: "Request to join sent", + isClickable: false, + ariaLabel: undefined, + tabIndex: undefined, + showUnsentTooltip: false, }, } satisfies Meta; @@ -65,6 +75,22 @@ export const Knocked: Story = { }, }; +export const Clickable: Story = { + args: { + isClickable: true, + ariaLabel: "Jump to first unread room", + tabIndex: 0, + }, +}; + +export const WithTooltip: Story = { + args: { + isHighlight: true, + symbol: "!", + showUnsentTooltip: true, + }, +}; + export const Hidden: Story = { args: { shouldRender: false, diff --git a/packages/shared-components/src/notifications/NotificationBadgeView/NotificationBadgeView.tsx b/packages/shared-components/src/notifications/NotificationBadgeView/NotificationBadgeView.tsx index 52b558840e..4cec043510 100644 --- a/packages/shared-components/src/notifications/NotificationBadgeView/NotificationBadgeView.tsx +++ b/packages/shared-components/src/notifications/NotificationBadgeView/NotificationBadgeView.tsx @@ -5,11 +5,13 @@ * Please see LICENSE files in the repository root for full details. */ -import React, { type JSX } from "react"; +import React, { type JSX, type MouseEventHandler } from "react"; import classNames from "classnames"; import { AskToJoinIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; +import { Tooltip } from "@vector-im/compound-web"; import { type ViewModel, useViewModel } from "../../core/viewmodel"; +import { useI18n } from "../../core/i18n/i18nContext"; import styles from "./NotificationBadgeView.module.css"; export type NotificationBadgeType = "dot" | "badge_2char" | "badge_3char"; @@ -44,26 +46,58 @@ export interface NotificationBadgeViewSnapshot { */ symbol: string | null; /** - * Accessible label for the knock icon. + * Whether to render the badge as an interactive control. */ - knockLabel?: string; + isClickable: boolean; + /** + * Accessible label for clickable badges. + */ + ariaLabel?: string; + /** + * Tab index for clickable badges. + */ + tabIndex?: number; + /** + * Whether to show the unsent-message tooltip. + */ + showUnsentTooltip: boolean; } -export type NotificationBadgeViewModel = ViewModel; +export interface NotificationBadgeViewActions { + /** + * Called when an interactive badge is activated. + */ + onClick?: MouseEventHandler; +} + +export type NotificationBadgeViewModel = ViewModel & NotificationBadgeViewActions; interface NotificationBadgeViewProps { vm: NotificationBadgeViewModel; + className?: string; } -export function NotificationBadgeView({ vm }: Readonly): JSX.Element { - const { shouldRender, isVisible, isNotification, isHighlight, isKnocked, badgeType, symbol, knockLabel } = - useViewModel(vm); +export function NotificationBadgeView({ vm, className }: Readonly): JSX.Element { + const { translate: _t } = useI18n(); + const { + shouldRender, + isVisible, + isNotification, + isHighlight, + isKnocked, + badgeType, + symbol, + isClickable, + ariaLabel, + tabIndex, + showUnsentTooltip, + } = useViewModel(vm); if (!shouldRender) { return <>; } - const classes = classNames(styles.notificationBadge, { + const classes = classNames(className, styles.notificationBadge, { [styles.visible]: isVisible, [styles.notification]: isNotification, [styles.highlight]: isHighlight, @@ -75,14 +109,26 @@ export function NotificationBadgeView({ vm }: Readonly - ) : ( - {symbol} - ); + const content = isKnocked ? ( + + ) : ( + {symbol} + ); - return ( + const badge = isClickable ? ( + + ) : (
); + + if (showUnsentTooltip) { + return ( + + {badge} + + ); + } + + return badge; } diff --git a/packages/shared-components/src/notifications/NotificationBadgeView/index.ts b/packages/shared-components/src/notifications/NotificationBadgeView/index.ts index fbb2fef1ee..1b1f91cca7 100644 --- a/packages/shared-components/src/notifications/NotificationBadgeView/index.ts +++ b/packages/shared-components/src/notifications/NotificationBadgeView/index.ts @@ -8,6 +8,7 @@ export { NotificationBadgeView, type NotificationBadgeType, + type NotificationBadgeViewActions, type NotificationBadgeViewModel, type NotificationBadgeViewSnapshot, } from "./NotificationBadgeView";