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 10:58:05 +01:00
committed by GitHub
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);
});
});