From 3f474874728a4d6b0aed195248a6c3b6dedc1853 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 26 Mar 2025 20:25:03 +0000 Subject: [PATCH] Switch away from nesting React trees and mangling the DOM (#29586) * Switch away from nesting React trees and mangling the DOM By parsing HTML events and manipulating the AST before passing it to React Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Use MatrixClientContext in Pill now that we are in the main React tree Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Add missing import Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Break import cycles Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Minimise Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Docs Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --------- Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- package.json | 4 +- src/HtmlUtils.tsx | 55 +-- src/Linkify.tsx | 18 +- src/components/views/elements/Pill.tsx | 52 ++- src/components/views/elements/Spoiler.tsx | 12 +- src/components/views/messages/CodeBlock.tsx | 41 ++- .../views/messages/EditHistoryMessage.tsx | 49 +-- .../views/messages/EventContentBody.tsx | 210 +++++++++++ src/components/views/messages/TextualBody.tsx | 192 ++-------- src/hooks/usePermalinkMember.ts | 15 +- src/linkify-matrix.ts | 2 - src/renderer/code-block.tsx | 20 ++ src/renderer/index.ts | 18 + src/renderer/link-tooltip.tsx | 34 ++ src/renderer/pill.tsx | 102 ++++++ src/renderer/spoiler.tsx | 24 ++ src/renderer/utils.tsx | 141 ++++++++ src/utils/pillify.tsx | 163 --------- src/utils/react.tsx | 53 --- src/utils/tooltipify.tsx | 76 ---- test/test-utils/client.ts | 10 + test/test-utils/test-utils.ts | 4 + test/unit-tests/HtmlUtils-test.tsx | 163 +++++---- .../__snapshots__/HtmlUtils-test.tsx.snap | 4 +- .../structures/MessagePanel-test.tsx | 2 + .../structures/TimelinePanel-test.tsx | 31 +- .../components/views/elements/Pill-test.tsx | 18 +- .../views/messages/TextualBody-test.tsx | 58 +-- .../__snapshots__/TextualBody-test.tsx.snap | 332 +++++++++--------- .../views/rooms/SearchResultTile-test.tsx | 7 +- .../__snapshots__/link-tooltip-test.tsx.snap | 32 ++ .../renderer/__snapshots__/pill-test.tsx.snap | 103 ++++++ .../unit-tests/renderer/link-tooltip-test.tsx | 47 +++ test/unit-tests/renderer/pill-test.tsx | 228 ++++++++++++ test/unit-tests/utils/pillify-test.tsx | 142 -------- test/unit-tests/utils/tooltipify-test.tsx | 77 ---- yarn.lock | 83 ++++- 37 files changed, 1488 insertions(+), 1134 deletions(-) create mode 100644 src/components/views/messages/EventContentBody.tsx create mode 100644 src/renderer/code-block.tsx create mode 100644 src/renderer/index.ts create mode 100644 src/renderer/link-tooltip.tsx create mode 100644 src/renderer/pill.tsx create mode 100644 src/renderer/spoiler.tsx create mode 100644 src/renderer/utils.tsx delete mode 100644 src/utils/pillify.tsx delete mode 100644 src/utils/react.tsx delete mode 100644 src/utils/tooltipify.tsx create mode 100644 test/unit-tests/renderer/__snapshots__/link-tooltip-test.tsx.snap create mode 100644 test/unit-tests/renderer/__snapshots__/pill-test.tsx.snap create mode 100644 test/unit-tests/renderer/link-tooltip-test.tsx create mode 100644 test/unit-tests/renderer/pill-test.tsx delete mode 100644 test/unit-tests/utils/pillify-test.tsx delete mode 100644 test/unit-tests/utils/tooltipify-test.tsx diff --git a/package.json b/package.json index 53886be6bd..4e5494ed82 100644 --- a/package.json +++ b/package.json @@ -107,6 +107,7 @@ "css-tree": "^3.0.0", "diff-dom": "^5.0.0", "diff-match-patch": "^1.0.5", + "domutils": "^3.2.2", "emojibase-regex": "15.3.2", "escape-html": "^1.0.3", "file-saver": "^2.0.5", @@ -115,12 +116,12 @@ "glob-to-regexp": "^0.4.1", "highlight.js": "^11.3.1", "html-entities": "^2.0.0", + "html-react-parser": "^5.2.2", "is-ip": "^3.1.0", "js-xxhash": "^4.0.0", "jsrsasign": "^11.0.0", "jszip": "^3.7.0", "katex": "^0.16.0", - "linkify-element": "4.2.0", "linkify-react": "4.2.0", "linkify-string": "4.2.0", "linkifyjs": "4.2.0", @@ -144,6 +145,7 @@ "react-blurhash": "^0.3.0", "react-dom": "^18.3.1", "react-focus-lock": "^2.5.1", + "react-string-replace": "^1.1.1", "react-transition-group": "^4.4.1", "react-virtualized": "^9.22.5", "rfc4648": "^1.4.0", diff --git a/src/HtmlUtils.tsx b/src/HtmlUtils.tsx index 0a6243a12d..daf30b2a46 100644 --- a/src/HtmlUtils.tsx +++ b/src/HtmlUtils.tsx @@ -25,7 +25,7 @@ import { PERMITTED_URL_SCHEMES } from "./utils/UrlUtils"; import { sanitizeHtmlParams, transformTags } from "./Linkify"; import { graphemeSegmenter } from "./utils/strings"; -export { Linkify, linkifyElement, linkifyAndSanitizeHtml } from "./Linkify"; +export { Linkify, linkifyAndSanitizeHtml } from "./Linkify"; // Anything outside the basic multilingual plane will be a surrogate pair const SURROGATE_PAIR_PATTERN = /([\ud800-\udbff])([\udc00-\udfff])/; @@ -365,53 +365,6 @@ function analyseEvent(content: IContent, highlights: Optional, opts: E } } -export function bodyToDiv( - content: IContent, - highlights: Optional, - opts: EventRenderOpts = {}, - ref?: React.Ref, -): ReactNode { - const { strippedBody, formattedBody, emojiBodyElements, className } = bodyToNode(content, highlights, opts); - - return formattedBody ? ( -
- ) : ( -
- {emojiBodyElements || strippedBody} -
- ); -} - -export function bodyToSpan( - content: IContent, - highlights: Optional, - opts: EventRenderOpts = {}, - ref?: React.Ref, - includeDir = true, -): ReactNode { - const { strippedBody, formattedBody, emojiBodyElements, className } = bodyToNode(content, highlights, opts); - - return formattedBody ? ( - - ) : ( - - {emojiBodyElements || strippedBody} - - ); -} - interface BodyToNodeReturn { strippedBody: string; formattedBody?: string; @@ -419,7 +372,11 @@ interface BodyToNodeReturn { className: string; } -function bodyToNode(content: IContent, highlights: Optional, opts: EventRenderOpts = {}): BodyToNodeReturn { +export function bodyToNode( + content: IContent, + highlights: Optional, + opts: EventRenderOpts = {}, +): BodyToNodeReturn { const eventInfo = analyseEvent(content, highlights, opts); let emojiBody = false; diff --git a/src/Linkify.tsx b/src/Linkify.tsx index a51b312d48..27dd4783be 100644 --- a/src/Linkify.tsx +++ b/src/Linkify.tsx @@ -11,12 +11,7 @@ import sanitizeHtml, { type IOptions } from "sanitize-html"; import { merge } from "lodash"; import _Linkify from "linkify-react"; -import { - _linkifyElement, - _linkifyString, - ELEMENT_URL_PATTERN, - options as linkifyMatrixOptions, -} from "./linkify-matrix"; +import { _linkifyString, ELEMENT_URL_PATTERN, options as linkifyMatrixOptions } from "./linkify-matrix"; import SettingsStore from "./settings/SettingsStore"; import { tryTransformPermalinkToLocalHref } from "./utils/permalinks/Permalinks"; import { mediaFromMxc } from "./customisations/Media"; @@ -223,17 +218,6 @@ export function linkifyString(str: string, options = linkifyMatrixOptions): stri return _linkifyString(str, options); } -/** - * Linkifies the given DOM element. This is a wrapper around 'linkifyjs/element'. - * - * @param {object} element DOM element to linkify - * @param {object} [options] Options for linkifyElement. Default: linkifyMatrixOptions - * @returns {object} - */ -export function linkifyElement(element: HTMLElement, options = linkifyMatrixOptions): HTMLElement { - return _linkifyElement(element, options); -} - /** * Linkify the given string and sanitize the HTML afterwards. * diff --git a/src/components/views/elements/Pill.tsx b/src/components/views/elements/Pill.tsx index e52bd6bff4..32e78fc32e 100644 --- a/src/components/views/elements/Pill.tsx +++ b/src/components/views/elements/Pill.tsx @@ -6,13 +6,12 @@ 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 ReactElement } from "react"; +import React, { type ReactElement, useContext } from "react"; import classNames from "classnames"; import { type Room, type RoomMember } from "matrix-js-sdk/src/matrix"; import { Tooltip } from "@vector-im/compound-web"; import { LinkIcon, UserSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; -import { MatrixClientPeg } from "../../../MatrixClientPeg"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import { usePermalink } from "../../../hooks/usePermalink"; import RoomAvatar from "../avatars/RoomAvatar"; @@ -28,14 +27,6 @@ export enum PillType { Keyword = "TYPE_KEYWORD", // Used to highlight keywords that triggered a notification rule } -export const pillRoomNotifPos = (text: string | null): number => { - return text?.indexOf("@room") ?? -1; -}; - -export const pillRoomNotifLen = (): number => { - return "@room".length; -}; - const linkIcon = ; const PillRoomAvatar: React.FC<{ @@ -89,6 +80,7 @@ export const Pill: React.FC = ({ shouldShowPillAvatar = true, text: customPillText, }) => { + const cli = useContext(MatrixClientContext); const { event, member, @@ -113,7 +105,7 @@ export const Pill: React.FC = ({ mx_RoomPill: type === PillType.RoomMention, mx_SpacePill: type === "space" || targetRoom?.isSpaceRoom(), mx_UserPill: type === PillType.UserMention, - mx_UserPill_me: resourceId === MatrixClientPeg.safeGet().getUserId(), + mx_UserPill_me: resourceId === cli.getUserId(), mx_EventPill: type === PillType.EventInOtherRoom || type === PillType.EventInSameRoom, mx_KeywordPill: type === PillType.Keyword, }); @@ -160,26 +152,24 @@ export const Pill: React.FC = ({ const isAnchor = !!inMessage && !!url; return ( - - - {isAnchor ? ( - - {avatar} - {pillText} - - ) : ( - - {avatar} - {pillText} - - )} - - + + {isAnchor ? ( + + {avatar} + {pillText} + + ) : ( + + {avatar} + {pillText} + + )} + ); }; diff --git a/src/components/views/elements/Spoiler.tsx b/src/components/views/elements/Spoiler.tsx index 588da46c6f..8d3d0a55fd 100644 --- a/src/components/views/elements/Spoiler.tsx +++ b/src/components/views/elements/Spoiler.tsx @@ -6,11 +6,11 @@ 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 from "react"; +import React, { type ReactNode } from "react"; interface IProps { reason?: string; - contentHtml: string; + children: ReactNode; } interface IState { @@ -38,9 +38,6 @@ export default class Spoiler extends React.Component { const reason = this.props.reason ? ( {"(" + this.props.reason + ")"} ) : null; - // react doesn't allow appending a DOM node as child. - // as such, we pass the this.props.contentHtml instead and then set the raw - // HTML content. This is secure as the contents have already been parsed previously return ( ); } diff --git a/src/components/views/messages/CodeBlock.tsx b/src/components/views/messages/CodeBlock.tsx index fd623eea01..2ec5f728b3 100644 --- a/src/components/views/messages/CodeBlock.tsx +++ b/src/components/views/messages/CodeBlock.tsx @@ -5,9 +5,10 @@ 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, { useState } from "react"; +import React, { type JSX, useState } from "react"; import classNames from "classnames"; -import { TooltipProvider } from "@vector-im/compound-web"; +import { type DOMNode, Element as ParserElement, domToReact } from "html-react-parser"; +import { textContent, getInnerHTML } from "domutils"; import { useSettingValue } from "../../../hooks/useSettings.ts"; import { CopyTextButton } from "../elements/CopyableText.tsx"; @@ -16,7 +17,7 @@ const MAX_HIGHLIGHT_LENGTH = 4096; const MAX_LINES_BEFORE_COLLAPSE = 5; interface Props { - children: HTMLElement; + preNode: ParserElement; onHeightChanged?(): void; } @@ -35,14 +36,16 @@ const ExpandCollapseButton: React.FC<{ ); }; -const CodeBlock: React.FC = ({ children, onHeightChanged }) => { +const CodeBlock: React.FC = ({ preNode, onHeightChanged }) => { const enableSyntaxHighlightLanguageDetection = useSettingValue("enableSyntaxHighlightLanguageDetection"); const showCodeLineNumbers = useSettingValue("showCodeLineNumbers"); const expandCodeByDefault = useSettingValue("expandCodeByDefault"); const [expanded, setExpanded] = useState(expandCodeByDefault); + const text = textContent(preNode); + let expandCollapseButton: JSX.Element | undefined; - if (children.textContent && children.textContent.split("\n").length >= MAX_LINES_BEFORE_COLLAPSE) { + if (text.split("\n").length >= MAX_LINES_BEFORE_COLLAPSE) { expandCollapseButton = ( = ({ children, onHeightChanged }) => { ); } + const innerHTML = getInnerHTML(preNode); let lineNumbers: JSX.Element | undefined; if (showCodeLineNumbers) { // Calculate number of lines in pre - const number = children.innerHTML.replace(/\n(<\/code>)?$/, "").split(/\n/).length; + const number = innerHTML.replace(/\n(<\/code>)?$/, "").split(/\n/).length; // Iterate through lines starting with 1 (number of the first line is 1) lineNumbers = ( @@ -108,28 +112,37 @@ const CodeBlock: React.FC = ({ children, onHeightChanged }) => { } } + function highlightCodeRef(div: HTMLElement | null): void { + highlightCode(div); + } + + let content = domToReact(preNode.children as DOMNode[]); + + // Add code element if it's missing since we depend on it + if (!preNode.children.some((child) => child instanceof ParserElement && child.tagName.toUpperCase() === "CODE")) { + content = {content}; + } + return ( - +
                 {lineNumbers}
-                
+
+ {content} +
{expandCollapseButton} children.getElementsByTagName("code")[0]?.textContent ?? null} + getTextToCopy={() => text} className={classNames("mx_EventTile_button mx_EventTile_copyButton", { mx_EventTile_buttonBottom: !!expandCollapseButton, })} /> - +
); }; diff --git a/src/components/views/messages/EditHistoryMessage.tsx b/src/components/views/messages/EditHistoryMessage.tsx index c7470e2b50..26687af159 100644 --- a/src/components/views/messages/EditHistoryMessage.tsx +++ b/src/components/views/messages/EditHistoryMessage.tsx @@ -10,11 +10,9 @@ import React, { createRef } from "react"; import { type EventStatus, type IContent, type MatrixEvent, MatrixEventEvent, MsgType } from "matrix-js-sdk/src/matrix"; import classNames from "classnames"; -import * as HtmlUtils from "../../../HtmlUtils"; +import EventContentBody from "./EventContentBody.tsx"; import { editBodyDiffToHtml } from "../../../utils/MessageDiffUtils"; import { formatTime } from "../../../DateUtils"; -import { pillifyLinks } from "../../../utils/pillify"; -import { tooltipifyLinks } from "../../../utils/tooltipify"; import { _t } from "../../../languageHandler"; import Modal from "../../../Modal"; import RedactedBody from "./RedactedBody"; @@ -23,7 +21,6 @@ import ConfirmAndWaitRedactDialog from "../dialogs/ConfirmAndWaitRedactDialog"; import ViewSource from "../../structures/ViewSource"; import SettingsStore from "../../../settings/SettingsStore"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; -import { ReactRootManager } from "../../../utils/react"; function getReplacedContent(event: MatrixEvent): IContent { const originalContent = event.getOriginalContent(); @@ -48,8 +45,6 @@ export default class EditHistoryMessage extends React.PureComponent; private content = createRef(); - private pills = new ReactRootManager(); - private tooltips = new ReactRootManager(); public constructor(props: IProps, context: React.ContextType) { super(props, context); @@ -94,37 +89,11 @@ export default class EditHistoryMessage extends React.PureComponent + ); } if (mxEvent.getContent().msgtype === MsgType.Emote) { const name = mxEvent.sender ? mxEvent.sender.name : mxEvent.getSender(); diff --git a/src/components/views/messages/EventContentBody.tsx b/src/components/views/messages/EventContentBody.tsx new file mode 100644 index 0000000000..bf0e48c16a --- /dev/null +++ b/src/components/views/messages/EventContentBody.tsx @@ -0,0 +1,210 @@ +/* +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, forwardRef, useContext, useMemo } 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 { Linkify } from "../../../Linkify.tsx"; +import PlatformPeg from "../../../PlatformPeg.ts"; +import { + applyReplacerOnString, + combineRenderers, + type Replacer, + type RendererMap, + keywordPillRenderer, + mentionPillRenderer, + ambiguousLinkTooltipRenderer, + codeBlockRenderer, + spoilerRenderer, + replacerToRenderFunction, +} from "../../../renderer"; +import MatrixClientContext from "../../../contexts/MatrixClientContext.tsx"; +import { useSettingValue } from "../../../hooks/useSettings.ts"; +import { filterBoolean } from "../../../utils/arrays.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, + onHeightChanged: (() => void) | 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, + onHeightChanged, + }); + }, [ + mxEvent, + options.renderMentionPills, + options.renderKeywordPills, + options.renderTooltipsForAmbiguousLinks, + options.renderSpoilers, + options.renderCodeBlocks, + isHtml, + room, + shouldShowPillAvatar, + onHeightChanged, + ]); + + 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[]; + /** + * Callback for when the height of the content changes + */ + onHeightChanged?: () => void; + /** + * Whether to include the `dir="auto"` attribute on the rendered element + */ + includeDir?: boolean; +} + +/** + * 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( + forwardRef( + ( + { as, mxEvent, stripReply, content, onHeightChanged, linkify, highlights, includeDir = true, ...options }, + ref, + ) => { + const enableBigEmoji = useSettingValue("TextualBody.enableBigEmoji"); + + const replacer = useReplacer(content, mxEvent, onHeightChanged, options); + const linkifyOptions = useMemo( + () => ({ + render: replacerToRenderFunction(replacer), + }), + [replacer], + ); + + 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, + }), + [content, enableBigEmoji, highlights, isEmote, stripReply], + ); + + if (as === "div") includeDir = true; // force dir="auto" on divs + + const As = as; + const body = formattedBody ? ( + + {parse(formattedBody, { + replace: replacer, + })} + + ) : ( + + {applyReplacerOnString(emojiBodyElements || strippedBody, replacer)} + + ); + + if (!linkify) return body; + + return {body}; + }, + ), +); + +export default EventContentBody; diff --git a/src/components/views/messages/TextualBody.tsx b/src/components/views/messages/TextualBody.tsx index 89be10dd25..bfca88bd4e 100644 --- a/src/components/views/messages/TextualBody.tsx +++ b/src/components/views/messages/TextualBody.tsx @@ -6,23 +6,18 @@ 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, { createRef, type SyntheticEvent, type MouseEvent, StrictMode } from "react"; -import { MsgType, PushRuleKind } from "matrix-js-sdk/src/matrix"; -import { TooltipProvider } from "@vector-im/compound-web"; -import { PushProcessor } from "matrix-js-sdk/src/pushprocessor"; +import React, { type JSX, createRef, type SyntheticEvent, type MouseEvent } from "react"; +import { MsgType } from "matrix-js-sdk/src/matrix"; -import * as HtmlUtils from "../../../HtmlUtils"; +import EventContentBody from "./EventContentBody.tsx"; import { formatDate } from "../../../DateUtils"; import Modal from "../../../Modal"; import dis from "../../../dispatcher/dispatcher"; import { _t } from "../../../languageHandler"; import SettingsStore from "../../../settings/SettingsStore"; -import { pillifyLinks } from "../../../utils/pillify"; -import { tooltipifyLinks } from "../../../utils/tooltipify"; import { IntegrationManagers } from "../../../integrations/IntegrationManagers"; import { isPermalinkHost, tryTransformPermalinkToLocalHref } from "../../../utils/permalinks/Permalinks"; import { Action } from "../../../dispatcher/actions"; -import Spoiler from "../elements/Spoiler"; import QuestionDialog from "../dialogs/QuestionDialog"; import MessageEditHistoryDialog from "../dialogs/MessageEditHistoryDialog"; import EditMessageComposer from "../rooms/EditMessageComposer"; @@ -34,10 +29,6 @@ import { options as linkifyOpts } from "../../../linkify-matrix"; import { getParentEventId } from "../../../utils/Reply"; import { EditWysiwygComposer } from "../rooms/wysiwyg_composer"; import { type IEventTileOps } from "../rooms/EventTile"; -import { MatrixClientPeg } from "../../../MatrixClientPeg"; -import CodeBlock from "./CodeBlock"; -import { Pill, PillType } from "../elements/Pill"; -import { ReactRootManager } from "../../../utils/react"; interface IState { // the URLs (if any) to be previewed with a LinkPreviewWidget inside this TextualBody. @@ -50,10 +41,6 @@ interface IState { export default class TextualBody extends React.Component { private readonly contentRef = createRef(); - private pills = new ReactRootManager(); - private tooltips = new ReactRootManager(); - private reactRoots = new ReactRootManager(); - public static contextType = RoomContext; declare public context: React.ContextType; @@ -69,74 +56,7 @@ export default class TextualBody extends React.Component { } private applyFormatting(): void { - // Function is only called from render / componentDidMount → contentRef is set - const content = this.contentRef.current!; - - this.activateSpoilers([content]); - - HtmlUtils.linkifyElement(content); - pillifyLinks(MatrixClientPeg.safeGet(), [content], this.props.mxEvent, this.pills); - this.calculateUrlPreview(); - - // tooltipifyLinks AFTER calculateUrlPreview because the DOM inside the tooltip - // container is empty before the internal component has mounted so calculateUrlPreview - // won't find any anchors - tooltipifyLinks([content], [...this.pills.elements, ...this.reactRoots.elements], this.tooltips); - - if (this.props.mxEvent.getContent().format === "org.matrix.custom.html") { - // Handle expansion and add buttons - const pres = [...content.getElementsByTagName("pre")]; - if (pres && pres.length > 0) { - for (let i = 0; i < pres.length; i++) { - // If there already is a div wrapping the codeblock we want to skip this. - // This happens after the codeblock was edited. - if (pres[i].parentElement?.className == "mx_EventTile_pre_container") continue; - // Add code element if it's missing since we depend on it - if (pres[i].getElementsByTagName("code").length == 0) { - this.addCodeElement(pres[i]); - } - // Wrap a div around
 so that the copy button can be correctly positioned
-                    // when the 
 overflows and is scrolled horizontally.
-                    this.wrapPreInReact(pres[i]);
-                }
-            }
-        }
-
-        // Highlight notification keywords using pills
-        const pushDetails = this.props.mxEvent.getPushDetails();
-        if (
-            pushDetails.rule?.enabled &&
-            pushDetails.rule.kind === PushRuleKind.ContentSpecific &&
-            pushDetails.rule.pattern
-        ) {
-            this.pillifyNotificationKeywords(
-                [content],
-                PushProcessor.getPushRuleGlobRegex(pushDetails.rule.pattern, true),
-            );
-        }
-    }
-
-    private addCodeElement(pre: HTMLPreElement): void {
-        const code = document.createElement("code");
-        code.append(...pre.childNodes);
-        pre.appendChild(code);
-    }
-
-    private wrapPreInReact(pre: HTMLPreElement): void {
-        const root = document.createElement("div");
-        root.className = "mx_EventTile_pre_container";
-
-        // Insert containing div in place of 
 block
-        pre.replaceWith(root);
-
-        this.reactRoots.render(
-            
-                {pre}
-            ,
-            root,
-            pre,
-        );
     }
 
     public componentDidUpdate(prevProps: Readonly): void {
@@ -150,12 +70,6 @@ export default class TextualBody extends React.Component {
         }
     }
 
-    public componentWillUnmount(): void {
-        this.pills.unmount();
-        this.tooltips.unmount();
-        this.reactRoots.unmount();
-    }
-
     public shouldComponentUpdate(nextProps: Readonly, nextState: Readonly): boolean {
         //console.info("shouldComponentUpdate: ShowUrlPreview for %s is %s", this.props.mxEvent.getId(), this.props.showUrlPreview);
 
@@ -195,79 +109,6 @@ export default class TextualBody extends React.Component {
         }
     }
 
-    private activateSpoilers(nodes: ArrayLike): void {
-        let node = nodes[0];
-        while (node) {
-            if (node.tagName === "SPAN" && typeof node.getAttribute("data-mx-spoiler") === "string") {
-                const spoilerContainer = document.createElement("span");
-
-                const reason = node.getAttribute("data-mx-spoiler") ?? undefined;
-                node.removeAttribute("data-mx-spoiler"); // we don't want to recurse
-                const spoiler = (
-                    
-                        
-                            
-                        
-                    
-                );
-
-                this.reactRoots.render(spoiler, spoilerContainer, node);
-
-                node.replaceWith(spoilerContainer);
-                node = spoilerContainer;
-            }
-
-            if (node.childNodes && node.childNodes.length) {
-                this.activateSpoilers(node.childNodes as NodeListOf);
-            }
-
-            node = node.nextSibling as Element;
-        }
-    }
-
-    /**
-     * Marks the text that activated a push-notification keyword pattern.
-     */
-    private pillifyNotificationKeywords(nodes: ArrayLike, exp: RegExp): void {
-        let node: Node | null = nodes[0];
-        while (node) {
-            if (node.nodeType === Node.TEXT_NODE) {
-                const text = node.nodeValue;
-                if (!text) {
-                    node = node.nextSibling;
-                    continue;
-                }
-                const match = text.match(exp);
-                if (!match || match.length < 2) {
-                    node = node.nextSibling;
-                    continue;
-                }
-                const keywordText = match[1];
-                const idx = match.index!;
-                const before = text.substring(0, idx);
-                const after = text.substring(idx + keywordText.length);
-
-                const container = document.createElement("span");
-                const newContent = (
-                    <>
-                        {before}
-                        
-                            
-                        
-                        {after}
-                    
-                );
-                this.reactRoots.render(newContent, container, node);
-
-                node.parentNode?.replaceChild(container, node);
-            } else if (node.childNodes && node.childNodes.length) {
-                this.pillifyNotificationKeywords(node.childNodes as NodeListOf, exp);
-            }
-
-            node = node.nextSibling;
-        }
-    }
-
     private findLinks(nodes: ArrayLike): string[] {
         let links: string[] = [];
 
@@ -479,18 +320,25 @@ 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);
-
-        const htmlOpts = {
-            disableBigEmoji: isEmote || !SettingsStore.getValue("TextualBody.enableBigEmoji"),
-            // Part of Replies fallback support
-            stripReplyFallback: stripReply,
-        };
-        let body = willHaveWrapper
-            ? HtmlUtils.bodyToSpan(content, this.props.highlights, htmlOpts, this.contentRef, false)
-            : HtmlUtils.bodyToDiv(content, this.props.highlights, htmlOpts, this.contentRef);
+        let body = (
+            
+        );
 
         if (this.props.replacingEventId) {
             body = (
diff --git a/src/hooks/usePermalinkMember.ts b/src/hooks/usePermalinkMember.ts
index 77267073ca..b5e751d28f 100644
--- a/src/hooks/usePermalinkMember.ts
+++ b/src/hooks/usePermalinkMember.ts
@@ -7,10 +7,10 @@ Please see LICENSE files in the repository root for full details.
 */
 
 import { type IMatrixProfile, type MatrixEvent, type Room, RoomMember } from "matrix-js-sdk/src/matrix";
-import { useEffect, useState } from "react";
+import { useContext, useEffect, useState } from "react";
 
 import { PillType } from "../components/views/elements/Pill";
-import { SdkContextClass } from "../contexts/SDKContext";
+import { SDKContext, type SdkContextClass } from "../contexts/SDKContext";
 import { type PermalinkParts } from "../utils/permalinks/PermalinkConstructor";
 
 const createMemberFromProfile = (userId: string, profile: IMatrixProfile): RoomMember => {
@@ -65,12 +65,12 @@ const determineUserId = (
  *          If sharing at least one room with the user, then the result will be the profile fetched via API.
  *          null in all other cases.
  */
-const determineMember = (userId: string, targetRoom: Room): RoomMember | null => {
+const determineMember = (userId: string, targetRoom: Room, context: SdkContextClass): RoomMember | null => {
     const targetRoomMember = targetRoom.getMember(userId);
 
     if (targetRoomMember) return targetRoomMember;
 
-    const knownProfile = SdkContextClass.instance.userProfilesStore.getOnlyKnownProfile(userId);
+    const knownProfile = context.userProfilesStore.getOnlyKnownProfile(userId);
 
     if (knownProfile) {
         return createMemberFromProfile(userId, knownProfile);
@@ -97,11 +97,12 @@ export const usePermalinkMember = (
     targetRoom: Room | null,
     event: MatrixEvent | null,
 ): RoomMember | null => {
+    const context = useContext(SDKContext);
     // User mentions and permalinks to events in the same room require to know the user.
     // If it cannot be initially determined, it will be looked up later by a memo hook.
     const shouldLookUpUser = type && [PillType.UserMention, PillType.EventInSameRoom].includes(type);
     const userId = determineUserId(type, parseResult, event);
-    const userInRoom = shouldLookUpUser && userId && targetRoom ? determineMember(userId, targetRoom) : null;
+    const userInRoom = shouldLookUpUser && userId && targetRoom ? determineMember(userId, targetRoom, context) : null;
     const [member, setMember] = useState(userInRoom);
 
     useEffect(() => {
@@ -111,7 +112,7 @@ export const usePermalinkMember = (
         }
 
         const doProfileLookup = async (): Promise => {
-            const fetchedProfile = await SdkContextClass.instance.userProfilesStore.fetchOnlyKnownProfile(userId);
+            const fetchedProfile = await context.userProfilesStore.fetchOnlyKnownProfile(userId);
 
             if (fetchedProfile) {
                 const newMember = createMemberFromProfile(userId, fetchedProfile);
@@ -120,7 +121,7 @@ export const usePermalinkMember = (
         };
 
         doProfileLookup();
-    }, [member, shouldLookUpUser, targetRoom, userId]);
+    }, [context, member, shouldLookUpUser, targetRoom, userId]);
 
     return member;
 };
diff --git a/src/linkify-matrix.ts b/src/linkify-matrix.ts
index aef4553140..c22445bb96 100644
--- a/src/linkify-matrix.ts
+++ b/src/linkify-matrix.ts
@@ -9,7 +9,6 @@ Please see LICENSE files in the repository root for full details.
 
 import * as linkifyjs from "linkifyjs";
 import { type EventListeners, type Opts, registerCustomProtocol, registerPlugin } from "linkifyjs";
-import linkifyElement from "linkify-element";
 import linkifyString from "linkify-string";
 import { getHttpUriForMxc, User } from "matrix-js-sdk/src/matrix";
 
@@ -274,5 +273,4 @@ PERMITTED_URL_SCHEMES.forEach((scheme) => {
 registerCustomProtocol("mxc", false);
 
 export const linkify = linkifyjs;
-export const _linkifyElement = linkifyElement;
 export const _linkifyString = linkifyString;
diff --git a/src/renderer/code-block.tsx b/src/renderer/code-block.tsx
new file mode 100644
index 0000000000..af9e4faefb
--- /dev/null
+++ b/src/renderer/code-block.tsx
@@ -0,0 +1,20 @@
+/*
+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 from "react";
+
+import { type RendererMap } from "./utils.tsx";
+import CodeBlock from "../components/views/messages/CodeBlock.tsx";
+
+/**
+ * Replaces `pre` elements with a CodeBlock component
+ */
+export const codeBlockRenderer: RendererMap = {
+    pre: (pre, { onHeightChanged }) => {
+        return ;
+    },
+};
diff --git a/src/renderer/index.ts b/src/renderer/index.ts
new file mode 100644
index 0000000000..eaed7b71c3
--- /dev/null
+++ b/src/renderer/index.ts
@@ -0,0 +1,18 @@
+/*
+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.
+*/
+
+export { ambiguousLinkTooltipRenderer } from "./link-tooltip";
+export { keywordPillRenderer, mentionPillRenderer } from "./pill";
+export { spoilerRenderer } from "./spoiler";
+export { codeBlockRenderer } from "./code-block";
+export {
+    applyReplacerOnString,
+    replacerToRenderFunction,
+    combineRenderers,
+    type RendererMap,
+    type Replacer,
+} from "./utils";
diff --git a/src/renderer/link-tooltip.tsx b/src/renderer/link-tooltip.tsx
new file mode 100644
index 0000000000..1c66b6d8e7
--- /dev/null
+++ b/src/renderer/link-tooltip.tsx
@@ -0,0 +1,34 @@
+/*
+Copyright 2024-2025 New Vector Ltd.
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+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 from "react";
+import { domToReact } from "html-react-parser";
+
+import LinkWithTooltip from "../components/views/elements/LinkWithTooltip";
+import { getSingleTextContentNode, type RendererMap } from "./utils.tsx";
+
+/**
+ * Wraps ambiguous links in a tooltip trigger that shows the full URL.
+ */
+export const ambiguousLinkTooltipRenderer: RendererMap = {
+    a: (anchor, { isHtml }) => {
+        // Ambiguous URLs are only possible in HTML content
+        if (!isHtml) return;
+
+        const href = anchor.attribs["href"];
+        if (href && href !== getSingleTextContentNode(anchor)) {
+            let tooltip = href as string;
+            try {
+                tooltip = new URL(href, window.location.href).toString();
+            } catch {
+                // Not all hrefs will be valid URLs
+            }
+            return {domToReact([anchor])};
+        }
+    },
+};
diff --git a/src/renderer/pill.tsx b/src/renderer/pill.tsx
new file mode 100644
index 0000000000..9157c1181a
--- /dev/null
+++ b/src/renderer/pill.tsx
@@ -0,0 +1,102 @@
+/*
+Copyright 2024-2025 New Vector Ltd.
+Copyright 2019-2023 The Matrix.org Foundation C.I.C.
+
+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 from "react";
+import { RuleId } from "matrix-js-sdk/src/matrix";
+import { type Element } from "html-react-parser";
+import { textContent } from "domutils";
+import reactStringReplace from "react-string-replace";
+import { PushProcessor } from "matrix-js-sdk/src/pushprocessor";
+
+import { Pill, PillType } from "../components/views/elements/Pill";
+import { parsePermalink } from "../utils/permalinks/Permalinks";
+import { type PermalinkParts } from "../utils/permalinks/PermalinkConstructor";
+import { hasParentMatching, type RendererMap, type ParentNode } from "./utils.tsx";
+
+const AT_ROOM_REGEX = PushProcessor.getPushRuleGlobRegex("@room", true, "gmi");
+
+/**
+ * A node here is an A element with a href attribute tag.
+ *
+ * It should be pillified if the permalink parser returns a result and one of the following conditions match:
+ * - Text content equals href. This is the case when sending a plain permalink inside a message.
+ * - The link is not from linkify (isHtml=true).
+ *   Composer completions already create an A tag.
+ */
+const shouldBePillified = (node: Element, href: string, parts: PermalinkParts | null, isHtml: boolean): boolean => {
+    // permalink parser didn't return any parts
+    if (!parts) return false;
+
+    const text = textContent(node);
+
+    // event permalink with custom label
+    if (parts.eventId && href !== text) return false;
+
+    return href === text || isHtml;
+};
+
+const isPreCode = (domNode: ParentNode | null): boolean =>
+    (domNode as Element)?.tagName === "PRE" || (domNode as Element)?.tagName === "CODE";
+
+/**
+ * Marks the text that activated a push-notification mention pattern.
+ */
+export const mentionPillRenderer: RendererMap = {
+    a: (anchor, { room, shouldShowPillAvatar, isHtml }) => {
+        if (!room) return;
+
+        const href = anchor.attribs["href"];
+        if (
+            href &&
+            !hasParentMatching(anchor, isPreCode) &&
+            shouldBePillified(anchor, href, parsePermalink(href), isHtml)
+        ) {
+            return ;
+        }
+    },
+
+    [Node.TEXT_NODE]: (text, { room, mxEvent, shouldShowPillAvatar }) => {
+        if (!room || !mxEvent) return;
+
+        const atRoomRule = room.client.pushProcessor.getPushRuleById(
+            mxEvent.getContent()["m.mentions"] !== undefined ? RuleId.IsRoomMention : RuleId.AtRoomNotification,
+        );
+        if (atRoomRule && room.client.pushProcessor.ruleMatchesEvent(atRoomRule, mxEvent)) {
+            const parts = reactStringReplace(text.data, AT_ROOM_REGEX, (_match, i) => (
+                
+            ));
+
+            if (parts.length <= 1) return; // no matches, skip replacing
+
+            return <>{parts};
+        }
+    },
+};
+
+/**
+ * Marks the text that activated a push-notification keyword pattern.
+ */
+export const keywordPillRenderer: RendererMap = {
+    [Node.TEXT_NODE]: (text, { keywordRegexpPattern }) => {
+        if (!keywordRegexpPattern) return;
+
+        const parts = reactStringReplace(text.data, keywordRegexpPattern, (match, i) => (
+            
+        ));
+
+        if (parts.length <= 1) return; // no matches, skip replacing
+
+        return <>{parts};
+    },
+};
diff --git a/src/renderer/spoiler.tsx b/src/renderer/spoiler.tsx
new file mode 100644
index 0000000000..ee7f45f48e
--- /dev/null
+++ b/src/renderer/spoiler.tsx
@@ -0,0 +1,24 @@
+/*
+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 from "react";
+import { domToReact, type DOMNode } from "html-react-parser";
+
+import { type RendererMap } from "./utils.tsx";
+import Spoiler from "../components/views/elements/Spoiler.tsx";
+
+/**
+ * Replaces spans with `data-mx-spoiler` with a Spoiler component.
+ */
+export const spoilerRenderer: RendererMap = {
+    span: (span) => {
+        const reason = span.attribs["data-mx-spoiler"];
+        if (typeof reason === "string") {
+            return {domToReact(span.children as DOMNode[])};
+        }
+    },
+};
diff --git a/src/renderer/utils.tsx b/src/renderer/utils.tsx
new file mode 100644
index 0000000000..5c7d6959e5
--- /dev/null
+++ b/src/renderer/utils.tsx
@@ -0,0 +1,141 @@
+/*
+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, { type JSX } from "react";
+import { type DOMNode, Element, type HTMLReactParserOptions, Text } from "html-react-parser";
+import { type MatrixEvent, type Room } from "matrix-js-sdk/src/matrix";
+import { type Opts } from "linkifyjs";
+
+/**
+ * The type of a parent node of an element, normally exported by domhandler but that is not a direct dependency of ours
+ */
+export type ParentNode = NonNullable;
+
+/**
+ * Returns the text content of a node if it is the only child and that child is a text node
+ * @param node - the node to check
+ */
+export const getSingleTextContentNode = (node: Element): string | null => {
+    if (node.childNodes.length === 1 && node.childNodes[0].type === "text") {
+        return node.childNodes[0].data;
+    }
+    return null;
+};
+
+/**
+ * Returns true if the node has a parent that matches the given matcher
+ * @param node - the node to check
+ * @param matcher - a function that returns true if the node matches
+ */
+export const hasParentMatching = (node: Element, matcher: (node: ParentNode | null) => boolean): boolean => {
+    let parent = node.parentNode;
+    while (parent) {
+        if (matcher(parent)) return true;
+        parent = parent.parentNode;
+    }
+    return false;
+};
+
+/**
+ * A replacer function that can be used with html-react-parser
+ */
+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;
+    });
+}
+
+/**
+ * Converts a Replacer function to a render function for linkify-react
+ * So that we can use the same replacer functions for both
+ * @param replacer The replacer function to convert
+ */
+export function replacerToRenderFunction(replacer: Replacer): Opts["render"] {
+    if (!replacer) return;
+    return ({ tagName, attributes, content }) => {
+        const domNode = new Element(tagName, attributes, [new Text(content)], "tag" as Element["type"]);
+        const result = replacer(domNode, 0);
+        if (result) return result;
+
+        // This is cribbed from the default render function in linkify-react
+        if (attributes.class) {
+            attributes.className = attributes.class;
+            delete attributes.class;
+        }
+        return React.createElement(tagName, attributes, content);
+    };
+}
+
+interface Parameters {
+    isHtml: boolean;
+    onHeightChanged?: () => void;
+    // Required for keywordPillRenderer
+    keywordRegexpPattern?: RegExp;
+    // Required for mentionPillRenderer
+    mxEvent?: MatrixEvent;
+    room?: Room;
+    shouldShowPillAvatar?: boolean;
+}
+
+type SpecialisedReplacer = (
+    node: T,
+    parameters: Parameters,
+    index: number,
+) => JSX.Element | string | void;
+
+/**
+ * A map of replacer functions for different types of nodes/tags.
+ * When a function returns a JSX element, the element will be rendered in place of the node.
+ */
+export type RendererMap = Partial<
+    {
+        [tagName in keyof HTMLElementTagNameMap]: SpecialisedReplacer;
+    } & {
+        [Node.TEXT_NODE]: SpecialisedReplacer;
+    }
+>;
+
+type PreparedRenderer = (parameters: Parameters) => Replacer;
+
+/**
+ * Combines multiple renderers into a single Replacer function
+ * @param renderers - the list of renderers to combine
+ */
+export const combineRenderers =
+    (...renderers: RendererMap[]): PreparedRenderer =>
+    (parameters) =>
+    (node, index) => {
+        if (node.type === "text") {
+            for (const replacer of renderers) {
+                const result = replacer[Node.TEXT_NODE]?.(node, parameters, index);
+                if (result) return result;
+            }
+        }
+        if (node instanceof Element) {
+            const tagName = node.tagName.toLowerCase() as keyof HTMLElementTagNameMap;
+            for (const replacer of renderers) {
+                const result = replacer[tagName]?.(node, parameters, index);
+                if (result) return result;
+            }
+        }
+    };
diff --git a/src/utils/pillify.tsx b/src/utils/pillify.tsx
deleted file mode 100644
index 6c83ad6553..0000000000
--- a/src/utils/pillify.tsx
+++ /dev/null
@@ -1,163 +0,0 @@
-/*
-Copyright 2024 New Vector Ltd.
-Copyright 2019-2023 The Matrix.org Foundation C.I.C.
-
-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, { StrictMode } from "react";
-import { type MatrixClient, type MatrixEvent, RuleId } from "matrix-js-sdk/src/matrix";
-import { TooltipProvider } from "@vector-im/compound-web";
-
-import SettingsStore from "../settings/SettingsStore";
-import { Pill, pillRoomNotifLen, pillRoomNotifPos, PillType } from "../components/views/elements/Pill";
-import { parsePermalink } from "./permalinks/Permalinks";
-import { type PermalinkParts } from "./permalinks/PermalinkConstructor";
-import { type ReactRootManager } from "./react";
-
-/**
- * A node here is an A element with a href attribute tag.
- *
- * It should be pillified if the permalink parser returns a result and one of the following conditions match:
- * - Text content equals href. This is the case when sending a plain permalink inside a message.
- * - The link does not have the "linkified" class.
- *   Composer completions already create an A tag.
- *   Linkify will not linkify things again. → There won't be a "linkified" class.
- */
-const shouldBePillified = (node: Element, href: string, parts: PermalinkParts | null): boolean => {
-    // permalink parser didn't return any parts
-    if (!parts) return false;
-
-    const textContent = node.textContent;
-
-    // event permalink with custom label
-    if (parts.eventId && href !== textContent) return false;
-
-    return href === textContent || !node.classList.contains("linkified");
-};
-
-/**
- * Recurses depth-first through a DOM tree, converting matrix.to links
- * into pills based on the context of a given room.  Returns a list of
- * the resulting React nodes so they can be unmounted rather than leaking.
- *
- * @param matrixClient the client of the logged-in user
- * @param {Element[]} nodes - a list of sibling DOM nodes to traverse to try
- *   to turn into pills.
- * @param {MatrixEvent} mxEvent - the matrix event which the DOM nodes are
- *   part of representing.
- * @param {ReactRootManager} pills - an accumulator of the DOM nodes which contain
- *   React components which have been mounted as part of this.
- *   The initial caller should pass in an empty array to seed the accumulator.
- */
-export function pillifyLinks(
-    matrixClient: MatrixClient,
-    nodes: ArrayLike,
-    mxEvent: MatrixEvent,
-    pills: ReactRootManager,
-): void {
-    const room = matrixClient.getRoom(mxEvent.getRoomId()) ?? undefined;
-    const shouldShowPillAvatar = SettingsStore.getValue("Pill.shouldShowPillAvatar");
-    let node = nodes[0];
-    while (node) {
-        let pillified = false;
-
-        if (node.tagName === "PRE" || node.tagName === "CODE" || pills.elements.includes(node)) {
-            // Skip code blocks and existing pills
-            node = node.nextSibling as Element;
-            continue;
-        } else if (node.tagName === "A" && node.getAttribute("href")) {
-            const href = node.getAttribute("href")!;
-            const parts = parsePermalink(href);
-
-            if (shouldBePillified(node, href, parts)) {
-                const pillContainer = document.createElement("span");
-
-                const pill = (
-                    
-                        
-                            
-                        
-                    
-                );
-
-                pills.render(pill, pillContainer, node);
-                node.replaceWith(pillContainer);
-                // Pills within pills aren't going to go well, so move on
-                pillified = true;
-
-                // update the current node with one that's now taken its place
-                node = pillContainer;
-            }
-        } else if (
-            node.nodeType === Node.TEXT_NODE &&
-            // as applying pills happens outside of react, make sure we're not doubly
-            // applying @room pills here, as a rerender with the same content won't touch the DOM
-            // to clear the pills from the last run of pillifyLinks
-            !node.parentElement?.classList.contains("mx_AtRoomPill")
-        ) {
-            let currentTextNode = node as Node as Text | null;
-            const roomNotifTextNodes: Text[] = [];
-
-            // Take a textNode and break it up to make all the instances of @room their
-            // own textNode, adding those nodes to roomNotifTextNodes
-            while (currentTextNode !== null) {
-                const roomNotifPos = pillRoomNotifPos(currentTextNode.textContent);
-                let nextTextNode: Text | null = null;
-                if (roomNotifPos > -1) {
-                    let roomTextNode = currentTextNode;
-
-                    if (roomNotifPos > 0) roomTextNode = roomTextNode.splitText(roomNotifPos);
-                    if (roomTextNode.textContent && roomTextNode.textContent.length > pillRoomNotifLen()) {
-                        nextTextNode = roomTextNode.splitText(pillRoomNotifLen());
-                    }
-                    roomNotifTextNodes.push(roomTextNode);
-                }
-                currentTextNode = nextTextNode;
-            }
-
-            if (roomNotifTextNodes.length > 0) {
-                const atRoomRule = matrixClient.pushProcessor.getPushRuleById(
-                    mxEvent.getContent()["m.mentions"] !== undefined ? RuleId.IsRoomMention : RuleId.AtRoomNotification,
-                );
-                if (atRoomRule && matrixClient.pushProcessor.ruleMatchesEvent(atRoomRule, mxEvent)) {
-                    // Now replace all those nodes with Pills
-                    for (const roomNotifTextNode of roomNotifTextNodes) {
-                        // Set the next node to be processed to the one after the node
-                        // we're adding now, since we've just inserted nodes into the structure
-                        // we're iterating over.
-                        // Note we've checked roomNotifTextNodes.length > 0 so we'll do this at least once
-                        node = roomNotifTextNode.nextSibling as Element;
-
-                        const pillContainer = document.createElement("span");
-                        const pill = (
-                            
-                                
-                                    
-                                
-                            
-                        );
-
-                        pills.render(pill, pillContainer, roomNotifTextNode);
-                        roomNotifTextNode.replaceWith(pillContainer);
-                    }
-                    // Nothing else to do for a text node (and we don't need to advance
-                    // the loop pointer because we did it above)
-                    continue;
-                }
-            }
-        }
-
-        if (node.childNodes && node.childNodes.length && !pillified) {
-            pillifyLinks(matrixClient, node.childNodes as NodeListOf, mxEvent, pills);
-        }
-
-        node = node.nextSibling as Element;
-    }
-}
diff --git a/src/utils/react.tsx b/src/utils/react.tsx
deleted file mode 100644
index 1a63340717..0000000000
--- a/src/utils/react.tsx
+++ /dev/null
@@ -1,53 +0,0 @@
-/*
-Copyright 2024 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 ReactNode } from "react";
-import { createRoot, type Root } from "react-dom/client";
-
-/**
- * Utility class to render & unmount additional React roots,
- * e.g. for pills, tooltips and other components rendered atop user-generated events.
- */
-export class ReactRootManager {
-    private roots: Root[] = [];
-    private rootElements: Element[] = [];
-    private revertElements: Array = [];
-
-    public get elements(): Element[] {
-        return this.rootElements;
-    }
-
-    /**
-     * Render a React component into a new root based on the given root element
-     * @param children the React component to render
-     * @param rootElement the root element to render the component into
-     * @param revertElement the element to replace the root element with when unmounting
-     *     needed to support double-rendering in React 18 Strict Dev mode
-     */
-    public render(children: ReactNode, rootElement: Element, revertElement: Node | null): void {
-        const root = createRoot(rootElement);
-        this.roots.push(root);
-        this.rootElements.push(rootElement);
-        this.revertElements.push(revertElement);
-        root.render(children);
-    }
-
-    /**
-     * Unmount all roots and revert the elements they were rendered into
-     */
-    public unmount(): void {
-        while (this.roots.length) {
-            const root = this.roots.pop()!;
-            const rootElement = this.rootElements.pop();
-            const revertElement = this.revertElements.pop();
-            root.unmount();
-            if (revertElement) {
-                rootElement?.replaceWith(revertElement);
-            }
-        }
-    }
-}
diff --git a/src/utils/tooltipify.tsx b/src/utils/tooltipify.tsx
deleted file mode 100644
index e2f8b89f29..0000000000
--- a/src/utils/tooltipify.tsx
+++ /dev/null
@@ -1,76 +0,0 @@
-/*
-Copyright 2024 New Vector Ltd.
-Copyright 2022 The Matrix.org Foundation C.I.C.
-
-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, { StrictMode } from "react";
-import { TooltipProvider } from "@vector-im/compound-web";
-
-import PlatformPeg from "../PlatformPeg";
-import LinkWithTooltip from "../components/views/elements/LinkWithTooltip";
-import { type ReactRootManager } from "./react";
-
-/**
- * If the platform enabled needsUrlTooltips, recurses depth-first through a DOM tree, adding tooltip previews
- * for link elements. Otherwise, does nothing.
- *
- * @param {Element[]} rootNodes - a list of sibling DOM nodes to traverse to try
- *   to add tooltips.
- * @param {Element[]} ignoredNodes - a list of nodes to not recurse into.
- * @param {ReactRootManager} tooltips - an accumulator of the DOM nodes which contain
- *   React components that have been mounted by this function. The initial caller
- *   should pass in an empty array to seed the accumulator.
- */
-export function tooltipifyLinks(
-    rootNodes: ArrayLike,
-    ignoredNodes: Element[],
-    tooltips: ReactRootManager,
-): void {
-    if (!PlatformPeg.get()?.needsUrlTooltips()) {
-        return;
-    }
-
-    let node = rootNodes[0];
-
-    while (node) {
-        if (ignoredNodes.includes(node) || tooltips.elements.includes(node)) {
-            node = node.nextSibling as Element;
-            continue;
-        }
-
-        if (
-            node.tagName === "A" &&
-            node.getAttribute("href") &&
-            node.getAttribute("href") !== node.textContent?.trim()
-        ) {
-            let href = node.getAttribute("href")!;
-            try {
-                href = new URL(href, window.location.href).toString();
-            } catch {
-                // Not all hrefs will be valid URLs
-            }
-
-            // The node's innerHTML was already sanitized before being rendered in the first place, here we are just
-            // wrapping the link with the LinkWithTooltip component, keeping the same children. Ideally we'd do this
-            // without the superfluous span but this is not something React trivially supports at this time.
-            const tooltip = (
-                
-                    
-                        
-                            
-                        
-                    
-                
-            );
-
-            tooltips.render(tooltip, node, null);
-        } else if (node.childNodes?.length) {
-            tooltipifyLinks(node.childNodes as NodeListOf, ignoredNodes, tooltips);
-        }
-
-        node = node.nextSibling as Element;
-    }
-}
diff --git a/test/test-utils/client.ts b/test/test-utils/client.ts
index 2f9a0e8116..40aacb748e 100644
--- a/test/test-utils/client.ts
+++ b/test/test-utils/client.ts
@@ -115,6 +115,16 @@ export const mockClientMethodsEvents = () => ({
     getPushActionsForEvent: jest.fn(),
 });
 
+/**
+ * Returns basic mocked pushProcessor
+ */
+export const mockClientPushProcessor = () => ({
+    pushProcessor: {
+        getPushRuleById: jest.fn(),
+        ruleMatchesEvent: jest.fn(),
+    },
+});
+
 /**
  * Returns basic mocked client methods related to server support
  */
diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts
index 75a42c86bc..93453d4629 100644
--- a/test/test-utils/test-utils.ts
+++ b/test/test-utils/test-utils.ts
@@ -303,6 +303,10 @@ export function createTestClient(): MatrixClient {
         getLocalAliases: jest.fn().mockReturnValue([]),
         uploadDeviceSigningKeys: jest.fn(),
         isKeyBackupKeyStored: jest.fn().mockResolvedValue(null),
+
+        pushProcessor: {
+            getPushRuleById: jest.fn(),
+        },
     } as unknown as MatrixClient;
 
     client.reEmitter = new ReEmitter(client);
diff --git a/test/unit-tests/HtmlUtils-test.tsx b/test/unit-tests/HtmlUtils-test.tsx
index ad469c3a92..0650db1890 100644
--- a/test/unit-tests/HtmlUtils-test.tsx
+++ b/test/unit-tests/HtmlUtils-test.tsx
@@ -6,12 +6,12 @@ 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 ReactElement } from "react";
+import React from "react";
 import { mocked } from "jest-mock";
 import { render, screen } from "jest-matrix-react";
-import { type IContent } from "matrix-js-sdk/src/matrix";
+import parse from "html-react-parser";
 
-import { bodyToSpan, formatEmojis, topicToHtml } from "../../src/HtmlUtils";
+import { bodyToHtml, bodyToNode, formatEmojis, topicToHtml } from "../../src/HtmlUtils";
 import SettingsStore from "../../src/settings/SettingsStore";
 
 jest.mock("../../src/settings/SettingsStore");
@@ -57,12 +57,8 @@ describe("topicToHtml", () => {
 });
 
 describe("bodyToHtml", () => {
-    function getHtml(content: IContent, highlights?: string[]): string {
-        return (bodyToSpan(content, highlights, {}) as ReactElement).props.dangerouslySetInnerHTML.__html;
-    }
-
     it("should apply highlights to HTML messages", () => {
-        const html = getHtml(
+        const html = bodyToHtml(
             {
                 body: "test **foo** bar",
                 msgtype: "m.text",
@@ -76,7 +72,7 @@ describe("bodyToHtml", () => {
     });
 
     it("should apply highlights to plaintext messages", () => {
-        const html = getHtml(
+        const html = bodyToHtml(
             {
                 body: "test foo bar",
                 msgtype: "m.text",
@@ -88,7 +84,7 @@ describe("bodyToHtml", () => {
     });
 
     it("should not respect HTML tags in plaintext message highlighting", () => {
-        const html = getHtml(
+        const html = bodyToHtml(
             {
                 body: "test foo bar",
                 msgtype: "m.text",
@@ -99,39 +95,12 @@ describe("bodyToHtml", () => {
         expect(html).toMatchInlineSnapshot(`"test foo <b>bar"`);
     });
 
-    it("generates big emoji for emoji made of multiple characters", () => {
-        const { asFragment } = render(bodyToSpan({ body: "👨‍👩‍👧‍👦 ↔️ 🇮🇸", msgtype: "m.text" }, [], {}) as ReactElement);
-
-        expect(asFragment()).toMatchSnapshot();
-    });
-
-    it("should generate big emoji for an emoji-only reply to a message", () => {
-        const { asFragment } = render(
-            bodyToSpan(
-                {
-                    "body": "> <@sender1:server> Test\n\n🥰",
-                    "format": "org.matrix.custom.html",
-                    "formatted_body":
-                        '
In reply to @sender1:server
Test
🥰', - "m.relates_to": { - "m.in_reply_to": { - event_id: "$eventId", - }, - }, - "msgtype": "m.text", - }, - [], - { - stripReplyFallback: true, - }, - ) as ReactElement, - ); - - expect(asFragment()).toMatchSnapshot(); - }); - it("does not mistake characters in text presentation mode for emoji", () => { - const { asFragment } = render(bodyToSpan({ body: "↔ ❗︎", msgtype: "m.text" }, [], {}) as ReactElement); + const { asFragment } = render( + + {parse(bodyToHtml({ body: "↔ ❗︎", msgtype: "m.text" }, [], {}))} + , + ); expect(asFragment()).toMatchSnapshot(); }); @@ -142,42 +111,54 @@ describe("bodyToHtml", () => { }); it("should render inline katex", () => { - const html = getHtml({ - body: "hello \\xi world", - msgtype: "m.text", - formatted_body: 'hello \\xi world', - format: "org.matrix.custom.html", - }); + const html = bodyToHtml( + { + body: "hello \\xi world", + msgtype: "m.text", + formatted_body: 'hello \\xi world', + format: "org.matrix.custom.html", + }, + [], + ); expect(html).toMatchSnapshot(); }); it("should render block katex", () => { - const html = getHtml({ - body: "hello \\xi world", - msgtype: "m.text", - formatted_body: '

hello

\\xi

world

', - format: "org.matrix.custom.html", - }); + const html = bodyToHtml( + { + body: "hello \\xi world", + msgtype: "m.text", + formatted_body: '

hello

\\xi

world

', + format: "org.matrix.custom.html", + }, + [], + ); expect(html).toMatchSnapshot(); }); it("should not mangle code blocks", () => { - const html = getHtml({ - body: "hello \\xi world", - msgtype: "m.text", - formatted_body: "

hello

$\\xi$

world

", - format: "org.matrix.custom.html", - }); + const html = bodyToHtml( + { + body: "hello \\xi world", + msgtype: "m.text", + formatted_body: "

hello

$\\xi$

world

", + format: "org.matrix.custom.html", + }, + [], + ); expect(html).toMatchSnapshot(); }); it("should not mangle divs", () => { - const html = getHtml({ - body: "hello world", - msgtype: "m.text", - formatted_body: "

hello

world
", - format: "org.matrix.custom.html", - }); + const html = bodyToHtml( + { + body: "hello world", + msgtype: "m.text", + formatted_body: "

hello

world
", + format: "org.matrix.custom.html", + }, + [], + ); expect(html).toMatchSnapshot(); }); }); @@ -198,3 +179,53 @@ describe("formatEmojis", () => { } }); }); + +describe("bodyToNode", () => { + it("generates big emoji for emoji made of multiple characters", () => { + const { className, emojiBodyElements } = bodyToNode( + { + body: "👨‍👩‍👧‍👦 ↔️ 🇮🇸", + msgtype: "m.text", + }, + [], + { + stripReplyFallback: true, + }, + ); + + const { asFragment } = render( + + {emojiBodyElements} + , + ); + + expect(asFragment()).toMatchSnapshot(); + }); + + it("should generate big emoji for an emoji-only reply to a message", () => { + const { className, formattedBody } = bodyToNode( + { + "body": "> <@sender1:server> Test\n\n🥰", + "format": "org.matrix.custom.html", + "formatted_body": + '
In reply to @sender1:server
Test
🥰', + "m.relates_to": { + "m.in_reply_to": { + event_id: "$eventId", + }, + }, + "msgtype": "m.text", + }, + [], + { + stripReplyFallback: true, + }, + ); + + const { asFragment } = render( + , + ); + + expect(asFragment()).toMatchSnapshot(); + }); +}); diff --git a/test/unit-tests/__snapshots__/HtmlUtils-test.tsx.snap b/test/unit-tests/__snapshots__/HtmlUtils-test.tsx.snap index 1a6e6dfe48..09ab44bfcb 100644 --- a/test/unit-tests/__snapshots__/HtmlUtils-test.tsx.snap +++ b/test/unit-tests/__snapshots__/HtmlUtils-test.tsx.snap @@ -19,7 +19,7 @@ exports[`bodyToHtml feature_latex_maths should render block katex 1`] = `"

hel exports[`bodyToHtml feature_latex_maths should render inline katex 1`] = `"hello ξ\\xi world"`; -exports[`bodyToHtml generates big emoji for emoji made of multiple characters 1`] = ` +exports[`bodyToNode generates big emoji for emoji made of multiple characters 1`] = ` `; -exports[`bodyToHtml should generate big emoji for an emoji-only reply to a message 1`] = ` +exports[`bodyToNode should generate big emoji for an emoji-only reply to a message 1`] = ` { manageReadReceipts={true} ref={(ref) => (timelinePanel = ref)} />, + withClientContextRenderOptions(MatrixClientPeg.safeGet()), ); await flushPromises(); await waitFor(() => expect(timelinePanel).toBeTruthy()); @@ -403,7 +411,10 @@ describe("TimelinePanel", () => { setupPagination(client, timeline, eventsPage1, null); await withScrollPanelMountSpy(async (mountSpy) => { - const { container } = render(); + const { container } = render( + , + withClientContextRenderOptions(MatrixClientPeg.safeGet()), + ); await waitFor(() => expectEvents(container, [events[1]])); @@ -420,7 +431,10 @@ describe("TimelinePanel", () => { const [, room, events] = setupTestData(); await withScrollPanelMountSpy(async (mountSpy) => { - const { container } = render(); + const { container } = render( + , + withClientContextRenderOptions(MatrixClientPeg.safeGet()), + ); await waitFor(() => expectEvents(container, [events[0], events[1]])); @@ -560,6 +574,7 @@ describe("TimelinePanel", () => { overlayTimelineSet={overlayTimelineSet} overlayTimelineSetFilter={isCallEvent} />, + withClientContextRenderOptions(MatrixClientPeg.safeGet()), ); await waitFor(() => expectEvents(container, [ @@ -599,6 +614,7 @@ describe("TimelinePanel", () => { const { container } = render( , + withClientContextRenderOptions(MatrixClientPeg.safeGet()), ); await waitFor(() => @@ -630,6 +646,7 @@ describe("TimelinePanel", () => { const { container } = render( , + withClientContextRenderOptions(MatrixClientPeg.safeGet()), ); await waitFor(() => @@ -661,6 +678,7 @@ describe("TimelinePanel", () => { const { container } = render( , + withClientContextRenderOptions(MatrixClientPeg.safeGet()), ); await waitFor(() => @@ -695,6 +713,7 @@ describe("TimelinePanel", () => { timelineSet={timelineSet} overlayTimelineSet={overlayTimelineSet} />, + withClientContextRenderOptions(MatrixClientPeg.safeGet()), ); await waitFor(() => expectEvents(container, [overlayEvents[0], events[0]])); @@ -768,6 +787,7 @@ describe("TimelinePanel", () => { await withScrollPanelMountSpy(async (mountSpy) => { const { container } = render( , + withClientContextRenderOptions(MatrixClientPeg.safeGet()), ); await waitFor(() => @@ -1027,7 +1047,10 @@ describe("TimelinePanel", () => { room.getTimelineSets = jest.fn().mockReturnValue([timelineSet]); await withScrollPanelMountSpy(async () => { - const { container } = render(); + const { container } = render( + , + withClientContextRenderOptions(MatrixClientPeg.safeGet()), + ); await waitFor(() => expectEvents(container, [events[1]])); }); diff --git a/test/unit-tests/components/views/elements/Pill-test.tsx b/test/unit-tests/components/views/elements/Pill-test.tsx index 8d6565c0a1..f3a49d6734 100644 --- a/test/unit-tests/components/views/elements/Pill-test.tsx +++ b/test/unit-tests/components/views/elements/Pill-test.tsx @@ -21,11 +21,14 @@ import { mkRoomCanonicalAliasEvent, mkRoomMemberJoinEvent, stubClient, + withClientContextRenderOptions, } from "../../../../test-utils"; import DMRoomMap from "../../../../../src/utils/DMRoomMap"; import { Action } from "../../../../../src/dispatcher/actions"; import { type ButtonEvent } from "../../../../../src/components/views/elements/AccessibleButton"; -import { SdkContextClass } from "../../../../../src/contexts/SDKContext"; +import { SDKContext, SdkContextClass } from "../../../../../src/contexts/SDKContext"; +import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg.ts"; +import { TestSdkContext } from "../../../TestSdkContext.ts"; describe("", () => { let client: Mocked; @@ -45,6 +48,10 @@ describe("", () => { let pillParentClickHandler: (e: ButtonEvent) => void; const renderPill = (props: PillProps): void => { + const cli = MatrixClientPeg.safeGet(); + const mockSdkContext = new TestSdkContext(); + mockSdkContext.client = cli; + const withDefault = { inMessage: true, shouldShowPillAvatar: true, @@ -53,9 +60,12 @@ describe("", () => { // wrap Pill with a div to allow testing of event bubbling renderResult = render( // eslint-disable-next-line jsx-a11y/click-events-have-key-events -

- -
, + +
+ +
+
, + withClientContextRenderOptions(cli), ); }; diff --git a/test/unit-tests/components/views/messages/TextualBody-test.tsx b/test/unit-tests/components/views/messages/TextualBody-test.tsx index db4790976a..7094ff7ff0 100644 --- a/test/unit-tests/components/views/messages/TextualBody-test.tsx +++ b/test/unit-tests/components/views/messages/TextualBody-test.tsx @@ -6,13 +6,19 @@ 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 from "react"; -import { type MatrixClient, type MatrixEvent, PushRuleKind } from "matrix-js-sdk/src/matrix"; +import React, { type ComponentProps } from "react"; +import { type MatrixClient, type MatrixEvent, PushRuleKind, type Room } from "matrix-js-sdk/src/matrix"; import { mocked, type MockedObject } from "jest-mock"; import { render, waitFor } from "jest-matrix-react"; import { PushProcessor } from "matrix-js-sdk/src/pushprocessor"; -import { getMockClientWithEventEmitter, mkEvent, mkMessage, mkStubRoom } from "../../../../test-utils"; +import { + getMockClientWithEventEmitter, + mkEvent, + mkMessage, + mkStubRoom, + mockClientPushProcessor, +} from "../../../../test-utils"; import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg"; import * as languageHandler from "../../../../../src/languageHandler"; import DMRoomMap from "../../../../../src/utils/DMRoomMap"; @@ -55,8 +61,8 @@ describe("", () => { jest.spyOn(global.Math, "random").mockRestore(); }); - const defaultRoom = mkStubRoom(room1Id, "test room", undefined); - const otherRoom = mkStubRoom(room2Id, room2Name, undefined); + let defaultRoom: Room; + let otherRoom: Room; let defaultMatrixClient: MockedObject; const defaultEvent = mkEvent({ @@ -70,6 +76,15 @@ describe("", () => { event: true, }); + const defaultProps: ComponentProps = { + mxEvent: defaultEvent, + highlights: [] as string[], + highlightLink: "", + onMessageAllowed: jest.fn(), + onHeightChanged: jest.fn(), + mediaEventHelper: {} as MediaEventHelper, + }; + beforeEach(() => { defaultMatrixClient = getMockClientWithEventEmitter({ getRoom: (roomId: string | undefined) => { @@ -89,6 +104,10 @@ describe("", () => { // @ts-expect-error defaultMatrixClient.pushProcessor = new PushProcessor(defaultMatrixClient); + defaultRoom = mkStubRoom(room1Id, "test room", defaultMatrixClient); + defaultProps.permalinkCreator = new RoomPermalinkCreator(defaultRoom); + otherRoom = mkStubRoom(room2Id, room2Name, defaultMatrixClient); + mocked(defaultRoom).findEventById.mockImplementation((eventId: string) => { if (eventId === defaultEvent.getId()) return defaultEvent; return undefined; @@ -96,16 +115,6 @@ describe("", () => { jest.spyOn(global.Math, "random").mockReturnValue(0.123456); }); - const defaultProps = { - mxEvent: defaultEvent, - highlights: [] as string[], - highlightLink: "", - onMessageAllowed: jest.fn(), - onHeightChanged: jest.fn(), - permalinkCreator: new RoomPermalinkCreator(defaultRoom), - mediaEventHelper: {} as MediaEventHelper, - }; - const getComponent = (props = {}, matrixClient: MatrixClient = defaultMatrixClient, renderingFn?: any) => (renderingFn ?? render)( @@ -180,7 +189,7 @@ describe("", () => { const { container } = getComponent({ mxEvent: ev }); const content = container.querySelector(".mx_EventTile_body"); expect(content.innerHTML).toMatchInlineSnapshot( - `"Chat with @user:example.com"`, + `"Chat with @user:example.com"`, ); }); @@ -189,7 +198,7 @@ describe("", () => { const { container } = getComponent({ mxEvent: ev }); const content = container.querySelector(".mx_EventTile_body"); expect(content.innerHTML).toMatchInlineSnapshot( - `"Chat with Member"`, + `"Chat with Member"`, ); }); @@ -198,7 +207,7 @@ describe("", () => { const { container } = getComponent({ mxEvent: ev }); const content = container.querySelector(".mx_EventTile_body"); expect(content.innerHTML).toMatchInlineSnapshot( - `"Visit #room:example.com"`, + `"Visit #room:example.com"`, ); }); @@ -207,7 +216,7 @@ describe("", () => { const { container } = getComponent({ mxEvent: ev }); const content = container.querySelector(".mx_EventTile_body"); expect(content.innerHTML).toMatchInlineSnapshot( - `"Visit #room:example.com"`, + `"Visit #room:example.com"`, ); }); @@ -245,7 +254,7 @@ describe("", () => { const { container } = getComponent({ mxEvent: ev }); const content = container.querySelector(".mx_EventTile_body"); expect(content.innerHTML).toMatchInlineSnapshot( - `"foo bar baz"`, + `"foo bar baz"`, ); }); }); @@ -254,7 +263,8 @@ describe("", () => { let matrixClient: MatrixClient; beforeEach(() => { matrixClient = getMockClientWithEventEmitter({ - getRoom: () => mkStubRoom(room1Id, "room name", undefined), + getRoom: jest.fn(), + ...mockClientPushProcessor(), getAccountData: (): MatrixEvent | undefined => undefined, getUserId: () => "@me:my_server", getHomeserverUrl: () => "https://my_server/", @@ -263,6 +273,7 @@ describe("", () => { isGuest: () => false, mxcUrlToHttp: (s: string) => s, }); + mocked(matrixClient.getRoom).mockReturnValue(mkStubRoom(room1Id, "room name", matrixClient)); DMRoomMap.makeShared(defaultMatrixClient); }); @@ -401,12 +412,15 @@ describe("", () => { beforeEach(() => { languageHandler.setMissingEntryGenerator((key) => key.split("|", 2)[1]); matrixClient = getMockClientWithEventEmitter({ - getRoom: () => mkStubRoom("room_id", "room name", undefined), + getRoom: jest.fn(), + getUserId: jest.fn(), + ...mockClientPushProcessor(), getAccountData: (): MatrixClient | undefined => undefined, getUrlPreview: (url: string) => new Promise(() => {}), isGuest: () => false, mxcUrlToHttp: (s: string) => s, }); + mocked(matrixClient.getRoom).mockReturnValue(mkStubRoom("room_id", "room name", matrixClient)); DMRoomMap.makeShared(defaultMatrixClient); }); diff --git a/test/unit-tests/components/views/messages/__snapshots__/TextualBody-test.tsx.snap b/test/unit-tests/components/views/messages/__snapshots__/TextualBody-test.tsx.snap index 44672397ac..8a725eee61 100644 --- a/test/unit-tests/components/views/messages/__snapshots__/TextualBody-test.tsx.snap +++ b/test/unit-tests/components/views/messages/__snapshots__/TextualBody-test.tsx.snap @@ -77,40 +77,38 @@ exports[` renders formatted m.text correctly pills appear for an dir="auto" > Chat with - - - + + - - + height="16px" + loading="lazy" + referrerpolicy="no-referrer" + src="mxc://avatar.url/image.png" + width="16px" + /> +
+ + Member + + +
`; @@ -124,40 +122,38 @@ exports[` renders formatted m.text correctly pills appear for eve dir="auto" > See this message - - - + + - - + height="16px" + loading="lazy" + referrerpolicy="no-referrer" + src="mxc://avatar.url/room.png" + width="16px" + /> + + + Message in room name + + + @@ -173,40 +169,38 @@ exports[` renders formatted m.text correctly pills appear for roo dir="auto" > A - - - + + - - + height="16px" + loading="lazy" + referrerpolicy="no-referrer" + src="mxc://avatar.url/room.png" + width="16px" + /> + + + room name + + + with vias @@ -287,40 +281,38 @@ exports[` renders formatted m.text correctly pills get injected c dir="auto" > Hey - - - + + - - + height="16px" + loading="lazy" + referrerpolicy="no-referrer" + src="mxc://avatar.url/image.png" + width="16px" + /> + + + Member + + + `; @@ -466,25 +458,21 @@ exports[` renders formatted m.text correctly spoilers get injecte dir="auto" > Hey - - - + (movie) + +   + + the movie was awesome + + `; @@ -522,9 +510,9 @@ exports[` renders plain-text m.text correctly linkification get a `; -exports[` renders plain-text m.text correctly should pillify a permalink to a message in the same room with the label »Message from Member« 1`] = `"Visit Message from Member"`; +exports[` renders plain-text m.text correctly should pillify a permalink to a message in the same room with the label »Message from Member« 1`] = `"Visit Message from Member"`; -exports[` renders plain-text m.text correctly should pillify a permalink to an event in another room with the label »Message in Room 2« 1`] = `"Visit Message in Room 2"`; +exports[` renders plain-text m.text correctly should pillify a permalink to an event in another room with the label »Message in Room 2« 1`] = `"Visit Message in Room 2"`; exports[` renders plain-text m.text correctly should pillify a permalink to an unknown message in the same room with the label »Message« 1`] = `
renders plain-text m.text correctly should pillify a pe dir="auto" > Visit - - - + + - - - - - Message - - - - + + + + Message + + +
`; diff --git a/test/unit-tests/components/views/rooms/SearchResultTile-test.tsx b/test/unit-tests/components/views/rooms/SearchResultTile-test.tsx index fb211f8fb6..a605650269 100644 --- a/test/unit-tests/components/views/rooms/SearchResultTile-test.tsx +++ b/test/unit-tests/components/views/rooms/SearchResultTile-test.tsx @@ -10,7 +10,7 @@ import React from "react"; import { MatrixEvent, Room, EventType } from "matrix-js-sdk/src/matrix"; import { render, type RenderResult } from "jest-matrix-react"; -import { stubClient } from "../../../../test-utils"; +import { stubClient, withClientContextRenderOptions } from "../../../../test-utils"; import SearchResultTile from "../../../../../src/components/views/rooms/SearchResultTile"; import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg"; @@ -28,7 +28,10 @@ describe("SearchResultTile", () => { }); function renderComponent(props: Partial): RenderResult { - return render(); + return render( + , + withClientContextRenderOptions(MatrixClientPeg.safeGet()), + ); } it("Sets up appropriate callEventGrouper for m.call. events", () => { diff --git a/test/unit-tests/renderer/__snapshots__/link-tooltip-test.tsx.snap b/test/unit-tests/renderer/__snapshots__/link-tooltip-test.tsx.snap new file mode 100644 index 0000000000..5700fb4dd0 --- /dev/null +++ b/test/unit-tests/renderer/__snapshots__/link-tooltip-test.tsx.snap @@ -0,0 +1,32 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`link-tooltip does nothing for empty element 1`] = ` + +
+ +`; + +exports[`link-tooltip wraps single anchor 1`] = ` + + + +
+ + + + + click + + + + +
+ + +
+`; diff --git a/test/unit-tests/renderer/__snapshots__/pill-test.tsx.snap b/test/unit-tests/renderer/__snapshots__/pill-test.tsx.snap new file mode 100644 index 0000000000..8154467c7e --- /dev/null +++ b/test/unit-tests/renderer/__snapshots__/pill-test.tsx.snap @@ -0,0 +1,103 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`keyword pills should do nothing for empty element 1`] = ` + +
+ +`; + +exports[`keyword pills should pillify 1`] = ` + +
+ Foo + + + + + TeST + + + + + Bar +
+
+`; + +exports[`mention pills should do nothing for empty element 1`] = ` + +
+ +`; + +exports[`mention pills should pillify @room 1`] = ` + +
+ + + + + + @room + + + + +
+
+`; + +exports[`mention pills should pillify @room in an intentional mentions world 1`] = ` + +
+ + + + + + @room + + + + +
+
+`; diff --git a/test/unit-tests/renderer/link-tooltip-test.tsx b/test/unit-tests/renderer/link-tooltip-test.tsx new file mode 100644 index 0000000000..63f9f174b5 --- /dev/null +++ b/test/unit-tests/renderer/link-tooltip-test.tsx @@ -0,0 +1,47 @@ +/* +Copyright 2024-2025 New Vector Ltd. +Copyright 2022 The Matrix.org Foundation C.I.C. + +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 from "react"; +import { screen, fireEvent, render, type RenderResult } from "jest-matrix-react"; +import parse from "html-react-parser"; + +import { ambiguousLinkTooltipRenderer, combineRenderers } from "../../../src/renderer"; +import PlatformPeg from "../../../src/PlatformPeg"; +import type BasePlatform from "../../../src/BasePlatform"; + +describe("link-tooltip", () => { + jest.spyOn(PlatformPeg, "get").mockReturnValue({ needsUrlTooltips: () => true } as unknown as BasePlatform); + + function renderTooltips(input: string): RenderResult { + return render( + <> + {parse(input, { + replace: combineRenderers(ambiguousLinkTooltipRenderer)({ isHtml: true }), + })} + , + ); + } + + it("does nothing for empty element", () => { + const { asFragment } = renderTooltips("
"); + expect(asFragment()).toMatchSnapshot(); + }); + + it("wraps single anchor", () => { + const { container, asFragment } = renderTooltips(` +
+ click +
+ `); + expect(asFragment()).toMatchSnapshot(); + const anchor = container.querySelector("a")!; + expect(anchor.getAttribute("href")).toEqual("/foo"); + fireEvent.focus(anchor.parentElement!); + expect(screen.getByLabelText("http://localhost/foo")).toBe(anchor.parentElement!); + }); +}); diff --git a/test/unit-tests/renderer/pill-test.tsx b/test/unit-tests/renderer/pill-test.tsx new file mode 100644 index 0000000000..f3f3e36f9b --- /dev/null +++ b/test/unit-tests/renderer/pill-test.tsx @@ -0,0 +1,228 @@ +/* +Copyright 2024-2025 New Vector Ltd. +Copyright 2022 The Matrix.org Foundation C.I.C. + +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 from "react"; +import { render, type RenderResult } from "jest-matrix-react"; +import { + MatrixEvent, + ConditionKind, + EventType, + PushRuleActionName, + Room, + TweakName, + type MatrixClient, +} from "matrix-js-sdk/src/matrix"; +import { mocked } from "jest-mock"; +import parse from "html-react-parser"; +import { PushProcessor } from "matrix-js-sdk/src/pushprocessor"; + +import { keywordPillRenderer, mentionPillRenderer, combineRenderers } from "../../../src/renderer"; +import { stubClient, withClientContextRenderOptions } from "../../test-utils"; +import { MatrixClientPeg } from "../../../src/MatrixClientPeg"; +import DMRoomMap from "../../../src/utils/DMRoomMap"; + +describe("mention pills", () => { + let cli: MatrixClient; + let room: Room; + const roomId = "!room:id"; + const event = new MatrixEvent({ + room_id: roomId, + type: EventType.RoomMessage, + content: { + body: "@room", + }, + }); + + beforeEach(() => { + stubClient(); + cli = MatrixClientPeg.safeGet(); + // @ts-expect-error + cli.pushProcessor = new PushProcessor(cli); + room = new Room(roomId, cli, cli.getUserId()!); + room.currentState.mayTriggerNotifOfType = jest.fn().mockReturnValue(true); + (cli.getRoom as jest.Mock).mockReturnValue(room); + cli.pushRules!.global = { + override: [ + { + rule_id: ".m.rule.roomnotif", + default: true, + enabled: true, + conditions: [ + { + kind: ConditionKind.EventMatch, + key: "content.body", + pattern: "@room", + }, + ], + actions: [ + PushRuleActionName.Notify, + { + set_tweak: TweakName.Highlight, + value: true, + }, + ], + }, + { + rule_id: ".m.rule.is_room_mention", + default: true, + enabled: true, + conditions: [ + { + kind: ConditionKind.EventPropertyIs, + key: "content.m\\.mentions.room", + value: true, + }, + { + kind: ConditionKind.SenderNotificationPermission, + key: "room", + }, + ], + actions: [ + PushRuleActionName.Notify, + { + set_tweak: TweakName.Highlight, + }, + ], + }, + ], + }; + + DMRoomMap.makeShared(cli); + }); + + function renderPills(input: string, mxEvent?: MatrixEvent): RenderResult { + return render( + <> + {parse(input, { + replace: combineRenderers(mentionPillRenderer)({ + mxEvent: mxEvent ?? event, + room, + isHtml: true, + }), + })} + , + withClientContextRenderOptions(cli), + ); + } + + it("should do nothing for empty element", () => { + const input = "
"; + const { asFragment } = renderPills(input); + expect(asFragment()).toMatchSnapshot(); + }); + + it("should pillify @room", () => { + const input = "
@room
"; + const { container, asFragment } = renderPills(input); + expect(asFragment()).toMatchSnapshot(); + expect(container.querySelector(".mx_Pill.mx_AtRoomPill")?.textContent).toBe("!@room"); + }); + + it("should pillify @room in an intentional mentions world", () => { + mocked(MatrixClientPeg.safeGet().supportsIntentionalMentions).mockReturnValue(true); + const { container, asFragment } = renderPills( + "
@room
", + new MatrixEvent({ + room_id: roomId, + type: EventType.RoomMessage, + content: { + "body": "@room", + "m.mentions": { + room: true, + }, + }, + }), + ); + expect(asFragment()).toMatchSnapshot(); + expect(container.querySelector(".mx_Pill.mx_AtRoomPill")?.textContent).toBe("!@room"); + }); +}); + +describe("keyword pills", () => { + let cli: MatrixClient; + const keywordRegexpPattern = /(test)/i; + + beforeEach(() => { + stubClient(); + cli = MatrixClientPeg.safeGet(); + cli.pushRules!.global = { + override: [ + { + rule_id: ".m.rule.roomnotif", + default: true, + enabled: true, + conditions: [ + { + kind: ConditionKind.EventMatch, + key: "content.body", + pattern: "@room", + }, + ], + actions: [ + PushRuleActionName.Notify, + { + set_tweak: TweakName.Highlight, + value: true, + }, + ], + }, + { + rule_id: ".m.rule.is_room_mention", + default: true, + enabled: true, + conditions: [ + { + kind: ConditionKind.EventPropertyIs, + key: "content.m\\.mentions.room", + value: true, + }, + { + kind: ConditionKind.SenderNotificationPermission, + key: "room", + }, + ], + actions: [ + PushRuleActionName.Notify, + { + set_tweak: TweakName.Highlight, + }, + ], + }, + ], + }; + + DMRoomMap.makeShared(cli); + }); + + function renderPills(input: string): RenderResult { + return render( + <> + {parse(input, { + replace: combineRenderers(keywordPillRenderer)({ + isHtml: true, + keywordRegexpPattern, + }), + })} + , + withClientContextRenderOptions(cli), + ); + } + + it("should do nothing for empty element", () => { + const input = "
"; + const { asFragment } = renderPills(input); + expect(asFragment()).toMatchSnapshot(); + }); + + it("should pillify", () => { + const input = "
Foo TeST Bar
"; + const { container, asFragment } = renderPills(input); + expect(asFragment()).toMatchSnapshot(); + expect(container.querySelector(".mx_Pill.mx_KeywordPill")?.textContent).toBe("TeST"); + }); +}); diff --git a/test/unit-tests/utils/pillify-test.tsx b/test/unit-tests/utils/pillify-test.tsx deleted file mode 100644 index 2b5a0859bb..0000000000 --- a/test/unit-tests/utils/pillify-test.tsx +++ /dev/null @@ -1,142 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -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 from "react"; -import { act, render } from "jest-matrix-react"; -import { MatrixEvent, ConditionKind, EventType, PushRuleActionName, Room, TweakName } from "matrix-js-sdk/src/matrix"; -import { mocked } from "jest-mock"; -import { PushProcessor } from "matrix-js-sdk/src/pushprocessor"; - -import { pillifyLinks } from "../../../src/utils/pillify"; -import { stubClient } from "../../test-utils"; -import { MatrixClientPeg } from "../../../src/MatrixClientPeg"; -import DMRoomMap from "../../../src/utils/DMRoomMap"; -import { ReactRootManager } from "../../../src/utils/react.tsx"; - -describe("pillify", () => { - const roomId = "!room:id"; - const event = new MatrixEvent({ - room_id: roomId, - type: EventType.RoomMessage, - content: { - body: "@room", - }, - }); - - beforeEach(() => { - stubClient(); - const cli = MatrixClientPeg.safeGet(); - const room = new Room(roomId, cli, cli.getUserId()!); - room.currentState.mayTriggerNotifOfType = jest.fn().mockReturnValue(true); - (cli.getRoom as jest.Mock).mockReturnValue(room); - cli.pushRules!.global = { - override: [ - { - rule_id: ".m.rule.roomnotif", - default: true, - enabled: true, - conditions: [ - { - kind: ConditionKind.EventMatch, - key: "content.body", - pattern: "@room", - }, - ], - actions: [ - PushRuleActionName.Notify, - { - set_tweak: TweakName.Highlight, - value: true, - }, - ], - }, - { - rule_id: ".m.rule.is_room_mention", - default: true, - enabled: true, - conditions: [ - { - kind: ConditionKind.EventPropertyIs, - key: "content.m\\.mentions.room", - value: true, - }, - { - kind: ConditionKind.SenderNotificationPermission, - key: "room", - }, - ], - actions: [ - PushRuleActionName.Notify, - { - set_tweak: TweakName.Highlight, - }, - ], - }, - ], - }; - // @ts-expect-error - cli.pushProcessor = new PushProcessor(cli); - - DMRoomMap.makeShared(cli); - }); - - it("should do nothing for empty element", () => { - const { container } = render(
); - const originalHtml = container.outerHTML; - const containers = new ReactRootManager(); - pillifyLinks(MatrixClientPeg.safeGet(), [container], event, containers); - expect(containers.elements).toHaveLength(0); - expect(container.outerHTML).toEqual(originalHtml); - }); - - it("should pillify @room", () => { - const { container } = render(
@room
); - const containers = new ReactRootManager(); - act(() => pillifyLinks(MatrixClientPeg.safeGet(), [container], event, containers)); - expect(containers.elements).toHaveLength(1); - expect(container.querySelector(".mx_Pill.mx_AtRoomPill")?.textContent).toBe("!@room"); - }); - - it("should pillify @room in an intentional mentions world", () => { - mocked(MatrixClientPeg.safeGet().supportsIntentionalMentions).mockReturnValue(true); - const { container } = render(
@room
); - const containers = new ReactRootManager(); - act(() => - pillifyLinks( - MatrixClientPeg.safeGet(), - [container], - new MatrixEvent({ - room_id: roomId, - type: EventType.RoomMessage, - content: { - "body": "@room", - "m.mentions": { - room: true, - }, - }, - }), - containers, - ), - ); - expect(containers.elements).toHaveLength(1); - expect(container.querySelector(".mx_Pill.mx_AtRoomPill")?.textContent).toBe("!@room"); - }); - - it("should not double up pillification on repeated calls", () => { - const { container } = render(
@room
); - const containers = new ReactRootManager(); - act(() => { - pillifyLinks(MatrixClientPeg.safeGet(), [container], event, containers); - pillifyLinks(MatrixClientPeg.safeGet(), [container], event, containers); - pillifyLinks(MatrixClientPeg.safeGet(), [container], event, containers); - pillifyLinks(MatrixClientPeg.safeGet(), [container], event, containers); - }); - expect(containers.elements).toHaveLength(1); - expect(container.querySelector(".mx_Pill.mx_AtRoomPill")?.textContent).toBe("!@room"); - }); -}); diff --git a/test/unit-tests/utils/tooltipify-test.tsx b/test/unit-tests/utils/tooltipify-test.tsx deleted file mode 100644 index 1bfdcad968..0000000000 --- a/test/unit-tests/utils/tooltipify-test.tsx +++ /dev/null @@ -1,77 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -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 from "react"; -import { act, render } from "jest-matrix-react"; - -import { tooltipifyLinks } from "../../../src/utils/tooltipify"; -import PlatformPeg from "../../../src/PlatformPeg"; -import type BasePlatform from "../../../src/BasePlatform"; -import { ReactRootManager } from "../../../src/utils/react.tsx"; - -describe("tooltipify", () => { - jest.spyOn(PlatformPeg, "get").mockReturnValue({ needsUrlTooltips: () => true } as unknown as BasePlatform); - - it("does nothing for empty element", () => { - const { container: root } = render(
); - const originalHtml = root.outerHTML; - const containers = new ReactRootManager(); - tooltipifyLinks([root], [], containers); - expect(containers.elements).toHaveLength(0); - expect(root.outerHTML).toEqual(originalHtml); - }); - - it("wraps single anchor", () => { - const { container: root } = render( -
- click -
, - ); - const containers = new ReactRootManager(); - tooltipifyLinks([root], [], containers); - expect(containers.elements).toHaveLength(1); - const anchor = root.querySelector("a"); - expect(anchor?.getAttribute("href")).toEqual("/foo"); - const tooltip = anchor!.querySelector(".mx_TextWithTooltip_target"); - expect(tooltip).toBeDefined(); - }); - - it("ignores node", () => { - const { container: root } = render( -
- click -
, - ); - const originalHtml = root.outerHTML; - const containers = new ReactRootManager(); - tooltipifyLinks([root], [root.children[0]], containers); - expect(containers.elements).toHaveLength(0); - expect(root.outerHTML).toEqual(originalHtml); - }); - - it("does not re-wrap if called multiple times", async () => { - const { container: root, unmount } = render( -
- click -
, - ); - const containers = new ReactRootManager(); - tooltipifyLinks([root], [], containers); - tooltipifyLinks([root], [], containers); - tooltipifyLinks([root], [], containers); - tooltipifyLinks([root], [], containers); - expect(containers.elements).toHaveLength(1); - const anchor = root.querySelector("a"); - expect(anchor?.getAttribute("href")).toEqual("/foo"); - const tooltip = anchor!.querySelector(".mx_TextWithTooltip_target"); - expect(tooltip).toBeDefined(); - await act(async () => { - unmount(); - }); - }); -}); diff --git a/yarn.lock b/yarn.lock index cd09507552..1910f97003 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5670,6 +5670,13 @@ domexception@^4.0.0: dependencies: webidl-conversions "^7.0.0" +domhandler@5.0.3, domhandler@^5.0.2, domhandler@^5.0.3: + version "5.0.3" + resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-5.0.3.tgz#cc385f7f751f1d1fc650c21374804254538c7d31" + integrity sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w== + dependencies: + domelementtype "^2.3.0" + domhandler@^4.0.0, domhandler@^4.2.0, domhandler@^4.3.1: version "4.3.1" resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-4.3.1.tgz#8d792033416f59d68bc03a5aa7b018c1ca89279c" @@ -5677,13 +5684,6 @@ domhandler@^4.0.0, domhandler@^4.2.0, domhandler@^4.3.1: dependencies: domelementtype "^2.2.0" -domhandler@^5.0.2, domhandler@^5.0.3: - version "5.0.3" - resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-5.0.3.tgz#cc385f7f751f1d1fc650c21374804254538c7d31" - integrity sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w== - dependencies: - domelementtype "^2.3.0" - domutils@^2.5.2, domutils@^2.8.0: version "2.8.0" resolved "https://registry.yarnpkg.com/domutils/-/domutils-2.8.0.tgz#4437def5db6e2d1f5d6ee859bd95ca7d02048135" @@ -5693,7 +5693,7 @@ domutils@^2.5.2, domutils@^2.8.0: domelementtype "^2.2.0" domhandler "^4.2.0" -domutils@^3.0.1: +domutils@^3.0.1, domutils@^3.2.1, domutils@^3.2.2: version "3.2.2" resolved "https://registry.yarnpkg.com/domutils/-/domutils-3.2.2.tgz#edbfe2b668b0c1d97c24baf0f1062b132221bc78" integrity sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw== @@ -5840,6 +5840,11 @@ entities@^4.2.0, entities@^4.4.0, entities@^4.5.0: resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48" integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw== +entities@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/entities/-/entities-6.0.0.tgz#09c9e29cb79b0a6459a9b9db9efb418ac5bb8e51" + integrity sha512-aKstq2TDOndCn4diEyp9Uq/Flu2i1GlLkc6XIDQSDMuaFE3OPW5OphLCyQ5SpSJZTb4reN+kTcYru5yIfXoRPw== + entities@~3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/entities/-/entities-3.0.1.tgz#2b887ca62585e96db3903482d336c1006c3001d4" @@ -7100,6 +7105,14 @@ hpack.js@^2.1.6: readable-stream "^2.0.1" wbuf "^1.1.0" +html-dom-parser@5.0.13: + version "5.0.13" + resolved "https://registry.yarnpkg.com/html-dom-parser/-/html-dom-parser-5.0.13.tgz#36b25b3ad05dc71741e9cd200ff276cdf8e3831d" + integrity sha512-B7JonBuAfG32I7fDouUQEogBrz3jK9gAuN1r1AaXpED6dIhtg/JwiSRhjGL7aOJwRz3HU4efowCjQBaoXiREqg== + dependencies: + domhandler "5.0.3" + htmlparser2 "10.0.0" + html-encoding-sniffer@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz#2cb1a8cf0db52414776e5b2a7a04d5dd98158de9" @@ -7130,6 +7143,16 @@ html-minifier-terser@^6.0.2: relateurl "^0.2.7" terser "^5.10.0" +html-react-parser@^5.2.2: + version "5.2.2" + resolved "https://registry.yarnpkg.com/html-react-parser/-/html-react-parser-5.2.2.tgz#a4159c14f08c06280ff793ce35f872c2283227cb" + integrity sha512-yA5012CJGSFWYZsgYzfr6HXJgDap38/AEP4ra8Cw+WHIi2ZRDXRX/QVYdumRf1P8zKyScKd6YOrWYvVEiPfGKg== + dependencies: + domhandler "5.0.3" + html-dom-parser "5.0.13" + react-property "2.0.2" + style-to-js "1.1.16" + html-tags@^3.3.1: version "3.3.1" resolved "https://registry.yarnpkg.com/html-tags/-/html-tags-3.3.1.tgz#a04026a18c882e4bba8a01a3d39cfe465d40b5ce" @@ -7146,6 +7169,16 @@ html-webpack-plugin@^5.5.3: pretty-error "^4.0.0" tapable "^2.0.0" +htmlparser2@10.0.0: + version "10.0.0" + resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-10.0.0.tgz#77ad249037b66bf8cc99c6e286ef73b83aeb621d" + integrity sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g== + dependencies: + domelementtype "^2.3.0" + domhandler "^5.0.3" + domutils "^3.2.1" + entities "^6.0.0" + htmlparser2@^6.1.0: version "6.1.0" resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-6.1.0.tgz#c4d762b6c3371a05dbe65e94ae43a9f845fb8fb7" @@ -7360,6 +7393,11 @@ ini@^4.1.3: resolved "https://registry.yarnpkg.com/ini/-/ini-4.1.3.tgz#4c359675a6071a46985eb39b14e4a2c0ec98a795" integrity sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg== +inline-style-parser@0.2.4: + version "0.2.4" + resolved "https://registry.yarnpkg.com/inline-style-parser/-/inline-style-parser-0.2.4.tgz#f4af5fe72e612839fcd453d989a586566d695f22" + integrity sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q== + internal-slot@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.1.0.tgz#1eac91762947d2f7056bc838d93e13b2e9604961" @@ -8510,11 +8548,6 @@ lines-and-columns@^1.1.6: resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632" integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg== -linkify-element@4.2.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/linkify-element/-/linkify-element-4.2.0.tgz#fb5c6d47576487a463fd22a0cc889e15833aa943" - integrity sha512-LahyRMhXAgWTP9TOid7pTv8UUZFDz+saLkIVAoGNmOvISt+uSeBzdGhk3dsvkdzAh1QMhkO+fVJjmkMEITre5g== - linkify-it@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/linkify-it/-/linkify-it-4.0.1.tgz#01f1d5e508190d06669982ba31a7d9f56a5751ec" @@ -10567,6 +10600,11 @@ react-lifecycles-compat@^3.0.4: resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362" integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA== +react-property@2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/react-property/-/react-property-2.0.2.tgz#d5ac9e244cef564880a610bc8d868bd6f60fdda6" + integrity sha512-+PbtI3VuDV0l6CleQMsx2gtK0JZbZKbpdu5ynr+lbsuvtmgbNcS3VM0tuY2QjFNOcWxvXeHjDpy42RO+4U2rug== + react-redux@^7.2.0: version "7.2.9" resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-7.2.9.tgz#09488fbb9416a4efe3735b7235055442b042481d" @@ -10598,6 +10636,11 @@ react-remove-scroll@2.6.0: use-callback-ref "^1.3.0" use-sidecar "^1.1.2" +react-string-replace@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/react-string-replace/-/react-string-replace-1.1.1.tgz#8413a598c60e397fe77df3464f2889f00ba25989" + integrity sha512-26TUbLzLfHQ5jO5N7y3Mx88eeKo0Ml0UjCQuX4BMfOd/JX+enQqlKpL1CZnmjeBRvQE8TR+ds9j1rqx9CxhKHQ== + react-style-singleton@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/react-style-singleton/-/react-style-singleton-2.2.1.tgz#f99e420492b2d8f34d38308ff660b60d0b1205b4" @@ -11702,6 +11745,20 @@ strip-json-comments@^3.1.1: resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== +style-to-js@1.1.16: + version "1.1.16" + resolved "https://registry.yarnpkg.com/style-to-js/-/style-to-js-1.1.16.tgz#e6bd6cd29e250bcf8fa5e6591d07ced7575dbe7a" + integrity sha512-/Q6ld50hKYPH3d/r6nr117TZkHR0w0kGGIVfpG9N6D8NymRPM9RqCUv4pRpJ62E5DqOYx2AFpbZMyCPnjQCnOw== + dependencies: + style-to-object "1.0.8" + +style-to-object@1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/style-to-object/-/style-to-object-1.0.8.tgz#67a29bca47eaa587db18118d68f9d95955e81292" + integrity sha512-xT47I/Eo0rwJmaXC4oilDGDWLohVhR6o/xAQcPQN8q6QBuZVL8qMYL85kLmST5cPjAorwvqIA4qXTRQoYHaL6g== + dependencies: + inline-style-parser "0.2.4" + stylehacks@^7.0.4: version "7.0.4" resolved "https://registry.yarnpkg.com/stylehacks/-/stylehacks-7.0.4.tgz#9c21f7374f4bccc0082412b859b3c89d77d3277c"