Refactor EventContentBody to shared-components (#31914)
* Init of refactoring of eventcontentbody * update stories css by copying css from element x to shared components * Replaced old component EventContentBody with newly created mmvm component EventContentBodyViewModel * Refactor TextualBody and EditHistoryMessage to properly manage EventContentBodyViewModel * generated snapshot after vitest * Update import placement for eslint to pass CI * Fixed lint warnings * Update css for codeblock to represent js highlight * test: add EventContentBodyViewModel snapshot coverage * fix: pass content ref to EventContentBodyView for link previews * Fix: return to old code that passed tests * Added storybook snapshots * Removal of old component that is being unused * Update snapshot * Fix missing enableBigEmoji and shouldShowPillAvatar settings in EventContentBodyViewModel * update snapshot * narrow setProps to mutable fields and skip no-op snapshot recomputes * Update Snapshots * replace EventContentBodyViewModel setProps with explicit setters and update call sites * render body in view and keep parser/replacer in snapshot * Eslint Restruct * Eslint Restructure * Removed unused function, moved to shared component * Remove Unused Module (Moved To Shared Component) * Disable EventContent-body Test to check weather it fixes CI * Enable EventContentBody Tests * Remove EventTest * Update Include in Vitest * Added EventContentBody test * Update Package.json * Update Lockfile * Update dependencies * update lockfile * ptimize EventContentBodyViewModel to recompute/merge only changed snapshot fields * Update snapshots * setEventContent and setStripReply run whenever the existing update block runs * defined arrow functions for undefined runtime issues that might occur. * Update test cases * Update packages/shared-components/src/message-body/EventContentBody/EventContentBodyView.tsx Co-authored-by: R Midhun Suresh <rmidhunsuresh@gmail.com> * Update packages/shared-components/src/message-body/EventContentBody/EventContentBodyView.tsx Co-authored-by: R Midhun Suresh <rmidhunsuresh@gmail.com> * move big-emoji and pill-avatar setting watchers into EventContentBodyViewModel * Update packages/shared-components/src/message-body/EventContentBody/index.tsx Co-authored-by: Florian Duros <florian.duros@ormaz.fr> * Update packages/shared-components/src/message-body/EventContentBody/EventContentBodyView.tsx Co-authored-by: Florian Duros <florian.duros@ormaz.fr> * Update packages/shared-components/src/message-body/EventContentBody/EventContentBody.test.tsx Co-authored-by: Florian Duros <florian.duros@ormaz.fr> * Update packages/shared-components/src/message-body/EventContentBody/EventContentBody.stories.tsx Co-authored-by: Florian Duros <florian.duros@ormaz.fr> * Update packages/shared-components/src/message-body/EventContentBody/EventContentBodyView.tsx Co-authored-by: Florian Duros <florian.duros@ormaz.fr> * Update packages/shared-components/src/message-body/EventContentBody/EventContentBodyView.tsx Co-authored-by: Florian Duros <florian.duros@ormaz.fr> * Fix dubblicate variables * clarify applyReplacerOnString input/replacer params * Added memo to the view * Prettier Fix * Update apps/web/src/viewmodels/message-body/EventContentBodyViewModel.ts Co-authored-by: Florian Duros <florian.duros@ormaz.fr> * Added compund variables instead of reguler values * Added boolean default values * remove redundant setting props from TextualBody and EditHistoryMessage * Prettier FIx * replace MatrixClientPeg usage with `client: MatrixClient | null` passed from context * TextualBody now passes EventContentBodyViewModel `client` from RoomContext. * Remove redundant as prop from EventContentBody VM usage * Normalize EventContentBodyViewModel renderer flags to booleans --------- Co-authored-by: R Midhun Suresh <rmidhunsuresh@gmail.com> Co-authored-by: Florian Duros <florian.duros@ormaz.fr>
This commit is contained in:
co-authored by
R Midhun Suresh
Florian Duros
parent
3e77974fa0
commit
8d076c897d
@@ -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
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user