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
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
@@ -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",
|
||||
|
||||
@@ -16,6 +16,7 @@ export * from "./crypto/SasEmoji";
|
||||
export * from "./event-tiles/EncryptionEventView";
|
||||
export * from "./event-tiles/EventTileBubble";
|
||||
export * from "./event-tiles/TextualEventView";
|
||||
export * from "./message-body/EventContentBody";
|
||||
export * from "./message-body/MediaBody";
|
||||
export * from "./message-body/MessageTimestampView";
|
||||
export * from "./message-body/DecryptionFailureBodyView";
|
||||
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
.EventTile_body {
|
||||
overflow-y: hidden;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.EventTile_bigEmoji {
|
||||
font-size: 48px;
|
||||
line-height: 57px;
|
||||
|
||||
.Emoji {
|
||||
font-size: inherit !important;
|
||||
}
|
||||
}
|
||||
|
||||
.markdownBody {
|
||||
font: var(--cpd-font-body-md-regular) !important;
|
||||
letter-spacing: var(--cpd-font-letter-spacing-body-md);
|
||||
font-family: inherit !important;
|
||||
white-space: normal !important;
|
||||
line-height: inherit !important;
|
||||
background-color: inherit;
|
||||
color: inherit; /* inherit the colour from the dark or light theme by default (but not for code blocks) */
|
||||
flex: 1;
|
||||
|
||||
pre,
|
||||
code {
|
||||
font-family:
|
||||
"Fira Code", "Apple Color Emoji", "Segoe UI Emoji", "Courier", monospace, "Noto Color Emoji" !important;
|
||||
background-color: var(--cpd-color-bg-subtle-primary);
|
||||
}
|
||||
|
||||
code:not(pre *) {
|
||||
background-color: var(--cpd-color-bg-subtle-primary);
|
||||
border: 1px solid var(--cpd-color-gray-400);
|
||||
border-radius: var(--cpd-space-1x);
|
||||
/* The horizontal padding is added by github-markdown-css .markdown-body */
|
||||
padding: var(--cpd-space-0-5x) 0;
|
||||
/* Avoid inline code blocks to be sticked when on multiple lines */
|
||||
line-height: 1.375rem;
|
||||
/* Avoid the border to be glued to the other words */
|
||||
margin-right: var(--cpd-space-0-5x);
|
||||
}
|
||||
|
||||
code {
|
||||
white-space: pre-wrap; /* don't collapse spaces in inline code blocks */
|
||||
}
|
||||
|
||||
pre {
|
||||
/* have to use overlay rather than auto otherwise Linux and Windows */
|
||||
/* Chrome gets very confused about vertical spacing: */
|
||||
/* https://github.com/vector-im/vector-web/issues/754 */
|
||||
overflow-x: overlay;
|
||||
overflow-y: visible;
|
||||
|
||||
&::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
border: 1px solid var(--cpd-color-gray-400);
|
||||
|
||||
code {
|
||||
white-space: pre; /* we want code blocks to be scrollable and not wrap */
|
||||
|
||||
> * {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.EventTile_searchHighlight {
|
||||
background-color: var(--cpd-color-text-action-accent);
|
||||
color: var(--cpd-color-text-on-solid-primary);
|
||||
border-radius: 5px; /* no compund for 5px */
|
||||
padding-inline: var(--cpd-space-0-5x);
|
||||
cursor: pointer;
|
||||
|
||||
a {
|
||||
background-color: var(--cpd-color-text-action-accent);
|
||||
color: var(--cpd-color-text-on-solid-primary);
|
||||
}
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type JSX } from "react";
|
||||
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { useMockedViewModel } from "../../viewmodel/useMockedViewModel";
|
||||
import { EventContentBodyView, type EventContentBodyViewSnapshot } from "./EventContentBodyView";
|
||||
import styles from "./EventContentBody.module.css";
|
||||
import { withViewDocs } from "../../../.storybook/withViewDocs";
|
||||
|
||||
type EventContentBodyStoryProps = EventContentBodyViewSnapshot & {
|
||||
as: "div" | "span";
|
||||
};
|
||||
|
||||
const EventContentBodyWrapperImpl = ({ as, ...snapshot }: EventContentBodyStoryProps): JSX.Element => {
|
||||
const vm = useMockedViewModel(snapshot, {});
|
||||
return <EventContentBodyView vm={vm} as={as} />;
|
||||
};
|
||||
const EventContentBodyWrapper = withViewDocs(EventContentBodyWrapperImpl, EventContentBodyView);
|
||||
|
||||
const meta = {
|
||||
title: "MessageBody/EventContentBody",
|
||||
component: EventContentBodyWrapper,
|
||||
tags: ["autodocs"],
|
||||
args: {
|
||||
as: "div",
|
||||
className: styles.EventTile_body,
|
||||
dir: "auto",
|
||||
},
|
||||
} satisfies Meta<typeof EventContentBodyWrapper>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const PlainText: Story = {
|
||||
args: {
|
||||
body: "Hello, this is a plain text message.",
|
||||
className: styles.EventTile_body,
|
||||
},
|
||||
};
|
||||
|
||||
export const BigEmoji: Story = {
|
||||
args: {
|
||||
body: [
|
||||
<span key="wave" className={styles.Emoji} title=":wave:">
|
||||
👋
|
||||
</span>,
|
||||
<span key="smile" className={styles.Emoji} title=":smile:">
|
||||
😊
|
||||
</span>,
|
||||
],
|
||||
className: `${styles.EventTile_body} ${styles.EventTile_bigEmoji}`,
|
||||
},
|
||||
};
|
||||
|
||||
export const HtmlContent: Story = {
|
||||
args: {
|
||||
body: "This is bold and italic text with a link.",
|
||||
formattedBody:
|
||||
"<p>This is <strong>bold</strong> and <em>italic</em> text with a <a href='https://matrix.org'>link</a>.</p>",
|
||||
className: `${styles.EventTile_body} ${styles.markdownBody}`,
|
||||
},
|
||||
};
|
||||
|
||||
export const CodeBlock: Story = {
|
||||
args: {
|
||||
body: 'function hello() {\n console.log("Hello, world!");\n}',
|
||||
formattedBody: '<pre><code>function hello() {\n console.log("Hello, world!");\n}</code></pre>',
|
||||
className: `${styles.EventTile_body} ${styles.markdownBody}`,
|
||||
},
|
||||
};
|
||||
|
||||
export const AsSpan: Story = {
|
||||
args: {
|
||||
as: "span",
|
||||
body: "This is rendered as a span element.",
|
||||
className: styles.EventTile_body,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithHighlight: Story = {
|
||||
args: {
|
||||
body: "Message with a highlighted word.",
|
||||
formattedBody: `Message with a <span class="${styles.EventTile_searchHighlight}">highlighted</span> word.`,
|
||||
className: styles.EventTile_body,
|
||||
},
|
||||
};
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { composeStories } from "@storybook/react-vite";
|
||||
import { render, screen } from "@test-utils";
|
||||
import React from "react";
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import * as stories from "./EventContentBody.stories";
|
||||
|
||||
const { PlainText, BigEmoji, HtmlContent, CodeBlock, AsSpan, WithHighlight } = composeStories(stories);
|
||||
|
||||
describe("EventContentBodyView", () => {
|
||||
it("renders plain text correctly", () => {
|
||||
const { container } = render(<PlainText />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders big emoji correctly", () => {
|
||||
const { container } = render(<BigEmoji />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders HTML content correctly", () => {
|
||||
const { container } = render(<HtmlContent />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders code block correctly", () => {
|
||||
const { container } = render(<CodeBlock />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders as span when specified", () => {
|
||||
const { container } = render(<AsSpan />);
|
||||
expect(container).toMatchSnapshot();
|
||||
expect(container.querySelector("span")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders highlighted content correctly", () => {
|
||||
const { container } = render(<WithHighlight />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("displays expected text content", () => {
|
||||
render(<PlainText />);
|
||||
expect(screen.getByText("Hello, this is a plain text message.")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type JSX, memo, type Ref } from "react";
|
||||
import parse, { type HTMLReactParserOptions } from "html-react-parser";
|
||||
|
||||
import { type ViewModel, useViewModel } from "../../viewmodel";
|
||||
import { applyReplacerOnString } from "../../utils/applyReplacerOnString";
|
||||
|
||||
type Replacer = HTMLReactParserOptions["replace"];
|
||||
type ParseFormattedBody = (formattedBody: string, replacer?: Replacer) => ReturnType<typeof parse>;
|
||||
|
||||
/**
|
||||
* Snapshot interface for the EventContentBody view.
|
||||
*/
|
||||
export interface EventContentBodyViewSnapshot {
|
||||
/**
|
||||
* The plain/emoji body content to render when no formatted body is available.
|
||||
*/
|
||||
body: string | JSX.Element[];
|
||||
/**
|
||||
* The raw formatted body HTML, if available.
|
||||
*/
|
||||
formattedBody?: string;
|
||||
/**
|
||||
* The text/element replacer used for pills, spoilers, code blocks, etc.
|
||||
*/
|
||||
replacer?: Replacer;
|
||||
/**
|
||||
* Optional parser implementation for formatted bodies.
|
||||
* This allows callers to provide a parser that matches their replacer implementation.
|
||||
*/
|
||||
parseFormattedBody?: ParseFormattedBody;
|
||||
/**
|
||||
* CSS class names to apply to the container element.
|
||||
*/
|
||||
className: string;
|
||||
/**
|
||||
* The text direction attribute.
|
||||
* Always "auto" for divs, controlled by includeDir prop for spans.
|
||||
*/
|
||||
dir?: "auto";
|
||||
}
|
||||
|
||||
export type EventContentBodyViewModel = ViewModel<EventContentBodyViewSnapshot>;
|
||||
|
||||
interface EventContentBodyBaseViewProps {
|
||||
/**
|
||||
* The ViewModel providing the snapshot data.
|
||||
*/
|
||||
vm: EventContentBodyViewModel;
|
||||
}
|
||||
|
||||
export type EventContentBodyViewProps = EventContentBodyBaseViewProps &
|
||||
(
|
||||
| {
|
||||
/**
|
||||
* Render the content in a span element.
|
||||
*/
|
||||
as: "span";
|
||||
/**
|
||||
* Optional ref to forward to the rendered span element.
|
||||
*/
|
||||
ref?: Ref<HTMLSpanElement>;
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* Render the content in a div element.
|
||||
*/
|
||||
as: "div";
|
||||
/**
|
||||
* Optional ref to forward to the rendered div element.
|
||||
*/
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* View component for rendering Matrix event content body.
|
||||
*/
|
||||
export const EventContentBodyView = memo(function EventContentBodyView({
|
||||
vm,
|
||||
as,
|
||||
ref,
|
||||
}: Readonly<EventContentBodyViewProps>): JSX.Element {
|
||||
const { body, formattedBody, replacer, className, dir, parseFormattedBody } = useViewModel(vm);
|
||||
const parseBody =
|
||||
parseFormattedBody ??
|
||||
((formatted: string, inputReplacer?: Replacer) =>
|
||||
parse(formatted, inputReplacer ? { replace: inputReplacer } : undefined));
|
||||
const children = formattedBody ? parseBody(formattedBody, replacer) : applyReplacerOnString(body, replacer);
|
||||
|
||||
if (as === "span") {
|
||||
return (
|
||||
<span ref={ref} className={className} dir={dir}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref} className={className} dir={dir}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`EventContentBodyView > renders HTML content correctly 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="EventTile_body markdownBody"
|
||||
dir="auto"
|
||||
>
|
||||
<p>
|
||||
This is
|
||||
<strong>
|
||||
bold
|
||||
</strong>
|
||||
and
|
||||
<em>
|
||||
italic
|
||||
</em>
|
||||
text with a
|
||||
<a
|
||||
href="https://matrix.org"
|
||||
>
|
||||
link
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`EventContentBodyView > renders as span when specified 1`] = `
|
||||
<div>
|
||||
<span
|
||||
class="EventTile_body"
|
||||
dir="auto"
|
||||
>
|
||||
This is rendered as a span element.
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`EventContentBodyView > renders big emoji correctly 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="EventTile_body EventTile_bigEmoji"
|
||||
dir="auto"
|
||||
>
|
||||
<span
|
||||
class="Emoji"
|
||||
title=":wave:"
|
||||
>
|
||||
👋
|
||||
</span>
|
||||
<span
|
||||
class="Emoji"
|
||||
title=":smile:"
|
||||
>
|
||||
😊
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`EventContentBodyView > renders code block correctly 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="EventTile_body markdownBody"
|
||||
dir="auto"
|
||||
>
|
||||
<pre>
|
||||
<code>
|
||||
function hello() {
|
||||
console.log("Hello, world!");
|
||||
}
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`EventContentBodyView > renders highlighted content correctly 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="EventTile_body"
|
||||
dir="auto"
|
||||
>
|
||||
Message with a
|
||||
<span
|
||||
class="EventTile_searchHighlight"
|
||||
>
|
||||
highlighted
|
||||
</span>
|
||||
word.
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`EventContentBodyView > renders plain text correctly 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="EventTile_body"
|
||||
dir="auto"
|
||||
>
|
||||
Hello, this is a plain text message.
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,13 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
export {
|
||||
EventContentBodyView,
|
||||
type EventContentBodyViewSnapshot,
|
||||
type EventContentBodyViewModel,
|
||||
type EventContentBodyViewProps,
|
||||
} from "./EventContentBodyView";
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type JSX } from "react";
|
||||
import { Text, type HTMLReactParserOptions } from "html-react-parser";
|
||||
|
||||
type Replacer = HTMLReactParserOptions["replace"];
|
||||
|
||||
/**
|
||||
* Applies a parser replacer to string content while passing through JSX elements unchanged.
|
||||
*
|
||||
* @param input Plain-text body content or pre-rendered JSX elements (for example emoji bodies).
|
||||
* Non-string items are returned verbatim.
|
||||
* @param replacer Optional replace callback to run on string items.
|
||||
* @returns The original `input` when no replacer is provided; otherwise an array where string
|
||||
* items are replaced and JSX elements are passed through unchanged.
|
||||
*/
|
||||
export function applyReplacerOnString(
|
||||
input: string | JSX.Element[],
|
||||
replacer?: Replacer,
|
||||
): JSX.Element | JSX.Element[] | string {
|
||||
if (!replacer) return input;
|
||||
|
||||
const arr = Array.isArray(input) ? input : [input];
|
||||
return arr.map((item, index): JSX.Element => {
|
||||
if (typeof item === "string") {
|
||||
return <React.Fragment key={index}>{(replacer(new Text(item), 0) as JSX.Element) || item}</React.Fragment>;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
}
|
||||
Generated
+3
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user