Add NotificationDecoration component

Add the NotificationDecoration component to shared-components.
This is a leaf component that renders notification badges and indicators
for rooms/items including mentions, unread counts, call indicators, etc.
This commit is contained in:
David Langley 2026-01-30 09:41:02 +00:00
parent b31527261d
commit 9e61bfa75e
17 changed files with 541 additions and 0 deletions

View File

@ -0,0 +1,120 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { NotificationDecoration, type NotificationDecorationProps } from "./NotificationDecoration";
const defaultProps: NotificationDecorationProps = {
hasAnyNotificationOrActivity: false,
isUnsentMessage: false,
invited: false,
isMention: false,
isActivityNotification: false,
isNotification: false,
hasUnreadCount: false,
count: 0,
muted: false,
};
const meta = {
title: "Room List/NotificationDecoration",
component: NotificationDecoration,
tags: ["autodocs"],
decorators: [
(Story) => (
<div style={{ padding: "16px", backgroundColor: "var(--cpd-color-bg-canvas-default)" }}>
<Story />
</div>
),
],
args: defaultProps,
} satisfies Meta<typeof NotificationDecoration>;
export default meta;
type Story = StoryObj<typeof meta>;
export const NoNotification: Story = {};
export const UnsentMessage: Story = {
args: {
hasAnyNotificationOrActivity: true,
isUnsentMessage: true,
},
};
export const VideoCall: Story = {
args: {
hasAnyNotificationOrActivity: true,
callType: "video",
},
};
export const VoiceCall: Story = {
args: {
hasAnyNotificationOrActivity: true,
callType: "voice",
},
};
export const Invited: Story = {
args: {
hasAnyNotificationOrActivity: true,
invited: true,
},
};
export const Mention: Story = {
args: {
hasAnyNotificationOrActivity: true,
isMention: true,
},
};
export const MentionWithCount: Story = {
args: {
hasAnyNotificationOrActivity: true,
isMention: true,
count: 5,
},
};
export const NotificationWithCount: Story = {
args: {
hasAnyNotificationOrActivity: true,
isNotification: true,
count: 3,
},
};
export const ActivityIndicator: Story = {
args: {
hasAnyNotificationOrActivity: true,
isActivityNotification: true,
},
};
export const Muted: Story = {
args: {
muted: true,
},
};
export const MutedWithoutActivity: Story = {
args: {
hasAnyNotificationOrActivity: false,
muted: true,
},
};
export const VideoCallWithoutActivity: Story = {
args: {
hasAnyNotificationOrActivity: false,
callType: "video",
},
};

View File

@ -0,0 +1,80 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { render } from "@test-utils";
import { composeStories } from "@storybook/react-vite";
import { describe, it, expect } from "vitest";
import * as stories from "./NotificationDecoration.stories";
const {
NoNotification,
UnsentMessage,
VideoCall,
VoiceCall,
Invited,
Mention,
MentionWithCount,
NotificationWithCount,
ActivityIndicator,
Muted,
} = composeStories(stories);
describe("<NotificationDecoration />", () => {
describe("snapshots", () => {
it("renders NoNotification story", () => {
const { container } = render(<NoNotification />);
expect(container).toMatchSnapshot();
});
it("renders UnsentMessage story", () => {
const { container } = render(<UnsentMessage />);
expect(container).toMatchSnapshot();
});
it("renders VideoCall story", () => {
const { container } = render(<VideoCall />);
expect(container).toMatchSnapshot();
});
it("renders VoiceCall story", () => {
const { container } = render(<VoiceCall />);
expect(container).toMatchSnapshot();
});
it("renders Invited story", () => {
const { container } = render(<Invited />);
expect(container).toMatchSnapshot();
});
it("renders Mention story", () => {
const { container } = render(<Mention />);
expect(container).toMatchSnapshot();
});
it("renders MentionWithCount story", () => {
const { container } = render(<MentionWithCount />);
expect(container).toMatchSnapshot();
});
it("renders NotificationWithCount story", () => {
const { container } = render(<NotificationWithCount />);
expect(container).toMatchSnapshot();
});
it("renders ActivityIndicator story", () => {
const { container } = render(<ActivityIndicator />);
expect(container).toMatchSnapshot();
});
it("renders Muted story", () => {
const { container } = render(<Muted />);
expect(container).toMatchSnapshot();
});
});
});

View File

