Skip to content
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@
"jsrsasign": "^11.0.0",
"jszip": "^3.7.0",
"katex": "^0.16.0",
"linkify-html": "4.3.2",
"linkify-react": "4.3.2",
"linkify-string": "4.3.2",
"linkifyjs": "4.3.2",
Expand Down
71 changes: 48 additions & 23 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
Comment thread
MidhunSureshR marked this conversation as resolved.
Outdated
? true
: [...(sanitizeParams.allowedClasses?.["a"] || []), "linkified"],
},
Comment thread
MidhunSureshR marked this conversation as resolved.
Outdated
};
}

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) {
Comment thread
MidhunSureshR marked this conversation as resolved.
Outdated
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 Expand Up @@ -428,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) {
Comment thread
MidhunSureshR marked this conversation as resolved.
// 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 };
Expand All @@ -458,7 +483,7 @@ export function bodyToHtml(content: IContent, highlights: Optional<string[]>, 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("");
}
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
15 changes: 3 additions & 12 deletions src/components/views/messages/EventContentBody.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -23,7 +22,6 @@ import {
ambiguousLinkTooltipRenderer,
codeBlockRenderer,
spoilerRenderer,
replacerToRenderFunction,
} from "../../../renderer";
import MatrixClientContext from "../../../contexts/MatrixClientContext.tsx";
import { useSettingValue } from "../../../hooks/useSettings.ts";
Expand Down Expand Up @@ -154,12 +152,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,8 +162,9 @@ const EventContentBody = memo(
// Part of Replies fallback support
stripReplyFallback: stripReply,
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
Expand All @@ -189,9 +182,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 @@ -210,7 +211,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;
}
Comment thread
MidhunSureshR marked this conversation as resolved.
Outdated
}
}
},
Expand Down Expand Up @@ -293,3 +298,4 @@ registerCustomProtocol("mxc", false);

export const linkify = linkifyjs;
export const _linkifyString = linkifyString;
export const _linkifyHtml = linkifyHtml;
6 changes: 4 additions & 2 deletions src/renderer/spoiler.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Spoiler reason={reason}>{domToReact(span.children as DOMNode[])}</Spoiler>;
return (
<Spoiler reason={reason}>{domToReact(span.children as DOMNode[], { replace: params.replace })}</Spoiler>
);
}
},
};
32 changes: 18 additions & 14 deletions src/renderer/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -114,27 +115,30 @@ export type RendererMap = Partial<
}
>;

type PreparedRenderer = (parameters: Parameters) => Replacer;
type PreparedRenderer = (parameters: Omit<Parameters, "replace">) => 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;
(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;
};
36 changes: 36 additions & 0 deletions test/unit-tests/HtmlUtils-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,42 @@ 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
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ describe("<TextualBody />", () => {
const { container } = getComponent({ mxEvent: ev });
const content = container.querySelector(".mx_EventTile_body");
expect(content.innerHTML).toMatchInlineSnapshot(
`"Chat with <a href="https://matrix.to/#/@user:example.com" rel="noreferrer noopener" class="linkified">@user:example.com</a>"`,
`"Chat with <a href="https://matrix.to/#/@user:example.com" class="linkified" rel="noreferrer noopener">@user:example.com</a>"`,
);
});

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

Expand Down
5 changes: 5 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -10664,6 +10664,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.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"
resolved "https://registry.yarnpkg.com/linkify-it/-/linkify-it-4.0.1.tgz#01f1d5e508190d06669982ba31a7d9f56a5751ec"
Expand Down
Loading