Refactor EventContentBody to shared-components (#31914)

* Init of refactoring of eventcontentbody

* update stories css by copying css from element x to shared components

* Replaced old component EventContentBody with newly created mmvm component EventContentBodyViewModel

* Refactor TextualBody and EditHistoryMessage to properly manage EventContentBodyViewModel

* generated snapshot after vitest

* Update import placement for eslint to pass CI

* Fixed lint warnings

* Update css for codeblock to represent js highlight

* test: add EventContentBodyViewModel snapshot coverage

* fix: pass content ref to EventContentBodyView for link previews

* Fix: return to old code that passed tests

* Added storybook snapshots

* Removal of old component that is being unused

* Update snapshot

* Fix missing enableBigEmoji and shouldShowPillAvatar settings in EventContentBodyViewModel

* update snapshot

* narrow setProps to mutable fields and skip no-op snapshot recomputes

* Update Snapshots

* replace EventContentBodyViewModel setProps with explicit setters and update call sites

* render body in view and keep parser/replacer in snapshot

* Eslint Restruct

* Eslint Restructure

* Removed unused function, moved to shared component

* Remove Unused Module (Moved To Shared Component)

* Disable EventContent-body Test to check weather it fixes CI

* Enable EventContentBody Tests

* Remove EventTest

* Update Include in Vitest

* Added EventContentBody test

* Update Package.json

* Update Lockfile

* Update dependencies

* update lockfile

* ptimize EventContentBodyViewModel to recompute/merge only changed snapshot fields

* Update snapshots

* setEventContent and setStripReply run whenever the existing update block runs

* defined arrow functions for undefined runtime issues that might occur.

* Update test cases

* Update packages/shared-components/src/message-body/EventContentBody/EventContentBodyView.tsx

Co-authored-by: R Midhun Suresh <rmidhunsuresh@gmail.com>

* Update packages/shared-components/src/message-body/EventContentBody/EventContentBodyView.tsx

Co-authored-by: R Midhun Suresh <rmidhunsuresh@gmail.com>

* move big-emoji and pill-avatar setting watchers into EventContentBodyViewModel

* Update packages/shared-components/src/message-body/EventContentBody/index.tsx

Co-authored-by: Florian Duros <florian.duros@ormaz.fr>

* Update packages/shared-components/src/message-body/EventContentBody/EventContentBodyView.tsx

Co-authored-by: Florian Duros <florian.duros@ormaz.fr>

* Update packages/shared-components/src/message-body/EventContentBody/EventContentBody.test.tsx

Co-authored-by: Florian Duros <florian.duros@ormaz.fr>

* Update packages/shared-components/src/message-body/EventContentBody/EventContentBody.stories.tsx

Co-authored-by: Florian Duros <florian.duros@ormaz.fr>

* Update packages/shared-components/src/message-body/EventContentBody/EventContentBodyView.tsx

Co-authored-by: Florian Duros <florian.duros@ormaz.fr>

* Update packages/shared-components/src/message-body/EventContentBody/EventContentBodyView.tsx

Co-authored-by: Florian Duros <florian.duros@ormaz.fr>

* Fix dubblicate variables

* clarify applyReplacerOnString input/replacer params

* Added memo to the view

* Prettier Fix

* Update apps/web/src/viewmodels/message-body/EventContentBodyViewModel.ts

Co-authored-by: Florian Duros <florian.duros@ormaz.fr>

* Added compund variables instead of reguler values

* Added boolean default values

* remove redundant setting props from TextualBody and EditHistoryMessage

* Prettier FIx

* replace MatrixClientPeg usage with `client: MatrixClient | null` passed from context

* TextualBody now passes EventContentBodyViewModel `client` from RoomContext.

* Remove redundant as prop from EventContentBody VM usage

* Normalize EventContentBodyViewModel renderer flags to booleans

---------

Co-authored-by: R Midhun Suresh <rmidhunsuresh@gmail.com>
Co-authored-by: Florian Duros <florian.duros@ormaz.fr>
This commit is contained in:
Zack
2026-03-09 09:58:05 +00:00
committed by GitHub
co-authored by R Midhun Suresh Florian Duros
parent 3e77974fa0
commit 8d076c897d
24 changed files with 1281 additions and 252 deletions
+1
View File
@@ -16,6 +16,7 @@ export * from "./crypto/SasEmoji";
export * from "./event-tiles/EncryptionEventView";
export * from "./event-tiles/EventTileBubble";
export * from "./event-tiles/TextualEventView";
export * from "./message-body/EventContentBody";
export * from "./message-body/MediaBody";
export * from "./message-body/MessageTimestampView";
export * from "./message-body/DecryptionFailureBodyView";
@@ -0,0 +1,89 @@
/*
* 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.
*/
.EventTile_body {
overflow-y: hidden;
text-align: start;
}
.EventTile_bigEmoji {
font-size: 48px;
line-height: 57px;
.Emoji {
font-size: inherit !important;
}
}
.markdownBody {
font: var(--cpd-font-body-md-regular) !important;
letter-spacing: var(--cpd-font-letter-spacing-body-md);
font-family: inherit !important;
white-space: normal !important;
line-height: inherit !important;
background-color: inherit;
color: inherit; /* inherit the colour from the dark or light theme by default (but not for code blocks) */
flex: 1;
pre,
code {
font-family:
"Fira Code", "Apple Color Emoji", "Segoe UI Emoji", "Courier", monospace, "Noto Color Emoji" !important;
background-color: var(--cpd-color-bg-subtle-primary);
}
code:not(pre *) {
background-color: var(--cpd-color-bg-subtle-primary);
border: 1px solid var(--cpd-color-gray-400);
border-radius: var(--cpd-space-1x);
/* The horizontal padding is added by github-markdown-css .markdown-body */
padding: var(--cpd-space-0-5x) 0;
/* Avoid inline code blocks to be sticked when on multiple lines */
line-height: 1.375rem;
/* Avoid the border to be glued to the other words */
margin-right: var(--cpd-space-0-5x);
}
code {
white-space: pre-wrap; /* don't collapse spaces in inline code blocks */
}
pre {
/* have to use overlay rather than auto otherwise Linux and Windows */
/* Chrome gets very confused about vertical spacing: */
/* https://github.com/vector-im/vector-web/issues/754 */
overflow-x: overlay;
overflow-y: visible;
&::-webkit-scrollbar-corner {
background: transparent;
}
border: 1px solid var(--cpd-color-gray-400);
code {
white-space: pre; /* we want code blocks to be scrollable and not wrap */
> * {
display: inline;
}
}
}
}
.EventTile_searchHighlight {
background-color: var(--cpd-color-text-action-accent);
color: var(--cpd-color-text-on-solid-primary);
border-radius: 5px; /* no compund for 5px */
padding-inline: var(--cpd-space-0-5x);
cursor: pointer;
a {
background-color: var(--cpd-color-text-action-accent);
color: var(--cpd-color-text-on-solid-primary);
}
}
@@ -0,0 +1,92 @@
/*
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 } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { useMockedViewModel } from "../../viewmodel/useMockedViewModel";
import { EventContentBodyView, type EventContentBodyViewSnapshot } from "./EventContentBodyView";
import styles from "./EventContentBody.module.css";
import { withViewDocs } from "../../../.storybook/withViewDocs";
type EventContentBodyStoryProps = EventContentBodyViewSnapshot & {
as: "div" | "span";
};
const EventContentBodyWrapperImpl = ({ as, ...snapshot }: EventContentBodyStoryProps): JSX.Element => {
const vm = useMockedViewModel(snapshot, {});
return <EventContentBodyView vm={vm} as={as} />;
};
const EventContentBodyWrapper = withViewDocs(EventContentBodyWrapperImpl, EventContentBodyView);
const meta = {
title: "MessageBody/EventContentBody",
component: EventContentBodyWrapper,
tags: ["autodocs"],
args: {
as: "div",
className: styles.EventTile_body,
dir: "auto",
},
} satisfies Meta<typeof EventContentBodyWrapper>;
export default meta;
type Story = StoryObj<typeof meta>;
export const PlainText: Story = {
args: {
body: "Hello, this is a plain text message.",
className: styles.EventTile_body,
},
};
export const BigEmoji: Story = {
args: {
body: [
<span key="wave" className={styles.Emoji} title=":wave:">
👋
</span>,
<span key="smile" className={styles.Emoji} title=":smile:">
😊
</span>,
],
className: `${styles.EventTile_body} ${styles.EventTile_bigEmoji}`,
},
};
export const HtmlContent: Story = {
args: {
body: "This is bold and italic text with a link.",
formattedBody:
"<p>This is <strong>bold</strong> and <em>italic</em> text with a <a href='https://matrix.org'>link</a>.</p>",
className: `${styles.EventTile_body} ${styles.markdownBody}`,
},
};
export const CodeBlock: Story = {
args: {
body: 'function hello() {\n console.log("Hello, world!");\n}',
formattedBody: '<pre><code>function hello() {\n console.log("Hello, world!");\n}</code></pre>',
className: `${styles.EventTile_body} ${styles.markdownBody}`,
},
};
export const AsSpan: Story = {
args: {
as: "span",
body: "This is rendered as a span element.",
className: styles.EventTile_body,
},
};
export const WithHighlight: Story = {
args: {
body: "Message with a highlighted word.",
formattedBody: `Message with a <span class="${styles.EventTile_searchHighlight}">highlighted</span> word.`,
className: styles.EventTile_body,
},
};
@@ -0,0 +1,53 @@
/*
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 { composeStories } from "@storybook/react-vite";
import { render, screen } from "@test-utils";
import React from "react";
import { describe, it, expect } from "vitest";
import * as stories from "./EventContentBody.stories";
const { PlainText, BigEmoji, HtmlContent, CodeBlock, AsSpan, WithHighlight } = composeStories(stories);
describe("EventContentBodyView", () => {
it("renders plain text correctly", () => {
const { container } = render(<PlainText />);
expect(container).toMatchSnapshot();
});
it("renders big emoji correctly", () => {
const { container } = render(<BigEmoji />);
expect(container).toMatchSnapshot();
});
it("renders HTML content correctly", () => {
const { container } = render(<HtmlContent />);
expect(container).toMatchSnapshot();
});
it("renders code block correctly", () => {
const { container } = render(<CodeBlock />);
expect(container).toMatchSnapshot();
});
it("renders as span when specified", () => {
const { container } = render(<AsSpan />);
expect(container).toMatchSnapshot();
expect(container.querySelector("span")).toBeInTheDocument();
});
it("renders highlighted content correctly", () => {
const { container } = render(<WithHighlight />);
expect(container).toMatchSnapshot();
});
it("displays expected text content", () => {
render(<PlainText />);
expect(screen.getByText("Hello, this is a plain text message.")).toBeInTheDocument();
});
});
@@ -0,0 +1,110 @@
/*
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, memo, type Ref } from "react";
import parse, { type HTMLReactParserOptions } from "html-react-parser";
import { type ViewModel, useViewModel } from "../../viewmodel";
import { applyReplacerOnString } from "../../utils/applyReplacerOnString";
type Replacer = HTMLReactParserOptions["replace"];
type ParseFormattedBody = (formattedBody: string, replacer?: Replacer) => ReturnType<typeof parse>;
/**
* Snapshot interface for the EventContentBody view.
*/
export interface EventContentBodyViewSnapshot {
/**
* The plain/emoji body content to render when no formatted body is available.
*/
body: string | JSX.Element[];
/**
* The raw formatted body HTML, if available.
*/
formattedBody?: string;
/**
* The text/element replacer used for pills, spoilers, code blocks, etc.
*/
replacer?: Replacer;
/**
* Optional parser implementation for formatted bodies.
* This allows callers to provide a parser that matches their replacer implementation.
*/
parseFormattedBody?: ParseFormattedBody;
/**
* CSS class names to apply to the container element.
*/
className: string;
/**
* The text direction attribute.
* Always "auto" for divs, controlled by includeDir prop for spans.
*/
dir?: "auto";
}
export type EventContentBodyViewModel = ViewModel<EventContentBodyViewSnapshot>;
interface EventContentBodyBaseViewProps {
/**
* The ViewModel providing the snapshot data.
*/
vm: EventContentBodyViewModel;
}
export type EventContentBodyViewProps = EventContentBodyBaseViewProps &
(
| {
/**
* Render the content in a span element.
*/
as: "span";
/**
* Optional ref to forward to the rendered span element.
*/
ref?: Ref<HTMLSpanElement>;
}
| {
/**
* Render the content in a div element.
*/
as: "div";
/**
* Optional ref to forward to the rendered div element.
*/
ref?: Ref<HTMLDivElement>;
}
);
/**
* View component for rendering Matrix event content body.
*/
export const EventContentBodyView = memo(function EventContentBodyView({
vm,
as,
ref,
}: Readonly<EventContentBodyViewProps>): JSX.Element {
const { body, formattedBody, replacer, className, dir, parseFormattedBody } = useViewModel(vm);
const parseBody =
parseFormattedBody ??
((formatted: string, inputReplacer?: Replacer) =>
parse(formatted, inputReplacer ? { replace: inputReplacer } : undefined));
const children = formattedBody ? parseBody(formattedBody, replacer) : applyReplacerOnString(body, replacer);
if (as === "span") {
return (
<span ref={ref} className={className} dir={dir}>
{children}
</span>
);
}
return (
<div ref={ref} className={className} dir={dir}>
{children}
</div>
);
});
@@ -0,0 +1,106 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`EventContentBodyView > renders HTML content correctly 1`] = `
<div>
<div
class="EventTile_body markdownBody"
dir="auto"
>
<p>
This is
<strong>
bold
</strong>
and
<em>
italic
</em>
text with a
<a
href="https://matrix.org"
>
link
</a>
.
</p>
</div>
</div>
`;
exports[`EventContentBodyView > renders as span when specified 1`] = `
<div>
<span
class="EventTile_body"
dir="auto"
>
This is rendered as a span element.
</span>
</div>
`;
exports[`EventContentBodyView > renders big emoji correctly 1`] = `
<div>
<div
class="EventTile_body EventTile_bigEmoji"
dir="auto"
>
<span
class="Emoji"
title=":wave:"
>
👋
</span>
<span
class="Emoji"
title=":smile:"
>
😊
</span>
</div>
</div>
`;
exports[`EventContentBodyView > renders code block correctly 1`] = `
<div>
<div
class="EventTile_body markdownBody"
dir="auto"
>
<pre>
<code>
function hello() {
console.log("Hello, world!");
}
</code>
</pre>
</div>
</div>
`;
exports[`EventContentBodyView > renders highlighted content correctly 1`] = `
<div>
<div
class="EventTile_body"
dir="auto"
>
Message with a
<span
class="EventTile_searchHighlight"
>
highlighted
</span>
word.
</div>
</div>
`;
exports[`EventContentBodyView > renders plain text correctly 1`] = `
<div>
<div
class="EventTile_body"
dir="auto"
>
Hello, this is a plain text message.
</div>
</div>
`;
@@ -0,0 +1,13 @@
/*
* 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 {
EventContentBodyView,
type EventContentBodyViewSnapshot,
type EventContentBodyViewModel,
type EventContentBodyViewProps,
} from "./EventContentBodyView";
@@ -0,0 +1,35 @@
/*
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 } from "react";
import { Text, type HTMLReactParserOptions } from "html-react-parser";
type Replacer = HTMLReactParserOptions["replace"];
/**
* Applies a parser replacer to string content while passing through JSX elements unchanged.
*
* @param input Plain-text body content or pre-rendered JSX elements (for example emoji bodies).
* Non-string items are returned verbatim.
* @param replacer Optional replace callback to run on string items.
* @returns The original `input` when no replacer is provided; otherwise an array where string
* items are replaced and JSX elements are passed through unchanged.
*/
export function applyReplacerOnString(
input: string | JSX.Element[],
replacer?: Replacer,
): JSX.Element | JSX.Element[] | string {
if (!replacer) return input;
const arr = Array.isArray(input) ? input : [input];
return arr.map((item, index): JSX.Element => {
if (typeof item === "string") {
return <React.Fragment key={index}>{(replacer(new Text(item), 0) as JSX.Element) || item}</React.Fragment>;
}
return item;
});
}