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
+21 -31
View File
@@ -6,13 +6,12 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import React, { type ReactElement } from "react";
import React, { type ReactElement, useContext } from "react";
import classNames from "classnames";
import { type Room, type RoomMember } from "matrix-js-sdk/src/matrix";
import { Tooltip } from "@vector-im/compound-web";
import { LinkIcon, UserSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { usePermalink } from "../../../hooks/usePermalink";
import RoomAvatar from "../avatars/RoomAvatar";
@@ -28,14 +27,6 @@ export enum PillType {
Keyword = "TYPE_KEYWORD", // Used to highlight keywords that triggered a notification rule
}
export const pillRoomNotifPos = (text: string | null): number => {
return text?.indexOf("@room") ?? -1;
};
export const pillRoomNotifLen = (): number => {
return "@room".length;
};
const linkIcon = <LinkIcon className="mx_Pill_LinkIcon mx_BaseAvatar" />;
const PillRoomAvatar: React.FC<{
@@ -89,6 +80,7 @@ export const Pill: React.FC<PillProps> = ({
shouldShowPillAvatar = true,
text: customPillText,
}) => {
const cli = useContext(MatrixClientContext);
const {
event,
member,
@@ -113,7 +105,7 @@ export const Pill: React.FC<PillProps> = ({
mx_RoomPill: type === PillType.RoomMention,
mx_SpacePill: type === "space" || targetRoom?.isSpaceRoom(),
mx_UserPill: type === PillType.UserMention,
mx_UserPill_me: resourceId === MatrixClientPeg.safeGet().getUserId(),
mx_UserPill_me: resourceId === cli.getUserId(),
mx_EventPill: type === PillType.EventInOtherRoom || type === PillType.EventInSameRoom,
mx_KeywordPill: type === PillType.Keyword,
});
@@ -160,26 +152,24 @@ export const Pill: React.FC<PillProps> = ({
const isAnchor = !!inMessage && !!url;
return (
<bdi>
<MatrixClientContext.Provider value={MatrixClientPeg.safeGet()}>
<Tooltip
description={resourceId ?? ""}
open={resourceId ? undefined : false}
placement="right"
isTriggerInteractive={isAnchor}
>
{isAnchor ? (
<a className={classes} href={url} onClick={onClick}>
{avatar}
<span className="mx_Pill_text">{pillText}</span>
</a>
) : (
<span className={classes}>
{avatar}
<span className="mx_Pill_text">{pillText}</span>
</span>
)}
</Tooltip>
</MatrixClientContext.Provider>
<Tooltip
description={resourceId ?? ""}
open={resourceId ? undefined : false}
placement="right"
isTriggerInteractive={isAnchor}
>
{isAnchor ? (
<a className={classes} href={url} onClick={onClick}>
{avatar}
<span className="mx_Pill_text">{pillText}</span>
</a>
) : (
<span className={classes}>
{avatar}
<span className="mx_Pill_text">{pillText}</span>
</span>
)}
</Tooltip>
</bdi>
);
};
+3 -9
View File
@@ -6,11 +6,11 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import React, { type ReactNode } from "react";
interface IProps {
reason?: string;
contentHtml: string;
children: ReactNode;
}
interface IState {
@@ -38,9 +38,6 @@ export default class Spoiler extends React.Component<IProps, IState> {
const reason = this.props.reason ? (
<span className="mx_EventTile_spoiler_reason">{"(" + this.props.reason + ")"}</span>
) : null;
// react doesn't allow appending a DOM node as child.
// as such, we pass the this.props.contentHtml instead and then set the raw
// HTML content. This is secure as the contents have already been parsed previously
return (
<button
className={"mx_EventTile_spoiler" + (this.state.visible ? " visible" : "")}
@@ -48,10 +45,7 @@ export default class Spoiler extends React.Component<IProps, IState> {
>
{reason}
&nbsp;
<span
className="mx_EventTile_spoiler_content"
dangerouslySetInnerHTML={{ __html: this.props.contentHtml }}
/>
<span className="mx_EventTile_spoiler_content">{this.props.children}</span>
</button>
);
}
+27 -14
View File
@@ -5,9 +5,10 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import React, { useState } from "react";
import React, { type JSX, useState } from "react";
import classNames from "classnames";
import { TooltipProvider } from "@vector-im/compound-web";
import { type DOMNode, Element as ParserElement, domToReact } from "html-react-parser";
import { textContent, getInnerHTML } from "domutils";
import { useSettingValue } from "../../../hooks/useSettings.ts";
import { CopyTextButton } from "../elements/CopyableText.tsx";
@@ -16,7 +17,7 @@ const MAX_HIGHLIGHT_LENGTH = 4096;
const MAX_LINES_BEFORE_COLLAPSE = 5;
interface Props {
children: HTMLElement;
preNode: ParserElement;
onHeightChanged?(): void;
}
@@ -35,14 +36,16 @@ const ExpandCollapseButton: React.FC<{
);
};
const CodeBlock: React.FC<Props> = ({ children, onHeightChanged }) => {
const CodeBlock: React.FC<Props> = ({ preNode, onHeightChanged }) => {
const enableSyntaxHighlightLanguageDetection = useSettingValue("enableSyntaxHighlightLanguageDetection");
const showCodeLineNumbers = useSettingValue("showCodeLineNumbers");
const expandCodeByDefault = useSettingValue("expandCodeByDefault");
const [expanded, setExpanded] = useState(expandCodeByDefault);
const text = textContent(preNode);
let expandCollapseButton: JSX.Element | undefined;
if (children.textContent && children.textContent.split("\n").length >= MAX_LINES_BEFORE_COLLAPSE) {
if (text.split("\n").length >= MAX_LINES_BEFORE_COLLAPSE) {
expandCollapseButton = (
<ExpandCollapseButton
expanded={expanded}
@@ -55,10 +58,11 @@ const CodeBlock: React.FC<Props> = ({ children, onHeightChanged }) => {
);
}
const innerHTML = getInnerHTML(preNode);
let lineNumbers: JSX.Element | undefined;
if (showCodeLineNumbers) {
// Calculate number of lines in pre
const number = children.innerHTML.replace(/\n(<\/code>)?$/, "").split(/\n/).length;
const number = innerHTML.replace(/\n(<\/code>)?$/, "").split(/\n/).length;
// Iterate through lines starting with 1 (number of the first line is 1)
lineNumbers = (
<span className="mx_EventTile_lineNumbers">
@@ -108,28 +112,37 @@ const CodeBlock: React.FC<Props> = ({ children, onHeightChanged }) => {
}
}
function highlightCodeRef(div: HTMLElement | null): void {
highlightCode(div);
}
let content = domToReact(preNode.children as DOMNode[]);
// Add code element if it's missing since we depend on it
if (!preNode.children.some((child) => child instanceof ParserElement && child.tagName.toUpperCase() === "CODE")) {
content = <code>{content}</code>;
}
return (
<TooltipProvider>
<div className="mx_EventTile_pre_container">
<pre
className={classNames({
mx_EventTile_collapsedCodeBlock: !expanded,
})}
>
{lineNumbers}
<div
style={{ display: "contents" }}
dangerouslySetInnerHTML={{ __html: children.innerHTML }}
ref={highlightCode}
/>
<div style={{ display: "contents" }} ref={highlightCodeRef}>
{content}
</div>
</pre>
{expandCollapseButton}
<CopyTextButton
getTextToCopy={() => children.getElementsByTagName("code")[0]?.textContent ?? null}
getTextToCopy={() => text}
className={classNames("mx_EventTile_button mx_EventTile_copyButton", {
mx_EventTile_buttonBottom: !!expandCollapseButton,
})}
/>
</TooltipProvider>
</div>
);
};
@@ -10,11 +10,9 @@ import React, { createRef } from "react";
import { type EventStatus, type IContent, type MatrixEvent, MatrixEventEvent, MsgType } from "matrix-js-sdk/src/matrix";
import classNames from "classnames";
import * as HtmlUtils from "../../../HtmlUtils";
import EventContentBody from "./EventContentBody.tsx";
import { editBodyDiffToHtml } from "../../../utils/MessageDiffUtils";
import { formatTime } from "../../../DateUtils";
import { pillifyLinks } from "../../../utils/pillify";
import { tooltipifyLinks } from "../../../utils/tooltipify";
import { _t } from "../../../languageHandler";
import Modal from "../../../Modal";
import RedactedBody from "./RedactedBody";
@@ -23,7 +21,6 @@ import ConfirmAndWaitRedactDialog from "../dialogs/ConfirmAndWaitRedactDialog";
import ViewSource from "../../structures/ViewSource";
import SettingsStore from "../../../settings/SettingsStore";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { ReactRootManager } from "../../../utils/react";
function getReplacedContent(event: MatrixEvent): IContent {
const originalContent = event.getOriginalContent();
@@ -48,8 +45,6 @@ export default class EditHistoryMessage extends React.PureComponent<IProps, ISta
declare public context: React.ContextType<typeof MatrixClientContext>;
private content = createRef<HTMLDivElement>();
private pills = new ReactRootManager();
private tooltips = new ReactRootManager();
public constructor(props: IProps, context: React.ContextType<typeof MatrixClientContext>) {
super(props, context);
@@ -94,37 +89,11 @@ export default class EditHistoryMessage extends React.PureComponent<IProps, ISta
);
};
private pillifyLinks(): void {
// not present for redacted events
if (this.content.current) {
pillifyLinks(this.context, this.content.current.children, this.props.mxEvent, this.pills);
}
}
private tooltipifyLinks(): void {
// not present for redacted events
if (this.content.current) {
tooltipifyLinks(this.content.current.children, this.pills.elements, this.tooltips);
}
}
public componentDidMount(): void {
this.pillifyLinks();
this.tooltipifyLinks();
}
public componentWillUnmount(): void {
this.pills.unmount();
this.tooltips.unmount();
const event = this.props.mxEvent;
event.localRedactionEvent()?.off(MatrixEventEvent.Status, this.onAssociatedStatusChanged);
}
public componentDidUpdate(): void {
this.pillifyLinks();
this.tooltipifyLinks();
}
private renderActionBar(): React.ReactNode {
// hide the button when already redacted
let redactButton: JSX.Element | undefined;
@@ -164,9 +133,19 @@ export default class EditHistoryMessage extends React.PureComponent<IProps, ISta
if (this.props.previousEdit) {
contentElements = editBodyDiffToHtml(getReplacedContent(this.props.previousEdit), content);
} else {
contentElements = HtmlUtils.bodyToSpan(content, null, {
stripReplyFallback: true,
});
contentElements = (
<EventContentBody
as="span"
mxEvent={mxEvent}
content={content}
highlights={[]}
stripReply
renderMentionPills
renderCodeBlocks
renderSpoilers
linkify
/>
);
}
if (mxEvent.getContent().msgtype === MsgType.Emote) {
const name = mxEvent.sender ? mxEvent.sender.name : mxEvent.getSender();
@@ -0,0 +1,210 @@
/*
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, { memo, forwardRef, useContext, useMemo } from "react";
import { type IContent, type MatrixEvent, MsgType, PushRuleKind } from "matrix-js-sdk/src/matrix";
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,
combineRenderers,
type Replacer,
type RendererMap,
keywordPillRenderer,
mentionPillRenderer,
ambiguousLinkTooltipRenderer,
codeBlockRenderer,
spoilerRenderer,
replacerToRenderFunction,
} from "../../../renderer";
import MatrixClientContext from "../../../contexts/MatrixClientContext.tsx";
import { useSettingValue } from "../../../hooks/useSettings.ts";
import { filterBoolean } from "../../../utils/arrays.ts";
/**
* Returns a RegExp pattern for the keyword in the push rule of the given Matrix event, if any
* @param mxEvent - the Matrix event to get the push rule keyword pattern from
*/
const getPushDetailsKeywordPatternRegexp = (mxEvent: MatrixEvent): RegExp | undefined => {
const pushDetails = mxEvent.getPushDetails();
if (
pushDetails.rule?.enabled &&
pushDetails.rule.kind === PushRuleKind.ContentSpecific &&
pushDetails.rule.pattern
) {
return PushProcessor.getPushRuleGlobRegex(pushDetails.rule.pattern, true, "gi");
}
return undefined;
};
interface ReplacerOptions {
/**
* Whether to render room/user mentions as pills
*/
renderMentionPills?: boolean;
/**
* Whether to render push rule keywords as pills
*/
renderKeywordPills?: boolean;
/**
* Whether to render spoilers as hidden content revealed on click
*/
renderSpoilers?: boolean;
/**
* Whether to render code blocks as syntax highlighted code with a copy to clipboard button
*/
renderCodeBlocks?: boolean;
/**
* Whether to render tooltips for ambiguous links, only effective on platforms which specify `needsUrlTooltips` true
*/
renderTooltipsForAmbiguousLinks?: boolean;
}
// Returns a memoized Replacer based on the input parameters
const useReplacer = (
content: IContent,
mxEvent: MatrixEvent | undefined,
onHeightChanged: (() => void) | undefined,
options: ReplacerOptions,
): Replacer => {
const cli = useContext(MatrixClientContext);
const room = cli.getRoom(mxEvent?.getRoomId()) ?? undefined;
const shouldShowPillAvatar = useSettingValue("Pill.shouldShowPillAvatar");
const isHtml = content.format === "org.matrix.custom.html";
const replacer = useMemo(() => {
const keywordRegexpPattern = mxEvent ? getPushDetailsKeywordPatternRegexp(mxEvent) : undefined;
const replacers = filterBoolean<RendererMap>([
options.renderMentionPills ? mentionPillRenderer : undefined,
options.renderKeywordPills && keywordRegexpPattern ? keywordPillRenderer : undefined,
options.renderTooltipsForAmbiguousLinks && PlatformPeg.get()?.needsUrlTooltips()
? ambiguousLinkTooltipRenderer
: undefined,
options.renderSpoilers ? spoilerRenderer : undefined,
options.renderCodeBlocks ? codeBlockRenderer : undefined,
]);
return combineRenderers(...replacers)({
isHtml,
mxEvent,
room,
shouldShowPillAvatar,
keywordRegexpPattern,
onHeightChanged,
});
}, [
mxEvent,
options.renderMentionPills,
options.renderKeywordPills,
options.renderTooltipsForAmbiguousLinks,
options.renderSpoilers,
options.renderCodeBlocks,
isHtml,
room,
shouldShowPillAvatar,
onHeightChanged,
]);
return replacer;
};
interface Props extends ReplacerOptions {
/**
* Whether to render the content in a div or span
*/
as: "span" | "div";
/**
* Whether to render links as clickable anchors
*/
linkify: boolean;
/**
* The Matrix event to render, required for renderMentionPills & renderKeywordPills
*/
mxEvent?: MatrixEvent;
/**
* The content to render
*/
content: IContent;
/**
* Whether to strip reply fallbacks from the content before rendering
*/
stripReply?: boolean;
/**
* Highlights to emphasise in the content
*/
highlights?: string[];
/**
* Callback for when the height of the content changes
*/
onHeightChanged?: () => void;
/**
* Whether to include the `dir="auto"` attribute on the rendered element
*/
includeDir?: boolean;
}
/**
* Component to render a Matrix event's content body.
* If the content is formatted HTML then it will be sanitised before rendering.
* A number of rendering features are supported as configured by {@link ReplacerOptions}
* Returns a div or span depending on `as`, the `dir` on a `div` is always set to `"auto"` but set by `includeDir` otherwise.
*/
const EventContentBody = memo(
forwardRef<HTMLElement, Props>(
(
{ as, mxEvent, stripReply, content, onHeightChanged, linkify, highlights, includeDir = true, ...options },
ref,
) => {
const enableBigEmoji = useSettingValue("TextualBody.enableBigEmoji");
const replacer = useReplacer(content, mxEvent, onHeightChanged, options);
const linkifyOptions = useMemo(
() => ({
render: replacerToRenderFunction(replacer),
}),
[replacer],
);
const isEmote = content.msgtype === MsgType.Emote;
const { strippedBody, formattedBody, emojiBodyElements, className } = useMemo(
() =>
bodyToNode(content, highlights, {
disableBigEmoji: isEmote || !enableBigEmoji,
// Part of Replies fallback support
stripReplyFallback: stripReply,
}),
[content, enableBigEmoji, highlights, isEmote, stripReply],
);
if (as === "div") includeDir = true; // force dir="auto" on divs
const As = as;
const body = formattedBody ? (
<As ref={ref as any} className={className} dir={includeDir ? "auto" : undefined}>
{parse(formattedBody, {
replace: replacer,
})}
</As>
) : (
<As ref={ref as any} className={className} dir={includeDir ? "auto" : undefined}>
{applyReplacerOnString(emojiBodyElements || strippedBody, replacer)}
</As>
);
if (!linkify) return body;
return <Linkify options={linkifyOptions}>{body}</Linkify>;
},
),
);
export default EventContentBody;
+20 -172
View File
@@ -6,23 +6,18 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import React, { createRef, type SyntheticEvent, type MouseEvent, StrictMode } from "react";
import { MsgType, PushRuleKind } from "matrix-js-sdk/src/matrix";
import { TooltipProvider } from "@vector-im/compound-web";
import { PushProcessor } from "matrix-js-sdk/src/pushprocessor";
import React, { type JSX, createRef, type SyntheticEvent, type MouseEvent } from "react";
import { MsgType } from "matrix-js-sdk/src/matrix";
import * as HtmlUtils from "../../../HtmlUtils";
import EventContentBody from "./EventContentBody.tsx";
import { formatDate } from "../../../DateUtils";
import Modal from "../../../Modal";
import dis from "../../../dispatcher/dispatcher";
import { _t } from "../../../languageHandler";
import SettingsStore from "../../../settings/SettingsStore";
import { pillifyLinks } from "../../../utils/pillify";
import { tooltipifyLinks } from "../../../utils/tooltipify";
import { IntegrationManagers } from "../../../integrations/IntegrationManagers";
import { isPermalinkHost, tryTransformPermalinkToLocalHref } from "../../../utils/permalinks/Permalinks";
import { Action } from "../../../dispatcher/actions";
import Spoiler from "../elements/Spoiler";
import QuestionDialog from "../dialogs/QuestionDialog";
import MessageEditHistoryDialog from "../dialogs/MessageEditHistoryDialog";
import EditMessageComposer from "../rooms/EditMessageComposer";
@@ -34,10 +29,6 @@ import { options as linkifyOpts } from "../../../linkify-matrix";
import { getParentEventId } from "../../../utils/Reply";
import { EditWysiwygComposer } from "../rooms/wysiwyg_composer";
import { type IEventTileOps } from "../rooms/EventTile";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import CodeBlock from "./CodeBlock";
import { Pill, PillType } from "../elements/Pill";
import { ReactRootManager } from "../../../utils/react";
interface IState {
// the URLs (if any) to be previewed with a LinkPreviewWidget inside this TextualBody.
@@ -50,10 +41,6 @@ interface IState {
export default class TextualBody extends React.Component<IBodyProps, IState> {
private readonly contentRef = createRef<HTMLDivElement>();
private pills = new ReactRootManager();
private tooltips = new ReactRootManager();
private reactRoots = new ReactRootManager();
public static contextType = RoomContext;
declare public context: React.ContextType<typeof RoomContext>;
@@ -69,74 +56,7 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
}
private applyFormatting(): void {
// Function is only called from render / componentDidMount → contentRef is set
const content = this.contentRef.current!;
this.activateSpoilers([content]);
HtmlUtils.linkifyElement(content);
pillifyLinks(MatrixClientPeg.safeGet(), [content], this.props.mxEvent, this.pills);
this.calculateUrlPreview();
// tooltipifyLinks AFTER calculateUrlPreview because the DOM inside the tooltip
// container is empty before the internal component has mounted so calculateUrlPreview
// won't find any anchors
tooltipifyLinks([content], [...this.pills.elements, ...this.reactRoots.elements], this.tooltips);
if (this.props.mxEvent.getContent().format === "org.matrix.custom.html") {
// Handle expansion and add buttons
const pres = [...content.getElementsByTagName("pre")];
if (pres && pres.length > 0) {
for (let i = 0; i < pres.length; i++) {
// If there already is a div wrapping the codeblock we want to skip this.
// This happens after the codeblock was edited.
if (pres[i].parentElement?.className == "mx_EventTile_pre_container") continue;
// Add code element if it's missing since we depend on it
if (pres[i].getElementsByTagName("code").length == 0) {
this.addCodeElement(pres[i]);
}
// Wrap a div around <pre> so that the copy button can be correctly positioned
// when the <pre> overflows and is scrolled horizontally.
this.wrapPreInReact(pres[i]);
}
}
}
// Highlight notification keywords using pills
const pushDetails = this.props.mxEvent.getPushDetails();
if (
pushDetails.rule?.enabled &&
pushDetails.rule.kind === PushRuleKind.ContentSpecific &&
pushDetails.rule.pattern
) {
this.pillifyNotificationKeywords(
[content],
PushProcessor.getPushRuleGlobRegex(pushDetails.rule.pattern, true),
);
}
}
private addCodeElement(pre: HTMLPreElement): void {
const code = document.createElement("code");
code.append(...pre.childNodes);
pre.appendChild(code);
}
private wrapPreInReact(pre: HTMLPreElement): void {
const root = document.createElement("div");
root.className = "mx_EventTile_pre_container";
// Insert containing div in place of <pre> block
pre.replaceWith(root);
this.reactRoots.render(
<StrictMode>
<CodeBlock onHeightChanged={this.props.onHeightChanged}>{pre}</CodeBlock>
</StrictMode>,
root,
pre,
);
}
public componentDidUpdate(prevProps: Readonly<IBodyProps>): void {
@@ -150,12 +70,6 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
}
}
public componentWillUnmount(): void {
this.pills.unmount();
this.tooltips.unmount();
this.reactRoots.unmount();
}
public shouldComponentUpdate(nextProps: Readonly<IBodyProps>, nextState: Readonly<IState>): boolean {
//console.info("shouldComponentUpdate: ShowUrlPreview for %s is %s", this.props.mxEvent.getId(), this.props.showUrlPreview);
@@ -195,79 +109,6 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
}
}
private activateSpoilers(nodes: ArrayLike<Element>): void {
let node = nodes[0];
while (node) {
if (node.tagName === "SPAN" && typeof node.getAttribute("data-mx-spoiler") === "string") {
const spoilerContainer = document.createElement("span");
const reason = node.getAttribute("data-mx-spoiler") ?? undefined;
node.removeAttribute("data-mx-spoiler"); // we don't want to recurse
const spoiler = (
<StrictMode>
<TooltipProvider>
<Spoiler reason={reason} contentHtml={node.outerHTML} />
</TooltipProvider>
</StrictMode>
);
this.reactRoots.render(spoiler, spoilerContainer, node);
node.replaceWith(spoilerContainer);
node = spoilerContainer;
}
if (node.childNodes && node.childNodes.length) {
this.activateSpoilers(node.childNodes as NodeListOf<Element>);
}
node = node.nextSibling as Element;
}
}
/**
* Marks the text that activated a push-notification keyword pattern.
*/
private pillifyNotificationKeywords(nodes: ArrayLike<Element>, exp: RegExp): void {
let node: Node | null = nodes[0];
while (node) {
if (node.nodeType === Node.TEXT_NODE) {
const text = node.nodeValue;
if (!text) {
node = node.nextSibling;
continue;
}
const match = text.match(exp);
if (!match || match.length < 2) {
node = node.nextSibling;
continue;
}
const keywordText = match[1];
const idx = match.index!;
const before = text.substring(0, idx);
const after = text.substring(idx + keywordText.length);
const container = document.createElement("span");
const newContent = (
<>
{before}
<TooltipProvider>
<Pill text={keywordText} type={PillType.Keyword} />
</TooltipProvider>
{after}
</>
);
this.reactRoots.render(newContent, container, node);
node.parentNode?.replaceChild(container, node);
} else if (node.childNodes && node.childNodes.length) {
this.pillifyNotificationKeywords(node.childNodes as NodeListOf<Element>, exp);
}
node = node.nextSibling;
}
}
private findLinks(nodes: ArrayLike<Element>): string[] {
let links: string[] = [];
@@ -479,18 +320,25 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
const willHaveWrapper =
this.props.replacingEventId || this.props.isSeeingThroughMessageHiddenForModeration || isEmote;
// only strip reply if this is the original replying event, edits thereafter do not have the fallback
const stripReply = !mxEvent.replacingEvent() && !!getParentEventId(mxEvent);
const htmlOpts = {
disableBigEmoji: isEmote || !SettingsStore.getValue("TextualBody.enableBigEmoji"),
// Part of Replies fallback support
stripReplyFallback: stripReply,
};
let body = willHaveWrapper
? HtmlUtils.bodyToSpan(content, this.props.highlights, htmlOpts, this.contentRef, false)
: HtmlUtils.bodyToDiv(content, this.props.highlights, htmlOpts, this.contentRef);
let body = (
<EventContentBody
as={willHaveWrapper ? "span" : "div"}
includeDir={false}
mxEvent={mxEvent}
content={content}
stripReply={stripReply}
linkify
highlights={this.props.highlights}
onHeightChanged={this.props.onHeightChanged}
ref={this.contentRef}
renderKeywordPills
renderMentionPills
renderCodeBlocks
renderSpoilers
/>
);
if (this.props.replacingEventId) {
body = (