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:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
<span
|
||||
className="mx_EventTile_spoiler_content"
|
||||
dangerouslySetInnerHTML={{ __html: this.props.contentHtml }}
|
||||
/>
|
||||
<span className="mx_EventTile_spoiler_content">{this.props.children}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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 = (
|
||||
|
||||
Reference in New Issue
Block a user