Merge branch 'develop' of ssh://github.com/element-hq/element-web into t3chguy/monorepo-playwright-common

# Conflicts:
#	pnpm-lock.yaml
This commit is contained in:
Michael Telatynski
2026-04-10 16:41:45 +01:00
71 changed files with 1309 additions and 902 deletions
+10 -1
View File
@@ -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
+21 -2
View File
@@ -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")
@@ -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);
}
+1
View File
@@ -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";
@@ -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">
*&nbsp;
<button type="button" className={styles.emoteSender} onClick={vm.onEmoteSenderClick}>
{emoteSenderName}
</button>
&nbsp;
{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";