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
/>
);