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:
co-authored by
R Midhun Suresh
Florian Duros
parent
3e77974fa0
commit
8d076c897d
@@ -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";
|
||||
|
||||
+89
@@ -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);
|
||||
}
|
||||
}
|
||||
+92
@@ -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,
|
||||
},
|
||||
};
|
||||
+53
@@ -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();
|
||||
});
|
||||
});
|
||||
+110
@@ -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>
|
||||
);
|
||||
});
|
||||
+106
@@ -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;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user