diff --git a/apps/web/src/components/views/messages/EditHistoryMessage.tsx b/apps/web/src/components/views/messages/EditHistoryMessage.tsx index c025cac9a5..4629d4786e 100644 --- a/apps/web/src/components/views/messages/EditHistoryMessage.tsx +++ b/apps/web/src/components/views/messages/EditHistoryMessage.tsx @@ -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; private content = createRef(); + private EventContentBodyViewModel: EventContentBodyViewModel; public constructor(props: IProps, context: React.ContextType) { 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 - ); + contentElements = ; } if (mxEvent.getContent().msgtype === MsgType.Emote) { const name = mxEvent.sender ? mxEvent.sender.name : mxEvent.getSender(); diff --git a/apps/web/src/components/views/messages/EventContentBody.tsx b/apps/web/src/components/views/messages/EventContentBody.tsx deleted file mode 100644 index 50d95642f8..0000000000 --- a/apps/web/src/components/views/messages/EventContentBody.tsx +++ /dev/null @@ -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([ - 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; -} - -/** - * 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 ? ( - - {parse(formattedBody, { - replace: replacer, - })} - - ) : ( - - {applyReplacerOnString(emojiBodyElements || strippedBody, replacer)} - - ); - - return body; - }, -); - -export default EventContentBody; diff --git a/apps/web/src/components/views/messages/TextualBody.tsx b/apps/web/src/components/views/messages/TextualBody.tsx index 34d6e9be94..5e65dc3271 100644 --- a/apps/web/src/components/views/messages/TextualBody.tsx +++ b/apps/web/src/components/views/messages/TextualBody.tsx @@ -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 { public static contextType = RoomContext; declare public context: React.ContextType; + private EventContentBodyViewModel: EventContentBodyViewModel; + public state = { links: [], widgetHidden: false, }; + public constructor(props: IBodyProps, context: React.ContextType) { + 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): 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 { } } + public componentWillUnmount(): void { + this.EventContentBodyViewModel.dispose(); + } + + private applyFormatting(): void { + this.calculateUrlPreview(); + } + public shouldComponentUpdate(nextProps: Readonly, nextState: Readonly): 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 { ); } + 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 { 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 = ( - ); diff --git a/apps/web/src/renderer/index.ts b/apps/web/src/renderer/index.ts index 3de73c8bbe..92e8bc12b0 100644 --- a/apps/web/src/renderer/index.ts +++ b/apps/web/src/renderer/index.ts @@ -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"; diff --git a/apps/web/src/renderer/utils.tsx b/apps/web/src/renderer/utils.tsx index 3109e9b447..4ebbd0b365 100644 --- a/apps/web/src/renderer/utils.tsx +++ b/apps/web/src/renderer/utils.tsx @@ -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 ( - {(replacer(new Text(input), 0) as JSX.Element) || input} - ); - } - return input; - }); -} - interface Parameters { isHtml: boolean; replace: Replacer; diff --git a/apps/web/src/viewmodels/message-body/EventContentBodyViewModel.ts b/apps/web/src/viewmodels/message-body/EventContentBodyViewModel.ts new file mode 100644 index 0000000000..e1f765403f --- /dev/null +++ b/apps/web/src/viewmodels/message-body/EventContentBodyViewModel.ts @@ -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([ + 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 + implements EventContentBodyViewModelInterface +{ + private static readonly computeBodySnapshot = ( + props: EventContentBodyViewModelProps, + ): Pick => { + 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 => 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 }); + }; +} diff --git a/apps/web/test/unit-tests/components/views/messages/TextualBody-test.tsx b/apps/web/test/unit-tests/components/views/messages/TextualBody-test.tsx index 3c9daa5d44..924473a692 100644 --- a/apps/web/test/unit-tests/components/views/messages/TextualBody-test.tsx +++ b/apps/web/test/unit-tests/components/views/messages/TextualBody-test.tsx @@ -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("", () => { afterEach(() => { - jest.spyOn(MatrixClientPeg, "get").mockRestore(); jest.spyOn(global.Math, "random").mockRestore(); }); @@ -114,12 +114,17 @@ describe("", () => { 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)( - + + + , ); + }; it("renders m.emote correctly", () => { DMRoomMap.makeShared(defaultMatrixClient); diff --git a/apps/web/test/viewmodels/message-body/EventContentBodyViewModel-test.tsx b/apps/web/test/viewmodels/message-body/EventContentBodyViewModel-test.tsx new file mode 100644 index 0000000000..1fbac26f84 --- /dev/null +++ b/apps/web/test/viewmodels/message-body/EventContentBodyViewModel-test.tsx @@ -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 => ({ + 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: "Hello", + emojiBodyElements: undefined, + className: "mx_EventTile_body", + }); + + const vm = new EventContentBodyViewModel(defaultProps()); + + const snapshot = vm.getSnapshot(); + + expect(snapshot.formattedBody).toBe("Hello"); + 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); + 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); + }); +}); diff --git a/packages/shared-components/__vis__/linux/__baselines__/message-body/EventContentBody/EventContentBody.stories.tsx/as-span-auto.png b/packages/shared-components/__vis__/linux/__baselines__/message-body/EventContentBody/EventContentBody.stories.tsx/as-span-auto.png new file mode 100644 index 0000000000..0c1ebb8218 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/message-body/EventContentBody/EventContentBody.stories.tsx/as-span-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/message-body/EventContentBody/EventContentBody.stories.tsx/big-emoji-auto.png b/packages/shared-components/__vis__/linux/__baselines__/message-body/EventContentBody/EventContentBody.stories.tsx/big-emoji-auto.png new file mode 100644 index 0000000000..dcb56d0ca3 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/message-body/EventContentBody/EventContentBody.stories.tsx/big-emoji-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/message-body/EventContentBody/EventContentBody.stories.tsx/code-block-auto.png b/packages/shared-components/__vis__/linux/__baselines__/message-body/EventContentBody/EventContentBody.stories.tsx/code-block-auto.png new file mode 100644 index 0000000000..d882baf705 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/message-body/EventContentBody/EventContentBody.stories.tsx/code-block-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/message-body/EventContentBody/EventContentBody.stories.tsx/html-content-auto.png b/packages/shared-components/__vis__/linux/__baselines__/message-body/EventContentBody/EventContentBody.stories.tsx/html-content-auto.png new file mode 100644 index 0000000000..5ffcefc0c4 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/message-body/EventContentBody/EventContentBody.stories.tsx/html-content-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/message-body/EventContentBody/EventContentBody.stories.tsx/plain-text-auto.png b/packages/shared-components/__vis__/linux/__baselines__/message-body/EventContentBody/EventContentBody.stories.tsx/plain-text-auto.png new file mode 100644 index 0000000000..92698906e9 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/message-body/EventContentBody/EventContentBody.stories.tsx/plain-text-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/message-body/EventContentBody/EventContentBody.stories.tsx/with-highlight-auto.png b/packages/shared-components/__vis__/linux/__baselines__/message-body/EventContentBody/EventContentBody.stories.tsx/with-highlight-auto.png new file mode 100644 index 0000000000..b3980d5224 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/message-body/EventContentBody/EventContentBody.stories.tsx/with-highlight-auto.png differ diff --git a/packages/shared-components/package.json b/packages/shared-components/package.json index 7180183563..34ae3b32b4 100644 --- a/packages/shared-components/package.json +++ b/packages/shared-components/package.json @@ -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", diff --git a/packages/shared-components/src/index.ts b/packages/shared-components/src/index.ts index 52ddcf115f..8f7b5d5654 100644 --- a/packages/shared-components/src/index.ts +++ b/packages/shared-components/src/index.ts @@ -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"; diff --git a/packages/shared-components/src/message-body/EventContentBody/EventContentBody.module.css b/packages/shared-components/src/message-body/EventContentBody/EventContentBody.module.css new file mode 100644 index 0000000000..dbb3f62bbb --- /dev/null +++ b/packages/shared-components/src/message-body/EventContentBody/EventContentBody.module.css @@ -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); + } +} diff --git a/packages/shared-components/src/message-body/EventContentBody/EventContentBody.stories.tsx b/packages/shared-components/src/message-body/EventContentBody/EventContentBody.stories.tsx new file mode 100644 index 0000000000..dacac25f57 --- /dev/null +++ b/packages/shared-components/src/message-body/EventContentBody/EventContentBody.stories.tsx @@ -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 ; +}; +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; + +export default meta; +type Story = StoryObj; + +export const PlainText: Story = { + args: { + body: "Hello, this is a plain text message.", + className: styles.EventTile_body, + }, +}; + +export const BigEmoji: Story = { + args: { + body: [ + + 👋 + , + + 😊 + , + ], + className: `${styles.EventTile_body} ${styles.EventTile_bigEmoji}`, + }, +}; + +export const HtmlContent: Story = { + args: { + body: "This is bold and italic text with a link.", + formattedBody: + "

This is bold and italic text with a link.

", + className: `${styles.EventTile_body} ${styles.markdownBody}`, + }, +}; + +export const CodeBlock: Story = { + args: { + body: 'function hello() {\n console.log("Hello, world!");\n}', + formattedBody: '
function hello() {\n  console.log("Hello, world!");\n}
', + 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 highlighted word.`, + className: styles.EventTile_body, + }, +}; diff --git a/packages/shared-components/src/message-body/EventContentBody/EventContentBody.test.tsx b/packages/shared-components/src/message-body/EventContentBody/EventContentBody.test.tsx new file mode 100644 index 0000000000..d6a0559218 --- /dev/null +++ b/packages/shared-components/src/message-body/EventContentBody/EventContentBody.test.tsx @@ -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(); + 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(); + }); +}); diff --git a/packages/shared-components/src/message-body/EventContentBody/EventContentBodyView.tsx b/packages/shared-components/src/message-body/EventContentBody/EventContentBodyView.tsx new file mode 100644 index 0000000000..1202882dc8 --- /dev/null +++ b/packages/shared-components/src/message-body/EventContentBody/EventContentBodyView.tsx @@ -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> + ); +}); diff --git a/packages/shared-components/src/message-body/EventContentBody/__snapshots__/EventContentBody.test.tsx.snap b/packages/shared-components/src/message-body/EventContentBody/__snapshots__/EventContentBody.test.tsx.snap new file mode 100644 index 0000000000..614c94839e --- /dev/null +++ b/packages/shared-components/src/message-body/EventContentBody/__snapshots__/EventContentBody.test.tsx.snap @@ -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> +`; diff --git a/packages/shared-components/src/message-body/EventContentBody/index.tsx b/packages/shared-components/src/message-body/EventContentBody/index.tsx new file mode 100644 index 0000000000..71f6798037 --- /dev/null +++ b/packages/shared-components/src/message-body/EventContentBody/index.tsx @@ -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"; diff --git a/packages/shared-components/src/utils/applyReplacerOnString.tsx b/packages/shared-components/src/utils/applyReplacerOnString.tsx new file mode 100644 index 0000000000..334ae1fd99 --- /dev/null +++ b/packages/shared-components/src/utils/applyReplacerOnString.tsx @@ -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; + }); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 35dee08fb3..33d8ce5686 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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