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 0000000000..7aa23cf638 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/notifications/NotificationBadgeView/NotificationBadgeView.stories.tsx/clickable-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/notifications/NotificationBadgeView/NotificationBadgeView.stories.tsx/with-tooltip-auto.png b/packages/shared-components/__vis__/linux/__baselines__/notifications/NotificationBadgeView/NotificationBadgeView.stories.tsx/with-tooltip-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/with-tooltip-auto.png differ 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";