Skip to content

Fix highlights in messages (or search results) breaking links #30264

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
54 changes: 39 additions & 15 deletions src/HtmlUtils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -298,6 +298,7 @@ export interface EventRenderOpts {
* Should inline media be rendered?
*/
mediaIsVisible?: boolean;
linkify?: boolean;
}

function analyseEvent(content: IContent, highlights: Optional<string[]>, opts: EventRenderOpts = {}): EventAnalysis {
Expand All @@ -320,6 +321,20 @@ function analyseEvent(content: IContent, highlights: Optional<string[]>, 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";
Expand All @@ -346,7 +361,9 @@ function analyseEvent(content: IContent, highlights: Optional<string[]>, 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
Expand All @@ -358,20 +375,27 @@ function analyseEvent(content: IContent, highlights: Optional<string[]>, 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<HTMLElement>("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<HTMLElement>("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("");
Expand Down
12 changes: 11 additions & 1 deletion src/Linkify.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.
*
Expand Down
11 changes: 2 additions & 9 deletions src/components/views/messages/EventContentBody.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -170,6 +164,7 @@ const EventContentBody = memo(
// Part of Replies fallback support
stripReplyFallback: stripReply,
mediaIsVisible,
linkify,
}),
[content, mediaIsVisible, enableBigEmoji, highlights, isEmote, stripReply],
);
Expand All @@ -189,9 +184,7 @@ const EventContentBody = memo(
</As>
);

if (!linkify) return body;

return <Linkify options={linkifyOptions}>{body}</Linkify>;
return body;
},
);

Expand Down
8 changes: 7 additions & 1 deletion src/linkify-matrix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
}
}
}
},
Expand Down Expand Up @@ -274,3 +279,4 @@ registerCustomProtocol("mxc", false);

export const linkify = linkifyjs;
export const _linkifyString = linkifyString;
export const _linkifyHtml = linkifyHtml;
32 changes: 32 additions & 0 deletions test/unit-tests/HtmlUtils-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,38 @@ describe("bodyToHtml", () => {
expect(html).toMatchInlineSnapshot(`"<span class="mx_EventTile_searchHighlight">test</span> foo &lt;b&gt;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 <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"`);
});

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 <a href=\"http://link.example/test/path\">http://link.example/test/path</a> bar",
format: "org.matrix.custom.html",
},
["test"],
{
linkify: true,
},
);

expect(html).toMatchInlineSnapshot(`"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"`);
});

it("does not mistake characters in text presentation mode for emoji", () => {
const { asFragment } = render(
<span className="mx_EventTile_body translate" dir="auto">
Expand Down
5 changes: 5 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading