From b21386317b2640f7a8196a055a7b1a25d7bfba65 Mon Sep 17 00:00:00 2001 From: Bojidar Marinov Date: Sun, 6 Jul 2025 17:27:00 +0300 Subject: [PATCH 1/8] Fix highlights in messages (or search results) breaking links Fixes #17011 and fixes #29807, by running the linkifier that turns text into links before the highlighter that adds highlights to text. --- package.json | 1 + src/HtmlUtils.tsx | 54 +++++++++++++------ src/Linkify.tsx | 12 ++++- .../views/messages/EventContentBody.tsx | 11 +--- src/linkify-matrix.ts | 8 ++- test/unit-tests/HtmlUtils-test.tsx | 32 +++++++++++ yarn.lock | 5 ++ 7 files changed, 97 insertions(+), 26 deletions(-) diff --git a/package.json b/package.json index c889f31d77f..e01383897e2 100644 --- a/package.json +++ b/package.json @@ -123,6 +123,7 @@ "jsrsasign": "^11.0.0", "jszip": "^3.7.0", "katex": "^0.16.0", + "linkify-html": "^4.3.1", "linkify-react": "4.3.1", "linkify-string": "4.3.1", "linkifyjs": "4.3.1", diff --git a/src/HtmlUtils.tsx b/src/HtmlUtils.tsx index 1d17710dab4..9da804311de 100644 --- a/src/HtmlUtils.tsx +++ b/src/HtmlUtils.tsx @@ -22,7 +22,7 @@ import { getEmojiFromUnicode } from "@matrix-org/emojibase-bindings"; import SettingsStore from "./settings/SettingsStore"; import { stripHTMLReply, stripPlainReply } from "./utils/Reply"; import { PERMITTED_URL_SCHEMES } from "./utils/UrlUtils"; -import { sanitizeHtmlParams, transformTags } from "./Linkify"; +import { linkifyHtml, sanitizeHtmlParams, transformTags } from "./Linkify"; import { graphemeSegmenter } from "./utils/strings"; export { Linkify, linkifyAndSanitizeHtml } from "./Linkify"; @@ -298,6 +298,7 @@ export interface EventRenderOpts { * Should inline media be rendered? */ mediaIsVisible?: boolean; + linkify?: boolean; } function analyseEvent(content: IContent, highlights: Optional, opts: EventRenderOpts = {}): EventAnalysis { @@ -320,6 +321,20 @@ function analyseEvent(content: IContent, highlights: Optional, opts: E }; } + if (opts.linkify) { + // Prevent mutating the source of sanitizeParams. + sanitizeParams = { + ...sanitizeParams, + allowedClasses: { + ... sanitizeParams.allowedClasses, + 'a': sanitizeParams.allowedClasses?.['a'] === true ? true : [ + ... (sanitizeParams.allowedClasses?.['a'] || []), + 'linkified', + ], + }, + }; + } + try { const isFormattedBody = content.format === "org.matrix.custom.html" && typeof content.formatted_body === "string"; @@ -346,7 +361,9 @@ function analyseEvent(content: IContent, highlights: Optional, opts: E ? new HtmlHighlighter("mx_EventTile_searchHighlight", opts.highlightLink) : null; - if (isFormattedBody) { + if (isFormattedBody || opts.linkify) { + let unsafeBody = formattedBody || escapeHtml(plainBody); + if (highlighter) { // XXX: We sanitize the HTML whilst also highlighting its text nodes, to avoid accidentally trying // to highlight HTML tags themselves. However, this does mean that we don't highlight textnodes which @@ -358,20 +375,27 @@ function analyseEvent(content: IContent, highlights: Optional, opts: E }; } - safeBody = sanitizeHtml(formattedBody!, sanitizeParams); - const phtml = new DOMParser().parseFromString(safeBody, "text/html"); - const isPlainText = phtml.body.innerHTML === phtml.body.textContent; - isHtmlMessage = !isPlainText; - - if (isHtmlMessage && SettingsStore.getValue("feature_latex_maths")) { - [...phtml.querySelectorAll("div[data-mx-maths], span[data-mx-maths]")].forEach((e) => { - e.outerHTML = katex.renderToString(decode(e.getAttribute("data-mx-maths")), { - throwOnError: false, - displayMode: e.tagName == "DIV", - output: "htmlAndMathml", + if (opts.linkify) { + unsafeBody = linkifyHtml(unsafeBody!); + } + + safeBody = sanitizeHtml(unsafeBody!, sanitizeParams); + + if (isFormattedBody) { + const phtml = new DOMParser().parseFromString(safeBody, "text/html"); + const isPlainText = phtml.body.innerHTML === phtml.body.textContent; + isHtmlMessage = !isPlainText; + + if (isHtmlMessage && SettingsStore.getValue("feature_latex_maths")) { + [...phtml.querySelectorAll("div[data-mx-maths], span[data-mx-maths]")].forEach((e) => { + e.outerHTML = katex.renderToString(decode(e.getAttribute("data-mx-maths")), { + throwOnError: false, + displayMode: e.tagName == "DIV", + output: "htmlAndMathml", + }); }); - }); - safeBody = phtml.body.innerHTML; + safeBody = phtml.body.innerHTML; + } } } else if (highlighter) { safeBody = highlighter.applyHighlights(escapeHtml(plainBody), safeHighlights!).join(""); diff --git a/src/Linkify.tsx b/src/Linkify.tsx index 846bf8e82d5..f324acd9b81 100644 --- a/src/Linkify.tsx +++ b/src/Linkify.tsx @@ -11,7 +11,7 @@ import sanitizeHtml, { type IOptions } from "sanitize-html"; import { merge } from "lodash"; import _Linkify from "linkify-react"; -import { _linkifyString, ELEMENT_URL_PATTERN, options as linkifyMatrixOptions } from "./linkify-matrix"; +import { _linkifyString, _linkifyHtml, ELEMENT_URL_PATTERN, options as linkifyMatrixOptions } from "./linkify-matrix"; import { tryTransformPermalinkToLocalHref } from "./utils/permalinks/Permalinks"; import { mediaFromMxc } from "./customisations/Media"; import { PERMITTED_URL_SCHEMES } from "./utils/UrlUtils"; @@ -213,6 +213,16 @@ export function linkifyString(str: string, options = linkifyMatrixOptions): stri return _linkifyString(str, options); } +/** + * Linkifies the given HTML-formatted string. This is a wrapper around 'linkifyjs/html'. + * + * @param {string} str HTML string to linkify + * @param {object} [options] Options for linkifyHtml. Default: linkifyMatrixOptions + * @returns {string} Linkified string + */ +export function linkifyHtml(str: string, options = linkifyMatrixOptions): string { + return _linkifyHtml(str, options); +} /** * Linkify the given string and sanitize the HTML afterwards. * diff --git a/src/components/views/messages/EventContentBody.tsx b/src/components/views/messages/EventContentBody.tsx index fce5428e8c5..68fee4d4008 100644 --- a/src/components/views/messages/EventContentBody.tsx +++ b/src/components/views/messages/EventContentBody.tsx @@ -154,12 +154,6 @@ const EventContentBody = memo( const [mediaIsVisible] = useMediaVisible(mxEvent?.getId(), mxEvent?.getRoomId()); const replacer = useReplacer(content, mxEvent, options); - const linkifyOptions = useMemo( - () => ({ - render: replacerToRenderFunction(replacer), - }), - [replacer], - ); const isEmote = content.msgtype === MsgType.Emote; @@ -170,6 +164,7 @@ const EventContentBody = memo( // Part of Replies fallback support stripReplyFallback: stripReply, mediaIsVisible, + linkify, }), [content, mediaIsVisible, enableBigEmoji, highlights, isEmote, stripReply], ); @@ -189,9 +184,7 @@ const EventContentBody = memo( ); - if (!linkify) return body; - - return {body}; + return body; }, ); diff --git a/src/linkify-matrix.ts b/src/linkify-matrix.ts index 0a96e088e9f..8d7da6c4d74 100644 --- a/src/linkify-matrix.ts +++ b/src/linkify-matrix.ts @@ -10,6 +10,7 @@ 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 linkifyString from "linkify-string"; +import linkifyHtml from "linkify-html"; import { getHttpUriForMxc, User } from "matrix-js-sdk/src/matrix"; import { @@ -189,7 +190,11 @@ export const options: Opts = { case Type.RoomAlias: case Type.UserId: default: { - return tryTransformEntityToPermalink(MatrixClientPeg.safeGet(), href) ?? ""; + if (MatrixClientPeg.get()) { + return tryTransformEntityToPermalink(MatrixClientPeg.get()!, href) ?? ""; + } else { + return href; + } } } }, @@ -274,3 +279,4 @@ registerCustomProtocol("mxc", false); export const linkify = linkifyjs; export const _linkifyString = linkifyString; +export const _linkifyHtml = linkifyHtml; diff --git a/test/unit-tests/HtmlUtils-test.tsx b/test/unit-tests/HtmlUtils-test.tsx index 97c8da60133..e0d91beefb4 100644 --- a/test/unit-tests/HtmlUtils-test.tsx +++ b/test/unit-tests/HtmlUtils-test.tsx @@ -86,6 +86,38 @@ describe("bodyToHtml", () => { expect(html).toMatchInlineSnapshot(`"test foo <b>bar"`); }); + it("should linkify and hightlight parts of links in plaintext message highlighting", () => { + const html = bodyToHtml( + { + body: "foo http://link.example/test/path bar", + msgtype: "m.text", + }, + ["test"], + { + linkify: true, + }, + ); + + expect(html).toMatchInlineSnapshot(`"foo http://link.example/test/path bar"`); + }); + + it("should hightlight parts of links in HTML message highlighting", () => { + const html = bodyToHtml( + { + body: "foo http://link.example/test/path bar", + msgtype: "m.text", + formatted_body: "foo http://link.example/test/path bar", + format: "org.matrix.custom.html", + }, + ["test"], + { + linkify: true, + }, + ); + + expect(html).toMatchInlineSnapshot(`"foo http://link.example/test/path bar"`); + }); + it("does not mistake characters in text presentation mode for emoji", () => { const { asFragment } = render( diff --git a/yarn.lock b/yarn.lock index 017a68f3926..282661a8b98 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9032,6 +9032,11 @@ 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-html@^4.3.1: + version "4.3.1" + resolved "https://registry.yarnpkg.com/linkify-html/-/linkify-html-4.3.1.tgz#6226a2205d96eb6a3b0c59571a2b02936c6386f3" + integrity sha512-6ZNyucw7fH9Ncu17s+hvHFB2sU6fLWowqH6MqkXxtVL2kKkhnrho/DMCE3fWovmzVPgWSFGvg6zLkW+VWrVr4w== + linkify-it@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/linkify-it/-/linkify-it-4.0.1.tgz#01f1d5e508190d06669982ba31a7d9f56a5751ec" From 6ffd670ed6cc5fa6e72fd4348d3082e31e3fe7d2 Mon Sep 17 00:00:00 2001 From: Bojidar Marinov Date: Mon, 25 Aug 2025 14:04:53 +0300 Subject: [PATCH 2/8] Fix jest test --- .../components/views/messages/TextualBody-test.tsx | 4 ++-- yarn.lock | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/test/unit-tests/components/views/messages/TextualBody-test.tsx b/test/unit-tests/components/views/messages/TextualBody-test.tsx index cb3d755f8bc..78a12611166 100644 --- a/test/unit-tests/components/views/messages/TextualBody-test.tsx +++ b/test/unit-tests/components/views/messages/TextualBody-test.tsx @@ -188,7 +188,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"`, ); }); @@ -206,7 +206,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"`, ); }); diff --git a/yarn.lock b/yarn.lock index d9cba81186b..b597802412b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10664,10 +10664,10 @@ 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-html@^4.3.1: - version "4.3.1" - resolved "https://registry.yarnpkg.com/linkify-html/-/linkify-html-4.3.1.tgz#6226a2205d96eb6a3b0c59571a2b02936c6386f3" - integrity sha512-6ZNyucw7fH9Ncu17s+hvHFB2sU6fLWowqH6MqkXxtVL2kKkhnrho/DMCE3fWovmzVPgWSFGvg6zLkW+VWrVr4w== +linkify-html@4.3.2: + version "4.3.2" + resolved "https://registry.yarnpkg.com/linkify-html/-/linkify-html-4.3.2.tgz#ef84b39828c66170221af1a49a042c7993bd4543" + integrity sha512-RozNgrfSFrNQlprJSZIN7lF+ZVPj5Pz8POQcu1PYGAUhL9tKtvtWcOXOmlXjuGGEWHtC6gt6Q2U4+VUq9ELmng== linkify-it@^4.0.1: version "4.0.1" From fb312b7c7ca0e1780d435eda69c7622a8b26182b Mon Sep 17 00:00:00 2001 From: Bojidar Marinov Date: Mon, 25 Aug 2025 16:14:20 +0300 Subject: [PATCH 3/8] Fix tests related to emojis and pills-inside-spoilers --- src/HtmlUtils.tsx | 27 ++++++++-------- .../views/messages/EventContentBody.tsx | 4 +-- src/renderer/spoiler.tsx | 6 ++-- src/renderer/utils.tsx | 32 +++++++++++-------- test/unit-tests/HtmlUtils-test.tsx | 10 ++++-- 5 files changed, 44 insertions(+), 35 deletions(-) diff --git a/src/HtmlUtils.tsx b/src/HtmlUtils.tsx index 9da804311de..add1576b9ed 100644 --- a/src/HtmlUtils.tsx +++ b/src/HtmlUtils.tsx @@ -326,11 +326,11 @@ function analyseEvent(content: IContent, highlights: Optional, opts: E sanitizeParams = { ...sanitizeParams, allowedClasses: { - ... sanitizeParams.allowedClasses, - 'a': sanitizeParams.allowedClasses?.['a'] === true ? true : [ - ... (sanitizeParams.allowedClasses?.['a'] || []), - 'linkified', - ], + ...sanitizeParams.allowedClasses, + a: + sanitizeParams.allowedClasses?.["a"] === true + ? true + : [...(sanitizeParams.allowedClasses?.["a"] || []), "linkified"], }, }; } @@ -452,14 +452,15 @@ export function bodyToNode( }); let formattedBody = eventInfo.safeBody; - if (eventInfo.isFormattedBody && eventInfo.bodyHasEmoji && eventInfo.safeBody) { - // This has to be done after the emojiBody check as to not break big emoji on replies - formattedBody = formatEmojis(eventInfo.safeBody, true).join(""); - } - let emojiBodyElements: JSX.Element[] | undefined; - if (!eventInfo.safeBody && eventInfo.bodyHasEmoji) { - emojiBodyElements = formatEmojis(eventInfo.strippedBody, false) as JSX.Element[]; + + if (eventInfo.bodyHasEmoji) { + if (eventInfo.safeBody) { + // This has to be done after the emojiBody check as to not break big emoji on replies + formattedBody = formatEmojis(eventInfo.safeBody, true).join(""); + } else { + emojiBodyElements = formatEmojis(eventInfo.strippedBody, false) as JSX.Element[]; + } } return { strippedBody: eventInfo.strippedBody, formattedBody, emojiBodyElements, className }; @@ -482,7 +483,7 @@ export function bodyToHtml(content: IContent, highlights: Optional, op const eventInfo = analyseEvent(content, highlights, opts); let formattedBody = eventInfo.safeBody; - if (eventInfo.isFormattedBody && eventInfo.bodyHasEmoji && formattedBody) { + if (eventInfo.bodyHasEmoji && formattedBody) { // This has to be done after the emojiBody check above as to not break big emoji on replies formattedBody = formatEmojis(eventInfo.safeBody, true).join(""); } diff --git a/src/components/views/messages/EventContentBody.tsx b/src/components/views/messages/EventContentBody.tsx index 68fee4d4008..8edaf3eba71 100644 --- a/src/components/views/messages/EventContentBody.tsx +++ b/src/components/views/messages/EventContentBody.tsx @@ -11,7 +11,6 @@ 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, @@ -23,7 +22,6 @@ import { ambiguousLinkTooltipRenderer, codeBlockRenderer, spoilerRenderer, - replacerToRenderFunction, } from "../../../renderer"; import MatrixClientContext from "../../../contexts/MatrixClientContext.tsx"; import { useSettingValue } from "../../../hooks/useSettings.ts"; @@ -166,7 +164,7 @@ const EventContentBody = memo( mediaIsVisible, linkify, }), - [content, mediaIsVisible, enableBigEmoji, highlights, isEmote, stripReply], + [content, mediaIsVisible, enableBigEmoji, highlights, isEmote, stripReply, linkify], ); if (as === "div") includeDir = true; // force dir="auto" on divs diff --git a/src/renderer/spoiler.tsx b/src/renderer/spoiler.tsx index ee7f45f48e7..7b71295c08b 100644 --- a/src/renderer/spoiler.tsx +++ b/src/renderer/spoiler.tsx @@ -15,10 +15,12 @@ import Spoiler from "../components/views/elements/Spoiler.tsx"; * Replaces spans with `data-mx-spoiler` with a Spoiler component. */ export const spoilerRenderer: RendererMap = { - span: (span) => { + span: (span, params) => { const reason = span.attribs["data-mx-spoiler"]; if (typeof reason === "string") { - return {domToReact(span.children as DOMNode[])}; + return ( + {domToReact(span.children as DOMNode[], { replace: params.replace })} + ); } }, }; diff --git a/src/renderer/utils.tsx b/src/renderer/utils.tsx index 0cf37b9a8c2..5f56c624def 100644 --- a/src/renderer/utils.tsx +++ b/src/renderer/utils.tsx @@ -88,6 +88,7 @@ export function replacerToRenderFunction(replacer: Replacer): Opts["render"] { interface Parameters { isHtml: boolean; + replace: Replacer; // Required for keywordPillRenderer keywordRegexpPattern?: RegExp; // Required for mentionPillRenderer @@ -114,7 +115,7 @@ export type RendererMap = Partial< } >; -type PreparedRenderer = (parameters: Parameters) => Replacer; +type PreparedRenderer = (parameters: Omit) => Replacer; /** * Combines multiple renderers into a single Replacer function @@ -122,19 +123,22 @@ type PreparedRenderer = (parameters: Parameters) => Replacer; */ 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; + (parameters) => { + const replace: Replacer = (node, index) => { + if (node.type === "text") { + for (const replacer of renderers) { + const result = replacer[Node.TEXT_NODE]?.(node, parametersWithReplace, 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; + if (node instanceof Element) { + const tagName = node.tagName.toLowerCase() as keyof HTMLElementTagNameMap; + for (const replacer of renderers) { + const result = replacer[tagName]?.(node, parametersWithReplace, index); + if (result) return result; + } } - } + }; + const parametersWithReplace: Parameters = { ...parameters, replace }; + return replace; }; diff --git a/test/unit-tests/HtmlUtils-test.tsx b/test/unit-tests/HtmlUtils-test.tsx index e0d91beefb4..2f71d53ae0d 100644 --- a/test/unit-tests/HtmlUtils-test.tsx +++ b/test/unit-tests/HtmlUtils-test.tsx @@ -98,7 +98,9 @@ describe("bodyToHtml", () => { }, ); - expect(html).toMatchInlineSnapshot(`"foo http://link.example/test/path bar"`); + expect(html).toMatchInlineSnapshot( + `"foo http://link.example/test/path bar"`, + ); }); it("should hightlight parts of links in HTML message highlighting", () => { @@ -106,7 +108,7 @@ describe("bodyToHtml", () => { { body: "foo http://link.example/test/path bar", msgtype: "m.text", - formatted_body: "foo http://link.example/test/path bar", + formatted_body: 'foo http://link.example/test/path bar', format: "org.matrix.custom.html", }, ["test"], @@ -115,7 +117,9 @@ describe("bodyToHtml", () => { }, ); - expect(html).toMatchInlineSnapshot(`"foo http://link.example/test/path bar"`); + expect(html).toMatchInlineSnapshot( + `"foo http://link.example/test/path bar"`, + ); }); it("does not mistake characters in text presentation mode for emoji", () => { From b3a63858cd8d81393bce3eea68ec62548360c297 Mon Sep 17 00:00:00 2001 From: Bojidar Marinov Date: Fri, 5 Sep 2025 21:20:43 +0300 Subject: [PATCH 4/8] Remove dead code --- src/renderer/index.ts | 1 - src/renderer/utils.tsx | 21 --------------------- 2 files changed, 22 deletions(-) diff --git a/src/renderer/index.ts b/src/renderer/index.ts index eaed7b71c3a..5066a7025eb 100644 --- a/src/renderer/index.ts +++ b/src/renderer/index.ts @@ -11,7 +11,6 @@ export { spoilerRenderer } from "./spoiler"; export { codeBlockRenderer } from "./code-block"; export { applyReplacerOnString, - replacerToRenderFunction, combineRenderers, type RendererMap, type Replacer, diff --git a/src/renderer/utils.tsx b/src/renderer/utils.tsx index 5f56c624def..400802ad27c 100644 --- a/src/renderer/utils.tsx +++ b/src/renderer/utils.tsx @@ -65,27 +65,6 @@ export function applyReplacerOnString( }); } -/** - * 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; replace: Replacer; From 5e779e83f07236d7b15ba63e42127ad52b6bed3d Mon Sep 17 00:00:00 2001 From: Bojidar Marinov Date: Fri, 5 Sep 2025 21:21:13 +0300 Subject: [PATCH 5/8] Address review comments around sanitizeParams --- src/HtmlUtils.tsx | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/HtmlUtils.tsx b/src/HtmlUtils.tsx index add1576b9ed..58268e7e57e 100644 --- a/src/HtmlUtils.tsx +++ b/src/HtmlUtils.tsx @@ -323,16 +323,14 @@ function analyseEvent(content: IContent, highlights: Optional, opts: E if (opts.linkify) { // Prevent mutating the source of sanitizeParams. - sanitizeParams = { - ...sanitizeParams, - allowedClasses: { - ...sanitizeParams.allowedClasses, - a: - sanitizeParams.allowedClasses?.["a"] === true - ? true - : [...(sanitizeParams.allowedClasses?.["a"] || []), "linkified"], - }, - }; + sanitizeParams = {...sanitizeParams} + sanitizeParams.allowedClasses ??= {} + if (typeof sanitizeParams.allowedClasses.a === "boolean") { + // All classes are already allowed for "a" + } else { + sanitizeParams.allowedClasses.a ??= [] + sanitizeParams.allowedClasses.a.push("linkified") + } } try { From 9c61de1bbb033a25d021d5ff635f0b8ca993ce37 Mon Sep 17 00:00:00 2001 From: Bojidar Marinov Date: Fri, 5 Sep 2025 23:36:56 +0300 Subject: [PATCH 6/8] Address review comment about linkify-matrix --- src/linkify-matrix.ts | 6 +----- test/unit-tests/HtmlUtils-test.tsx | 2 ++ 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/linkify-matrix.ts b/src/linkify-matrix.ts index a0d2058b1ff..b6ed8ee7fc2 100644 --- a/src/linkify-matrix.ts +++ b/src/linkify-matrix.ts @@ -211,11 +211,7 @@ export const options: Opts = { case Type.RoomAlias: case Type.UserId: default: { - if (MatrixClientPeg.get()) { - return tryTransformEntityToPermalink(MatrixClientPeg.get()!, href) ?? ""; - } else { - return href; - } + return tryTransformEntityToPermalink(MatrixClientPeg.safeGet(), href) ?? ""; } } }, diff --git a/test/unit-tests/HtmlUtils-test.tsx b/test/unit-tests/HtmlUtils-test.tsx index 2f71d53ae0d..690f2a713ab 100644 --- a/test/unit-tests/HtmlUtils-test.tsx +++ b/test/unit-tests/HtmlUtils-test.tsx @@ -87,6 +87,8 @@ describe("bodyToHtml", () => { }); it("should linkify and hightlight parts of links in plaintext message highlighting", () => { + getMockClientWithEventEmitter({}); + const html = bodyToHtml( { body: "foo http://link.example/test/path bar", From 9316015cc3c78b93bc99ffa27c8971e75acf4ee5 Mon Sep 17 00:00:00 2001 From: Bojidar Marinov Date: Sat, 6 Sep 2025 00:09:08 +0300 Subject: [PATCH 7/8] Fix code style --- src/HtmlUtils.tsx | 8 ++++---- src/renderer/index.ts | 7 +------ src/renderer/utils.tsx | 1 - 3 files changed, 5 insertions(+), 11 deletions(-) diff --git a/src/HtmlUtils.tsx b/src/HtmlUtils.tsx index 58268e7e57e..4e3a38a1f20 100644 --- a/src/HtmlUtils.tsx +++ b/src/HtmlUtils.tsx @@ -323,13 +323,13 @@ function analyseEvent(content: IContent, highlights: Optional, opts: E if (opts.linkify) { // Prevent mutating the source of sanitizeParams. - sanitizeParams = {...sanitizeParams} - sanitizeParams.allowedClasses ??= {} + sanitizeParams = { ...sanitizeParams }; + sanitizeParams.allowedClasses ??= {}; if (typeof sanitizeParams.allowedClasses.a === "boolean") { // All classes are already allowed for "a" } else { - sanitizeParams.allowedClasses.a ??= [] - sanitizeParams.allowedClasses.a.push("linkified") + sanitizeParams.allowedClasses.a ??= []; + sanitizeParams.allowedClasses.a.push("linkified"); } } diff --git a/src/renderer/index.ts b/src/renderer/index.ts index 5066a7025eb..3de73c8bbe6 100644 --- a/src/renderer/index.ts +++ b/src/renderer/index.ts @@ -9,9 +9,4 @@ export { ambiguousLinkTooltipRenderer } from "./link-tooltip"; export { keywordPillRenderer, mentionPillRenderer } from "./pill"; export { spoilerRenderer } from "./spoiler"; export { codeBlockRenderer } from "./code-block"; -export { - applyReplacerOnString, - combineRenderers, - type RendererMap, - type Replacer, -} from "./utils"; +export { applyReplacerOnString, combineRenderers, type RendererMap, type Replacer } from "./utils"; diff --git a/src/renderer/utils.tsx b/src/renderer/utils.tsx index 400802ad27c..3109e9b447e 100644 --- a/src/renderer/utils.tsx +++ b/src/renderer/utils.tsx @@ -8,7 +8,6 @@ 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 From 09dfb69b9b86998c36ea77dacc5382b1fe7be31a Mon Sep 17 00:00:00 2001 From: Bojidar Marinov Date: Tue, 23 Sep 2025 18:00:29 +0300 Subject: [PATCH 8/8] Refactor if statement per review --- src/HtmlUtils.tsx | 57 ++++++++++++++++++++++++----------------------- 1 file changed, 29 insertions(+), 28 deletions(-) diff --git a/src/HtmlUtils.tsx b/src/HtmlUtils.tsx index 4e3a38a1f20..027a51ea725 100644 --- a/src/HtmlUtils.tsx +++ b/src/HtmlUtils.tsx @@ -359,42 +359,43 @@ function analyseEvent(content: IContent, highlights: Optional, opts: E ? new HtmlHighlighter("mx_EventTile_searchHighlight", opts.highlightLink) : null; - if (isFormattedBody || opts.linkify) { - let unsafeBody = formattedBody || escapeHtml(plainBody); - - if (highlighter) { - // XXX: We sanitize the HTML whilst also highlighting its text nodes, to avoid accidentally trying - // to highlight HTML tags themselves. However, this does mean that we don't highlight textnodes which - // are interrupted by HTML tags (not that we did before) - e.g. foobar won't get highlighted - // by an attempt to search for 'foobar'. Then again, the search query probably wouldn't work either - // XXX: hacky bodge to temporarily apply a textFilter to the sanitizeParams structure. - sanitizeParams.textFilter = function (safeText) { - return highlighter.applyHighlights(safeText, safeHighlights!).join(""); - }; - } + if (highlighter) { + // XXX: We sanitize the HTML whilst also highlighting its text nodes, to avoid accidentally trying + // to highlight HTML tags themselves. However, this does mean that we don't highlight textnodes which + // are interrupted by HTML tags (not that we did before) - e.g. foobar won't get highlighted + // by an attempt to search for 'foobar'. Then again, the search query probably wouldn't work either + // XXX: hacky bodge to temporarily apply a textFilter to the sanitizeParams structure. + sanitizeParams.textFilter = function (safeText) { + return highlighter.applyHighlights(safeText, safeHighlights!).join(""); + }; + } + + if (isFormattedBody) { + let unsafeBody = formattedBody!; if (opts.linkify) { - unsafeBody = linkifyHtml(unsafeBody!); + unsafeBody = linkifyHtml(unsafeBody); } - safeBody = sanitizeHtml(unsafeBody!, sanitizeParams); + safeBody = sanitizeHtml(unsafeBody, sanitizeParams); - if (isFormattedBody) { - const phtml = new DOMParser().parseFromString(safeBody, "text/html"); - const isPlainText = phtml.body.innerHTML === phtml.body.textContent; - isHtmlMessage = !isPlainText; + const phtml = new DOMParser().parseFromString(safeBody, "text/html"); + const isPlainText = phtml.body.innerHTML === phtml.body.textContent; + isHtmlMessage = !isPlainText; - if (isHtmlMessage && SettingsStore.getValue("feature_latex_maths")) { - [...phtml.querySelectorAll("div[data-mx-maths], span[data-mx-maths]")].forEach((e) => { - e.outerHTML = katex.renderToString(decode(e.getAttribute("data-mx-maths")), { - throwOnError: false, - displayMode: e.tagName == "DIV", - output: "htmlAndMathml", - }); + if (isHtmlMessage && SettingsStore.getValue("feature_latex_maths")) { + [...phtml.querySelectorAll("div[data-mx-maths], span[data-mx-maths]")].forEach((e) => { + e.outerHTML = katex.renderToString(decode(e.getAttribute("data-mx-maths")), { + throwOnError: false, + displayMode: e.tagName == "DIV", + output: "htmlAndMathml", }); - safeBody = phtml.body.innerHTML; - } + }); + safeBody = phtml.body.innerHTML; } + } else if (opts.linkify) { + // If we are linkifying plain text, pass the result through sanitizeHtml so that the highlighter configured in sanitizeParams.textFilter gets applied. + safeBody = sanitizeHtml(linkifyHtml(escapeHtml(plainBody)), sanitizeParams); } else if (highlighter) { safeBody = highlighter.applyHighlights(escapeHtml(plainBody), safeHighlights!).join(""); }