diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/TextualBodyView/TextualBody.stories.tsx/caption-with-preview-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/TextualBodyView/TextualBody.stories.tsx/caption-with-preview-auto.png new file mode 100644 index 0000000000..5250dc03ed Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/TextualBodyView/TextualBody.stories.tsx/caption-with-preview-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/TextualBodyView/TextualBody.stories.tsx/default-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/TextualBodyView/TextualBody.stories.tsx/default-auto.png new file mode 100644 index 0000000000..e47c6b1f86 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/TextualBodyView/TextualBody.stories.tsx/default-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/TextualBodyView/TextualBody.stories.tsx/edited-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/TextualBodyView/TextualBody.stories.tsx/edited-auto.png new file mode 100644 index 0000000000..5a6416705f Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/TextualBodyView/TextualBody.stories.tsx/edited-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/TextualBodyView/TextualBody.stories.tsx/emote-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/TextualBodyView/TextualBody.stories.tsx/emote-auto.png new file mode 100644 index 0000000000..f71301034c Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/TextualBodyView/TextualBody.stories.tsx/emote-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/TextualBodyView/TextualBody.stories.tsx/highlight-link-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/TextualBodyView/TextualBody.stories.tsx/highlight-link-auto.png new file mode 100644 index 0000000000..e47c6b1f86 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/TextualBodyView/TextualBody.stories.tsx/highlight-link-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/TextualBodyView/TextualBody.stories.tsx/notice-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/TextualBodyView/TextualBody.stories.tsx/notice-auto.png new file mode 100644 index 0000000000..1112a2b9c0 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/TextualBodyView/TextualBody.stories.tsx/notice-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/TextualBodyView/TextualBody.stories.tsx/pending-moderation-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/TextualBodyView/TextualBody.stories.tsx/pending-moderation-auto.png new file mode 100644 index 0000000000..cda879c609 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/TextualBodyView/TextualBody.stories.tsx/pending-moderation-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/TextualBodyView/TextualBody.stories.tsx/starter-link-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/TextualBodyView/TextualBody.stories.tsx/starter-link-auto.png new file mode 100644 index 0000000000..4fb76984cd Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/TextualBodyView/TextualBody.stories.tsx/starter-link-auto.png differ diff --git a/packages/shared-components/src/index.ts b/packages/shared-components/src/index.ts index c1f53abbb7..c134ab8b57 100644 --- a/packages/shared-components/src/index.ts +++ b/packages/shared-components/src/index.ts @@ -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"; diff --git a/packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/.gitkeep b/packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/.gitkeep deleted file mode 100644 index 8b13789179..0000000000 --- a/packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/TextualBody.module.css b/packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/TextualBody.module.css new file mode 100644 index 0000000000..683c8a8a2a --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/TextualBody.module.css @@ -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); +} diff --git a/packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/TextualBody.stories.tsx b/packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/TextualBody.stories.tsx new file mode 100644 index 0000000000..26604c0814 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/TextualBody.stories.tsx @@ -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 & { + 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 ; +}; + +const TextualBodyViewWrapper = withViewDocs(TextualBodyViewWrapperImpl, TextualBodyView); + +const DefaultBody =
Hello, this is a textual message.
; +const Preview = ( +
+ URL preview +
+); + +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; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Notice: Story = { + args: { + kind: TextualBodyViewKind.NOTICE, + body:
This is a notice message.
, + }, +}; + +export const CaptionWithPreview: Story = { + args: { + kind: TextualBodyViewKind.CAPTION, + body:
Caption for the uploaded image.
, + 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:
Launch the integration flow.
, + }, +}; + +export const Emote: Story = { + args: { + kind: TextualBodyViewKind.EMOTE, + body: waves enthusiastically, + showEditedMarker: true, + }, +}; diff --git a/packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/TextualBody.test.tsx b/packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/TextualBody.test.tsx new file mode 100644 index 0000000000..5e4d8a3e89 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/TextualBody.test.tsx @@ -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(); + expect(container).toMatchSnapshot(); + }); + + it("renders the notice branch", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders caption messages with url previews", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders emote messages with annotations", () => { + const { container } = render(); + 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(); + const vm = new MockViewModel({ + kind: TextualBodyViewKind.TEXT, + }) as TextualBodyViewModel; + + render(Body content} 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 + implements TextualBodyViewActions + { + public onEditedMarkerClick?: MouseEventHandler; + public onBodyActionClick?: MouseEventHandler; + public onEmoteSenderClick?: MouseEventHandler; + + 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(waves} />); + + 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 + implements TextualBodyViewActions + { + public onEditedMarkerClick?: MouseEventHandler; + + 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(Body content} />); + + 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 + implements TextualBodyViewActions + { + public onBodyActionClick?: MouseEventHandler; + + 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(Launch the integration flow.} />); + + 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); + }); +}); diff --git a/packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/TextualBodyView.tsx b/packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/TextualBodyView.tsx new file mode 100644 index 0000000000..aabde86295 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/TextualBodyView.tsx @@ -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; + /** + * Activation handler used when `bodyWrapper` is `ACTION`. + */ + onBodyActionClick?: MouseEventHandler; + /** + * Click handler for the edited marker. + */ + onEditedMarkerClick?: MouseEventHandler; + /** + * Click handler for the emote sender. + */ + onEmoteSenderClick?: MouseEventHandler; +} + +export type TextualBodyViewModel = ViewModel; + +export type TextualBodyContentElement = HTMLDivElement | HTMLSpanElement; +export type TextualBodyContentRef = Ref; + +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): 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 | undefined = vm.onEditedMarkerClick + ? (event): void => { + event.preventDefault(); + event.stopPropagation(); + vm.onEditedMarkerClick?.(event); + } + : undefined; + + const markers: ReactNode[] = []; + if (showEditedMarker) { + const editedMarkerButton = ( + + ); + + markers.push( + editedMarkerTooltip ? ( + + {editedMarkerButton} + + ) : ( + React.cloneElement(editedMarkerButton, { key: "edited-marker" }) + ), + ); + } + + if (showPendingModerationMarker) { + markers.push( + + {pendingModerationText} + , + ); + } + + if (bodyWrapper === TextualBodyViewBodyWrapperKind.LINK && bodyLinkHref) { + renderedBody = ( + + {renderedBody} + + ); + } else if (bodyWrapper === TextualBodyViewBodyWrapperKind.ACTION) { + renderedBody = ( + + ); + } + + if (markers.length > 0) { + const annotatedClasses = classNames(styles.annotated, { + [styles.annotatedInline]: kind === TextualBodyViewKind.EMOTE, + }); + + renderedBody = + kind === TextualBodyViewKind.EMOTE ? ( + + {renderedBody} + {markers} + + ) : ( +
+ {renderedBody} + {markers} +
+ ); + } + + if (kind === TextualBodyViewKind.EMOTE) { + return ( +
+ *  + +   + {renderedBody} + {urlPreviews} +
+ ); + } + + return ( +
+ {renderedBody} + {urlPreviews} +
+ ); +} diff --git a/packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/__snapshots__/TextualBody.test.tsx.snap b/packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/__snapshots__/TextualBody.test.tsx.snap new file mode 100644 index 0000000000..2f69f4985f --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/__snapshots__/TextualBody.test.tsx.snap @@ -0,0 +1,76 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`TextualBodyView > renders caption messages with url previews 1`] = ` +
+
+
+ Caption for the uploaded image. +
+
+ URL preview +
+
+
+`; + +exports[`TextualBodyView > renders emote messages with annotations 1`] = ` +
+
+ *  + +   + + + waves enthusiastically + + + +
+
+`; + +exports[`TextualBodyView > renders the default message body 1`] = ` +
+
+
+ Hello, this is a textual message. +
+
+
+`; + +exports[`TextualBodyView > renders the notice branch 1`] = ` +
+
+
+ This is a notice message. +
+
+
+`; diff --git a/packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/index.tsx b/packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/index.tsx new file mode 100644 index 0000000000..dce11459c8 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/index.tsx @@ -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";