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
@@ -9,8 +9,9 @@ Please see LICENSE files in the repository root for full details.
import React, { type JSX, createRef } from "react";
import { type EventStatus, type IContent, type MatrixEvent, MatrixEventEvent, MsgType } from "matrix-js-sdk/src/matrix";
import classNames from "classnames";
import { EventContentBodyView } from "@element-hq/web-shared-components";
import EventContentBody from "./EventContentBody.tsx";
import { EventContentBodyViewModel } from "../../../viewmodels/message-body/EventContentBodyViewModel";
import { editBodyDiffToHtml } from "../../../utils/MessageDiffUtils";
import { formatTime } from "../../../DateUtils";
import { _t } from "../../../languageHandler";
@@ -45,17 +46,39 @@ export default class EditHistoryMessage extends React.PureComponent<IProps, ISta
declare public context: React.ContextType<typeof MatrixClientContext>;
private content = createRef<HTMLDivElement>();
private EventContentBodyViewModel: EventContentBodyViewModel;
public constructor(props: IProps, context: React.ContextType<typeof MatrixClientContext>) {
super(props, context);
const cli = this.context;
const userId = cli.getSafeUserId();
const event = this.props.mxEvent;
const event = props.mxEvent;
const room = cli.getRoom(event.getRoomId());
event.localRedactionEvent()?.on(MatrixEventEvent.Status, this.onAssociatedStatusChanged);
const canRedact = room?.currentState.maySendRedactionForEvent(event, userId) ?? false;
this.state = { canRedact, sendStatus: event.getAssociatedStatus() };
const mxEventContent = getReplacedContent(event);
this.EventContentBodyViewModel = new EventContentBodyViewModel({
mxEvent: event,
content: mxEventContent,
highlights: [],
stripReply: true,
renderTooltipsForAmbiguousLinks: true,
renderMentionPills: true,
renderCodeBlocks: true,
renderSpoilers: true,
linkify: true,
client: cli,
});
}
public componentDidUpdate(prevProps: IProps): void {
if (prevProps.mxEvent !== this.props.mxEvent) {
const mxEventContent = getReplacedContent(this.props.mxEvent);
this.EventContentBodyViewModel.setEventContent(this.props.mxEvent, mxEventContent);
}
}
private onAssociatedStatusChanged = (): void => {
@@ -92,6 +115,7 @@ export default class EditHistoryMessage extends React.PureComponent<IProps, ISta
public componentWillUnmount(): void {
const event = this.props.mxEvent;
event.localRedactionEvent()?.off(MatrixEventEvent.Status, this.onAssociatedStatusChanged);
this.EventContentBodyViewModel.dispose();
}
private renderActionBar(): React.ReactNode {
@@ -133,20 +157,7 @@ export default class EditHistoryMessage extends React.PureComponent<IProps, ISta
if (this.props.previousEdit) {
contentElements = editBodyDiffToHtml(getReplacedContent(this.props.previousEdit), content);
} else {
contentElements = (
<EventContentBody
as="span"
mxEvent={mxEvent}
content={content}
highlights={[]}
stripReply
renderTooltipsForAmbiguousLinks
renderMentionPills
renderCodeBlocks
renderSpoilers
linkify
/>
);
contentElements = <EventContentBodyView vm={this.EventContentBodyViewModel} as="span" />;
}
if (mxEvent.getContent().msgtype === MsgType.Emote) {
const name = mxEvent.sender ? mxEvent.sender.name : mxEvent.getSender();
@@ -1,189 +0,0 @@
/*
Copyright 2025 New Vector 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, { memo, useContext, useMemo, type Ref } from "react";
import { type IContent, type MatrixEvent, MsgType, PushRuleKind } from "matrix-js-sdk/src/matrix";
import parse from "html-react-parser";
import { PushProcessor } from "matrix-js-sdk/src/pushprocessor";
import { bodyToNode } from "../../../HtmlUtils.tsx";
import PlatformPeg from "../../../PlatformPeg.ts";
import {
applyReplacerOnString,
combineRenderers,
type Replacer,
type RendererMap,
keywordPillRenderer,
mentionPillRenderer,
ambiguousLinkTooltipRenderer,
codeBlockRenderer,
spoilerRenderer,
} from "../../../renderer";
import MatrixClientContext from "../../../contexts/MatrixClientContext.tsx";
import { useSettingValue } from "../../../hooks/useSettings.ts";
import { filterBoolean } from "../../../utils/arrays.ts";
import { useMediaVisible } from "../../../hooks/useMediaVisible.ts";
/**
* Returns a RegExp pattern for the keyword in the push rule of the given Matrix event, if any
* @param mxEvent - the Matrix event to get the push rule keyword pattern from
*/
const getPushDetailsKeywordPatternRegexp = (mxEvent: MatrixEvent): RegExp | undefined => {
const pushDetails = mxEvent.getPushDetails();
if (
pushDetails.rule?.enabled &&
pushDetails.rule.kind === PushRuleKind.ContentSpecific &&
pushDetails.rule.pattern
) {
return PushProcessor.getPushRuleGlobRegex(pushDetails.rule.pattern, true, "gi");
}
return undefined;
};
interface ReplacerOptions {
/**
* Whether to render room/user mentions as pills
*/
renderMentionPills?: boolean;
/**
* Whether to render push rule keywords as pills
*/
renderKeywordPills?: boolean;
/**
* Whether to render spoilers as hidden content revealed on click
*/
renderSpoilers?: boolean;
/**
* Whether to render code blocks as syntax highlighted code with a copy to clipboard button
*/
renderCodeBlocks?: boolean;
/**
* Whether to render tooltips for ambiguous links, only effective on platforms which specify `needsUrlTooltips` true
*/
renderTooltipsForAmbiguousLinks?: boolean;
}
// Returns a memoized Replacer based on the input parameters
const useReplacer = (content: IContent, mxEvent: MatrixEvent | undefined, options: ReplacerOptions): Replacer => {
const cli = useContext(MatrixClientContext);
const room = cli.getRoom(mxEvent?.getRoomId()) ?? undefined;
const shouldShowPillAvatar = useSettingValue("Pill.shouldShowPillAvatar");
const isHtml = content.format === "org.matrix.custom.html";
const replacer = useMemo(() => {
const keywordRegexpPattern = mxEvent ? getPushDetailsKeywordPatternRegexp(mxEvent) : undefined;
const replacers = filterBoolean<RendererMap>([
options.renderMentionPills ? mentionPillRenderer : undefined,
options.renderKeywordPills && keywordRegexpPattern ? keywordPillRenderer : undefined,
options.renderTooltipsForAmbiguousLinks && PlatformPeg.get()?.needsUrlTooltips()
? ambiguousLinkTooltipRenderer
: undefined,
options.renderSpoilers ? spoilerRenderer : undefined,
options.renderCodeBlocks ? codeBlockRenderer : undefined,
]);
return combineRenderers(...replacers)({
isHtml,
mxEvent,
room,
shouldShowPillAvatar,
keywordRegexpPattern,
});
}, [
mxEvent,
options.renderMentionPills,
options.renderKeywordPills,
options.renderTooltipsForAmbiguousLinks,
options.renderSpoilers,
options.renderCodeBlocks,
isHtml,
room,
shouldShowPillAvatar,
]);
return replacer;
};
interface Props extends ReplacerOptions {
/**
* Whether to render the content in a div or span
*/
as: "span" | "div";
/**
* Whether to render links as clickable anchors
*/
linkify: boolean;
/**
* The Matrix event to render, required for renderMentionPills & renderKeywordPills
*/
mxEvent?: MatrixEvent;
/**
* The content to render
*/
content: IContent;
/**
* Whether to strip reply fallbacks from the content before rendering
*/
stripReply?: boolean;
/**
* Highlights to emphasise in the content
*/
highlights?: string[];
/**
* Whether to include the `dir="auto"` attribute on the rendered element
*/
includeDir?: boolean;
ref?: Ref<HTMLElement>;
}
/**
* Component to render a Matrix event's content body.
* If the content is formatted HTML then it will be sanitised before rendering.
* A number of rendering features are supported as configured by {@link ReplacerOptions}
* Returns a div or span depending on `as`, the `dir` on a `div` is always set to `"auto"` but set by `includeDir` otherwise.
*/
const EventContentBody = memo(
({ as, mxEvent, stripReply, content, linkify, highlights, includeDir = true, ref, ...options }: Props) => {
const enableBigEmoji = useSettingValue("TextualBody.enableBigEmoji");
const [mediaIsVisible] = useMediaVisible(mxEvent);
const replacer = useReplacer(content, mxEvent, options);
const isEmote = content.msgtype === MsgType.Emote;
const { strippedBody, formattedBody, emojiBodyElements, className } = useMemo(
() =>
bodyToNode(content, highlights, {
disableBigEmoji: isEmote || !enableBigEmoji,
// Part of Replies fallback support
stripReplyFallback: stripReply,
mediaIsVisible,
linkify,
}),
[content, mediaIsVisible, enableBigEmoji, highlights, isEmote, stripReply, linkify],
);
if (as === "div") includeDir = true; // force dir="auto" on divs
const As = as;
const body = formattedBody ? (
<As ref={ref as any} className={className} dir={includeDir ? "auto" : undefined}>
{parse(formattedBody, {
replace: replacer,
})}
</As>
) : (
<As ref={ref as any} className={className} dir={includeDir ? "auto" : undefined}>
{applyReplacerOnString(emojiBodyElements || strippedBody, replacer)}
</As>
);
return body;
},
);
export default EventContentBody;
@@ -8,8 +8,9 @@ Please see LICENSE files in the repository root for full details.
import React, { type JSX, createRef, type SyntheticEvent, type MouseEvent } from "react";
import { MsgType } from "matrix-js-sdk/src/matrix";
import { EventContentBodyView } from "@element-hq/web-shared-components";
import EventContentBody from "./EventContentBody.tsx";
import { EventContentBodyViewModel } from "../../../viewmodels/message-body/EventContentBodyViewModel";
import { formatDate } from "../../../DateUtils";
import Modal from "../../../Modal";
import dis from "../../../dispatcher/dispatcher";
@@ -44,22 +45,77 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
public static contextType = RoomContext;
declare public context: React.ContextType<typeof RoomContext>;
private EventContentBodyViewModel: EventContentBodyViewModel;
public state = {
links: [],
widgetHidden: false,
};
public constructor(props: IBodyProps, context: React.ContextType<typeof RoomContext>) {
super(props, context);
const mxEvent = props.mxEvent;
const content = mxEvent.getContent();
const isEmote = content.msgtype === MsgType.Emote;
const willHaveWrapper =
!!props.replacingEventId || !!props.isSeeingThroughMessageHiddenForModeration || isEmote;
// only strip reply if this is the original replying event, edits thereafter do not have the fallback
const stripReply = !mxEvent.replacingEvent() && !!getParentEventId(mxEvent);
this.EventContentBodyViewModel = new EventContentBodyViewModel({
as: willHaveWrapper ? "span" : "div",
includeDir: false,
mxEvent,
content,
stripReply,
linkify: true,
highlights: props.highlights,
renderTooltipsForAmbiguousLinks: true,
renderKeywordPills: true,
renderMentionPills: true,
renderCodeBlocks: true,
renderSpoilers: true,
client: context.room?.client ?? null,
});
}
public componentDidMount(): void {
if (!this.props.editState) {
this.applyFormatting();
}
}
private applyFormatting(): void {
this.calculateUrlPreview();
}
public componentDidUpdate(prevProps: Readonly<IBodyProps>): void {
// Update the ViewModel when relevant props change
const mxEventChanged = prevProps.mxEvent !== this.props.mxEvent;
const highlightsChanged = prevProps.highlights !== this.props.highlights;
const wrapperChanged =
prevProps.replacingEventId !== this.props.replacingEventId ||
prevProps.isSeeingThroughMessageHiddenForModeration !==
this.props.isSeeingThroughMessageHiddenForModeration;
if (mxEventChanged || highlightsChanged || wrapperChanged) {
const mxEvent = this.props.mxEvent;
const content = mxEvent.getContent();
const isEmote = content.msgtype === MsgType.Emote;
const willHaveWrapper =
!!this.props.replacingEventId || !!this.props.isSeeingThroughMessageHiddenForModeration || isEmote;
// only strip reply if this is the original replying event, edits thereafter do not have the fallback
const stripReply = !mxEvent.replacingEvent() && !!getParentEventId(mxEvent);
this.EventContentBodyViewModel.setEventContent(mxEvent, content);
this.EventContentBodyViewModel.setStripReply(stripReply);
if (mxEventChanged || wrapperChanged) {
this.EventContentBodyViewModel.setAs(willHaveWrapper ? "span" : "div");
}
if (highlightsChanged) {
this.EventContentBodyViewModel.setHighlights(this.props.highlights);
}
}
// Handle formatting updates
if (!this.props.editState) {
const stoppedEditing = prevProps.editState && !this.props.editState;
const messageWasEdited = prevProps.replacingEventId !== this.props.replacingEventId;
@@ -70,6 +126,14 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
}
}
public componentWillUnmount(): void {
this.EventContentBodyViewModel.dispose();
}
private applyFormatting(): void {
this.calculateUrlPreview();
}
public shouldComponentUpdate(nextProps: Readonly<IBodyProps>, nextState: Readonly<IState>): boolean {
//console.info("shouldComponentUpdate: ShowUrlPreview for %s is %s", this.props.mxEvent.getId(), this.props.showUrlPreview);
@@ -311,6 +375,7 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
<EditMessageComposer editState={this.props.editState} className="mx_EventTile_content" />
);
}
const mxEvent = this.props.mxEvent;
const content = mxEvent.getContent();
const isNotice = content.msgtype === MsgType.Notice;
@@ -321,23 +386,12 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
const willHaveWrapper =
this.props.replacingEventId || this.props.isSeeingThroughMessageHiddenForModeration || isEmote;
// only strip reply if this is the original replying event, edits thereafter do not have the fallback
const stripReply = !mxEvent.replacingEvent() && !!getParentEventId(mxEvent);
let body = (
<EventContentBody
<EventContentBodyView
vm={this.EventContentBodyViewModel}
as={willHaveWrapper ? "span" : "div"}
includeDir={false}
mxEvent={mxEvent}
content={content}
stripReply={stripReply}
linkify
highlights={this.props.highlights}
ref={this.contentRef}
renderTooltipsForAmbiguousLinks
renderKeywordPills
renderMentionPills
renderCodeBlocks
renderSpoilers
/>
);
+1 -1
View File
@@ -9,4 +9,4 @@ export { ambiguousLinkTooltipRenderer } from "./link-tooltip";
export { keywordPillRenderer, mentionPillRenderer } from "./pill";
export { spoilerRenderer } from "./spoiler";
export { codeBlockRenderer } from "./code-block";
export { applyReplacerOnString, combineRenderers, type RendererMap, type Replacer } from "./utils";
export { combineRenderers, type RendererMap, type Replacer } from "./utils";
+2 -22
View File
@@ -5,8 +5,8 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX } from "react";
import { type DOMNode, Element, type HTMLReactParserOptions, Text } from "html-react-parser";
import { type JSX } from "react";
import { type DOMNode, Element, type HTMLReactParserOptions, type Text } from "html-react-parser";
import { type MatrixEvent, type Room } from "matrix-js-sdk/src/matrix";
/**
@@ -44,26 +44,6 @@ export const hasParentMatching = (node: Element, matcher: (node: ParentNode | nu
*/
export type Replacer = HTMLReactParserOptions["replace"];
/**
* Passes through any non-string inputs verbatim, as such they should only be used for emoji bodies
*/
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((input, index): JSX.Element => {
if (typeof input === "string") {
return (
<React.Fragment key={index}>{(replacer(new Text(input), 0) as JSX.Element) || input}</React.Fragment>
);
}
return input;
});
}
interface Parameters {
isHtml: boolean;
replace: Replacer;
@@ -0,0 +1,292 @@
/*
Copyright 2025 New Vector 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 { type IContent, type MatrixClient, type MatrixEvent, MsgType, PushRuleKind } from "matrix-js-sdk/src/matrix";
import parse from "html-react-parser";
import { PushProcessor } from "matrix-js-sdk/src/pushprocessor";
import {
BaseViewModel,
type EventContentBodyViewSnapshot,
type EventContentBodyViewModel as EventContentBodyViewModelInterface,
} from "@element-hq/web-shared-components";
import { bodyToNode } from "../../HtmlUtils";
import PlatformPeg from "../../PlatformPeg";
import {
combineRenderers,
type Replacer,
type RendererMap,
keywordPillRenderer,
mentionPillRenderer,
ambiguousLinkTooltipRenderer,
codeBlockRenderer,
spoilerRenderer,
} from "../../renderer";
import { filterBoolean } from "../../utils/arrays";
import SettingsStore from "../../settings/SettingsStore";
/**
* Options for configuring which renderers to apply.
*/
export interface ReplacerOptions {
/**
* Whether to render room/user mentions as pills.
* @default false
*/
renderMentionPills?: boolean;
/**
* Whether to render push rule keywords as pills.
* @default false
*/
renderKeywordPills?: boolean;
/**
* Whether to render spoilers as hidden content revealed on click.
* @default false
*/
renderSpoilers?: boolean;
/**
* Whether to render code blocks as syntax highlighted code with a copy to clipboard button.
* @default false
*/
renderCodeBlocks?: boolean;
/**
* Whether to render tooltips for ambiguous links, only effective on platforms which specify `needsUrlTooltips` true.
* @default false
*/
renderTooltipsForAmbiguousLinks?: boolean;
}
/**
* Props for the EventContentBody ViewModel.
*/
export interface EventContentBodyViewModelProps extends ReplacerOptions {
/**
* The content to render.
*/
content: IContent;
/**
* The Matrix event to render, required for renderMentionPills & renderKeywordPills.
*/
mxEvent?: MatrixEvent;
/**
* Whether to strip reply fallbacks from the content before rendering.
* @default false
*/
stripReply?: boolean;
/**
* Highlights to emphasise in the content.
*/
highlights?: string[];
/**
* Whether to render links as clickable anchors.
* @default true
*/
linkify?: boolean;
/**
* Whether to include the `dir="auto"` attribute on the rendered element.
* Always true for "div" elements.
* @default true
*/
includeDir?: boolean;
/**
* Whether to render the content in a div or span.
* @default "span"
*/
as?: "span" | "div";
/**
* Whether big emoji should be enabled.
* @default false
*/
enableBigEmoji?: boolean;
/**
* Whether media is visible in the event.
* @default true
*/
mediaIsVisible?: boolean;
/**
* Whether to show pill avatars.
* @default true
*/
shouldShowPillAvatar?: boolean;
/**
* Matrix client used to resolve room context for renderers.
*/
client: MatrixClient | null;
}
/**
* Returns a RegExp pattern for the keyword in the push rule of the given Matrix event, if any.
*/
const getPushDetailsKeywordPatternRegexp = (mxEvent: MatrixEvent): RegExp | undefined => {
const pushDetails = mxEvent.getPushDetails();
if (
pushDetails.rule?.enabled &&
pushDetails.rule.kind === PushRuleKind.ContentSpecific &&
pushDetails.rule.pattern
) {
return PushProcessor.getPushRuleGlobRegex(pushDetails.rule.pattern, true, "gi");
}
return undefined;
};
/**
* Creates a replacer function based on the provided options and context.
*/
const createReplacer = (props: EventContentBodyViewModelProps): Replacer => {
const { content, mxEvent, shouldShowPillAvatar, client, ...options } = props;
const room = client?.getRoom(mxEvent?.getRoomId()) ?? undefined;
const isHtml = content.format === "org.matrix.custom.html";
const keywordRegexpPattern = mxEvent ? getPushDetailsKeywordPatternRegexp(mxEvent) : undefined;
const replacers = filterBoolean<RendererMap>([
options.renderMentionPills ? mentionPillRenderer : undefined,
options.renderKeywordPills && keywordRegexpPattern ? keywordPillRenderer : undefined,
options.renderTooltipsForAmbiguousLinks && PlatformPeg.get()?.needsUrlTooltips()
? ambiguousLinkTooltipRenderer
: undefined,
options.renderSpoilers ? spoilerRenderer : undefined,
options.renderCodeBlocks ? codeBlockRenderer : undefined,
]);
return combineRenderers(...replacers)({
isHtml,
mxEvent,
room,
shouldShowPillAvatar,
keywordRegexpPattern,
});
};
/**
* ViewModel for EventContentBody component.
* Handles all Matrix SDK interactions and content processing.
*/
export class EventContentBodyViewModel
extends BaseViewModel<EventContentBodyViewSnapshot, EventContentBodyViewModelProps>
implements EventContentBodyViewModelInterface
{
private static readonly computeBodySnapshot = (
props: EventContentBodyViewModelProps,
): Pick<EventContentBodyViewSnapshot, "body" | "formattedBody" | "className"> => {
const { content, stripReply, highlights, linkify, enableBigEmoji, mediaIsVisible } = props;
const isEmote = content.msgtype === MsgType.Emote;
const { strippedBody, formattedBody, emojiBodyElements, className } = bodyToNode(content, highlights, {
disableBigEmoji: isEmote || !enableBigEmoji,
stripReplyFallback: stripReply,
mediaIsVisible,
linkify,
});
return {
body: emojiBodyElements || strippedBody,
formattedBody,
className,
};
};
private static readonly computeDir = (props: EventContentBodyViewModelProps): "auto" | undefined => {
const { as = "span", includeDir = true } = props;
return as === "div" || includeDir ? "auto" : undefined;
};
private static readonly parseFormattedBody = (
formattedBody: string,
replacer?: Replacer,
): ReturnType<typeof parse> => parse(formattedBody, replacer ? { replace: replacer } : undefined);
private static readonly computeSnapshot = (props: EventContentBodyViewModelProps): EventContentBodyViewSnapshot => {
const { body, formattedBody, className } = EventContentBodyViewModel.computeBodySnapshot(props);
const replacer = createReplacer(props);
const dir = EventContentBodyViewModel.computeDir(props);
return {
body,
formattedBody,
replacer,
parseFormattedBody: EventContentBodyViewModel.parseFormattedBody,
className,
dir,
};
};
public constructor(props: EventContentBodyViewModelProps) {
const propsWithSettingDefaults: EventContentBodyViewModelProps = {
...props,
renderMentionPills: props.renderMentionPills ?? false,
renderKeywordPills: props.renderKeywordPills ?? false,
renderSpoilers: props.renderSpoilers ?? false,
renderCodeBlocks: props.renderCodeBlocks ?? false,
renderTooltipsForAmbiguousLinks: props.renderTooltipsForAmbiguousLinks ?? false,
enableBigEmoji: props.enableBigEmoji ?? SettingsStore.getValue("TextualBody.enableBigEmoji"),
shouldShowPillAvatar: props.shouldShowPillAvatar ?? SettingsStore.getValue("Pill.shouldShowPillAvatar"),
};
super(propsWithSettingDefaults, EventContentBodyViewModel.computeSnapshot(propsWithSettingDefaults));
const enableBigEmojiWatcherRef = SettingsStore.watchSetting(
"TextualBody.enableBigEmoji",
null,
(_settingName, _roomId, _level, _newValAtLevel, newVal) => {
this.setEnableBigEmoji(newVal);
},
);
this.disposables.track(() => SettingsStore.unwatchSetting(enableBigEmojiWatcherRef));
const shouldShowPillAvatarWatcherRef = SettingsStore.watchSetting(
"Pill.shouldShowPillAvatar",
null,
(_settingName, _roomId, _level, _newValAtLevel, newVal) => {
this.setShouldShowPillAvatar(newVal);
},
);
this.disposables.track(() => SettingsStore.unwatchSetting(shouldShowPillAvatarWatcherRef));
}
public setEventContent = (mxEvent: MatrixEvent | undefined, content: IContent): void => {
this.props.mxEvent = mxEvent;
this.props.content = content;
const { body, formattedBody, className } = EventContentBodyViewModel.computeBodySnapshot(this.props);
const replacer = createReplacer(this.props);
this.snapshot.merge({ body, formattedBody, replacer, className });
};
public setStripReply = (stripReply?: boolean): void => {
this.props.stripReply = stripReply;
const { body, formattedBody, className } = EventContentBodyViewModel.computeBodySnapshot(this.props);
this.snapshot.merge({ body, formattedBody, className });
};
public setHighlights = (highlights?: string[]): void => {
this.props.highlights = highlights;
const { body, formattedBody, className } = EventContentBodyViewModel.computeBodySnapshot(this.props);
this.snapshot.merge({ body, formattedBody, className });
};
public setAs = (as: "span" | "div"): void => {
this.props.as = as;
const dir = EventContentBodyViewModel.computeDir(this.props);
this.snapshot.merge({ dir });
};
public setEnableBigEmoji = (enableBigEmoji?: boolean): void => {
this.props.enableBigEmoji = enableBigEmoji;
const { body, formattedBody, className } = EventContentBodyViewModel.computeBodySnapshot(this.props);
this.snapshot.merge({ body, formattedBody, className });
};
public setShouldShowPillAvatar = (shouldShowPillAvatar?: boolean): void => {
this.props.shouldShowPillAvatar = shouldShowPillAvatar;
const replacer = createReplacer(this.props);
this.snapshot.merge({ replacer });
};
}
@@ -19,13 +19,14 @@ import {
mkStubRoom,
mockClientPushProcessor,
} from "../../../../test-utils";
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
import * as languageHandler from "../../../../../src/languageHandler";
import DMRoomMap from "../../../../../src/utils/DMRoomMap";
import TextualBody from "../../../../../src/components/views/messages/TextualBody";
import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext";
import RoomContext from "../../../../../src/contexts/RoomContext";
import { RoomPermalinkCreator } from "../../../../../src/utils/permalinks/Permalinks";
import { type MediaEventHelper } from "../../../../../src/utils/MediaEventHelper";
import { getRoomContext } from "../../../../test-utils/room";
const room1Id = "!room1:example.com";
const room2Id = "!room2:example.com";
@@ -57,7 +58,6 @@ const mkFormattedMessage = (body: string, formattedBody: string): MatrixEvent =>
describe("<TextualBody />", () => {
afterEach(() => {
jest.spyOn(MatrixClientPeg, "get").mockRestore();
jest.spyOn(global.Math, "random").mockRestore();
});
@@ -114,12 +114,17 @@ describe("<TextualBody />", () => {
jest.spyOn(global.Math, "random").mockReturnValue(0.123456);
});
const getComponent = (props = {}, matrixClient: MatrixClient = defaultMatrixClient, renderingFn?: any) =>
(renderingFn ?? render)(
const getComponent = (props = {}, matrixClient: MatrixClient = defaultMatrixClient, renderingFn?: any) => {
const mergedProps = { ...defaultProps, ...props };
const room = matrixClient.getRoom(mergedProps.mxEvent.getRoomId()) ?? defaultRoom;
return (renderingFn ?? render)(
<MatrixClientContext.Provider value={matrixClient}>
<TextualBody {...defaultProps} {...props} />
<RoomContext.Provider value={getRoomContext(room, {})}>
<TextualBody {...mergedProps} />
</RoomContext.Provider>
</MatrixClientContext.Provider>,
);
};
it("renders m.emote correctly", () => {
DMRoomMap.makeShared(defaultMatrixClient);
@@ -0,0 +1,373 @@
/*
* 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 { MsgType, PushRuleKind, type MatrixEvent, type Room } from "matrix-js-sdk/src/matrix";
import { type JSX } from "react";
import {
EventContentBodyViewModel,
type EventContentBodyViewModelProps,
} from "../../../src/viewmodels/message-body/EventContentBodyViewModel";
import { stubClient, mkStubRoom, mkEvent } from "../../test-utils";
import { bodyToNode } from "../../../src/HtmlUtils";
import {
combineRenderers,
mentionPillRenderer,
keywordPillRenderer,
ambiguousLinkTooltipRenderer,
spoilerRenderer,
codeBlockRenderer,
} from "../../../src/renderer";
import PlatformPeg from "../../../src/PlatformPeg";
import type BasePlatform from "../../../src/BasePlatform";
import SettingsStore from "../../../src/settings/SettingsStore";
jest.mock("../../../src/HtmlUtils", () => ({
...jest.requireActual("../../../src/HtmlUtils"),
bodyToNode: jest.fn(),
}));
jest.mock("../../../src/renderer", () => ({
combineRenderers: jest.fn(),
mentionPillRenderer: jest.fn(),
keywordPillRenderer: jest.fn(),
ambiguousLinkTooltipRenderer: jest.fn(),
codeBlockRenderer: jest.fn(),
spoilerRenderer: jest.fn(),
}));
jest.mock("../../../src/PlatformPeg", () => ({
__esModule: true,
default: {
get: jest.fn(),
},
}));
const mockedBodyToNode = jest.mocked(bodyToNode);
const mockedCombineRenderers = jest.mocked(combineRenderers);
const mockedPlatformPeg = jest.mocked(PlatformPeg);
describe("EventContentBodyViewModel", () => {
const defaultContent = {
body: "Hello world",
msgtype: MsgType.Text,
};
const defaultProps = (overrides: Partial<EventContentBodyViewModelProps> = {}): EventContentBodyViewModelProps => ({
client: null,
content: defaultContent,
linkify: false,
as: "span",
...overrides,
});
beforeEach(() => {
mockedBodyToNode.mockReset();
mockedCombineRenderers.mockReset();
mockedPlatformPeg.get.mockReset();
mockedPlatformPeg.get.mockReturnValue(null);
});
it("passes render options to bodyToNode", () => {
const replacer = jest.fn();
mockedCombineRenderers.mockReturnValue(() => replacer);
mockedBodyToNode.mockReturnValue({
strippedBody: "Hello world",
formattedBody: undefined,
emojiBodyElements: undefined,
className: "mx_EventTile_body",
});
const vm = new EventContentBodyViewModel(
defaultProps({
linkify: true,
stripReply: true,
enableBigEmoji: true,
mediaIsVisible: false,
}),
);
const snapshot = vm.getSnapshot();
expect(mockedBodyToNode).toHaveBeenCalledWith(defaultContent, undefined, {
disableBigEmoji: false,
stripReplyFallback: true,
mediaIsVisible: false,
linkify: true,
});
expect(snapshot.body).toBe("Hello world");
expect(snapshot.replacer).toBe(replacer);
expect(snapshot.className).toContain("mx_EventTile_body");
});
it("initializes setting-backed options from SettingsStore when omitted", () => {
const replacer = jest.fn();
const createReplacerFromOptions = jest.fn().mockReturnValue(replacer);
mockedCombineRenderers.mockReturnValue(createReplacerFromOptions);
mockedBodyToNode.mockReturnValue({
strippedBody: "Hello world",
formattedBody: undefined,
emojiBodyElements: undefined,
className: "mx_EventTile_body",
});
const getValueSpy = jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName) => {
if (settingName === "TextualBody.enableBigEmoji") return false;
if (settingName === "Pill.shouldShowPillAvatar") return false;
return true;
});
new EventContentBodyViewModel(defaultProps());
expect(getValueSpy).toHaveBeenCalledWith("TextualBody.enableBigEmoji");
expect(getValueSpy).toHaveBeenCalledWith("Pill.shouldShowPillAvatar");
expect(mockedBodyToNode).toHaveBeenCalledWith(
defaultContent,
undefined,
expect.objectContaining({ disableBigEmoji: true }),
);
expect(mockedCombineRenderers).toHaveBeenCalledWith();
expect(createReplacerFromOptions).toHaveBeenCalledWith(
expect.objectContaining({ shouldShowPillAvatar: false }),
);
getValueSpy.mockRestore();
});
it("uses the injected client to resolve the room for renderer context", () => {
const replacer = jest.fn();
const createReplacerFromOptions = jest.fn().mockReturnValue(replacer);
mockedCombineRenderers.mockReturnValue(createReplacerFromOptions);
mockedBodyToNode.mockReturnValue({
strippedBody: "Hello world",
formattedBody: undefined,
emojiBodyElements: undefined,
className: "mx_EventTile_body",
});
const client = stubClient();
const mxEvent = mkEvent({
type: "m.room.message",
room: "!room:example.org",
user: "@user:example.org",
content: defaultContent,
event: true,
});
const room = mkStubRoom("!room:example.org", "Room", client) as Room;
const getRoomSpy = jest.spyOn(client, "getRoom").mockReturnValue(room);
new EventContentBodyViewModel(defaultProps({ mxEvent, client }));
expect(getRoomSpy).toHaveBeenCalledWith("!room:example.org");
expect(createReplacerFromOptions).toHaveBeenCalledWith(expect.objectContaining({ room }));
});
it("forces disableBigEmoji for emote events", () => {
const replacer = jest.fn();
mockedCombineRenderers.mockReturnValue(() => replacer);
mockedBodyToNode.mockReturnValue({
strippedBody: "Emote",
formattedBody: undefined,
emojiBodyElements: undefined,
className: "mx_EventTile_body",
});
new EventContentBodyViewModel(
defaultProps({
content: {
body: "Emote",
msgtype: MsgType.Emote,
},
enableBigEmoji: true,
}),
);
expect(mockedBodyToNode).toHaveBeenCalledWith(
{ body: "Emote", msgtype: MsgType.Emote },
undefined,
expect.objectContaining({ disableBigEmoji: true }),
);
});
it("uses parse when formattedBody is provided", () => {
const replacer = jest.fn();
mockedCombineRenderers.mockReturnValue(() => replacer);
mockedBodyToNode.mockReturnValue({
strippedBody: "Hello world",
formattedBody: "<b>Hello</b>",
emojiBodyElements: undefined,
className: "mx_EventTile_body",
});
const vm = new EventContentBodyViewModel(defaultProps());
const snapshot = vm.getSnapshot();
expect(snapshot.formattedBody).toBe("<b>Hello</b>");
expect(snapshot.body).toBe("Hello world");
expect(snapshot.replacer).toBe(replacer);
});
it("uses emojiBodyElements when provided", () => {
const replacer = jest.fn();
mockedCombineRenderers.mockReturnValue(() => replacer);
const emojiElements = ["emoji"] as unknown as JSX.Element[];
mockedBodyToNode.mockReturnValue({
strippedBody: "ignored",
formattedBody: undefined,
emojiBodyElements: emojiElements,
className: "mx_EventTile_body",
});
const vm = new EventContentBodyViewModel(defaultProps());
expect(vm.getSnapshot().body).toBe(emojiElements);
expect(vm.getSnapshot().replacer).toBe(replacer);
});
it("sets dir to auto for div elements even when includeDir is false", () => {
const replacer = jest.fn();
mockedCombineRenderers.mockReturnValue(() => replacer);
mockedBodyToNode.mockReturnValue({
strippedBody: "Hello world",
formattedBody: undefined,
emojiBodyElements: undefined,
className: "mx_EventTile_body",
});
const vm = new EventContentBodyViewModel(defaultProps({ as: "div", includeDir: false }));
expect(vm.getSnapshot().dir).toBe("auto");
});
it("omits dir when includeDir is false on span elements", () => {
const replacer = jest.fn();
mockedCombineRenderers.mockReturnValue(() => replacer);
mockedBodyToNode.mockReturnValue({
strippedBody: "Hello world",
formattedBody: undefined,
emojiBodyElements: undefined,
className: "mx_EventTile_body",
});
const vm = new EventContentBodyViewModel(defaultProps({ as: "span", includeDir: false }));
expect(vm.getSnapshot().dir).toBeUndefined();
});
it("updates snapshot when setEventContent changes content", () => {
const replacer = jest.fn();
mockedCombineRenderers.mockReturnValue(() => replacer);
mockedBodyToNode.mockReturnValue({
strippedBody: "Initial",
formattedBody: undefined,
emojiBodyElements: undefined,
className: "mx_EventTile_body",
});
const vm = new EventContentBodyViewModel(defaultProps());
expect(vm.getSnapshot().body).toBe("Initial");
mockedBodyToNode.mockReturnValue({
strippedBody: "Updated",
formattedBody: undefined,
emojiBodyElements: undefined,
className: "mx_EventTile_body",
});
vm.setEventContent(undefined, { body: "Updated", msgtype: MsgType.Text });
expect(vm.getSnapshot().body).toBe("Updated");
});
it("emits updates when setters are called with unchanged values", () => {
const replacer = jest.fn();
mockedCombineRenderers.mockReturnValue(() => replacer);
mockedBodyToNode.mockReturnValue({
strippedBody: "Initial",
formattedBody: undefined,
emojiBodyElements: undefined,
className: "mx_EventTile_body",
});
const vm = new EventContentBodyViewModel(defaultProps());
const previousSnapshot = vm.getSnapshot();
const subscriber = jest.fn();
vm.subscribe(subscriber);
vm.setEventContent(undefined, defaultContent);
vm.setAs("span");
expect(subscriber).toHaveBeenCalledTimes(2);
expect(vm.getSnapshot()).toEqual(previousSnapshot);
});
it("includes renderers based on options and platform capabilities", () => {
const replacer = jest.fn();
mockedCombineRenderers.mockReturnValue(() => replacer);
mockedBodyToNode.mockReturnValue({
strippedBody: "Hello world",
formattedBody: undefined,
emojiBodyElements: undefined,
className: "mx_EventTile_body",
});
mockedPlatformPeg.get.mockReturnValue({ needsUrlTooltips: () => true } as unknown as BasePlatform);
const client = stubClient();
const mxEvent = mkEvent({
type: "m.room.message",
room: "!room:example.org",
user: "@user:example.org",
content: defaultContent,
event: true,
});
jest.spyOn(mxEvent, "getPushDetails").mockReturnValue({
rule: {
enabled: true,
kind: PushRuleKind.ContentSpecific,
pattern: "Hello",
},
} as unknown as ReturnType<MatrixEvent["getPushDetails"]>);
jest.spyOn(client, "getRoom").mockReturnValue(mkStubRoom("!room:example.org", "Room", client) as Room);
new EventContentBodyViewModel(
defaultProps({
renderMentionPills: true,
renderKeywordPills: true,
renderTooltipsForAmbiguousLinks: true,
renderSpoilers: true,
renderCodeBlocks: true,
mxEvent,
}),
);
expect(mockedCombineRenderers).toHaveBeenCalledWith(
mentionPillRenderer,
keywordPillRenderer,
ambiguousLinkTooltipRenderer,
spoilerRenderer,
codeBlockRenderer,
);
});
it("skips tooltip renderer when platform does not need URL tooltips", () => {
const replacer = jest.fn();
mockedCombineRenderers.mockReturnValue(() => replacer);
mockedBodyToNode.mockReturnValue({
strippedBody: "Hello world",
formattedBody: undefined,
emojiBodyElements: undefined,
className: "mx_EventTile_body",
});
mockedPlatformPeg.get.mockReturnValue({ needsUrlTooltips: () => false } as unknown as BasePlatform);
new EventContentBodyViewModel(
defaultProps({
renderMentionPills: true,
renderTooltipsForAmbiguousLinks: true,
}),
);
expect(mockedCombineRenderers).toHaveBeenCalledWith(mentionPillRenderer);
});
});
+1
View File
@@ -56,6 +56,7 @@
"@vector-im/compound-design-tokens": "catalog:",
"classnames": "^2.5.1",
"counterpart": "^0.18.6",
"html-react-parser": "^5.2.2",
"lodash": "npm:lodash-es@^4.17.21",
"matrix-web-i18n": "catalog:",
"react-merge-refs": "^3.0.2",
+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;
});
}
+3
View File
@@ -775,6 +775,9 @@ importers:
counterpart:
specifier: ^0.18.6
version: 0.18.6
html-react-parser:
specifier: ^5.2.2
version: 5.2.17(@types/react@19.2.10)(react@19.2.4)
lodash:
specifier: npm:lodash-es@^4.17.21
version: lodash-es@4.17.23