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>
This commit is contained in:
Michael Telatynski
2025-03-26 20:25:03 +00:00
committed by GitHub
parent 89e22e00fb
commit 3f47487472
37 changed files with 1488 additions and 1134 deletions
+20
View File
@@ -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 <CodeBlock onHeightChanged={onHeightChanged} preNode={pre} />;
},
};
+18
View File
@@ -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";
+34
View File
@@ -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 <LinkWithTooltip tooltip={tooltip}>{domToReact([anchor])}</LinkWithTooltip>;
}
},
};
+102
View File
@@ -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 <Pill url={href} inMessage={true} room={room} shouldShowPillAvatar={shouldShowPillAvatar} />;
}
},
[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) => (
<Pill
key={i}
type={PillType.AtRoomMention}
inMessage={true}
room={room}
shouldShowPillAvatar={shouldShowPillAvatar}
/>
));
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) => (
<Pill key={i} text={match} type={PillType.Keyword} />
));
if (parts.length <= 1) return; // no matches, skip replacing
return <>{parts}</>;
},
};
+24
View File
@@ -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 <Spoiler reason={reason}>{domToReact(span.children as DOMNode[])}</Spoiler>;
}
},
};
+141
View File
@@ -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<Element["parentNode"]>;
/**
* 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 (
<React.Fragment key={index}>{(replacer(new Text(input), 0) as JSX.Element) || input}</React.Fragment>
);
}
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<T extends DOMNode> = (
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<Element>;
} & {
[Node.TEXT_NODE]: SpecialisedReplacer<Text>;
}
>;
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;
}
}
};