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
+3 -1
View File
@@ -107,6 +107,7 @@
"css-tree": "^3.0.0", "css-tree": "^3.0.0",
"diff-dom": "^5.0.0", "diff-dom": "^5.0.0",
"diff-match-patch": "^1.0.5", "diff-match-patch": "^1.0.5",
"domutils": "^3.2.2",
"emojibase-regex": "15.3.2", "emojibase-regex": "15.3.2",
"escape-html": "^1.0.3", "escape-html": "^1.0.3",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
@@ -115,12 +116,12 @@
"glob-to-regexp": "^0.4.1", "glob-to-regexp": "^0.4.1",
"highlight.js": "^11.3.1", "highlight.js": "^11.3.1",
"html-entities": "^2.0.0", "html-entities": "^2.0.0",
"html-react-parser": "^5.2.2",
"is-ip": "^3.1.0", "is-ip": "^3.1.0",
"js-xxhash": "^4.0.0", "js-xxhash": "^4.0.0",
"jsrsasign": "^11.0.0", "jsrsasign": "^11.0.0",
"jszip": "^3.7.0", "jszip": "^3.7.0",
"katex": "^0.16.0", "katex": "^0.16.0",
"linkify-element": "4.2.0",
"linkify-react": "4.2.0", "linkify-react": "4.2.0",
"linkify-string": "4.2.0", "linkify-string": "4.2.0",
"linkifyjs": "4.2.0", "linkifyjs": "4.2.0",
@@ -144,6 +145,7 @@
"react-blurhash": "^0.3.0", "react-blurhash": "^0.3.0",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-focus-lock": "^2.5.1", "react-focus-lock": "^2.5.1",
"react-string-replace": "^1.1.1",
"react-transition-group": "^4.4.1", "react-transition-group": "^4.4.1",
"react-virtualized": "^9.22.5", "react-virtualized": "^9.22.5",
"rfc4648": "^1.4.0", "rfc4648": "^1.4.0",
+6 -49
View File
@@ -25,7 +25,7 @@ import { PERMITTED_URL_SCHEMES } from "./utils/UrlUtils";
import { sanitizeHtmlParams, transformTags } from "./Linkify"; import { sanitizeHtmlParams, transformTags } from "./Linkify";
import { graphemeSegmenter } from "./utils/strings"; import { graphemeSegmenter } from "./utils/strings";
export { Linkify, linkifyElement, linkifyAndSanitizeHtml } from "./Linkify"; export { Linkify, linkifyAndSanitizeHtml } from "./Linkify";
// Anything outside the basic multilingual plane will be a surrogate pair // Anything outside the basic multilingual plane will be a surrogate pair
const SURROGATE_PAIR_PATTERN = /([\ud800-\udbff])([\udc00-\udfff])/; const SURROGATE_PAIR_PATTERN = /([\ud800-\udbff])([\udc00-\udfff])/;
@@ -365,53 +365,6 @@ function analyseEvent(content: IContent, highlights: Optional<string[]>, opts: E
} }
} }
export function bodyToDiv(
content: IContent,
highlights: Optional<string[]>,
opts: EventRenderOpts = {},
ref?: React.Ref<HTMLDivElement>,
): ReactNode {
const { strippedBody, formattedBody, emojiBodyElements, className } = bodyToNode(content, highlights, opts);
return formattedBody ? (
<div
key="body"
ref={ref}
className={className}
dangerouslySetInnerHTML={{ __html: formattedBody }}
dir="auto"
/>
) : (
<div key="body" ref={ref} className={className} dir="auto">
{emojiBodyElements || strippedBody}
</div>
);
}
export function bodyToSpan(
content: IContent,
highlights: Optional<string[]>,
opts: EventRenderOpts = {},
ref?: React.Ref<HTMLSpanElement>,
includeDir = true,
): ReactNode {
const { strippedBody, formattedBody, emojiBodyElements, className } = bodyToNode(content, highlights, opts);
return formattedBody ? (
<span
key="body"
ref={ref}
className={className}
dangerouslySetInnerHTML={{ __html: formattedBody }}
dir={includeDir ? "auto" : undefined}
/>
) : (
<span key="body" ref={ref} className={className} dir={includeDir ? "auto" : undefined}>
{emojiBodyElements || strippedBody}
</span>
);
}
interface BodyToNodeReturn { interface BodyToNodeReturn {
strippedBody: string; strippedBody: string;
formattedBody?: string; formattedBody?: string;
@@ -419,7 +372,11 @@ interface BodyToNodeReturn {
className: string; className: string;
} }
function bodyToNode(content: IContent, highlights: Optional<string[]>, opts: EventRenderOpts = {}): BodyToNodeReturn { export function bodyToNode(
content: IContent,
highlights: Optional<string[]>,
opts: EventRenderOpts = {},
): BodyToNodeReturn {
const eventInfo = analyseEvent(content, highlights, opts); const eventInfo = analyseEvent(content, highlights, opts);
let emojiBody = false; let emojiBody = false;
+1 -17
View File
@@ -11,12 +11,7 @@ import sanitizeHtml, { type IOptions } from "sanitize-html";
import { merge } from "lodash"; import { merge } from "lodash";
import _Linkify from "linkify-react"; import _Linkify from "linkify-react";
import { import { _linkifyString, ELEMENT_URL_PATTERN, options as linkifyMatrixOptions } from "./linkify-matrix";
_linkifyElement,
_linkifyString,
ELEMENT_URL_PATTERN,
options as linkifyMatrixOptions,
} from "./linkify-matrix";
import SettingsStore from "./settings/SettingsStore"; import SettingsStore from "./settings/SettingsStore";
import { tryTransformPermalinkToLocalHref } from "./utils/permalinks/Permalinks"; import { tryTransformPermalinkToLocalHref } from "./utils/permalinks/Permalinks";
import { mediaFromMxc } from "./customisations/Media"; import { mediaFromMxc } from "./customisations/Media";
@@ -223,17 +218,6 @@ export function linkifyString(str: string, options = linkifyMatrixOptions): stri
return _linkifyString(str, options); return _linkifyString(str, options);
} }
/**
* Linkifies the given DOM element. This is a wrapper around 'linkifyjs/element'.
*
* @param {object} element DOM element to linkify
* @param {object} [options] Options for linkifyElement. Default: linkifyMatrixOptions
* @returns {object}
*/
export function linkifyElement(element: HTMLElement, options = linkifyMatrixOptions): HTMLElement {
return _linkifyElement(element, options);
}
/** /**
* Linkify the given string and sanitize the HTML afterwards. * Linkify the given string and sanitize the HTML afterwards.
* *
+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. 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 classNames from "classnames";
import { type Room, type RoomMember } from "matrix-js-sdk/src/matrix"; import { type Room, type RoomMember } from "matrix-js-sdk/src/matrix";
import { Tooltip } from "@vector-im/compound-web"; import { Tooltip } from "@vector-im/compound-web";
import { LinkIcon, UserSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import { LinkIcon, UserSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import MatrixClientContext from "../../../contexts/MatrixClientContext"; import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { usePermalink } from "../../../hooks/usePermalink"; import { usePermalink } from "../../../hooks/usePermalink";
import RoomAvatar from "../avatars/RoomAvatar"; import RoomAvatar from "../avatars/RoomAvatar";
@@ -28,14 +27,6 @@ export enum PillType {
Keyword = "TYPE_KEYWORD", // Used to highlight keywords that triggered a notification rule 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 linkIcon = <LinkIcon className="mx_Pill_LinkIcon mx_BaseAvatar" />;
const PillRoomAvatar: React.FC<{ const PillRoomAvatar: React.FC<{
@@ -89,6 +80,7 @@ export const Pill: React.FC<PillProps> = ({
shouldShowPillAvatar = true, shouldShowPillAvatar = true,
text: customPillText, text: customPillText,
}) => { }) => {
const cli = useContext(MatrixClientContext);
const { const {
event, event,
member, member,
@@ -113,7 +105,7 @@ export const Pill: React.FC<PillProps> = ({
mx_RoomPill: type === PillType.RoomMention, mx_RoomPill: type === PillType.RoomMention,
mx_SpacePill: type === "space" || targetRoom?.isSpaceRoom(), mx_SpacePill: type === "space" || targetRoom?.isSpaceRoom(),
mx_UserPill: type === PillType.UserMention, 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_EventPill: type === PillType.EventInOtherRoom || type === PillType.EventInSameRoom,
mx_KeywordPill: type === PillType.Keyword, mx_KeywordPill: type === PillType.Keyword,
}); });
@@ -160,26 +152,24 @@ export const Pill: React.FC<PillProps> = ({
const isAnchor = !!inMessage && !!url; const isAnchor = !!inMessage && !!url;
return ( return (
<bdi> <bdi>
<MatrixClientContext.Provider value={MatrixClientPeg.safeGet()}> <Tooltip
<Tooltip description={resourceId ?? ""}
description={resourceId ?? ""} open={resourceId ? undefined : false}
open={resourceId ? undefined : false} placement="right"
placement="right" isTriggerInteractive={isAnchor}
isTriggerInteractive={isAnchor} >
> {isAnchor ? (
{isAnchor ? ( <a className={classes} href={url} onClick={onClick}>
<a className={classes} href={url} onClick={onClick}> {avatar}
{avatar} <span className="mx_Pill_text">{pillText}</span>
<span className="mx_Pill_text">{pillText}</span> </a>
</a> ) : (
) : ( <span className={classes}>
<span className={classes}> {avatar}
{avatar} <span className="mx_Pill_text">{pillText}</span>
<span className="mx_Pill_text">{pillText}</span> </span>
</span> )}
)} </Tooltip>
</Tooltip>
</MatrixClientContext.Provider>
</bdi> </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. Please see LICENSE files in the repository root for full details.
*/ */
import React from "react"; import React, { type ReactNode } from "react";
interface IProps { interface IProps {
reason?: string; reason?: string;
contentHtml: string; children: ReactNode;
} }
interface IState { interface IState {
@@ -38,9 +38,6 @@ export default class Spoiler extends React.Component<IProps, IState> {
const reason = this.props.reason ? ( const reason = this.props.reason ? (
<span className="mx_EventTile_spoiler_reason">{"(" + this.props.reason + ")"}</span> <span className="mx_EventTile_spoiler_reason">{"(" + this.props.reason + ")"}</span>
) : null; ) : 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 ( return (
<button <button
className={"mx_EventTile_spoiler" + (this.state.visible ? " visible" : "")} className={"mx_EventTile_spoiler" + (this.state.visible ? " visible" : "")}
@@ -48,10 +45,7 @@ export default class Spoiler extends React.Component<IProps, IState> {
> >
{reason} {reason}
&nbsp; &nbsp;
<span <span className="mx_EventTile_spoiler_content">{this.props.children}</span>
className="mx_EventTile_spoiler_content"
dangerouslySetInnerHTML={{ __html: this.props.contentHtml }}
/>
</button> </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. 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 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 { useSettingValue } from "../../../hooks/useSettings.ts";
import { CopyTextButton } from "../elements/CopyableText.tsx"; import { CopyTextButton } from "../elements/CopyableText.tsx";
@@ -16,7 +17,7 @@ const MAX_HIGHLIGHT_LENGTH = 4096;
const MAX_LINES_BEFORE_COLLAPSE = 5; const MAX_LINES_BEFORE_COLLAPSE = 5;
interface Props { interface Props {
children: HTMLElement; preNode: ParserElement;
onHeightChanged?(): void; 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 enableSyntaxHighlightLanguageDetection = useSettingValue("enableSyntaxHighlightLanguageDetection");
const showCodeLineNumbers = useSettingValue("showCodeLineNumbers"); const showCodeLineNumbers = useSettingValue("showCodeLineNumbers");
const expandCodeByDefault = useSettingValue("expandCodeByDefault"); const expandCodeByDefault = useSettingValue("expandCodeByDefault");
const [expanded, setExpanded] = useState(expandCodeByDefault); const [expanded, setExpanded] = useState(expandCodeByDefault);
const text = textContent(preNode);
let expandCollapseButton: JSX.Element | undefined; 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 = (
<ExpandCollapseButton <ExpandCollapseButton
expanded={expanded} expanded={expanded}
@@ -55,10 +58,11 @@ const CodeBlock: React.FC<Props> = ({ children, onHeightChanged }) => {
); );
} }
const innerHTML = getInnerHTML(preNode);
let lineNumbers: JSX.Element | undefined; let lineNumbers: JSX.Element | undefined;
if (showCodeLineNumbers) { if (showCodeLineNumbers) {
// Calculate number of lines in pre // 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) // Iterate through lines starting with 1 (number of the first line is 1)
lineNumbers = ( lineNumbers = (
<span className="mx_EventTile_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 ( return (
<TooltipProvider> <div className="mx_EventTile_pre_container">
<pre <pre
className={classNames({ className={classNames({
mx_EventTile_collapsedCodeBlock: !expanded, mx_EventTile_collapsedCodeBlock: !expanded,
})} })}
> >
{lineNumbers} {lineNumbers}
<div <div style={{ display: "contents" }} ref={highlightCodeRef}>
style={{ display: "contents" }} {content}
dangerouslySetInnerHTML={{ __html: children.innerHTML }} </div>
ref={highlightCode}
/>
</pre> </pre>
{expandCollapseButton} {expandCollapseButton}
<CopyTextButton <CopyTextButton
getTextToCopy={() => children.getElementsByTagName("code")[0]?.textContent ?? null} getTextToCopy={() => text}
className={classNames("mx_EventTile_button mx_EventTile_copyButton", { className={classNames("mx_EventTile_button mx_EventTile_copyButton", {
mx_EventTile_buttonBottom: !!expandCollapseButton, 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 { type EventStatus, type IContent, type MatrixEvent, MatrixEventEvent, MsgType } from "matrix-js-sdk/src/matrix";
import classNames from "classnames"; import classNames from "classnames";
import * as HtmlUtils from "../../../HtmlUtils"; import EventContentBody from "./EventContentBody.tsx";
import { editBodyDiffToHtml } from "../../../utils/MessageDiffUtils"; import { editBodyDiffToHtml } from "../../../utils/MessageDiffUtils";
import { formatTime } from "../../../DateUtils"; import { formatTime } from "../../../DateUtils";
import { pillifyLinks } from "../../../utils/pillify";
import { tooltipifyLinks } from "../../../utils/tooltipify";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import Modal from "../../../Modal"; import Modal from "../../../Modal";
import RedactedBody from "./RedactedBody"; import RedactedBody from "./RedactedBody";
@@ -23,7 +21,6 @@ import ConfirmAndWaitRedactDialog from "../dialogs/ConfirmAndWaitRedactDialog";
import ViewSource from "../../structures/ViewSource"; import ViewSource from "../../structures/ViewSource";
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import MatrixClientContext from "../../../contexts/MatrixClientContext"; import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { ReactRootManager } from "../../../utils/react";
function getReplacedContent(event: MatrixEvent): IContent { function getReplacedContent(event: MatrixEvent): IContent {
const originalContent = event.getOriginalContent(); const originalContent = event.getOriginalContent();
@@ -48,8 +45,6 @@ export default class EditHistoryMessage extends React.PureComponent<IProps, ISta
declare public context: React.ContextType<typeof MatrixClientContext>; declare public context: React.ContextType<typeof MatrixClientContext>;
private content = createRef<HTMLDivElement>(); private content = createRef<HTMLDivElement>();
private pills = new ReactRootManager();
private tooltips = new ReactRootManager();
public constructor(props: IProps, context: React.ContextType<typeof MatrixClientContext>) { public constructor(props: IProps, context: React.ContextType<typeof MatrixClientContext>) {
super(props, context); 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 { public componentWillUnmount(): void {
this.pills.unmount();
this.tooltips.unmount();
const event = this.props.mxEvent; const event = this.props.mxEvent;
event.localRedactionEvent()?.off(MatrixEventEvent.Status, this.onAssociatedStatusChanged); event.localRedactionEvent()?.off(MatrixEventEvent.Status, this.onAssociatedStatusChanged);
} }
public componentDidUpdate(): void {
this.pillifyLinks();
this.tooltipifyLinks();
}
private renderActionBar(): React.ReactNode { private renderActionBar(): React.ReactNode {
// hide the button when already redacted // hide the button when already redacted
let redactButton: JSX.Element | undefined; let redactButton: JSX.Element | undefined;
@@ -164,9 +133,19 @@ export default class EditHistoryMessage extends React.PureComponent<IProps, ISta
if (this.props.previousEdit) { if (this.props.previousEdit) {
contentElements = editBodyDiffToHtml(getReplacedContent(this.props.previousEdit), content); contentElements = editBodyDiffToHtml(getReplacedContent(this.props.previousEdit), content);
} else { } else {
contentElements = HtmlUtils.bodyToSpan(content, null, { contentElements = (
stripReplyFallback: true, <EventContentBody
}); as="span"
mxEvent={mxEvent}
content={content}
highlights={[]}
stripReply
renderMentionPills
renderCodeBlocks
renderSpoilers
linkify
/>
);
} }
if (mxEvent.getContent().msgtype === MsgType.Emote) { if (mxEvent.getContent().msgtype === MsgType.Emote) {
const name = mxEvent.sender ? mxEvent.sender.name : mxEvent.getSender(); 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. Please see LICENSE files in the repository root for full details.
*/ */
import React, { createRef, type SyntheticEvent, type MouseEvent, StrictMode } from "react"; import React, { type JSX, createRef, type SyntheticEvent, type MouseEvent } from "react";
import { MsgType, PushRuleKind } from "matrix-js-sdk/src/matrix"; import { MsgType } from "matrix-js-sdk/src/matrix";
import { TooltipProvider } from "@vector-im/compound-web";
import { PushProcessor } from "matrix-js-sdk/src/pushprocessor";
import * as HtmlUtils from "../../../HtmlUtils"; import EventContentBody from "./EventContentBody.tsx";
import { formatDate } from "../../../DateUtils"; import { formatDate } from "../../../DateUtils";
import Modal from "../../../Modal"; import Modal from "../../../Modal";
import dis from "../../../dispatcher/dispatcher"; import dis from "../../../dispatcher/dispatcher";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import { pillifyLinks } from "../../../utils/pillify";
import { tooltipifyLinks } from "../../../utils/tooltipify";
import { IntegrationManagers } from "../../../integrations/IntegrationManagers"; import { IntegrationManagers } from "../../../integrations/IntegrationManagers";
import { isPermalinkHost, tryTransformPermalinkToLocalHref } from "../../../utils/permalinks/Permalinks"; import { isPermalinkHost, tryTransformPermalinkToLocalHref } from "../../../utils/permalinks/Permalinks";
import { Action } from "../../../dispatcher/actions"; import { Action } from "../../../dispatcher/actions";
import Spoiler from "../elements/Spoiler";
import QuestionDialog from "../dialogs/QuestionDialog"; import QuestionDialog from "../dialogs/QuestionDialog";
import MessageEditHistoryDialog from "../dialogs/MessageEditHistoryDialog"; import MessageEditHistoryDialog from "../dialogs/MessageEditHistoryDialog";
import EditMessageComposer from "../rooms/EditMessageComposer"; import EditMessageComposer from "../rooms/EditMessageComposer";
@@ -34,10 +29,6 @@ import { options as linkifyOpts } from "../../../linkify-matrix";
import { getParentEventId } from "../../../utils/Reply"; import { getParentEventId } from "../../../utils/Reply";
import { EditWysiwygComposer } from "../rooms/wysiwyg_composer"; import { EditWysiwygComposer } from "../rooms/wysiwyg_composer";
import { type IEventTileOps } from "../rooms/EventTile"; 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 { interface IState {
// the URLs (if any) to be previewed with a LinkPreviewWidget inside this TextualBody. // 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> { export default class TextualBody extends React.Component<IBodyProps, IState> {
private readonly contentRef = createRef<HTMLDivElement>(); private readonly contentRef = createRef<HTMLDivElement>();
private pills = new ReactRootManager();
private tooltips = new ReactRootManager();
private reactRoots = new ReactRootManager();
public static contextType = RoomContext; public static contextType = RoomContext;
declare public context: React.ContextType<typeof RoomContext>; declare public context: React.ContextType<typeof RoomContext>;
@@ -69,74 +56,7 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
} }
private applyFormatting(): void { 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(); 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 { 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 { public shouldComponentUpdate(nextProps: Readonly<IBodyProps>, nextState: Readonly<IState>): boolean {
//console.info("shouldComponentUpdate: ShowUrlPreview for %s is %s", this.props.mxEvent.getId(), this.props.showUrlPreview); //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[] { private findLinks(nodes: ArrayLike<Element>): string[] {
let links: string[] = []; let links: string[] = [];
@@ -479,18 +320,25 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
const willHaveWrapper = const willHaveWrapper =
this.props.replacingEventId || this.props.isSeeingThroughMessageHiddenForModeration || isEmote; this.props.replacingEventId || this.props.isSeeingThroughMessageHiddenForModeration || isEmote;
// only strip reply if this is the original replying event, edits thereafter do not have the fallback // only strip reply if this is the original replying event, edits thereafter do not have the fallback
const stripReply = !mxEvent.replacingEvent() && !!getParentEventId(mxEvent); const stripReply = !mxEvent.replacingEvent() && !!getParentEventId(mxEvent);
let body = (
const htmlOpts = { <EventContentBody
disableBigEmoji: isEmote || !SettingsStore.getValue("TextualBody.enableBigEmoji"), as={willHaveWrapper ? "span" : "div"}
// Part of Replies fallback support includeDir={false}
stripReplyFallback: stripReply, mxEvent={mxEvent}
}; content={content}
let body = willHaveWrapper stripReply={stripReply}
? HtmlUtils.bodyToSpan(content, this.props.highlights, htmlOpts, this.contentRef, false) linkify
: HtmlUtils.bodyToDiv(content, this.props.highlights, htmlOpts, this.contentRef); highlights={this.props.highlights}
onHeightChanged={this.props.onHeightChanged}
ref={this.contentRef}
renderKeywordPills
renderMentionPills
renderCodeBlocks
renderSpoilers
/>
);
if (this.props.replacingEventId) { if (this.props.replacingEventId) {
body = ( body = (
+8 -7
View File
@@ -7,10 +7,10 @@ Please see LICENSE files in the repository root for full details.
*/ */
import { type IMatrixProfile, type MatrixEvent, type Room, RoomMember } from "matrix-js-sdk/src/matrix"; import { type IMatrixProfile, type MatrixEvent, type Room, RoomMember } from "matrix-js-sdk/src/matrix";
import { useEffect, useState } from "react"; import { useContext, useEffect, useState } from "react";
import { PillType } from "../components/views/elements/Pill"; import { PillType } from "../components/views/elements/Pill";
import { SdkContextClass } from "../contexts/SDKContext"; import { SDKContext, type SdkContextClass } from "../contexts/SDKContext";
import { type PermalinkParts } from "../utils/permalinks/PermalinkConstructor"; import { type PermalinkParts } from "../utils/permalinks/PermalinkConstructor";
const createMemberFromProfile = (userId: string, profile: IMatrixProfile): RoomMember => { const createMemberFromProfile = (userId: string, profile: IMatrixProfile): RoomMember => {
@@ -65,12 +65,12 @@ const determineUserId = (
* If sharing at least one room with the user, then the result will be the profile fetched via API. * If sharing at least one room with the user, then the result will be the profile fetched via API.
* null in all other cases. * null in all other cases.
*/ */
const determineMember = (userId: string, targetRoom: Room): RoomMember | null => { const determineMember = (userId: string, targetRoom: Room, context: SdkContextClass): RoomMember | null => {
const targetRoomMember = targetRoom.getMember(userId); const targetRoomMember = targetRoom.getMember(userId);
if (targetRoomMember) return targetRoomMember; if (targetRoomMember) return targetRoomMember;
const knownProfile = SdkContextClass.instance.userProfilesStore.getOnlyKnownProfile(userId); const knownProfile = context.userProfilesStore.getOnlyKnownProfile(userId);
if (knownProfile) { if (knownProfile) {
return createMemberFromProfile(userId, knownProfile); return createMemberFromProfile(userId, knownProfile);
@@ -97,11 +97,12 @@ export const usePermalinkMember = (
targetRoom: Room | null, targetRoom: Room | null,
event: MatrixEvent | null, event: MatrixEvent | null,
): RoomMember | null => { ): RoomMember | null => {
const context = useContext(SDKContext);
// User mentions and permalinks to events in the same room require to know the user. // User mentions and permalinks to events in the same room require to know the user.
// If it cannot be initially determined, it will be looked up later by a memo hook. // If it cannot be initially determined, it will be looked up later by a memo hook.
const shouldLookUpUser = type && [PillType.UserMention, PillType.EventInSameRoom].includes(type); const shouldLookUpUser = type && [PillType.UserMention, PillType.EventInSameRoom].includes(type);
const userId = determineUserId(type, parseResult, event); const userId = determineUserId(type, parseResult, event);
const userInRoom = shouldLookUpUser && userId && targetRoom ? determineMember(userId, targetRoom) : null; const userInRoom = shouldLookUpUser && userId && targetRoom ? determineMember(userId, targetRoom, context) : null;
const [member, setMember] = useState<RoomMember | null>(userInRoom); const [member, setMember] = useState<RoomMember | null>(userInRoom);
useEffect(() => { useEffect(() => {
@@ -111,7 +112,7 @@ export const usePermalinkMember = (
} }
const doProfileLookup = async (): Promise<void> => { const doProfileLookup = async (): Promise<void> => {
const fetchedProfile = await SdkContextClass.instance.userProfilesStore.fetchOnlyKnownProfile(userId); const fetchedProfile = await context.userProfilesStore.fetchOnlyKnownProfile(userId);
if (fetchedProfile) { if (fetchedProfile) {
const newMember = createMemberFromProfile(userId, fetchedProfile); const newMember = createMemberFromProfile(userId, fetchedProfile);
@@ -120,7 +121,7 @@ export const usePermalinkMember = (
}; };
doProfileLookup(); doProfileLookup();
}, [member, shouldLookUpUser, targetRoom, userId]); }, [context, member, shouldLookUpUser, targetRoom, userId]);
return member; return member;
}; };
-2
View File
@@ -9,7 +9,6 @@ Please see LICENSE files in the repository root for full details.
import * as linkifyjs from "linkifyjs"; import * as linkifyjs from "linkifyjs";
import { type EventListeners, type Opts, registerCustomProtocol, registerPlugin } from "linkifyjs"; import { type EventListeners, type Opts, registerCustomProtocol, registerPlugin } from "linkifyjs";
import linkifyElement from "linkify-element";
import linkifyString from "linkify-string"; import linkifyString from "linkify-string";
import { getHttpUriForMxc, User } from "matrix-js-sdk/src/matrix"; import { getHttpUriForMxc, User } from "matrix-js-sdk/src/matrix";
@@ -274,5 +273,4 @@ PERMITTED_URL_SCHEMES.forEach((scheme) => {
registerCustomProtocol("mxc", false); registerCustomProtocol("mxc", false);
export const linkify = linkifyjs; export const linkify = linkifyjs;
export const _linkifyElement = linkifyElement;
export const _linkifyString = linkifyString; export const _linkifyString = linkifyString;
+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;
}
}
};
-163
View File
@@ -1,163 +0,0 @@
/*
Copyright 2024 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, { StrictMode } from "react";
import { type MatrixClient, type MatrixEvent, RuleId } from "matrix-js-sdk/src/matrix";
import { TooltipProvider } from "@vector-im/compound-web";
import SettingsStore from "../settings/SettingsStore";
import { Pill, pillRoomNotifLen, pillRoomNotifPos, PillType } from "../components/views/elements/Pill";
import { parsePermalink } from "./permalinks/Permalinks";
import { type PermalinkParts } from "./permalinks/PermalinkConstructor";
import { type ReactRootManager } from "./react";
/**
* 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 does not have the "linkified" class.
* Composer completions already create an A tag.
* Linkify will not linkify things again. → There won't be a "linkified" class.
*/
const shouldBePillified = (node: Element, href: string, parts: PermalinkParts | null): boolean => {
// permalink parser didn't return any parts
if (!parts) return false;
const textContent = node.textContent;
// event permalink with custom label
if (parts.eventId && href !== textContent) return false;
return href === textContent || !node.classList.contains("linkified");
};
/**
* Recurses depth-first through a DOM tree, converting matrix.to links
* into pills based on the context of a given room. Returns a list of
* the resulting React nodes so they can be unmounted rather than leaking.
*
* @param matrixClient the client of the logged-in user
* @param {Element[]} nodes - a list of sibling DOM nodes to traverse to try
* to turn into pills.
* @param {MatrixEvent} mxEvent - the matrix event which the DOM nodes are
* part of representing.
* @param {ReactRootManager} pills - an accumulator of the DOM nodes which contain
* React components which have been mounted as part of this.
* The initial caller should pass in an empty array to seed the accumulator.
*/
export function pillifyLinks(
matrixClient: MatrixClient,
nodes: ArrayLike<Element>,
mxEvent: MatrixEvent,
pills: ReactRootManager,
): void {
const room = matrixClient.getRoom(mxEvent.getRoomId()) ?? undefined;
const shouldShowPillAvatar = SettingsStore.getValue("Pill.shouldShowPillAvatar");
let node = nodes[0];
while (node) {
let pillified = false;
if (node.tagName === "PRE" || node.tagName === "CODE" || pills.elements.includes(node)) {
// Skip code blocks and existing pills
node = node.nextSibling as Element;
continue;
} else if (node.tagName === "A" && node.getAttribute("href")) {
const href = node.getAttribute("href")!;
const parts = parsePermalink(href);
if (shouldBePillified(node, href, parts)) {
const pillContainer = document.createElement("span");
const pill = (
<StrictMode>
<TooltipProvider>
<Pill url={href} inMessage={true} room={room} shouldShowPillAvatar={shouldShowPillAvatar} />
</TooltipProvider>
</StrictMode>
);
pills.render(pill, pillContainer, node);
node.replaceWith(pillContainer);
// Pills within pills aren't going to go well, so move on
pillified = true;
// update the current node with one that's now taken its place
node = pillContainer;
}
} else if (
node.nodeType === Node.TEXT_NODE &&
// as applying pills happens outside of react, make sure we're not doubly
// applying @room pills here, as a rerender with the same content won't touch the DOM
// to clear the pills from the last run of pillifyLinks
!node.parentElement?.classList.contains("mx_AtRoomPill")
) {
let currentTextNode = node as Node as Text | null;
const roomNotifTextNodes: Text[] = [];
// Take a textNode and break it up to make all the instances of @room their
// own textNode, adding those nodes to roomNotifTextNodes
while (currentTextNode !== null) {
const roomNotifPos = pillRoomNotifPos(currentTextNode.textContent);
let nextTextNode: Text | null = null;
if (roomNotifPos > -1) {
let roomTextNode = currentTextNode;
if (roomNotifPos > 0) roomTextNode = roomTextNode.splitText(roomNotifPos);
if (roomTextNode.textContent && roomTextNode.textContent.length > pillRoomNotifLen()) {
nextTextNode = roomTextNode.splitText(pillRoomNotifLen());
}
roomNotifTextNodes.push(roomTextNode);
}
currentTextNode = nextTextNode;
}
if (roomNotifTextNodes.length > 0) {
const atRoomRule = matrixClient.pushProcessor.getPushRuleById(
mxEvent.getContent()["m.mentions"] !== undefined ? RuleId.IsRoomMention : RuleId.AtRoomNotification,
);
if (atRoomRule && matrixClient.pushProcessor.ruleMatchesEvent(atRoomRule, mxEvent)) {
// Now replace all those nodes with Pills
for (const roomNotifTextNode of roomNotifTextNodes) {
// Set the next node to be processed to the one after the node
// we're adding now, since we've just inserted nodes into the structure
// we're iterating over.
// Note we've checked roomNotifTextNodes.length > 0 so we'll do this at least once
node = roomNotifTextNode.nextSibling as Element;
const pillContainer = document.createElement("span");
const pill = (
<StrictMode>
<TooltipProvider>
<Pill
type={PillType.AtRoomMention}
inMessage={true}
room={room}
shouldShowPillAvatar={shouldShowPillAvatar}
/>
</TooltipProvider>
</StrictMode>
);
pills.render(pill, pillContainer, roomNotifTextNode);
roomNotifTextNode.replaceWith(pillContainer);
}
// Nothing else to do for a text node (and we don't need to advance
// the loop pointer because we did it above)
continue;
}
}
}
if (node.childNodes && node.childNodes.length && !pillified) {
pillifyLinks(matrixClient, node.childNodes as NodeListOf<Element>, mxEvent, pills);
}
node = node.nextSibling as Element;
}
}
-53
View File
@@ -1,53 +0,0 @@
/*
Copyright 2024 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 { type ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
/**
* Utility class to render & unmount additional React roots,
* e.g. for pills, tooltips and other components rendered atop user-generated events.
*/
export class ReactRootManager {
private roots: Root[] = [];
private rootElements: Element[] = [];
private revertElements: Array<Node | null> = [];
public get elements(): Element[] {
return this.rootElements;
}
/**
* Render a React component into a new root based on the given root element
* @param children the React component to render
* @param rootElement the root element to render the component into
* @param revertElement the element to replace the root element with when unmounting
* needed to support double-rendering in React 18 Strict Dev mode
*/
public render(children: ReactNode, rootElement: Element, revertElement: Node | null): void {
const root = createRoot(rootElement);
this.roots.push(root);
this.rootElements.push(rootElement);
this.revertElements.push(revertElement);
root.render(children);
}
/**
* Unmount all roots and revert the elements they were rendered into
*/
public unmount(): void {
while (this.roots.length) {
const root = this.roots.pop()!;
const rootElement = this.rootElements.pop();
const revertElement = this.revertElements.pop();
root.unmount();
if (revertElement) {
rootElement?.replaceWith(revertElement);
}
}
}
}
-76
View File
@@ -1,76 +0,0 @@
/*
Copyright 2024 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, { StrictMode } from "react";
import { TooltipProvider } from "@vector-im/compound-web";
import PlatformPeg from "../PlatformPeg";
import LinkWithTooltip from "../components/views/elements/LinkWithTooltip";
import { type ReactRootManager } from "./react";
/**
* If the platform enabled needsUrlTooltips, recurses depth-first through a DOM tree, adding tooltip previews
* for link elements. Otherwise, does nothing.
*
* @param {Element[]} rootNodes - a list of sibling DOM nodes to traverse to try
* to add tooltips.
* @param {Element[]} ignoredNodes - a list of nodes to not recurse into.
* @param {ReactRootManager} tooltips - an accumulator of the DOM nodes which contain
* React components that have been mounted by this function. The initial caller
* should pass in an empty array to seed the accumulator.
*/
export function tooltipifyLinks(
rootNodes: ArrayLike<Element>,
ignoredNodes: Element[],
tooltips: ReactRootManager,
): void {
if (!PlatformPeg.get()?.needsUrlTooltips()) {
return;
}
let node = rootNodes[0];
while (node) {
if (ignoredNodes.includes(node) || tooltips.elements.includes(node)) {
node = node.nextSibling as Element;
continue;
}
if (
node.tagName === "A" &&
node.getAttribute("href") &&
node.getAttribute("href") !== node.textContent?.trim()
) {
let href = node.getAttribute("href")!;
try {
href = new URL(href, window.location.href).toString();
} catch {
// Not all hrefs will be valid URLs
}
// The node's innerHTML was already sanitized before being rendered in the first place, here we are just
// wrapping the link with the LinkWithTooltip component, keeping the same children. Ideally we'd do this
// without the superfluous span but this is not something React trivially supports at this time.
const tooltip = (
<StrictMode>
<TooltipProvider>
<LinkWithTooltip tooltip={href}>
<span dangerouslySetInnerHTML={{ __html: node.innerHTML }} />
</LinkWithTooltip>
</TooltipProvider>
</StrictMode>
);
tooltips.render(tooltip, node, null);
} else if (node.childNodes?.length) {
tooltipifyLinks(node.childNodes as NodeListOf<Element>, ignoredNodes, tooltips);
}
node = node.nextSibling as Element;
}
}
+10
View File
@@ -115,6 +115,16 @@ export const mockClientMethodsEvents = () => ({
getPushActionsForEvent: jest.fn(), getPushActionsForEvent: jest.fn(),
}); });
/**
* Returns basic mocked pushProcessor
*/
export const mockClientPushProcessor = () => ({
pushProcessor: {
getPushRuleById: jest.fn(),
ruleMatchesEvent: jest.fn(),
},
});
/** /**
* Returns basic mocked client methods related to server support * Returns basic mocked client methods related to server support
*/ */
+4
View File
@@ -303,6 +303,10 @@ export function createTestClient(): MatrixClient {
getLocalAliases: jest.fn().mockReturnValue([]), getLocalAliases: jest.fn().mockReturnValue([]),
uploadDeviceSigningKeys: jest.fn(), uploadDeviceSigningKeys: jest.fn(),
isKeyBackupKeyStored: jest.fn().mockResolvedValue(null), isKeyBackupKeyStored: jest.fn().mockResolvedValue(null),
pushProcessor: {
getPushRuleById: jest.fn(),
},
} as unknown as MatrixClient; } as unknown as MatrixClient;
client.reEmitter = new ReEmitter(client); client.reEmitter = new ReEmitter(client);
+97 -66
View File
@@ -6,12 +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. Please see LICENSE files in the repository root for full details.
*/ */
import React, { type ReactElement } from "react"; import React from "react";
import { mocked } from "jest-mock"; import { mocked } from "jest-mock";
import { render, screen } from "jest-matrix-react"; import { render, screen } from "jest-matrix-react";
import { type IContent } from "matrix-js-sdk/src/matrix"; import parse from "html-react-parser";
import { bodyToSpan, formatEmojis, topicToHtml } from "../../src/HtmlUtils"; import { bodyToHtml, bodyToNode, formatEmojis, topicToHtml } from "../../src/HtmlUtils";
import SettingsStore from "../../src/settings/SettingsStore"; import SettingsStore from "../../src/settings/SettingsStore";
jest.mock("../../src/settings/SettingsStore"); jest.mock("../../src/settings/SettingsStore");
@@ -57,12 +57,8 @@ describe("topicToHtml", () => {
}); });
describe("bodyToHtml", () => { describe("bodyToHtml", () => {
function getHtml(content: IContent, highlights?: string[]): string {
return (bodyToSpan(content, highlights, {}) as ReactElement).props.dangerouslySetInnerHTML.__html;
}
it("should apply highlights to HTML messages", () => { it("should apply highlights to HTML messages", () => {
const html = getHtml( const html = bodyToHtml(
{ {
body: "test **foo** bar", body: "test **foo** bar",
msgtype: "m.text", msgtype: "m.text",
@@ -76,7 +72,7 @@ describe("bodyToHtml", () => {
}); });
it("should apply highlights to plaintext messages", () => { it("should apply highlights to plaintext messages", () => {
const html = getHtml( const html = bodyToHtml(
{ {
body: "test foo bar", body: "test foo bar",
msgtype: "m.text", msgtype: "m.text",
@@ -88,7 +84,7 @@ describe("bodyToHtml", () => {
}); });
it("should not respect HTML tags in plaintext message highlighting", () => { it("should not respect HTML tags in plaintext message highlighting", () => {
const html = getHtml( const html = bodyToHtml(
{ {
body: "test foo <b>bar", body: "test foo <b>bar",
msgtype: "m.text", msgtype: "m.text",
@@ -99,39 +95,12 @@ describe("bodyToHtml", () => {
expect(html).toMatchInlineSnapshot(`"<span class="mx_EventTile_searchHighlight">test</span> foo &lt;b&gt;bar"`); expect(html).toMatchInlineSnapshot(`"<span class="mx_EventTile_searchHighlight">test</span> foo &lt;b&gt;bar"`);
}); });
it("generates big emoji for emoji made of multiple characters", () => {
const { asFragment } = render(bodyToSpan({ body: "👨‍👩‍👧‍👦 ↔️ 🇮🇸", msgtype: "m.text" }, [], {}) as ReactElement);
expect(asFragment()).toMatchSnapshot();
});
it("should generate big emoji for an emoji-only reply to a message", () => {
const { asFragment } = render(
bodyToSpan(
{
"body": "> <@sender1:server> Test\n\n🥰",
"format": "org.matrix.custom.html",
"formatted_body":
'<mx-reply><blockquote><a href="https://matrix.to/#/!roomId:server/$eventId">In reply to</a> <a href="https://matrix.to/#/@sender1:server">@sender1:server</a><br>Test</blockquote></mx-reply>🥰',
"m.relates_to": {
"m.in_reply_to": {
event_id: "$eventId",
},
},
"msgtype": "m.text",
},
[],
{
stripReplyFallback: true,
},
) as ReactElement,
);
expect(asFragment()).toMatchSnapshot();
});
it("does not mistake characters in text presentation mode for emoji", () => { it("does not mistake characters in text presentation mode for emoji", () => {
const { asFragment } = render(bodyToSpan({ body: "↔ ❗︎", msgtype: "m.text" }, [], {}) as ReactElement); const { asFragment } = render(
<span className="mx_EventTile_body translate" dir="auto">
{parse(bodyToHtml({ body: "↔ ❗︎", msgtype: "m.text" }, [], {}))}
</span>,
);
expect(asFragment()).toMatchSnapshot(); expect(asFragment()).toMatchSnapshot();
}); });
@@ -142,42 +111,54 @@ describe("bodyToHtml", () => {
}); });
it("should render inline katex", () => { it("should render inline katex", () => {
const html = getHtml({ const html = bodyToHtml(
body: "hello \\xi world", {
msgtype: "m.text", body: "hello \\xi world",
formatted_body: 'hello <span data-mx-maths="\\xi"><code>\\xi</code></span> world', msgtype: "m.text",
format: "org.matrix.custom.html", formatted_body: 'hello <span data-mx-maths="\\xi"><code>\\xi</code></span> world',
}); format: "org.matrix.custom.html",
},
[],
);
expect(html).toMatchSnapshot(); expect(html).toMatchSnapshot();
}); });
it("should render block katex", () => { it("should render block katex", () => {
const html = getHtml({ const html = bodyToHtml(
body: "hello \\xi world", {
msgtype: "m.text", body: "hello \\xi world",
formatted_body: '<p>hello</p><div data-mx-maths="\\xi"><code>\\xi</code></div><p>world</p>', msgtype: "m.text",
format: "org.matrix.custom.html", formatted_body: '<p>hello</p><div data-mx-maths="\\xi"><code>\\xi</code></div><p>world</p>',
}); format: "org.matrix.custom.html",
},
[],
);
expect(html).toMatchSnapshot(); expect(html).toMatchSnapshot();
}); });
it("should not mangle code blocks", () => { it("should not mangle code blocks", () => {
const html = getHtml({ const html = bodyToHtml(
body: "hello \\xi world", {
msgtype: "m.text", body: "hello \\xi world",
formatted_body: "<p>hello</p><pre><code>$\\xi$</code></pre><p>world</p>", msgtype: "m.text",
format: "org.matrix.custom.html", formatted_body: "<p>hello</p><pre><code>$\\xi$</code></pre><p>world</p>",
}); format: "org.matrix.custom.html",
},
[],
);
expect(html).toMatchSnapshot(); expect(html).toMatchSnapshot();
}); });
it("should not mangle divs", () => { it("should not mangle divs", () => {
const html = getHtml({ const html = bodyToHtml(
body: "hello world", {
msgtype: "m.text", body: "hello world",
formatted_body: "<p>hello</p><div>world</div>", msgtype: "m.text",
format: "org.matrix.custom.html", formatted_body: "<p>hello</p><div>world</div>",
}); format: "org.matrix.custom.html",
},
[],
);
expect(html).toMatchSnapshot(); expect(html).toMatchSnapshot();
}); });
}); });
@@ -198,3 +179,53 @@ describe("formatEmojis", () => {
} }
}); });
}); });
describe("bodyToNode", () => {
it("generates big emoji for emoji made of multiple characters", () => {
const { className, emojiBodyElements } = bodyToNode(
{
body: "👨‍👩‍👧‍👦 ↔️ 🇮🇸",
msgtype: "m.text",
},
[],
{
stripReplyFallback: true,
},
);
const { asFragment } = render(
<span className={className} dir="auto">
{emojiBodyElements}
</span>,
);
expect(asFragment()).toMatchSnapshot();
});
it("should generate big emoji for an emoji-only reply to a message", () => {
const { className, formattedBody } = bodyToNode(
{
"body": "> <@sender1:server> Test\n\n🥰",
"format": "org.matrix.custom.html",
"formatted_body":
'<mx-reply><blockquote><a href="https://matrix.to/#/!roomId:server/$eventId">In reply to</a> <a href="https://matrix.to/#/@sender1:server">@sender1:server</a><br>Test</blockquote></mx-reply>🥰',
"m.relates_to": {
"m.in_reply_to": {
event_id: "$eventId",
},
},
"msgtype": "m.text",
},
[],
{
stripReplyFallback: true,
},
);
const { asFragment } = render(
<span className={className} dir="auto" dangerouslySetInnerHTML={{ __html: formattedBody! }} />,
);
expect(asFragment()).toMatchSnapshot();
});
});
@@ -19,7 +19,7 @@ exports[`bodyToHtml feature_latex_maths should render block katex 1`] = `"<p>hel
exports[`bodyToHtml feature_latex_maths should render inline katex 1`] = `"hello <span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>ξ</mi></mrow><annotation encoding="application/x-tex">\\xi</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.8889em;vertical-align:-0.1944em;"></span><span class="mord mathnormal" style="margin-right:0.04601em;">ξ</span></span></span></span> world"`; exports[`bodyToHtml feature_latex_maths should render inline katex 1`] = `"hello <span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>ξ</mi></mrow><annotation encoding="application/x-tex">\\xi</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.8889em;vertical-align:-0.1944em;"></span><span class="mord mathnormal" style="margin-right:0.04601em;">ξ</span></span></span></span> world"`;
exports[`bodyToHtml generates big emoji for emoji made of multiple characters 1`] = ` exports[`bodyToNode generates big emoji for emoji made of multiple characters 1`] = `
<DocumentFragment> <DocumentFragment>
<span <span
class="mx_EventTile_body mx_EventTile_bigEmoji translate" class="mx_EventTile_body mx_EventTile_bigEmoji translate"
@@ -49,7 +49,7 @@ exports[`bodyToHtml generates big emoji for emoji made of multiple characters 1`
</DocumentFragment> </DocumentFragment>
`; `;
exports[`bodyToHtml should generate big emoji for an emoji-only reply to a message 1`] = ` exports[`bodyToNode should generate big emoji for an emoji-only reply to a message 1`] = `
<DocumentFragment> <DocumentFragment>
<span <span
class="mx_EventTile_body mx_EventTile_bigEmoji translate" class="mx_EventTile_body mx_EventTile_bigEmoji translate"
@@ -26,6 +26,7 @@ import {
mockClientMethodsCrypto, mockClientMethodsCrypto,
mockClientMethodsEvents, mockClientMethodsEvents,
mockClientMethodsUser, mockClientMethodsUser,
mockClientPushProcessor,
} from "../../../test-utils"; } from "../../../test-utils";
import type ResizeNotifier from "../../../../src/utils/ResizeNotifier"; import type ResizeNotifier from "../../../../src/utils/ResizeNotifier";
import { type IRoomState } from "../../../../src/components/structures/RoomView"; import { type IRoomState } from "../../../../src/components/structures/RoomView";
@@ -45,6 +46,7 @@ describe("MessagePanel", function () {
...mockClientMethodsUser(userId), ...mockClientMethodsUser(userId),
...mockClientMethodsEvents(), ...mockClientMethodsEvents(),
...mockClientMethodsCrypto(), ...mockClientMethodsCrypto(),
...mockClientPushProcessor(),
getAccountData: jest.fn(), getAccountData: jest.fn(),
isUserIgnored: jest.fn().mockReturnValue(false), isUserIgnored: jest.fn().mockReturnValue(false),
isRoomEncrypted: jest.fn().mockReturnValue(false), isRoomEncrypted: jest.fn().mockReturnValue(false),
@@ -36,7 +36,14 @@ import TimelinePanel from "../../../../src/components/structures/TimelinePanel";
import MatrixClientContext from "../../../../src/contexts/MatrixClientContext"; import MatrixClientContext from "../../../../src/contexts/MatrixClientContext";
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
import { isCallEvent } from "../../../../src/components/structures/LegacyCallEventGrouper"; import { isCallEvent } from "../../../../src/components/structures/LegacyCallEventGrouper";
import { filterConsole, flushPromises, mkMembership, mkRoom, stubClient } from "../../../test-utils"; import {
filterConsole,
flushPromises,
mkMembership,
mkRoom,
stubClient,
withClientContextRenderOptions,
} from "../../../test-utils";
import { mkThread } from "../../../test-utils/threads"; import { mkThread } from "../../../test-utils/threads";
import { createMessageEventContent } from "../../../test-utils/events"; import { createMessageEventContent } from "../../../test-utils/events";
import SettingsStore from "../../../../src/settings/SettingsStore"; import SettingsStore from "../../../../src/settings/SettingsStore";
@@ -206,6 +213,7 @@ describe("TimelinePanel", () => {
manageReadReceipts={true} manageReadReceipts={true}
ref={(ref) => (timelinePanel = ref)} ref={(ref) => (timelinePanel = ref)}
/>, />,
withClientContextRenderOptions(MatrixClientPeg.safeGet()),
); );
await flushPromises(); await flushPromises();
await waitFor(() => expect(timelinePanel).toBeTruthy()); await waitFor(() => expect(timelinePanel).toBeTruthy());
@@ -403,7 +411,10 @@ describe("TimelinePanel", () => {
setupPagination(client, timeline, eventsPage1, null); setupPagination(client, timeline, eventsPage1, null);
await withScrollPanelMountSpy(async (mountSpy) => { await withScrollPanelMountSpy(async (mountSpy) => {
const { container } = render(<TimelinePanel {...getProps(room, events)} timelineSet={timelineSet} />); const { container } = render(
<TimelinePanel {...getProps(room, events)} timelineSet={timelineSet} />,
withClientContextRenderOptions(MatrixClientPeg.safeGet()),
);
await waitFor(() => expectEvents(container, [events[1]])); await waitFor(() => expectEvents(container, [events[1]]));
@@ -420,7 +431,10 @@ describe("TimelinePanel", () => {
const [, room, events] = setupTestData(); const [, room, events] = setupTestData();
await withScrollPanelMountSpy(async (mountSpy) => { await withScrollPanelMountSpy(async (mountSpy) => {
const { container } = render(<TimelinePanel {...getProps(room, events)} />); const { container } = render(
<TimelinePanel {...getProps(room, events)} />,
withClientContextRenderOptions(MatrixClientPeg.safeGet()),
);
await waitFor(() => expectEvents(container, [events[0], events[1]])); await waitFor(() => expectEvents(container, [events[0], events[1]]));
@@ -560,6 +574,7 @@ describe("TimelinePanel", () => {
overlayTimelineSet={overlayTimelineSet} overlayTimelineSet={overlayTimelineSet}
overlayTimelineSetFilter={isCallEvent} overlayTimelineSetFilter={isCallEvent}
/>, />,
withClientContextRenderOptions(MatrixClientPeg.safeGet()),
); );
await waitFor(() => await waitFor(() =>
expectEvents(container, [ expectEvents(container, [
@@ -599,6 +614,7 @@ describe("TimelinePanel", () => {
const { container } = render( const { container } = render(
<TimelinePanel {...getProps(room, events)} overlayTimelineSet={overlayTimelineSet} />, <TimelinePanel {...getProps(room, events)} overlayTimelineSet={overlayTimelineSet} />,
withClientContextRenderOptions(MatrixClientPeg.safeGet()),
); );
await waitFor(() => await waitFor(() =>
@@ -630,6 +646,7 @@ describe("TimelinePanel", () => {
const { container } = render( const { container } = render(
<TimelinePanel {...getProps(room, events)} overlayTimelineSet={overlayTimelineSet} />, <TimelinePanel {...getProps(room, events)} overlayTimelineSet={overlayTimelineSet} />,
withClientContextRenderOptions(MatrixClientPeg.safeGet()),
); );
await waitFor(() => await waitFor(() =>
@@ -661,6 +678,7 @@ describe("TimelinePanel", () => {
const { container } = render( const { container } = render(
<TimelinePanel {...getProps(room, events)} overlayTimelineSet={overlayTimelineSet} />, <TimelinePanel {...getProps(room, events)} overlayTimelineSet={overlayTimelineSet} />,
withClientContextRenderOptions(MatrixClientPeg.safeGet()),
); );
await waitFor(() => await waitFor(() =>
@@ -695,6 +713,7 @@ describe("TimelinePanel", () => {
timelineSet={timelineSet} timelineSet={timelineSet}
overlayTimelineSet={overlayTimelineSet} overlayTimelineSet={overlayTimelineSet}
/>, />,
withClientContextRenderOptions(MatrixClientPeg.safeGet()),
); );
await waitFor(() => expectEvents(container, [overlayEvents[0], events[0]])); await waitFor(() => expectEvents(container, [overlayEvents[0], events[0]]));
@@ -768,6 +787,7 @@ describe("TimelinePanel", () => {
await withScrollPanelMountSpy(async (mountSpy) => { await withScrollPanelMountSpy(async (mountSpy) => {
const { container } = render( const { container } = render(
<TimelinePanel {...getProps(room, events)} overlayTimelineSet={overlayTimelineSet} />, <TimelinePanel {...getProps(room, events)} overlayTimelineSet={overlayTimelineSet} />,
withClientContextRenderOptions(MatrixClientPeg.safeGet()),
); );
await waitFor(() => await waitFor(() =>
@@ -1027,7 +1047,10 @@ describe("TimelinePanel", () => {
room.getTimelineSets = jest.fn().mockReturnValue([timelineSet]); room.getTimelineSets = jest.fn().mockReturnValue([timelineSet]);
await withScrollPanelMountSpy(async () => { await withScrollPanelMountSpy(async () => {
const { container } = render(<TimelinePanel {...getProps(room, events)} timelineSet={timelineSet} />); const { container } = render(
<TimelinePanel {...getProps(room, events)} timelineSet={timelineSet} />,
withClientContextRenderOptions(MatrixClientPeg.safeGet()),
);
await waitFor(() => expectEvents(container, [events[1]])); await waitFor(() => expectEvents(container, [events[1]]));
}); });
@@ -21,11 +21,14 @@ import {
mkRoomCanonicalAliasEvent, mkRoomCanonicalAliasEvent,
mkRoomMemberJoinEvent, mkRoomMemberJoinEvent,
stubClient, stubClient,
withClientContextRenderOptions,
} from "../../../../test-utils"; } from "../../../../test-utils";
import DMRoomMap from "../../../../../src/utils/DMRoomMap"; import DMRoomMap from "../../../../../src/utils/DMRoomMap";
import { Action } from "../../../../../src/dispatcher/actions"; import { Action } from "../../../../../src/dispatcher/actions";
import { type ButtonEvent } from "../../../../../src/components/views/elements/AccessibleButton"; import { type ButtonEvent } from "../../../../../src/components/views/elements/AccessibleButton";
import { SdkContextClass } from "../../../../../src/contexts/SDKContext"; import { SDKContext, SdkContextClass } from "../../../../../src/contexts/SDKContext";
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg.ts";
import { TestSdkContext } from "../../../TestSdkContext.ts";
describe("<Pill>", () => { describe("<Pill>", () => {
let client: Mocked<MatrixClient>; let client: Mocked<MatrixClient>;
@@ -45,6 +48,10 @@ describe("<Pill>", () => {
let pillParentClickHandler: (e: ButtonEvent) => void; let pillParentClickHandler: (e: ButtonEvent) => void;
const renderPill = (props: PillProps): void => { const renderPill = (props: PillProps): void => {
const cli = MatrixClientPeg.safeGet();
const mockSdkContext = new TestSdkContext();
mockSdkContext.client = cli;
const withDefault = { const withDefault = {
inMessage: true, inMessage: true,
shouldShowPillAvatar: true, shouldShowPillAvatar: true,
@@ -53,9 +60,12 @@ describe("<Pill>", () => {
// wrap Pill with a div to allow testing of event bubbling // wrap Pill with a div to allow testing of event bubbling
renderResult = render( renderResult = render(
// eslint-disable-next-line jsx-a11y/click-events-have-key-events // eslint-disable-next-line jsx-a11y/click-events-have-key-events
<div onClick={pillParentClickHandler}> <SDKContext.Provider value={mockSdkContext}>
<Pill {...withDefault} /> <div onClick={pillParentClickHandler}>
</div>, <Pill {...withDefault} />
</div>
</SDKContext.Provider>,
withClientContextRenderOptions(cli),
); );
}; };
@@ -6,13 +6,19 @@ 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. Please see LICENSE files in the repository root for full details.
*/ */
import React from "react"; import React, { type ComponentProps } from "react";
import { type MatrixClient, type MatrixEvent, PushRuleKind } from "matrix-js-sdk/src/matrix"; import { type MatrixClient, type MatrixEvent, PushRuleKind, type Room } from "matrix-js-sdk/src/matrix";
import { mocked, type MockedObject } from "jest-mock"; import { mocked, type MockedObject } from "jest-mock";
import { render, waitFor } from "jest-matrix-react"; import { render, waitFor } from "jest-matrix-react";
import { PushProcessor } from "matrix-js-sdk/src/pushprocessor"; import { PushProcessor } from "matrix-js-sdk/src/pushprocessor";
import { getMockClientWithEventEmitter, mkEvent, mkMessage, mkStubRoom } from "../../../../test-utils"; import {
getMockClientWithEventEmitter,
mkEvent,
mkMessage,
mkStubRoom,
mockClientPushProcessor,
} from "../../../../test-utils";
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg"; import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
import * as languageHandler from "../../../../../src/languageHandler"; import * as languageHandler from "../../../../../src/languageHandler";
import DMRoomMap from "../../../../../src/utils/DMRoomMap"; import DMRoomMap from "../../../../../src/utils/DMRoomMap";
@@ -55,8 +61,8 @@ describe("<TextualBody />", () => {
jest.spyOn(global.Math, "random").mockRestore(); jest.spyOn(global.Math, "random").mockRestore();
}); });
const defaultRoom = mkStubRoom(room1Id, "test room", undefined); let defaultRoom: Room;
const otherRoom = mkStubRoom(room2Id, room2Name, undefined); let otherRoom: Room;
let defaultMatrixClient: MockedObject<MatrixClient>; let defaultMatrixClient: MockedObject<MatrixClient>;
const defaultEvent = mkEvent({ const defaultEvent = mkEvent({
@@ -70,6 +76,15 @@ describe("<TextualBody />", () => {
event: true, event: true,
}); });
const defaultProps: ComponentProps<typeof TextualBody> = {
mxEvent: defaultEvent,
highlights: [] as string[],
highlightLink: "",
onMessageAllowed: jest.fn(),
onHeightChanged: jest.fn(),
mediaEventHelper: {} as MediaEventHelper,
};
beforeEach(() => { beforeEach(() => {
defaultMatrixClient = getMockClientWithEventEmitter({ defaultMatrixClient = getMockClientWithEventEmitter({
getRoom: (roomId: string | undefined) => { getRoom: (roomId: string | undefined) => {
@@ -89,6 +104,10 @@ describe("<TextualBody />", () => {
// @ts-expect-error // @ts-expect-error
defaultMatrixClient.pushProcessor = new PushProcessor(defaultMatrixClient); defaultMatrixClient.pushProcessor = new PushProcessor(defaultMatrixClient);
defaultRoom = mkStubRoom(room1Id, "test room", defaultMatrixClient);
defaultProps.permalinkCreator = new RoomPermalinkCreator(defaultRoom);
otherRoom = mkStubRoom(room2Id, room2Name, defaultMatrixClient);
mocked(defaultRoom).findEventById.mockImplementation((eventId: string) => { mocked(defaultRoom).findEventById.mockImplementation((eventId: string) => {
if (eventId === defaultEvent.getId()) return defaultEvent; if (eventId === defaultEvent.getId()) return defaultEvent;
return undefined; return undefined;
@@ -96,16 +115,6 @@ describe("<TextualBody />", () => {
jest.spyOn(global.Math, "random").mockReturnValue(0.123456); jest.spyOn(global.Math, "random").mockReturnValue(0.123456);
}); });
const defaultProps = {
mxEvent: defaultEvent,
highlights: [] as string[],
highlightLink: "",
onMessageAllowed: jest.fn(),
onHeightChanged: jest.fn(),
permalinkCreator: new RoomPermalinkCreator(defaultRoom),
mediaEventHelper: {} as MediaEventHelper,
};
const getComponent = (props = {}, matrixClient: MatrixClient = defaultMatrixClient, renderingFn?: any) => const getComponent = (props = {}, matrixClient: MatrixClient = defaultMatrixClient, renderingFn?: any) =>
(renderingFn ?? render)( (renderingFn ?? render)(
<MatrixClientContext.Provider value={matrixClient}> <MatrixClientContext.Provider value={matrixClient}>
@@ -180,7 +189,7 @@ describe("<TextualBody />", () => {
const { container } = getComponent({ mxEvent: ev }); const { container } = getComponent({ mxEvent: ev });
const content = container.querySelector(".mx_EventTile_body"); const content = container.querySelector(".mx_EventTile_body");
expect(content.innerHTML).toMatchInlineSnapshot( expect(content.innerHTML).toMatchInlineSnapshot(
`"Chat with <a href="https://matrix.to/#/@user:example.com" class="linkified" rel="noreferrer noopener">@user:example.com</a>"`, `"Chat with <a href="https://matrix.to/#/@user:example.com" rel="noreferrer noopener" class="linkified">@user:example.com</a>"`,
); );
}); });
@@ -189,7 +198,7 @@ describe("<TextualBody />", () => {
const { container } = getComponent({ mxEvent: ev }); const { container } = getComponent({ mxEvent: ev });
const content = container.querySelector(".mx_EventTile_body"); const content = container.querySelector(".mx_EventTile_body");
expect(content.innerHTML).toMatchInlineSnapshot( expect(content.innerHTML).toMatchInlineSnapshot(
`"Chat with <span><bdi><a class="mx_Pill mx_UserPill mx_UserPill_me" href="https://matrix.to/#/@user:example.com"><span aria-label="Profile picture" aria-hidden="true" data-testid="avatar-img" data-type="round" data-color="2" class="_avatar_1qbcf_8 mx_BaseAvatar" style="--cpd-avatar-size: 16px;"><img loading="lazy" alt="" src="mxc://avatar.url/image.png" referrerpolicy="no-referrer" class="_image_1qbcf_41" data-type="round" width="16px" height="16px"></span><span class="mx_Pill_text">Member</span></a></bdi></span>"`, `"Chat with <bdi><a class="mx_Pill mx_UserPill mx_UserPill_me" href="https://matrix.to/#/@user:example.com"><span aria-label="Profile picture" aria-hidden="true" data-testid="avatar-img" data-type="round" data-color="2" class="_avatar_1qbcf_8 mx_BaseAvatar" style="--cpd-avatar-size: 16px;"><img loading="lazy" alt="" src="mxc://avatar.url/image.png" referrerpolicy="no-referrer" class="_image_1qbcf_41" data-type="round" width="16px" height="16px"></span><span class="mx_Pill_text">Member</span></a></bdi>"`,
); );
}); });
@@ -198,7 +207,7 @@ describe("<TextualBody />", () => {
const { container } = getComponent({ mxEvent: ev }); const { container } = getComponent({ mxEvent: ev });
const content = container.querySelector(".mx_EventTile_body"); const content = container.querySelector(".mx_EventTile_body");
expect(content.innerHTML).toMatchInlineSnapshot( expect(content.innerHTML).toMatchInlineSnapshot(
`"Visit <a href="https://matrix.to/#/#room:example.com" class="linkified" rel="noreferrer noopener">#room:example.com</a>"`, `"Visit <a href="https://matrix.to/#/#room:example.com" rel="noreferrer noopener" class="linkified">#room:example.com</a>"`,
); );
}); });
@@ -207,7 +216,7 @@ describe("<TextualBody />", () => {
const { container } = getComponent({ mxEvent: ev }); const { container } = getComponent({ mxEvent: ev });
const content = container.querySelector(".mx_EventTile_body"); const content = container.querySelector(".mx_EventTile_body");
expect(content.innerHTML).toMatchInlineSnapshot( expect(content.innerHTML).toMatchInlineSnapshot(
`"Visit <span><bdi><a class="mx_Pill mx_RoomPill" href="https://matrix.to/#/#room:example.com"><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 24 24" class="mx_Pill_LinkIcon mx_BaseAvatar"><path d="M12 19.071q-1.467 1.467-3.536 1.467-2.067 0-3.535-1.467t-1.467-3.535q0-2.07 1.467-3.536L7.05 9.879q.3-.3.707-.3t.707.3.301.707-.3.707l-2.122 2.121a2.9 2.9 0 0 0-.884 2.122q0 1.237.884 2.12.884.885 2.121.885t2.122-.884l2.121-2.121q.3-.3.707-.3t.707.3.3.707q0 .405-.3.707zm-1.414-4.243q-.3.3-.707.301a.97.97 0 0 1-.707-.3q-.3-.3-.301-.708 0-.405.3-.707l4.243-4.242q.3-.3.707-.3t.707.3.3.707-.3.707zm6.364-.707q-.3.3-.707.3a.97.97 0 0 1-.707-.3q-.3-.3-.301-.707 0-.405.3-.707l2.122-2.121q.884-.885.884-2.121 0-1.238-.884-2.122a2.9 2.9 0 0 0-2.121-.884q-1.237 0-2.122.884l-2.121 2.122q-.3.3-.707.3a.97.97 0 0 1-.707-.3q-.3-.3-.3-.708 0-.405.3-.707L12 4.93q1.467-1.467 3.536-1.467t3.535 1.467 1.467 3.536T19.071 12z"></path></svg><span class="mx_Pill_text">#room:example.com</span></a></bdi></span>"`, `"Visit <bdi><a class="mx_Pill mx_RoomPill" href="https://matrix.to/#/#room:example.com"><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 24 24" class="mx_Pill_LinkIcon mx_BaseAvatar"><path d="M12 19.071q-1.467 1.467-3.536 1.467-2.067 0-3.535-1.467t-1.467-3.535q0-2.07 1.467-3.536L7.05 9.879q.3-.3.707-.3t.707.3.301.707-.3.707l-2.122 2.121a2.9 2.9 0 0 0-.884 2.122q0 1.237.884 2.12.884.885 2.121.885t2.122-.884l2.121-2.121q.3-.3.707-.3t.707.3.3.707q0 .405-.3.707zm-1.414-4.243q-.3.3-.707.301a.97.97 0 0 1-.707-.3q-.3-.3-.301-.708 0-.405.3-.707l4.243-4.242q.3-.3.707-.3t.707.3.3.707-.3.707zm6.364-.707q-.3.3-.707.3a.97.97 0 0 1-.707-.3q-.3-.3-.301-.707 0-.405.3-.707l2.122-2.121q.884-.885.884-2.121 0-1.238-.884-2.122a2.9 2.9 0 0 0-2.121-.884q-1.237 0-2.122.884l-2.121 2.122q-.3.3-.707.3a.97.97 0 0 1-.707-.3q-.3-.3-.3-.708 0-.405.3-.707L12 4.93q1.467-1.467 3.536-1.467t3.535 1.467 1.467 3.536T19.071 12z"></path></svg><span class="mx_Pill_text">#room:example.com</span></a></bdi>"`,
); );
}); });
@@ -245,7 +254,7 @@ describe("<TextualBody />", () => {
const { container } = getComponent({ mxEvent: ev }); const { container } = getComponent({ mxEvent: ev });
const content = container.querySelector(".mx_EventTile_body"); const content = container.querySelector(".mx_EventTile_body");
expect(content.innerHTML).toMatchInlineSnapshot( expect(content.innerHTML).toMatchInlineSnapshot(
`"<span>foo <bdi><span tabindex="0"><span class="mx_Pill mx_KeywordPill"><span class="mx_Pill_text">bar</span></span></span></bdi> baz</span>"`, `"foo <bdi><span tabindex="0"><span class="mx_Pill mx_KeywordPill"><span class="mx_Pill_text">bar</span></span></span></bdi> baz"`,
); );
}); });
}); });
@@ -254,7 +263,8 @@ describe("<TextualBody />", () => {
let matrixClient: MatrixClient; let matrixClient: MatrixClient;
beforeEach(() => { beforeEach(() => {
matrixClient = getMockClientWithEventEmitter({ matrixClient = getMockClientWithEventEmitter({
getRoom: () => mkStubRoom(room1Id, "room name", undefined), getRoom: jest.fn(),
...mockClientPushProcessor(),
getAccountData: (): MatrixEvent | undefined => undefined, getAccountData: (): MatrixEvent | undefined => undefined,
getUserId: () => "@me:my_server", getUserId: () => "@me:my_server",
getHomeserverUrl: () => "https://my_server/", getHomeserverUrl: () => "https://my_server/",
@@ -263,6 +273,7 @@ describe("<TextualBody />", () => {
isGuest: () => false, isGuest: () => false,
mxcUrlToHttp: (s: string) => s, mxcUrlToHttp: (s: string) => s,
}); });
mocked(matrixClient.getRoom).mockReturnValue(mkStubRoom(room1Id, "room name", matrixClient));
DMRoomMap.makeShared(defaultMatrixClient); DMRoomMap.makeShared(defaultMatrixClient);
}); });
@@ -401,12 +412,15 @@ describe("<TextualBody />", () => {
beforeEach(() => { beforeEach(() => {
languageHandler.setMissingEntryGenerator((key) => key.split("|", 2)[1]); languageHandler.setMissingEntryGenerator((key) => key.split("|", 2)[1]);
matrixClient = getMockClientWithEventEmitter({ matrixClient = getMockClientWithEventEmitter({
getRoom: () => mkStubRoom("room_id", "room name", undefined), getRoom: jest.fn(),
getUserId: jest.fn(),
...mockClientPushProcessor(),
getAccountData: (): MatrixClient | undefined => undefined, getAccountData: (): MatrixClient | undefined => undefined,
getUrlPreview: (url: string) => new Promise(() => {}), getUrlPreview: (url: string) => new Promise(() => {}),
isGuest: () => false, isGuest: () => false,
mxcUrlToHttp: (s: string) => s, mxcUrlToHttp: (s: string) => s,
}); });
mocked(matrixClient.getRoom).mockReturnValue(mkStubRoom("room_id", "room name", matrixClient));
DMRoomMap.makeShared(defaultMatrixClient); DMRoomMap.makeShared(defaultMatrixClient);
}); });
@@ -77,40 +77,38 @@ exports[`<TextualBody /> renders formatted m.text correctly pills appear for an
dir="auto" dir="auto"
> >
Chat with Chat with
<span> <bdi>
<bdi> <a
<a class="mx_Pill mx_UserPill"
class="mx_Pill mx_UserPill" href="https://matrix.to/#/@user:example.com"
href="https://matrix.to/#/@user:example.com" >
<span
aria-hidden="true"
aria-label="Profile picture"
class="_avatar_1qbcf_8 mx_BaseAvatar"
data-color="2"
data-testid="avatar-img"
data-type="round"
style="--cpd-avatar-size: 16px;"
> >
<span <img
aria-hidden="true" alt=""
aria-label="Profile picture" class="_image_1qbcf_41"
class="_avatar_1qbcf_8 mx_BaseAvatar"
data-color="2"
data-testid="avatar-img"
data-type="round" data-type="round"
style="--cpd-avatar-size: 16px;" height="16px"
> loading="lazy"
<img referrerpolicy="no-referrer"
alt="" src="mxc://avatar.url/image.png"
class="_image_1qbcf_41" width="16px"
data-type="round" />
height="16px" </span>
loading="lazy" <span
referrerpolicy="no-referrer" class="mx_Pill_text"
src="mxc://avatar.url/image.png" >
width="16px" Member
/> </span>
</span> </a>
<span </bdi>
class="mx_Pill_text"
>
Member
</span>
</a>
</bdi>
</span>
</div> </div>
`; `;
@@ -124,40 +122,38 @@ exports[`<TextualBody /> renders formatted m.text correctly pills appear for eve
dir="auto" dir="auto"
> >
See this message See this message
<span> <bdi>
<bdi> <a
<a class="mx_Pill mx_EventPill"
class="mx_Pill mx_EventPill" href="https://matrix.to/#/!ZxbRYPQXDXKGmDnJNg:example.com/$16085560162aNpaH:example.com?via=example.com"
href="https://matrix.to/#/!ZxbRYPQXDXKGmDnJNg:example.com/$16085560162aNpaH:example.com?via=example.com" >
<span
aria-hidden="true"
aria-label="Avatar"
class="_avatar_1qbcf_8 mx_BaseAvatar"
data-color="1"
data-testid="avatar-img"
data-type="round"
style="--cpd-avatar-size: 16px;"
> >
<span <img
aria-hidden="true" alt=""
aria-label="Avatar" class="_image_1qbcf_41"
class="_avatar_1qbcf_8 mx_BaseAvatar"
data-color="1"
data-testid="avatar-img"
data-type="round" data-type="round"
style="--cpd-avatar-size: 16px;" height="16px"
> loading="lazy"
<img referrerpolicy="no-referrer"
alt="" src="mxc://avatar.url/room.png"
class="_image_1qbcf_41" width="16px"
data-type="round" />
height="16px" </span>
loading="lazy" <span
referrerpolicy="no-referrer" class="mx_Pill_text"
src="mxc://avatar.url/room.png" >
width="16px" Message in room name
/> </span>
</span> </a>
<span </bdi>
class="mx_Pill_text"
>
Message in room name
</span>
</a>
</bdi>
</span>
</div> </div>
</div> </div>
</DocumentFragment> </DocumentFragment>
@@ -173,40 +169,38 @@ exports[`<TextualBody /> renders formatted m.text correctly pills appear for roo
dir="auto" dir="auto"
> >
A A
<span> <bdi>
<bdi> <a
<a class="mx_Pill mx_RoomPill"
class="mx_Pill mx_RoomPill" href="https://matrix.to/#/!ZxbRYPQXDXKGmDnJNg:example.com?via=example.com&via=bob.com"
href="https://matrix.to/#/!ZxbRYPQXDXKGmDnJNg:example.com?via=example.com&via=bob.com" >
<span
aria-hidden="true"
aria-label="Avatar"
class="_avatar_1qbcf_8 mx_BaseAvatar"
data-color="1"
data-testid="avatar-img"
data-type="round"
style="--cpd-avatar-size: 16px;"
> >
<span <img
aria-hidden="true" alt=""
aria-label="Avatar" class="_image_1qbcf_41"
class="_avatar_1qbcf_8 mx_BaseAvatar"
data-color="1"
data-testid="avatar-img"
data-type="round" data-type="round"
style="--cpd-avatar-size: 16px;" height="16px"
> loading="lazy"
<img referrerpolicy="no-referrer"
alt="" src="mxc://avatar.url/room.png"
class="_image_1qbcf_41" width="16px"
data-type="round" />
height="16px" </span>
loading="lazy" <span
referrerpolicy="no-referrer" class="mx_Pill_text"
src="mxc://avatar.url/room.png" >
width="16px" room name
/> </span>
</span> </a>
<span </bdi>
class="mx_Pill_text"
>
room name
</span>
</a>
</bdi>
</span>
with vias with vias
</div> </div>
</div> </div>
@@ -287,40 +281,38 @@ exports[`<TextualBody /> renders formatted m.text correctly pills get injected c
dir="auto" dir="auto"
> >
Hey Hey
<span> <bdi>
<bdi> <a
<a class="mx_Pill mx_UserPill"
class="mx_Pill mx_UserPill" href="https://matrix.to/#/@user:server"
href="https://matrix.to/#/@user:server" >
<span
aria-hidden="true"
aria-label="Profile picture"
class="_avatar_1qbcf_8 mx_BaseAvatar"
data-color="2"
data-testid="avatar-img"
data-type="round"
style="--cpd-avatar-size: 16px;"
> >
<span <img
aria-hidden="true" alt=""
aria-label="Profile picture" class="_image_1qbcf_41"
class="_avatar_1qbcf_8 mx_BaseAvatar"
data-color="2"
data-testid="avatar-img"
data-type="round" data-type="round"
style="--cpd-avatar-size: 16px;" height="16px"
> loading="lazy"
<img referrerpolicy="no-referrer"
alt="" src="mxc://avatar.url/image.png"
class="_image_1qbcf_41" width="16px"
data-type="round" />
height="16px" </span>
loading="lazy" <span
referrerpolicy="no-referrer" class="mx_Pill_text"
src="mxc://avatar.url/image.png" >
width="16px" Member
/> </span>
</span> </a>
<span </bdi>
class="mx_Pill_text"
>
Member
</span>
</a>
</bdi>
</span>
</div> </div>
`; `;
@@ -466,25 +458,21 @@ exports[`<TextualBody /> renders formatted m.text correctly spoilers get injecte
dir="auto" dir="auto"
> >
Hey Hey
<span> <button
<button class="mx_EventTile_spoiler"
class="mx_EventTile_spoiler" >
<span
class="mx_EventTile_spoiler_reason"
> >
<span (movie)
class="mx_EventTile_spoiler_reason" </span>
>  
(movie) <span
</span> class="mx_EventTile_spoiler_content"
  >
<span the movie was awesome
class="mx_EventTile_spoiler_content" </span>
> </button>
<span>
the movie was awesome
</span>
</span>
</button>
</span>
</div> </div>
`; `;
@@ -522,9 +510,9 @@ exports[`<TextualBody /> renders plain-text m.text correctly linkification get a
</div> </div>
`; `;
exports[`<TextualBody /> renders plain-text m.text correctly should pillify a permalink to a message in the same room with the label »Message from Member« 1`] = `"Visit <span><bdi><a class="mx_Pill mx_EventPill" href="https://matrix.to/#/!room1:example.com/%event_id%"><span aria-label="Profile picture" aria-hidden="true" data-testid="avatar-img" data-type="round" data-color="2" class="_avatar_1qbcf_8 mx_BaseAvatar" style="--cpd-avatar-size: 16px;"><img loading="lazy" alt="" src="mxc://avatar.url/image.png" referrerpolicy="no-referrer" class="_image_1qbcf_41" data-type="round" width="16px" height="16px"></span><span class="mx_Pill_text">Message from Member</span></a></bdi></span>"`; exports[`<TextualBody /> renders plain-text m.text correctly should pillify a permalink to a message in the same room with the label »Message from Member« 1`] = `"Visit <bdi><a class="mx_Pill mx_EventPill" href="https://matrix.to/#/!room1:example.com/%event_id%"><span aria-label="Profile picture" aria-hidden="true" data-testid="avatar-img" data-type="round" data-color="2" class="_avatar_1qbcf_8 mx_BaseAvatar" style="--cpd-avatar-size: 16px;"><img loading="lazy" alt="" src="mxc://avatar.url/image.png" referrerpolicy="no-referrer" class="_image_1qbcf_41" data-type="round" width="16px" height="16px"></span><span class="mx_Pill_text">Message from Member</span></a></bdi>"`;
exports[`<TextualBody /> renders plain-text m.text correctly should pillify a permalink to an event in another room with the label »Message in Room 2« 1`] = `"Visit <span><bdi><a class="mx_Pill mx_EventPill" href="https://matrix.to/#/!room2:example.com/%event_id%"><span aria-label="Avatar" aria-hidden="true" data-testid="avatar-img" data-type="round" data-color="2" class="_avatar_1qbcf_8 mx_BaseAvatar" style="--cpd-avatar-size: 16px;"><img loading="lazy" alt="" src="mxc://avatar.url/room.png" referrerpolicy="no-referrer" class="_image_1qbcf_41" data-type="round" width="16px" height="16px"></span><span class="mx_Pill_text">Message in Room 2</span></a></bdi></span>"`; exports[`<TextualBody /> renders plain-text m.text correctly should pillify a permalink to an event in another room with the label »Message in Room 2« 1`] = `"Visit <bdi><a class="mx_Pill mx_EventPill" href="https://matrix.to/#/!room2:example.com/%event_id%"><span aria-label="Avatar" aria-hidden="true" data-testid="avatar-img" data-type="round" data-color="2" class="_avatar_1qbcf_8 mx_BaseAvatar" style="--cpd-avatar-size: 16px;"><img loading="lazy" alt="" src="mxc://avatar.url/room.png" referrerpolicy="no-referrer" class="_image_1qbcf_41" data-type="round" width="16px" height="16px"></span><span class="mx_Pill_text">Message in Room 2</span></a></bdi>"`;
exports[`<TextualBody /> renders plain-text m.text correctly should pillify a permalink to an unknown message in the same room with the label »Message« 1`] = ` exports[`<TextualBody /> renders plain-text m.text correctly should pillify a permalink to an unknown message in the same room with the label »Message« 1`] = `
<div <div
@@ -532,32 +520,30 @@ exports[`<TextualBody /> renders plain-text m.text correctly should pillify a pe
dir="auto" dir="auto"
> >
Visit Visit
<span> <bdi>
<bdi> <a
<a class="mx_Pill mx_EventPill"
class="mx_Pill mx_EventPill" href="https://matrix.to/#/!room1:example.com/!abc123"
href="https://matrix.to/#/!room1:example.com/!abc123" >
<svg
class="mx_Pill_LinkIcon mx_BaseAvatar"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
> >
<svg <path
class="mx_Pill_LinkIcon mx_BaseAvatar" d="M12 19.071q-1.467 1.467-3.536 1.467-2.067 0-3.535-1.467t-1.467-3.535q0-2.07 1.467-3.536L7.05 9.879q.3-.3.707-.3t.707.3.301.707-.3.707l-2.122 2.121a2.9 2.9 0 0 0-.884 2.122q0 1.237.884 2.12.884.885 2.121.885t2.122-.884l2.121-2.121q.3-.3.707-.3t.707.3.3.707q0 .405-.3.707zm-1.414-4.243q-.3.3-.707.301a.97.97 0 0 1-.707-.3q-.3-.3-.301-.708 0-.405.3-.707l4.243-4.242q.3-.3.707-.3t.707.3.3.707-.3.707zm6.364-.707q-.3.3-.707.3a.97.97 0 0 1-.707-.3q-.3-.3-.301-.707 0-.405.3-.707l2.122-2.121q.884-.885.884-2.121 0-1.238-.884-2.122a2.9 2.9 0 0 0-2.121-.884q-1.237 0-2.122.884l-2.121 2.122q-.3.3-.707.3a.97.97 0 0 1-.707-.3q-.3-.3-.3-.708 0-.405.3-.707L12 4.93q1.467-1.467 3.536-1.467t3.535 1.467 1.467 3.536T19.071 12z"
fill="currentColor" />
height="1em" </svg>
viewBox="0 0 24 24" <span
width="1em" class="mx_Pill_text"
xmlns="http://www.w3.org/2000/svg" >
> Message
<path </span>
d="M12 19.071q-1.467 1.467-3.536 1.467-2.067 0-3.535-1.467t-1.467-3.535q0-2.07 1.467-3.536L7.05 9.879q.3-.3.707-.3t.707.3.301.707-.3.707l-2.122 2.121a2.9 2.9 0 0 0-.884 2.122q0 1.237.884 2.12.884.885 2.121.885t2.122-.884l2.121-2.121q.3-.3.707-.3t.707.3.3.707q0 .405-.3.707zm-1.414-4.243q-.3.3-.707.301a.97.97 0 0 1-.707-.3q-.3-.3-.301-.708 0-.405.3-.707l4.243-4.242q.3-.3.707-.3t.707.3.3.707-.3.707zm6.364-.707q-.3.3-.707.3a.97.97 0 0 1-.707-.3q-.3-.3-.301-.707 0-.405.3-.707l2.122-2.121q.884-.885.884-2.121 0-1.238-.884-2.122a2.9 2.9 0 0 0-2.121-.884q-1.237 0-2.122.884l-2.121 2.122q-.3.3-.707.3a.97.97 0 0 1-.707-.3q-.3-.3-.3-.708 0-.405.3-.707L12 4.93q1.467-1.467 3.536-1.467t3.535 1.467 1.467 3.536T19.071 12z" </a>
/> </bdi>
</svg>
<span
class="mx_Pill_text"
>
Message
</span>
</a>
</bdi>
</span>
</div> </div>
`; `;
@@ -10,7 +10,7 @@ import React from "react";
import { MatrixEvent, Room, EventType } from "matrix-js-sdk/src/matrix"; import { MatrixEvent, Room, EventType } from "matrix-js-sdk/src/matrix";
import { render, type RenderResult } from "jest-matrix-react"; import { render, type RenderResult } from "jest-matrix-react";
import { stubClient } from "../../../../test-utils"; import { stubClient, withClientContextRenderOptions } from "../../../../test-utils";
import SearchResultTile from "../../../../../src/components/views/rooms/SearchResultTile"; import SearchResultTile from "../../../../../src/components/views/rooms/SearchResultTile";
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg"; import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
@@ -28,7 +28,10 @@ describe("SearchResultTile", () => {
}); });
function renderComponent(props: Partial<Props>): RenderResult { function renderComponent(props: Partial<Props>): RenderResult {
return render(<SearchResultTile timeline={[]} ourEventsIndexes={[1]} {...props} />); return render(
<SearchResultTile timeline={[]} ourEventsIndexes={[1]} {...props} />,
withClientContextRenderOptions(MatrixClientPeg.safeGet()),
);
} }
it("Sets up appropriate callEventGrouper for m.call. events", () => { it("Sets up appropriate callEventGrouper for m.call. events", () => {
@@ -0,0 +1,32 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`link-tooltip does nothing for empty element 1`] = `
<DocumentFragment>
<div />
</DocumentFragment>
`;
exports[`link-tooltip wraps single anchor 1`] = `
<DocumentFragment>
<div>
<span
aria-labelledby=":r0:"
tabindex="0"
>
<a
href="/foo"
>
click
</a>
</span>
</div>
</DocumentFragment>
`;
@@ -0,0 +1,103 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`keyword pills should do nothing for empty element 1`] = `
<DocumentFragment>
<div />
</DocumentFragment>
`;
exports[`keyword pills should pillify 1`] = `
<DocumentFragment>
<div>
Foo
<bdi>
<span
tabindex="0"
>
<span
class="mx_Pill mx_KeywordPill"
>
<span
class="mx_Pill_text"
>
TeST
</span>
</span>
</span>
</bdi>
Bar
</div>
</DocumentFragment>
`;
exports[`mention pills should do nothing for empty element 1`] = `
<DocumentFragment>
<div />
</DocumentFragment>
`;
exports[`mention pills should pillify @room 1`] = `
<DocumentFragment>
<div>
<bdi>
<span
tabindex="0"
>
<span
class="mx_Pill mx_AtRoomPill"
>
<span
aria-hidden="true"
class="_avatar_1qbcf_8 mx_BaseAvatar _avatar-imageless_1qbcf_52"
data-color="4"
data-testid="avatar-img"
data-type="round"
role="presentation"
style="--cpd-avatar-size: 16px;"
>
!
</span>
<span
class="mx_Pill_text"
>
@room
</span>
</span>
</span>
</bdi>
</div>
</DocumentFragment>
`;
exports[`mention pills should pillify @room in an intentional mentions world 1`] = `
<DocumentFragment>
<div>
<bdi>
<span
tabindex="0"
>
<span
class="mx_Pill mx_AtRoomPill"
>
<span
aria-hidden="true"
class="_avatar_1qbcf_8 mx_BaseAvatar _avatar-imageless_1qbcf_52"
data-color="4"
data-testid="avatar-img"
data-type="round"
role="presentation"
style="--cpd-avatar-size: 16px;"
>
!
</span>
<span
class="mx_Pill_text"
>
@room
</span>
</span>
</span>
</bdi>
</div>
</DocumentFragment>
`;
@@ -0,0 +1,47 @@
/*
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 { screen, fireEvent, render, type RenderResult } from "jest-matrix-react";
import parse from "html-react-parser";
import { ambiguousLinkTooltipRenderer, combineRenderers } from "../../../src/renderer";
import PlatformPeg from "../../../src/PlatformPeg";
import type BasePlatform from "../../../src/BasePlatform";
describe("link-tooltip", () => {
jest.spyOn(PlatformPeg, "get").mockReturnValue({ needsUrlTooltips: () => true } as unknown as BasePlatform);
function renderTooltips(input: string): RenderResult {
return render(
<>
{parse(input, {
replace: combineRenderers(ambiguousLinkTooltipRenderer)({ isHtml: true }),
})}
</>,
);
}
it("does nothing for empty element", () => {
const { asFragment } = renderTooltips("<div></div>");
expect(asFragment()).toMatchSnapshot();
});
it("wraps single anchor", () => {
const { container, asFragment } = renderTooltips(`
<div>
<a href="/foo">click</a>
</div>
`);
expect(asFragment()).toMatchSnapshot();
const anchor = container.querySelector("a")!;
expect(anchor.getAttribute("href")).toEqual("/foo");
fireEvent.focus(anchor.parentElement!);
expect(screen.getByLabelText("http://localhost/foo")).toBe(anchor.parentElement!);
});
});
+228
View File
@@ -0,0 +1,228 @@
/*
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 { render, type RenderResult } from "jest-matrix-react";
import {
MatrixEvent,
ConditionKind,
EventType,
PushRuleActionName,
Room,
TweakName,
type MatrixClient,
} from "matrix-js-sdk/src/matrix";
import { mocked } from "jest-mock";
import parse from "html-react-parser";
import { PushProcessor } from "matrix-js-sdk/src/pushprocessor";
import { keywordPillRenderer, mentionPillRenderer, combineRenderers } from "../../../src/renderer";
import { stubClient, withClientContextRenderOptions } from "../../test-utils";
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
import DMRoomMap from "../../../src/utils/DMRoomMap";
describe("mention pills", () => {
let cli: MatrixClient;
let room: Room;
const roomId = "!room:id";
const event = new MatrixEvent({
room_id: roomId,
type: EventType.RoomMessage,
content: {
body: "@room",
},
});
beforeEach(() => {
stubClient();
cli = MatrixClientPeg.safeGet();
// @ts-expect-error
cli.pushProcessor = new PushProcessor(cli);
room = new Room(roomId, cli, cli.getUserId()!);
room.currentState.mayTriggerNotifOfType = jest.fn().mockReturnValue(true);
(cli.getRoom as jest.Mock).mockReturnValue(room);
cli.pushRules!.global = {
override: [
{
rule_id: ".m.rule.roomnotif",
default: true,
enabled: true,
conditions: [
{
kind: ConditionKind.EventMatch,
key: "content.body",
pattern: "@room",
},
],
actions: [
PushRuleActionName.Notify,
{
set_tweak: TweakName.Highlight,
value: true,
},
],
},
{
rule_id: ".m.rule.is_room_mention",
default: true,
enabled: true,
conditions: [
{
kind: ConditionKind.EventPropertyIs,
key: "content.m\\.mentions.room",
value: true,
},
{
kind: ConditionKind.SenderNotificationPermission,
key: "room",
},
],
actions: [
PushRuleActionName.Notify,
{
set_tweak: TweakName.Highlight,
},
],
},
],
};
DMRoomMap.makeShared(cli);
});
function renderPills(input: string, mxEvent?: MatrixEvent): RenderResult {
return render(
<>
{parse(input, {
replace: combineRenderers(mentionPillRenderer)({
mxEvent: mxEvent ?? event,
room,
isHtml: true,
}),
})}
</>,
withClientContextRenderOptions(cli),
);
}
it("should do nothing for empty element", () => {
const input = "<div></div>";
const { asFragment } = renderPills(input);
expect(asFragment()).toMatchSnapshot();
});
it("should pillify @room", () => {
const input = "<div>@room</div>";
const { container, asFragment } = renderPills(input);
expect(asFragment()).toMatchSnapshot();
expect(container.querySelector(".mx_Pill.mx_AtRoomPill")?.textContent).toBe("!@room");
});
it("should pillify @room in an intentional mentions world", () => {
mocked(MatrixClientPeg.safeGet().supportsIntentionalMentions).mockReturnValue(true);
const { container, asFragment } = renderPills(
"<div>@room</div>",
new MatrixEvent({
room_id: roomId,
type: EventType.RoomMessage,
content: {
"body": "@room",
"m.mentions": {
room: true,
},
},
}),
);
expect(asFragment()).toMatchSnapshot();
expect(container.querySelector(".mx_Pill.mx_AtRoomPill")?.textContent).toBe("!@room");
});
});
describe("keyword pills", () => {
let cli: MatrixClient;
const keywordRegexpPattern = /(test)/i;
beforeEach(() => {
stubClient();
cli = MatrixClientPeg.safeGet();
cli.pushRules!.global = {
override: [
{
rule_id: ".m.rule.roomnotif",
default: true,
enabled: true,
conditions: [
{
kind: ConditionKind.EventMatch,
key: "content.body",
pattern: "@room",
},
],
actions: [
PushRuleActionName.Notify,
{
set_tweak: TweakName.Highlight,
value: true,
},
],
},
{
rule_id: ".m.rule.is_room_mention",
default: true,
enabled: true,
conditions: [
{
kind: ConditionKind.EventPropertyIs,
key: "content.m\\.mentions.room",
value: true,
},
{
kind: ConditionKind.SenderNotificationPermission,
key: "room",
},
],
actions: [
PushRuleActionName.Notify,
{
set_tweak: TweakName.Highlight,
},
],
},
],
};
DMRoomMap.makeShared(cli);
});
function renderPills(input: string): RenderResult {
return render(
<>
{parse(input, {
replace: combineRenderers(keywordPillRenderer)({
isHtml: true,
keywordRegexpPattern,
}),
})}
</>,
withClientContextRenderOptions(cli),
);
}
it("should do nothing for empty element", () => {
const input = "<div></div>";
const { asFragment } = renderPills(input);
expect(asFragment()).toMatchSnapshot();
});
it("should pillify", () => {
const input = "<div>Foo TeST Bar</div>";
const { container, asFragment } = renderPills(input);
expect(asFragment()).toMatchSnapshot();
expect(container.querySelector(".mx_Pill.mx_KeywordPill")?.textContent).toBe("TeST");
});
});
-142
View File
@@ -1,142 +0,0 @@
/*
Copyright 2024 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 { act, render } from "jest-matrix-react";
import { MatrixEvent, ConditionKind, EventType, PushRuleActionName, Room, TweakName } from "matrix-js-sdk/src/matrix";
import { mocked } from "jest-mock";
import { PushProcessor } from "matrix-js-sdk/src/pushprocessor";
import { pillifyLinks } from "../../../src/utils/pillify";
import { stubClient } from "../../test-utils";
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
import DMRoomMap from "../../../src/utils/DMRoomMap";
import { ReactRootManager } from "../../../src/utils/react.tsx";
describe("pillify", () => {
const roomId = "!room:id";
const event = new MatrixEvent({
room_id: roomId,
type: EventType.RoomMessage,
content: {
body: "@room",
},
});
beforeEach(() => {
stubClient();
const cli = MatrixClientPeg.safeGet();
const room = new Room(roomId, cli, cli.getUserId()!);
room.currentState.mayTriggerNotifOfType = jest.fn().mockReturnValue(true);
(cli.getRoom as jest.Mock).mockReturnValue(room);
cli.pushRules!.global = {
override: [
{
rule_id: ".m.rule.roomnotif",
default: true,
enabled: true,
conditions: [
{
kind: ConditionKind.EventMatch,
key: "content.body",
pattern: "@room",
},
],
actions: [
PushRuleActionName.Notify,
{
set_tweak: TweakName.Highlight,
value: true,
},
],
},
{
rule_id: ".m.rule.is_room_mention",
default: true,
enabled: true,
conditions: [
{
kind: ConditionKind.EventPropertyIs,
key: "content.m\\.mentions.room",
value: true,
},
{
kind: ConditionKind.SenderNotificationPermission,
key: "room",
},
],
actions: [
PushRuleActionName.Notify,
{
set_tweak: TweakName.Highlight,
},
],
},
],
};
// @ts-expect-error
cli.pushProcessor = new PushProcessor(cli);
DMRoomMap.makeShared(cli);
});
it("should do nothing for empty element", () => {
const { container } = render(<div />);
const originalHtml = container.outerHTML;
const containers = new ReactRootManager();
pillifyLinks(MatrixClientPeg.safeGet(), [container], event, containers);
expect(containers.elements).toHaveLength(0);
expect(container.outerHTML).toEqual(originalHtml);
});
it("should pillify @room", () => {
const { container } = render(<div>@room</div>);
const containers = new ReactRootManager();
act(() => pillifyLinks(MatrixClientPeg.safeGet(), [container], event, containers));
expect(containers.elements).toHaveLength(1);
expect(container.querySelector(".mx_Pill.mx_AtRoomPill")?.textContent).toBe("!@room");
});
it("should pillify @room in an intentional mentions world", () => {
mocked(MatrixClientPeg.safeGet().supportsIntentionalMentions).mockReturnValue(true);
const { container } = render(<div>@room</div>);
const containers = new ReactRootManager();
act(() =>
pillifyLinks(
MatrixClientPeg.safeGet(),
[container],
new MatrixEvent({
room_id: roomId,
type: EventType.RoomMessage,
content: {
"body": "@room",
"m.mentions": {
room: true,
},
},
}),
containers,
),
);
expect(containers.elements).toHaveLength(1);
expect(container.querySelector(".mx_Pill.mx_AtRoomPill")?.textContent).toBe("!@room");
});
it("should not double up pillification on repeated calls", () => {
const { container } = render(<div>@room</div>);
const containers = new ReactRootManager();
act(() => {
pillifyLinks(MatrixClientPeg.safeGet(), [container], event, containers);
pillifyLinks(MatrixClientPeg.safeGet(), [container], event, containers);
pillifyLinks(MatrixClientPeg.safeGet(), [container], event, containers);
pillifyLinks(MatrixClientPeg.safeGet(), [container], event, containers);
});
expect(containers.elements).toHaveLength(1);
expect(container.querySelector(".mx_Pill.mx_AtRoomPill")?.textContent).toBe("!@room");
});
});
-77
View File
@@ -1,77 +0,0 @@
/*
Copyright 2024 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 { act, render } from "jest-matrix-react";
import { tooltipifyLinks } from "../../../src/utils/tooltipify";
import PlatformPeg from "../../../src/PlatformPeg";
import type BasePlatform from "../../../src/BasePlatform";
import { ReactRootManager } from "../../../src/utils/react.tsx";
describe("tooltipify", () => {
jest.spyOn(PlatformPeg, "get").mockReturnValue({ needsUrlTooltips: () => true } as unknown as BasePlatform);
it("does nothing for empty element", () => {
const { container: root } = render(<div />);
const originalHtml = root.outerHTML;
const containers = new ReactRootManager();
tooltipifyLinks([root], [], containers);
expect(containers.elements).toHaveLength(0);
expect(root.outerHTML).toEqual(originalHtml);
});
it("wraps single anchor", () => {
const { container: root } = render(
<div>
<a href="/foo">click</a>
</div>,
);
const containers = new ReactRootManager();
tooltipifyLinks([root], [], containers);
expect(containers.elements).toHaveLength(1);
const anchor = root.querySelector("a");
expect(anchor?.getAttribute("href")).toEqual("/foo");
const tooltip = anchor!.querySelector(".mx_TextWithTooltip_target");
expect(tooltip).toBeDefined();
});
it("ignores node", () => {
const { container: root } = render(
<div>
<a href="/foo">click</a>
</div>,
);
const originalHtml = root.outerHTML;
const containers = new ReactRootManager();
tooltipifyLinks([root], [root.children[0]], containers);
expect(containers.elements).toHaveLength(0);
expect(root.outerHTML).toEqual(originalHtml);
});
it("does not re-wrap if called multiple times", async () => {
const { container: root, unmount } = render(
<div>
<a href="/foo">click</a>
</div>,
);
const containers = new ReactRootManager();
tooltipifyLinks([root], [], containers);
tooltipifyLinks([root], [], containers);
tooltipifyLinks([root], [], containers);
tooltipifyLinks([root], [], containers);
expect(containers.elements).toHaveLength(1);
const anchor = root.querySelector("a");
expect(anchor?.getAttribute("href")).toEqual("/foo");
const tooltip = anchor!.querySelector(".mx_TextWithTooltip_target");
expect(tooltip).toBeDefined();
await act(async () => {
unmount();
});
});
});
+70 -13
View File
@@ -5670,6 +5670,13 @@ domexception@^4.0.0:
dependencies: dependencies:
webidl-conversions "^7.0.0" webidl-conversions "^7.0.0"
domhandler@5.0.3, domhandler@^5.0.2, domhandler@^5.0.3:
version "5.0.3"
resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-5.0.3.tgz#cc385f7f751f1d1fc650c21374804254538c7d31"
integrity sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==
dependencies:
domelementtype "^2.3.0"
domhandler@^4.0.0, domhandler@^4.2.0, domhandler@^4.3.1: domhandler@^4.0.0, domhandler@^4.2.0, domhandler@^4.3.1:
version "4.3.1" version "4.3.1"
resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-4.3.1.tgz#8d792033416f59d68bc03a5aa7b018c1ca89279c" resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-4.3.1.tgz#8d792033416f59d68bc03a5aa7b018c1ca89279c"
@@ -5677,13 +5684,6 @@ domhandler@^4.0.0, domhandler@^4.2.0, domhandler@^4.3.1:
dependencies: dependencies:
domelementtype "^2.2.0" domelementtype "^2.2.0"
domhandler@^5.0.2, domhandler@^5.0.3:
version "5.0.3"
resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-5.0.3.tgz#cc385f7f751f1d1fc650c21374804254538c7d31"
integrity sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==
dependencies:
domelementtype "^2.3.0"
domutils@^2.5.2, domutils@^2.8.0: domutils@^2.5.2, domutils@^2.8.0:
version "2.8.0" version "2.8.0"
resolved "https://registry.yarnpkg.com/domutils/-/domutils-2.8.0.tgz#4437def5db6e2d1f5d6ee859bd95ca7d02048135" resolved "https://registry.yarnpkg.com/domutils/-/domutils-2.8.0.tgz#4437def5db6e2d1f5d6ee859bd95ca7d02048135"
@@ -5693,7 +5693,7 @@ domutils@^2.5.2, domutils@^2.8.0:
domelementtype "^2.2.0" domelementtype "^2.2.0"
domhandler "^4.2.0" domhandler "^4.2.0"
domutils@^3.0.1: domutils@^3.0.1, domutils@^3.2.1, domutils@^3.2.2:
version "3.2.2" version "3.2.2"
resolved "https://registry.yarnpkg.com/domutils/-/domutils-3.2.2.tgz#edbfe2b668b0c1d97c24baf0f1062b132221bc78" resolved "https://registry.yarnpkg.com/domutils/-/domutils-3.2.2.tgz#edbfe2b668b0c1d97c24baf0f1062b132221bc78"
integrity sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw== integrity sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==
@@ -5840,6 +5840,11 @@ entities@^4.2.0, entities@^4.4.0, entities@^4.5.0:
resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48" resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48"
integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw== integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==
entities@^6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/entities/-/entities-6.0.0.tgz#09c9e29cb79b0a6459a9b9db9efb418ac5bb8e51"
integrity sha512-aKstq2TDOndCn4diEyp9Uq/Flu2i1GlLkc6XIDQSDMuaFE3OPW5OphLCyQ5SpSJZTb4reN+kTcYru5yIfXoRPw==
entities@~3.0.1: entities@~3.0.1:
version "3.0.1" version "3.0.1"
resolved "https://registry.yarnpkg.com/entities/-/entities-3.0.1.tgz#2b887ca62585e96db3903482d336c1006c3001d4" resolved "https://registry.yarnpkg.com/entities/-/entities-3.0.1.tgz#2b887ca62585e96db3903482d336c1006c3001d4"
@@ -7100,6 +7105,14 @@ hpack.js@^2.1.6:
readable-stream "^2.0.1" readable-stream "^2.0.1"
wbuf "^1.1.0" wbuf "^1.1.0"
html-dom-parser@5.0.13:
version "5.0.13"
resolved "https://registry.yarnpkg.com/html-dom-parser/-/html-dom-parser-5.0.13.tgz#36b25b3ad05dc71741e9cd200ff276cdf8e3831d"
integrity sha512-B7JonBuAfG32I7fDouUQEogBrz3jK9gAuN1r1AaXpED6dIhtg/JwiSRhjGL7aOJwRz3HU4efowCjQBaoXiREqg==
dependencies:
domhandler "5.0.3"
htmlparser2 "10.0.0"
html-encoding-sniffer@^3.0.0: html-encoding-sniffer@^3.0.0:
version "3.0.0" version "3.0.0"
resolved "https://registry.yarnpkg.com/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz#2cb1a8cf0db52414776e5b2a7a04d5dd98158de9" resolved "https://registry.yarnpkg.com/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz#2cb1a8cf0db52414776e5b2a7a04d5dd98158de9"
@@ -7130,6 +7143,16 @@ html-minifier-terser@^6.0.2:
relateurl "^0.2.7" relateurl "^0.2.7"
terser "^5.10.0" terser "^5.10.0"
html-react-parser@^5.2.2:
version "5.2.2"
resolved "https://registry.yarnpkg.com/html-react-parser/-/html-react-parser-5.2.2.tgz#a4159c14f08c06280ff793ce35f872c2283227cb"
integrity sha512-yA5012CJGSFWYZsgYzfr6HXJgDap38/AEP4ra8Cw+WHIi2ZRDXRX/QVYdumRf1P8zKyScKd6YOrWYvVEiPfGKg==
dependencies:
domhandler "5.0.3"
html-dom-parser "5.0.13"
react-property "2.0.2"
style-to-js "1.1.16"
html-tags@^3.3.1: html-tags@^3.3.1:
version "3.3.1" version "3.3.1"
resolved "https://registry.yarnpkg.com/html-tags/-/html-tags-3.3.1.tgz#a04026a18c882e4bba8a01a3d39cfe465d40b5ce" resolved "https://registry.yarnpkg.com/html-tags/-/html-tags-3.3.1.tgz#a04026a18c882e4bba8a01a3d39cfe465d40b5ce"
@@ -7146,6 +7169,16 @@ html-webpack-plugin@^5.5.3:
pretty-error "^4.0.0" pretty-error "^4.0.0"
tapable "^2.0.0" tapable "^2.0.0"
htmlparser2@10.0.0:
version "10.0.0"
resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-10.0.0.tgz#77ad249037b66bf8cc99c6e286ef73b83aeb621d"
integrity sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==
dependencies:
domelementtype "^2.3.0"
domhandler "^5.0.3"
domutils "^3.2.1"
entities "^6.0.0"
htmlparser2@^6.1.0: htmlparser2@^6.1.0:
version "6.1.0" version "6.1.0"
resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-6.1.0.tgz#c4d762b6c3371a05dbe65e94ae43a9f845fb8fb7" resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-6.1.0.tgz#c4d762b6c3371a05dbe65e94ae43a9f845fb8fb7"
@@ -7360,6 +7393,11 @@ ini@^4.1.3:
resolved "https://registry.yarnpkg.com/ini/-/ini-4.1.3.tgz#4c359675a6071a46985eb39b14e4a2c0ec98a795" resolved "https://registry.yarnpkg.com/ini/-/ini-4.1.3.tgz#4c359675a6071a46985eb39b14e4a2c0ec98a795"
integrity sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg== integrity sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==
inline-style-parser@0.2.4:
version "0.2.4"
resolved "https://registry.yarnpkg.com/inline-style-parser/-/inline-style-parser-0.2.4.tgz#f4af5fe72e612839fcd453d989a586566d695f22"
integrity sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==
internal-slot@^1.1.0: internal-slot@^1.1.0:
version "1.1.0" version "1.1.0"
resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.1.0.tgz#1eac91762947d2f7056bc838d93e13b2e9604961" resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.1.0.tgz#1eac91762947d2f7056bc838d93e13b2e9604961"
@@ -8510,11 +8548,6 @@ lines-and-columns@^1.1.6:
resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632" resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632"
integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg== integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==
linkify-element@4.2.0:
version "4.2.0"
resolved "https://registry.yarnpkg.com/linkify-element/-/linkify-element-4.2.0.tgz#fb5c6d47576487a463fd22a0cc889e15833aa943"
integrity sha512-LahyRMhXAgWTP9TOid7pTv8UUZFDz+saLkIVAoGNmOvISt+uSeBzdGhk3dsvkdzAh1QMhkO+fVJjmkMEITre5g==
linkify-it@^4.0.1: linkify-it@^4.0.1:
version "4.0.1" version "4.0.1"
resolved "https://registry.yarnpkg.com/linkify-it/-/linkify-it-4.0.1.tgz#01f1d5e508190d06669982ba31a7d9f56a5751ec" resolved "https://registry.yarnpkg.com/linkify-it/-/linkify-it-4.0.1.tgz#01f1d5e508190d06669982ba31a7d9f56a5751ec"
@@ -10567,6 +10600,11 @@ react-lifecycles-compat@^3.0.4:
resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362" resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362"
integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA== integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==
react-property@2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/react-property/-/react-property-2.0.2.tgz#d5ac9e244cef564880a610bc8d868bd6f60fdda6"
integrity sha512-+PbtI3VuDV0l6CleQMsx2gtK0JZbZKbpdu5ynr+lbsuvtmgbNcS3VM0tuY2QjFNOcWxvXeHjDpy42RO+4U2rug==
react-redux@^7.2.0: react-redux@^7.2.0:
version "7.2.9" version "7.2.9"
resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-7.2.9.tgz#09488fbb9416a4efe3735b7235055442b042481d" resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-7.2.9.tgz#09488fbb9416a4efe3735b7235055442b042481d"
@@ -10598,6 +10636,11 @@ react-remove-scroll@2.6.0:
use-callback-ref "^1.3.0" use-callback-ref "^1.3.0"
use-sidecar "^1.1.2" use-sidecar "^1.1.2"
react-string-replace@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/react-string-replace/-/react-string-replace-1.1.1.tgz#8413a598c60e397fe77df3464f2889f00ba25989"
integrity sha512-26TUbLzLfHQ5jO5N7y3Mx88eeKo0Ml0UjCQuX4BMfOd/JX+enQqlKpL1CZnmjeBRvQE8TR+ds9j1rqx9CxhKHQ==
react-style-singleton@^2.2.1: react-style-singleton@^2.2.1:
version "2.2.1" version "2.2.1"
resolved "https://registry.yarnpkg.com/react-style-singleton/-/react-style-singleton-2.2.1.tgz#f99e420492b2d8f34d38308ff660b60d0b1205b4" resolved "https://registry.yarnpkg.com/react-style-singleton/-/react-style-singleton-2.2.1.tgz#f99e420492b2d8f34d38308ff660b60d0b1205b4"
@@ -11702,6 +11745,20 @@ strip-json-comments@^3.1.1:
resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006"
integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==
style-to-js@1.1.16:
version "1.1.16"
resolved "https://registry.yarnpkg.com/style-to-js/-/style-to-js-1.1.16.tgz#e6bd6cd29e250bcf8fa5e6591d07ced7575dbe7a"
integrity sha512-/Q6ld50hKYPH3d/r6nr117TZkHR0w0kGGIVfpG9N6D8NymRPM9RqCUv4pRpJ62E5DqOYx2AFpbZMyCPnjQCnOw==
dependencies:
style-to-object "1.0.8"
style-to-object@1.0.8:
version "1.0.8"
resolved "https://registry.yarnpkg.com/style-to-object/-/style-to-object-1.0.8.tgz#67a29bca47eaa587db18118d68f9d95955e81292"
integrity sha512-xT47I/Eo0rwJmaXC4oilDGDWLohVhR6o/xAQcPQN8q6QBuZVL8qMYL85kLmST5cPjAorwvqIA4qXTRQoYHaL6g==
dependencies:
inline-style-parser "0.2.4"
stylehacks@^7.0.4: stylehacks@^7.0.4:
version "7.0.4" version "7.0.4"
resolved "https://registry.yarnpkg.com/stylehacks/-/stylehacks-7.0.4.tgz#9c21f7374f4bccc0082412b859b3c89d77d3277c" resolved "https://registry.yarnpkg.com/stylehacks/-/stylehacks-7.0.4.tgz#9c21f7374f4bccc0082412b859b3c89d77d3277c"