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
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:
+29
@@ -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" } };
|
||||
+21
@@ -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;
|
||||
}
|
||||
}
|
||||
+99
@@ -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;
|
||||
}
|
||||
}
|
||||
+514
@@ -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>
|
||||
);
|
||||
};
|
||||
+205
@@ -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>
|
||||
);
|
||||
}
|
||||
+299
@@ -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";
|
||||
+87
@@ -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,
|
||||
},
|
||||
};
|
||||
+116
@@ -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);
|
||||
});
|
||||
});
|
||||
+197
@@ -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>
|
||||
);
|
||||
};
|
||||
+83
@@ -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";
|
||||
+59
@@ -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>
|
||||
);
|
||||
});
|
||||
+20
@@ -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;
|
||||
}
|
||||
+182
@@ -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);
|
||||
});
|
||||
});
|
||||
+95
@@ -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>
|
||||
);
|
||||
};
|
||||
+47
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+132
@@ -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>
|
||||
);
|
||||
};
|
||||
+25
@@ -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);
|
||||
}
|
||||
}
|
||||
+92
@@ -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",
|
||||
},
|
||||
};
|
||||
+85
@@ -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>
|
||||
);
|
||||
}
|
||||
+142
@@ -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";
|
||||
+39
@@ -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>
|
||||
);
|
||||
}
|
||||
+42
@@ -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";
|
||||
+21
@@ -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);
|
||||
}
|
||||
+59
@@ -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",
|
||||
},
|
||||
};
|
||||
+48
@@ -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;
|
||||
+100
@@ -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";
|
||||
+31
@@ -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;
|
||||
}
|
||||
}
|
||||
+112
@@ -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",
|
||||
},
|
||||
},
|
||||
};
|
||||
+222
@@ -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");
|
||||
});
|
||||
});
|
||||
+138
@@ -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>
|
||||
);
|
||||
}
|
||||
+120
@@ -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>
|
||||
`;
|
||||
+13
@@ -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";
|
||||
+14
@@ -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);
|
||||
}
|
||||
+95
@@ -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>,
|
||||
},
|
||||
};
|
||||
+149
@@ -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");
|
||||
});
|
||||
});
|
||||
+124
@@ -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>
|
||||
);
|
||||
}
|
||||
+252
@@ -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>
|
||||
`;
|
||||
+13
@@ -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";
|
||||
+51
@@ -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;
|
||||
}
|
||||
}
|
||||
+53
@@ -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.",
|
||||
},
|
||||
};
|
||||
+37
@@ -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();
|
||||
});
|
||||
});
|
||||
+64
@@ -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>
|
||||
);
|
||||
}
|
||||
+124
@@ -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>
|
||||
`;
|
||||
+8
@@ -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";
|
||||
+23
@@ -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);
|
||||
}
|
||||
}
|
||||
+25
@@ -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 = {};
|
||||
+22
@@ -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();
|
||||
});
|
||||
});
|
||||
+27
@@ -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>
|
||||
);
|
||||
}
|
||||
+24
@@ -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>
|
||||
`;
|
||||
+8
@@ -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";
|
||||
+1
@@ -0,0 +1 @@
|
||||
|
||||
+25
@@ -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 = {};
|
||||
+22
@@ -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();
|
||||
});
|
||||
});
|
||||
+29
@@ -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>;
|
||||
}
|
||||
+11
@@ -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>
|
||||
`;
|
||||
+8
@@ -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";
|
||||
+1
@@ -0,0 +1 @@
|
||||
|
||||
+38
@@ -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);
|
||||
}
|
||||
+76
@@ -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,
|
||||
},
|
||||
};
|
||||
+90
@@ -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");
|
||||
});
|
||||
});
|
||||
+84
@@ -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>
|
||||
);
|
||||
}
|
||||
+77
@@ -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>
|
||||
`;
|
||||
+13
@@ -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";
|
||||
+123
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+169
@@ -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,
|
||||
},
|
||||
};
|
||||
+45
@@ -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");
|
||||
});
|
||||
});
|
||||
+165
@@ -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>
|
||||
);
|
||||
}
|
||||
+182
@@ -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>
|
||||
`;
|
||||
+8
@@ -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";
|
||||
+33
@@ -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;
|
||||
}
|
||||
+151
@@ -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
Reference in New Issue
Block a user