Merge branch 'develop' of ssh://github.com/element-hq/element-web into t3chguy/monorepo-playwright-common
# Conflicts: # pnpm-lock.yaml
@@ -12,4 +12,13 @@ RUN npm i -g playwright@${PLAYWRIGHT_VERSION}
|
||||
|
||||
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||
|
||||
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||
# We use `docker-init` as PID 1, which means that the container shuts down correctly on SIGTERM.
|
||||
#
|
||||
# (The problem is that PID 1 doesn't get default signal handlers, and
|
||||
# playwright server doesn't register a SIGTERM handler, so if that ends up as
|
||||
# PID 1, then it ignores SIGTERM. Likewise bash doesn't set a SIGTERM handler by default.
|
||||
#
|
||||
# The easiest solution is to use docker-init, which is in fact `tini` (https://github.com/krallin/tini).
|
||||
#
|
||||
# See https://github.com/krallin/tini/issues/8#issuecomment-146135930 for a good explanation of all this.)
|
||||
ENTRYPOINT ["/usr/bin/docker-init", "/docker-entrypoint.sh"]
|
||||
|
||||
@@ -1,4 +1,2 @@
|
||||
#!/bin/bash
|
||||
|
||||
# We use npm here as we used `npm i -g` to install playwright in the Dockerfile
|
||||
npm exec -- playwright run-server --port "$PORT" --host 0.0.0.0
|
||||
exec /usr/bin/playwright run-server --port "$PORT" --host 0.0.0.0
|
||||
|
||||
@@ -24,6 +24,8 @@ type PaginationLinks = {
|
||||
first?: string;
|
||||
};
|
||||
|
||||
const ANSI_COLOUR_REGEX = /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g;
|
||||
|
||||
// We see quite a few test flakes which are caused by the app exploding
|
||||
// so we have some magic strings we check the logs for to better track the flake with its cause
|
||||
const SPECIAL_CASES: Record<string, string> = {
|
||||
@@ -38,18 +40,35 @@ class FlakyReporter implements Reporter {
|
||||
public onTestEnd(test: TestCase): void {
|
||||
// Ignores flakes on Dendrite and Pinecone as they have their own flakes we do not track
|
||||
if (["Dendrite", "Pinecone"].includes(test.parent.project()!.name!)) return;
|
||||
let failures = [`${test.location.file.split("playwright/e2e/")[1]}: ${test.title}`];
|
||||
|
||||
if (test.outcome() === "flaky") {
|
||||
const failures: string[] = [];
|
||||
|
||||
const timedOutRuns = test.results.filter((result) => result.status === "timedOut");
|
||||
const pageLogs = timedOutRuns.flatMap((result) =>
|
||||
result.attachments.filter((attachment) => attachment.name.startsWith("page-")),
|
||||
);
|
||||
|
||||
// If a test failed due to a systemic fault then the test is not flaky, the app is, record it as such.
|
||||
const specialCases = Object.keys(SPECIAL_CASES).filter((log) =>
|
||||
pageLogs.some((attachment) => attachment.name.startsWith("page-") && attachment.body?.includes(log)),
|
||||
);
|
||||
if (specialCases.length > 0) {
|
||||
failures = specialCases.map((specialCase) => SPECIAL_CASES[specialCase]);
|
||||
failures.push(...specialCases.map((specialCase) => SPECIAL_CASES[specialCase]));
|
||||
}
|
||||
|
||||
// Check for fixtures failing to set up
|
||||
const errorMessages = timedOutRuns
|
||||
.map((r) => r.error?.message?.replace(ANSI_COLOUR_REGEX, ""))
|
||||
.filter(Boolean) as string[];
|
||||
for (const error of errorMessages) {
|
||||
if (error.startsWith("Fixture") && error.endsWith("exceeded during setup.")) {
|
||||
failures.push(error);
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length < 1) {
|
||||
failures.push(`${test.location.file.split("playwright/e2e/")[1]}: ${test.title}`);
|
||||
}
|
||||
|
||||
for (const title of failures) {
|
||||
|
||||
@@ -19,8 +19,15 @@ WS_PORT=3000
|
||||
PW_VERSION=$(pnpm --silent -- playwright --version | awk '{print $2}')
|
||||
IMAGE_NAME="ghcr.io/element-hq/element-web/playwright-server:$PW_VERSION"
|
||||
|
||||
# Pull the image, failing that build the image
|
||||
docker pull "$IMAGE_NAME" 2>/dev/null || build_image "$IMAGE_NAME"
|
||||
# If the image exists in the repository, pull it; otherwise, build it.
|
||||
#
|
||||
# (This explicit test gives the user clearer progress info than just
|
||||
# `docker pull 2>/dev/null || build_image`.)
|
||||
if docker manifest inspect "$IMAGE_NAME" &>/dev/null; then
|
||||
docker pull "$IMAGE_NAME"
|
||||
else
|
||||
build_image "$IMAGE_NAME"
|
||||
fi
|
||||
|
||||
# Start the playwright-server in docker
|
||||
CONTAINER=$(docker run --network=host -v /tmp:/tmp --rm -d -e PORT="$WS_PORT" "$IMAGE_NAME")
|
||||
|
||||
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import React from "react";
|
||||
|
||||
import { I18nApi } from "./I18nApi";
|
||||
|
||||
@@ -20,4 +21,21 @@ describe("I18nApi", () => {
|
||||
|
||||
expect(i18n.translate("hello.world" as TranslationKey)).toBe("Hello, World!");
|
||||
});
|
||||
|
||||
it("can register a translation and use it with tags", () => {
|
||||
const i18n = new I18nApi();
|
||||
i18n.register({
|
||||
["hello.world" as TranslationKey]: {
|
||||
en: "Hello, <Bold>World!</Bold>",
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
i18n.translate("hello.world" as TranslationKey, {}, { Bold: (sub) => <strong>{sub}</strong> }),
|
||||
).toStrictEqual(
|
||||
<span>
|
||||
Hello, <strong>World!</strong>
|
||||
</span>,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,12 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type I18nApi as II18nApi, type Variables, type Translations } from "@element-hq/element-web-module-api";
|
||||
import {
|
||||
type I18nApi as II18nApi,
|
||||
type Variables,
|
||||
type Translations,
|
||||
type Tags,
|
||||
} from "@element-hq/element-web-module-api";
|
||||
|
||||
import { humanizeTime } from "../utils/humanize";
|
||||
import { _t, getLocale, registerTranslations } from "./i18n";
|
||||
@@ -41,8 +46,12 @@ export class I18nApi implements II18nApi {
|
||||
* Perform a translation, with optional variables
|
||||
* @param key - The key to translate
|
||||
* @param variables - Optional variables to interpolate into the translation
|
||||
* @param tags - Optional tags to interpolate into the translation
|
||||
*/
|
||||
public translate(this: void, key: TranslationKey, variables?: Variables): string {
|
||||
public translate(this: void, key: TranslationKey, variables?: Variables): string;
|
||||
public translate(this: void, key: TranslationKey, variables: Variables | undefined, tags: Tags): React.ReactNode;
|
||||
public translate(this: void, key: TranslationKey, variables?: Variables, tags?: Tags): React.ReactNode | string {
|
||||
if (tags) return _t(key, variables, tags);
|
||||
return _t(key, variables);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ export * from "./room/timeline/event-tile/body/EventContentBodyView";
|
||||
export * from "./room/timeline/event-tile/body/RedactedBodyView";
|
||||
export * from "./room/timeline/event-tile/body/MFileBodyView";
|
||||
export * from "./room/timeline/event-tile/body/MVideoBodyView";
|
||||
export * from "./room/timeline/event-tile/body/TextualBodyView";
|
||||
export * from "./room/timeline/event-tile/EventTileView/TileErrorView";
|
||||
export * from "./core/pill-input/Pill";
|
||||
export * from "./core/pill-input/PillInput";
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
.root {
|
||||
overflow-y: hidden;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.text,
|
||||
.caption {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.notice {
|
||||
white-space: pre-wrap;
|
||||
color: var(--cpd-color-text-secondary);
|
||||
}
|
||||
|
||||
.emote {
|
||||
white-space: pre-wrap;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.annotated {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.annotatedInline {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.annotation {
|
||||
user-select: none;
|
||||
display: inline-block;
|
||||
margin-inline-start: 9px; /* Preserve legacy EventTile spacing for inline annotations like (edited) */
|
||||
font: var(--cpd-font-body-xs-regular);
|
||||
color: var(--cpd-color-text-secondary);
|
||||
}
|
||||
|
||||
.editedMarker {
|
||||
appearance: none;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.bodyLink,
|
||||
.bodyAction {
|
||||
color: inherit;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
|
||||
.bodyAction {
|
||||
appearance: none;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
padding: 0;
|
||||
text-align: inherit;
|
||||
}
|
||||
|
||||
.emoteSender {
|
||||
all: unset;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.editedMarker:focus-visible,
|
||||
.bodyAction:focus-visible,
|
||||
.emoteSender:focus-visible {
|
||||
outline: 2px solid var(--cpd-color-border-focused);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--cpd-space-0-5x);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/*
|
||||
* 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 ReactElement, type ReactNode } from "react";
|
||||
import { fn } from "storybook/test";
|
||||
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { useMockedViewModel } from "../../../../../core/viewmodel/useMockedViewModel";
|
||||
import { withViewDocs } from "../../../../../../.storybook/withViewDocs";
|
||||
import {
|
||||
TextualBodyView,
|
||||
TextualBodyViewBodyWrapperKind,
|
||||
TextualBodyViewKind,
|
||||
type TextualBodyViewActions,
|
||||
type TextualBodyViewSnapshot,
|
||||
} from "./TextualBodyView";
|
||||
|
||||
type WrapperProps = TextualBodyViewSnapshot &
|
||||
Partial<TextualBodyViewActions> & {
|
||||
body: ReactElement;
|
||||
urlPreviews?: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const TextualBodyViewWrapperImpl = ({
|
||||
body,
|
||||
urlPreviews,
|
||||
className,
|
||||
onRootClick,
|
||||
onBodyActionClick,
|
||||
onEditedMarkerClick,
|
||||
onEmoteSenderClick,
|
||||
...snapshotProps
|
||||
}: WrapperProps): JSX.Element => {
|
||||
const vm = useMockedViewModel(snapshotProps, {
|
||||
onRootClick: onRootClick ?? fn(),
|
||||
onBodyActionClick: onBodyActionClick ?? fn(),
|
||||
onEditedMarkerClick: onEditedMarkerClick ?? fn(),
|
||||
onEmoteSenderClick: onEmoteSenderClick ?? fn(),
|
||||
});
|
||||
|
||||
return <TextualBodyView vm={vm} body={body} urlPreviews={urlPreviews} className={className} />;
|
||||
};
|
||||
|
||||
const TextualBodyViewWrapper = withViewDocs(TextualBodyViewWrapperImpl, TextualBodyView);
|
||||
|
||||
const DefaultBody = <div>Hello, this is a textual message.</div>;
|
||||
const Preview = (
|
||||
<div
|
||||
style={{
|
||||
marginTop: "8px",
|
||||
padding: "8px",
|
||||
borderRadius: "8px",
|
||||
backgroundColor: "var(--cpd-color-bg-subtle-secondary)",
|
||||
}}
|
||||
>
|
||||
URL preview
|
||||
</div>
|
||||
);
|
||||
|
||||
const TEXTUAL_BODY_VIEW_KIND_OPTIONS = [
|
||||
TextualBodyViewKind.TEXT,
|
||||
TextualBodyViewKind.NOTICE,
|
||||
TextualBodyViewKind.EMOTE,
|
||||
TextualBodyViewKind.CAPTION,
|
||||
];
|
||||
|
||||
const TEXTUAL_BODY_VIEW_BODY_WRAPPER_KIND_OPTIONS = [
|
||||
TextualBodyViewBodyWrapperKind.NONE,
|
||||
TextualBodyViewBodyWrapperKind.LINK,
|
||||
TextualBodyViewBodyWrapperKind.ACTION,
|
||||
];
|
||||
|
||||
const meta = {
|
||||
title: "MessageBody/TextualBody",
|
||||
component: TextualBodyViewWrapper,
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
kind: {
|
||||
options: TEXTUAL_BODY_VIEW_KIND_OPTIONS,
|
||||
control: { type: "select" },
|
||||
},
|
||||
bodyWrapper: {
|
||||
options: TEXTUAL_BODY_VIEW_BODY_WRAPPER_KIND_OPTIONS,
|
||||
control: { type: "select" },
|
||||
},
|
||||
},
|
||||
args: {
|
||||
kind: TextualBodyViewKind.TEXT,
|
||||
bodyWrapper: TextualBodyViewBodyWrapperKind.NONE,
|
||||
body: DefaultBody,
|
||||
urlPreviews: undefined,
|
||||
showEditedMarker: false,
|
||||
editedMarkerText: "(edited)",
|
||||
editedMarkerTooltip: "Edited yesterday at 11:48",
|
||||
editedMarkerCaption: "View edit history",
|
||||
showPendingModerationMarker: false,
|
||||
pendingModerationText: "(Visible to you while moderation is pending)",
|
||||
emoteSenderName: "Alice",
|
||||
bodyActionAriaLabel: "Open starter link",
|
||||
},
|
||||
} satisfies Meta<typeof TextualBodyViewWrapper>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const Notice: Story = {
|
||||
args: {
|
||||
kind: TextualBodyViewKind.NOTICE,
|
||||
body: <div>This is a notice message.</div>,
|
||||
},
|
||||
};
|
||||
|
||||
export const CaptionWithPreview: Story = {
|
||||
args: {
|
||||
kind: TextualBodyViewKind.CAPTION,
|
||||
body: <div>Caption for the uploaded image.</div>,
|
||||
urlPreviews: Preview,
|
||||
},
|
||||
};
|
||||
|
||||
export const Edited: Story = {
|
||||
args: {
|
||||
showEditedMarker: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const PendingModeration: Story = {
|
||||
args: {
|
||||
showPendingModerationMarker: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const HighlightLink: Story = {
|
||||
args: {
|
||||
bodyWrapper: TextualBodyViewBodyWrapperKind.LINK,
|
||||
bodyLinkHref: "https://example.org/#/room/!room:example.org/$event",
|
||||
},
|
||||
};
|
||||
|
||||
export const StarterLink: Story = {
|
||||
args: {
|
||||
bodyWrapper: TextualBodyViewBodyWrapperKind.ACTION,
|
||||
body: <div>Launch the integration flow.</div>,
|
||||
},
|
||||
};
|
||||
|
||||
export const Emote: Story = {
|
||||
args: {
|
||||
kind: TextualBodyViewKind.EMOTE,
|
||||
body: <span>waves enthusiastically</span>,
|
||||
showEditedMarker: true,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,192 @@
|
||||
/*
|
||||
* 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, { createRef, 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 {
|
||||
TextualBodyView,
|
||||
TextualBodyViewBodyWrapperKind,
|
||||
TextualBodyViewKind,
|
||||
type TextualBodyContentElement,
|
||||
type TextualBodyViewActions,
|
||||
type TextualBodyViewModel,
|
||||
type TextualBodyViewSnapshot,
|
||||
} from "./TextualBodyView";
|
||||
import * as publicApi from "./index";
|
||||
import * as stories from "./TextualBody.stories";
|
||||
|
||||
const { Default, Notice, CaptionWithPreview, Emote } = composeStories(stories);
|
||||
|
||||
describe("TextualBodyView", () => {
|
||||
it("renders the default message body", () => {
|
||||
const { container } = render(<Default />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders the notice branch", () => {
|
||||
const { container } = render(<Notice />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders caption messages with url previews", () => {
|
||||
const { container } = render(<CaptionWithPreview />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders emote messages with annotations", () => {
|
||||
const { container } = render(<Emote />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("re-exports the public TextualBodyView API", () => {
|
||||
expect(publicApi.TextualBodyView).toBe(TextualBodyView);
|
||||
});
|
||||
|
||||
it("forwards body refs to the rendered body element", () => {
|
||||
const bodyRef = createRef<TextualBodyContentElement>();
|
||||
const vm = new MockViewModel<TextualBodyViewSnapshot>({
|
||||
kind: TextualBodyViewKind.TEXT,
|
||||
}) as TextualBodyViewModel;
|
||||
|
||||
render(<TextualBodyView vm={vm} body={<div>Body content</div>} bodyRef={bodyRef} />);
|
||||
|
||||
expect(bodyRef.current).not.toBeNull();
|
||||
expect(bodyRef.current?.textContent).toBe("Body content");
|
||||
});
|
||||
|
||||
it("invokes edited marker, body action, and emote sender handlers", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onEditedMarkerClick = vi.fn();
|
||||
const onBodyActionClick = vi.fn();
|
||||
const onEmoteSenderClick = vi.fn();
|
||||
|
||||
class TestTextualBodyViewModel
|
||||
extends MockViewModel<TextualBodyViewSnapshot>
|
||||
implements TextualBodyViewActions
|
||||
{
|
||||
public onEditedMarkerClick?: MouseEventHandler<HTMLButtonElement>;
|
||||
public onBodyActionClick?: MouseEventHandler<HTMLElement>;
|
||||
public onEmoteSenderClick?: MouseEventHandler<HTMLButtonElement>;
|
||||
|
||||
public constructor(snapshot: TextualBodyViewSnapshot, actions: TextualBodyViewActions) {
|
||||
super(snapshot);
|
||||
Object.assign(this, actions);
|
||||
}
|
||||
}
|
||||
|
||||
const vm = new TestTextualBodyViewModel(
|
||||
{
|
||||
kind: TextualBodyViewKind.EMOTE,
|
||||
bodyWrapper: TextualBodyViewBodyWrapperKind.ACTION,
|
||||
bodyActionAriaLabel: "Open starter link",
|
||||
showEditedMarker: true,
|
||||
editedMarkerText: "(edited)",
|
||||
editedMarkerTooltip: "Edited yesterday at 11:48",
|
||||
editedMarkerCaption: "View edit history",
|
||||
emoteSenderName: "Alice",
|
||||
},
|
||||
{
|
||||
onEditedMarkerClick,
|
||||
onBodyActionClick,
|
||||
onEmoteSenderClick,
|
||||
},
|
||||
) as TextualBodyViewModel;
|
||||
|
||||
render(<TextualBodyView vm={vm} body={<span>waves</span>} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Alice" }));
|
||||
await user.click(screen.getByRole("button", { name: "Open starter link" }));
|
||||
await user.click(screen.getByRole("button", { name: "(edited)" }));
|
||||
|
||||
expect(onEmoteSenderClick).toHaveBeenCalledTimes(1);
|
||||
expect(onBodyActionClick).toHaveBeenCalledTimes(1);
|
||||
expect(onEditedMarkerClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders link-wrapped annotated bodies without an edited tooltip", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onEditedMarkerClick = vi.fn();
|
||||
|
||||
class TestTextualBodyViewModel
|
||||
extends MockViewModel<TextualBodyViewSnapshot>
|
||||
implements TextualBodyViewActions
|
||||
{
|
||||
public onEditedMarkerClick?: MouseEventHandler<HTMLButtonElement>;
|
||||
|
||||
public constructor(snapshot: TextualBodyViewSnapshot, actions: TextualBodyViewActions) {
|
||||
super(snapshot);
|
||||
Object.assign(this, actions);
|
||||
}
|
||||
}
|
||||
|
||||
const vm = new TestTextualBodyViewModel(
|
||||
{
|
||||
kind: TextualBodyViewKind.TEXT,
|
||||
bodyWrapper: TextualBodyViewBodyWrapperKind.LINK,
|
||||
bodyLinkHref: "https://example.org/#/room/!room:example.org/$event",
|
||||
showEditedMarker: true,
|
||||
editedMarkerText: "(edited)",
|
||||
showPendingModerationMarker: true,
|
||||
pendingModerationText: "(Visible to you while moderation is pending)",
|
||||
},
|
||||
{ onEditedMarkerClick },
|
||||
) as TextualBodyViewModel;
|
||||
|
||||
render(<TextualBodyView vm={vm} body={<div>Body content</div>} />);
|
||||
|
||||
expect(screen.getByRole("link")).toHaveAttribute("href", "https://example.org/#/room/!room:example.org/$event");
|
||||
expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("(Visible to you while moderation is pending)")).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "(edited)" }));
|
||||
|
||||
expect(onEditedMarkerClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders action wrappers as native buttons and activates them for Enter and Space key presses", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onBodyActionClick = vi.fn();
|
||||
|
||||
class TestTextualBodyViewModel
|
||||
extends MockViewModel<TextualBodyViewSnapshot>
|
||||
implements TextualBodyViewActions
|
||||
{
|
||||
public onBodyActionClick?: MouseEventHandler<HTMLElement>;
|
||||
|
||||
public constructor(snapshot: TextualBodyViewSnapshot, actions: TextualBodyViewActions) {
|
||||
super(snapshot);
|
||||
Object.assign(this, actions);
|
||||
}
|
||||
}
|
||||
|
||||
const vm = new TestTextualBodyViewModel(
|
||||
{
|
||||
kind: TextualBodyViewKind.TEXT,
|
||||
bodyWrapper: TextualBodyViewBodyWrapperKind.ACTION,
|
||||
bodyActionAriaLabel: "Open starter link",
|
||||
},
|
||||
{ onBodyActionClick },
|
||||
) as TextualBodyViewModel;
|
||||
|
||||
render(<TextualBodyView vm={vm} body={<span>Launch the integration flow.</span>} />);
|
||||
|
||||
const action = screen.getByRole("button", { name: "Open starter link" });
|
||||
expect(action).toHaveAttribute("type", "button");
|
||||
|
||||
action.focus();
|
||||
await user.keyboard("{Escape}");
|
||||
await user.keyboard("{Enter}");
|
||||
await user.keyboard(" ");
|
||||
|
||||
expect(onBodyActionClick).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,278 @@
|
||||
/*
|
||||
* 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, {
|
||||
cloneElement,
|
||||
isValidElement,
|
||||
type JSX,
|
||||
type MouseEventHandler,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
type Ref,
|
||||
} from "react";
|
||||
import classNames from "classnames";
|
||||
import { Tooltip } from "@vector-im/compound-web";
|
||||
|
||||
import { type ViewModel, useViewModel } from "../../../../../core/viewmodel";
|
||||
import styles from "./TextualBody.module.css";
|
||||
|
||||
export const enum TextualBodyViewKind {
|
||||
TEXT = "TEXT",
|
||||
NOTICE = "NOTICE",
|
||||
EMOTE = "EMOTE",
|
||||
CAPTION = "CAPTION",
|
||||
}
|
||||
|
||||
export const enum TextualBodyViewBodyWrapperKind {
|
||||
NONE = "NONE",
|
||||
LINK = "LINK",
|
||||
ACTION = "ACTION",
|
||||
}
|
||||
|
||||
export interface TextualBodyViewSnapshot {
|
||||
/**
|
||||
* Optional id passed to the root message-body element.
|
||||
*/
|
||||
id?: string;
|
||||
/**
|
||||
* Controls the layout and styling branch for the body.
|
||||
*/
|
||||
kind: TextualBodyViewKind;
|
||||
/**
|
||||
* Optional outer wrapper applied around the rendered body content.
|
||||
*/
|
||||
bodyWrapper?: TextualBodyViewBodyWrapperKind;
|
||||
/**
|
||||
* Href used when `bodyWrapper` is `LINK`.
|
||||
*/
|
||||
bodyLinkHref?: string;
|
||||
/**
|
||||
* Accessible label used when `bodyWrapper` is `ACTION`.
|
||||
*/
|
||||
bodyActionAriaLabel?: string;
|
||||
/**
|
||||
* Whether to render the edited marker.
|
||||
*/
|
||||
showEditedMarker?: boolean;
|
||||
/**
|
||||
* Visible label for the edited marker.
|
||||
*/
|
||||
editedMarkerText?: string;
|
||||
/**
|
||||
* Tooltip description for the edited marker.
|
||||
*/
|
||||
editedMarkerTooltip?: string;
|
||||
/**
|
||||
* Optional tooltip caption for the edited marker.
|
||||
*/
|
||||
editedMarkerCaption?: string;
|
||||
/**
|
||||
* Whether to render the pending-moderation marker.
|
||||
*/
|
||||
showPendingModerationMarker?: boolean;
|
||||
/**
|
||||
* Visible label for the pending-moderation marker.
|
||||
*/
|
||||
pendingModerationText?: string;
|
||||
/**
|
||||
* Sender label rendered for emote events.
|
||||
*/
|
||||
emoteSenderName?: string;
|
||||
}
|
||||
|
||||
export interface TextualBodyViewActions {
|
||||
/**
|
||||
* Capture-phase click handler attached to the root message-body container.
|
||||
*/
|
||||
onRootClick?: MouseEventHandler<HTMLDivElement>;
|
||||
/**
|
||||
* Activation handler used when `bodyWrapper` is `ACTION`.
|
||||
*/
|
||||
onBodyActionClick?: MouseEventHandler<HTMLElement>;
|
||||
/**
|
||||
* Click handler for the edited marker.
|
||||
*/
|
||||
onEditedMarkerClick?: MouseEventHandler<HTMLButtonElement>;
|
||||
/**
|
||||
* Click handler for the emote sender.
|
||||
*/
|
||||
onEmoteSenderClick?: MouseEventHandler<HTMLButtonElement>;
|
||||
}
|
||||
|
||||
export type TextualBodyViewModel = ViewModel<TextualBodyViewSnapshot, TextualBodyViewActions>;
|
||||
|
||||
export type TextualBodyContentElement = HTMLDivElement | HTMLSpanElement;
|
||||
export type TextualBodyContentRef = Ref<TextualBodyContentElement>;
|
||||
|
||||
interface TextualBodyViewProps {
|
||||
/**
|
||||
* The view model providing the layout state and event handlers.
|
||||
*/
|
||||
vm: TextualBodyViewModel;
|
||||
/**
|
||||
* The message body element, typically `EventContentBodyView`.
|
||||
*/
|
||||
body: ReactElement;
|
||||
/**
|
||||
* Optional ref to attach to the message body element.
|
||||
*/
|
||||
bodyRef?: TextualBodyContentRef;
|
||||
/**
|
||||
* Optional URL preview subtree rendered after the body.
|
||||
*/
|
||||
urlPreviews?: ReactNode;
|
||||
/**
|
||||
* Optional host-level class names.
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-clones the supplied body element so consumers can observe the rendered
|
||||
* body node via `bodyRef` without constraining the `body` prop shape.
|
||||
*/
|
||||
function attachBodyRef(body: ReactElement, bodyRef?: TextualBodyContentRef): ReactElement {
|
||||
if (!bodyRef || !isValidElement(body)) {
|
||||
return body;
|
||||
}
|
||||
|
||||
return cloneElement(body as ReactElement<{ ref?: TextualBodyContentRef }>, { ref: bodyRef });
|
||||
}
|
||||
|
||||
export function TextualBodyView({
|
||||
vm,
|
||||
body,
|
||||
bodyRef,
|
||||
urlPreviews,
|
||||
className,
|
||||
}: Readonly<TextualBodyViewProps>): JSX.Element {
|
||||
const {
|
||||
id,
|
||||
kind,
|
||||
bodyWrapper = TextualBodyViewBodyWrapperKind.NONE,
|
||||
bodyLinkHref,
|
||||
bodyActionAriaLabel,
|
||||
showEditedMarker,
|
||||
editedMarkerText,
|
||||
editedMarkerTooltip,
|
||||
editedMarkerCaption,
|
||||
showPendingModerationMarker,
|
||||
pendingModerationText,
|
||||
emoteSenderName,
|
||||
} = useViewModel(vm);
|
||||
|
||||
const rootClasses = classNames(className, styles.root, {
|
||||
[styles.text]: kind === TextualBodyViewKind.TEXT,
|
||||
[styles.notice]: kind === TextualBodyViewKind.NOTICE,
|
||||
[styles.emote]: kind === TextualBodyViewKind.EMOTE,
|
||||
[styles.caption]: kind === TextualBodyViewKind.CAPTION,
|
||||
});
|
||||
|
||||
let renderedBody: ReactNode = attachBodyRef(body, bodyRef);
|
||||
const onEditedMarkerClick: MouseEventHandler<HTMLButtonElement> | undefined = vm.onEditedMarkerClick
|
||||
? (event): void => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
vm.onEditedMarkerClick?.(event);
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const markers: ReactNode[] = [];
|
||||
if (showEditedMarker) {
|
||||
const editedMarkerButton = (
|
||||
<button
|
||||
type="button"
|
||||
className={classNames(styles.annotation, styles.editedMarker)}
|
||||
onClick={onEditedMarkerClick}
|
||||
>
|
||||
<span>{editedMarkerText}</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
markers.push(
|
||||
editedMarkerTooltip ? (
|
||||
<Tooltip
|
||||
key="edited-marker"
|
||||
description={editedMarkerTooltip}
|
||||
caption={editedMarkerCaption}
|
||||
isTriggerInteractive={true}
|
||||
>
|
||||
{editedMarkerButton}
|
||||
</Tooltip>
|
||||
) : (
|
||||
React.cloneElement(editedMarkerButton, { key: "edited-marker" })
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (showPendingModerationMarker) {
|
||||
markers.push(
|
||||
<span key="pending-moderation-marker" className={styles.annotation}>
|
||||
{pendingModerationText}
|
||||
</span>,
|
||||
);
|
||||
}
|
||||
|
||||
if (bodyWrapper === TextualBodyViewBodyWrapperKind.LINK && bodyLinkHref) {
|
||||
renderedBody = (
|
||||
<a href={bodyLinkHref} className={styles.bodyLink}>
|
||||
{renderedBody}
|
||||
</a>
|
||||
);
|
||||
} else if (bodyWrapper === TextualBodyViewBodyWrapperKind.ACTION) {
|
||||
renderedBody = (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={bodyActionAriaLabel}
|
||||
className={styles.bodyAction}
|
||||
onClick={vm.onBodyActionClick}
|
||||
>
|
||||
{renderedBody}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (markers.length > 0) {
|
||||
const annotatedClasses = classNames(styles.annotated, {
|
||||
[styles.annotatedInline]: kind === TextualBodyViewKind.EMOTE,
|
||||
});
|
||||
|
||||
renderedBody =
|
||||
kind === TextualBodyViewKind.EMOTE ? (
|
||||
<span dir="auto" className={annotatedClasses}>
|
||||
{renderedBody}
|
||||
{markers}
|
||||
</span>
|
||||
) : (
|
||||
<div dir="auto" className={annotatedClasses}>
|
||||
{renderedBody}
|
||||
{markers}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (kind === TextualBodyViewKind.EMOTE) {
|
||||
return (
|
||||
<div id={id} className={rootClasses} onClickCapture={vm.onRootClick} dir="auto">
|
||||
*
|
||||
<button type="button" className={styles.emoteSender} onClick={vm.onEmoteSenderClick}>
|
||||
{emoteSenderName}
|
||||
</button>
|
||||
|
||||
{renderedBody}
|
||||
{urlPreviews}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div id={id} className={rootClasses} onClickCapture={vm.onRootClick}>
|
||||
{renderedBody}
|
||||
{urlPreviews}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`TextualBodyView > renders caption messages with url previews 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="root caption"
|
||||
>
|
||||
<div>
|
||||
Caption for the uploaded image.
|
||||
</div>
|
||||
<div
|
||||
style="margin-top: 8px; padding: 8px; border-radius: 8px; background-color: var(--cpd-color-bg-subtle-secondary);"
|
||||
>
|
||||
URL preview
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`TextualBodyView > renders emote messages with annotations 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="root emote"
|
||||
dir="auto"
|
||||
>
|
||||
*
|
||||
<button
|
||||
class="emoteSender"
|
||||
type="button"
|
||||
>
|
||||
Alice
|
||||
</button>
|
||||
|
||||
<span
|
||||
class="annotated annotatedInline"
|
||||
dir="auto"
|
||||
>
|
||||
<span>
|
||||
waves enthusiastically
|
||||
</span>
|
||||
<button
|
||||
class="annotation editedMarker"
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
(edited)
|
||||
</span>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`TextualBodyView > renders the default message body 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="root text"
|
||||
>
|
||||
<div>
|
||||
Hello, this is a textual message.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`TextualBodyView > renders the notice branch 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="root notice"
|
||||
>
|
||||
<div>
|
||||
This is a notice message.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* 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 {
|
||||
TextualBodyView,
|
||||
TextualBodyViewKind,
|
||||
TextualBodyViewBodyWrapperKind,
|
||||
type TextualBodyViewSnapshot,
|
||||
type TextualBodyViewActions,
|
||||
type TextualBodyViewModel,
|
||||
type TextualBodyContentElement,
|
||||
type TextualBodyContentRef,
|
||||
} from "./TextualBodyView";
|
||||