feat: show call participants in room list (Discord-style)
Docker / Docker Buildx (push) Has been cancelled
Build Debian package / Build package (release) Has been cancelled
Build and Deploy / prepare (release) Has been cancelled
Deploy release / Deploy to Cloudflare Pages (release) Has been cancelled
Build and Deploy / Trigger Pro pipeline (release) Has been cancelled
Build and Deploy / Windows arm64 (release) Has been cancelled
Build and Deploy / Windows x64 (release) Has been cancelled
Build and Deploy / macOS (release) Has been cancelled
Build and Deploy / Linux amd64 (sqlcipher static) (release) Has been cancelled
Build and Deploy / Linux arm64 (sqlcipher static) (release) Has been cancelled
Build and Deploy / ${{ needs.prepare.outputs.deploy == 'true' && 'Deploy' || 'Deploy (dry-run)' }} (release) Has been cancelled
Build and Deploy / Deploy builds to ESS (release) Has been cancelled

This commit is contained in:
sorB
2026-05-10 14:25:35 +02:00
parent b797925316
commit 3da363517f
4610 changed files with 827237 additions and 1 deletions
@@ -0,0 +1,29 @@
/*
* Copyright (c) 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 { Meta, StoryObj } from "@storybook/react-vite";
import { HistoryVisibilityBadge } from "./HistoryVisibilityBadge";
const meta = {
title: "Room/HistoryVisibilityBadge",
component: HistoryVisibilityBadge,
tags: ["autodocs"],
parameters: {
design: {
type: "figma",
url: "https://www.figma.com/design/IXcnmuaIwtm3F3vBuFCPUp/Room-History-Sharing?node-id=39-10758&t=MKC8KCGCpykDbrcX-1",
},
},
} satisfies Meta<typeof HistoryVisibilityBadge>;
export default meta;
type Story = StoryObj<typeof meta>;
export const InvitedHistoryVisibility: Story = { args: { historyVisibility: "invited" } };
export const JoinedHistoryVisibility: Story = { args: { historyVisibility: "joined" } };
export const SharedHistoryVisibility: Story = { args: { historyVisibility: "shared" } };
export const WorldReadableHistoryVisibility: Story = { args: { historyVisibility: "world_readable" } };
@@ -0,0 +1,21 @@
/*
* Copyright (c) 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 { describe, expect, it } from "vitest";
import { render } from "@testing-library/react";
import { HistoryVisibilityBadge } from "./HistoryVisibilityBadge.tsx";
describe("HistoryVisibilityBadge", () => {
for (const visibility of ["invited", "joined", "shared", "world_readable"]) {
it(`renders the badge for ${visibility}`, () => {
const { container } = render(<HistoryVisibilityBadge historyVisibility={visibility as any} />);
expect(container).toMatchSnapshot();
});
}
});
@@ -0,0 +1,56 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX } from "react";
import { Badge } from "@vector-im/compound-web";
import {
HistoryIcon,
UserProfileSolidIcon,
VisibilityOffIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { _t } from "../../core/i18n/i18n";
interface Props {
/** The history visibility of the room, according to the room state. */
historyVisibility: "invited" | "joined" | "shared" | "world_readable";
}
/** A badge showing the history visibility of a room. */
export function HistoryVisibilityBadge({ historyVisibility }: Props): JSX.Element | null {
const iconProps = {
color: "var(--cpd-color-icon-info-primary)",
width: "1rem", // 16px at the default font size, per the design
height: "1rem",
};
switch (historyVisibility) {
case "invited":
case "joined":
return (
<Badge kind="blue">
<VisibilityOffIcon {...iconProps} />
{_t("room|history_visibility_badge|private")}
</Badge>
);
case "shared":
return (
<Badge kind="blue">
<HistoryIcon {...iconProps} />
{_t("room|history_visibility_badge|shared")}
</Badge>
);
case "world_readable":
return (
<Badge kind="blue">
<UserProfileSolidIcon {...iconProps} />
{_t("room|history_visibility_badge|world_readable")}
</Badge>
);
default:
return null;
}
}
@@ -0,0 +1,99 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`HistoryVisibilityBadge > renders the badge for invited 1`] = `
<div>
<span
class="_typography_6v6n8_153 _font-body-sm-medium_6v6n8_41 _badge_10dml_8"
data-kind="blue"
>
<svg
color="var(--cpd-color-icon-info-primary)"
fill="currentColor"
height="1rem"
viewBox="0 0 24 24"
width="1rem"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m16.1 13.3-1.45-1.45q.225-1.175-.675-2.2t-2.325-.8L10.2 7.4q.424-.2.863-.3A4.2 4.2 0 0 1 12 7q1.875 0 3.188 1.312Q16.5 9.625 16.5 11.5q0 .5-.1.938t-.3.862m3.2 3.15-1.45-1.4a11 11 0 0 0 1.688-1.588A9 9 0 0 0 20.8 11.5q-1.25-2.524-3.588-4.013Q14.875 6 12 6q-.724 0-1.425.1a10 10 0 0 0-1.375.3L7.65 4.85A11.1 11.1 0 0 1 12 4q3.575 0 6.425 1.887T22.7 10.8a.8.8 0 0 1 .1.313q.025.188.025.387a2 2 0 0 1-.125.7 10.9 10.9 0 0 1-3.4 4.25m-.2 5.45-3.5-3.45q-.874.274-1.762.413Q12.95 19 12 19q-3.575 0-6.425-1.887T1.3 12.2a.8.8 0 0 1-.1-.312 3 3 0 0 1 0-.763.8.8 0 0 1 .1-.3Q1.825 9.7 2.55 8.75A13.3 13.3 0 0 1 4.15 7L2.075 4.9a.93.93 0 0 1-.275-.688q0-.412.3-.712a.95.95 0 0 1 .7-.275q.425 0 .7.275l17 17q.275.275.288.688a.93.93 0 0 1-.288.712.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275M5.55 8.4q-.725.65-1.325 1.425A9 9 0 0 0 3.2 11.5q1.25 2.524 3.588 4.012T12 17q.5 0 .975-.062.475-.063.975-.138l-.9-.95q-.274.075-.525.113A3.5 3.5 0 0 1 12 16q-1.875 0-3.187-1.312Q7.5 13.375 7.5 11.5q0-.274.038-.525.037-.25.112-.525z"
/>
</svg>
New members don't see history
</span>
</div>
`;
exports[`HistoryVisibilityBadge > renders the badge for joined 1`] = `
<div>
<span
class="_typography_6v6n8_153 _font-body-sm-medium_6v6n8_41 _badge_10dml_8"
data-kind="blue"
>
<svg
color="var(--cpd-color-icon-info-primary)"
fill="currentColor"
height="1rem"
viewBox="0 0 24 24"
width="1rem"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m16.1 13.3-1.45-1.45q.225-1.175-.675-2.2t-2.325-.8L10.2 7.4q.424-.2.863-.3A4.2 4.2 0 0 1 12 7q1.875 0 3.188 1.312Q16.5 9.625 16.5 11.5q0 .5-.1.938t-.3.862m3.2 3.15-1.45-1.4a11 11 0 0 0 1.688-1.588A9 9 0 0 0 20.8 11.5q-1.25-2.524-3.588-4.013Q14.875 6 12 6q-.724 0-1.425.1a10 10 0 0 0-1.375.3L7.65 4.85A11.1 11.1 0 0 1 12 4q3.575 0 6.425 1.887T22.7 10.8a.8.8 0 0 1 .1.313q.025.188.025.387a2 2 0 0 1-.125.7 10.9 10.9 0 0 1-3.4 4.25m-.2 5.45-3.5-3.45q-.874.274-1.762.413Q12.95 19 12 19q-3.575 0-6.425-1.887T1.3 12.2a.8.8 0 0 1-.1-.312 3 3 0 0 1 0-.763.8.8 0 0 1 .1-.3Q1.825 9.7 2.55 8.75A13.3 13.3 0 0 1 4.15 7L2.075 4.9a.93.93 0 0 1-.275-.688q0-.412.3-.712a.95.95 0 0 1 .7-.275q.425 0 .7.275l17 17q.275.275.288.688a.93.93 0 0 1-.288.712.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275M5.55 8.4q-.725.65-1.325 1.425A9 9 0 0 0 3.2 11.5q1.25 2.524 3.588 4.012T12 17q.5 0 .975-.062.475-.063.975-.138l-.9-.95q-.274.075-.525.113A3.5 3.5 0 0 1 12 16q-1.875 0-3.187-1.312Q7.5 13.375 7.5 11.5q0-.274.038-.525.037-.25.112-.525z"
/>
</svg>
New members don't see history
</span>
</div>
`;
exports[`HistoryVisibilityBadge > renders the badge for shared 1`] = `
<div>
<span
class="_typography_6v6n8_153 _font-body-sm-medium_6v6n8_41 _badge_10dml_8"
data-kind="blue"
>
<svg
color="var(--cpd-color-icon-info-primary)"
fill="currentColor"
height="1rem"
viewBox="0 0 24 24"
width="1rem"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M18.93 8A8 8 0 1 1 4 12a1 1 0 1 0-2 0c0 5.523 4.477 10 10 10s10-4.477 10-10a10 10 0 0 0-.832-4A10 10 0 0 0 12 2a9.99 9.99 0 0 0-8 3.999V4a1 1 0 0 0-2 0v4a1 1 0 0 0 1 1h4a1 1 0 0 0 0-2H5.755A7.99 7.99 0 0 1 12 4a8 8 0 0 1 6.93 4"
/>
<path
d="M13 8a1 1 0 1 0-2 0v4a1 1 0 0 0 .293.707l2.83 2.83a1 1 0 0 0 1.414-1.414L13 11.586z"
/>
</svg>
New members see history
</span>
</div>
`;
exports[`HistoryVisibilityBadge > renders the badge for world_readable 1`] = `
<div>
<span
class="_typography_6v6n8_153 _font-body-sm-medium_6v6n8_41 _badge_10dml_8"
data-kind="blue"
>
<svg
color="var(--cpd-color-icon-info-primary)"
fill="currentColor"
height="1rem"
viewBox="0 0 24 24"
width="1rem"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 15q-1.65 0-2.825-1.175T8 11t1.175-2.825T12 7t2.825 1.175T16 11t-1.175 2.825T12 15"
/>
<path
d="M19.528 18.583A9.96 9.96 0 0 0 22 12c0-5.523-4.477-10-10-10S2 6.477 2 12c0 2.52.933 4.824 2.472 6.583A9.98 9.98 0 0 0 12 22a9.98 9.98 0 0 0 7.528-3.417M8.75 16.388q-1.373.332-2.709.95a8 8 0 1 1 11.918 0 14.7 14.7 0 0 0-2.709-.95A13.8 13.8 0 0 0 12 16q-1.65 0-3.25.387"
/>
</svg>
Anyone can see history
</span>
</div>
`;
@@ -0,0 +1,8 @@
/*
* Copyright (c) 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 * from "./HistoryVisibilityBadge";
@@ -0,0 +1,30 @@
/*
* Copyright (c) 2025 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.
*/
.container {
color: var(--cpd-color-text-primary);
}
.container svg {
color: var(--cpd-color-text-primary);
}
.secondaryAction.secondaryAction[data-kind="secondary"] > svg {
color: var(--cpd-color-text-secondary);
}
.primaryAction.primaryAction[data-kind="primary"] > svg {
color: var(--cpd-color-text-on-solid-primary);
}
.title {
color: var(--cpd-color-text-critical-primary);
}
.description {
color: var(--cpd-color-text-primary);
}
@@ -0,0 +1,121 @@
/*
* Copyright (c) 2025 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 Meta, type StoryObj } from "@storybook/react-vite";
import React, { type JSX } from "react";
import { fn } from "storybook/test";
import { useMockedViewModel } from "../../core/viewmodel";
import { withViewDocs } from "../../../.storybook/withViewDocs";
import {
RoomStatusBarState,
RoomStatusBarView,
type RoomStatusBarViewActions,
type RoomStatusBarViewSnapshot,
} from "./RoomStatusBarView";
type RoomStatusBarProps = RoomStatusBarViewSnapshot & RoomStatusBarViewActions;
const RoomStatusBarViewWrapperImpl = ({
onResendAllClick,
onDeleteAllClick,
onRetryRoomCreationClick,
onTermsAndConditionsClicked,
...rest
}: RoomStatusBarProps): JSX.Element => {
const vm = useMockedViewModel(rest, {
onResendAllClick,
onDeleteAllClick,
onRetryRoomCreationClick,
onTermsAndConditionsClicked,
});
return <RoomStatusBarView vm={vm} />;
};
const RoomStatusBarViewWrapper = withViewDocs(RoomStatusBarViewWrapperImpl, RoomStatusBarView);
const meta = {
title: "Room/RoomStatusBarView",
component: RoomStatusBarViewWrapper,
tags: ["autodocs"],
argTypes: {},
args: {
onResendAllClick: fn(),
onDeleteAllClick: fn(),
onRetryRoomCreationClick: fn(),
onTermsAndConditionsClicked: fn(),
},
parameters: {
design: {
type: "figma",
url: "https://www.figma.com/design/rTaQE2nIUSLav4Tg3nozq7/Compound-Web-Components?node-id=11019-2353&t=p8SkJGh9tJx09MTJ-4",
},
},
} satisfies Meta<typeof RoomStatusBarViewWrapper>;
export default meta;
type Story = StoryObj<typeof RoomStatusBarViewWrapper>;
/**
* Rendered when the client has lost connection with the server.
*/
export const WithConnectionLost: Story = {
args: {
state: RoomStatusBarState.ConnectionLost,
},
};
/**
* Rendered when the client needs the user to consent to some terms and conditions before
* they can perform any room actions.
*/
export const WithConsentLink: Story = {
args: {
state: RoomStatusBarState.NeedsConsent,
consentUri: "#example",
},
};
/**
* Rendered when the server has hit a usage limit and is forbidding the user from performing
* any actions in the room. There is an optional parameter to link to an admin to contact.
*/
export const WithResourceLimit: Story = {
args: {
state: RoomStatusBarState.ResourceLimited,
resourceLimit: "hs_disabled",
adminContactHref: "#example",
},
};
/**
* Rendered when the client has some unsent messages in the room, stored locally.
*/
export const WithUnsentMessages: Story = {
args: {
state: RoomStatusBarState.UnsentMessages,
isResending: false,
},
};
/**
* Rendered when the client has some unsent messages in the room, stored locally and is
* trying to send them.
*/
export const WithUnsentMessagesSending: Story = {
args: {
state: RoomStatusBarState.UnsentMessages,
isResending: true,
},
};
/**
* Rendered when a local room has failed to be created.
*/
export const WithLocalRoomRetry: Story = {
args: {
state: RoomStatusBarState.LocalRoomFailed,
},
};
@@ -0,0 +1,70 @@
/*
* Copyright (c) 2025 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 userEvent from "@testing-library/user-event";
import { describe, it, vi, expect } from "vitest";
import * as stories from "./RoomStatusBarView.stories.tsx";
const { WithConnectionLost, WithConsentLink, WithResourceLimit, WithUnsentMessages, WithLocalRoomRetry } =
composeStories(stories);
describe("RoomStatusBarView", () => {
it("renders connection lost", () => {
const { container } = render(<WithConnectionLost />);
expect(container).toMatchSnapshot();
});
it("renders resource limit error", () => {
const { container } = render(<WithResourceLimit />);
expect(container).toMatchSnapshot();
});
it("renders consent link", () => {
const { container, getByRole } = render(<WithConsentLink />);
expect(container).toMatchSnapshot();
const button = getByRole("link");
expect(button.getAttribute("href")).toEqual("#example");
});
it("renders unsent messages", async () => {
const { container } = render(
<WithUnsentMessages onDeleteAllClick={vi.fn()} onRetryRoomCreationClick={vi.fn()} />,
);
expect(container).toMatchSnapshot();
});
it("renders unsent messages and deletes all", async () => {
const onDeleteAllClick = vi.fn();
const { container, getByRole } = render(<WithUnsentMessages onDeleteAllClick={onDeleteAllClick} />);
expect(container).toMatchSnapshot();
const button = getByRole("button", { name: "Delete all" });
await userEvent.click(button);
expect(onDeleteAllClick).toHaveBeenCalled();
});
it("renders unsent messages and resends all", async () => {
const onResendAllClick = vi.fn();
const { container, getByRole } = render(<WithUnsentMessages onResendAllClick={onResendAllClick} />);
expect(container).toMatchSnapshot();
const button = getByRole("button", { name: "Retry all" });
await userEvent.click(button);
expect(onResendAllClick).toHaveBeenCalled();
});
it("renders local room error", async () => {
const onRetryRoomCreationClick = vi.fn();
const { container, getByRole } = render(
<WithLocalRoomRetry onRetryRoomCreationClick={onRetryRoomCreationClick} />,
);
expect(container).toMatchSnapshot();
const button = getByRole("button", { name: "Retry" });
await userEvent.click(button);
expect(onRetryRoomCreationClick).toHaveBeenCalled();
});
});
@@ -0,0 +1,307 @@
/*
* Copyright (c) 2025 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, { useCallback, useId, type JSX } from "react";
import { RestartIcon, DeleteIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { Button, InlineSpinner, Text } from "@vector-im/compound-web";
import styles from "./RoomStatusBarView.module.css";
import { type ViewModel, useViewModel } from "../../core/viewmodel";
import { useI18n } from "../../core/i18n/i18nContext";
import { Banner } from "../composer/Banner";
export interface RoomStatusBarViewActions {
/**
* Called when the user clicks on the 'resend all' button in the 'unsent messages' bar.
*/
onResendAllClick?: () => Promise<void>;
/**
* Called when the user clicks on the 'cancel all' button in the 'unsent messages' bar.
*/
onDeleteAllClick?: () => void;
/**
* Called when the user clicks on the 'Retry' button in the 'failed to start chat' bar.
*/
onRetryRoomCreationClick?: () => void;
/**
* Called when the user clicks on the 'Review Terms and Conditions' button.
*/
onTermsAndConditionsClicked?: () => void;
}
export const RoomStatusBarState = {
/**
* Connectivity to the homeserver has been lost. The user can not take any actions
* until the connection is restored.
*/
ConnectionLost: "ConnectionLost",
/**
* The homeserver has indiciated the user needs to consent to the Terms and Conditions
* before they can send a message.
*/
NeedsConsent: "NeedsConsent",
/**
* The homeserver has indiciated that messages can not be sent due to a resource limit
* being reached. The user may use the given admin contact details.
*/
ResourceLimited: "ResourceLimited",
/**
* There are messages stored locally that previously failed to send that the user
* may now retry or delete.
*/
UnsentMessages: "UnsentMessages",
/**
* There was an error creating a room. The user may retry creation.
*/
LocalRoomFailed: "LocalRoomFailed",
} as const;
export interface RoomStatusBarNotVisible {
state: null;
}
export interface RoomStatusBarNoConnection {
state: "ConnectionLost";
}
export interface RoomStatusBarConsentState {
state: "NeedsConsent";
consentUri: string;
}
export interface RoomStatusBarResourceLimitedState {
state: "ResourceLimited";
resourceLimit: "monthly_active_user" | "hs_disabled" | string;
adminContactHref?: string;
}
export interface RoomStatusBarUnsentMessagesState {
state: "UnsentMessages";
isResending: boolean;
}
export interface RoomStatusBarLocalRoomError {
state: "LocalRoomFailed";
}
export type RoomStatusBarViewSnapshot =
| RoomStatusBarNoConnection
| RoomStatusBarConsentState
| RoomStatusBarResourceLimitedState
| RoomStatusBarUnsentMessagesState
| RoomStatusBarLocalRoomError
| RoomStatusBarNotVisible;
/**
* The view model for RoomStatusBarView.
*/
export type RoomStatusBarViewModel = ViewModel<RoomStatusBarViewSnapshot, RoomStatusBarViewActions>;
interface RoomStatusBarViewProps {
/**
* The view model for the banner.
*/
vm: RoomStatusBarViewModel;
}
/**
* A component to alert to a failure in the context of a room.
*
* @example
* ```tsx
* <RoomStatusBarView vm={RoomStatusBarViewModel} />
* ```
*/
export function RoomStatusBarView({ vm }: Readonly<RoomStatusBarViewProps>): JSX.Element | null {
const { translate: _t } = useI18n();
const snapshot = useViewModel(vm);
const bannerTitleId = useId();
const deleteAllClick = useCallback<React.MouseEventHandler<HTMLButtonElement>>(
(ev) => {
ev.preventDefault();
vm.onDeleteAllClick?.();
},
[vm],
);
const resendClick = useCallback<React.MouseEventHandler<HTMLButtonElement>>(
(ev) => {
ev.preventDefault();
void vm.onResendAllClick?.();
},
[vm],
);
const retryRoomCreationClick = useCallback<React.MouseEventHandler<HTMLButtonElement>>(
(ev) => {
ev.preventDefault();
vm.onRetryRoomCreationClick?.();
},
[vm],
);
const termsAndConditionsClicked = useCallback<React.MouseEventHandler<HTMLAnchorElement>>(() => {
// Allow the link to go through.
vm.onTermsAndConditionsClicked?.();
}, [vm]);
if (snapshot.state === null) {
// Nothing to show!
return null;
}
switch (snapshot.state) {
case RoomStatusBarState.ConnectionLost:
return (
<Banner type="critical" role="status" aria-labelledby={bannerTitleId}>
<div className={styles.container}>
<Text className={styles.title} id={bannerTitleId} weight="medium">
{_t("room|status_bar|server_connectivity_lost_title")}
</Text>
<Text className={styles.description}>
{_t("room|status_bar|server_connectivity_lost_description")}
</Text>
</div>
</Banner>
);
case RoomStatusBarState.NeedsConsent:
return (
<Banner
type="critical"
role="status"
aria-labelledby={bannerTitleId}
actions={
<Button
onClick={termsAndConditionsClicked}
className={styles.primaryAction}
kind="primary"
size="md"
as="a"
href={snapshot.consentUri}
target="_blank"
rel="noreferrer noopener"
>
{_t("terms|tac_button")}
</Button>
}
>
<div className={styles.container}>
<Text className={styles.title} id={bannerTitleId} weight="medium">
{_t("room|status_bar|requires_consent_agreement_title")}
</Text>
</div>
</Banner>
);
case RoomStatusBarState.ResourceLimited:
return (
<Banner
type="critical"
role="status"
aria-labelledby={bannerTitleId}
actions={
snapshot.adminContactHref && (
<Button
kind="secondary"
size="md"
as="a"
href={snapshot.adminContactHref}
target="_blank"
rel="noreferrer noopener"
>
Contact admin
</Button>
)
}
>
<div className={styles.container}>
<Text className={styles.title} id={bannerTitleId} weight="medium">
{{
monthly_active_user: _t("room|status_bar|monthly_user_limit_reached_title"),
hs_disabled: _t("room|status_bar|homeserver_blocked_title"),
}[snapshot.resourceLimit] || _t("room|status_bar|exceeded_resource_limit_title")}
</Text>
<Text className={styles.description}>
{_t("room|status_bar|exceeded_resource_limit_description")}
</Text>
</div>
</Banner>
);
case RoomStatusBarState.LocalRoomFailed:
return (
<Banner
role="status"
type="critical"
aria-labelledby={bannerTitleId}
actions={
<Button
size="md"
kind="primary"
className={styles.primaryAction}
Icon={RestartIcon}
onClick={retryRoomCreationClick}
>
{_t("action|retry")}
</Button>
}
>
<Text className={styles.title} id={bannerTitleId} weight="medium">
{_t("room|status_bar|failed_to_create_room_title")}
</Text>
</Banner>
);
case RoomStatusBarState.UnsentMessages:
return (
<Banner
role="status"
type="critical"
actions={
snapshot.isResending ? (
<InlineSpinner />
) : (
<>
{vm.onDeleteAllClick && (
<Button
size="md"
kind="secondary"
Icon={DeleteIcon}
className={styles.secondaryAction}
onClick={deleteAllClick}
>
{_t("room|status_bar|delete_all")}
</Button>
)}
{vm.onResendAllClick && (
<Button
size="md"
kind="primary"
Icon={RestartIcon}
onClick={resendClick}
className={styles.primaryAction}
>
{_t("room|status_bar|retry_all")}
</Button>
)}
</>
)
}
aria-labelledby={bannerTitleId}
>
<div className={styles.container}>
<Text className={styles.title} id={bannerTitleId} weight="medium">
{_t("room|status_bar|some_messages_not_sent")}
</Text>
<Text className={styles.description}>{_t("room|status_bar|select_messages_to_retry")}</Text>
</div>
</Banner>
);
default:
// We should never get into this state.
return null;
}
}
@@ -0,0 +1,514 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`RoomStatusBarView > renders connection lost 1`] = `
<div>
<div
aria-labelledby="_r_0_"
class="Banner-module_banner"
data-type="critical"
role="status"
>
<div
class="Banner-module_icon"
>
<svg
fill="currentColor"
font-size="24"
height="1em"
viewBox="0 0 24 24"
width="1em"
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 22m0-2q3.35 0 5.675-2.325T20 12t-2.325-5.675T12 4 6.325 6.325 4 12t2.325 5.675T12 20"
/>
</svg>
</div>
<div
class="Banner-module_content"
>
<div
class="RoomStatusBarView-module_container"
>
<p
class="_typography_6v6n8_153 _font-body-md-medium_6v6n8_60 RoomStatusBarView-module_title"
id="_r_0_"
>
Connectivity to the server has been lost.
</p>
<p
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 RoomStatusBarView-module_description"
>
Sent messages will be stored until your connection has returned.
</p>
</div>
</div>
<div
class="Banner-module_actions"
/>
</div>
</div>
`;
exports[`RoomStatusBarView > renders consent link 1`] = `
<div>
<div
aria-labelledby="_r_2_"
class="Banner-module_banner"
data-type="critical"
role="status"
>
<div
class="Banner-module_icon"
>
<svg
fill="currentColor"
font-size="24"
height="1em"
viewBox="0 0 24 24"
width="1em"
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 22m0-2q3.35 0 5.675-2.325T20 12t-2.325-5.675T12 4 6.325 6.325 4 12t2.325 5.675T12 20"
/>
</svg>
</div>
<div
class="Banner-module_content"
>
<div
class="RoomStatusBarView-module_container"
>
<p
class="_typography_6v6n8_153 _font-body-md-medium_6v6n8_60 RoomStatusBarView-module_title"
id="_r_2_"
>
You can't send any messages until you review and agree to our terms and conditions.
</p>
</div>
</div>
<div
class="Banner-module_actions"
>
<a
class="_button_1nw83_8 RoomStatusBarView-module_primaryAction"
data-kind="primary"
data-size="md"
href="#example"
rel="noreferrer noopener"
role="link"
tabindex="0"
target="_blank"
>
Review terms and conditions
</a>
</div>
</div>
</div>
`;
exports[`RoomStatusBarView > renders local room error 1`] = `
<div>
<div
aria-labelledby="_r_6_"
class="Banner-module_banner"
data-type="critical"
role="status"
>
<div
class="Banner-module_icon"
>
<svg
fill="currentColor"
font-size="24"
height="1em"
viewBox="0 0 24 24"
width="1em"
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 22m0-2q3.35 0 5.675-2.325T20 12t-2.325-5.675T12 4 6.325 6.325 4 12t2.325 5.675T12 20"
/>
</svg>
</div>
<div
class="Banner-module_content"
>
<p
class="_typography_6v6n8_153 _font-body-md-medium_6v6n8_60 RoomStatusBarView-module_title"
id="_r_6_"
>
Could not start a chat with this user
</p>
</div>
<div
class="Banner-module_actions"
>
<button
class="_button_1nw83_8 RoomStatusBarView-module_primaryAction _has-icon_1nw83_60"
data-kind="primary"
data-size="md"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M18.93 8A8 8 0 1 1 4 12a1 1 0 1 0-2 0c0 5.523 4.477 10 10 10s10-4.477 10-10a10 10 0 0 0-.832-4A10 10 0 0 0 12 2a9.99 9.99 0 0 0-8 3.999V4a1 1 0 0 0-2 0v4a1 1 0 0 0 1 1h4a1 1 0 0 0 0-2H5.755A7.99 7.99 0 0 1 12 4a8 8 0 0 1 6.93 4"
/>
</svg>
Retry
</button>
</div>
</div>
</div>
`;
exports[`RoomStatusBarView > renders resource limit error 1`] = `
<div>
<div
aria-labelledby="_r_1_"
class="Banner-module_banner"
data-type="critical"
role="status"
>
<div
class="Banner-module_icon"
>
<svg
fill="currentColor"
font-size="24"
height="1em"
viewBox="0 0 24 24"
width="1em"
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 22m0-2q3.35 0 5.675-2.325T20 12t-2.325-5.675T12 4 6.325 6.325 4 12t2.325 5.675T12 20"
/>
</svg>
</div>
<div
class="Banner-module_content"
>
<div
class="RoomStatusBarView-module_container"
>
<p
class="_typography_6v6n8_153 _font-body-md-medium_6v6n8_60 RoomStatusBarView-module_title"
id="_r_1_"
>
Your message wasn't sent because this homeserver has been blocked by its administrator.
</p>
<p
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 RoomStatusBarView-module_description"
>
Please contact your service administrator to continue using the service.
</p>
</div>
</div>
<div
class="Banner-module_actions"
>
<a
class="_button_1nw83_8"
data-kind="secondary"
data-size="md"
href="#example"
rel="noreferrer noopener"
role="link"
tabindex="0"
target="_blank"
>
Contact admin
</a>
</div>
</div>
</div>
`;
exports[`RoomStatusBarView > renders unsent messages 1`] = `
<div>
<div
aria-labelledby="_r_3_"
class="Banner-module_banner"
data-type="critical"
role="status"
>
<div
class="Banner-module_icon"
>
<svg
fill="currentColor"
font-size="24"
height="1em"
viewBox="0 0 24 24"
width="1em"
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 22m0-2q3.35 0 5.675-2.325T20 12t-2.325-5.675T12 4 6.325 6.325 4 12t2.325 5.675T12 20"
/>
</svg>
</div>
<div
class="Banner-module_content"
>
<div
class="RoomStatusBarView-module_container"
>
<p
class="_typography_6v6n8_153 _font-body-md-medium_6v6n8_60 RoomStatusBarView-module_title"
id="_r_3_"
>
Some of your messages have not been sent
</p>
<p
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 RoomStatusBarView-module_description"
>
You can select all or individual messages to retry or delete
</p>
</div>
</div>
<div
class="Banner-module_actions"
>
<button
class="_button_1nw83_8 RoomStatusBarView-module_secondaryAction _has-icon_1nw83_60"
data-kind="secondary"
data-size="md"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M7 21q-.824 0-1.412-.587A1.93 1.93 0 0 1 5 19V6a.97.97 0 0 1-.713-.287A.97.97 0 0 1 4 5q0-.424.287-.713A.97.97 0 0 1 5 4h4q0-.424.287-.712A.97.97 0 0 1 10 3h4q.424 0 .713.288Q15 3.575 15 4h4q.424 0 .712.287Q20 4.576 20 5t-.288.713A.97.97 0 0 1 19 6v13q0 .824-.587 1.413A1.93 1.93 0 0 1 17 21zM7 6v13h10V6zm2 10q0 .424.287.712Q9.576 17 10 17t.713-.288A.97.97 0 0 0 11 16V9a.97.97 0 0 0-.287-.713A.97.97 0 0 0 10 8a.97.97 0 0 0-.713.287A.97.97 0 0 0 9 9zm4 0q0 .424.287.712.288.288.713.288.424 0 .713-.288A.97.97 0 0 0 15 16V9a.97.97 0 0 0-.287-.713A.97.97 0 0 0 14 8a.97.97 0 0 0-.713.287A.97.97 0 0 0 13 9z"
/>
</svg>
Delete all
</button>
<button
class="_button_1nw83_8 RoomStatusBarView-module_primaryAction _has-icon_1nw83_60"
data-kind="primary"
data-size="md"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M18.93 8A8 8 0 1 1 4 12a1 1 0 1 0-2 0c0 5.523 4.477 10 10 10s10-4.477 10-10a10 10 0 0 0-.832-4A10 10 0 0 0 12 2a9.99 9.99 0 0 0-8 3.999V4a1 1 0 0 0-2 0v4a1 1 0 0 0 1 1h4a1 1 0 0 0 0-2H5.755A7.99 7.99 0 0 1 12 4a8 8 0 0 1 6.93 4"
/>
</svg>
Retry all
</button>
</div>
</div>
</div>
`;
exports[`RoomStatusBarView > renders unsent messages and deletes all 1`] = `
<div>
<div
aria-labelledby="_r_4_"
class="Banner-module_banner"
data-type="critical"
role="status"
>
<div
class="Banner-module_icon"
>
<svg
fill="currentColor"
font-size="24"
height="1em"
viewBox="0 0 24 24"
width="1em"
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 22m0-2q3.35 0 5.675-2.325T20 12t-2.325-5.675T12 4 6.325 6.325 4 12t2.325 5.675T12 20"
/>
</svg>
</div>
<div
class="Banner-module_content"
>
<div
class="RoomStatusBarView-module_container"
>
<p
class="_typography_6v6n8_153 _font-body-md-medium_6v6n8_60 RoomStatusBarView-module_title"
id="_r_4_"
>
Some of your messages have not been sent
</p>
<p
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 RoomStatusBarView-module_description"
>
You can select all or individual messages to retry or delete
</p>
</div>
</div>
<div
class="Banner-module_actions"
>
<button
class="_button_1nw83_8 RoomStatusBarView-module_secondaryAction _has-icon_1nw83_60"
data-kind="secondary"
data-size="md"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M7 21q-.824 0-1.412-.587A1.93 1.93 0 0 1 5 19V6a.97.97 0 0 1-.713-.287A.97.97 0 0 1 4 5q0-.424.287-.713A.97.97 0 0 1 5 4h4q0-.424.287-.712A.97.97 0 0 1 10 3h4q.424 0 .713.288Q15 3.575 15 4h4q.424 0 .712.287Q20 4.576 20 5t-.288.713A.97.97 0 0 1 19 6v13q0 .824-.587 1.413A1.93 1.93 0 0 1 17 21zM7 6v13h10V6zm2 10q0 .424.287.712Q9.576 17 10 17t.713-.288A.97.97 0 0 0 11 16V9a.97.97 0 0 0-.287-.713A.97.97 0 0 0 10 8a.97.97 0 0 0-.713.287A.97.97 0 0 0 9 9zm4 0q0 .424.287.712.288.288.713.288.424 0 .713-.288A.97.97 0 0 0 15 16V9a.97.97 0 0 0-.287-.713A.97.97 0 0 0 14 8a.97.97 0 0 0-.713.287A.97.97 0 0 0 13 9z"
/>
</svg>
Delete all
</button>
<button
class="_button_1nw83_8 RoomStatusBarView-module_primaryAction _has-icon_1nw83_60"
data-kind="primary"
data-size="md"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M18.93 8A8 8 0 1 1 4 12a1 1 0 1 0-2 0c0 5.523 4.477 10 10 10s10-4.477 10-10a10 10 0 0 0-.832-4A10 10 0 0 0 12 2a9.99 9.99 0 0 0-8 3.999V4a1 1 0 0 0-2 0v4a1 1 0 0 0 1 1h4a1 1 0 0 0 0-2H5.755A7.99 7.99 0 0 1 12 4a8 8 0 0 1 6.93 4"
/>
</svg>
Retry all
</button>
</div>
</div>
</div>
`;
exports[`RoomStatusBarView > renders unsent messages and resends all 1`] = `
<div>
<div
aria-labelledby="_r_5_"
class="Banner-module_banner"
data-type="critical"
role="status"
>
<div
class="Banner-module_icon"
>
<svg
fill="currentColor"
font-size="24"
height="1em"
viewBox="0 0 24 24"
width="1em"
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 22m0-2q3.35 0 5.675-2.325T20 12t-2.325-5.675T12 4 6.325 6.325 4 12t2.325 5.675T12 20"
/>
</svg>
</div>
<div
class="Banner-module_content"
>
<div
class="RoomStatusBarView-module_container"
>
<p
class="_typography_6v6n8_153 _font-body-md-medium_6v6n8_60 RoomStatusBarView-module_title"
id="_r_5_"
>
Some of your messages have not been sent
</p>
<p
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 RoomStatusBarView-module_description"
>
You can select all or individual messages to retry or delete
</p>
</div>
</div>
<div
class="Banner-module_actions"
>
<button
class="_button_1nw83_8 RoomStatusBarView-module_secondaryAction _has-icon_1nw83_60"
data-kind="secondary"
data-size="md"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M7 21q-.824 0-1.412-.587A1.93 1.93 0 0 1 5 19V6a.97.97 0 0 1-.713-.287A.97.97 0 0 1 4 5q0-.424.287-.713A.97.97 0 0 1 5 4h4q0-.424.287-.712A.97.97 0 0 1 10 3h4q.424 0 .713.288Q15 3.575 15 4h4q.424 0 .712.287Q20 4.576 20 5t-.288.713A.97.97 0 0 1 19 6v13q0 .824-.587 1.413A1.93 1.93 0 0 1 17 21zM7 6v13h10V6zm2 10q0 .424.287.712Q9.576 17 10 17t.713-.288A.97.97 0 0 0 11 16V9a.97.97 0 0 0-.287-.713A.97.97 0 0 0 10 8a.97.97 0 0 0-.713.287A.97.97 0 0 0 9 9zm4 0q0 .424.287.712.288.288.713.288.424 0 .713-.288A.97.97 0 0 0 15 16V9a.97.97 0 0 0-.287-.713A.97.97 0 0 0 14 8a.97.97 0 0 0-.713.287A.97.97 0 0 0 13 9z"
/>
</svg>
Delete all
</button>
<button
class="_button_1nw83_8 RoomStatusBarView-module_primaryAction _has-icon_1nw83_60"
data-kind="primary"
data-size="md"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M18.93 8A8 8 0 1 1 4 12a1 1 0 1 0-2 0c0 5.523 4.477 10 10 10s10-4.477 10-10a10 10 0 0 0-.832-4A10 10 0 0 0 12 2a9.99 9.99 0 0 0-8 3.999V4a1 1 0 0 0-2 0v4a1 1 0 0 0 1 1h4a1 1 0 0 0 0-2H5.755A7.99 7.99 0 0 1 12 4a8 8 0 0 1 6.93 4"
/>
</svg>
Retry all
</button>
</div>
</div>
</div>
`;
@@ -0,0 +1,8 @@
/*
* Copyright (c) 2025 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 * from "./RoomStatusBarView";
@@ -0,0 +1,35 @@
/*
Copyright (c) 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.
*/
.container {
width: 304px; /* hardcoded to match figma -> might become a resizable pip in the future */
height: 278px; /* hardcoded to match figma -> might become a resizable pip in the future */
display: flex;
flex-direction: column;
padding: var(--cpd-space-3x);
background: var(--cpd-color-bg-canvas-default);
border: 1px solid var(--cpd-color-border-interactive-secondary);
border-radius: var(--cpd-space-6x);
gap: var(--cpd-space-2x);
box-shadow: 0 0 var(--cpd-space-2x) rgba(0, 0, 0, 0.1);
}
.header {
top: 0;
gap: var(--cpd-space-2x);
display: flex;
font: var(--cpd-font-body-md-semibold);
align-items: center;
}
.roundedCornerContainer {
border-radius: var(--cpd-space-4x);
overflow: hidden;
display: flex;
flex-grow: 1;
}
@@ -0,0 +1,89 @@
/*
Copyright (c) 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 Meta, type StoryFn } from "@storybook/react-vite";
import React, { type JSX } from "react";
import { fn } from "storybook/test";
import { useMockedViewModel } from "../../core/viewmodel";
import { WidgetPipView, type WidgetPipViewActions, type WidgetPipViewSnapshot } from "./WidgetPipView";
import { withViewDocs } from "../../../.storybook/withViewDocs";
type WidgetPipViewProps = WidgetPipViewSnapshot & WidgetPipViewActions;
// Helper components that are provided outside of this storybook
const RoomAvatarMock: React.FC = () => (
<div style={{ width: 20, height: 20, borderRadius: "50%", backgroundColor: "grey" }} />
);
const PersistentAppMock: React.FC = () => <div style={{ backgroundColor: "grey", flexGrow: 1 }} />;
const WidgetPipViewWrapperImpl = ({
onBackClick,
persistentAppComponent,
onStartMoving,
...rest
}: WidgetPipViewProps): JSX.Element => {
const vm = useMockedViewModel(rest, {
onBackClick,
persistentAppComponent,
onStartMoving,
});
return <WidgetPipView vm={vm} RoomAvatar={RoomAvatarMock} />;
};
const WidgetPipViewWrapper = withViewDocs(WidgetPipViewWrapperImpl, WidgetPipView);
export default {
title: "Room/WidgetPipView",
component: WidgetPipViewWrapper,
tags: ["autodocs"],
argTypes: {},
args: {
widgetId: "xyz",
roomId: "roomId",
roomName: "Room Name",
onBackClick: fn(),
persistentAppComponent: PersistentAppMock,
onStartMoving: fn(),
},
parameters: {
design: {
type: "figma",
url: "https://www.figma.com/design/aOEkaJtaBmPy058V7uoqVr/Element-Call-Updates---Q1-2026--New-?node-id=21-31333&p=f&t=zBuFi63PKdQ0Nhab-0",
},
},
} satisfies Meta<typeof WidgetPipViewWrapper>;
const Template: StoryFn<typeof WidgetPipViewWrapper> = (args) => <WidgetPipViewWrapper {...args} />;
/**
* Rendered when using a widget with just a grey background.
*/
export const WithGreyWidget = Template.bind({});
WithGreyWidget.args = {};
/**
* Rendered when using a transparent background widget like Element Call.
*/
export const WithElementCallWidgetMock = Template.bind({});
const CallPill: React.FC = () => (
<div style={{ borderRadius: "50%", width: "50px", height: "50px", backgroundColor: "grey" }} />
);
WithElementCallWidgetMock.args = {
roomName: "Element Call Room",
persistentAppComponent: () => (
<div style={{ flexGrow: 1, display: "flex", flexDirection: "column" }}>
<div style={{ flexGrow: 4, backgroundColor: "gray", borderRadius: "24px" }} />
<div style={{ display: "flex", justifyContent: "space-between", margin: "10px" }}>
<CallPill />
<CallPill />
<CallPill />
<CallPill />
</div>
</div>
),
};
@@ -0,0 +1,51 @@
/*
* Copyright (c) 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 userEvent from "@testing-library/user-event";
import { describe, it, vi, expect } from "vitest";
import { getByTestId } from "storybook/test";
import * as stories from "./WidgetPipView.stories.tsx";
const { WithGreyWidget } = composeStories(stories);
describe("WidgetPipView", () => {
it("renders with gray widget", () => {
const { container } = render(<WithGreyWidget />);
expect(container).toMatchSnapshot();
});
it("detects back click action", async () => {
const onBackClick = vi.fn();
const { container, getByRole } = render(<WithGreyWidget onBackClick={onBackClick} />);
expect(container).toMatchSnapshot();
const button = getByRole("button", { name: "Back" });
await userEvent.click(button);
expect(onBackClick).toHaveBeenCalled();
});
it("detects double click triggers back", async () => {
const onBackClick = vi.fn();
const { container } = render(<WithGreyWidget onStartMoving={onBackClick} />);
expect(container).toMatchSnapshot();
const pipContainer = getByTestId(container, "widget-pip-container");
await userEvent.dblClick(pipContainer);
expect(onBackClick).toHaveBeenCalled();
});
it("detects on mouse down for drag", async () => {
const onStartMoving = vi.fn();
const { container } = render(<WithGreyWidget onStartMoving={onStartMoving} />);
expect(container).toMatchSnapshot();
const pipContainer = getByTestId(container, "widget-pip-container");
await userEvent.click(pipContainer);
expect(onStartMoving).toHaveBeenCalled();
});
});
@@ -0,0 +1,109 @@
/*
Copyright (c) 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React, { type FC } from "react";
import { ChevronLeftIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { IconButton } from "@vector-im/compound-web";
import styles from "./WidgetPipView.module.css";
import { useViewModel, type ViewModel } from "../../core/viewmodel";
import { useI18n } from "../..";
export interface WidgetPipViewActions {
/**
* Call this once the back button is clicked in the pip view.
* The view model will handle navigating back to the associated room.
* @param ev The mouse event that triggered the back click.
*/
onBackClick: (ev: React.MouseEvent<Element, MouseEvent>) => void;
/**
* The view model exposes the `<PersistentApp />` component via this action.
* `PersistentApp` is not available in shared components.
* It can be any React component that renders a widget.
* It will be mounted inside the PipView.
*/
persistentAppComponent: React.FC<{
persistentWidgetId: string;
persistentRoomId: string;
}>;
/**
* Action that needs to be called when the pip view starts to get dragged.
* @param ev The mouse event that triggered the drag start.
*/
onStartMoving: (ev: React.MouseEvent<Element, MouseEvent>) => void;
}
export interface WidgetPipViewSnapshot {
/**
* The widget ID this view is rendering.
*/
widgetId: string;
/**
* The room name the Pip View should use in the header.
*/
roomName: string;
/**
* The room ID this PiP view’s widget is associated with.
*/
roomId: string;
}
/**
* The view model for the widget PiP view.
*/
export type WidgetPipViewModel = ViewModel<WidgetPipViewSnapshot> & WidgetPipViewActions;
export interface WidgetPipViewProps {
/**
* The WidgetPipViewModel to expose the WidgetPipViewSnapshot and to:
* - handling the back button callback.
* - exposing the persistentApp react component to the view.
*/
vm: WidgetPipViewModel;
/**
* The avatar is passed as a React component.
* This allows any avatar implementation to be used in this view (like RoomAvatar).
*/
// In the future the avatar component can/should also become a shared component.
// It would then be accessible in the shared component package and we could remove this prop.
RoomAvatar: React.FC<{ size: string }>;
}
/**
* A picture-in-picture view for a widget. Additional controls are shown if the
* widget represents a call.
*/
export const WidgetPipView: FC<WidgetPipViewProps> = ({ vm, RoomAvatar }) => {
const snapshot = useViewModel(vm);
const { translate: _t } = useI18n();
return (
// The interaction where we use the `onMouseDown` handler is only useful for dragging the widget around,
// which is not possible via the keyboard. The outcome of this interaction can only be changed
// if the user interacts with a mouse. Hence there is no use in providing an accessible alternative.
// In the future we might consider introducing alternative shortcuts for moving the PiP around
// with the keyboard.
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div data-testid="widget-pip-container" className={styles.container} onMouseDown={vm.onStartMoving}>
<div className={styles.header}>
<IconButton
size="28px"
data-testid="base-card-back-button"
onClick={(ev) => vm.onBackClick(ev)}
tooltip={_t("action|back")}
kind="secondary"
>
<ChevronLeftIcon />
</IconButton>
<RoomAvatar size="20px" />
{snapshot.roomName}
</div>
<div className={styles.roundedCornerContainer}>
<vm.persistentAppComponent persistentWidgetId={snapshot.widgetId} persistentRoomId={snapshot.roomId} />
</div>
</div>
);
};
@@ -0,0 +1,205 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`WidgetPipView > detects back click action 1`] = `
<div>
<div
class="WidgetPipView-module_container"
data-testid="widget-pip-container"
>
<div
class="WidgetPipView-module_header"
>
<button
aria-labelledby="_r_6_"
class="_icon-button_1215g_8"
data-kind="secondary"
data-testid="base-card-back-button"
role="button"
style="--cpd-icon-button-size: 28px;"
tabindex="0"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m13.3 17.3-4.6-4.6a.9.9 0 0 1-.213-.325A1.1 1.1 0 0 1 8.425 12q0-.2.062-.375A.9.9 0 0 1 8.7 11.3l4.6-4.6a.95.95 0 0 1 .7-.275q.425 0 .7.275a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7L10.8 12l3.9 3.9a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
/>
</svg>
</div>
</button>
<div
style="width: 20px; height: 20px; border-radius: 50%; background-color: grey;"
/>
Room Name
</div>
<div
class="WidgetPipView-module_roundedCornerContainer"
>
<div
style="background-color: grey; flex-grow: 1;"
/>
</div>
</div>
</div>
`;
exports[`WidgetPipView > detects double click triggers back 1`] = `
<div>
<div
class="WidgetPipView-module_container"
data-testid="widget-pip-container"
>
<div
class="WidgetPipView-module_header"
>
<button
aria-labelledby="_r_c_"
class="_icon-button_1215g_8"
data-kind="secondary"
data-testid="base-card-back-button"
role="button"
style="--cpd-icon-button-size: 28px;"
tabindex="0"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m13.3 17.3-4.6-4.6a.9.9 0 0 1-.213-.325A1.1 1.1 0 0 1 8.425 12q0-.2.062-.375A.9.9 0 0 1 8.7 11.3l4.6-4.6a.95.95 0 0 1 .7-.275q.425 0 .7.275a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7L10.8 12l3.9 3.9a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
/>
</svg>
</div>
</button>
<div
style="width: 20px; height: 20px; border-radius: 50%; background-color: grey;"
/>
Room Name
</div>
<div
class="WidgetPipView-module_roundedCornerContainer"
>
<div
style="background-color: grey; flex-grow: 1;"
/>
</div>
</div>
</div>
`;
exports[`WidgetPipView > detects on mouse down for drag 1`] = `
<div>
<div
class="WidgetPipView-module_container"
data-testid="widget-pip-container"
>
<div
class="WidgetPipView-module_header"
>
<button
aria-labelledby="_r_i_"
class="_icon-button_1215g_8"
data-kind="secondary"
data-testid="base-card-back-button"
role="button"
style="--cpd-icon-button-size: 28px;"
tabindex="0"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m13.3 17.3-4.6-4.6a.9.9 0 0 1-.213-.325A1.1 1.1 0 0 1 8.425 12q0-.2.062-.375A.9.9 0 0 1 8.7 11.3l4.6-4.6a.95.95 0 0 1 .7-.275q.425 0 .7.275a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7L10.8 12l3.9 3.9a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
/>
</svg>
</div>
</button>
<div
style="width: 20px; height: 20px; border-radius: 50%; background-color: grey;"
/>
Room Name
</div>
<div
class="WidgetPipView-module_roundedCornerContainer"
>
<div
style="background-color: grey; flex-grow: 1;"
/>
</div>
</div>
</div>
`;
exports[`WidgetPipView > renders with gray widget 1`] = `
<div>
<div
class="WidgetPipView-module_container"
data-testid="widget-pip-container"
>
<div
class="WidgetPipView-module_header"
>
<button
aria-labelledby="_r_0_"
class="_icon-button_1215g_8"
data-kind="secondary"
data-testid="base-card-back-button"
role="button"
style="--cpd-icon-button-size: 28px;"
tabindex="0"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m13.3 17.3-4.6-4.6a.9.9 0 0 1-.213-.325A1.1 1.1 0 0 1 8.425 12q0-.2.062-.375A.9.9 0 0 1 8.7 11.3l4.6-4.6a.95.95 0 0 1 .7-.275q.425 0 .7.275a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7L10.8 12l3.9 3.9a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
/>
</svg>
</div>
</button>
<div
style="width: 20px; height: 20px; border-radius: 50%; background-color: grey;"
/>
Room Name
</div>
<div
class="WidgetPipView-module_roundedCornerContainer"
>
<div
style="background-color: grey; flex-grow: 1;"
/>
</div>
</div>
</div>
`;
@@ -0,0 +1,8 @@
/*
* Copyright (c) 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 * from "./WidgetPipView";
@@ -0,0 +1,71 @@
/*
* Copyright (c) 2025 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.
*/
.banner {
container-type: inline-size;
container-name: banner;
display: flex;
justify-content: start;
gap: var(--cpd-space-3x);
padding: var(--cpd-space-4x);
border-top: 1px solid var(--cpd-color-gray-400);
}
.banner[data-type="success"] {
background: var(--cpd-color-gradient-subtle-linear);
border-color: var(--cpd-color-green-900);
}
.banner[data-type="critical"] {
background: var(--cpd-color-gradient-critical-linear);
border-color: var(--cpd-color-border-critical-subtle);
}
.banner[data-type="info"] {
background: var(--cpd-color-gradient-info-linear);
border-color: var(--cpd-color-blue-900);
}
.banner[data-type="info"] :is(svg) {
color: var(--cpd-color-blue-900);
}
.banner[data-type="success"] :is(.content, svg) {
color: var(--cpd-color-green-900);
}
.banner[data-type="critical"] :is(.content, svg) {
color: var(--cpd-color-red-900);
}
.banner p {
margin: 0;
}
.icon {
display: flex;
align-items: flex-start;
}
.icon img {
height: 24px;
aspect-ratio: auto;
border-radius: 50%;
}
.actions {
margin-left: auto;
flex: 0;
display: flex;
flex-direction: row;
gap: var(--cpd-space-4x);
align-self: center;
white-space: nowrap;
}
@@ -0,0 +1,86 @@
/*
* Copyright (c) 2025 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 { fn } from "storybook/test";
import { type Meta, type StoryObj } from "@storybook/react-vite";
import { Button } from "@vector-im/compound-web";
import { Banner } from "./Banner";
import avatarImage from "../../../../static/element.png";
const meta = {
title: "Room/Banner",
component: Banner,
tags: ["autodocs"],
args: {
children: <p>Hello! This is a status banner.</p>,
onClose: fn(),
},
} satisfies Meta<typeof Banner>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const Info: Story = {
args: {
type: "info",
},
};
export const Success: Story = {
args: {
type: "success",
},
};
export const Critical: Story = {
args: {
type: "critical",
},
};
export const WithAction: Story = {
args: {
children: (
<p>
Alice's (<b>@alice:example.com</b>) identity was reset. <a href="https://example.org">Learn more</a>
</p>
),
actions: (
<Button kind="primary" size="md">
Withdraw verification
</Button>
),
},
};
export const WithAvatarImage: Story = {
args: {
avatar: <img alt="Example" src={avatarImage} />,
},
};
export const WithoutClose: Story = {
args: {
onClose: undefined,
},
};
export const WithLoadsOfContent: Story = {
args: {
type: "info",
children: (
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed quis massa facilisis, venenatis risus
consectetur, sagittis libero. Aenean et scelerisque justo. Nunc luctus, mi sed facilisis suscipit, magna
ante pharetra sem, eu rutrum purus quam quis arcu. Sed eleifend arcu vitae magna sodales, sit amet
fermentum urna dictum. Mauris vel velit pulvinar enim mollis tincidunt. Vivamus egestas rhoncus
sagittis. Curabitur auctor vehicula massa, et cursus lacus laoreet a. Maecenas et sollicitudin lectus,
in ligula.
</p>
),
},
};
@@ -0,0 +1,42 @@
/*
* Copyright (c) 2025 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 { describe, it, expect } from "vitest";
import { composeStories } from "@storybook/react-vite";
import * as stories from "./Banner.stories.tsx";
const { Default, Info, Success, WithAction, WithAvatarImage, Critical } = composeStories(stories);
describe("AvatarWithDetails", () => {
it("renders a default banner", () => {
const { container } = render(<Default />);
expect(container).toMatchSnapshot();
});
it("renders a info banner", () => {
const { container } = render(<Info />);
expect(container).toMatchSnapshot();
});
it("renders a success banner", () => {
const { container } = render(<Success />);
expect(container).toMatchSnapshot();
});
it("renders a critical banner", () => {
const { container } = render(<Critical />);
expect(container).toMatchSnapshot();
});
it("renders a banner with an action", () => {
const { container } = render(<WithAction />);
expect(container).toMatchSnapshot();
});
it("renders a banner with an avatar iamge", () => {
const { container } = render(<WithAvatarImage />);
expect(container).toMatchSnapshot();
});
});
@@ -0,0 +1,92 @@
/*
* Copyright (c) 2025 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 classNames from "classnames";
import React, {
type MouseEventHandler,
type ReactElement,
type ReactNode,
type PropsWithChildren,
useMemo,
type HTMLAttributes,
} from "react";
import { Button } from "@vector-im/compound-web";
import CheckCircleIcon from "@vector-im/compound-design-tokens/assets/web/icons/check-circle";
import ErrorIcon from "@vector-im/compound-design-tokens/assets/web/icons/error";
import InfoIcon from "@vector-im/compound-design-tokens/assets/web/icons/info";
import styles from "./Banner.module.css";
import { _t } from "../../../core/i18n/i18n";
interface BannerProps {
/**
* The type of the status banner.
*/
type?: "success" | "info" | "critical";
/**
* The banner avatar.
*/
avatar?: React.ReactNode;
/**
* Actions presented to the user in the right-hand side of the banner alongside the dismiss button.
*/
actions?: ReactNode;
/**
* Called when the user presses the "dismiss" button.
*/
onClose?: MouseEventHandler<HTMLButtonElement>;
}
/**
* A banner component used for displaying user-facing information above the message composer.
*
* @example
* ```tsx
* <Banner onClose={onCloseHandler} />
* ```
*/
export function Banner({
type,
children,
avatar,
className,
actions,
onClose,
...props
}: PropsWithChildren<BannerProps & HTMLAttributes<HTMLDivElement>>): ReactElement {
const classes = classNames(styles.banner, className);
const icon = useMemo((): ReactElement => {
switch (type) {
case "critical":
return <ErrorIcon fontSize={24} />;
case "info":
return <InfoIcon fontSize={24} />;
case "success":
return <CheckCircleIcon fontSize={24} />;
default:
return <InfoIcon fontSize={24} />;
}
}, [type]);
return (
<div {...props} className={classes} data-type={type}>
<div className={styles.icon}>{avatar ?? icon}</div>
<div className={styles.content}>{children}</div>
<div className={styles.actions}>
{actions}
{onClose && (
<Button kind="secondary" size="md" onClick={onClose}>
{_t("action|dismiss")}
</Button>
)}
</div>
</div>
);
}
@@ -0,0 +1,299 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`AvatarWithDetails > renders a banner with an action 1`] = `
<div>
<div
class="Banner-module_banner"
>
<div
class="Banner-module_icon"
>
<svg
fill="currentColor"
font-size="24"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M11.288 7.288A.97.97 0 0 1 12 7q.424 0 .713.287Q13 7.576 13 8t-.287.713A.97.97 0 0 1 12 9a.97.97 0 0 1-.713-.287A.97.97 0 0 1 11 8q0-.424.287-.713m.001 4.001A.97.97 0 0 1 12 11q.424 0 .713.287.287.288.287.713v4q0 .424-.287.712A.97.97 0 0 1 12 17a.97.97 0 0 1-.713-.288A.97.97 0 0 1 11 16v-4q0-.424.287-.713"
/>
<path
clip-rule="evenodd"
d="M22 12c0 5.523-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2s10 4.477 10 10m-2 0a8 8 0 1 1-16 0 8 8 0 0 1 16 0"
fill-rule="evenodd"
/>
</svg>
</div>
<div
class="Banner-module_content"
>
<p>
Alice's (
<b>
@alice:example.com
</b>
) identity was reset.
<a
href="https://example.org"
>
Learn more
</a>
</p>
</div>
<div
class="Banner-module_actions"
>
<button
class="_button_1nw83_8"
data-kind="primary"
data-size="md"
role="button"
tabindex="0"
>
Withdraw verification
</button>
<button
class="_button_1nw83_8"
data-kind="secondary"
data-size="md"
role="button"
tabindex="0"
>
Dismiss
</button>
</div>
</div>
</div>
`;
exports[`AvatarWithDetails > renders a banner with an avatar iamge 1`] = `
<div>
<div
class="Banner-module_banner"
>
<div
class="Banner-module_icon"
>
<img
alt="Example"
src="/static/element.png"
/>
</div>
<div
class="Banner-module_content"
>
<p>
Hello! This is a status banner.
</p>
</div>
<div
class="Banner-module_actions"
>
<button
class="_button_1nw83_8"
data-kind="secondary"
data-size="md"
role="button"
tabindex="0"
>
Dismiss
</button>
</div>
</div>
</div>
`;
exports[`AvatarWithDetails > renders a critical banner 1`] = `
<div>
<div
class="Banner-module_banner"
data-type="critical"
>
<div
class="Banner-module_icon"
>
<svg
fill="currentColor"
font-size="24"
height="1em"
viewBox="0 0 24 24"
width="1em"
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 22m0-2q3.35 0 5.675-2.325T20 12t-2.325-5.675T12 4 6.325 6.325 4 12t2.325 5.675T12 20"
/>
</svg>
</div>
<div
class="Banner-module_content"
>
<p>
Hello! This is a status banner.
</p>
</div>
<div
class="Banner-module_actions"
>
<button
class="_button_1nw83_8"
data-kind="secondary"
data-size="md"
role="button"
tabindex="0"
>
Dismiss
</button>
</div>
</div>
</div>
`;
exports[`AvatarWithDetails > renders a default banner 1`] = `
<div>
<div
class="Banner-module_banner"
>
<div
class="Banner-module_icon"
>
<svg
fill="currentColor"
font-size="24"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M11.288 7.288A.97.97 0 0 1 12 7q.424 0 .713.287Q13 7.576 13 8t-.287.713A.97.97 0 0 1 12 9a.97.97 0 0 1-.713-.287A.97.97 0 0 1 11 8q0-.424.287-.713m.001 4.001A.97.97 0 0 1 12 11q.424 0 .713.287.287.288.287.713v4q0 .424-.287.712A.97.97 0 0 1 12 17a.97.97 0 0 1-.713-.288A.97.97 0 0 1 11 16v-4q0-.424.287-.713"
/>
<path
clip-rule="evenodd"
d="M22 12c0 5.523-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2s10 4.477 10 10m-2 0a8 8 0 1 1-16 0 8 8 0 0 1 16 0"
fill-rule="evenodd"
/>
</svg>
</div>
<div
class="Banner-module_content"
>
<p>
Hello! This is a status banner.
</p>
</div>
<div
class="Banner-module_actions"
>
<button
class="_button_1nw83_8"
data-kind="secondary"
data-size="md"
role="button"
tabindex="0"
>
Dismiss
</button>
</div>
</div>
</div>
`;
exports[`AvatarWithDetails > renders a info banner 1`] = `
<div>
<div
class="Banner-module_banner"
data-type="info"
>
<div
class="Banner-module_icon"
>
<svg
fill="currentColor"
font-size="24"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M11.288 7.288A.97.97 0 0 1 12 7q.424 0 .713.287Q13 7.576 13 8t-.287.713A.97.97 0 0 1 12 9a.97.97 0 0 1-.713-.287A.97.97 0 0 1 11 8q0-.424.287-.713m.001 4.001A.97.97 0 0 1 12 11q.424 0 .713.287.287.288.287.713v4q0 .424-.287.712A.97.97 0 0 1 12 17a.97.97 0 0 1-.713-.288A.97.97 0 0 1 11 16v-4q0-.424.287-.713"
/>
<path
clip-rule="evenodd"
d="M22 12c0 5.523-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2s10 4.477 10 10m-2 0a8 8 0 1 1-16 0 8 8 0 0 1 16 0"
fill-rule="evenodd"
/>
</svg>
</div>
<div
class="Banner-module_content"
>
<p>
Hello! This is a status banner.
</p>
</div>
<div
class="Banner-module_actions"
>
<button
class="_button_1nw83_8"
data-kind="secondary"
data-size="md"
role="button"
tabindex="0"
>
Dismiss
</button>
</div>
</div>
</div>
`;
exports[`AvatarWithDetails > renders a success banner 1`] = `
<div>
<div
class="Banner-module_banner"
data-type="success"
>
<div
class="Banner-module_icon"
>
<svg
fill="currentColor"
font-size="24"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m10.6 13.8-2.15-2.15a.95.95 0 0 0-.7-.275.95.95 0 0 0-.7.275.95.95 0 0 0-.275.7q0 .425.275.7L9.9 15.9q.3.3.7.3t.7-.3l5.65-5.65a.95.95 0 0 0 .275-.7.95.95 0 0 0-.275-.7.95.95 0 0 0-.7-.275.95.95 0 0 0-.7.275zM12 22a9.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 22m0-2q3.35 0 5.675-2.325T20 12t-2.325-5.675T12 4 6.325 6.325 4 12t2.325 5.675T12 20"
/>
</svg>
</div>
<div
class="Banner-module_content"
>
<p>
Hello! This is a status banner.
</p>
</div>
<div
class="Banner-module_actions"
>
<button
class="_button_1nw83_8"
data-kind="secondary"
data-size="md"
role="button"
tabindex="0"
>
Dismiss
</button>
</div>
</div>
</div>
`;
@@ -0,0 +1,8 @@
/*
* Copyright (c) 2025 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 * from "./Banner";
@@ -0,0 +1,87 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX } from "react";
import { fn } from "storybook/test";
import { IconButton } from "@vector-im/compound-web";
import TriggerIcon from "@vector-im/compound-design-tokens/assets/web/icons/overflow-horizontal";
import type { Meta, StoryObj } from "@storybook/react-vite";
import {
type WidgetContextMenuViewActions,
type WidgetContextMenuViewSnapshot,
WidgetContextMenuView,
} from "./WidgetContextMenuView";
import { useMockedViewModel } from "../../../core/viewmodel/useMockedViewModel";
import { withViewDocs } from "../../../../.storybook/withViewDocs";
type WidgetContextMenuViewModelProps = WidgetContextMenuViewSnapshot & WidgetContextMenuViewActions;
const WidgetContextMenuViewWrapperImpl = ({
onStreamAudioClick,
onEditClick,
onSnapshotClick,
onDeleteClick,
onRevokeClick,
onFinished,
onMoveButton,
...rest
}: WidgetContextMenuViewModelProps): JSX.Element => {
const vm = useMockedViewModel(rest, {
onStreamAudioClick,
onEditClick,
onSnapshotClick,
onDeleteClick,
onRevokeClick,
onFinished,
onMoveButton,
});
return <WidgetContextMenuView vm={vm} />;
};
const WidgetContextMenuViewWrapper = withViewDocs(WidgetContextMenuViewWrapperImpl, WidgetContextMenuView);
const meta = {
title: "Room/WidgetContextMenuView",
component: WidgetContextMenuViewWrapper,
tags: ["autodocs"],
args: {
showStreamAudioStreamButton: true,
showEditButton: true,
showRevokeButton: true,
showDeleteButton: true,
showSnapshotButton: true,
showMoveButtons: [true, true],
canModify: true,
isMenuOpened: true,
trigger: (
<IconButton size="24px" aria-label="context menu trigger button" inert={true} tabIndex={-1}>
<TriggerIcon />
</IconButton>
),
onStreamAudioClick: fn(),
onEditClick: fn(),
onSnapshotClick: fn(),
onDeleteClick: fn(),
onRevokeClick: fn(),
onFinished: fn(),
onMoveButton: fn(),
},
} satisfies Meta<typeof WidgetContextMenuViewWrapper>;
export default meta;
type Story = StoryObj<typeof WidgetContextMenuViewWrapper>;
export const Default: Story = {};
export const OnlyBasicModification: Story = {
args: {
showSnapshotButton: false,
showMoveButtons: [false, false],
showStreamAudioStreamButton: false,
showEditButton: false,
},
};
@@ -0,0 +1,116 @@
/*
* Copyright 2025 New Vector 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 { screen, render } from "@test-utils";
import userEvent from "@testing-library/user-event";
import { IconButton } from "@vector-im/compound-web";
import { composeStories } from "@storybook/react-vite";
import TriggerIcon from "@vector-im/compound-design-tokens/assets/web/icons/overflow-horizontal";
import { describe, vi, expect, it, afterEach } from "vitest";
import {
type WidgetContextMenuViewActions,
type WidgetContextMenuViewSnapshot,
WidgetContextMenuView,
} from "./WidgetContextMenuView";
import * as stories from "./WidgetContextMenuView.stories.tsx";
import { MockViewModel } from "../../../core/viewmodel/MockViewModel.ts";
import { I18nApi } from "../../../core/i18n/I18nApi.ts";
import { I18nContext } from "../../../core/i18n/i18nContext.ts";
const { Default, OnlyBasicModification } = composeStories(stories);
describe("<WidgetContextMenuView />", () => {
afterEach(() => {
vi.clearAllMocks();
});
it("renders widget contextmenu with all options", () => {
const { container } = render(<Default />);
expect(container).toMatchSnapshot();
});
it("renders widget contextmenu without only basic modification", () => {
const { container } = render(<OnlyBasicModification />);
expect(container).toMatchSnapshot();
});
const onKeyDown = vi.fn();
const togglePlay = vi.fn();
const onSeekbarChange = vi.fn();
const onStreamAudioClick = vi.fn();
const onEditClick = vi.fn();
const onSnapshotClick = vi.fn();
const onDeleteClick = vi.fn();
const onRevokeClick = vi.fn();
const onFinished = vi.fn();
const onMoveButton = vi.fn();
class WidgetContextMenuViewModel
extends MockViewModel<WidgetContextMenuViewSnapshot>
implements WidgetContextMenuViewActions
{
public onKeyDown = onKeyDown;
public togglePlay = togglePlay;
public onSeekbarChange = onSeekbarChange;
public onStreamAudioClick = onStreamAudioClick;
public onEditClick = onEditClick;
public onSnapshotClick = onSnapshotClick;
public onDeleteClick = onDeleteClick;
public onRevokeClick = onRevokeClick;
public onFinished = onFinished;
public onMoveButton = onMoveButton;
}
const defaultValue: WidgetContextMenuViewSnapshot = {
showStreamAudioStreamButton: true,
showEditButton: true,
showRevokeButton: true,
showDeleteButton: true,
showSnapshotButton: true,
showMoveButtons: [true, true],
canModify: true,
isMenuOpened: true,
userWidget: false,
trigger: (
<IconButton size="24px" aria-label="context menu trigger button">
<TriggerIcon />
</IconButton>
),
};
it("should attach vm methods", async () => {
const vm = new WidgetContextMenuViewModel(defaultValue);
render(<WidgetContextMenuView vm={vm} />, {
wrapper: ({ children }) => <I18nContext.Provider value={new I18nApi()}>{children}</I18nContext.Provider>,
});
await userEvent.click(screen.getByRole("menuitem", { name: "Start audio stream" }));
expect(onStreamAudioClick).toHaveBeenCalled();
await userEvent.click(screen.getByRole("menuitem", { name: "Edit" }));
expect(onEditClick).toHaveBeenCalled();
await userEvent.click(screen.getByRole("menuitem", { name: "Take a picture" }));
expect(onSnapshotClick).toHaveBeenCalled();
await userEvent.click(screen.getByRole("menuitem", { name: "Revoke permissions" }));
expect(onRevokeClick).toHaveBeenCalled();
await userEvent.click(screen.getByRole("menuitem", { name: "Remove for everyone" }));
expect(onDeleteClick).toHaveBeenCalled();
await userEvent.click(screen.getByRole("menuitem", { name: "Move left" }));
expect(onMoveButton).toHaveBeenCalledWith(-1);
await userEvent.click(screen.getByRole("menuitem", { name: "Move right" }));
expect(onMoveButton).toHaveBeenCalledWith(1);
});
});
@@ -0,0 +1,197 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { type ReactNode, type JSX } from "react";
import { IconButton, Menu, MenuItem } from "@vector-im/compound-web";
import TriggerIcon from "@vector-im/compound-design-tokens/assets/web/icons/overflow-horizontal";
import { type ViewModel } from "../../../core/viewmodel/ViewModel.ts";
import { useI18n } from "../../../core/i18n/i18nContext.ts";
import { useViewModel } from "../../../core/viewmodel/useViewModel.ts";
export interface WidgetContextMenuViewSnapshot {
/**
* Indicates if the audio stream button needs to be shown or not
* depending on the config value audio_stream_url and widget type jitsi
*/
showStreamAudioStreamButton: boolean;
/**
* Indicates if the edit button is shown depending the user permission to modify
*/
showEditButton: boolean;
/**
* Indicates if revoke widget button needs to be shown or not
*/
showRevokeButton: boolean;
/**
* Indicates if delete widget button needs to be shown or not
*/
showDeleteButton: boolean;
/**
* Show take screenshot button or not dependning on config value enableWidgetScreenshots
*/
showSnapshotButton: boolean;
/**
* show move widget position button
*/
showMoveButtons: [boolean, boolean];
/**
* Indicates if user can modify the widget settings
*/
canModify: boolean;
/**
* Indicates if the widget menu is opened or not
*/
isMenuOpened: boolean;
/**
* A component that is displayed which trigger the menu to open or close
*/
trigger: ReactNode;
/**
* If it's an instance of a user widget
*/
userWidget: boolean;
}
export interface WidgetContextMenuViewActions {
/**
* Function triggered when stream audio is clicked
*/
onStreamAudioClick: () => Promise<void>;
/**
* Function triggered when edit button is clicked
*/
onEditClick: () => void;
/**
* Function triggered when snapshot button is clicked
*/
onSnapshotClick: () => void;
/**
* Function triggered when delete button is clicked
*/
onDeleteClick: () => void;
/**
* Function triggered when revoke button is clicked
*/
onRevokeClick: () => void;
/**
* Called when the action is finished, to close the menu
*/
onFinished: () => void;
/**
* Button used to move up or down in the list the widget position
* @param direction 1 or -1
*/
onMoveButton: (direction: number) => void;
}
export type WidgetContextMenuViewModel = ViewModel<WidgetContextMenuViewSnapshot, WidgetContextMenuViewActions>;
interface WidgetContextMenuViewProps {
vm: WidgetContextMenuViewModel;
}
/**
* A context menu component used to display the correct items that needs to be displayed for a widget item menu
*/
export const WidgetContextMenuView: React.FC<WidgetContextMenuViewProps> = ({ vm }) => {
const { translate: _t } = useI18n();
const {
showStreamAudioStreamButton,
showEditButton,
showSnapshotButton,
showDeleteButton,
showRevokeButton,
showMoveButtons,
isMenuOpened,
userWidget,
trigger,
} = useViewModel(vm);
let streamAudioStreamButton: JSX.Element | undefined;
if (showStreamAudioStreamButton) {
streamAudioStreamButton = (
<MenuItem onSelect={vm.onStreamAudioClick} label={_t("widget|context_menu|start_audio_stream")} />
);
}
let editButton: JSX.Element | undefined;
if (showEditButton) {
editButton = <MenuItem onSelect={vm.onEditClick} label={_t("action|edit")} />;
}
let snapshotButton: JSX.Element | undefined;
if (showSnapshotButton) {
snapshotButton = <MenuItem onSelect={vm.onSnapshotClick} label={_t("widget|context_menu|screenshot")} />;
}
let deleteButton: JSX.Element | undefined;
if (showDeleteButton) {
deleteButton = (
<MenuItem
onSelect={vm.onDeleteClick}
label={userWidget ? _t("action|remove") : _t("widget|context_menu|remove")}
/>
);
}
let revokeButton: JSX.Element | undefined;
if (showRevokeButton) {
revokeButton = <MenuItem onSelect={vm.onRevokeClick} label={_t("widget|context_menu|revoke")} />;
}
const [showMoveLeftButton, showMoveRightButton] = showMoveButtons;
let moveLeftButton: JSX.Element | undefined;
if (showMoveLeftButton) {
moveLeftButton = <MenuItem onSelect={() => vm.onMoveButton(-1)} label={_t("widget|context_menu|move_left")} />;
}
let moveRightButton: JSX.Element | undefined;
if (showMoveRightButton) {
moveRightButton = <MenuItem onSelect={() => vm.onMoveButton(1)} label={_t("widget|context_menu|move_right")} />;
}
// Only render menu items when the menu is open to prevent focusable elements in aria-hidden container
const renderMenuItems = (): React.ReactNode => {
if (!isMenuOpened) return null;
return (
<>
{streamAudioStreamButton}
{editButton}
{revokeButton}
{deleteButton}
{snapshotButton}
{moveLeftButton}
{moveRightButton}
</>
);
};
// Default trigger icon if no valid trigger element was passed
const wrappedTrigger = React.isValidElement(trigger) ? (
trigger
) : (
<IconButton size="24px" aria-label="context menu trigger button" inert={true} tabIndex={-1}>
<TriggerIcon />
</IconButton>
);
return (
<Menu
title="Widget context menu"
open={isMenuOpened}
showTitle={false}
side="right"
align="start"
trigger={wrappedTrigger}
onOpenChange={vm.onFinished}
>
{renderMenuItems()}
</Menu>
);
};
@@ -0,0 +1,83 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<WidgetContextMenuView /> > renders widget contextmenu with all options 1`] = `
<div
aria-hidden="true"
data-aria-hidden="true"
>
<button
aria-controls="radix-_r_1_"
aria-disabled="false"
aria-expanded="true"
aria-haspopup="menu"
aria-label="context menu trigger button"
class="_icon-button_1215g_8"
data-kind="primary"
data-state="open"
id="radix-_r_0_"
inert=""
role="button"
style="--cpd-icon-button-size: 24px;"
tabindex="-1"
type="button"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
/>
</svg>
</div>
</button>
</div>
`;
exports[`<WidgetContextMenuView /> > renders widget contextmenu without only basic modification 1`] = `
<div
aria-hidden="true"
data-aria-hidden="true"
>
<button
aria-controls="radix-_r_b_"
aria-disabled="false"
aria-expanded="true"
aria-haspopup="menu"
aria-label="context menu trigger button"
class="_icon-button_1215g_8"
data-kind="primary"
data-state="open"
id="radix-_r_a_"
inert=""
role="button"
style="--cpd-icon-button-size: 24px;"
tabindex="-1"
type="button"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
/>
</svg>
</div>
</button>
</div>
`;
@@ -0,0 +1,9 @@
/*
* Copyright 2025 New Vector 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 type { WidgetContextMenuViewSnapshot, WidgetContextMenuViewModel } from "./WidgetContextMenuView";
export { WidgetContextMenuView } from "./WidgetContextMenuView";
@@ -0,0 +1,59 @@
/*
* 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, { forwardRef } from "react";
import { Heading, Tooltip } from "@vector-im/compound-web";
import ChevronDownIcon from "@vector-im/compound-design-tokens/assets/web/icons/chevron-down";
import { Flex } from "../../../core/utils/Flex";
import { useI18n } from "../../../core/i18n/i18nContext";
/** Props for DateSeparatorButton. */
export interface DateSeparatorButtonProps {
/** Visible date label shown in the separator button. */
label: string;
/** Controls tooltip visibility when parent manages open state. */
tooltipOpen?: boolean;
/** Extra CSS classes to apply to the component. */
className?: string;
/** Called when the pointer enters the button trigger. */
onMouseEnter?: React.MouseEventHandler<HTMLDivElement>;
/** Called when the pointer leaves the button trigger. */
onMouseLeave?: React.MouseEventHandler<HTMLDivElement>;
/** Called when the button trigger receives focus. */
onFocus?: React.FocusEventHandler<HTMLDivElement>;
/** Called when the button trigger loses focus. */
onBlur?: React.FocusEventHandler<HTMLDivElement>;
}
/** Interactive date separator button that forwards its ref to the underlying trigger element used by Compound Menu. */
export const DateSeparatorButton = forwardRef<HTMLDivElement, DateSeparatorButtonProps>(function DateSeparatorButton(
{ label, tooltipOpen, className, ...props },
forwardedRef,
): React.ReactNode {
const { translate: _t } = useI18n();
return (
<Tooltip description={_t("room|jump_to_date")} placement="right" open={tooltipOpen}>
<Flex
ref={forwardedRef}
data-testid="jump-to-date-separator-button"
className={className}
aria-live="off"
aria-label={_t("room|jump_to_date")}
role="button"
tabIndex={0}
{...props}
>
<Heading as="h2" size="lg" aria-hidden="true">
{label}
</Heading>
<ChevronDownIcon />
</Flex>
</Tooltip>
);
});
@@ -0,0 +1,20 @@
/*
* 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.
*/
.picker_menu {
max-inline-size: none;
padding: 0;
gap: 0;
}
.picker_menu_item {
padding: var(--cpd-space-3x) var(--cpd-space-5x);
}
.picker_separator {
margin-inline: 0;
}
@@ -0,0 +1,182 @@
/*
* 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 { fireEvent, render, screen } from "@test-utils";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { type DateSeparatorViewModel, type DateSeparatorViewSnapshot } from "./DateSeparatorView";
import { DateSeparatorContextMenuView } from "./DateSeparatorContextMenuView";
class TestDateSeparatorViewModel implements DateSeparatorViewModel {
private listeners = new Set<() => void>();
public constructor(private snapshot: DateSeparatorViewSnapshot) {}
public getSnapshot = (): DateSeparatorViewSnapshot => this.snapshot;
public subscribe = (listener: () => void): (() => void) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
public onLastWeekPicked = vi.fn<() => void>();
public onLastMonthPicked = vi.fn<() => void>();
public onBeginningPicked = vi.fn<() => void>();
public onDatePicked = vi.fn<(dateString: string) => void>();
}
function renderMenu({
open = true,
jumpToEnabled = true,
onOpenChange = vi.fn<(open: boolean) => void>(),
}: {
open?: boolean;
jumpToEnabled?: boolean;
onOpenChange?: (open: boolean) => void;
} = {}): { vm: TestDateSeparatorViewModel; onOpenChange: (open: boolean) => void } {
const vm = new TestDateSeparatorViewModel({
label: "Today",
jumpToEnabled,
jumpFromDate: "2025-01-15",
});
render(
<DateSeparatorContextMenuView
vm={vm}
open={open}
onOpenChange={onOpenChange}
trigger={
<button type="button" data-testid="jump-to-trigger">
Trigger
</button>
}
/>,
);
return { vm, onOpenChange };
}
describe("DateSeparatorContextMenuView", () => {
it("renders menu actions and date picker when open", () => {
renderMenu({ open: true, jumpToEnabled: true });
expect(screen.getByTestId("jump-to-date-last-week")).toBeInTheDocument();
expect(screen.getByTestId("jump-to-date-last-month")).toBeInTheDocument();
expect(screen.getByTestId("jump-to-date-beginning")).toBeInTheDocument();
expect(screen.getByTestId("jump-to-date-picker")).toBeInTheDocument();
});
it("calls onOpenChange for opening and closing transitions", async () => {
const user = userEvent.setup();
const onOpenChange = vi.fn<(open: boolean) => void>();
renderMenu({ open: false, jumpToEnabled: true, onOpenChange });
await user.click(screen.getByTestId("jump-to-trigger"));
expect(onOpenChange).toHaveBeenCalledWith(true);
renderMenu({ open: true, jumpToEnabled: true, onOpenChange });
await user.click(screen.getByTestId("jump-to-date-last-week"));
expect(onOpenChange).toHaveBeenCalledWith(false);
});
it("wires week/month/beginning menu actions to the correct callbacks", async () => {
const user = userEvent.setup();
const { vm } = renderMenu({ open: true, jumpToEnabled: true });
await user.click(screen.getByTestId("jump-to-date-last-week"));
await user.click(screen.getByTestId("jump-to-date-last-month"));
await user.click(screen.getByTestId("jump-to-date-beginning"));
expect(vm.onLastWeekPicked).toHaveBeenCalledTimes(1);
expect(vm.onLastMonthPicked).toHaveBeenCalledTimes(1);
expect(vm.onBeginningPicked).toHaveBeenCalledTimes(1);
});
it("submits date picker and closes the menu", async () => {
const user = userEvent.setup();
const onOpenChange = vi.fn<(open: boolean) => void>();
const { vm } = renderMenu({ open: true, jumpToEnabled: true, onOpenChange });
const dateInput = screen.getByLabelText("Pick a date to jump to");
fireEvent.input(dateInput, { target: { value: "2025-01-10" } });
await user.click(screen.getByRole("button", { name: "Go" }));
expect(vm.onDatePicked).toHaveBeenCalledWith("2025-01-10");
expect(onOpenChange).toHaveBeenCalledWith(false);
});
it("moves focus from date input to submit button on Tab", async () => {
const user = userEvent.setup();
renderMenu({ open: true, jumpToEnabled: true });
const dateInput = screen.getByLabelText("Pick a date to jump to");
const submitButton = screen.getByRole("button", { name: "Go" });
dateInput.focus();
await user.keyboard("{Tab}");
expect(submitButton).toHaveFocus();
});
it("moves focus from submit button back to date input on Shift+Tab", async () => {
const user = userEvent.setup();
renderMenu({ open: true, jumpToEnabled: true });
const dateInput = screen.getByLabelText("Pick a date to jump to");
const submitButton = screen.getByRole("button", { name: "Go" });
submitButton.focus();
await user.keyboard("{Shift>}{Tab}{/Shift}");
expect(dateInput).toHaveFocus();
});
it("closes the menu on Tab from the submit button", async () => {
const user = userEvent.setup();
const onOpenChange = vi.fn<(open: boolean) => void>();
const { vm } = renderMenu({ open: true, jumpToEnabled: true, onOpenChange });
const submitButton = screen.getByRole("button", { name: "Go" });
submitButton.focus();
await user.keyboard("{Tab}");
expect(onOpenChange).toHaveBeenCalledWith(false);
expect(vm.onDatePicked).not.toHaveBeenCalled();
});
it("submits date picker with Enter on the submit button", async () => {
const user = userEvent.setup();
const onOpenChange = vi.fn<(open: boolean) => void>();
const { vm } = renderMenu({ open: true, jumpToEnabled: true, onOpenChange });
const dateInput = screen.getByLabelText("Pick a date to jump to");
fireEvent.input(dateInput, { target: { value: "2025-01-11" } });
const submitButton = screen.getByRole("button", { name: "Go" });
submitButton.focus();
await user.keyboard("{Enter}");
expect(vm.onDatePicked).toHaveBeenCalledWith("2025-01-11");
expect(onOpenChange).toHaveBeenCalledWith(false);
});
it("submits date picker with Space on the submit button", async () => {
const user = userEvent.setup();
const onOpenChange = vi.fn<(open: boolean) => void>();
const { vm } = renderMenu({ open: true, jumpToEnabled: true, onOpenChange });
const dateInput = screen.getByLabelText("Pick a date to jump to");
fireEvent.input(dateInput, { target: { value: "2025-01-12" } });
const submitButton = screen.getByRole("button", { name: "Go" });
submitButton.focus();
await user.keyboard(" ");
expect(vm.onDatePicked).toHaveBeenCalledWith("2025-01-12");
expect(onOpenChange).toHaveBeenCalledWith(false);
});
});
@@ -0,0 +1,95 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX, type PropsWithChildren, useRef } from "react";
import { Menu, MenuItem, Separator } from "@vector-im/compound-web";
import { capitalize } from "lodash";
import { useI18n } from "../../../core/i18n/i18nContext";
import { humanizeRelativeTime } from "../../../core/utils/humanize";
import { type DateSeparatorViewModel } from "./DateSeparatorView";
import { DateSeparatorDatePickerView } from "./DateSeparatorDatePickerView";
import styles from "./DateSeparatorContextMenuView.module.css";
/**
* Props for DateSeparatorContextMenuView component.
*/
export interface DateSeparatorContextMenuViewProps {
/** The date separator view model. */
vm: DateSeparatorViewModel;
/** Whether the menu is open (controlled by the parent). */
open: boolean;
/** The element used as the menu trigger. */
trigger: React.ReactNode;
/** Called when the menu requests an open state change. */
onOpenChange?: (open: boolean) => void;
}
/**
* Date separator jump-to menu.
* Uses the wrapped child as the menu trigger.
*/
export const DateSeparatorContextMenuView: React.FC<PropsWithChildren<DateSeparatorContextMenuViewProps>> = ({
vm,
open,
trigger,
onOpenChange,
}): JSX.Element => {
const i18n = useI18n();
const { translate: _t } = useI18n();
const dateInputRef = useRef<HTMLInputElement>(null);
const onKeyDown = (event: React.KeyboardEvent<HTMLElement>): void => {
if (event.key !== "ArrowDown") return;
event.preventDefault();
dateInputRef.current?.focus();
};
return (
<Menu
open={open}
onOpenChange={(newOpen) => {
onOpenChange?.(newOpen);
}}
title={_t("room|jump_to_date")}
showTitle={false}
trigger={trigger}
align="start"
className={styles.picker_menu}
>
<MenuItem
label={capitalize(humanizeRelativeTime(i18n).format(-1, "week"))}
onSelect={() => vm.onLastWeekPicked?.()}
data-testid="jump-to-date-last-week"
hideChevron={true}
className={styles.picker_menu_item}
/>
<MenuItem
label={capitalize(humanizeRelativeTime(i18n).format(-1, "month"))}
onSelect={() => vm.onLastMonthPicked?.()}
data-testid="jump-to-date-last-month"
hideChevron={true}
className={styles.picker_menu_item}
/>
<MenuItem
label={_t("room|jump_to_date_beginning")}
onSelect={() => vm.onBeginningPicked?.()}
data-testid="jump-to-date-beginning"
hideChevron={true}
className={styles.picker_menu_item}
onKeyDown={onKeyDown}
/>
<Separator decorative className={styles.picker_separator} />
<DateSeparatorDatePickerView
vm={vm}
inputRef={dateInputRef}
onSubmitted={() => onOpenChange?.(false)}
onDismissed={() => onOpenChange?.(false)}
/>
</Menu>
);
};
@@ -0,0 +1,47 @@
/*
* 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.
*/
.picker_menu_item {
padding: var(--cpd-space-3x) var(--cpd-space-5x);
}
.picker_form {
display: flex;
flex-direction: row;
flex-wrap: wrap;
padding: 0;
gap: var(--cpd-space-2x);
color: var(--cpd-color-text-primary);
font: var(--cpd-font-body-md-medium);
}
.picker_input {
&:focus-within {
border-color: var(--cpd-color-border-focused);
}
}
.picker_input_date {
font: inherit;
color-scheme: light;
padding: var(--cpd-space-2x);
:global(.cpd-theme-dark) &,
:global(.cpd-theme-dark-hc) & {
color-scheme: dark;
}
}
@media (prefers-color-scheme: dark) {
.picker_input_date {
color-scheme: dark;
:global(.cpd-theme-light) &,
:global(.cpd-theme-light-hc) & {
color-scheme: light;
}
}
}
@@ -0,0 +1,132 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX, useId, useRef, useState } from "react";
import { Root, Submit, Field, TextControl, MenuItem } from "@vector-im/compound-web";
import { formatDateForInput } from "../../../core/utils/DateUtils";
import { useI18n } from "../../../core/i18n/i18nContext";
import { useViewModel } from "../../../core/viewmodel";
import { type DateSeparatorViewModel } from "./DateSeparatorView";
import styles from "./DateSeparatorDatePickerView.module.css";
/**
* Props for DateSeparatorDatePickerView component.
*/
export interface DateSeparatorDatePickerViewProps {
/** The date separator view model. */
vm: DateSeparatorViewModel;
/** Optional input ref shared with parent for focus management. */
inputRef?: React.RefObject<HTMLInputElement | null>;
/** Called after a date has been submitted. */
onSubmitted?: () => void;
/** Called when the picker is dismissed without submitting. */
onDismissed?: () => void;
}
/**
* Date picker menu item.
*/
export const DateSeparatorDatePickerView: React.FC<DateSeparatorDatePickerViewProps> = ({
vm,
inputRef,
onSubmitted,
onDismissed,
}): JSX.Element => {
const snapshot = useViewModel(vm);
const date = snapshot.jumpFromDate ? new Date(snapshot.jumpFromDate) : new Date();
const dateInputDefaultValue = formatDateForInput(date);
const { translate: _t } = useI18n();
const dateInputId = useId();
const [dateValue, setDateValue] = useState(dateInputDefaultValue);
const localDateInputRef = useRef<HTMLInputElement>(null);
const dateInputRef = inputRef ?? localDateInputRef;
const submitButtonRef = useRef<HTMLButtonElement>(null);
const onDateInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>): void => {
if (event.key === "Tab") {
if (event.shiftKey) {
onDismissed?.();
} else {
event.preventDefault();
submitButtonRef.current?.focus();
}
}
};
const onDateValueInput = (event: React.InputEvent<HTMLInputElement>): void => {
setDateValue(event.currentTarget.value);
};
const submitDate = (): void => {
vm.onDatePicked?.(dateValue);
onSubmitted?.();
};
const onJumpToDateSubmit = (event: React.SubmitEvent<HTMLFormElement>): void => {
event.preventDefault();
submitDate();
};
const onSubmitButtonKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>): void => {
if (event.key === "Tab") {
if (event.shiftKey) {
event.preventDefault();
dateInputRef.current?.focus();
} else {
onDismissed?.();
}
}
if (event.key == "Enter" || event.key == " " || event.key == "Spacebar") {
event.preventDefault();
submitDate();
}
};
const keepMenuOpenOnSelect = (event: Event): void => {
event.preventDefault();
};
return (
<MenuItem
as="div"
data-testid="jump-to-date-picker"
label={_t("room|jump_to_date")}
onSelect={keepMenuOpenOnSelect}
hideChevron={true}
className={styles.picker_menu_item}
>
<Root className={styles.picker_form} onSubmit={onJumpToDateSubmit}>
<Field name="jump-to-date-field" className={styles.picker_input}>
<TextControl
ref={dateInputRef}
id={dateInputId}
type="date"
aria-label={_t("room|jump_to_date_prompt")}
onInput={onDateValueInput}
onKeyDown={onDateInputKeyDown}
value={dateValue}
max={formatDateForInput(new Date())}
className={styles.picker_input_date}
/>
</Field>
<Submit
ref={submitButtonRef}
className={styles.picker_button}
type="submit"
kind="primary"
size="md"
onKeyDown={onSubmitButtonKeyDown}
>
{_t("action|go")}
</Submit>
</Root>
</MenuItem>
);
};
@@ -0,0 +1,25 @@
/*
* 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.
*/
.content {
padding: 0 25px;
cursor: pointer;
h2 {
flex: 0 0 auto;
margin: 0;
font-size: inherit;
text-transform: capitalize;
}
svg {
align-self: center;
width: 16px;
height: 16px;
color: var(--cpd-color-icon-secondary);
}
}
@@ -0,0 +1,92 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX } from "react";
import { expect, userEvent, within } from "storybook/test";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { DateSeparatorView, type DateSeparatorViewSnapshot, type DateSeparatorViewActions } from "./DateSeparatorView";
import { useMockedViewModel } from "../../../core/viewmodel/useMockedViewModel";
import { withViewDocs } from "../../../../.storybook/withViewDocs";
type DateSeparatorProps = DateSeparatorViewSnapshot & DateSeparatorViewActions;
const DateSeparatorViewWrapperImpl = ({
onLastWeekPicked,
onLastMonthPicked,
onBeginningPicked,
onDatePicked,
className,
...rest
}: DateSeparatorProps & { className?: string }): JSX.Element => {
const vm = useMockedViewModel(rest, { onLastWeekPicked, onLastMonthPicked, onBeginningPicked, onDatePicked });
return <DateSeparatorView vm={vm} className={className} />;
};
const DateSeparatorViewWrapper = withViewDocs(DateSeparatorViewWrapperImpl, DateSeparatorView);
const meta = {
title: "Timeline/Timeline Meta/DateSeparatorView",
component: DateSeparatorViewWrapper,
tags: ["autodocs"],
argTypes: {
jumpToEnabled: { control: "boolean" },
jumpFromDate: { control: "text" },
className: { control: "text" },
},
args: {
label: "Today",
},
} satisfies Meta<typeof DateSeparatorViewWrapper>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const HasExtraClassNames: Story = {
args: {
className: "extra_class_1 extra_class_2",
},
};
export const WithJumpToTooltip: Story = {
args: {
jumpToEnabled: true,
jumpFromDate: "2025-01-15",
onLastWeekPicked: () => console.log("onLastWeekPicked"),
onLastMonthPicked: () => console.log("onLastMonthPicked"),
onBeginningPicked: () => console.log("onBeginningPicked"),
onDatePicked: () => console.log("onDatePicked"),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.hover(canvas.getByText("Today"));
await expect(within(canvasElement.ownerDocument.body).findByRole("tooltip")).resolves.toBeInTheDocument();
},
};
export const WithJumpToDatePicker: Story = {
args: {
jumpToEnabled: true,
jumpFromDate: "2025-01-15",
onLastWeekPicked: () => console.log("onLastWeekPicked"),
onLastMonthPicked: () => console.log("onLastMonthPicked"),
onBeginningPicked: () => console.log("onBeginningPicked"),
onDatePicked: () => console.log("onDatePicked"),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByText("Today"));
await expect(within(canvasElement.ownerDocument.body).findByText("Jump to date")).resolves.toBeInTheDocument();
},
};
export const LongLocalizedLabel: Story = {
args: {
label: "Wednesday, December 17, 2025 at 11:59 PM Coordinated Universal Time",
},
};
@@ -0,0 +1,85 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { render, screen } from "@test-utils";
import { composeStories } from "@storybook/react-vite";
import React, { createRef } from "react";
import { describe, it, expect } from "vitest";
import { waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { BaseViewModel } from "../../../core/viewmodel/BaseViewModel";
import { DateSeparatorButton } from "./DateSeparatorButton";
import { DateSeparatorView, type DateSeparatorViewModel, type DateSeparatorViewSnapshot } from "./DateSeparatorView";
import * as stories from "./DateSeparatorView.stories";
const { Default, HasExtraClassNames, WithJumpToDatePicker, LongLocalizedLabel } = composeStories(stories);
class MutableDateSeparatorViewModel
extends BaseViewModel<DateSeparatorViewSnapshot, undefined>
implements DateSeparatorViewModel
{
public constructor(snapshot: DateSeparatorViewSnapshot) {
super(undefined, snapshot);
}
public setSnapshot(snapshot: DateSeparatorViewSnapshot): void {
this.snapshot.set(snapshot);
}
public onLastWeekPicked = (): void => undefined;
public onLastMonthPicked = (): void => undefined;
public onBeginningPicked = (): void => undefined;
public onDatePicked = (_dateString: string): void => undefined;
}
describe("DateSeparatorView", () => {
it("renders default story", () => {
const { container } = render(<Default />);
expect(screen.getByText("Today")).toBeInTheDocument();
expect(container).toMatchSnapshot();
});
it("renders with extra class names", () => {
const { container } = render(<HasExtraClassNames />);
expect(container.firstElementChild).toHaveClass("extra_class_1");
expect(container.firstElementChild).toHaveClass("extra_class_2");
expect(container).toMatchSnapshot();
});
it("renders with jump to date picker story", async () => {
const { container } = render(<WithJumpToDatePicker />);
await userEvent.click(screen.getByTestId("jump-to-date-separator-button"));
await expect(screen.findByTestId("jump-to-date-last-week")).resolves.toBeInTheDocument();
expect(container).toMatchSnapshot();
});
it("renders long localized label story", () => {
const { container } = render(<LongLocalizedLabel />);
expect(
screen.getByText("Wednesday, December 17, 2025 at 11:59 PM Coordinated Universal Time"),
).toBeInTheDocument();
expect(container).toMatchSnapshot();
});
it("updates when view model snapshot changes", async () => {
const vm = new MutableDateSeparatorViewModel({ label: "Today" });
render(<DateSeparatorView vm={vm} />);
expect(screen.getByText("Today", { selector: "h2" })).toBeInTheDocument();
vm.setSnapshot({ label: "Yesterday" });
await waitFor(() => expect(screen.getByText("Yesterday", { selector: "h2" })).toBeInTheDocument());
});
it("forwards refs to the trigger element", () => {
const ref = createRef<HTMLDivElement>();
render(<DateSeparatorButton ref={ref} label="Today" />);
expect(ref.current).toBe(screen.getByTestId("jump-to-date-separator-button"));
});
});
@@ -0,0 +1,117 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import classNames from "classnames";
import React, { type JSX, useState } from "react";
import { Heading } from "@vector-im/compound-web";
import { type ViewModel } from "../../../core/viewmodel/ViewModel";
import { useViewModel } from "../../../core/viewmodel/useViewModel";
import styles from "./DateSeparatorView.module.css";
import { Flex } from "../../../core/utils/Flex";
import { TimelineSeparator } from "../TimelineSeparator";
import { DateSeparatorContextMenuView } from "./DateSeparatorContextMenuView";
import { DateSeparatorButton } from "./DateSeparatorButton";
export interface DateSeparatorViewSnapshot {
/**
* Visible date label and the separator's accessible label.
*/
label: string;
/**
* Controls whether the jump-to menu is rendered.
*/
jumpToEnabled?: boolean;
/**
* Reference date as input format used to prefill the jump-to-date picker value.
*/
jumpFromDate?: string;
}
export interface DateSeparatorViewActions {
/** Optional: Jump to messages from the last week. */
onLastWeekPicked?: () => void;
/** Optional: Jump to messages from the last month. */
onLastMonthPicked?: () => void;
/** Optional: Jump to the beginning of the room history. */
onBeginningPicked?: () => void;
/** Optional: Jump to the picked date of the room history. */
onDatePicked?: (date: string) => void;
}
/**
* The view model for the component.
*/
export type DateSeparatorViewModel = ViewModel<DateSeparatorViewSnapshot> & DateSeparatorViewActions;
interface DateSeparatorViewProps {
/**
* The view model for the component.
*/
vm: DateSeparatorViewModel;
/**
* Extra CSS classes to apply to the component.
*/
className?: string;
}
/**
* Renders a timeline date separator.
* When `jumpToEnabled` is true, wraps the separator label with a jump-to menu trigger.
* The tooltip is disabled while the menu is open to avoid overlap.
*
* @example
* ```tsx
* <DateSeparatorView vm={vm} />
* ```
*/
export function DateSeparatorView({ vm, className }: Readonly<DateSeparatorViewProps>): JSX.Element {
const { label, jumpToEnabled } = useViewModel(vm);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isTriggerHovered, setIsTriggerHovered] = useState(false);
const [isTriggerFocused, setIsTriggerFocused] = useState(false);
const onMenuOpenChange = (newOpen: boolean): void => {
setIsMenuOpen(newOpen);
if (newOpen) {
setIsTriggerHovered(false);
setIsTriggerFocused(false);
}
};
if (jumpToEnabled) {
return (
<TimelineSeparator label={label} className={classNames(className)} role="none">
<DateSeparatorContextMenuView
vm={vm}
open={isMenuOpen}
onOpenChange={onMenuOpenChange}
trigger={
<DateSeparatorButton
label={label}
tooltipOpen={!isMenuOpen && (isTriggerHovered || isTriggerFocused)}
className={styles.content}
onMouseEnter={() => setIsTriggerHovered(true)}
onMouseLeave={() => setIsTriggerHovered(false)}
onFocus={(event) => setIsTriggerFocused(event.currentTarget.matches(":focus-visible"))}
onBlur={() => setIsTriggerFocused(false)}
/>
}
/>
</TimelineSeparator>
);
}
return (
<TimelineSeparator label={label} className={classNames(className)}>
<Flex className={styles.content}>
<Heading as="h2" size="lg" aria-hidden="true">
{label}
</Heading>
</Flex>
</TimelineSeparator>
);
}
@@ -0,0 +1,142 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`DateSeparatorView > renders default story 1`] = `
<div>
<div
aria-label="Today"
class="Flex-module_flex TimelineSeparator-module_timelineSeparator"
role="separator"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<hr
role="none"
/>
<div
class="Flex-module_flex DateSeparatorView-module_content"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<h2
aria-hidden="true"
class="_typography_6v6n8_153 _font-heading-lg-regular_6v6n8_121"
>
Today
</h2>
</div>
<hr
role="none"
/>
</div>
</div>
`;
exports[`DateSeparatorView > renders long localized label story 1`] = `
<div>
<div
aria-label="Wednesday, December 17, 2025 at 11:59 PM Coordinated Universal Time"
class="Flex-module_flex TimelineSeparator-module_timelineSeparator"
role="separator"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<hr
role="none"
/>
<div
class="Flex-module_flex DateSeparatorView-module_content"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<h2
aria-hidden="true"
class="_typography_6v6n8_153 _font-heading-lg-regular_6v6n8_121"
>
Wednesday, December 17, 2025 at 11:59 PM Coordinated Universal Time
</h2>
</div>
<hr
role="none"
/>
</div>
</div>
`;
exports[`DateSeparatorView > renders with extra class names 1`] = `
<div>
<div
aria-label="Today"
class="Flex-module_flex extra_class_1 extra_class_2 TimelineSeparator-module_timelineSeparator"
role="separator"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<hr
role="none"
/>
<div
class="Flex-module_flex DateSeparatorView-module_content"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<h2
aria-hidden="true"
class="_typography_6v6n8_153 _font-heading-lg-regular_6v6n8_121"
>
Today
</h2>
</div>
<hr
role="none"
/>
</div>
</div>
`;
exports[`DateSeparatorView > renders with jump to date picker story 1`] = `
<div>
<div
class="Flex-module_flex TimelineSeparator-module_timelineSeparator"
role="none"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<hr
aria-hidden="true"
data-aria-hidden="true"
role="none"
/>
<div
aria-controls="radix-_r_1_"
aria-expanded="true"
aria-haspopup="menu"
aria-label="Jump to date"
aria-live="off"
class="Flex-module_flex DateSeparatorView-module_content"
data-state="open"
data-testid="jump-to-date-separator-button"
id="radix-_r_0_"
role="button"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
tabindex="0"
type="button"
>
<h2
aria-hidden="true"
class="_typography_6v6n8_153 _font-heading-lg-regular_6v6n8_121"
>
Today
</h2>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 14.95q-.2 0-.375-.062a.9.9 0 0 1-.325-.213l-4.6-4.6a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l3.9 3.9 3.9-3.9a.95.95 0 0 1 .7-.275q.425 0 .7.275a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7l-4.6 4.6q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
/>
</svg>
</div>
<hr
aria-hidden="true"
data-aria-hidden="true"
role="none"
/>
</div>
</div>
`;
@@ -0,0 +1,8 @@
/*
* 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 { DateSeparatorView, type DateSeparatorViewModel, type DateSeparatorViewSnapshot } from "./DateSeparatorView";
@@ -0,0 +1,39 @@
/*
* 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 { createContext, useContext } from "react";
/** Event tile layout selected by the host surface. */
export type EventLayout = "group" | "bubble" | "irc";
/** Density variant applied within an event layout. */
export type EventDensity = "default" | "compact";
/** Presentation settings that shared event/timeline components can adapt to. */
export interface EventPresentation {
/** Layout family used for event rendering. */
layout: EventLayout;
/** Spacing density used within the layout. */
density: EventDensity;
}
/** Default event presentation used when no provider is present. */
export const DEFAULT_EVENT_PRESENTATION: EventPresentation = {
layout: "group",
density: "default",
};
const EventPresentationContext = createContext<EventPresentation>(DEFAULT_EVENT_PRESENTATION);
EventPresentationContext.displayName = "EventPresentationContext";
/** Provides event presentation settings to shared event/timeline components. */
export const EventPresentationProvider = EventPresentationContext.Provider;
/** Returns the current event presentation settings. */
export function useEventPresentation(): EventPresentation {
return useContext(EventPresentationContext);
}
@@ -0,0 +1,15 @@
/*
* 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 {
DEFAULT_EVENT_PRESENTATION,
EventPresentationProvider,
useEventPresentation,
type EventDensity,
type EventLayout,
type EventPresentation,
} from "./EventPresentationContext";
@@ -0,0 +1,28 @@
/*
* 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.
*/
.readMarker {
height: 0;
margin: 0;
padding: 0;
border: 0;
}
.line {
margin-top: 0;
position: relative;
top: -1px;
z-index: 1;
width: 99%;
opacity: 1;
border-top: solid 1px var(--cpd-color-icon-accent-tertiary);
border-bottom: solid 1px var(--cpd-color-icon-accent-tertiary);
will-change: width, opacity;
transition:
width 400ms easeinsine 1s,
opacity 400ms easeinsine 1s;
}
@@ -0,0 +1,60 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX } from "react";
import { fn } from "storybook/test";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { ReadMarker, type ReadMarkerProps } from "./ReadMarker";
const ReadMarkerWrapper = ({
className,
onCurrentMarkerRef,
onGhostLineRef,
onGhostTransitionEnd,
...props
}: Readonly<ReadMarkerProps>): JSX.Element => {
return (
<ul>
<ReadMarker
{...props}
className={className}
onCurrentMarkerRef={onCurrentMarkerRef ?? fn()}
onGhostLineRef={onGhostLineRef ?? fn()}
onGhostTransitionEnd={onGhostTransitionEnd ?? fn()}
/>
</ul>
);
};
const meta = {
title: "Timeline/Timeline Meta/ReadMarker",
component: ReadMarkerWrapper,
tags: ["autodocs"],
args: {
eventId: "$event",
kind: "current",
showLine: true,
},
} satisfies Meta<typeof ReadMarkerWrapper>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Current: Story = {};
export const HiddenCurrent: Story = {
args: {
showLine: false,
},
};
export const Ghost: Story = {
args: {
kind: "ghost",
},
};
@@ -0,0 +1,85 @@
/*
* 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 { composeStories } from "@storybook/react-vite";
import { fireEvent, render, screen } from "@test-utils";
import React from "react";
import { describe, expect, it, vi } from "vitest";
import { ReadMarker } from "./ReadMarker";
import * as stories from "./ReadMarker.stories";
const { Current, HiddenCurrent, Ghost } = composeStories(stories);
describe("ReadMarker", () => {
it("renders the current read marker", () => {
const { container } = render(<Current />);
expect(container).toMatchSnapshot();
});
it("renders the hidden current read marker without a line", () => {
const { container } = render(<HiddenCurrent />);
expect(container).toMatchSnapshot();
expect(container.querySelector("hr")).toBeNull();
});
it("renders the ghost read marker", () => {
const { container } = render(<Ghost />);
expect(container).toMatchSnapshot();
});
it("applies custom className to the list item", () => {
render(
<ul>
<ReadMarker
eventId="$event"
kind="current"
showLine={true}
className="custom-read-marker compatibility-class"
/>
</ul>,
);
const item = screen.getByRole("listitem");
expect(item).toHaveClass("custom-read-marker", "compatibility-class");
expect(item).toHaveAttribute("data-scroll-tokens", "$event");
});
it("wires ghost marker actions", () => {
const onGhostLineRef = vi.fn();
const onGhostTransitionEnd = vi.fn();
render(
<ul>
<ReadMarker
eventId="$ghost"
kind="ghost"
onGhostLineRef={onGhostLineRef}
onGhostTransitionEnd={onGhostTransitionEnd}
/>
</ul>,
);
const line = screen.getByRole("separator");
fireEvent.transitionEnd(line);
expect(onGhostLineRef).toHaveBeenCalled();
expect(onGhostTransitionEnd).toHaveBeenCalledTimes(1);
});
it("wires the current marker ref", () => {
const onCurrentMarkerRef = vi.fn();
render(
<ul>
<ReadMarker eventId="$current" kind="current" showLine={true} onCurrentMarkerRef={onCurrentMarkerRef} />
</ul>,
);
expect(onCurrentMarkerRef).toHaveBeenCalled();
});
});
@@ -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, { type JSX, type RefCallback, type TransitionEventHandler } from "react";
import classNames from "classnames";
import styles from "./ReadMarker.module.css";
export type ReadMarkerKind = "current" | "ghost";
export interface ReadMarkerProps {
/**
* The event ID this marker is associated with.
*/
eventId: string;
/**
* Whether this is the active read marker or a ghost marker transitioning out.
*/
kind: ReadMarkerKind;
/**
* Whether the visible line should be rendered for the active marker.
* Hidden active markers still render the host `<li>` to preserve layout calculations.
*/
showLine?: boolean;
/**
* Ref callback for the active read marker `<li>`.
*/
onCurrentMarkerRef?: RefCallback<HTMLLIElement>;
/**
* Ref callback for the ghost marker `<hr>`.
*/
onGhostLineRef?: RefCallback<HTMLHRElement>;
/**
* Transition-end handler for the ghost marker `<hr>`.
*/
onGhostTransitionEnd?: TransitionEventHandler<HTMLHRElement>;
/**
* Optional CSS className for the outer list item.
*/
className?: string;
}
export function ReadMarker({
eventId,
kind,
showLine = true,
onCurrentMarkerRef,
onGhostLineRef,
onGhostTransitionEnd,
className,
}: Readonly<ReadMarkerProps>): JSX.Element {
let line: JSX.Element | null = null;
if (kind === "ghost") {
line = (
<hr
className={styles.line}
ref={onGhostLineRef}
onTransitionEnd={onGhostTransitionEnd}
data-eventid={eventId}
/>
);
} else if (showLine) {
line = <hr className={styles.line} />;
}
return (
<li
className={classNames(className, styles.readMarker)}
ref={kind === "current" ? onCurrentMarkerRef : undefined}
data-scroll-tokens={kind === "current" ? eventId : undefined}
>
{line}
</li>
);
}
@@ -0,0 +1,42 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`ReadMarker > renders the current read marker 1`] = `
<div>
<ul>
<li
class="ReadMarker-module_readMarker"
data-scroll-tokens="$event"
>
<hr
class="ReadMarker-module_line"
/>
</li>
</ul>
</div>
`;
exports[`ReadMarker > renders the ghost read marker 1`] = `
<div>
<ul>
<li
class="ReadMarker-module_readMarker"
>
<hr
class="ReadMarker-module_line"
data-eventid="$event"
/>
</li>
</ul>
</div>
`;
exports[`ReadMarker > renders the hidden current read marker without a line 1`] = `
<div>
<ul>
<li
class="ReadMarker-module_readMarker"
data-scroll-tokens="$event"
/>
</ul>
</div>
`;
@@ -0,0 +1,8 @@
/*
* 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 { ReadMarker, type ReadMarkerKind, type ReadMarkerProps } from "./ReadMarker";
@@ -0,0 +1,21 @@
/*
* 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.
*/
.timelineSeparator {
clear: both;
margin: var(--cpd-space-1x) 0;
font: var(--cpd-font-body-md-regular);
letter-spacing: var(--cpd-font-letter-spacing-body-md);
color: var(--cpd-color-text-primary);
}
.timelineSeparator > hr {
flex: 1 1 0;
height: 0;
border: none;
border-bottom: 1px solid var(--cpd-color-gray-400);
}
@@ -0,0 +1,59 @@
/*
* 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 TimelineSeparator from "./TimelineSeparator";
import styles from "./TimelineSeparator.module.css";
const meta = {
title: "Timeline/Timeline Meta/TimelineSeparator",
component: TimelineSeparator,
tags: ["autodocs"],
args: {
label: "Label Separator",
children: "Timeline Separator",
},
} satisfies Meta<typeof TimelineSeparator>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const WithHtmlChild: Story = {
args: {
label: "Custom Label",
children: (
<h2 className={styles.timelineSeparator} aria-hidden="true">
Thursday
</h2>
),
},
};
export const WithDateEvent: Story = {
args: {
label: "Date Event Separator",
children: "Wednesday",
},
};
export const WithLateEvent: Story = {
args: {
label: "Late Event Separator",
children: "Fri, Jan 9, 2026",
},
};
export const WithoutChildren: Story = {
args: {
children: undefined,
label: "Separator without children",
},
};
@@ -0,0 +1,48 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { render } from "@test-utils";
import { composeStories } from "@storybook/react-vite";
import React from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import * as stories from "./TimelineSeparator.stories.tsx";
const { Default, WithHtmlChild, WithoutChildren, WithDateEvent, WithLateEvent } = composeStories(stories);
describe("TimelineSeparator", () => {
afterEach(() => {
vi.clearAllMocks();
});
describe("Snapshot tests", () => {
it("renders the timeline separator in default state", () => {
const { container } = render(<Default />);
expect(container).toMatchSnapshot();
});
it("renders the timeline separator with HTML child", () => {
const { container } = render(<WithHtmlChild />);
expect(container).toMatchSnapshot();
});
it("renders the timeline separator with date event", () => {
const { container } = render(<WithDateEvent />);
expect(container).toMatchSnapshot();
});
it("renders the timeline separator with late event", () => {
const { container } = render(<WithLateEvent />);
expect(container).toMatchSnapshot();
});
it("renders the timeline separator without children", () => {
const { container } = render(<WithoutChildren />);
expect(container).toMatchSnapshot();
});
});
});
@@ -0,0 +1,60 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React, { type PropsWithChildren } from "react";
import classNames from "classnames";
import styles from "./TimelineSeparator.module.css";
import { Flex } from "../../../core/utils/Flex";
/**
* Timeline separator props
*/
export interface TimelineSeparatorProps {
/**
* Accessible label for the separator (for example: "Today", "Yesterday", or a date).
*/
label: string;
/**
* The CSS class name.
*/
className?: string;
/**
* Optional children to render inside the timeline separator
*/
children?: PropsWithChildren["children"];
/**
* ARIA role for the separator container.
* Use "none" when the separator contains interactive controls.
* @default "separator"
*/
role?: "separator" | "none";
}
/**
* Generic timeline separator component to render within a MessagePanel
*
* @param label the accessible label string describing the separator
* @param children the children to draw within the timeline separator
*/
const TimelineSeparator: React.FC<TimelineSeparatorProps> = ({ label, className, children, role = "separator" }) => {
// ARIA treats <hr/>s as separators, here we abuse them slightly so manually treat this entire thing as one
return (
<Flex
className={classNames(className, styles.timelineSeparator)}
role={role}
aria-label={role === "separator" ? label : undefined}
align="center"
>
<hr role="none" />
{children}
<hr role="none" />
</Flex>
);
};
export default TimelineSeparator;
@@ -0,0 +1,100 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`TimelineSeparator > Snapshot tests > renders the timeline separator in default state 1`] = `
<div>
<div
aria-label="Label Separator"
class="Flex-module_flex TimelineSeparator-module_timelineSeparator"
role="separator"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<hr
role="none"
/>
Timeline Separator
<hr
role="none"
/>
</div>
</div>
`;
exports[`TimelineSeparator > Snapshot tests > renders the timeline separator with HTML child 1`] = `
<div>
<div
aria-label="Custom Label"
class="Flex-module_flex TimelineSeparator-module_timelineSeparator"
role="separator"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<hr
role="none"
/>
<h2
aria-hidden="true"
class="TimelineSeparator-module_timelineSeparator"
>
Thursday
</h2>
<hr
role="none"
/>
</div>
</div>
`;
exports[`TimelineSeparator > Snapshot tests > renders the timeline separator with date event 1`] = `
<div>
<div
aria-label="Date Event Separator"
class="Flex-module_flex TimelineSeparator-module_timelineSeparator"
role="separator"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<hr
role="none"
/>
Wednesday
<hr
role="none"
/>
</div>
</div>
`;
exports[`TimelineSeparator > Snapshot tests > renders the timeline separator with late event 1`] = `
<div>
<div
aria-label="Late Event Separator"
class="Flex-module_flex TimelineSeparator-module_timelineSeparator"
role="separator"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<hr
role="none"
/>
Fri, Jan 9, 2026
<hr
role="none"
/>
</div>
</div>
`;
exports[`TimelineSeparator > Snapshot tests > renders the timeline separator without children 1`] = `
<div>
<div
aria-label="Separator without children"
class="Flex-module_flex TimelineSeparator-module_timelineSeparator"
role="separator"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<hr
role="none"
/>
<hr
role="none"
/>
</div>
</div>
`;
@@ -0,0 +1,8 @@
/*
* 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 { default as TimelineSeparator, type TimelineSeparatorProps } from "./TimelineSeparator";
@@ -0,0 +1,31 @@
/*
* 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.
*/
.disambiguatedProfile {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
.disambiguatedProfile_displayName {
font: var(--cpd-font-body-md-semibold);
letter-spacing: var(--cpd-letter-spacing-body-md);
margin-inline-end: 0;
/* keeps the height in check, important for the bubble apperance */
line-height: 1;
}
.disambiguatedProfile_mxid {
color: var(--cpd-color-text-secondary);
font-size: var(--cpd-font-size-body-sm);
margin-inline-start: 5px;
}
.userStatus {
margin-inline-start: 5px;
}
}
@@ -0,0 +1,112 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX } from "react";
import { fn } from "storybook/test";
import type { Meta, StoryObj } from "@storybook/react-vite";
import {
DisambiguatedProfileView,
type DisambiguatedProfileViewSnapshot,
type DisambiguatedProfileViewActions,
} from "./DisambiguatedProfileView";
import { useMockedViewModel } from "../../../../../core/viewmodel";
import { withViewDocs } from "../../../../../../.storybook/withViewDocs";
type DisambiguatedProfileProps = DisambiguatedProfileViewSnapshot & DisambiguatedProfileViewActions;
const DisambiguatedProfileViewWrapperImpl = ({
onClick,
className,
...rest
}: DisambiguatedProfileProps & { className?: string }): JSX.Element => {
const vm = useMockedViewModel(rest, { onClick });
return <DisambiguatedProfileView vm={vm} className={className} />;
};
const DisambiguatedProfileViewWrapper = withViewDocs(DisambiguatedProfileViewWrapperImpl, DisambiguatedProfileView);
const meta = {
title: "Timeline/Timeline Event/DisambiguatedProfile",
component: DisambiguatedProfileViewWrapper,
tags: ["autodocs"],
argTypes: {
displayName: { control: "text" },
colorClass: { control: "text" },
className: { control: "text" },
displayIdentifier: { control: "text" },
title: { control: "text" },
emphasizeDisplayName: { control: "boolean" },
userStatus: {
status: { control: "string" },
emoji: { control: "string" },
},
},
args: {
displayName: "Alice",
emphasizeDisplayName: true,
onClick: fn(),
},
} satisfies Meta<typeof DisambiguatedProfileViewWrapper>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const WithMxid: Story = {
args: {
displayName: "Alice",
displayIdentifier: "@alice:example.org",
colorClass: "mx_Username_color1",
},
};
export const WithColorClass: Story = {
args: {
displayName: "Bob",
colorClass: "mx_Username_color3",
},
};
export const Emphasized: Story = {
args: {
displayName: "Charlie",
emphasizeDisplayName: true,
},
};
export const WithTooltip: Story = {
args: {
displayName: "Diana",
title: "Diana (@diana:example.org)",
},
};
export const WithUserStatus: Story = {
args: {
displayName: "Eve",
title: "Eve (@eve:matrix.org)",
userStatus: {
emoji: "🏝️",
text: "On holiday",
},
},
};
export const FullExample: Story = {
args: {
displayName: "Eve",
displayIdentifier: "@eve:matrix.org",
colorClass: "mx_Username_color5",
title: "Eve (@eve:matrix.org)",
emphasizeDisplayName: true,
userStatus: {
emoji: "🏝️",
text: "On holiday",
},
},
};
@@ -0,0 +1,222 @@
/*
* 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 { composeStories } from "@storybook/react-vite";
import React from "react";
import userEvent from "@testing-library/user-event";
import { createEvent, fireEvent } from "@testing-library/dom";
import { afterEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@test-utils";
import * as stories from "./DisambiguatedProfile.stories";
import {
DisambiguatedProfileView,
type DisambiguatedProfileViewActions,
type DisambiguatedProfileViewSnapshot,
} from "./DisambiguatedProfileView";
import { MockViewModel } from "../../../../../core/viewmodel/MockViewModel";
const { Default, WithMxid, WithColorClass, Emphasized, WithTooltip, FullExample } = composeStories(stories);
describe("DisambiguatedProfileView", () => {
afterEach(() => {
vi.clearAllMocks();
});
it("renders the default state", () => {
const { container } = render(<Default />);
expect(container).toMatchSnapshot();
});
it("renders with MXID for disambiguation", () => {
const { container } = render(<WithMxid />);
expect(container).toMatchSnapshot();
});
it("renders with color class", () => {
const { container } = render(<WithColorClass />);
expect(container).toMatchSnapshot();
});
it("renders with emphasized display name", () => {
const { container } = render(<Emphasized />);
expect(container).toMatchSnapshot();
});
it("renders with tooltip", () => {
const { container } = render(<WithTooltip />);
expect(container).toMatchSnapshot();
});
it("renders the full example", () => {
const { container } = render(<FullExample />);
expect(container).toMatchSnapshot();
});
class DisambiguatedProfileViewModel
extends MockViewModel<DisambiguatedProfileViewSnapshot>
implements DisambiguatedProfileViewActions
{
public onClick?: DisambiguatedProfileViewActions["onClick"];
public constructor(snapshot: DisambiguatedProfileViewSnapshot, actions: DisambiguatedProfileViewActions = {}) {
super(snapshot);
this.onClick = actions.onClick;
}
}
const getProfileContainer = (displayName: string): HTMLDivElement => {
const profileContainer = screen.getByText(displayName).closest("div");
if (!profileContainer) {
throw new Error("Expected profile container to exist");
}
return profileContainer;
};
it("should display the display name", () => {
const vm = new DisambiguatedProfileViewModel({
displayName: "Eve",
});
render(<DisambiguatedProfileView vm={vm} />);
expect(screen.getByText("Eve")).toBeInTheDocument();
});
it("should display the MXID when provided", () => {
const vm = new DisambiguatedProfileViewModel({
displayName: "Test User",
displayIdentifier: "@test:example.org",
});
render(<DisambiguatedProfileView vm={vm} />);
expect(screen.getByText("@test:example.org")).toBeInTheDocument();
});
it("should call onClick when clicked", async () => {
const onClick = vi.fn();
const user = userEvent.setup();
const vm = new DisambiguatedProfileViewModel(
{
displayName: "Clickable User",
},
{ onClick },
);
render(<DisambiguatedProfileView vm={vm} />);
await user.click(screen.getByText("Clickable User"));
expect(onClick).toHaveBeenCalled();
});
it("should set button semantics when onClick is provided", () => {
const vm = new DisambiguatedProfileViewModel(
{
displayName: "Keyboard User",
},
{ onClick: vi.fn() },
);
render(<DisambiguatedProfileView vm={vm} />);
const profileContainer = getProfileContainer("Keyboard User");
expect(profileContainer).toHaveAttribute("role", "button");
expect(profileContainer).toHaveAttribute("tabIndex", "0");
});
it("should call onClick on keyboard activation keys", () => {
const onClick = vi.fn();
const vm = new DisambiguatedProfileViewModel(
{
displayName: "Keyboard User",
},
{ onClick },
);
render(<DisambiguatedProfileView vm={vm} />);
const profileContainer = getProfileContainer("Keyboard User");
const enterEvent = createEvent.keyDown(profileContainer, { key: "Enter" });
fireEvent(profileContainer, enterEvent);
const spaceEvent = createEvent.keyDown(profileContainer, { key: " " });
fireEvent(profileContainer, spaceEvent);
expect(enterEvent.defaultPrevented).toBe(true);
expect(spaceEvent.defaultPrevented).toBe(true);
expect(onClick).toHaveBeenCalledTimes(2);
});
it("should not call onClick for non-activation keys", () => {
const onClick = vi.fn();
const vm = new DisambiguatedProfileViewModel(
{
displayName: "Keyboard User",
},
{ onClick },
);
render(<DisambiguatedProfileView vm={vm} />);
const profileContainer = getProfileContainer("Keyboard User");
fireEvent.keyDown(profileContainer, { key: "Escape" });
expect(onClick).not.toHaveBeenCalled();
});
it("should not set button semantics when onClick is not provided", () => {
const vm = new DisambiguatedProfileViewModel({
displayName: "Static User",
});
render(<DisambiguatedProfileView vm={vm} />);
const profileContainer = getProfileContainer("Static User");
expect(profileContainer).not.toHaveAttribute("role");
expect(profileContainer).not.toHaveAttribute("tabIndex");
});
it("should display tooltip title when provided", () => {
const vm = new DisambiguatedProfileViewModel({
displayName: "User With Tooltip",
title: "User With Tooltip (@user:example.org)",
});
render(<DisambiguatedProfileView vm={vm} />);
expect(screen.getByText("User With Tooltip").closest("div")).toHaveAttribute(
"title",
"User With Tooltip (@user:example.org)",
);
});
it("should apply color class when provided", () => {
const vm = new DisambiguatedProfileViewModel({
displayName: "Colored User",
colorClass: "mx_Username_color3",
});
render(<DisambiguatedProfileView vm={vm} />);
const displayNameElement = screen.getByText("Colored User");
expect(displayNameElement).toHaveClass("mx_Username_color3");
});
it("should apply emphasis styling when emphasizeDisplayName is true", () => {
const vm = new DisambiguatedProfileViewModel({
displayName: "Emphasized User",
emphasizeDisplayName: true,
});
render(<DisambiguatedProfileView vm={vm} />);
const displayNameElement = screen.getByText("Emphasized User");
expect(displayNameElement).toHaveClass("mx_DisambiguatedProfile_displayName");
});
it("should apply custom className to the profile container", () => {
const vm = new DisambiguatedProfileViewModel({
displayName: "Classed User",
});
render(<DisambiguatedProfileView vm={vm} className="custom-profile another-class" />);
const profileContainer = getProfileContainer("Classed User");
expect(profileContainer).toHaveClass("custom-profile", "another-class");
});
});
@@ -0,0 +1,138 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX, type KeyboardEventHandler, type MouseEventHandler } from "react";
import classNames from "classnames";
import { Text, Tooltip } from "@vector-im/compound-web";
import { type ViewModel, useViewModel } from "../../../../../core/viewmodel";
import styles from "./DisambiguatedProfile.module.css";
/**
* The snapshot representing the current state of the DisambiguatedProfile.
*/
export interface DisambiguatedProfileViewSnapshot {
/**
* The display name to show.
*/
displayName: string;
/**
* The CSS class for coloring the display name (e.g., "mx_Username_color1").
* Undefined if coloring is not enabled.
*/
colorClass?: string;
/**
* The formatted user identifier to display when disambiguation is needed.
* Undefined if disambiguation is not required.
*/
displayIdentifier?: string;
/**
* The tooltip title text (pre-translated).
* Undefined if tooltip is not enabled.
*/
title?: string;
/**
* Whether to emphasize the display name with additional styling.
*/
emphasizeDisplayName?: boolean;
/**
* User status message
*/
userStatus?: {
emoji: string;
text: string;
};
}
/**
* Actions that can be performed on the DisambiguatedProfile.
*/
export interface DisambiguatedProfileViewActions {
/**
* Optional click handler for the profile.
*/
onClick?: MouseEventHandler<HTMLDivElement>;
}
/**
* The view model for DisambiguatedProfileView.
*/
export type DisambiguatedProfileViewModel = ViewModel<
DisambiguatedProfileViewSnapshot,
DisambiguatedProfileViewActions
>;
interface DisambiguatedProfileViewProps {
/**
* The view model for the disambiguated profile.
*/
vm: DisambiguatedProfileViewModel;
/**
* Optional CSS class name applied to the profile container.
*/
className?: string;
}
/**
* A component to display a user's profile with optional disambiguation.
* Shows the display name and optionally the MXID when disambiguation is needed
* (e.g., when multiple users have the same display name).
*
* @example
* ```tsx
* <DisambiguatedProfileView vm={disambiguatedProfileViewModel} />
* ```
*/
export function DisambiguatedProfileView({ vm, className }: Readonly<DisambiguatedProfileViewProps>): JSX.Element {
const { displayName, colorClass, displayIdentifier, title, emphasizeDisplayName, userStatus } = useViewModel(vm);
const userStatusEmoji = userStatus && [...new Intl.Segmenter().segment(userStatus.emoji)][0]?.segment;
const displayNameClasses = classNames(colorClass, {
[styles.disambiguatedProfile_displayName]: emphasizeDisplayName,
mx_DisambiguatedProfile_displayName: emphasizeDisplayName,
});
// Handle keyboard interaction for accessibility if onClick is provided
const handleKeyDown: KeyboardEventHandler<HTMLDivElement> | undefined = vm.onClick
? (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
vm.onClick?.(event as unknown as React.MouseEvent<HTMLDivElement>);
}
}
: undefined;
return (
<div
className={classNames(className, styles.disambiguatedProfile)}
title={title}
onClick={vm.onClick}
onKeyDown={handleKeyDown}
role={vm.onClick ? "button" : undefined}
tabIndex={vm.onClick ? 0 : undefined}
>
<span className={displayNameClasses} dir="auto">
{displayName}
</span>
{/* mx_DisambiguatedProfile_mxid is required for PCSS selectors like .mx_MemberTileView .mx_DisambiguatedProfile_mxid */}
{displayIdentifier && (
<span className={classNames("mx_DisambiguatedProfile_mxid", styles.disambiguatedProfile_mxid)}>
{displayIdentifier}
</span>
)}
{userStatus && (
<Tooltip description={userStatus.text}>
<Text as="span" size="md" className={styles.userStatus}>
{userStatusEmoji}
</Text>
</Tooltip>
)}
</div>
);
}
@@ -0,0 +1,120 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`DisambiguatedProfileView > renders the default state 1`] = `
<div>
<div
class="DisambiguatedProfile-module_disambiguatedProfile"
role="button"
tabindex="0"
>
<span
class="DisambiguatedProfile-module_disambiguatedProfile_displayName mx_DisambiguatedProfile_displayName"
dir="auto"
>
Alice
</span>
</div>
</div>
`;
exports[`DisambiguatedProfileView > renders the full example 1`] = `
<div>
<div
class="DisambiguatedProfile-module_disambiguatedProfile"
role="button"
tabindex="0"
title="Eve (@eve:matrix.org)"
>
<span
class="mx_Username_color5 DisambiguatedProfile-module_disambiguatedProfile_displayName mx_DisambiguatedProfile_displayName"
dir="auto"
>
Eve
</span>
<span
class="mx_DisambiguatedProfile_mxid DisambiguatedProfile-module_disambiguatedProfile_mxid"
>
@eve:matrix.org
</span>
<span
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 DisambiguatedProfile-module_userStatus"
>
🏝️
</span>
</div>
</div>
`;
exports[`DisambiguatedProfileView > renders with MXID for disambiguation 1`] = `
<div>
<div
class="DisambiguatedProfile-module_disambiguatedProfile"
role="button"
tabindex="0"
>
<span
class="mx_Username_color1 DisambiguatedProfile-module_disambiguatedProfile_displayName mx_DisambiguatedProfile_displayName"
dir="auto"
>
Alice
</span>
<span
class="mx_DisambiguatedProfile_mxid DisambiguatedProfile-module_disambiguatedProfile_mxid"
>
@alice:example.org
</span>
</div>
</div>
`;
exports[`DisambiguatedProfileView > renders with color class 1`] = `
<div>
<div
class="DisambiguatedProfile-module_disambiguatedProfile"
role="button"
tabindex="0"
>
<span
class="mx_Username_color3 DisambiguatedProfile-module_disambiguatedProfile_displayName mx_DisambiguatedProfile_displayName"
dir="auto"
>
Bob
</span>
</div>
</div>
`;
exports[`DisambiguatedProfileView > renders with emphasized display name 1`] = `
<div>
<div
class="DisambiguatedProfile-module_disambiguatedProfile"
role="button"
tabindex="0"
>
<span
class="DisambiguatedProfile-module_disambiguatedProfile_displayName mx_DisambiguatedProfile_displayName"
dir="auto"
>
Charlie
</span>
</div>
</div>
`;
exports[`DisambiguatedProfileView > renders with tooltip 1`] = `
<div>
<div
class="DisambiguatedProfile-module_disambiguatedProfile"
role="button"
tabindex="0"
title="Diana (@diana:example.org)"
>
<span
class="DisambiguatedProfile-module_disambiguatedProfile_displayName mx_DisambiguatedProfile_displayName"
dir="auto"
>
Diana
</span>
</div>
</div>
`;
@@ -0,0 +1,13 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
export {
DisambiguatedProfileView,
type DisambiguatedProfileViewModel,
type DisambiguatedProfileViewSnapshot,
type DisambiguatedProfileViewActions,
} from "./DisambiguatedProfileView";
@@ -0,0 +1,14 @@
/*
* 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.
*/
.content svg[data-state="supported"] {
color: inherit;
}
.content svg[data-state="unsupported"] {
color: var(--cpd-color-icon-critical-primary);
}
@@ -0,0 +1,95 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { EncryptionEventView, EncryptionEventState, type EncryptionEventViewSnapshot } from "./EncryptionEventView";
import { useMockedViewModel } from "../../../../../core/viewmodel/useMockedViewModel";
import { withViewDocs } from "../../../../../../.storybook/withViewDocs";
type EncryptionEventProps = EncryptionEventViewSnapshot;
const EncryptionEventViewWrapperImpl = ({
className,
...rest
}: EncryptionEventProps & { className?: string }): JSX.Element => {
const vm = useMockedViewModel(rest, {});
return <EncryptionEventView vm={vm} className={className} />;
};
const EncryptionEventViewWrapper = withViewDocs(EncryptionEventViewWrapperImpl, EncryptionEventView);
const meta = {
title: "Timeline/Timeline Event/EncryptionEventView",
component: EncryptionEventViewWrapper,
tags: ["autodocs"],
argTypes: {
state: {
options: Object.entries(EncryptionEventState)
.filter(([key, value]) => key === value)
.map(([key]) => key),
control: { type: "select" },
},
},
args: {
state: EncryptionEventState.ENABLED,
encryptedStateEvents: false,
userName: "Alice",
className: "",
},
} satisfies Meta<typeof EncryptionEventViewWrapper>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const StateEncryptionEnabled: Story = {
args: {
state: EncryptionEventState.ENABLED,
encryptedStateEvents: true,
},
};
export const ParametersChanged: Story = {
args: {
state: EncryptionEventState.CHANGED,
},
};
export const DisableAttempt: Story = {
args: {
state: EncryptionEventState.DISABLE_ATTEMPT,
},
};
export const EnabledDirectMessage: Story = {
args: {
state: EncryptionEventState.ENABLED_DM,
userName: "Alice",
},
};
export const EnabledLocalRoom: Story = {
args: {
state: EncryptionEventState.ENABLED_LOCAL,
},
};
export const Unsupported: Story = {
args: {
state: EncryptionEventState.UNSUPPORTED,
},
};
export const WithTimestamp: Story = {
args: {
state: EncryptionEventState.ENABLED,
timestamp: <span>14:56</span>,
},
};
@@ -0,0 +1,149 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { render, screen } from "@test-utils";
import { composeStories } from "@storybook/react-vite";
import { describe, it, expect } from "vitest";
import React from "react";
import { EncryptionEventState, EncryptionEventView } from "./EncryptionEventView";
import * as stories from "./EncryptionEventView.stories";
import { MockViewModel } from "../../../../../core/viewmodel";
const {
Default,
StateEncryptionEnabled,
ParametersChanged,
DisableAttempt,
EnabledDirectMessage,
EnabledLocalRoom,
Unsupported,
WithTimestamp,
} = composeStories(stories);
describe("EncryptionEventView", () => {
const renderView = (
state: EncryptionEventState,
encryptedStateEvents?: boolean,
userName?: string,
className?: string,
): void => {
const vm = new MockViewModel({
state,
encryptedStateEvents,
userName,
});
render(<EncryptionEventView vm={vm} className={className} />);
};
it("renders Default story", () => {
const { container } = render(<Default />);
expect(container).toMatchSnapshot();
});
it("renders StateEncryptionEnabled story", () => {
const { container } = render(<StateEncryptionEnabled />);
expect(container).toMatchSnapshot();
});
it("renders ParametersChanged story", () => {
const { container } = render(<ParametersChanged />);
expect(container).toMatchSnapshot();
});
it("renders DisableAttempt story", () => {
const { container } = render(<DisableAttempt />);
expect(container).toMatchSnapshot();
});
it("renders EnabledDirectMessage story", () => {
const { container } = render(<EnabledDirectMessage />);
expect(container).toMatchSnapshot();
});
it("renders EnabledLocalRoom story", () => {
const { container } = render(<EnabledLocalRoom />);
expect(container).toMatchSnapshot();
});
it("renders Unsupported story", () => {
const { container } = render(<Unsupported />);
expect(container).toMatchSnapshot();
});
it("renders WithTimestamp story", () => {
const { container } = render(<WithTimestamp />);
expect(container).toMatchSnapshot();
});
it("shows enabled room encryption copy", () => {
renderView(EncryptionEventState.ENABLED);
expect(screen.getByText("Encryption enabled")).toBeInTheDocument();
expect(
screen.getByText(
"Messages in this room are end-to-end encrypted. When people join, you can verify them in their profile, just tap on their profile picture.",
),
).toBeInTheDocument();
});
it("shows enabled state encryption copy", () => {
renderView(EncryptionEventState.ENABLED, true);
expect(screen.getByText("Experimental state encryption enabled")).toBeInTheDocument();
expect(
screen.getByText(
"Messages and state events in this room are end-to-end encrypted. When people join, you can verify them in their profile, just tap on their profile picture.",
),
).toBeInTheDocument();
});
it("shows changed encryption parameters copy", () => {
renderView(EncryptionEventState.CHANGED);
expect(screen.getByText("Encryption enabled")).toBeInTheDocument();
expect(screen.getByText("Some encryption parameters have been changed.")).toBeInTheDocument();
});
it("shows disable attempt copy", () => {
renderView(EncryptionEventState.DISABLE_ATTEMPT);
expect(screen.getByText("Encryption enabled")).toBeInTheDocument();
expect(screen.getByText("Ignored attempt to disable encryption")).toBeInTheDocument();
});
it("shows unsupported encryption copy", () => {
renderView(EncryptionEventState.UNSUPPORTED);
expect(screen.getByText("Encryption not enabled")).toBeInTheDocument();
expect(screen.getByText("The encryption used by this room isn't supported.")).toBeInTheDocument();
});
it("shows local room encryption copy", () => {
renderView(EncryptionEventState.ENABLED_LOCAL);
expect(screen.getByText("Encryption enabled")).toBeInTheDocument();
expect(screen.getByText("Messages in this chat will be end-to-end encrypted.")).toBeInTheDocument();
});
it("shows dm room encryption copy with display name", () => {
renderView(EncryptionEventState.ENABLED_DM, false, "Alice");
expect(screen.getByText("Encryption enabled")).toBeInTheDocument();
expect(
screen.getByText(
"Messages here are end-to-end encrypted. Verify Alice in their profile - tap on their profile picture.",
),
).toBeInTheDocument();
});
it("renders additional class name on the event tile bubble", () => {
renderView(EncryptionEventState.ENABLED, false, undefined, "custom-class");
expect(screen.getByText("Encryption enabled").parentElement).toHaveClass("custom-class");
});
});
@@ -0,0 +1,124 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX } from "react";
import { LockSolidIcon, ErrorSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import classNames from "classnames";
import { type ViewModel, useViewModel } from "../../../../../core/viewmodel";
import styles from "./EncryptionEventView.module.css";
import { useI18n } from "../../../../../core/i18n/i18nContext";
import { EventTileBubble } from "../EventTileBubble";
export enum EncryptionEventState {
/** Encryption settings changed while encryption stayed enabled. */
CHANGED = "CHANGED",
/** Someone attempted to disable encryption in an encrypted room. */
DISABLE_ATTEMPT = "DISABLE_ATTEMPT",
/** Encryption was enabled in a regular room. */
ENABLED = "ENABLED",
/** Encryption was enabled in a DM room. */
ENABLED_DM = "ENABLED_DM",
/** Encryption was enabled in a local room. */
ENABLED_LOCAL = "ENABLED_LOCAL",
/** Encryption is unavailable/unsupported for this event context. */
UNSUPPORTED = "UNSUPPORTED",
}
export type EncryptionEventViewSnapshot = {
/** Which encryption event variant to render. */
state: EncryptionEventState;
/** Whether state-event encryption messaging should be shown. */
encryptedStateEvents?: boolean;
/** Display name for DM partner, used by ENABLED_DM subtitle text. */
userName?: string;
/** Optional timestamp element rendered in the EventTileBubble footer slot. */
timestamp?: JSX.Element;
};
/**
* ViewModel contract consumed by {@link EncryptionEventView}.
*/
export type EncryptionEventViewModel = ViewModel<EncryptionEventViewSnapshot>;
export interface EncryptionEventViewProps {
/**
* ViewModel providing the current encryption event snapshot.
*/
vm: ViewModel<EncryptionEventViewSnapshot>;
/**
* Optional CSS classes passed through to EventTileBubble.
*/
className?: string;
/**
* Optional Ref forwarded to the root DOM element.
*/
ref?: React.RefObject<HTMLDivElement>;
}
/**
* Renders a timeline bubble describing an encryption-related room event.
*
* Text and icon are selected from `snapshot.state` with optional context:
* - `encryptedStateEvents` switches to state-event specific wording.
* - `userName` is used for DM-specific subtitle text.
* - `timestamp` renders in the bubble footer slot.
*
* Use `className` for host-level styling, following the default React pattern.
*
* @example
* ```tsx
* <EncryptionEventView vm={encryptionEventVm} className="mx_EncryptionEvent" />
* ```
*/
export function EncryptionEventView({ vm, ref, className }: Readonly<EncryptionEventViewProps>): JSX.Element {
const { translate: _t } = useI18n();
const { state, encryptedStateEvents, userName, timestamp } = useViewModel(vm);
let icon = <LockSolidIcon data-state="supported" />;
let title = encryptedStateEvents ? _t("common|state_encryption_enabled") : _t("common|encryption_enabled");
let subtitle = "";
switch (state) {
case EncryptionEventState.CHANGED:
subtitle = _t("timeline|m.room.encryption|parameters_changed");
break;
case EncryptionEventState.DISABLE_ATTEMPT:
title = _t("common|encryption_enabled");
subtitle = _t("timeline|m.room.encryption|disable_attempt");
break;
case EncryptionEventState.ENABLED:
subtitle = encryptedStateEvents
? _t("timeline|m.room.encryption|state_enabled")
: _t("timeline|m.room.encryption|enabled");
break;
case EncryptionEventState.ENABLED_DM:
subtitle = _t("timeline|m.room.encryption|enabled_dm", { displayName: userName });
break;
case EncryptionEventState.ENABLED_LOCAL:
subtitle = _t("timeline|m.room.encryption|enabled_local");
break;
case EncryptionEventState.UNSUPPORTED:
default:
icon = <ErrorSolidIcon data-state="unsupported" />;
title = _t("timeline|m.room.encryption|disabled");
subtitle = _t("timeline|m.room.encryption|unsupported");
break;
}
return (
<EventTileBubble
icon={icon}
className={classNames(className, styles.content)}
title={title}
subtitle={subtitle}
ref={ref}
>
{timestamp}
</EventTileBubble>
);
}
@@ -0,0 +1,252 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`EncryptionEventView > renders Default story 1`] = `
<div>
<div
class="EventTileBubble-module_container EncryptionEventView-module_content"
>
<svg
data-state="supported"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 22q-.824 0-1.412-.587A1.93 1.93 0 0 1 4 20V10q0-.825.588-1.412A1.93 1.93 0 0 1 6 8h1V6q0-2.075 1.463-3.537Q9.926 1 12 1q2.075 0 3.537 1.463Q17 3.925 17 6v2h1q.824 0 1.413.588Q20 9.175 20 10v10q0 .824-.587 1.413A1.93 1.93 0 0 1 18 22zM9 8h6V6q0-1.25-.875-2.125A2.9 2.9 0 0 0 12 3q-1.25 0-2.125.875A2.9 2.9 0 0 0 9 6z"
/>
</svg>
<div
class="EventTileBubble-module_title"
>
Encryption enabled
</div>
<div
class="EventTileBubble-module_subtitle"
>
Messages in this room are end-to-end encrypted. When people join, you can verify them in their profile, just tap on their profile picture.
</div>
</div>
</div>
`;
exports[`EncryptionEventView > renders DisableAttempt story 1`] = `
<div>
<div
class="EventTileBubble-module_container EncryptionEventView-module_content"
>
<svg
data-state="supported"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 22q-.824 0-1.412-.587A1.93 1.93 0 0 1 4 20V10q0-.825.588-1.412A1.93 1.93 0 0 1 6 8h1V6q0-2.075 1.463-3.537Q9.926 1 12 1q2.075 0 3.537 1.463Q17 3.925 17 6v2h1q.824 0 1.413.588Q20 9.175 20 10v10q0 .824-.587 1.413A1.93 1.93 0 0 1 18 22zM9 8h6V6q0-1.25-.875-2.125A2.9 2.9 0 0 0 12 3q-1.25 0-2.125.875A2.9 2.9 0 0 0 9 6z"
/>
</svg>
<div
class="EventTileBubble-module_title"
>
Encryption enabled
</div>
<div
class="EventTileBubble-module_subtitle"
>
Ignored attempt to disable encryption
</div>
</div>
</div>
`;
exports[`EncryptionEventView > renders EnabledDirectMessage story 1`] = `
<div>
<div
class="EventTileBubble-module_container EncryptionEventView-module_content"
>
<svg
data-state="supported"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 22q-.824 0-1.412-.587A1.93 1.93 0 0 1 4 20V10q0-.825.588-1.412A1.93 1.93 0 0 1 6 8h1V6q0-2.075 1.463-3.537Q9.926 1 12 1q2.075 0 3.537 1.463Q17 3.925 17 6v2h1q.824 0 1.413.588Q20 9.175 20 10v10q0 .824-.587 1.413A1.93 1.93 0 0 1 18 22zM9 8h6V6q0-1.25-.875-2.125A2.9 2.9 0 0 0 12 3q-1.25 0-2.125.875A2.9 2.9 0 0 0 9 6z"
/>
</svg>
<div
class="EventTileBubble-module_title"
>
Encryption enabled
</div>
<div
class="EventTileBubble-module_subtitle"
>
Messages here are end-to-end encrypted. Verify Alice in their profile - tap on their profile picture.
</div>
</div>
</div>
`;
exports[`EncryptionEventView > renders EnabledLocalRoom story 1`] = `
<div>
<div
class="EventTileBubble-module_container EncryptionEventView-module_content"
>
<svg
data-state="supported"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 22q-.824 0-1.412-.587A1.93 1.93 0 0 1 4 20V10q0-.825.588-1.412A1.93 1.93 0 0 1 6 8h1V6q0-2.075 1.463-3.537Q9.926 1 12 1q2.075 0 3.537 1.463Q17 3.925 17 6v2h1q.824 0 1.413.588Q20 9.175 20 10v10q0 .824-.587 1.413A1.93 1.93 0 0 1 18 22zM9 8h6V6q0-1.25-.875-2.125A2.9 2.9 0 0 0 12 3q-1.25 0-2.125.875A2.9 2.9 0 0 0 9 6z"
/>
</svg>
<div
class="EventTileBubble-module_title"
>
Encryption enabled
</div>
<div
class="EventTileBubble-module_subtitle"
>
Messages in this chat will be end-to-end encrypted.
</div>
</div>
</div>
`;
exports[`EncryptionEventView > renders ParametersChanged story 1`] = `
<div>
<div
class="EventTileBubble-module_container EncryptionEventView-module_content"
>
<svg
data-state="supported"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 22q-.824 0-1.412-.587A1.93 1.93 0 0 1 4 20V10q0-.825.588-1.412A1.93 1.93 0 0 1 6 8h1V6q0-2.075 1.463-3.537Q9.926 1 12 1q2.075 0 3.537 1.463Q17 3.925 17 6v2h1q.824 0 1.413.588Q20 9.175 20 10v10q0 .824-.587 1.413A1.93 1.93 0 0 1 18 22zM9 8h6V6q0-1.25-.875-2.125A2.9 2.9 0 0 0 12 3q-1.25 0-2.125.875A2.9 2.9 0 0 0 9 6z"
/>
</svg>
<div
class="EventTileBubble-module_title"
>
Encryption enabled
</div>
<div
class="EventTileBubble-module_subtitle"
>
Some encryption parameters have been changed.
</div>
</div>
</div>
`;
exports[`EncryptionEventView > renders StateEncryptionEnabled story 1`] = `
<div>
<div
class="EventTileBubble-module_container EncryptionEventView-module_content"
>
<svg
data-state="supported"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 22q-.824 0-1.412-.587A1.93 1.93 0 0 1 4 20V10q0-.825.588-1.412A1.93 1.93 0 0 1 6 8h1V6q0-2.075 1.463-3.537Q9.926 1 12 1q2.075 0 3.537 1.463Q17 3.925 17 6v2h1q.824 0 1.413.588Q20 9.175 20 10v10q0 .824-.587 1.413A1.93 1.93 0 0 1 18 22zM9 8h6V6q0-1.25-.875-2.125A2.9 2.9 0 0 0 12 3q-1.25 0-2.125.875A2.9 2.9 0 0 0 9 6z"
/>
</svg>
<div
class="EventTileBubble-module_title"
>
Experimental state encryption enabled
</div>
<div
class="EventTileBubble-module_subtitle"
>
Messages and state events in this room are end-to-end encrypted. When people join, you can verify them in their profile, just tap on their profile picture.
</div>
</div>
</div>
`;
exports[`EncryptionEventView > renders Unsupported story 1`] = `
<div>
<div
class="EventTileBubble-module_container EncryptionEventView-module_content"
>
<svg
data-state="unsupported"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
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
class="EventTileBubble-module_title"
>
Encryption not enabled
</div>
<div
class="EventTileBubble-module_subtitle"
>
The encryption used by this room isn't supported.
</div>
</div>
</div>
`;
exports[`EncryptionEventView > renders WithTimestamp story 1`] = `
<div>
<div
class="EventTileBubble-module_container EncryptionEventView-module_content"
>
<svg
data-state="supported"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 22q-.824 0-1.412-.587A1.93 1.93 0 0 1 4 20V10q0-.825.588-1.412A1.93 1.93 0 0 1 6 8h1V6q0-2.075 1.463-3.537Q9.926 1 12 1q2.075 0 3.537 1.463Q17 3.925 17 6v2h1q.824 0 1.413.588Q20 9.175 20 10v10q0 .824-.587 1.413A1.93 1.93 0 0 1 18 22zM9 8h6V6q0-1.25-.875-2.125A2.9 2.9 0 0 0 12 3q-1.25 0-2.125.875A2.9 2.9 0 0 0 9 6z"
/>
</svg>
<div
class="EventTileBubble-module_title"
>
Encryption enabled
</div>
<div
class="EventTileBubble-module_subtitle"
>
Messages in this room are end-to-end encrypted. When people join, you can verify them in their profile, just tap on their profile picture.
</div>
<span>
14:56
</span>
</div>
</div>
`;
@@ -0,0 +1,13 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
export {
EncryptionEventView,
EncryptionEventState,
type EncryptionEventViewSnapshot,
type EncryptionEventViewModel,
} from "./EncryptionEventView";
@@ -0,0 +1,51 @@
/*
* 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.
*/
.container {
background-color: var(--cpd-color-bg-subtle-secondary);
padding: var(--cpd-space-3x);
border-radius: 8px;
/* Reserve space for external timestamps, but also cap the width */
/* Legacy variable: --MessageTimestamp-width: 46px; /* 8 + 30 (avatar) + 8 */
/* max-width: min(calc(100% - 2 * var(--MessageTimestamp-width)), 600px); */
max-width: min(calc(100% - 2 * 46px), 600px);
box-sizing: border-box;
display: grid;
grid-template-columns: 24px minmax(0, 1fr) min-content min-content;
svg {
position: relative;
grid-column: 1;
grid-row: 1 / 3;
width: 16px;
height: 16px;
content: "";
inset: 0;
mask-repeat: no-repeat;
mask-position: center;
mask-size: contain;
margin-top: var(--cpd-space-1x);
}
.title,
.subtitle {
grid-column: 2;
overflow-wrap: break-word;
min-inline-size: 50px;
}
.title {
font-weight: var(--cpd-font-weight-semibold);
font-size: var(--cpd-font-size-body-md);
grid-row: 1;
}
.subtitle {
font-size: var(--cpd-font-size-body-sm);
grid-row: 2;
}
}
@@ -0,0 +1,53 @@
/*
* 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 { LockSolidIcon, ErrorSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { EventTileBubble } from "./EventTileBubble";
const meta = {
title: "Timeline/Timeline Event/EventTileBubble",
component: EventTileBubble,
tags: ["autodocs"],
args: {
icon: <ErrorSolidIcon />,
title: "Title goes here",
subtitle: "Subtitle goes here",
className: "custom-class",
},
} satisfies Meta<typeof EventTileBubble>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const HasLockSolidIcon: Story = {
args: {
className: undefined,
icon: <LockSolidIcon />,
children: undefined,
},
};
export const HasChildren: Story = {
args: {
className: undefined,
children: <div>children</div>,
},
};
export const IsCryptoEventBubble: Story = {
args: {
className: undefined,
icon: <LockSolidIcon />,
title: "Encryption enabled",
subtitle: "Messages here are end-to-end encrypted. Verify XYZ in their profile - tap on their profile picture.",
},
};
@@ -0,0 +1,37 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { render } from "@test-utils";
import { composeStories } from "@storybook/react-vite";
import { describe, it, expect } from "vitest";
import React from "react";
import * as stories from "./EventTileBubble.stories.tsx";
const { Default, HasLockSolidIcon, HasChildren, IsCryptoEventBubble } = composeStories(stories);
describe("EventTileBubble", () => {
it("renders the event tile bubble", () => {
const { container } = render(<Default />);
expect(container).toMatchSnapshot();
});
it("renders the event tile bubble with icon", () => {
const { container } = render(<HasLockSolidIcon />);
expect(container).toMatchSnapshot();
});
it("renders the event tile bubble with children", () => {
const { container } = render(<HasChildren />);
expect(container).toMatchSnapshot();
});
it("renders the event tile bubble as crypto event bubble", () => {
const { container } = render(<IsCryptoEventBubble />);
expect(container).toMatchSnapshot();
});
});
@@ -0,0 +1,64 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX, type ReactNode } from "react";
import classNames from "classnames";
import styles from "./EventTileBubble.module.css";
export interface EventTileBubbleProps {
/**
* Icon rendered at the start of the bubble.
*/
icon: JSX.Element;
/**
* Main title text for the bubble.
*/
title: string;
/**
* Optional subtitle rendered beneath the title.
*/
subtitle?: ReactNode;
/**
* Optional extra class name for the container.
*/
className?: string;
/**
* Optional children rendered between subtitle and timestamp.
*/
children?: JSX.Element;
/**
* Forwarded ref for the container element.
*/
ref?: React.RefObject<HTMLDivElement>;
}
/**
* EventTileBubble renders a compact event tile with an icon, title, and optional subtitle/content.
*
* @example
* ```tsx
* <EventTileBubble icon={<Icon />} title="Room created" />
* ```
*/
export function EventTileBubble({
icon,
title,
subtitle,
className,
children,
ref,
}: EventTileBubbleProps): JSX.Element {
return (
<div className={classNames(styles.container, className)} ref={ref}>
{icon}
<div className={styles.title}>{title}</div>
{subtitle && <div className={styles.subtitle}>{subtitle}</div>}
{children}
</div>
);
}
@@ -0,0 +1,124 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`EventTileBubble > renders the event tile bubble 1`] = `
<div>
<div
class="EventTileBubble-module_container custom-class"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
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
class="EventTileBubble-module_title"
>
Title goes here
</div>
<div
class="EventTileBubble-module_subtitle"
>
Subtitle goes here
</div>
</div>
</div>
`;
exports[`EventTileBubble > renders the event tile bubble as crypto event bubble 1`] = `
<div>
<div
class="EventTileBubble-module_container"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 22q-.824 0-1.412-.587A1.93 1.93 0 0 1 4 20V10q0-.825.588-1.412A1.93 1.93 0 0 1 6 8h1V6q0-2.075 1.463-3.537Q9.926 1 12 1q2.075 0 3.537 1.463Q17 3.925 17 6v2h1q.824 0 1.413.588Q20 9.175 20 10v10q0 .824-.587 1.413A1.93 1.93 0 0 1 18 22zM9 8h6V6q0-1.25-.875-2.125A2.9 2.9 0 0 0 12 3q-1.25 0-2.125.875A2.9 2.9 0 0 0 9 6z"
/>
</svg>
<div
class="EventTileBubble-module_title"
>
Encryption enabled
</div>
<div
class="EventTileBubble-module_subtitle"
>
Messages here are end-to-end encrypted. Verify XYZ in their profile - tap on their profile picture.
</div>
</div>
</div>
`;
exports[`EventTileBubble > renders the event tile bubble with children 1`] = `
<div>
<div
class="EventTileBubble-module_container"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
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
class="EventTileBubble-module_title"
>
Title goes here
</div>
<div
class="EventTileBubble-module_subtitle"
>
Subtitle goes here
</div>
<div>
children
</div>
</div>
</div>
`;
exports[`EventTileBubble > renders the event tile bubble with icon 1`] = `
<div>
<div
class="EventTileBubble-module_container"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 22q-.824 0-1.412-.587A1.93 1.93 0 0 1 4 20V10q0-.825.588-1.412A1.93 1.93 0 0 1 6 8h1V6q0-2.075 1.463-3.537Q9.926 1 12 1q2.075 0 3.537 1.463Q17 3.925 17 6v2h1q.824 0 1.413.588Q20 9.175 20 10v10q0 .824-.587 1.413A1.93 1.93 0 0 1 18 22zM9 8h6V6q0-1.25-.875-2.125A2.9 2.9 0 0 0 12 3q-1.25 0-2.125.875A2.9 2.9 0 0 0 9 6z"
/>
</svg>
<div
class="EventTileBubble-module_title"
>
Title goes here
</div>
<div
class="EventTileBubble-module_subtitle"
>
Subtitle goes here
</div>
</div>
</div>
`;
@@ -0,0 +1,8 @@
/*
* 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 { EventTileBubble, type EventTileBubbleProps } from "./EventTileBubble";
@@ -0,0 +1,23 @@
/*
* 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.
*/
.pinnedMessageBadge {
position: relative;
display: flex;
align-items: center;
gap: var(--cpd-space-1x);
padding: var(--cpd-space-1x) var(--cpd-space-3x) var(--cpd-space-1x) var(--cpd-space-1x);
font: var(--cpd-font-body-xs-medium);
background-color: var(--cpd-color-bg-subtle-secondary);
color: var(--cpd-color-text-secondary);
border: 1px solid var(--cpd-color-alpha-gray-400);
border-radius: 99px;
svg {
fill: var(--cpd-color-icon-secondary);
}
}
@@ -0,0 +1,25 @@
/*
* 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 { Meta, StoryObj } from "@storybook/react-vite";
import { PinnedMessageBadge } from "./PinnedMessageBadge";
const meta = {
title: "Timeline/Timeline Event/PinnedMessageBadge",
component: PinnedMessageBadge,
args: {
"aria-describedby": "event-tile-description",
"tabIndex": 0,
},
tags: ["autodocs"],
} satisfies Meta<typeof PinnedMessageBadge>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
@@ -0,0 +1,22 @@
/*
* 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 { composeStories } from "@storybook/react-vite";
import { describe, expect, it } from "vitest";
import { render } from "@test-utils";
import * as stories from "./PinnedMessageBadge.stories";
const { Default } = composeStories(stories);
describe("PinnedMessageBadge", () => {
it("renders the default badge", () => {
const { container } = render(<Default />);
expect(container).toMatchSnapshot();
});
});
@@ -0,0 +1,27 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { type HTMLAttributes, type JSX } from "react";
import classNames from "classnames";
import PinIcon from "@vector-im/compound-design-tokens/assets/web/icons/pin-solid";
import styles from "./PinnedMessageBadge.module.css";
import { _t } from "../../../../../core/i18n/i18n";
export type PinnedMessageBadgeProps = HTMLAttributes<HTMLDivElement>;
/**
* A badge indicating that a message is pinned.
*/
export function PinnedMessageBadge({ className, children, ...props }: Readonly<PinnedMessageBadgeProps>): JSX.Element {
return (
<div {...props} className={classNames(styles.pinnedMessageBadge, className)}>
<PinIcon width="16px" height="16px" />
{children ?? _t("room|pinned_message_badge")}
</div>
);
}
@@ -0,0 +1,24 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`PinnedMessageBadge > renders the default badge 1`] = `
<div>
<div
aria-describedby="event-tile-description"
class="PinnedMessageBadge-module_pinnedMessageBadge"
tabindex="0"
>
<svg
fill="currentColor"
height="16px"
viewBox="0 0 24 24"
width="16px"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5.769 2.857A.5.5 0 0 1 6.119 2h11.762a.5.5 0 0 1 .35.857L16.15 4.9a.5.5 0 0 0-.15.357v4.487a.5.5 0 0 0 .15.356l3.7 3.644a.5.5 0 0 1 .15.356v1.4a.5.5 0 0 1-.5.5H13v6a1 1 0 1 1-2 0v-6H4.5a.5.5 0 0 1-.5-.5v-1.4a.5.5 0 0 1 .15-.356l3.7-3.644A.5.5 0 0 0 8 9.744V5.257a.5.5 0 0 0-.15-.357z"
/>
</svg>
Pinned message
</div>
</div>
`;
@@ -0,0 +1,8 @@
/*
* 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 { PinnedMessageBadge, type PinnedMessageBadgeProps } from "./PinnedMessageBadge";
@@ -0,0 +1,25 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type Meta, type StoryObj } from "@storybook/react-vite";
import { TextualEventView as TextualEventComponent } from "./TextualEventView";
import { MockViewModel } from "../../../../../core/viewmodel/MockViewModel";
const meta = {
title: "Timeline/Timeline Event/TextualEventView",
component: TextualEventComponent,
tags: ["autodocs"],
args: {
vm: new MockViewModel({ content: "Dummy textual event text" }),
},
} satisfies Meta<typeof TextualEventComponent>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
@@ -0,0 +1,22 @@
/*
Copyright 2025 New Vector 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 { composeStories } from "@storybook/react-vite";
import { render } from "@test-utils";
import React from "react";
import { describe, it, expect } from "vitest";
import * as stories from "./TextualEventView.stories.tsx";
const { Default } = composeStories(stories);
describe("TextualEventView", () => {
it("renders a textual event", () => {
const { container } = render(<Default />);
expect(container).toMatchSnapshot();
});
});
@@ -0,0 +1,29 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React, { type ReactNode, type JSX } from "react";
import { type ViewModel, useViewModel } from "../../../../../core/viewmodel";
export type TextualEventViewSnapshot = {
content: string | ReactNode;
};
export interface Props {
vm: ViewModel<TextualEventViewSnapshot>;
}
/**
* Renders a plain textual timeline event without message-body decorations.
*
* This view is used for simple informational timeline entries where the
* content is already prepared by the view model.
*/
export function TextualEventView({ vm }: Props): JSX.Element {
const snapshot = useViewModel(vm);
return <div className="mx_TextualEvent">{snapshot.content}</div>;
}
@@ -0,0 +1,11 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`TextualEventView > renders a textual event 1`] = `
<div>
<div
class="mx_TextualEvent"
>
Dummy textual event text
</div>
</div>
`;
@@ -0,0 +1,8 @@
/*
Copyright 2025 New Vector 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 { TextualEventView, type TextualEventViewSnapshot } from "./TextualEventView";
@@ -0,0 +1,38 @@
.tileErrorView {
color: var(--cpd-color-text-critical-primary);
list-style: none;
text-align: center;
}
.line {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: var(--cpd-space-2x);
}
.bubble .line {
flex-direction: column;
}
.message {
padding: var(--cpd-space-1x) var(--cpd-space-4x);
}
.viewSourceButton {
appearance: none;
padding: 0;
border: 0;
background: none;
color: var(--cpd-color-text-action-primary);
cursor: pointer;
font: inherit;
text-decoration: underline;
}
.viewSourceButton:focus-visible {
outline: 2px solid var(--cpd-color-border-focused);
outline-offset: 2px;
border-radius: var(--cpd-space-1x);
}
@@ -0,0 +1,76 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { type ComponentProps, type JSX } from "react";
import { fn } from "storybook/test";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { withViewDocs } from "../../../../../../.storybook/withViewDocs";
import { useMockedViewModel } from "../../../../../core/viewmodel";
import { EventPresentationProvider } from "../../../EventPresentation";
import { TileErrorView, type TileErrorViewActions, type TileErrorViewSnapshot } from "./TileErrorView";
type WrapperProps = TileErrorViewSnapshot &
Partial<TileErrorViewActions> &
Omit<ComponentProps<typeof TileErrorView>, "vm">;
const TileErrorViewWrapperImpl = ({
className,
onBugReportClick = fn(),
onViewSourceClick = fn(),
...snapshotProps
}: WrapperProps): JSX.Element => {
const vm = useMockedViewModel(snapshotProps, {
onBugReportClick,
onViewSourceClick,
});
return <TileErrorView vm={vm} className={className} />;
};
const TileErrorViewWrapper = withViewDocs(TileErrorViewWrapperImpl, TileErrorView);
const meta = {
title: "Timeline/Timeline Event/TileErrorView",
component: TileErrorViewWrapper,
tags: ["autodocs"],
decorators: [
(Story): JSX.Element => (
<ul>
<Story />
</ul>
),
],
args: {
message: "Can't load this message",
eventType: "m.room.message",
bugReportCtaLabel: "Submit debug logs",
viewSourceCtaLabel: "View source",
},
} satisfies Meta<typeof TileErrorViewWrapper>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const BubbleLayout: Story = {
decorators: [
(Story): JSX.Element => (
<EventPresentationProvider value={{ layout: "bubble", density: "default" }}>
<Story />
</EventPresentationProvider>
),
],
};
export const WithoutActions: Story = {
args: {
bugReportCtaLabel: undefined,
viewSourceCtaLabel: undefined,
},
};
@@ -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, { type MouseEventHandler } from "react";
import { composeStories } from "@storybook/react-vite";
import userEvent from "@testing-library/user-event";
import { render, screen } from "@test-utils";
import { describe, expect, it, vi } from "vitest";
import { MockViewModel } from "../../../../../core/viewmodel";
import {
TileErrorView,
type TileErrorViewActions,
type TileErrorViewModel,
type TileErrorViewSnapshot,
} from "./TileErrorView";
import * as stories from "./TileErrorView.stories";
const { Default, BubbleLayout, WithoutActions } = composeStories(stories);
describe("TileErrorView", () => {
it("renders the default tile error state", () => {
const { container } = render(<Default />);
expect(container).toMatchSnapshot();
});
it("renders the bubble layout variant", () => {
const { container } = render(<BubbleLayout />);
expect(container).toMatchSnapshot();
});
it("renders the fallback text without actions", () => {
render(<WithoutActions />);
expect(screen.getByText("Can't load this message (m.room.message)")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Submit debug logs" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "View source" })).not.toBeInTheDocument();
});
it("invokes bug-report and view-source actions", async () => {
const user = userEvent.setup();
const onBugReportClick = vi.fn();
const onViewSourceClick = vi.fn();
class TestTileErrorViewModel extends MockViewModel<TileErrorViewSnapshot> implements TileErrorViewActions {
public onBugReportClick?: MouseEventHandler<HTMLButtonElement>;
public onViewSourceClick?: MouseEventHandler<HTMLButtonElement>;
public constructor(snapshot: TileErrorViewSnapshot, actions: TileErrorViewActions) {
super(snapshot);
Object.assign(this, actions);
}
}
const vm = new TestTileErrorViewModel(
{
message: "Can't load this message",
eventType: "m.room.message",
bugReportCtaLabel: "Submit debug logs",
viewSourceCtaLabel: "View source",
},
{
onBugReportClick,
onViewSourceClick,
},
) as TileErrorViewModel;
render(<TileErrorView vm={vm} />);
await user.click(screen.getByRole("button", { name: "Submit debug logs" }));
await user.click(screen.getByRole("button", { name: "View source" }));
expect(onBugReportClick).toHaveBeenCalledTimes(1);
expect(onViewSourceClick).toHaveBeenCalledTimes(1);
});
it("applies a custom className to the root element", () => {
const vm = new MockViewModel<TileErrorViewSnapshot>({
message: "Can't load this message",
}) as TileErrorViewModel;
render(<TileErrorView vm={vm} className="custom-tile-error" />);
expect(screen.getByRole("status").closest("li")).toHaveClass("custom-tile-error");
});
});
@@ -0,0 +1,84 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX, type MouseEventHandler } from "react";
import classNames from "classnames";
import { Button } from "@vector-im/compound-web";
import { type ViewModel, useViewModel } from "../../../../../core/viewmodel";
import { useEventPresentation } from "../../../EventPresentation";
import styles from "./TileErrorView.module.css";
/** Snapshot data for rendering an event tile error fallback. */
export interface TileErrorViewSnapshot {
/** Primary fallback text shown when a tile fails to render. */
message: string;
/** Optional event type appended to the fallback text. */
eventType?: string;
/** Optional label for the bug-report action button. */
bugReportCtaLabel?: string;
/** Optional label for the view-source action. */
viewSourceCtaLabel?: string;
}
/** User actions emitted by the tile error fallback. */
export interface TileErrorViewActions {
/** Invoked when the bug-report button is clicked. */
onBugReportClick?: MouseEventHandler<HTMLButtonElement>;
/** Invoked when the view-source action is clicked. */
onViewSourceClick?: MouseEventHandler<HTMLButtonElement>;
}
/** View model contract for the tile error fallback. */
export type TileErrorViewModel = ViewModel<TileErrorViewSnapshot, TileErrorViewActions>;
interface TileErrorViewProps {
/**
* The view model for the tile error fallback.
*/
vm: TileErrorViewModel;
/**
* Optional host-level class names.
*/
className?: string;
}
/**
* Renders a timeline tile fallback when message content cannot be displayed.
*
* The component shows the fallback error message from the view model, optionally
* appends the event type in parentheses, and can render bug-report and view-source
* actions when their labels are provided.
*/
export function TileErrorView({ vm, className }: Readonly<TileErrorViewProps>): JSX.Element {
const { layout } = useEventPresentation();
const { message, eventType, bugReportCtaLabel, viewSourceCtaLabel } = useViewModel(vm);
return (
<li
className={classNames(styles.tileErrorView, className, { [styles.bubble]: layout === "bubble" })}
data-layout={layout}
>
<div className={styles.line} role="status">
<span className={styles.message}>
{message}
{eventType && ` (${eventType})`}
</span>
{bugReportCtaLabel && (
<Button kind="secondary" size="md" onClick={vm.onBugReportClick}>
{bugReportCtaLabel}
</Button>
)}
{viewSourceCtaLabel && (
<button type="button" className={styles.viewSourceButton} onClick={vm.onViewSourceClick}>
{viewSourceCtaLabel}
</button>
)}
</div>
</li>
);
}
@@ -0,0 +1,77 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`TileErrorView > renders the bubble layout variant 1`] = `
<div>
<ul>
<li
class="TileErrorView-module_tileErrorView TileErrorView-module_bubble"
data-layout="bubble"
>
<div
class="TileErrorView-module_line"
role="status"
>
<span
class="TileErrorView-module_message"
>
Can't load this message
(m.room.message)
</span>
<button
class="_button_1nw83_8"
data-kind="secondary"
data-size="md"
role="button"
tabindex="0"
>
Submit debug logs
</button>
<button
class="TileErrorView-module_viewSourceButton"
type="button"
>
View source
</button>
</div>
</li>
</ul>
</div>
`;
exports[`TileErrorView > renders the default tile error state 1`] = `
<div>
<ul>
<li
class="TileErrorView-module_tileErrorView"
data-layout="group"
>
<div
class="TileErrorView-module_line"
role="status"
>
<span
class="TileErrorView-module_message"
>
Can't load this message
(m.room.message)
</span>
<button
class="_button_1nw83_8"
data-kind="secondary"
data-size="md"
role="button"
tabindex="0"
>
Submit debug logs
</button>
<button
class="TileErrorView-module_viewSourceButton"
type="button"
>
View source
</button>
</div>
</li>
</ul>
</div>
`;
@@ -0,0 +1,13 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
export {
TileErrorView,
type TileErrorViewActions,
type TileErrorViewModel,
type TileErrorViewSnapshot,
} from "./TileErrorView";
@@ -0,0 +1,123 @@
/*
* 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.
*/
button.preview {
background: #fff;
}
.preview {
display: flex;
position: relative;
width: 100%;
height: 200px;
background-size: cover;
background-position: center;
border: none;
padding: 0;
> img {
width: 100%;
object-fit: cover;
}
.playButton[data-kind="primary"] {
padding: 0;
width: 50px;
height: 50px;
margin: auto;
background: var(--cpd-color-text-on-solid-primary);
> svg {
margin: auto;
border-radius: 50px;
color: var(--cpd-color-icon-primary);
}
}
}
.container {
max-width: 478px;
display: flex;
border: 1px solid var(--cpd-color-bg-subtle-secondary);
border-radius: 12px; /* Get radius from cpd */
flex-direction: column;
color: var(--cpd-color-gray-900);
overflow: clip;
background: var(--cpd-color-bg-subtle-secondary);
&.inline {
flex-direction: row;
gap: var(--cpd-space-4x);
padding: var(--cpd-space-3x) var(--cpd-space-4x);
.title {
margin: 0;
}
.siteAvatar {
margin: auto 0;
}
.siteName {
margin: 0;
}
}
.textContent {
padding: var(--cpd-space-3x) var(--cpd-space-4x);
&.inline {
padding: 0;
}
display: flex;
flex-direction: column;
gap: var(--cpd-space-1x);
}
.caption {
display: inline-flex;
flex-direction: column;
min-width: 0; /* Prevent blowout */
}
.caption {
flex: 1;
overflow: hidden; /* cause it to wrap rather than clip */
}
.title,
.description {
display: inline-block;
-webkit-box-orient: vertical;
overflow: hidden;
white-space: normal;
}
.title {
display: inline-block;
line-clamp: 2;
-webkit-line-clamp: 2;
color: var(--cpd-color-text-primary);
text-decoration-line: none;
}
.description {
margin: 0;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
overflow: hidden;
}
.siteName {
margin-top: var(--cpd-space-1x);
vertical-align: middle;
display: flex;
gap: var(--cpd-space-1-5x);
> * {
/* Center everything */
margin: auto 0;
}
}
}
@@ -0,0 +1,169 @@
/*
* 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 { fn } from "storybook/test";
import type { Meta, StoryFn } from "@storybook/react-vite";
import { LinkPreview } from "./LinkPreview";
import { LinkedTextContext } from "../../../../../core/utils/LinkedText";
import imageFile from "../../../../../../static/element.png";
import imageFileWide from "../../../../../../static/wideImage.png";
export default {
title: "EventTiles/LinkPreview",
component: LinkPreview,
tags: ["autodocs"],
args: {
onImageClick: fn(),
},
argTypes: {
siteName: {
control: "text",
},
author: {
control: "text",
},
siteIcon: { control: { type: "file", accept: ".png" } },
image: {},
},
parameters: {
design: {
type: "figma",
url: "https://www.figma.com/design/sI9A2kV2K4xeiyqJsL7Ey3/Link-Previews?node-id=87-7920",
},
},
} satisfies Meta<typeof LinkPreview>;
const Template: StoryFn<typeof LinkPreview> = (args) => (
<LinkedTextContext.Provider value={{}}>
<LinkPreview {...args} />
</LinkedTextContext.Provider>
);
export const Default = Template.bind({});
Default.args = {
title: "A simple title",
description: "A simple description",
link: "https://matrix.org",
siteName: "Site name",
image: {
imageThumb: imageFile,
imageFull: imageFile,
alt: "Element logo",
playable: false,
},
};
export const Title = Template.bind({});
Title.args = {
title: "A simple title",
link: "https://matrix.org",
siteName: "matrix.org",
};
export const TitleAndDescription = Template.bind({});
TitleAndDescription.args = {
title: "A simple title",
description: "A simple description with a link to https://matrix.org",
link: "https://matrix.org",
siteName: "matrix.org",
};
export const WithSiteIcon = Template.bind({});
WithSiteIcon.args = {
title: "A simple title",
link: "https://matrix.org",
siteName: "matrix.org",
siteIcon: imageFile,
};
export const WithSiteIconAndDescription = Template.bind({});
WithSiteIconAndDescription.args = {
title: "A simple title",
description: "A simple description with a link to https://matrix.org",
link: "https://matrix.org",
siteName: "matrix.org",
siteIcon: imageFile,
};
export const WithTooltip = Template.bind({});
WithTooltip.args = {
title: "A simple title",
description: "A simple description",
showTooltipOnLink: true,
link: "https://matrix.org",
siteName: "matrix.org",
};
export const Article = Template.bind({});
Article.args = {
title: "A linked article",
description:
"This is a basic description returned from the linked source, usually with a word or two about what the link contains.",
link: "https://matrix.org",
siteName: "blog.example.org",
image: {
imageThumb: imageFileWide,
imageFull: imageFileWide,
alt: "A dog",
playable: false,
},
};
export const Video = Template.bind({});
Video.args = {
title: "A linked video",
description:
"This is a link to a video. You cannot play the video inline yet, but you can click the play button to open the link",
link: "https://matrix.org",
siteName: "blog.example.org",
image: {
imageThumb: imageFileWide,
imageFull: imageFileWide,
alt: "A dog",
playable: true,
},
};
export const Social = Template.bind({});
Social.args = {
description: "Sending a small message",
link: "https://matrix.org",
siteName: "socialsite.example.org",
title: "Test user (@test)",
author: "@test",
};
export const SocialWithImage = Template.bind({});
SocialWithImage.args = {
description: "Sending a message with an attached image.",
title: "Test user (@test)",
link: "https://matrix.org",
siteName: "socialsite.example.org",
author: "@test",
image: {
imageThumb: imageFileWide,
imageFull: imageFileWide,
alt: "A dog",
playable: false,
},
};
export const WithVeryLongText = Template.bind({});
WithVeryLongText.args = {
title: "GitHub - element-hq/not-a-real-repo: A very very long PR title that should be rendered nicely",
description:
"This PR doesn't actually exist and neither does the repository. It might exist one day if we go into the business of making paradoxical repository names.",
link: "https://matrix.org",
siteName: "GitHub",
image: {
imageThumb: imageFile,
imageFull: imageFile,
alt: "Element logo",
playable: false,
},
};
@@ -0,0 +1,45 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { render, screen } from "@test-utils";
import { composeStories } from "@storybook/react-vite";
import { describe, it, expect } from "vitest";
import React from "react";
import userEvent from "@testing-library/user-event";
import * as stories from "./LinkPreview.stories.tsx";
const { Default, WithTooltip, Title, TitleAndDescription, Video } = composeStories(stories);
describe("LinkPreview", () => {
it("renders a preview", () => {
const { container } = render(<Default />);
expect(container).toMatchSnapshot();
});
it("renders a preview with just a title", () => {
const { container } = render(<Title />);
expect(container).toMatchSnapshot();
});
it("renders a preview with just a title and description", () => {
const { container } = render(<TitleAndDescription />);
expect(container).toMatchSnapshot();
});
it("renders a preview with a tooltip", async () => {
const user = userEvent.setup();
render(<WithTooltip />);
await user.tab();
expect(screen.getByText("A simple title")).toHaveFocus();
// Tooltip has the URL
expect(await screen.findByText("https://matrix.org/")).toBeVisible();
});
it("renders a playable preview that can be opened with a click", () => {
const { container } = render(<Video />);
expect(container).toMatchSnapshot();
const button = screen.getByLabelText("Open link");
expect(button).toHaveAttribute("href", "https://matrix.org");
});
});
@@ -0,0 +1,165 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { type MouseEventHandler, type JSX, useCallback } from "react";
import { Tooltip, Text, Avatar, Button } from "@vector-im/compound-web";
import PlaySolidIcon from "@vector-im/compound-design-tokens/assets/web/icons/play-solid";
import classNames from "classnames";
import { useI18n } from "../../../../../core/i18n/i18nContext";
import type { UrlPreview } from "../types";
import { LinkedText } from "../../../../../core/utils/LinkedText";
import styles from "./LinkPreview.module.css";
export interface LinkPreviewActions {
onImageClick: () => void;
}
export type LinkPreviewProps = UrlPreview & LinkPreviewActions;
function LinkTitle({
title,
showTooltipOnLink,
link,
}: Pick<LinkPreviewProps, "title" | "showTooltipOnLink" | "link">): JSX.Element {
const caption = new URL(link).toString();
const anchor = (
<Text
as="a"
type="body"
weight="semibold"
size="md"
className={styles.title}
href={link}
target="_blank"
rel="noreferrer noopener"
>
{title}
</Text>
);
return showTooltipOnLink ? <Tooltip label={caption}>{anchor}</Tooltip> : anchor;
}
function LinkSiteName({ siteIcon, siteName }: { siteIcon?: string; siteName: string }): JSX.Element {
return (
<div className={styles.siteName}>
{siteIcon && <Avatar size="16px" name={siteName} id={siteName} src={siteIcon} />}
<Text as="span" size="sm" weight="regular">
{siteName}
</Text>
</div>
);
}
/**
* A condensed link preview that only contains the site icon, the title of the link and the site name.
*/
function LinkPreviewInline({
title,
showTooltipOnLink,
siteIcon,
siteName,
link,
}: Omit<LinkPreviewProps, "image" | "description" | "author" | "onImageClick">): JSX.Element {
return (
<div className={classNames(styles.container, styles.inline)}>
{siteIcon && (
<div className={styles.siteAvatar}>
<Avatar type="square" size="48px" name={title} id={title} src={siteIcon} />
</div>
)}
<div className={classNames(styles.textContent, styles.inline)}>
<LinkTitle title={title} showTooltipOnLink={showTooltipOnLink} link={link} />
{siteName && <LinkSiteName siteName={siteName} />}
</div>
</div>
);
}
/**
* LinkPreview renders a single preview component for a single link on an event. It is usually rendered as part of
* a `UrlPreviewGroupView`.
*/
export function LinkPreview({ onImageClick, ...preview }: LinkPreviewProps): JSX.Element {
const { translate: _t } = useI18n();
const onImageClickHandler = useCallback<MouseEventHandler>(
(ev) => {
if (ev.button != 0 || ev.metaKey) return;
ev.preventDefault();
if (!preview.image?.imageFull) {
return;
}
onImageClick();
},
[preview.image?.imageFull, onImageClick],
);
if (!preview.image && !preview.author && !preview.description) {
return <LinkPreviewInline {...preview} />;
}
let img: JSX.Element | undefined;
if (preview.image) {
if (preview.image.playable) {
// Playable media do not have clickable images so we don't
// overlay buttons atop buttons, instead we render a
// button for them to open the media.
img = (
<div
style={{
backgroundImage: `url('${preview.image.imageThumb}')`,
}}
className={styles.preview}
>
<Button
as="a"
href={preview.link}
aria-label={_t("timeline|url_preview|open_link")}
className={styles.playButton}
target="_blank"
rel="noreferrer noopener"
kind="primary"
>
<PlaySolidIcon width="24px" height="24px" />
</Button>
</div>
);
} else {
// Otherwise, the preview can be clicked on.
img = (
<button
className={styles.preview}
onClick={onImageClickHandler}
aria-label={_t("timeline|url_preview|view_image")}
>
<img src={preview.image.imageThumb} alt={preview.image.alt} title={preview.image.alt} />
</button>
);
}
}
return (
<div className={styles.container}>
{img}
<div className={styles.textContent}>
{preview.author && (
<Text as="span" size="md" weight="semibold">
{preview.author}
</Text>
)}
<LinkTitle title={preview.title} showTooltipOnLink={preview.showTooltipOnLink} link={preview.link} />
<LinkedText type="body" size="md" className={styles.description}>
{preview.description}
</LinkedText>
{preview.siteName && <LinkSiteName siteName={preview.siteName} siteIcon={preview.siteIcon} />}
</div>
</div>
);
}
@@ -0,0 +1,182 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`LinkPreview > renders a playable preview that can be opened with a click 1`] = `
<div>
<div
class="LinkPreview-module_container"
>
<div
class="LinkPreview-module_preview"
style="background-image: url("/static/wideImage.png");"
>
<a
aria-label="Open link"
class="_button_1nw83_8 LinkPreview-module_playButton"
data-kind="primary"
data-size="lg"
href="https://matrix.org"
rel="noreferrer noopener"
role="link"
tabindex="0"
target="_blank"
>
<svg
fill="currentColor"
height="24px"
viewBox="0 0 24 24"
width="24px"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m8.98 4.677 9.921 5.58c1.36.764 1.36 2.722 0 3.486l-9.92 5.58C7.647 20.073 6 19.11 6 17.58V6.42c0-1.53 1.647-2.493 2.98-1.743"
/>
</svg>
</a>
</div>
<div
class="LinkPreview-module_textContent"
>
<a
class="_typography_6v6n8_153 _font-body-md-semibold_6v6n8_55 LinkPreview-module_title"
href="https://matrix.org"
rel="noreferrer noopener"
target="_blank"
>
A linked video
</a>
<p
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 LinkedText-module_container LinkPreview-module_description"
>
This is a link to a video. You cannot play the video inline yet, but you can click the play button to open the link
</p>
<div
class="LinkPreview-module_siteName"
>
<span
class="_typography_6v6n8_153 _font-body-sm-regular_6v6n8_31"
>
blog.example.org
</span>
</div>
</div>
</div>
</div>
`;
exports[`LinkPreview > renders a preview 1`] = `
<div>
<div
class="LinkPreview-module_container"
>
<button
aria-label="View image"
class="LinkPreview-module_preview"
>
<img
alt="Element logo"
src="/static/element.png"
title="Element logo"
/>
</button>
<div
class="LinkPreview-module_textContent"
>
<a
class="_typography_6v6n8_153 _font-body-md-semibold_6v6n8_55 LinkPreview-module_title"
href="https://matrix.org"
rel="noreferrer noopener"
target="_blank"
>
A simple title
</a>
<p
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 LinkedText-module_container LinkPreview-module_description"
>
A simple description
</p>
<div
class="LinkPreview-module_siteName"
>
<span
class="_typography_6v6n8_153 _font-body-sm-regular_6v6n8_31"
>
Site name
</span>
</div>
</div>
</div>
</div>
`;
exports[`LinkPreview > renders a preview with just a title 1`] = `
<div>
<div
class="LinkPreview-module_container LinkPreview-module_inline"
>
<div
class="LinkPreview-module_textContent LinkPreview-module_inline"
>
<a
class="_typography_6v6n8_153 _font-body-md-semibold_6v6n8_55 LinkPreview-module_title"
href="https://matrix.org"
rel="noreferrer noopener"
target="_blank"
>
A simple title
</a>
<div
class="LinkPreview-module_siteName"
>
<span
class="_typography_6v6n8_153 _font-body-sm-regular_6v6n8_31"
>
matrix.org
</span>
</div>
</div>
</div>
</div>
`;
exports[`LinkPreview > renders a preview with just a title and description 1`] = `
<div>
<div
class="LinkPreview-module_container"
>
<div
class="LinkPreview-module_textContent"
>
<a
class="_typography_6v6n8_153 _font-body-md-semibold_6v6n8_55 LinkPreview-module_title"
href="https://matrix.org"
rel="noreferrer noopener"
target="_blank"
>
A simple title
</a>
<p
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 LinkedText-module_container LinkPreview-module_description"
>
A simple description with a link to
<a
data-linkified="true"
href="https://matrix.org"
rel="noreferrer noopener"
target="_blank"
>
https://matrix.org
</a>
</p>
<div
class="LinkPreview-module_siteName"
>
<span
class="_typography_6v6n8_153 _font-body-sm-regular_6v6n8_31"
>
matrix.org
</span>
</div>
</div>
</div>
</div>
`;
@@ -0,0 +1,8 @@
/*
* 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 { LinkPreview } from "./LinkPreview";
@@ -0,0 +1,33 @@
/*
* 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.
*/
.previewGroup {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--cpd-space-4x);
margin-bottom: var(--cpd-space-4x);
&.compactLayout {
gap: var(--cpd-space-2x);
margin-bottom: var(--cpd-space-2x);
}
.toggleButton[data-kind="tertiary"] {
margin-left: auto;
margin-right: auto;
text-decoration: none;
color: var(--cpd-color-icon-accent-primary);
font-weight: var(--cpd-font-weight-regular);
}
}
.wrapper {
margin-top: var(--cpd-space-4x);
display: flex;
flex-direction: row;
}
@@ -0,0 +1,151 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX } from "react";
import { fn } from "storybook/test";
import imageFile from "../../../../../static/element.png";
import tallImageFile from "../../../../../static/tallImage.png";
import type { Meta, StoryFn } from "@storybook/react-vite";
import {
UrlPreviewGroupView,
type UrlPreviewGroupViewActions,
type UrlPreviewGroupViewSnapshot,
} from "./UrlPreviewGroupView";
import { useMockedViewModel } from "../../../../core/viewmodel";
import { LinkedTextContext } from "../../../../core/utils/LinkedText";
import { withViewDocs } from "../../../../../.storybook/withViewDocs";
import { EventPresentationProvider } from "../../EventPresentation";
type UrlPreviewGroupViewProps = UrlPreviewGroupViewSnapshot & UrlPreviewGroupViewActions;
const UrlPreviewGroupViewWrapperImpl = ({
onHideClick,
onImageClick,
onTogglePreviewLimit,
...rest
}: UrlPreviewGroupViewProps): JSX.Element => {
const vm = useMockedViewModel(rest, {
onHideClick,
onImageClick,
onTogglePreviewLimit,
});
return (
<LinkedTextContext.Provider value={{}}>
<UrlPreviewGroupView vm={vm} />
</LinkedTextContext.Provider>
);
};
const UrlPreviewGroupViewWrapper = withViewDocs(UrlPreviewGroupViewWrapperImpl, UrlPreviewGroupView);
export default {
title: "Timeline/Timeline Event/UrlPreviewGroupView",
component: UrlPreviewGroupViewWrapper,
tags: ["autodocs"],
args: {
onHideClick: fn(),
onImageClick: fn(),
onTogglePreviewLimit: fn(),
},
parameters: {
design: {
type: "figma",
url: "https://www.figma.com/design/sI9A2kV2K4xeiyqJsL7Ey3/Link-Previews?node-id=87-7920",
},
},
} satisfies Meta<typeof UrlPreviewGroupViewWrapper>;
const Template: StoryFn<typeof UrlPreviewGroupViewWrapper> = (args) => <UrlPreviewGroupViewWrapper {...args} />;
export const Default = Template.bind({});
Default.args = {
previews: [
{
title: "A simple title",
description: "A simple description",
link: "https://matrix.org",
showTooltipOnLink: false,
siteName: "matrix.org",
image: {
imageThumb: imageFile,
imageFull: imageFile,
alt: "The element logo",
playable: false,
},
},
],
};
export const MultiplePreviewsHidden = Template.bind({});
MultiplePreviewsHidden.args = {
previews: Default.args.previews,
overPreviewLimit: true,
previewsLimited: true,
totalPreviewCount: 10,
};
export const MultiplePreviewsVisible = Template.bind({});
MultiplePreviewsVisible.args = {
previews: [
{
title: "One",
description: "A regular square image.",
link: "https://matrix.org/one",
siteName: "matrix.org",
showTooltipOnLink: false,
image: {
imageThumb: imageFile,
imageFull: imageFile,
alt: "The element logo",
playable: false,
},
},
// These images should appear the same size despite having different dimensions.
{
title: "Two",
description: "This one has a taller image which should crop nicely.",
link: "https://matrix.org/two",
siteName: "matrix.org",
showTooltipOnLink: false,
image: {
imageThumb: tallImageFile,
imageFull: tallImageFile,
alt: "A dog",
playable: false,
},
},
{
title: "Three",
description: "One more description",
link: "https://matrix.org/three",
siteName: "matrix.org",
showTooltipOnLink: false,
image: {
imageThumb: imageFile,
imageFull: imageFile,
alt: "The element logo",
playable: false,
},
},
],
overPreviewLimit: true,
previewsLimited: false,
totalPreviewCount: 10,
};
export const WithCompactView = Template.bind({});
WithCompactView.args = {
...MultiplePreviewsVisible.args,
};
WithCompactView.decorators = [
(Story): JSX.Element => (
<EventPresentationProvider value={{ layout: "group", density: "compact" }}>
<Story />
</EventPresentationProvider>
),
];

Some files were not shown because too many files have changed in this diff Show More