mirror of
https://github.com/Novaloop-AG/healthchat-web.git
synced 2026-11-07 15:08:24 +00:00
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:
committed by
GitHub
parent
89e22e00fb
commit
3f47487472
+3
-1
@@ -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
@@ -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
@@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|
||||||
<span
|
<span className="mx_EventTile_spoiler_content">{this.props.children}</span>
|
||||||
className="mx_EventTile_spoiler_content"
|
|
||||||
dangerouslySetInnerHTML={{ __html: this.props.contentHtml }}
|
|
||||||
/>
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
|
|||||||
Please see LICENSE files in the repository root for full details.
|
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;
|
||||||
@@ -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 = (
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2025 New Vector Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import { type RendererMap } from "./utils.tsx";
|
||||||
|
import CodeBlock from "../components/views/messages/CodeBlock.tsx";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces `pre` elements with a CodeBlock component
|
||||||
|
*/
|
||||||
|
export const codeBlockRenderer: RendererMap = {
|
||||||
|
pre: (pre, { onHeightChanged }) => {
|
||||||
|
return <CodeBlock onHeightChanged={onHeightChanged} preNode={pre} />;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2025 New Vector Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { ambiguousLinkTooltipRenderer } from "./link-tooltip";
|
||||||
|
export { keywordPillRenderer, mentionPillRenderer } from "./pill";
|
||||||
|
export { spoilerRenderer } from "./spoiler";
|
||||||
|
export { codeBlockRenderer } from "./code-block";
|
||||||
|
export {
|
||||||
|
applyReplacerOnString,
|
||||||
|
replacerToRenderFunction,
|
||||||
|
combineRenderers,
|
||||||
|
type RendererMap,
|
||||||
|
type Replacer,
|
||||||
|
} from "./utils";
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2024-2025 New Vector Ltd.
|
||||||
|
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { domToReact } from "html-react-parser";
|
||||||
|
|
||||||
|
import LinkWithTooltip from "../components/views/elements/LinkWithTooltip";
|
||||||
|
import { getSingleTextContentNode, type RendererMap } from "./utils.tsx";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps ambiguous links in a tooltip trigger that shows the full URL.
|
||||||
|
*/
|
||||||
|
export const ambiguousLinkTooltipRenderer: RendererMap = {
|
||||||
|
a: (anchor, { isHtml }) => {
|
||||||
|
// Ambiguous URLs are only possible in HTML content
|
||||||
|
if (!isHtml) return;
|
||||||
|
|
||||||
|
const href = anchor.attribs["href"];
|
||||||
|
if (href && href !== getSingleTextContentNode(anchor)) {
|
||||||
|
let tooltip = href as string;
|
||||||
|
try {
|
||||||
|
tooltip = new URL(href, window.location.href).toString();
|
||||||
|
} catch {
|
||||||
|
// Not all hrefs will be valid URLs
|
||||||
|
}
|
||||||
|
return <LinkWithTooltip tooltip={tooltip}>{domToReact([anchor])}</LinkWithTooltip>;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2024-2025 New Vector Ltd.
|
||||||
|
Copyright 2019-2023 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { RuleId } from "matrix-js-sdk/src/matrix";
|
||||||
|
import { type Element } from "html-react-parser";
|
||||||
|
import { textContent } from "domutils";
|
||||||
|
import reactStringReplace from "react-string-replace";
|
||||||
|
import { PushProcessor } from "matrix-js-sdk/src/pushprocessor";
|
||||||
|
|
||||||
|
import { Pill, PillType } from "../components/views/elements/Pill";
|
||||||
|
import { parsePermalink } from "../utils/permalinks/Permalinks";
|
||||||
|
import { type PermalinkParts } from "../utils/permalinks/PermalinkConstructor";
|
||||||
|
import { hasParentMatching, type RendererMap, type ParentNode } from "./utils.tsx";
|
||||||
|
|
||||||
|
const AT_ROOM_REGEX = PushProcessor.getPushRuleGlobRegex("@room", true, "gmi");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A node here is an A element with a href attribute tag.
|
||||||
|
*
|
||||||
|
* It should be pillified if the permalink parser returns a result and one of the following conditions match:
|
||||||
|
* - Text content equals href. This is the case when sending a plain permalink inside a message.
|
||||||
|
* - The link is not from linkify (isHtml=true).
|
||||||
|
* Composer completions already create an A tag.
|
||||||
|
*/
|
||||||
|
const shouldBePillified = (node: Element, href: string, parts: PermalinkParts | null, isHtml: boolean): boolean => {
|
||||||
|
// permalink parser didn't return any parts
|
||||||
|
if (!parts) return false;
|
||||||
|
|
||||||
|
const text = textContent(node);
|
||||||
|
|
||||||
|
// event permalink with custom label
|
||||||
|
if (parts.eventId && href !== text) return false;
|
||||||
|
|
||||||
|
return href === text || isHtml;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isPreCode = (domNode: ParentNode | null): boolean =>
|
||||||
|
(domNode as Element)?.tagName === "PRE" || (domNode as Element)?.tagName === "CODE";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks the text that activated a push-notification mention pattern.
|
||||||
|
*/
|
||||||
|
export const mentionPillRenderer: RendererMap = {
|
||||||
|
a: (anchor, { room, shouldShowPillAvatar, isHtml }) => {
|
||||||
|
if (!room) return;
|
||||||
|
|
||||||
|
const href = anchor.attribs["href"];
|
||||||
|
if (
|
||||||
|
href &&
|
||||||
|
!hasParentMatching(anchor, isPreCode) &&
|
||||||
|
shouldBePillified(anchor, href, parsePermalink(href), isHtml)
|
||||||
|
) {
|
||||||
|
return <Pill url={href} inMessage={true} room={room} shouldShowPillAvatar={shouldShowPillAvatar} />;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
[Node.TEXT_NODE]: (text, { room, mxEvent, shouldShowPillAvatar }) => {
|
||||||
|
if (!room || !mxEvent) return;
|
||||||
|
|
||||||
|
const atRoomRule = room.client.pushProcessor.getPushRuleById(
|
||||||
|
mxEvent.getContent()["m.mentions"] !== undefined ? RuleId.IsRoomMention : RuleId.AtRoomNotification,
|
||||||
|
);
|
||||||
|
if (atRoomRule && room.client.pushProcessor.ruleMatchesEvent(atRoomRule, mxEvent)) {
|
||||||
|
const parts = reactStringReplace(text.data, AT_ROOM_REGEX, (_match, i) => (
|
||||||
|
<Pill
|
||||||
|
key={i}
|
||||||
|
type={PillType.AtRoomMention}
|
||||||
|
inMessage={true}
|
||||||
|
room={room}
|
||||||
|
shouldShowPillAvatar={shouldShowPillAvatar}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
|
||||||
|
if (parts.length <= 1) return; // no matches, skip replacing
|
||||||
|
|
||||||
|
return <>{parts}</>;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks the text that activated a push-notification keyword pattern.
|
||||||
|
*/
|
||||||
|
export const keywordPillRenderer: RendererMap = {
|
||||||
|
[Node.TEXT_NODE]: (text, { keywordRegexpPattern }) => {
|
||||||
|
if (!keywordRegexpPattern) return;
|
||||||
|
|
||||||
|
const parts = reactStringReplace(text.data, keywordRegexpPattern, (match, i) => (
|
||||||
|
<Pill key={i} text={match} type={PillType.Keyword} />
|
||||||
|
));
|
||||||
|
|
||||||
|
if (parts.length <= 1) return; // no matches, skip replacing
|
||||||
|
|
||||||
|
return <>{parts}</>;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2025 New Vector Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { domToReact, type DOMNode } from "html-react-parser";
|
||||||
|
|
||||||
|
import { type RendererMap } from "./utils.tsx";
|
||||||
|
import Spoiler from "../components/views/elements/Spoiler.tsx";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces spans with `data-mx-spoiler` with a Spoiler component.
|
||||||
|
*/
|
||||||
|
export const spoilerRenderer: RendererMap = {
|
||||||
|
span: (span) => {
|
||||||
|
const reason = span.attribs["data-mx-spoiler"];
|
||||||
|
if (typeof reason === "string") {
|
||||||
|
return <Spoiler reason={reason}>{domToReact(span.children as DOMNode[])}</Spoiler>;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2025 New Vector Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { type JSX } from "react";
|
||||||
|
import { type DOMNode, Element, type HTMLReactParserOptions, Text } from "html-react-parser";
|
||||||
|
import { type MatrixEvent, type Room } from "matrix-js-sdk/src/matrix";
|
||||||
|
import { type Opts } from "linkifyjs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The type of a parent node of an element, normally exported by domhandler but that is not a direct dependency of ours
|
||||||
|
*/
|
||||||
|
export type ParentNode = NonNullable<Element["parentNode"]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the text content of a node if it is the only child and that child is a text node
|
||||||
|
* @param node - the node to check
|
||||||
|
*/
|
||||||
|
export const getSingleTextContentNode = (node: Element): string | null => {
|
||||||
|
if (node.childNodes.length === 1 && node.childNodes[0].type === "text") {
|
||||||
|
return node.childNodes[0].data;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the node has a parent that matches the given matcher
|
||||||
|
* @param node - the node to check
|
||||||
|
* @param matcher - a function that returns true if the node matches
|
||||||
|
*/
|
||||||
|
export const hasParentMatching = (node: Element, matcher: (node: ParentNode | null) => boolean): boolean => {
|
||||||
|
let parent = node.parentNode;
|
||||||
|
while (parent) {
|
||||||
|
if (matcher(parent)) return true;
|
||||||
|
parent = parent.parentNode;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A replacer function that can be used with html-react-parser
|
||||||
|
*/
|
||||||
|
export type Replacer = HTMLReactParserOptions["replace"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Passes through any non-string inputs verbatim, as such they should only be used for emoji bodies
|
||||||
|
*/
|
||||||
|
export function applyReplacerOnString(
|
||||||
|
input: string | JSX.Element[],
|
||||||
|
replacer: Replacer,
|
||||||
|
): JSX.Element | JSX.Element[] | string {
|
||||||
|
if (!replacer) return input;
|
||||||
|
|
||||||
|
const arr = Array.isArray(input) ? input : [input];
|
||||||
|
return arr.map((input, index): JSX.Element => {
|
||||||
|
if (typeof input === "string") {
|
||||||
|
return (
|
||||||
|
<React.Fragment key={index}>{(replacer(new Text(input), 0) as JSX.Element) || input}</React.Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return input;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a Replacer function to a render function for linkify-react
|
||||||
|
* So that we can use the same replacer functions for both
|
||||||
|
* @param replacer The replacer function to convert
|
||||||
|
*/
|
||||||
|
export function replacerToRenderFunction(replacer: Replacer): Opts["render"] {
|
||||||
|
if (!replacer) return;
|
||||||
|
return ({ tagName, attributes, content }) => {
|
||||||
|
const domNode = new Element(tagName, attributes, [new Text(content)], "tag" as Element["type"]);
|
||||||
|
const result = replacer(domNode, 0);
|
||||||
|
if (result) return result;
|
||||||
|
|
||||||
|
// This is cribbed from the default render function in linkify-react
|
||||||
|
if (attributes.class) {
|
||||||
|
attributes.className = attributes.class;
|
||||||
|
delete attributes.class;
|
||||||
|
}
|
||||||
|
return React.createElement(tagName, attributes, content);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Parameters {
|
||||||
|
isHtml: boolean;
|
||||||
|
onHeightChanged?: () => void;
|
||||||
|
// Required for keywordPillRenderer
|
||||||
|
keywordRegexpPattern?: RegExp;
|
||||||
|
// Required for mentionPillRenderer
|
||||||
|
mxEvent?: MatrixEvent;
|
||||||
|
room?: Room;
|
||||||
|
shouldShowPillAvatar?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SpecialisedReplacer<T extends DOMNode> = (
|
||||||
|
node: T,
|
||||||
|
parameters: Parameters,
|
||||||
|
index: number,
|
||||||
|
) => JSX.Element | string | void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A map of replacer functions for different types of nodes/tags.
|
||||||
|
* When a function returns a JSX element, the element will be rendered in place of the node.
|
||||||
|
*/
|
||||||
|
export type RendererMap = Partial<
|
||||||
|
{
|
||||||
|
[tagName in keyof HTMLElementTagNameMap]: SpecialisedReplacer<Element>;
|
||||||
|
} & {
|
||||||
|
[Node.TEXT_NODE]: SpecialisedReplacer<Text>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
type PreparedRenderer = (parameters: Parameters) => Replacer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combines multiple renderers into a single Replacer function
|
||||||
|
* @param renderers - the list of renderers to combine
|
||||||
|
*/
|
||||||
|
export const combineRenderers =
|
||||||
|
(...renderers: RendererMap[]): PreparedRenderer =>
|
||||||
|
(parameters) =>
|
||||||
|
(node, index) => {
|
||||||
|
if (node.type === "text") {
|
||||||
|
for (const replacer of renderers) {
|
||||||
|
const result = replacer[Node.TEXT_NODE]?.(node, parameters, index);
|
||||||
|
if (result) return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (node instanceof Element) {
|
||||||
|
const tagName = node.tagName.toLowerCase() as keyof HTMLElementTagNameMap;
|
||||||
|
for (const replacer of renderers) {
|
||||||
|
const result = replacer[tagName]?.(node, parameters, index);
|
||||||
|
if (result) return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 <b>bar"`);
|
expect(html).toMatchInlineSnapshot(`"<span class="mx_EventTile_searchHighlight">test</span> foo <b>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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+159
-173
@@ -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!);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user