Move TextualBody to shared components (#32868)

* Init, refactoring and movement of TextualBody to shared components, adding stories, test and view

* migrate TextualBody to shared view + app viewmodel

* Update snapshots + prettier fix

* Fix Prettier

* added new tests to make coverage happy

* add comment to attachbodyRef function

* Fix: Remove event onkeydown and remove hardcoded mx css

* Update enums to const enums

* added comment on css to explain 9px

* Update comment

* Correcting comment, pushed too fast..

* Update Css To Fix (edited)

* Update snapshot to reflect css changes

* Fix emote into one liner

* Update snapshot
This commit is contained in:
Zack
2026-04-09 13:36:24 +02:00
committed by GitHub
parent 6486a6b5ff
commit 1721b69017
16 changed files with 805 additions and 1 deletions
+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";