@ -0,0 +1,90 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import {
MentionIcon,
ErrorSolidIcon,
NotificationsOffSolidIcon,
VideoCallSolidIcon,
EmailSolidIcon,
VoiceCallSolidIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { UnreadCounter, Unread } from "@vector-im/compound-web";
import { Flex } from "../../../utils/Flex";
/**
* Data representing the notification state for a room or item.
* Used in snapshots and passed to the NotificationDecoration component.
*/
export interface NotificationDecorationData {
/** Whether there is any notification or activity to display */
hasAnyNotificationOrActivity: boolean;
/** Whether there's an unsent message */
isUnsentMessage: boolean;
/** Whether the user is invited to the room */
invited: boolean;
/** Whether the notification is a mention */
isMention: boolean;
/** Whether there's activity (not a full notification) */
isActivityNotification: boolean;
/** Whether there's a notification (not just activity) */
isNotification: boolean;
/** Whether there are unread messages with a count */
hasUnreadCount: boolean;
/** Notification count */
count: number;
/** Whether notifications are muted */
muted: boolean;
/** Optional call type indicator */
callType?: "video" | "voice";
}
/**
* Props for the NotificationDecoration component.
*/
export interface NotificationDecorationProps extends NotificationDecorationData {}
/**
* Renders notification badges and indicators for rooms/items
*/
export const NotificationDecoration: React.FC<NotificationDecorationProps> = ({
hasAnyNotificationOrActivity,
muted,
callType,
isUnsentMessage,
invited,
isMention,
isNotification,
isActivityNotification,
count,
}) => {
// Don't render anything if there's nothing to show
if (!hasAnyNotificationOrActivity && !muted && !callType) {
return null;
}
return (
<Flex align="center" justify="center" gap="var(--cpd-space-1x)" data-testid="notification-decoration">
{isUnsentMessage && (
<ErrorSolidIcon width="20px" height="20px" fill="var(--cpd-color-icon-critical-primary)" />
)}
{callType === "video" && (
<VideoCallSolidIcon width="20px" height="20px" fill="var(--cpd-color-icon-accent-primary)" />
)}
{callType === "voice" && (
<VoiceCallSolidIcon width="20px" height="20px" fill="var(--cpd-color-icon-accent-primary)" />
)}
{invited && <EmailSolidIcon width="20px" height="20px" fill="var(--cpd-color-icon-accent-primary)" />}
{isMention && <MentionIcon width="20px" height="20px" fill="var(--cpd-color-icon-accent-primary)" />}
{(isMention || isNotification) && <UnreadCounter count={count || null} />}
{isActivityNotification && <Unread />}
{muted && <NotificationsOffSolidIcon width="20px" height="20px" fill="var(--cpd-color-icon-tertiary)" />}
</Flex>
);
};

View File

@ -0,0 +1,242 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<NotificationDecoration /> > snapshots > renders ActivityIndicator story 1`] = `
<div>
<div
style="padding: 16px; background-color: var(--cpd-color-bg-canvas-default);"
>
<div
class="flex"
data-testid="notification-decoration"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
>
<div
class="_unread_cti0f_8"
>
<div />
</div>
</div>
</div>
</div>
`;
exports[`<NotificationDecoration /> > snapshots > renders Invited story 1`] = `
<div>
<div
style="padding: 16px; background-color: var(--cpd-color-bg-canvas-default);"
>
<div
class="flex"
data-testid="notification-decoration"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
>
<svg
fill="var(--cpd-color-icon-accent-primary)"
height="20px"
viewBox="0 0 24 24"
width="20px"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4 4h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2m0 5.111a1 1 0 0 0 .514.874l7 3.89a1 1 0 0 0 .972 0l7-3.89a1 1 0 1 0-.972-1.748L12 11.856 5.486 8.237A1 1 0 0 0 4 9.111"
/>
</svg>
</div>
</div>
</div>
`;
exports[`<NotificationDecoration /> > snapshots > renders Mention story 1`] = `
<div>
<div
style="padding: 16px; background-color: var(--cpd-color-bg-canvas-default);"
>
<div
class="flex"
data-testid="notification-decoration"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
>
<svg
fill="var(--cpd-color-icon-accent-primary)"
height="20px"
viewBox="0 0 24 24"
width="20px"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 4a8 8 0 1 0 0 16 1 1 0 1 1 0 2C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10v1.5a3.5 3.5 0 0 1-6.396 1.966A5 5 0 1 1 17 12v1.5a1.5 1.5 0 0 0 3 0V12a8 8 0 0 0-8-8m3 8a3 3 0 1 0-6 0 3 3 0 0 0 6 0"
/>
</svg>
<div
class="_unread-counter_1147r_8"
/>
</div>
</div>
</div>
`;
exports[`<NotificationDecoration /> > snapshots > renders MentionWithCount story 1`] = `
<div>
<div
style="padding: 16px; background-color: var(--cpd-color-bg-canvas-default);"
>
<div
class="flex"
data-testid="notification-decoration"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
>
<svg
fill="var(--cpd-color-icon-accent-primary)"
height="20px"
viewBox="0 0 24 24"
width="20px"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 4a8 8 0 1 0 0 16 1 1 0 1 1 0 2C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10v1.5a3.5 3.5 0 0 1-6.396 1.966A5 5 0 1 1 17 12v1.5a1.5 1.5 0 0 0 3 0V12a8 8 0 0 0-8-8m3 8a3 3 0 1 0-6 0 3 3 0 0 0 6 0"
/>
</svg>
<span
class="_unread-counter_1147r_8"
>
5
</span>
</div>
</div>
</div>
`;
exports[`<NotificationDecoration /> > snapshots > renders Muted story 1`] = `
<div>
<div
style="padding: 16px; background-color: var(--cpd-color-bg-canvas-default);"
>
<div
class="flex"
data-testid="notification-decoration"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
>
<svg
fill="var(--cpd-color-icon-tertiary)"
height="20px"
viewBox="0 0 24 24"
width="20px"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m4.917 2.083 17 17a1 1 0 0 1-1.414 1.414L19.006 19H4.414c-.89 0-1.337-1.077-.707-1.707L5 16v-6s0-2.034 1.096-3.91L3.504 3.498a1 1 0 0 1 1.414-1.414M19 13.35 9.136 3.484C9.93 3.181 10.874 3 12 3c7 0 7 7 7 7z"
/>
<path
d="M10 20h4a2 2 0 0 1-4 0"
/>
</svg>
</div>
</div>
</div>
`;
exports[`<NotificationDecoration /> > snapshots > renders NoNotification story 1`] = `
<div>
<div
style="padding: 16px; background-color: var(--cpd-color-bg-canvas-default);"
/>
</div>
`;
exports[`<NotificationDecoration /> > snapshots > renders NotificationWithCount story 1`] = `
<div>
<div
style="padding: 16px; background-color: var(--cpd-color-bg-canvas-default);"
>
<div
class="flex"
data-testid="notification-decoration"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
>
<span
class="_unread-counter_1147r_8"
>
3
</span>
</div>
</div>
</div>
`;
exports[`<NotificationDecoration /> > snapshots > renders UnsentMessage story 1`] = `
<div>
<div
style="padding: 16px; background-color: var(--cpd-color-bg-canvas-default);"
>
<div
class="flex"
data-testid="notification-decoration"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
>
<svg
fill="var(--cpd-color-icon-critical-primary)"
height="20px"
viewBox="0 0 24 24"
width="20px"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 17q.424 0 .713-.288A.97.97 0 0 0 13 16a.97.97 0 0 0-.287-.713A.97.97 0 0 0 12 15a.97.97 0 0 0-.713.287A.97.97 0 0 0 11 16q0 .424.287.712.288.288.713.288m0-4q.424 0 .713-.287A.97.97 0 0 0 13 12V8a.97.97 0 0 0-.287-.713A.97.97 0 0 0 12 7a.97.97 0 0 0-.713.287A.97.97 0 0 0 11 8v4q0 .424.287.713.288.287.713.287m0 9a9.7 9.7 0 0 1-3.9-.788 10.1 10.1 0 0 1-3.175-2.137q-1.35-1.35-2.137-3.175A9.7 9.7 0 0 1 2 12q0-2.075.788-3.9a10.1 10.1 0 0 1 2.137-3.175q1.35-1.35 3.175-2.137A9.7 9.7 0 0 1 12 2q2.075 0 3.9.788a10.1 10.1 0 0 1 3.175 2.137q1.35 1.35 2.137 3.175A9.7 9.7 0 0 1 22 12a9.7 9.7 0 0 1-.788 3.9 10.1 10.1 0 0 1-2.137 3.175q-1.35 1.35-3.175 2.137A9.7 9.7 0 0 1 12 22"
/>
</svg>
</div>
</div>
</div>
`;
exports[`<NotificationDecoration /> > snapshots > renders VideoCall story 1`] = `
<div>
<div
style="padding: 16px; background-color: var(--cpd-color-bg-canvas-default);"
>
<div
class="flex"
data-testid="notification-decoration"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
>
<svg
fill="var(--cpd-color-icon-accent-primary)"
height="20px"
viewBox="0 0 24 24"
width="20px"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 4h10a2 2 0 0 1 2 2v4.286l3.35-2.871a1 1 0 0 1 1.65.76v7.65a1 1 0 0 1-1.65.76L18 13.715V18a2 2 0 0 1-2 2H6a4 4 0 0 1-4-4V8a4 4 0 0 1 4-4"
/>
</svg>
</div>
</div>
</div>
`;
exports[`<NotificationDecoration /> > snapshots > renders VoiceCall story 1`] = `
<div>
<div
style="padding: 16px; background-color: var(--cpd-color-bg-canvas-default);"
>
<div
class="flex"
data-testid="notification-decoration"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
>
<svg
fill="var(--cpd-color-icon-accent-primary)"
height="20px"
viewBox="0 0 24 24"
width="20px"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m20.958 16.374.039 3.527q0 .427-.33.756-.33.33-.756.33a16 16 0 0 1-6.57-1.105 16.2 16.2 0 0 1-5.563-3.663 16.1 16.1 0 0 1-3.653-5.573 16.3 16.3 0 0 1-1.115-6.56q0-.427.33-.757T4.095 3l3.528.039a1.07 1.07 0 0 1 1.085.93l.543 3.954q.039.271-.039.504a1.1 1.1 0 0 1-.271.426l-1.64 1.64q.505 1.008 1.154 1.909c.433.6 1.444 1.696 1.444 1.696s1.095 1.01 1.696 1.444q.9.65 1.909 1.153l1.64-1.64q.193-.193.426-.27t.504-.04l3.954.543q.406.059.668.359t.262.727"
/>
</svg>
</div>
</div>
</div>
`;

View File

@ -0,0 +1,9 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
export { NotificationDecoration } from "./NotificationDecoration";
export type { NotificationDecorationProps, NotificationDecorationData } from "./NotificationDecoration";