Skip to content

Commit cf51b25

Browse files
bojidar-bgt3chguy
andauthored
Fix highlights in messages (or search results) breaking links (#30264)
* 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. * Fix jest test * Fix tests related to emojis and pills-inside-spoilers * Remove dead code * Address review comments around sanitizeParams * Address review comment about linkify-matrix * Fix code style * Refactor if statement per review --------- Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
1 parent 9d973c8 commit cf51b25

11 files changed

Lines changed: 128 additions & 79 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@
122122
"jsrsasign": "^11.0.0",
123123
"jszip": "^3.7.0",
124124
"katex": "^0.16.0",
125+
"linkify-html": "4.3.2",
125126
"linkify-react": "4.3.2",
126127
"linkify-string": "4.3.2",
127128
"linkifyjs": "4.3.2",

src/HtmlUtils.tsx

Lines changed: 43 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import { getEmojiFromUnicode } from "@matrix-org/emojibase-bindings";
2222
import SettingsStore from "./settings/SettingsStore";
2323
import { stripHTMLReply, stripPlainReply } from "./utils/Reply";
2424
import { PERMITTED_URL_SCHEMES } from "./utils/UrlUtils";
25-
import { sanitizeHtmlParams, transformTags } from "./Linkify";
25+
import { linkifyHtml, sanitizeHtmlParams, transformTags } from "./Linkify";
2626
import { graphemeSegmenter } from "./utils/strings";
2727

2828
export { Linkify, linkifyAndSanitizeHtml } from "./Linkify";
@@ -298,6 +298,7 @@ export interface EventRenderOpts {
298298
* Should inline media be rendered?
299299
*/
300300
mediaIsVisible?: boolean;
301+
linkify?: boolean;
301302
}
302303

303304
function analyseEvent(content: IContent, highlights: Optional<string[]>, opts: EventRenderOpts = {}): EventAnalysis {
@@ -320,6 +321,18 @@ function analyseEvent(content: IContent, highlights: Optional<string[]>, opts: E
320321
};
321322
}
322323

324+
if (opts.linkify) {
325+
// Prevent mutating the source of sanitizeParams.
326+
sanitizeParams = { ...sanitizeParams };
327+
sanitizeParams.allowedClasses ??= {};
328+
if (typeof sanitizeParams.allowedClasses.a === "boolean") {
329+
// All classes are already allowed for "a"
330+
} else {
331+
sanitizeParams.allowedClasses.a ??= [];
332+
sanitizeParams.allowedClasses.a.push("linkified");
333+
}
334+
}
335+
323336
try {
324337
const isFormattedBody =
325338
content.format === "org.matrix.custom.html" && typeof content.formatted_body === "string";
@@ -346,19 +359,26 @@ function analyseEvent(content: IContent, highlights: Optional<string[]>, opts: E
346359
? new HtmlHighlighter("mx_EventTile_searchHighlight", opts.highlightLink)
347360
: null;
348361

362+
if (highlighter) {
363+
// XXX: We sanitize the HTML whilst also highlighting its text nodes, to avoid accidentally trying
364+
// to highlight HTML tags themselves. However, this does mean that we don't highlight textnodes which
365+
// are interrupted by HTML tags (not that we did before) - e.g. foo<span/>bar won't get highlighted
366+
// by an attempt to search for 'foobar'. Then again, the search query probably wouldn't work either
367+
// XXX: hacky bodge to temporarily apply a textFilter to the sanitizeParams structure.
368+
sanitizeParams.textFilter = function (safeText) {
369+
return highlighter.applyHighlights(safeText, safeHighlights!).join("");
370+
};
371+
}
372+
349373
if (isFormattedBody) {
350-
if (highlighter) {
351-
// XXX: We sanitize the HTML whilst also highlighting its text nodes, to avoid accidentally trying
352-
// to highlight HTML tags themselves. However, this does mean that we don't highlight textnodes which
353-
// are interrupted by HTML tags (not that we did before) - e.g. foo<span/>bar won't get highlighted
354-
// by an attempt to search for 'foobar'. Then again, the search query probably wouldn't work either
355-
// XXX: hacky bodge to temporarily apply a textFilter to the sanitizeParams structure.
356-
sanitizeParams.textFilter = function (safeText) {
357-
return highlighter.applyHighlights(safeText, safeHighlights!).join("");
358-
};
374+
let unsafeBody = formattedBody!;
375+
376+
if (opts.linkify) {
377+
unsafeBody = linkifyHtml(unsafeBody);
359378
}
360379

361-
safeBody = sanitizeHtml(formattedBody!, sanitizeParams);
380+
safeBody = sanitizeHtml(unsafeBody, sanitizeParams);
381+
362382
const phtml = new DOMParser().parseFromString(safeBody, "text/html");
363383
const isPlainText = phtml.body.innerHTML === phtml.body.textContent;
364384
isHtmlMessage = !isPlainText;
@@ -373,6 +393,9 @@ function analyseEvent(content: IContent, highlights: Optional<string[]>, opts: E
373393
});
374394
safeBody = phtml.body.innerHTML;
375395
}
396+
} else if (opts.linkify) {
397+
// If we are linkifying plain text, pass the result through sanitizeHtml so that the highlighter configured in sanitizeParams.textFilter gets applied.
398+
safeBody = sanitizeHtml(linkifyHtml(escapeHtml(plainBody)), sanitizeParams);
376399
} else if (highlighter) {
377400
safeBody = highlighter.applyHighlights(escapeHtml(plainBody), safeHighlights!).join("");
378401
}
@@ -428,14 +451,15 @@ export function bodyToNode(
428451
});
429452

430453
let formattedBody = eventInfo.safeBody;
431-
if (eventInfo.isFormattedBody && eventInfo.bodyHasEmoji && eventInfo.safeBody) {
432-
// This has to be done after the emojiBody check as to not break big emoji on replies
433-
formattedBody = formatEmojis(eventInfo.safeBody, true).join("");
434-
}
435-
436454
let emojiBodyElements: JSX.Element[] | undefined;
437-
if (!eventInfo.safeBody && eventInfo.bodyHasEmoji) {
438-
emojiBodyElements = formatEmojis(eventInfo.strippedBody, false) as JSX.Element[];
455+
456+
if (eventInfo.bodyHasEmoji) {
457+
if (eventInfo.safeBody) {
458+
// This has to be done after the emojiBody check as to not break big emoji on replies
459+
formattedBody = formatEmojis(eventInfo.safeBody, true).join("");
460+
} else {
461+
emojiBodyElements = formatEmojis(eventInfo.strippedBody, false) as JSX.Element[];
462+
}
439463
}
440464

441465
return { strippedBody: eventInfo.strippedBody, formattedBody, emojiBodyElements, className };
@@ -458,7 +482,7 @@ export function bodyToHtml(content: IContent, highlights: Optional<string[]>, op
458482
const eventInfo = analyseEvent(content, highlights, opts);
459483

460484
let formattedBody = eventInfo.safeBody;
461-
if (eventInfo.isFormattedBody && eventInfo.bodyHasEmoji && formattedBody) {
485+
if (eventInfo.bodyHasEmoji && formattedBody) {
462486
// This has to be done after the emojiBody check above as to not break big emoji on replies
463487
formattedBody = formatEmojis(eventInfo.safeBody, true).join("");
464488
}

src/Linkify.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import sanitizeHtml, { type IOptions } from "sanitize-html";
1111
import { merge } from "lodash";
1212
import _Linkify from "linkify-react";
1313

14-
import { _linkifyString, ELEMENT_URL_PATTERN, options as linkifyMatrixOptions } from "./linkify-matrix";
14+
import { _linkifyString, _linkifyHtml, ELEMENT_URL_PATTERN, options as linkifyMatrixOptions } from "./linkify-matrix";
1515
import { tryTransformPermalinkToLocalHref } from "./utils/permalinks/Permalinks";
1616
import { mediaFromMxc } from "./customisations/Media";
1717
import { PERMITTED_URL_SCHEMES } from "./utils/UrlUtils";
@@ -213,6 +213,16 @@ export function linkifyString(str: string, options = linkifyMatrixOptions): stri
213213
return _linkifyString(str, options);
214214
}
215215

216+
/**
217+
* Linkifies the given HTML-formatted string. This is a wrapper around 'linkifyjs/html'.
218+
*
219+
* @param {string} str HTML string to linkify
220+
* @param {object} [options] Options for linkifyHtml. Default: linkifyMatrixOptions
221+
* @returns {string} Linkified string
222+
*/
223+
export function linkifyHtml(str: string, options = linkifyMatrixOptions): string {
224+
return _linkifyHtml(str, options);
225+
}
216226
/**
217227
* Linkify the given string and sanitize the HTML afterwards.
218228
*

src/components/views/messages/EventContentBody.tsx

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import parse from "html-react-parser";
1111
import { PushProcessor } from "matrix-js-sdk/src/pushprocessor";
1212

1313
import { bodyToNode } from "../../../HtmlUtils.tsx";
14-
import { Linkify } from "../../../Linkify.tsx";
1514
import PlatformPeg from "../../../PlatformPeg.ts";
1615
import {
1716
applyReplacerOnString,
@@ -23,7 +22,6 @@ import {
2322
ambiguousLinkTooltipRenderer,
2423
codeBlockRenderer,
2524
spoilerRenderer,
26-
replacerToRenderFunction,
2725
} from "../../../renderer";
2826
import MatrixClientContext from "../../../contexts/MatrixClientContext.tsx";
2927
import { useSettingValue } from "../../../hooks/useSettings.ts";
@@ -154,12 +152,6 @@ const EventContentBody = memo(
154152
const [mediaIsVisible] = useMediaVisible(mxEvent);
155153

156154
const replacer = useReplacer(content, mxEvent, options);
157-
const linkifyOptions = useMemo(
158-
() => ({
159-
render: replacerToRenderFunction(replacer),
160-
}),
161-
[replacer],
162-
);
163155

164156
const isEmote = content.msgtype === MsgType.Emote;
165157

@@ -170,8 +162,9 @@ const EventContentBody = memo(
170162
// Part of Replies fallback support
171163
stripReplyFallback: stripReply,
172164
mediaIsVisible,
165+
linkify,
173166
}),
174-
[content, mediaIsVisible, enableBigEmoji, highlights, isEmote, stripReply],
167+
[content, mediaIsVisible, enableBigEmoji, highlights, isEmote, stripReply, linkify],
175168
);
176169

177170
if (as === "div") includeDir = true; // force dir="auto" on divs
@@ -189,9 +182,7 @@ const EventContentBody = memo(
189182
</As>
190183
);
191184

192-
if (!linkify) return body;
193-
194-
return <Linkify options={linkifyOptions}>{body}</Linkify>;
185+
return body;
195186
},
196187
);
197188

src/linkify-matrix.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ Please see LICENSE files in the repository root for full details.
1010
import * as linkifyjs from "linkifyjs";
1111
import { type EventListeners, type Opts, registerCustomProtocol, registerPlugin } from "linkifyjs";
1212
import linkifyString from "linkify-string";
13+
import linkifyHtml from "linkify-html";
1314
import { getHttpUriForMxc, User } from "matrix-js-sdk/src/matrix";
1415

1516
import {
@@ -293,3 +294,4 @@ registerCustomProtocol("mxc", false);
293294

294295
export const linkify = linkifyjs;
295296
export const _linkifyString = linkifyString;
297+
export const _linkifyHtml = linkifyHtml;

src/renderer/index.ts

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,4 @@ export { ambiguousLinkTooltipRenderer } from "./link-tooltip";
99
export { keywordPillRenderer, mentionPillRenderer } from "./pill";
1010
export { spoilerRenderer } from "./spoiler";
1111
export { codeBlockRenderer } from "./code-block";
12-
export {
13-
applyReplacerOnString,
14-
replacerToRenderFunction,
15-
combineRenderers,
16-
type RendererMap,
17-
type Replacer,
18-
} from "./utils";
12+
export { applyReplacerOnString, combineRenderers, type RendererMap, type Replacer } from "./utils";

src/renderer/spoiler.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,12 @@ import Spoiler from "../components/views/elements/Spoiler.tsx";
1515
* Replaces spans with `data-mx-spoiler` with a Spoiler component.
1616
*/
1717
export const spoilerRenderer: RendererMap = {
18-
span: (span) => {
18+
span: (span, params) => {
1919
const reason = span.attribs["data-mx-spoiler"];
2020
if (typeof reason === "string") {
21-
return <Spoiler reason={reason}>{domToReact(span.children as DOMNode[])}</Spoiler>;
21+
return (
22+
<Spoiler reason={reason}>{domToReact(span.children as DOMNode[], { replace: params.replace })}</Spoiler>
23+
);
2224
}
2325
},
2426
};

src/renderer/utils.tsx

Lines changed: 18 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ Please see LICENSE files in the repository root for full details.
88
import React, { type JSX } from "react";
99
import { type DOMNode, Element, type HTMLReactParserOptions, Text } from "html-react-parser";
1010
import { type MatrixEvent, type Room } from "matrix-js-sdk/src/matrix";
11-
import { type Opts } from "linkifyjs";
1211

1312
/**
1413
* The type of a parent node of an element, normally exported by domhandler but that is not a direct dependency of ours
@@ -65,29 +64,9 @@ export function applyReplacerOnString(
6564
});
6665
}
6766

68-
/**
69-
* Converts a Replacer function to a render function for linkify-react
70-
* So that we can use the same replacer functions for both
71-
* @param replacer The replacer function to convert
72-
*/
73-
export function replacerToRenderFunction(replacer: Replacer): Opts["render"] {
74-
if (!replacer) return;
75-
return ({ tagName, attributes, content }) => {
76-
const domNode = new Element(tagName, attributes, [new Text(content)], "tag" as Element["type"]);
77-
const result = replacer(domNode, 0);
78-
if (result) return result;
79-
80-
// This is cribbed from the default render function in linkify-react
81-
if (attributes.class) {
82-
attributes.className = attributes.class;
83-
delete attributes.class;
84-
}
85-
return React.createElement(tagName, attributes, content);
86-
};
87-
}
88-
8967
interface Parameters {
9068
isHtml: boolean;
69+
replace: Replacer;
9170
// Required for keywordPillRenderer
9271
keywordRegexpPattern?: RegExp;
9372
// Required for mentionPillRenderer
@@ -114,27 +93,30 @@ export type RendererMap = Partial<
11493
}
11594
>;
11695

117-
type PreparedRenderer = (parameters: Parameters) => Replacer;
96+
type PreparedRenderer = (parameters: Omit<Parameters, "replace">) => Replacer;
11897

11998
/**
12099
* Combines multiple renderers into a single Replacer function
121100
* @param renderers - the list of renderers to combine
122101
*/
123102
export const combineRenderers =
124103
(...renderers: RendererMap[]): PreparedRenderer =>
125-
(parameters) =>
126-
(node, index) => {
127-
if (node.type === "text") {
128-
for (const replacer of renderers) {
129-
const result = replacer[Node.TEXT_NODE]?.(node, parameters, index);
130-
if (result) return result;
104+
(parameters) => {
105+
const replace: Replacer = (node, index) => {
106+
if (node.type === "text") {
107+
for (const replacer of renderers) {
108+
const result = replacer[Node.TEXT_NODE]?.(node, parametersWithReplace, index);
109+
if (result) return result;
110+
}
131111
}
132-
}
133-
if (node instanceof Element) {
134-
const tagName = node.tagName.toLowerCase() as keyof HTMLElementTagNameMap;
135-
for (const replacer of renderers) {
136-
const result = replacer[tagName]?.(node, parameters, index);
137-
if (result) return result;
112+
if (node instanceof Element) {
113+
const tagName = node.tagName.toLowerCase() as keyof HTMLElementTagNameMap;
114+
for (const replacer of renderers) {
115+
const result = replacer[tagName]?.(node, parametersWithReplace, index);
116+
if (result) return result;
117+
}
138118
}
139-
}
119+
};
120+
const parametersWithReplace: Parameters = { ...parameters, replace };
121+
return replace;
140122
};

test/unit-tests/HtmlUtils-test.tsx

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,44 @@ describe("bodyToHtml", () => {
8686
expect(html).toMatchInlineSnapshot(`"<span class="mx_EventTile_searchHighlight">test</span> foo &lt;b&gt;bar"`);
8787
});
8888

89+
it("should linkify and hightlight parts of links in plaintext message highlighting", () => {
90+
getMockClientWithEventEmitter({});
91+
92+
const html = bodyToHtml(
93+
{
94+
body: "foo http://link.example/test/path bar",
95+
msgtype: "m.text",
96+
},
97+
["test"],
98+
{
99+
linkify: true,
100+
},
101+
);
102+
103+
expect(html).toMatchInlineSnapshot(
104+
`"foo <a href="http://link.example/test/path" class="linkified" target="_blank" rel="noreferrer noopener">http://link.example/<span class="mx_EventTile_searchHighlight">test</span>/path</a> bar"`,
105+
);
106+
});
107+
108+
it("should hightlight parts of links in HTML message highlighting", () => {
109+
const html = bodyToHtml(
110+
{
111+
body: "foo http://link.example/test/path bar",
112+
msgtype: "m.text",
113+
formatted_body: 'foo <a href="http://link.example/test/path">http://link.example/test/path</a> bar',
114+
format: "org.matrix.custom.html",
115+
},
116+
["test"],
117+
{
118+
linkify: true,
119+
},
120+
);
121+
122+
expect(html).toMatchInlineSnapshot(
123+
`"foo <a href="http://link.example/test/path" target="_blank" rel="noreferrer noopener">http://link.example/<span class="mx_EventTile_searchHighlight">test</span>/path</a> bar"`,
124+
);
125+
});
126+
89127
it("does not mistake characters in text presentation mode for emoji", () => {
90128
const { asFragment } = render(
91129
<span className="mx_EventTile_body translate" dir="auto">

test/unit-tests/components/views/messages/TextualBody-test.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ describe("<TextualBody />", () => {
188188
const { container } = getComponent({ mxEvent: ev });
189189
const content = container.querySelector(".mx_EventTile_body");
190190
expect(content.innerHTML).toMatchInlineSnapshot(
191-
`"Chat with <a href="https://matrix.to/#/@user:example.com" rel="noreferrer noopener" class="linkified">@user:example.com</a>"`,
191+
`"Chat with <a href="https://matrix.to/#/@user:example.com" class="linkified" rel="noreferrer noopener">@user:example.com</a>"`,
192192
);
193193
});
194194

@@ -206,7 +206,7 @@ describe("<TextualBody />", () => {
206206
const { container } = getComponent({ mxEvent: ev });
207207
const content = container.querySelector(".mx_EventTile_body");
208208
expect(content.innerHTML).toMatchInlineSnapshot(
209-
`"Visit <a href="https://matrix.to/#/#room:example.com" rel="noreferrer noopener" class="linkified">#room:example.com</a>"`,
209+
`"Visit <a href="https://matrix.to/#/#room:example.com" class="linkified" rel="noreferrer noopener">#room:example.com</a>"`,
210210
);
211211
});
212212

0 commit comments

Comments
 (0)