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
|
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 |
@@ -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";
|
||||