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:
@@ -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} />;
|
||||
},
|
||||
};
|
||||
@@ -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";
|
||||
@@ -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>;
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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}</>;
|
||||
},
|
||||
};
|
||||
@@ -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>;
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user