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
This commit is contained in:
Zack
2026-08-10 08:32:23 +00:00
committed by GitHub
parent a73f8c389b
commit ce74b5f614
41 changed files with 668 additions and 398 deletions
@@ -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();
}
}
-1
View File
@@ -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";
+4 -4
View File
@@ -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;
}
@@ -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;
}
}
@@ -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;
@@ -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;
}
@@ -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);
}
@@ -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);
}
}
}
@@ -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;
@@ -177,7 +177,7 @@ $accent-1400: var(--cpd-color-green-1400);
}
}
.mx_NotificationBadge {
.mx_SpotlightDialog_notificationBadge {
background-color: $background !important;
}
@@ -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<IProps, ISt
<NotificationBadge
notification={this.state.notificationState}
hideIfDot={this.props.hideIfDot}
roomId={this.props.room.roomId}
className="mx_DecoratedRoomAvatar_notificationBadge"
/>
);
}
@@ -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<IEntryProps<any>> = ({ room, type, content, matrixClient:
className = "mx_ForwardList_sendFailed";
disabled = true;
title = _t("timeline|send_state_failed");
icon = <NotificationBadge notification={StaticNotificationState.RED_EXCLAMATION} />;
icon = (
<NotificationBadge
notification={StaticNotificationState.RED_EXCLAMATION}
className="mx_ForwardDialog_notificationBadge"
/>
);
}
const id = `mx_ForwardDialog_entry_${room.roomId}`;
@@ -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<IProps> = ({ initialText = "", initialFilter = n
<span className="mx_SpotlightDialog_result_name" title={result.room.name}>
{result.room.name}
</span>
<NotificationBadge notification={notification} />
<NotificationBadge
notification={notification}
className="mx_SpotlightDialog_notificationBadge"
/>
<RoomContextDetails
id={`mx_SpotlightDialog_button_result_${result.room.roomId}_details`}
className="mx_SpotlightDialog_result_details"
@@ -1096,7 +1099,10 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
tooltipProps={{ tabIndex: -1 }}
/>
{room.name}
<NotificationBadge notification={notification} />
<NotificationBadge
notification={notification}
className="mx_SpotlightDialog_notificationBadge"
/>
<RoomContextDetails
id={`mx_SpotlightDialog_button_recentSearch_${room.roomId}_details`}
className="mx_SpotlightDialog_result_details"
@@ -12,7 +12,7 @@ import { CheckCircleIcon, CircleIcon } from "@vector-im/compound-design-tokens/a
import { _t } from "../../../../languageHandler";
import { StaticNotificationState } from "../../../../stores/notifications/StaticNotificationState";
import NotificationBadge from "../NotificationBadge";
import { NotificationBadge } from "../NotificationBadge/NotificationBadge";
import { ReadReceiptGroup } from "../ReadReceiptGroup";
import { type IReadReceiptPosition } from "../ReadReceiptMarker";
import { type EventTileReceiptState } from "../../../../viewmodels/room/timeline/event-tile/EventTileReceiptState";
@@ -1,127 +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.
*/
import React, { type JSX, type ReactNode } from "react";
import { Tooltip } from "@vector-im/compound-web";
import SettingsStore from "../../../settings/SettingsStore";
import { type XOR } from "../../../@types/common";
import { type NotificationState, NotificationStateEvents } from "../../../stores/notifications/NotificationState";
import { _t } from "../../../languageHandler";
import { NotificationLevel } from "../../../stores/notifications/NotificationLevel";
import { StatelessNotificationBadge } from "./NotificationBadge/StatelessNotificationBadge";
interface IProps {
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;
/**
* The room ID, if any, the badge represents.
*/
roomId?: string;
}
interface IClickableProps extends IProps, React.InputHTMLAttributes<Element> {
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<XOR<IProps, IClickableProps>, 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<IProps>): 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<typeof StatelessNotificationBadge> = {
symbol: notification.symbol,
count: notification.count,
level: notification.level,
knocked: notification.knocked,
};
let badge: JSX.Element;
if (onClick) {
badge = <StatelessNotificationBadge {...commonProps} onClick={onClick} tabIndex={tabIndex} />;
} else {
badge = <StatelessNotificationBadge {...commonProps} />;
}
if (showUnsentTooltip && notification.level === NotificationLevel.Unsent) {
return (
<Tooltip label={_t("notifications|message_didnt_send")} placement="right">
{badge}
</Tooltip>
);
}
return badge;
}
}
@@ -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<HTMLButtonElement>;
}
export function NotificationBadge({
notification,
hideIfDot,
showUnsentTooltip,
tabIndex,
ariaLabel,
className,
onClick,
}: Readonly<Props>): 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 <NotificationBadgeView vm={vm} className={className} />;
}
@@ -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<HTMLDivElement>;
}
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<HTMLButtonElement>;
}
/**
* 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<Props, ClickableProps>): JSX.Element => {
forceDot,
tabIndex,
ariaLabel,
className,
onClick,
}: Readonly<Props>): 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 = <AskToJoinIcon aria-label={_t("room|knock_sent")} />;
}
// 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 || <span className="mx_NotificationBadge_count">{symbol}</span>;
if (props.onClick) {
return (
<AccessibleButton {...props} className={classes} onClick={props.onClick} ref={props.ref}>
{content}
{props.children}
</AccessibleButton>
);
}
return (
<div className={classes} ref={props.ref}>
{content}
</div>
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 <NotificationBadgeView vm={vm} className={className} />;
};
@@ -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 <NotificationBadgeView vm={vm} />;
return <NotificationBadgeView vm={vm} className={className} />;
}
@@ -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";
@@ -276,6 +276,7 @@ export default function NotificationSettings2(): JSX.Element {
symbol="1"
count={1}
level={NotificationLevel.Notification}
className="mx_NotificationSettings2_notificationBadge"
/>
),
},
@@ -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 = <T extends keyof HTMLElementTagNameMap>({
<NotificationBadge
onClick={jumpToNotification}
notification={notificationState}
aria-label={ariaLabel}
className="mx_SpacePanel_notificationBadge"
ariaLabel={ariaLabel}
tabIndex={tabIndex}
showUnsentTooltip={true}
/>
@@ -144,7 +144,13 @@ function ThreadsActivityCentreRow({ room, onClick, notificationLevel }: ThreadsA
label={room.name}
Icon={<DecoratedRoomAvatar room={room} size="32px" />}
>
<StatelessNotificationBadge level={notificationLevel} count={0} symbol={null} forceDot={true} />
<StatelessNotificationBadge
level={notificationLevel}
count={0}
symbol={null}
forceDot={true}
className="mx_ThreadsActivityCentre_notificationBadge"
/>
</MenuItem>
);
}
-1
View File
@@ -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 <a>settings</a>",
"mentions_keywords": "Mentions and keywords",
"message_didnt_send": "Message didn't send. Click for info.",
"mute_description": "You won't get any notifications"
},
"notifier": {
-6
View File
@@ -248,7 +248,6 @@ export interface Settings {
"MessageComposerInput.showStickersButton": IBaseSetting<boolean>;
"MessageComposerInput.showPollsButton": IBaseSetting<boolean>;
"MessageComposerInput.insertTrailingColon": IBaseSetting<boolean>;
"Notifications.alwaysShowBadgeCounts": IBaseSetting<boolean>;
"Notifications.showbold": IBaseSetting<boolean>;
"Notifications.tac_only_notifications": IBaseSetting<boolean>;
"useCompactLayout": IBaseSetting<boolean>;
@@ -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,
@@ -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<HTMLButtonElement>;
}
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<NotificationBadgeViewSnapshot, NotificationBadgeViewModelProps>
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<NotificationBadgeViewModelProps, "onClick" | "ariaLabel" | "tabIndex">): void {
this.props = {
...this.props,
onClick,
ariaLabel,
tabIndex,
};
this.updateSnapshotFromProps();
}
public onClick: MouseEventHandler<HTMLButtonElement> = (event) => {
this.props.onClick?.(event);
};
}
@@ -73,6 +73,8 @@ export class UnreadNotificationBadgeViewModel
isKnocked: false,
badgeType: getBadgeType(level, displaySymbol, props.forceDot),
symbol: displaySymbol,
isClickable: false,
showUnsentTooltip: false,
};
};
@@ -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(<NotificationBadge roomId="!foo:bar" notification={notif} />);
expect(container.querySelector(".mx_NotificationBadge_dot")).toBeInTheDocument();
expect(container.querySelector(".mx_NotificationBadge")).toBeInTheDocument();
const { container } = render(<NotificationBadge notification={notif} />);
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(<NotificationBadge roomId="!foo:bar" notification={notif} hideIfDot={true} />);
expect(container.querySelector(".mx_NotificationBadge_dot")).not.toBeInTheDocument();
expect(container.querySelector(".mx_NotificationBadge")).not.toBeInTheDocument();
const { container } = render(<NotificationBadge notification={notif} hideIfDot={true} />);
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(<NotificationBadge roomId="!foo:bar" notification={notif} hideIfDot={true} />);
expect(container.querySelector(".mx_NotificationBadge_dot")).not.toBeInTheDocument();
expect(container.querySelector(".mx_NotificationBadge")).toBeInTheDocument();
const { container } = render(<NotificationBadge notification={notif} hideIfDot={true} />);
expect(container.querySelector('[data-badge-type="dot"]')).not.toBeInTheDocument();
expect(container.querySelector('[data-testid="notification-badge"]')).toBeInTheDocument();
});
describe("StatelessNotificationBadge", () => {
@@ -17,14 +17,14 @@ describe("StatelessNotificationBadge", () => {
const { container } = render(
<StatelessNotificationBadge symbol="!" count={0} level={NotificationLevel.Unsent} />,
);
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(
<StatelessNotificationBadge symbol="!" count={0} level={NotificationLevel.Highlight} knocked={true} />,
);
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(
<StatelessNotificationBadge symbol={null} count={3} level={NotificationLevel.Activity} />,
);
expect(container.querySelector(".mx_NotificationBadge_dot")).toBeInTheDocument();
expect(container.querySelector('[data-badge-type="dot"]')).toBeInTheDocument();
});
it("has badge style for notification", () => {
const { container } = render(
<StatelessNotificationBadge symbol={null} count={3} level={NotificationLevel.Notification} />,
);
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();
});
});
@@ -640,10 +640,13 @@ exports[`<Notifications /> correctly handles the loading/disabled state 1`] = `
<span>
Show a badge
<div
class="mx_NotificationBadge mx_NotificationBadge_visible mx_NotificationBadge_level_notification mx_NotificationBadge_2char cpd-theme-light"
class="mx_NotificationSettings2_notificationBadge _notificationBadge_kb9ny_8 _visible_kb9ny_15 _notification_kb9ny_8 _badge2Char_kb9ny_56 cpd-theme-light"
data-badge-type="badge_2char"
data-notification-level="notification"
data-testid="notification-badge"
>
<span
class="mx_NotificationBadge_count"
class="_count_kb9ny_39"
>
1
</span>
@@ -1655,10 +1658,13 @@ exports[`<Notifications /> matches the snapshot 1`] = `
<span>
Show a badge
<div
class="mx_NotificationBadge mx_NotificationBadge_visible mx_NotificationBadge_level_notification mx_NotificationBadge_2char cpd-theme-light"
class="mx_NotificationSettings2_notificationBadge _notificationBadge_kb9ny_8 _visible_kb9ny_15 _notification_kb9ny_8 _badge2Char_kb9ny_56 cpd-theme-light"
data-badge-type="badge_2char"
data-notification-level="notification"
data-testid="notification-badge"
>
<span
class="mx_NotificationBadge_count"
class="_count_kb9ny_39"
>
1
</span>
@@ -127,7 +127,7 @@ describe("SpaceButton", () => {
/>,
);
expect(container.querySelector(".mx_NotificationBadge_count")).toHaveTextContent("8");
expect(getByTestId(container, "notification-badge")).toHaveTextContent("8");
expect(asFragment()).toMatchSnapshot();
});
});
@@ -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 () => {
@@ -25,17 +25,21 @@ exports[`SpaceButton metaspace should render notificationState if one is provide
<div
class="mx_SpacePanel_badgeContainer"
>
<div
class="mx_AccessibleButton mx_NotificationBadge mx_NotificationBadge_visible mx_NotificationBadge_level_notification mx_NotificationBadge_2char cpd-theme-light"
role="button"
<button
aria-label="Jump to first unread room."
class="mx_SpacePanel_notificationBadge _notificationBadge_kb9ny_8 _visible_kb9ny_15 _notification_kb9ny_8 _badge2Char_kb9ny_56 cpd-theme-light"
data-badge-type="badge_2char"
data-notification-level="notification"
data-testid="notification-badge"
tabindex="-1"
type="button"
>
<span
class="mx_NotificationBadge_count"
class="_count_kb9ny_39"
>
8
</span>
</div>
</button>
</div>
</div>
<span
@@ -66,10 +66,13 @@ exports[`ThreadsActivityCentre renders notifications matching the snapshot 1`] =
/>
</svg>
<div
class="mx_NotificationBadge mx_NotificationBadge_visible mx_NotificationBadge_level_highlight mx_NotificationBadge_dot"
class="mx_ThreadsActivityCentre_notificationBadge _notificationBadge_kb9ny_8 _visible_kb9ny_15 _highlight_kb9ny_47 _dot_kb9ny_32"
data-badge-type="dot"
data-notification-level="highlight"
data-testid="notification-badge"
>
<span
class="mx_NotificationBadge_count"
class="_count_kb9ny_39"
/>
</div>
</button>
@@ -114,10 +117,13 @@ exports[`ThreadsActivityCentre renders notifications matching the snapshot 1`] =
/>
</svg>
<div
class="mx_NotificationBadge mx_NotificationBadge_visible mx_NotificationBadge_level_notification mx_NotificationBadge_dot"
class="mx_ThreadsActivityCentre_notificationBadge _notificationBadge_kb9ny_8 _visible_kb9ny_15 _notification_kb9ny_8 _dot_kb9ny_32"
data-badge-type="dot"
data-notification-level="notification"
data-testid="notification-badge"
>
<span
class="mx_NotificationBadge_count"
class="_count_kb9ny_39"
/>
</div>
</button>
@@ -225,10 +231,13 @@ exports[`ThreadsActivityCentre should order the room with the same notification
/>
</svg>
<div
class="mx_NotificationBadge mx_NotificationBadge_visible mx_NotificationBadge_level_highlight mx_NotificationBadge_dot"
class="mx_ThreadsActivityCentre_notificationBadge _notificationBadge_kb9ny_8 _visible_kb9ny_15 _highlight_kb9ny_47 _dot_kb9ny_32"
data-badge-type="dot"
data-notification-level="highlight"
data-testid="notification-badge"
>
<span
class="mx_NotificationBadge_count"
class="_count_kb9ny_39"
/>
</div>
</button>
@@ -273,10 +282,13 @@ exports[`ThreadsActivityCentre should order the room with the same notification
/>
</svg>
<div
class="mx_NotificationBadge mx_NotificationBadge_visible mx_NotificationBadge_level_highlight mx_NotificationBadge_dot"
class="mx_ThreadsActivityCentre_notificationBadge _notificationBadge_kb9ny_8 _visible_kb9ny_15 _highlight_kb9ny_47 _dot_kb9ny_32"
data-badge-type="dot"
data-notification-level="highlight"
data-testid="notification-badge"
>
<span
class="mx_NotificationBadge_count"
class="_count_kb9ny_39"
/>
</div>
</button>
@@ -321,10 +333,13 @@ exports[`ThreadsActivityCentre should order the room with the same notification
/>
</svg>
<div
class="mx_NotificationBadge mx_NotificationBadge_visible mx_NotificationBadge_level_highlight mx_NotificationBadge_dot"
class="mx_ThreadsActivityCentre_notificationBadge _notificationBadge_kb9ny_8 _visible_kb9ny_15 _highlight_kb9ny_47 _dot_kb9ny_32"
data-badge-type="dot"
data-notification-level="highlight"
data-testid="notification-badge"
>
<span
class="mx_NotificationBadge_count"
class="_count_kb9ny_39"
/>
</div>
</button>
@@ -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,
});
});
});
@@ -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",
@@ -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;
@@ -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 <NotificationBadgeView vm={vm} />;
};
@@ -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<typeof NotificationBadgeViewWrapper>;
@@ -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,
@@ -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<NotificationBadgeViewSnapshot>;
export interface NotificationBadgeViewActions {
/**
* Called when an interactive badge is activated.
*/
onClick?: MouseEventHandler<HTMLButtonElement>;
}
export type NotificationBadgeViewModel = ViewModel<NotificationBadgeViewSnapshot> & NotificationBadgeViewActions;
interface NotificationBadgeViewProps {
vm: NotificationBadgeViewModel;
className?: string;
}
export function NotificationBadgeView({ vm }: Readonly<NotificationBadgeViewProps>): JSX.Element {
const { shouldRender, isVisible, isNotification, isHighlight, isKnocked, badgeType, symbol, knockLabel } =
useViewModel(vm);
export function NotificationBadgeView({ vm, className }: Readonly<NotificationBadgeViewProps>): 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<NotificationBadgeViewProp
});
const notificationLevel = isHighlight ? "highlight" : isNotification ? "notification" : undefined;
const content =
isKnocked && knockLabel ? (
<AskToJoinIcon aria-label={knockLabel} />
) : (
<span className={styles.count}>{symbol}</span>
);
const content = isKnocked ? (
<AskToJoinIcon aria-label={_t("room|knock_sent")} />
) : (
<span className={styles.count}>{symbol}</span>
);
return (
const badge = isClickable ? (
<button
type="button"
data-testid="notification-badge"
data-badge-type={badgeType}
data-notification-level={notificationLevel}
className={classes}
aria-label={ariaLabel}
tabIndex={tabIndex}
onClick={vm.onClick}
>
{content}
</button>
) : (
<div
data-testid="notification-badge"
data-badge-type={badgeType}
@@ -92,4 +138,14 @@ export function NotificationBadgeView({ vm }: Readonly<NotificationBadgeViewProp
{content}
</div>
);
if (showUnsentTooltip) {
return (
<Tooltip label={_t("notifications|message_didnt_send")} placement="right">
{badge}
</Tooltip>
);
}
return badge;
}
@@ -8,6 +8,7 @@
export {
NotificationBadgeView,
type NotificationBadgeType,
type NotificationBadgeViewActions,
type NotificationBadgeViewModel,
type NotificationBadgeViewSnapshot,
} from "./NotificationBadgeView";