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
@@ -107,6 +107,7 @@
|
||||
"css-tree": "^3.0.0",
|
||||
"diff-dom": "^5.0.0",
|
||||
"diff-match-patch": "^1.0.5",
|
||||
"domutils": "^3.2.2",
|
||||
"emojibase-regex": "15.3.2",
|
||||
"escape-html": "^1.0.3",
|
||||
"file-saver": "^2.0.5",
|
||||
@@ -115,12 +116,12 @@
|
||||
"glob-to-regexp": "^0.4.1",
|
||||
"highlight.js": "^11.3.1",
|
||||
"html-entities": "^2.0.0",
|
||||
"html-react-parser": "^5.2.2",
|
||||
"is-ip": "^3.1.0",
|
||||
"js-xxhash": "^4.0.0",
|
||||
"jsrsasign": "^11.0.0",
|
||||
"jszip": "^3.7.0",
|
||||
"katex": "^0.16.0",
|
||||
"linkify-element": "4.2.0",
|
||||
"linkify-react": "4.2.0",
|
||||
"linkify-string": "4.2.0",
|
||||
"linkifyjs": "4.2.0",
|
||||
@@ -144,6 +145,7 @@
|
||||
"react-blurhash": "^0.3.0",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-focus-lock": "^2.5.1",
|
||||
"react-string-replace": "^1.1.1",
|
||||
"react-transition-group": "^4.4.1",
|
||||
"react-virtualized": "^9.22.5",
|
||||
"rfc4648": "^1.4.0",
|
||||
|
||||
@@ -25,7 +25,7 @@ import { PERMITTED_URL_SCHEMES } from "./utils/UrlUtils";
|
||||
import { sanitizeHtmlParams, transformTags } from "./Linkify";
|
||||
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
|
||||
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 {
|
||||
strippedBody: string;
|
||||
formattedBody?: string;
|
||||
@@ -419,7 +372,11 @@ interface BodyToNodeReturn {
|
||||
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);
|
||||
|
||||
let emojiBody = false;
|
||||
|
||||
@@ -11,12 +11,7 @@ import sanitizeHtml, { type IOptions } from "sanitize-html";
|
||||
import { merge } from "lodash";
|
||||
import _Linkify from "linkify-react";
|
||||
|
||||
import {
|
||||
_linkifyElement,
|
||||
_linkifyString,
|
||||
ELEMENT_URL_PATTERN,
|
||||
options as linkifyMatrixOptions,
|
||||
} from "./linkify-matrix";
|
||||
import { _linkifyString, ELEMENT_URL_PATTERN, options as linkifyMatrixOptions } from "./linkify-matrix";
|
||||
import SettingsStore from "./settings/SettingsStore";
|
||||
import { tryTransformPermalinkToLocalHref } from "./utils/permalinks/Permalinks";
|
||||
import { mediaFromMxc } from "./customisations/Media";
|
||||
@@ -223,17 +218,6 @@ export function linkifyString(str: string, options = linkifyMatrixOptions): stri
|
||||
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.
|
||||
*
|
||||
|
||||
@@ -6,13 +6,12 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type ReactElement } from "react";
|
||||
import React, { type ReactElement, useContext } from "react";
|
||||
import classNames from "classnames";
|
||||
import { type Room, type RoomMember } from "matrix-js-sdk/src/matrix";
|
||||
import { Tooltip } from "@vector-im/compound-web";
|
||||
import { LinkIcon, UserSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import { usePermalink } from "../../../hooks/usePermalink";
|
||||
import RoomAvatar from "../avatars/RoomAvatar";
|
||||
@@ -28,14 +27,6 @@ export enum PillType {
|
||||
Keyword = "TYPE_KEYWORD", // Used to highlight keywords that triggered a notification rule
|
||||
}
|
||||
|
||||
export const pillRoomNotifPos = (text: string | null): number => {
|
||||
return text?.indexOf("@room") ?? -1;
|
||||
};
|
||||
|
||||
export const pillRoomNotifLen = (): number => {
|
||||
return "@room".length;
|
||||
};
|
||||
|
||||
const linkIcon = <LinkIcon className="mx_Pill_LinkIcon mx_BaseAvatar" />;
|
||||
|
||||
const PillRoomAvatar: React.FC<{
|
||||
@@ -89,6 +80,7 @@ export const Pill: React.FC<PillProps> = ({
|
||||
shouldShowPillAvatar = true,
|
||||
text: customPillText,
|
||||
}) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
const {
|
||||
event,
|
||||
member,
|
||||
@@ -113,7 +105,7 @@ export const Pill: React.FC<PillProps> = ({
|
||||
mx_RoomPill: type === PillType.RoomMention,
|
||||
mx_SpacePill: type === "space" || targetRoom?.isSpaceRoom(),
|
||||
mx_UserPill: type === PillType.UserMention,
|
||||
mx_UserPill_me: resourceId === MatrixClientPeg.safeGet().getUserId(),
|
||||
mx_UserPill_me: resourceId === cli.getUserId(),
|
||||
mx_EventPill: type === PillType.EventInOtherRoom || type === PillType.EventInSameRoom,
|
||||
mx_KeywordPill: type === PillType.Keyword,
|
||||
});
|
||||
@@ -160,26 +152,24 @@ export const Pill: React.FC<PillProps> = ({
|
||||
const isAnchor = !!inMessage && !!url;
|
||||
return (
|
||||
<bdi>
|
||||
<MatrixClientContext.Provider value={MatrixClientPeg.safeGet()}>
|
||||
<Tooltip
|
||||
description={resourceId ?? ""}
|
||||
open={resourceId ? undefined : false}
|
||||
placement="right"
|
||||
isTriggerInteractive={isAnchor}
|
||||
>
|
||||
{isAnchor ? (
|
||||
<a className={classes} href={url} onClick={onClick}>
|
||||
{avatar}
|
||||
<span className="mx_Pill_text">{pillText}</span>
|
||||
</a>
|
||||
) : (
|
||||
<span className={classes}>
|
||||
{avatar}
|
||||
<span className="mx_Pill_text">{pillText}</span>
|
||||
</span>
|
||||
)}
|
||||
</Tooltip>
|
||||
</MatrixClientContext.Provider>
|
||||
<Tooltip
|
||||
description={resourceId ?? ""}
|
||||
open={resourceId ? undefined : false}
|
||||
placement="right"
|
||||
isTriggerInteractive={isAnchor}
|
||||
>
|
||||
{isAnchor ? (
|
||||
<a className={classes} href={url} onClick={onClick}>
|
||||
{avatar}
|
||||
<span className="mx_Pill_text">{pillText}</span>
|
||||
</a>
|
||||
) : (
|
||||
<span className={classes}>
|
||||
{avatar}
|
||||
<span className="mx_Pill_text">{pillText}</span>
|
||||
</span>
|
||||
)}
|
||||
</Tooltip>
|
||||
</bdi>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,11 +6,11 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import React, { type ReactNode } from "react";
|
||||
|
||||
interface IProps {
|
||||
reason?: string;
|
||||
contentHtml: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
@@ -38,9 +38,6 @@ export default class Spoiler extends React.Component<IProps, IState> {
|
||||
const reason = this.props.reason ? (
|
||||
<span className="mx_EventTile_spoiler_reason">{"(" + this.props.reason + ")"}</span>
|
||||
) : null;
|
||||
// react doesn't allow appending a DOM node as child.
|
||||
// as such, we pass the this.props.contentHtml instead and then set the raw
|
||||
// HTML content. This is secure as the contents have already been parsed previously
|
||||
return (
|
||||
<button
|
||||
className={"mx_EventTile_spoiler" + (this.state.visible ? " visible" : "")}
|
||||
@@ -48,10 +45,7 @@ export default class Spoiler extends React.Component<IProps, IState> {
|
||||
>
|
||||
{reason}
|
||||
|
||||
<span
|
||||
className="mx_EventTile_spoiler_content"
|
||||
dangerouslySetInnerHTML={{ __html: this.props.contentHtml }}
|
||||
/>
|
||||
<span className="mx_EventTile_spoiler_content">{this.props.children}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,9 +5,10 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { useState } from "react";
|
||||
import React, { type JSX, useState } from "react";
|
||||
import classNames from "classnames";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
import { type DOMNode, Element as ParserElement, domToReact } from "html-react-parser";
|
||||
import { textContent, getInnerHTML } from "domutils";
|
||||
|
||||
import { useSettingValue } from "../../../hooks/useSettings.ts";
|
||||
import { CopyTextButton } from "../elements/CopyableText.tsx";
|
||||
@@ -16,7 +17,7 @@ const MAX_HIGHLIGHT_LENGTH = 4096;
|
||||
const MAX_LINES_BEFORE_COLLAPSE = 5;
|
||||
|
||||
interface Props {
|
||||
children: HTMLElement;
|
||||
preNode: ParserElement;
|
||||
onHeightChanged?(): void;
|
||||
}
|
||||
|
||||
@@ -35,14 +36,16 @@ const ExpandCollapseButton: React.FC<{
|
||||
);
|
||||
};
|
||||
|
||||
const CodeBlock: React.FC<Props> = ({ children, onHeightChanged }) => {
|
||||
const CodeBlock: React.FC<Props> = ({ preNode, onHeightChanged }) => {
|
||||
const enableSyntaxHighlightLanguageDetection = useSettingValue("enableSyntaxHighlightLanguageDetection");
|
||||
const showCodeLineNumbers = useSettingValue("showCodeLineNumbers");
|
||||
const expandCodeByDefault = useSettingValue("expandCodeByDefault");
|
||||
const [expanded, setExpanded] = useState(expandCodeByDefault);
|
||||
|
||||
const text = textContent(preNode);
|
||||
|
||||
let expandCollapseButton: JSX.Element | undefined;
|
||||
if (children.textContent && children.textContent.split("\n").length >= MAX_LINES_BEFORE_COLLAPSE) {
|
||||
if (text.split("\n").length >= MAX_LINES_BEFORE_COLLAPSE) {
|
||||
expandCollapseButton = (
|
||||
<ExpandCollapseButton
|
||||
expanded={expanded}
|
||||
@@ -55,10 +58,11 @@ const CodeBlock: React.FC<Props> = ({ children, onHeightChanged }) => {
|
||||
);
|
||||
}
|
||||
|
||||
const innerHTML = getInnerHTML(preNode);
|
||||
let lineNumbers: JSX.Element | undefined;
|
||||
if (showCodeLineNumbers) {
|
||||
// Calculate number of lines in pre
|
||||
const number = children.innerHTML.replace(/\n(<\/code>)?$/, "").split(/\n/).length;
|
||||
const number = innerHTML.replace(/\n(<\/code>)?$/, "").split(/\n/).length;
|
||||
// Iterate through lines starting with 1 (number of the first line is 1)
|
||||
lineNumbers = (
|
||||
<span className="mx_EventTile_lineNumbers">
|
||||
@@ -108,28 +112,37 @@ const CodeBlock: React.FC<Props> = ({ children, onHeightChanged }) => {
|
||||
}
|
||||
}
|
||||
|
||||
function highlightCodeRef(div: HTMLElement | null): void {
|
||||
highlightCode(div);
|
||||
}
|
||||
|
||||
let content = domToReact(preNode.children as DOMNode[]);
|
||||
|
||||
// Add code element if it's missing since we depend on it
|
||||
if (!preNode.children.some((child) => child instanceof ParserElement && child.tagName.toUpperCase() === "CODE")) {
|
||||
content = <code>{content}</code>;
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="mx_EventTile_pre_container">
|
||||
<pre
|
||||
className={classNames({
|
||||
mx_EventTile_collapsedCodeBlock: !expanded,
|
||||
})}
|
||||
>
|
||||
{lineNumbers}
|
||||
<div
|
||||
style={{ display: "contents" }}
|
||||
dangerouslySetInnerHTML={{ __html: children.innerHTML }}
|
||||
ref={highlightCode}
|
||||
/>
|
||||
<div style={{ display: "contents" }} ref={highlightCodeRef}>
|
||||
{content}
|
||||
</div>
|
||||
</pre>
|
||||
{expandCollapseButton}
|
||||
<CopyTextButton
|
||||
getTextToCopy={() => children.getElementsByTagName("code")[0]?.textContent ?? null}
|
||||
getTextToCopy={() => text}
|
||||
className={classNames("mx_EventTile_button mx_EventTile_copyButton", {
|
||||
mx_EventTile_buttonBottom: !!expandCollapseButton,
|
||||
})}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -10,11 +10,9 @@ import React, { createRef } from "react";
|
||||
import { type EventStatus, type IContent, type MatrixEvent, MatrixEventEvent, MsgType } from "matrix-js-sdk/src/matrix";
|
||||
import classNames from "classnames";
|
||||
|
||||
import * as HtmlUtils from "../../../HtmlUtils";
|
||||
import EventContentBody from "./EventContentBody.tsx";
|
||||
import { editBodyDiffToHtml } from "../../../utils/MessageDiffUtils";
|
||||
import { formatTime } from "../../../DateUtils";
|
||||
import { pillifyLinks } from "../../../utils/pillify";
|
||||
import { tooltipifyLinks } from "../../../utils/tooltipify";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import Modal from "../../../Modal";
|
||||
import RedactedBody from "./RedactedBody";
|
||||
@@ -23,7 +21,6 @@ import ConfirmAndWaitRedactDialog from "../dialogs/ConfirmAndWaitRedactDialog";
|
||||
import ViewSource from "../../structures/ViewSource";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import { ReactRootManager } from "../../../utils/react";
|
||||
|
||||
function getReplacedContent(event: MatrixEvent): IContent {
|
||||
const originalContent = event.getOriginalContent();
|
||||
@@ -48,8 +45,6 @@ export default class EditHistoryMessage extends React.PureComponent<IProps, ISta
|
||||
declare public context: React.ContextType<typeof MatrixClientContext>;
|
||||
|
||||
private content = createRef<HTMLDivElement>();
|
||||
private pills = new ReactRootManager();
|
||||
private tooltips = new ReactRootManager();
|
||||
|
||||
public constructor(props: IProps, context: React.ContextType<typeof MatrixClientContext>) {
|
||||
super(props, context);
|
||||
@@ -94,37 +89,11 @@ export default class EditHistoryMessage extends React.PureComponent<IProps, ISta
|
||||
);
|
||||
};
|
||||
|
||||
private pillifyLinks(): void {
|
||||
// not present for redacted events
|
||||
if (this.content.current) {
|
||||
pillifyLinks(this.context, this.content.current.children, this.props.mxEvent, this.pills);
|
||||
}
|
||||
}
|
||||
|
||||
private tooltipifyLinks(): void {
|
||||
// not present for redacted events
|
||||
if (this.content.current) {
|
||||
tooltipifyLinks(this.content.current.children, this.pills.elements, this.tooltips);
|
||||
}
|
||||
}
|
||||
|
||||
public componentDidMount(): void {
|
||||
this.pillifyLinks();
|
||||
this.tooltipifyLinks();
|
||||
}
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
this.pills.unmount();
|
||||
this.tooltips.unmount();
|
||||
const event = this.props.mxEvent;
|
||||
event.localRedactionEvent()?.off(MatrixEventEvent.Status, this.onAssociatedStatusChanged);
|
||||
}
|
||||
|
||||
public componentDidUpdate(): void {
|
||||
this.pillifyLinks();
|
||||
this.tooltipifyLinks();
|
||||
}
|
||||
|
||||
private renderActionBar(): React.ReactNode {
|
||||
// hide the button when already redacted
|
||||
let redactButton: JSX.Element | undefined;
|
||||
@@ -164,9 +133,19 @@ export default class EditHistoryMessage extends React.PureComponent<IProps, ISta
|
||||
if (this.props.previousEdit) {
|
||||
contentElements = editBodyDiffToHtml(getReplacedContent(this.props.previousEdit), content);
|
||||
} else {
|
||||
contentElements = HtmlUtils.bodyToSpan(content, null, {
|
||||
stripReplyFallback: true,
|
||||
});
|
||||
contentElements = (
|
||||
<EventContentBody
|
||||
as="span"
|
||||
mxEvent={mxEvent}
|
||||
content={content}
|
||||
highlights={[]}
|
||||
stripReply
|
||||
renderMentionPills
|
||||
renderCodeBlocks
|
||||
renderSpoilers
|
||||
linkify
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (mxEvent.getContent().msgtype === MsgType.Emote) {
|
||||
const name = mxEvent.sender ? mxEvent.sender.name : mxEvent.getSender();
|
||||
|
||||
210
src/components/views/messages/EventContentBody.tsx
Normal file
210
src/components/views/messages/EventContentBody.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { memo, forwardRef, useContext, useMemo } from "react";
|
||||
import { type IContent, type MatrixEvent, MsgType, PushRuleKind } from "matrix-js-sdk/src/matrix";
|
||||
import parse from "html-react-parser";
|
||||
import { PushProcessor } from "matrix-js-sdk/src/pushprocessor";
|
||||
|
||||
import { bodyToNode } from "../../../HtmlUtils.tsx";
|
||||
import { Linkify } from "../../../Linkify.tsx";
|
||||
import PlatformPeg from "../../../PlatformPeg.ts";
|
||||
import {
|
||||
applyReplacerOnString,
|
||||
combineRenderers,
|
||||
type Replacer,
|
||||
type RendererMap,
|
||||
keywordPillRenderer,
|
||||
mentionPillRenderer,
|
||||
ambiguousLinkTooltipRenderer,
|
||||
codeBlockRenderer,
|
||||
spoilerRenderer,
|
||||
replacerToRenderFunction,
|
||||
} from "../../../renderer";
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext.tsx";
|
||||
import { useSettingValue } from "../../../hooks/useSettings.ts";
|
||||
import { filterBoolean } from "../../../utils/arrays.ts";
|
||||
|
||||
/**
|
||||
* Returns a RegExp pattern for the keyword in the push rule of the given Matrix event, if any
|
||||
* @param mxEvent - the Matrix event to get the push rule keyword pattern from
|
||||
*/
|
||||
const getPushDetailsKeywordPatternRegexp = (mxEvent: MatrixEvent): RegExp | undefined => {
|
||||
const pushDetails = mxEvent.getPushDetails();
|
||||
if (
|
||||
pushDetails.rule?.enabled &&
|
||||
pushDetails.rule.kind === PushRuleKind.ContentSpecific &&
|
||||
pushDetails.rule.pattern
|
||||
) {
|
||||
return PushProcessor.getPushRuleGlobRegex(pushDetails.rule.pattern, true, "gi");
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
interface ReplacerOptions {
|
||||
/**
|
||||
* Whether to render room/user mentions as pills
|
||||
*/
|
||||
renderMentionPills?: boolean;
|
||||
/**
|
||||
* Whether to render push rule keywords as pills
|
||||
*/
|
||||
renderKeywordPills?: boolean;
|
||||
/**
|
||||
* Whether to render spoilers as hidden content revealed on click
|
||||
*/
|
||||
renderSpoilers?: boolean;
|
||||
/**
|
||||
* Whether to render code blocks as syntax highlighted code with a copy to clipboard button
|
||||
*/
|
||||
renderCodeBlocks?: boolean;
|
||||
/**
|
||||
* Whether to render tooltips for ambiguous links, only effective on platforms which specify `needsUrlTooltips` true
|
||||
*/
|
||||
renderTooltipsForAmbiguousLinks?: boolean;
|
||||
}
|
||||
|
||||
// Returns a memoized Replacer based on the input parameters
|
||||
const useReplacer = (
|
||||
content: IContent,
|
||||
mxEvent: MatrixEvent | undefined,
|
||||
onHeightChanged: (() => void) | undefined,
|
||||
options: ReplacerOptions,
|
||||
): Replacer => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
const room = cli.getRoom(mxEvent?.getRoomId()) ?? undefined;
|
||||
|
||||
const shouldShowPillAvatar = useSettingValue("Pill.shouldShowPillAvatar");
|
||||
const isHtml = content.format === "org.matrix.custom.html";
|
||||
|
||||
const replacer = useMemo(() => {
|
||||
const keywordRegexpPattern = mxEvent ? getPushDetailsKeywordPatternRegexp(mxEvent) : undefined;
|
||||
const replacers = filterBoolean<RendererMap>([
|
||||
options.renderMentionPills ? mentionPillRenderer : undefined,
|
||||
options.renderKeywordPills && keywordRegexpPattern ? keywordPillRenderer : undefined,
|
||||
options.renderTooltipsForAmbiguousLinks && PlatformPeg.get()?.needsUrlTooltips()
|
||||
? ambiguousLinkTooltipRenderer
|
||||
: undefined,
|
||||
options.renderSpoilers ? spoilerRenderer : undefined,
|
||||
options.renderCodeBlocks ? codeBlockRenderer : undefined,
|
||||
]);
|
||||
return combineRenderers(...replacers)({
|
||||
isHtml,
|
||||
mxEvent,
|
||||
room,
|
||||
shouldShowPillAvatar,
|
||||
keywordRegexpPattern,
|
||||
onHeightChanged,
|
||||
});
|
||||
}, [
|
||||
mxEvent,
|
||||
options.renderMentionPills,
|
||||
options.renderKeywordPills,
|
||||
options.renderTooltipsForAmbiguousLinks,
|
||||
options.renderSpoilers,
|
||||
options.renderCodeBlocks,
|
||||
isHtml,
|
||||
room,
|
||||
shouldShowPillAvatar,
|
||||
onHeightChanged,
|
||||
]);
|
||||
|
||||
return replacer;
|
||||
};
|
||||
|
||||
interface Props extends ReplacerOptions {
|
||||
/**
|
||||
* Whether to render the content in a div or span
|
||||
*/
|
||||
as: "span" | "div";
|
||||
/**
|
||||
* Whether to render links as clickable anchors
|
||||
*/
|
||||
linkify: boolean;
|
||||
/**
|
||||
* The Matrix event to render, required for renderMentionPills & renderKeywordPills
|
||||
*/
|
||||
mxEvent?: MatrixEvent;
|
||||
/**
|
||||
* The content to render
|
||||
*/
|
||||
content: IContent;
|
||||
/**
|
||||
* Whether to strip reply fallbacks from the content before rendering
|
||||
*/
|
||||
stripReply?: boolean;
|
||||
/**
|
||||
* Highlights to emphasise in the content
|
||||
*/
|
||||
highlights?: string[];
|
||||
/**
|
||||
* Callback for when the height of the content changes
|
||||
*/
|
||||
onHeightChanged?: () => void;
|
||||
/**
|
||||
* Whether to include the `dir="auto"` attribute on the rendered element
|
||||
*/
|
||||
includeDir?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component to render a Matrix event's content body.
|
||||
* If the content is formatted HTML then it will be sanitised before rendering.
|
||||
* A number of rendering features are supported as configured by {@link ReplacerOptions}
|
||||
* Returns a div or span depending on `as`, the `dir` on a `div` is always set to `"auto"` but set by `includeDir` otherwise.
|
||||
*/
|
||||
const EventContentBody = memo(
|
||||
forwardRef<HTMLElement, Props>(
|
||||
(
|
||||
{ as, mxEvent, stripReply, content, onHeightChanged, linkify, highlights, includeDir = true, ...options },
|
||||
ref,
|
||||
) => {
|
||||
const enableBigEmoji = useSettingValue("TextualBody.enableBigEmoji");
|
||||
|
||||
const replacer = useReplacer(content, mxEvent, onHeightChanged, options);
|
||||
const linkifyOptions = useMemo(
|
||||
() => ({
|
||||
render: replacerToRenderFunction(replacer),
|
||||
}),
|
||||
[replacer],
|
||||
);
|
||||
|
||||
const isEmote = content.msgtype === MsgType.Emote;
|
||||
|
||||
const { strippedBody, formattedBody, emojiBodyElements, className } = useMemo(
|
||||
() =>
|
||||
bodyToNode(content, highlights, {
|
||||
disableBigEmoji: isEmote || !enableBigEmoji,
|
||||
// Part of Replies fallback support
|
||||
stripReplyFallback: stripReply,
|
||||
}),
|
||||
[content, enableBigEmoji, highlights, isEmote, stripReply],
|
||||
);
|
||||
|
||||
if (as === "div") includeDir = true; // force dir="auto" on divs
|
||||
|
||||
const As = as;
|
||||
const body = formattedBody ? (
|
||||
<As ref={ref as any} className={className} dir={includeDir ? "auto" : undefined}>
|
||||
{parse(formattedBody, {
|
||||
replace: replacer,
|
||||
})}
|
||||
</As>
|
||||
) : (
|
||||
<As ref={ref as any} className={className} dir={includeDir ? "auto" : undefined}>
|
||||
{applyReplacerOnString(emojiBodyElements || strippedBody, replacer)}
|
||||
</As>
|
||||
);
|
||||
|
||||
if (!linkify) return body;
|
||||
|
||||
return <Linkify options={linkifyOptions}>{body}</Linkify>;
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
export default EventContentBody;
|
||||
@@ -6,23 +6,18 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { createRef, type SyntheticEvent, type MouseEvent, StrictMode } from "react";
|
||||
import { MsgType, PushRuleKind } from "matrix-js-sdk/src/matrix";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
import { PushProcessor } from "matrix-js-sdk/src/pushprocessor";
|
||||
import React, { type JSX, createRef, type SyntheticEvent, type MouseEvent } from "react";
|
||||
import { MsgType } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import * as HtmlUtils from "../../../HtmlUtils";
|
||||
import EventContentBody from "./EventContentBody.tsx";
|
||||
import { formatDate } from "../../../DateUtils";
|
||||
import Modal from "../../../Modal";
|
||||
import dis from "../../../dispatcher/dispatcher";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import { pillifyLinks } from "../../../utils/pillify";
|
||||
import { tooltipifyLinks } from "../../../utils/tooltipify";
|
||||
import { IntegrationManagers } from "../../../integrations/IntegrationManagers";
|
||||
import { isPermalinkHost, tryTransformPermalinkToLocalHref } from "../../../utils/permalinks/Permalinks";
|
||||
import { Action } from "../../../dispatcher/actions";
|
||||
import Spoiler from "../elements/Spoiler";
|
||||
import QuestionDialog from "../dialogs/QuestionDialog";
|
||||
import MessageEditHistoryDialog from "../dialogs/MessageEditHistoryDialog";
|
||||
import EditMessageComposer from "../rooms/EditMessageComposer";
|
||||
@@ -34,10 +29,6 @@ import { options as linkifyOpts } from "../../../linkify-matrix";
|
||||
import { getParentEventId } from "../../../utils/Reply";
|
||||
import { EditWysiwygComposer } from "../rooms/wysiwyg_composer";
|
||||
import { type IEventTileOps } from "../rooms/EventTile";
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import CodeBlock from "./CodeBlock";
|
||||
import { Pill, PillType } from "../elements/Pill";
|
||||
import { ReactRootManager } from "../../../utils/react";
|
||||
|
||||
interface IState {
|
||||
// the URLs (if any) to be previewed with a LinkPreviewWidget inside this TextualBody.
|
||||
@@ -50,10 +41,6 @@ interface IState {
|
||||
export default class TextualBody extends React.Component<IBodyProps, IState> {
|
||||
private readonly contentRef = createRef<HTMLDivElement>();
|
||||
|
||||
private pills = new ReactRootManager();
|
||||
private tooltips = new ReactRootManager();
|
||||
private reactRoots = new ReactRootManager();
|
||||
|
||||
public static contextType = RoomContext;
|
||||
declare public context: React.ContextType<typeof RoomContext>;
|
||||
|
||||
@@ -69,74 +56,7 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
|
||||
}
|
||||
|
||||
private applyFormatting(): void {
|
||||
// Function is only called from render / componentDidMount → contentRef is set
|
||||
const content = this.contentRef.current!;
|
||||
|
||||
this.activateSpoilers([content]);
|
||||
|
||||
HtmlUtils.linkifyElement(content);
|
||||
pillifyLinks(MatrixClientPeg.safeGet(), [content], this.props.mxEvent, this.pills);
|
||||
|
||||
this.calculateUrlPreview();
|
||||
|
||||
// tooltipifyLinks AFTER calculateUrlPreview because the DOM inside the tooltip
|
||||
// container is empty before the internal component has mounted so calculateUrlPreview
|
||||
// won't find any anchors
|
||||
tooltipifyLinks([content], [...this.pills.elements, ...this.reactRoots.elements], this.tooltips);
|
||||
|
||||
if (this.props.mxEvent.getContent().format === "org.matrix.custom.html") {
|
||||
// Handle expansion and add buttons
|
||||
const pres = [...content.getElementsByTagName("pre")];
|
||||
if (pres && pres.length > 0) {
|
||||
for (let i = 0; i < pres.length; i++) {
|
||||
// If there already is a div wrapping the codeblock we want to skip this.
|
||||
// This happens after the codeblock was edited.
|
||||
if (pres[i].parentElement?.className == "mx_EventTile_pre_container") continue;
|
||||
// Add code element if it's missing since we depend on it
|
||||
if (pres[i].getElementsByTagName("code").length == 0) {
|
||||
this.addCodeElement(pres[i]);
|
||||
}
|
||||
// Wrap a div around <pre> so that the copy button can be correctly positioned
|
||||
// when the <pre> overflows and is scrolled horizontally.
|
||||
this.wrapPreInReact(pres[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Highlight notification keywords using pills
|
||||
const pushDetails = this.props.mxEvent.getPushDetails();
|
||||
if (
|
||||
pushDetails.rule?.enabled &&
|
||||
pushDetails.rule.kind === PushRuleKind.ContentSpecific &&
|
||||
pushDetails.rule.pattern
|
||||
) {
|
||||
this.pillifyNotificationKeywords(
|
||||
[content],
|
||||
PushProcessor.getPushRuleGlobRegex(pushDetails.rule.pattern, true),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private addCodeElement(pre: HTMLPreElement): void {
|
||||
const code = document.createElement("code");
|
||||
code.append(...pre.childNodes);
|
||||
pre.appendChild(code);
|
||||
}
|
||||
|
||||
private wrapPreInReact(pre: HTMLPreElement): void {
|
||||
const root = document.createElement("div");
|
||||
root.className = "mx_EventTile_pre_container";
|
||||
|
||||
// Insert containing div in place of <pre> block
|
||||
pre.replaceWith(root);
|
||||
|
||||
this.reactRoots.render(
|
||||
<StrictMode>
|
||||
<CodeBlock onHeightChanged={this.props.onHeightChanged}>{pre}</CodeBlock>
|
||||
</StrictMode>,
|
||||
root,
|
||||
pre,
|
||||
);
|
||||
}
|
||||
|
||||
public componentDidUpdate(prevProps: Readonly<IBodyProps>): void {
|
||||
@@ -150,12 +70,6 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
|
||||
}
|
||||
}
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
this.pills.unmount();
|
||||
this.tooltips.unmount();
|
||||
this.reactRoots.unmount();
|
||||
}
|
||||
|
||||
public shouldComponentUpdate(nextProps: Readonly<IBodyProps>, nextState: Readonly<IState>): boolean {
|
||||
//console.info("shouldComponentUpdate: ShowUrlPreview for %s is %s", this.props.mxEvent.getId(), this.props.showUrlPreview);
|
||||
|
||||
@@ -195,79 +109,6 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
|
||||
}
|
||||
}
|
||||
|
||||
private activateSpoilers(nodes: ArrayLike<Element>): void {
|
||||
let node = nodes[0];
|
||||
while (node) {
|
||||
if (node.tagName === "SPAN" && typeof node.getAttribute("data-mx-spoiler") === "string") {
|
||||
const spoilerContainer = document.createElement("span");
|
||||
|
||||
const reason = node.getAttribute("data-mx-spoiler") ?? undefined;
|
||||
node.removeAttribute("data-mx-spoiler"); // we don't want to recurse
|
||||
const spoiler = (
|
||||
<StrictMode>
|
||||
<TooltipProvider>
|
||||
<Spoiler reason={reason} contentHtml={node.outerHTML} />
|
||||
</TooltipProvider>
|
||||
</StrictMode>
|
||||
);
|
||||
|
||||
this.reactRoots.render(spoiler, spoilerContainer, node);
|
||||
|
||||
node.replaceWith(spoilerContainer);
|
||||
node = spoilerContainer;
|
||||
}
|
||||
|
||||
if (node.childNodes && node.childNodes.length) {
|
||||
this.activateSpoilers(node.childNodes as NodeListOf<Element>);
|
||||
}
|
||||
|
||||
node = node.nextSibling as Element;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks the text that activated a push-notification keyword pattern.
|
||||
*/
|
||||
private pillifyNotificationKeywords(nodes: ArrayLike<Element>, exp: RegExp): void {
|
||||
let node: Node | null = nodes[0];
|
||||
while (node) {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
const text = node.nodeValue;
|
||||
if (!text) {
|
||||
node = node.nextSibling;
|
||||
continue;
|
||||
}
|
||||
const match = text.match(exp);
|
||||
if (!match || match.length < 2) {
|
||||
node = node.nextSibling;
|
||||
continue;
|
||||
}
|
||||
const keywordText = match[1];
|
||||
const idx = match.index!;
|
||||
const before = text.substring(0, idx);
|
||||
const after = text.substring(idx + keywordText.length);
|
||||
|
||||
const container = document.createElement("span");
|
||||
const newContent = (
|
||||
<>
|
||||
{before}
|
||||
<TooltipProvider>
|
||||
<Pill text={keywordText} type={PillType.Keyword} />
|
||||
</TooltipProvider>
|
||||
{after}
|
||||
</>
|
||||
);
|
||||
this.reactRoots.render(newContent, container, node);
|
||||
|
||||
node.parentNode?.replaceChild(container, node);
|
||||
} else if (node.childNodes && node.childNodes.length) {
|
||||
this.pillifyNotificationKeywords(node.childNodes as NodeListOf<Element>, exp);
|
||||
}
|
||||
|
||||
node = node.nextSibling;
|
||||
}
|
||||
}
|
||||
|
||||
private findLinks(nodes: ArrayLike<Element>): string[] {
|
||||
let links: string[] = [];
|
||||
|
||||
@@ -479,18 +320,25 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
|
||||
|
||||
const willHaveWrapper =
|
||||
this.props.replacingEventId || this.props.isSeeingThroughMessageHiddenForModeration || isEmote;
|
||||
|
||||
// only strip reply if this is the original replying event, edits thereafter do not have the fallback
|
||||
const stripReply = !mxEvent.replacingEvent() && !!getParentEventId(mxEvent);
|
||||
|
||||
const htmlOpts = {
|
||||
disableBigEmoji: isEmote || !SettingsStore.getValue("TextualBody.enableBigEmoji"),
|
||||
// Part of Replies fallback support
|
||||
stripReplyFallback: stripReply,
|
||||
};
|
||||
let body = willHaveWrapper
|
||||
? HtmlUtils.bodyToSpan(content, this.props.highlights, htmlOpts, this.contentRef, false)
|
||||
: HtmlUtils.bodyToDiv(content, this.props.highlights, htmlOpts, this.contentRef);
|
||||
let body = (
|
||||
<EventContentBody
|
||||
as={willHaveWrapper ? "span" : "div"}
|
||||
includeDir={false}
|
||||
mxEvent={mxEvent}
|
||||
content={content}
|
||||
stripReply={stripReply}
|
||||
linkify
|
||||
highlights={this.props.highlights}
|
||||
onHeightChanged={this.props.onHeightChanged}
|
||||
ref={this.contentRef}
|
||||
renderKeywordPills
|
||||
renderMentionPills
|
||||
renderCodeBlocks
|
||||
renderSpoilers
|
||||
/>
|
||||
);
|
||||
|
||||
if (this.props.replacingEventId) {
|
||||
body = (
|
||||
|
||||
@@ -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 { useEffect, useState } from "react";
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
|
||||
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";
|
||||
|
||||
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.
|
||||
* 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);
|
||||
|
||||
if (targetRoomMember) return targetRoomMember;
|
||||
|
||||
const knownProfile = SdkContextClass.instance.userProfilesStore.getOnlyKnownProfile(userId);
|
||||
const knownProfile = context.userProfilesStore.getOnlyKnownProfile(userId);
|
||||
|
||||
if (knownProfile) {
|
||||
return createMemberFromProfile(userId, knownProfile);
|
||||
@@ -97,11 +97,12 @@ export const usePermalinkMember = (
|
||||
targetRoom: Room | null,
|
||||
event: MatrixEvent | null,
|
||||
): RoomMember | null => {
|
||||
const context = useContext(SDKContext);
|
||||
// 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.
|
||||
const shouldLookUpUser = type && [PillType.UserMention, PillType.EventInSameRoom].includes(type);
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -111,7 +112,7 @@ export const usePermalinkMember = (
|
||||
}
|
||||
|
||||
const doProfileLookup = async (): Promise<void> => {
|
||||
const fetchedProfile = await SdkContextClass.instance.userProfilesStore.fetchOnlyKnownProfile(userId);
|
||||
const fetchedProfile = await context.userProfilesStore.fetchOnlyKnownProfile(userId);
|
||||
|
||||
if (fetchedProfile) {
|
||||
const newMember = createMemberFromProfile(userId, fetchedProfile);
|
||||
@@ -120,7 +121,7 @@ export const usePermalinkMember = (
|
||||
};
|
||||
|
||||
doProfileLookup();
|
||||
}, [member, shouldLookUpUser, targetRoom, userId]);
|
||||
}, [context, member, shouldLookUpUser, targetRoom, userId]);
|
||||
|
||||
return member;
|
||||
};
|
||||
|
||||
@@ -9,7 +9,6 @@ Please see LICENSE files in the repository root for full details.
|
||||
|
||||
import * as linkifyjs from "linkifyjs";
|
||||
import { type EventListeners, type Opts, registerCustomProtocol, registerPlugin } from "linkifyjs";
|
||||
import linkifyElement from "linkify-element";
|
||||
import linkifyString from "linkify-string";
|
||||
import { getHttpUriForMxc, User } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
@@ -274,5 +273,4 @@ PERMITTED_URL_SCHEMES.forEach((scheme) => {
|
||||
registerCustomProtocol("mxc", false);
|
||||
|
||||
export const linkify = linkifyjs;
|
||||
export const _linkifyElement = linkifyElement;
|
||||
export const _linkifyString = linkifyString;
|
||||
|
||||
20
src/renderer/code-block.tsx
Normal file
20
src/renderer/code-block.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
|
||||
import { type RendererMap } from "./utils.tsx";
|
||||
import CodeBlock from "../components/views/messages/CodeBlock.tsx";
|
||||
|
||||
/**
|
||||
* Replaces `pre` elements with a CodeBlock component
|
||||
*/
|
||||
export const codeBlockRenderer: RendererMap = {
|
||||
pre: (pre, { onHeightChanged }) => {
|
||||
return <CodeBlock onHeightChanged={onHeightChanged} preNode={pre} />;
|
||||
},
|
||||
};
|
||||
18
src/renderer/index.ts
Normal file
18
src/renderer/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
export { ambiguousLinkTooltipRenderer } from "./link-tooltip";
|
||||
export { keywordPillRenderer, mentionPillRenderer } from "./pill";
|
||||
export { spoilerRenderer } from "./spoiler";
|
||||
export { codeBlockRenderer } from "./code-block";
|
||||
export {
|
||||
applyReplacerOnString,
|
||||
replacerToRenderFunction,
|
||||
combineRenderers,
|
||||
type RendererMap,
|
||||
type Replacer,
|
||||
} from "./utils";
|
||||
34
src/renderer/link-tooltip.tsx
Normal file
34
src/renderer/link-tooltip.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
Copyright 2024-2025 New Vector Ltd.
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { domToReact } from "html-react-parser";
|
||||
|
||||
import LinkWithTooltip from "../components/views/elements/LinkWithTooltip";
|
||||
import { getSingleTextContentNode, type RendererMap } from "./utils.tsx";
|
||||
|
||||
/**
|
||||
* Wraps ambiguous links in a tooltip trigger that shows the full URL.
|
||||
*/
|
||||
export const ambiguousLinkTooltipRenderer: RendererMap = {
|
||||
a: (anchor, { isHtml }) => {
|
||||
// Ambiguous URLs are only possible in HTML content
|
||||
if (!isHtml) return;
|
||||
|
||||
const href = anchor.attribs["href"];
|
||||
if (href && href !== getSingleTextContentNode(anchor)) {
|
||||
let tooltip = href as string;
|
||||
try {
|
||||
tooltip = new URL(href, window.location.href).toString();
|
||||
} catch {
|
||||
// Not all hrefs will be valid URLs
|
||||
}
|
||||
return <LinkWithTooltip tooltip={tooltip}>{domToReact([anchor])}</LinkWithTooltip>;
|
||||
}
|
||||
},
|
||||
};
|
||||
102
src/renderer/pill.tsx
Normal file
102
src/renderer/pill.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
Copyright 2024-2025 New Vector Ltd.
|
||||
Copyright 2019-2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { RuleId } from "matrix-js-sdk/src/matrix";
|
||||
import { type Element } from "html-react-parser";
|
||||
import { textContent } from "domutils";
|
||||
import reactStringReplace from "react-string-replace";
|
||||
import { PushProcessor } from "matrix-js-sdk/src/pushprocessor";
|
||||
|
||||
import { Pill, PillType } from "../components/views/elements/Pill";
|
||||
import { parsePermalink } from "../utils/permalinks/Permalinks";
|
||||
import { type PermalinkParts } from "../utils/permalinks/PermalinkConstructor";
|
||||
import { hasParentMatching, type RendererMap, type ParentNode } from "./utils.tsx";
|
||||
|
||||
const AT_ROOM_REGEX = PushProcessor.getPushRuleGlobRegex("@room", true, "gmi");
|
||||
|
||||
/**
|
||||
* A node here is an A element with a href attribute tag.
|
||||
*
|
||||
* It should be pillified if the permalink parser returns a result and one of the following conditions match:
|
||||
* - Text content equals href. This is the case when sending a plain permalink inside a message.
|
||||
* - The link is not from linkify (isHtml=true).
|
||||
* Composer completions already create an A tag.
|
||||
*/
|
||||
const shouldBePillified = (node: Element, href: string, parts: PermalinkParts | null, isHtml: boolean): boolean => {
|
||||
// permalink parser didn't return any parts
|
||||
if (!parts) return false;
|
||||
|
||||
const text = textContent(node);
|
||||
|
||||
// event permalink with custom label
|
||||
if (parts.eventId && href !== text) return false;
|
||||
|
||||
return href === text || isHtml;
|
||||
};
|
||||
|
||||
const isPreCode = (domNode: ParentNode | null): boolean =>
|
||||
(domNode as Element)?.tagName === "PRE" || (domNode as Element)?.tagName === "CODE";
|
||||
|
||||
/**
|
||||
* Marks the text that activated a push-notification mention pattern.
|
||||
*/
|
||||
export const mentionPillRenderer: RendererMap = {
|
||||
a: (anchor, { room, shouldShowPillAvatar, isHtml }) => {
|
||||
if (!room) return;
|
||||
|
||||
const href = anchor.attribs["href"];
|
||||
if (
|
||||
href &&
|
||||
!hasParentMatching(anchor, isPreCode) &&
|
||||
shouldBePillified(anchor, href, parsePermalink(href), isHtml)
|
||||
) {
|
||||
return <Pill url={href} inMessage={true} room={room} shouldShowPillAvatar={shouldShowPillAvatar} />;
|
||||
}
|
||||
},
|
||||
|
||||
[Node.TEXT_NODE]: (text, { room, mxEvent, shouldShowPillAvatar }) => {
|
||||
if (!room || !mxEvent) return;
|
||||
|
||||
const atRoomRule = room.client.pushProcessor.getPushRuleById(
|
||||
mxEvent.getContent()["m.mentions"] !== undefined ? RuleId.IsRoomMention : RuleId.AtRoomNotification,
|
||||
);
|
||||
if (atRoomRule && room.client.pushProcessor.ruleMatchesEvent(atRoomRule, mxEvent)) {
|
||||
const parts = reactStringReplace(text.data, AT_ROOM_REGEX, (_match, i) => (
|
||||
<Pill
|
||||
key={i}
|
||||
type={PillType.AtRoomMention}
|
||||
inMessage={true}
|
||||
room={room}
|
||||
shouldShowPillAvatar={shouldShowPillAvatar}
|
||||
/>
|
||||
));
|
||||
|
||||
if (parts.length <= 1) return; // no matches, skip replacing
|
||||
|
||||
return <>{parts}</>;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Marks the text that activated a push-notification keyword pattern.
|
||||
*/
|
||||
export const keywordPillRenderer: RendererMap = {
|
||||
[Node.TEXT_NODE]: (text, { keywordRegexpPattern }) => {
|
||||
if (!keywordRegexpPattern) return;
|
||||
|
||||
const parts = reactStringReplace(text.data, keywordRegexpPattern, (match, i) => (
|
||||
<Pill key={i} text={match} type={PillType.Keyword} />
|
||||
));
|
||||
|
||||
if (parts.length <= 1) return; // no matches, skip replacing
|
||||
|
||||
return <>{parts}</>;
|
||||
},
|
||||
};
|
||||
24
src/renderer/spoiler.tsx
Normal file
24
src/renderer/spoiler.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { domToReact, type DOMNode } from "html-react-parser";
|
||||
|
||||
import { type RendererMap } from "./utils.tsx";
|
||||
import Spoiler from "../components/views/elements/Spoiler.tsx";
|
||||
|
||||
/**
|
||||
* Replaces spans with `data-mx-spoiler` with a Spoiler component.
|
||||
*/
|
||||
export const spoilerRenderer: RendererMap = {
|
||||
span: (span) => {
|
||||
const reason = span.attribs["data-mx-spoiler"];
|
||||
if (typeof reason === "string") {
|
||||
return <Spoiler reason={reason}>{domToReact(span.children as DOMNode[])}</Spoiler>;
|
||||
}
|
||||
},
|
||||
};
|
||||
141
src/renderer/utils.tsx
Normal file
141
src/renderer/utils.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type JSX } from "react";
|
||||
import { type DOMNode, Element, type HTMLReactParserOptions, Text } from "html-react-parser";
|
||||
import { type MatrixEvent, type Room } from "matrix-js-sdk/src/matrix";
|
||||
import { type Opts } from "linkifyjs";
|
||||
|
||||
/**
|
||||
* The type of a parent node of an element, normally exported by domhandler but that is not a direct dependency of ours
|
||||
*/
|
||||
export type ParentNode = NonNullable<Element["parentNode"]>;
|
||||
|
||||
/**
|
||||
* Returns the text content of a node if it is the only child and that child is a text node
|
||||
* @param node - the node to check
|
||||
*/
|
||||
export const getSingleTextContentNode = (node: Element): string | null => {
|
||||
if (node.childNodes.length === 1 && node.childNodes[0].type === "text") {
|
||||
return node.childNodes[0].data;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns true if the node has a parent that matches the given matcher
|
||||
* @param node - the node to check
|
||||
* @param matcher - a function that returns true if the node matches
|
||||
*/
|
||||
export const hasParentMatching = (node: Element, matcher: (node: ParentNode | null) => boolean): boolean => {
|
||||
let parent = node.parentNode;
|
||||
while (parent) {
|
||||
if (matcher(parent)) return true;
|
||||
parent = parent.parentNode;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* A replacer function that can be used with html-react-parser
|
||||
*/
|
||||
export type Replacer = HTMLReactParserOptions["replace"];
|
||||
|
||||
/**
|
||||
* Passes through any non-string inputs verbatim, as such they should only be used for emoji bodies
|
||||
*/
|
||||
export function applyReplacerOnString(
|
||||
input: string | JSX.Element[],
|
||||
replacer: Replacer,
|
||||
): JSX.Element | JSX.Element[] | string {
|
||||
if (!replacer) return input;
|
||||
|
||||
const arr = Array.isArray(input) ? input : [input];
|
||||
return arr.map((input, index): JSX.Element => {
|
||||
if (typeof input === "string") {
|
||||
return (
|
||||
<React.Fragment key={index}>{(replacer(new Text(input), 0) as JSX.Element) || input}</React.Fragment>
|
||||
);
|
||||
}
|
||||
return input;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a Replacer function to a render function for linkify-react
|
||||
* So that we can use the same replacer functions for both
|
||||
* @param replacer The replacer function to convert
|
||||
*/
|
||||
export function replacerToRenderFunction(replacer: Replacer): Opts["render"] {
|
||||
if (!replacer) return;
|
||||
return ({ tagName, attributes, content }) => {
|
||||
const domNode = new Element(tagName, attributes, [new Text(content)], "tag" as Element["type"]);
|
||||
const result = replacer(domNode, 0);
|
||||
if (result) return result;
|
||||
|
||||
// This is cribbed from the default render function in linkify-react
|
||||
if (attributes.class) {
|
||||
attributes.className = attributes.class;
|
||||
delete attributes.class;
|
||||
}
|
||||
return React.createElement(tagName, attributes, content);
|
||||
};
|
||||
}
|
||||
|
||||
interface Parameters {
|
||||
isHtml: boolean;
|
||||
onHeightChanged?: () => void;
|
||||
// Required for keywordPillRenderer
|
||||
keywordRegexpPattern?: RegExp;
|
||||
// Required for mentionPillRenderer
|
||||
mxEvent?: MatrixEvent;
|
||||
room?: Room;
|
||||
shouldShowPillAvatar?: boolean;
|
||||
}
|
||||
|
||||
type SpecialisedReplacer<T extends DOMNode> = (
|
||||
node: T,
|
||||
parameters: Parameters,
|
||||
index: number,
|
||||
) => JSX.Element | string | void;
|
||||
|
||||
/**
|
||||
* A map of replacer functions for different types of nodes/tags.
|
||||
* When a function returns a JSX element, the element will be rendered in place of the node.
|
||||
*/
|
||||
export type RendererMap = Partial<
|
||||
{
|
||||
[tagName in keyof HTMLElementTagNameMap]: SpecialisedReplacer<Element>;
|
||||
} & {
|
||||
[Node.TEXT_NODE]: SpecialisedReplacer<Text>;
|
||||
}
|
||||
>;
|
||||
|
||||
type PreparedRenderer = (parameters: Parameters) => Replacer;
|
||||
|
||||
/**
|
||||
* Combines multiple renderers into a single Replacer function
|
||||
* @param renderers - the list of renderers to combine
|
||||
*/
|
||||
export const combineRenderers =
|
||||
(...renderers: RendererMap[]): PreparedRenderer =>
|
||||
(parameters) =>
|
||||
(node, index) => {
|
||||
if (node.type === "text") {
|
||||
for (const replacer of renderers) {
|
||||
const result = replacer[Node.TEXT_NODE]?.(node, parameters, index);
|
||||
if (result) return result;
|
||||
}
|
||||
}
|
||||
if (node instanceof Element) {
|
||||
const tagName = node.tagName.toLowerCase() as keyof HTMLElementTagNameMap;
|
||||
for (const replacer of renderers) {
|
||||
const result = replacer[tagName]?.(node, parameters, index);
|
||||
if (result) return result;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns basic mocked pushProcessor
|
||||
*/
|
||||
export const mockClientPushProcessor = () => ({
|
||||
pushProcessor: {
|
||||
getPushRuleById: jest.fn(),
|
||||
ruleMatchesEvent: jest.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns basic mocked client methods related to server support
|
||||
*/
|
||||
|
||||
@@ -303,6 +303,10 @@ export function createTestClient(): MatrixClient {
|
||||
getLocalAliases: jest.fn().mockReturnValue([]),
|
||||
uploadDeviceSigningKeys: jest.fn(),
|
||||
isKeyBackupKeyStored: jest.fn().mockResolvedValue(null),
|
||||
|
||||
pushProcessor: {
|
||||
getPushRuleById: jest.fn(),
|
||||
},
|
||||
} as unknown as MatrixClient;
|
||||
|
||||
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.
|
||||
*/
|
||||
|
||||
import React, { type ReactElement } from "react";
|
||||
import React from "react";
|
||||
import { mocked } from "jest-mock";
|
||||
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";
|
||||
|
||||
jest.mock("../../src/settings/SettingsStore");
|
||||
@@ -57,12 +57,8 @@ describe("topicToHtml", () => {
|
||||
});
|
||||
|
||||
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", () => {
|
||||
const html = getHtml(
|
||||
const html = bodyToHtml(
|
||||
{
|
||||
body: "test **foo** bar",
|
||||
msgtype: "m.text",
|
||||
@@ -76,7 +72,7 @@ describe("bodyToHtml", () => {
|
||||
});
|
||||
|
||||
it("should apply highlights to plaintext messages", () => {
|
||||
const html = getHtml(
|
||||
const html = bodyToHtml(
|
||||
{
|
||||
body: "test foo bar",
|
||||
msgtype: "m.text",
|
||||
@@ -88,7 +84,7 @@ describe("bodyToHtml", () => {
|
||||
});
|
||||
|
||||
it("should not respect HTML tags in plaintext message highlighting", () => {
|
||||
const html = getHtml(
|
||||
const html = bodyToHtml(
|
||||
{
|
||||
body: "test foo <b>bar",
|
||||
msgtype: "m.text",
|
||||
@@ -99,39 +95,12 @@ describe("bodyToHtml", () => {
|
||||
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", () => {
|
||||
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();
|
||||
});
|
||||
@@ -142,42 +111,54 @@ describe("bodyToHtml", () => {
|
||||
});
|
||||
|
||||
it("should render inline katex", () => {
|
||||
const html = getHtml({
|
||||
body: "hello \\xi world",
|
||||
msgtype: "m.text",
|
||||
formatted_body: 'hello <span data-mx-maths="\\xi"><code>\\xi</code></span> world',
|
||||
format: "org.matrix.custom.html",
|
||||
});
|
||||
const html = bodyToHtml(
|
||||
{
|
||||
body: "hello \\xi world",
|
||||
msgtype: "m.text",
|
||||
formatted_body: 'hello <span data-mx-maths="\\xi"><code>\\xi</code></span> world',
|
||||
format: "org.matrix.custom.html",
|
||||
},
|
||||
[],
|
||||
);
|
||||
expect(html).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should render block katex", () => {
|
||||
const html = getHtml({
|
||||
body: "hello \\xi world",
|
||||
msgtype: "m.text",
|
||||
formatted_body: '<p>hello</p><div data-mx-maths="\\xi"><code>\\xi</code></div><p>world</p>',
|
||||
format: "org.matrix.custom.html",
|
||||
});
|
||||
const html = bodyToHtml(
|
||||
{
|
||||
body: "hello \\xi world",
|
||||
msgtype: "m.text",
|
||||
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();
|
||||
});
|
||||
|
||||
it("should not mangle code blocks", () => {
|
||||
const html = getHtml({
|
||||
body: "hello \\xi world",
|
||||
msgtype: "m.text",
|
||||
formatted_body: "<p>hello</p><pre><code>$\\xi$</code></pre><p>world</p>",
|
||||
format: "org.matrix.custom.html",
|
||||
});
|
||||
const html = bodyToHtml(
|
||||
{
|
||||
body: "hello \\xi world",
|
||||
msgtype: "m.text",
|
||||
formatted_body: "<p>hello</p><pre><code>$\\xi$</code></pre><p>world</p>",
|
||||
format: "org.matrix.custom.html",
|
||||
},
|
||||
[],
|
||||
);
|
||||
expect(html).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should not mangle divs", () => {
|
||||
const html = getHtml({
|
||||
body: "hello world",
|
||||
msgtype: "m.text",
|
||||
formatted_body: "<p>hello</p><div>world</div>",
|
||||
format: "org.matrix.custom.html",
|
||||
});
|
||||
const html = bodyToHtml(
|
||||
{
|
||||
body: "hello world",
|
||||
msgtype: "m.text",
|
||||
formatted_body: "<p>hello</p><div>world</div>",
|
||||
format: "org.matrix.custom.html",
|
||||
},
|
||||
[],
|
||||
);
|
||||
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 generates big emoji for emoji made of multiple characters 1`] = `
|
||||
exports[`bodyToNode generates big emoji for emoji made of multiple characters 1`] = `
|
||||
<DocumentFragment>
|
||||
<span
|
||||
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>
|
||||
`;
|
||||
|
||||
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>
|
||||
<span
|
||||
class="mx_EventTile_body mx_EventTile_bigEmoji translate"
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
mockClientMethodsCrypto,
|
||||
mockClientMethodsEvents,
|
||||
mockClientMethodsUser,
|
||||
mockClientPushProcessor,
|
||||
} from "../../../test-utils";
|
||||
import type ResizeNotifier from "../../../../src/utils/ResizeNotifier";
|
||||
import { type IRoomState } from "../../../../src/components/structures/RoomView";
|
||||
@@ -45,6 +46,7 @@ describe("MessagePanel", function () {
|
||||
...mockClientMethodsUser(userId),
|
||||
...mockClientMethodsEvents(),
|
||||
...mockClientMethodsCrypto(),
|
||||
...mockClientPushProcessor(),
|
||||
getAccountData: jest.fn(),
|
||||
isUserIgnored: 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 { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
|
||||
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 { createMessageEventContent } from "../../../test-utils/events";
|
||||
import SettingsStore from "../../../../src/settings/SettingsStore";
|
||||
@@ -206,6 +213,7 @@ describe("TimelinePanel", () => {
|
||||
manageReadReceipts={true}
|
||||
ref={(ref) => (timelinePanel = ref)}
|
||||
/>,
|
||||
withClientContextRenderOptions(MatrixClientPeg.safeGet()),
|
||||
);
|
||||
await flushPromises();
|
||||
await waitFor(() => expect(timelinePanel).toBeTruthy());
|
||||
@@ -403,7 +411,10 @@ describe("TimelinePanel", () => {
|
||||
setupPagination(client, timeline, eventsPage1, null);
|
||||
|
||||
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]]));
|
||||
|
||||
@@ -420,7 +431,10 @@ describe("TimelinePanel", () => {
|
||||
const [, room, events] = setupTestData();
|
||||
|
||||
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]]));
|
||||
|
||||
@@ -560,6 +574,7 @@ describe("TimelinePanel", () => {
|
||||
overlayTimelineSet={overlayTimelineSet}
|
||||
overlayTimelineSetFilter={isCallEvent}
|
||||
/>,
|
||||
withClientContextRenderOptions(MatrixClientPeg.safeGet()),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expectEvents(container, [
|
||||
@@ -599,6 +614,7 @@ describe("TimelinePanel", () => {
|
||||
|
||||
const { container } = render(
|
||||
<TimelinePanel {...getProps(room, events)} overlayTimelineSet={overlayTimelineSet} />,
|
||||
withClientContextRenderOptions(MatrixClientPeg.safeGet()),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
@@ -630,6 +646,7 @@ describe("TimelinePanel", () => {
|
||||
|
||||
const { container } = render(
|
||||
<TimelinePanel {...getProps(room, events)} overlayTimelineSet={overlayTimelineSet} />,
|
||||
withClientContextRenderOptions(MatrixClientPeg.safeGet()),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
@@ -661,6 +678,7 @@ describe("TimelinePanel", () => {
|
||||
|
||||
const { container } = render(
|
||||
<TimelinePanel {...getProps(room, events)} overlayTimelineSet={overlayTimelineSet} />,
|
||||
withClientContextRenderOptions(MatrixClientPeg.safeGet()),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
@@ -695,6 +713,7 @@ describe("TimelinePanel", () => {
|
||||
timelineSet={timelineSet}
|
||||
overlayTimelineSet={overlayTimelineSet}
|
||||
/>,
|
||||
withClientContextRenderOptions(MatrixClientPeg.safeGet()),
|
||||
);
|
||||
|
||||
await waitFor(() => expectEvents(container, [overlayEvents[0], events[0]]));
|
||||
@@ -768,6 +787,7 @@ describe("TimelinePanel", () => {
|
||||
await withScrollPanelMountSpy(async (mountSpy) => {
|
||||
const { container } = render(
|
||||
<TimelinePanel {...getProps(room, events)} overlayTimelineSet={overlayTimelineSet} />,
|
||||
withClientContextRenderOptions(MatrixClientPeg.safeGet()),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
@@ -1027,7 +1047,10 @@ describe("TimelinePanel", () => {
|
||||
room.getTimelineSets = jest.fn().mockReturnValue([timelineSet]);
|
||||
|
||||
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]]));
|
||||
});
|
||||
|
||||
@@ -21,11 +21,14 @@ import {
|
||||
mkRoomCanonicalAliasEvent,
|
||||
mkRoomMemberJoinEvent,
|
||||
stubClient,
|
||||
withClientContextRenderOptions,
|
||||
} from "../../../../test-utils";
|
||||
import DMRoomMap from "../../../../../src/utils/DMRoomMap";
|
||||
import { Action } from "../../../../../src/dispatcher/actions";
|
||||
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>", () => {
|
||||
let client: Mocked<MatrixClient>;
|
||||
@@ -45,6 +48,10 @@ describe("<Pill>", () => {
|
||||
let pillParentClickHandler: (e: ButtonEvent) => void;
|
||||
|
||||
const renderPill = (props: PillProps): void => {
|
||||
const cli = MatrixClientPeg.safeGet();
|
||||
const mockSdkContext = new TestSdkContext();
|
||||
mockSdkContext.client = cli;
|
||||
|
||||
const withDefault = {
|
||||
inMessage: true,
|
||||
shouldShowPillAvatar: true,
|
||||
@@ -53,9 +60,12 @@ describe("<Pill>", () => {
|
||||
// wrap Pill with a div to allow testing of event bubbling
|
||||
renderResult = render(
|
||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events
|
||||
<div onClick={pillParentClickHandler}>
|
||||
<Pill {...withDefault} />
|
||||
</div>,
|
||||
<SDKContext.Provider value={mockSdkContext}>
|
||||
<div onClick={pillParentClickHandler}>
|
||||
<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.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { type MatrixClient, type MatrixEvent, PushRuleKind } from "matrix-js-sdk/src/matrix";
|
||||
import React, { type ComponentProps } from "react";
|
||||
import { type MatrixClient, type MatrixEvent, PushRuleKind, type Room } from "matrix-js-sdk/src/matrix";
|
||||
import { mocked, type MockedObject } from "jest-mock";
|
||||
import { render, waitFor } from "jest-matrix-react";
|
||||
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 * as languageHandler from "../../../../../src/languageHandler";
|
||||
import DMRoomMap from "../../../../../src/utils/DMRoomMap";
|
||||
@@ -55,8 +61,8 @@ describe("<TextualBody />", () => {
|
||||
jest.spyOn(global.Math, "random").mockRestore();
|
||||
});
|
||||
|
||||
const defaultRoom = mkStubRoom(room1Id, "test room", undefined);
|
||||
const otherRoom = mkStubRoom(room2Id, room2Name, undefined);
|
||||
let defaultRoom: Room;
|
||||
let otherRoom: Room;
|
||||
let defaultMatrixClient: MockedObject<MatrixClient>;
|
||||
|
||||
const defaultEvent = mkEvent({
|
||||
@@ -70,6 +76,15 @@ describe("<TextualBody />", () => {
|
||||
event: true,
|
||||
});
|
||||
|
||||
const defaultProps: ComponentProps<typeof TextualBody> = {
|
||||
mxEvent: defaultEvent,
|
||||
highlights: [] as string[],
|
||||
highlightLink: "",
|
||||
onMessageAllowed: jest.fn(),
|
||||
onHeightChanged: jest.fn(),
|
||||
mediaEventHelper: {} as MediaEventHelper,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
defaultMatrixClient = getMockClientWithEventEmitter({
|
||||
getRoom: (roomId: string | undefined) => {
|
||||
@@ -89,6 +104,10 @@ describe("<TextualBody />", () => {
|
||||
// @ts-expect-error
|
||||
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) => {
|
||||
if (eventId === defaultEvent.getId()) return defaultEvent;
|
||||
return undefined;
|
||||
@@ -96,16 +115,6 @@ describe("<TextualBody />", () => {
|
||||
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) =>
|
||||
(renderingFn ?? render)(
|
||||
<MatrixClientContext.Provider value={matrixClient}>
|
||||
@@ -180,7 +189,7 @@ describe("<TextualBody />", () => {
|
||||
const { container } = getComponent({ mxEvent: ev });
|
||||
const content = container.querySelector(".mx_EventTile_body");
|
||||
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 content = container.querySelector(".mx_EventTile_body");
|
||||
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 content = container.querySelector(".mx_EventTile_body");
|
||||
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 content = container.querySelector(".mx_EventTile_body");
|
||||
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 content = container.querySelector(".mx_EventTile_body");
|
||||
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;
|
||||
beforeEach(() => {
|
||||
matrixClient = getMockClientWithEventEmitter({
|
||||
getRoom: () => mkStubRoom(room1Id, "room name", undefined),
|
||||
getRoom: jest.fn(),
|
||||
...mockClientPushProcessor(),
|
||||
getAccountData: (): MatrixEvent | undefined => undefined,
|
||||
getUserId: () => "@me:my_server",
|
||||
getHomeserverUrl: () => "https://my_server/",
|
||||
@@ -263,6 +273,7 @@ describe("<TextualBody />", () => {
|
||||
isGuest: () => false,
|
||||
mxcUrlToHttp: (s: string) => s,
|
||||
});
|
||||
mocked(matrixClient.getRoom).mockReturnValue(mkStubRoom(room1Id, "room name", matrixClient));
|
||||
DMRoomMap.makeShared(defaultMatrixClient);
|
||||
});
|
||||
|
||||
@@ -401,12 +412,15 @@ describe("<TextualBody />", () => {
|
||||
beforeEach(() => {
|
||||
languageHandler.setMissingEntryGenerator((key) => key.split("|", 2)[1]);
|
||||
matrixClient = getMockClientWithEventEmitter({
|
||||
getRoom: () => mkStubRoom("room_id", "room name", undefined),
|
||||
getRoom: jest.fn(),
|
||||
getUserId: jest.fn(),
|
||||
...mockClientPushProcessor(),
|
||||
getAccountData: (): MatrixClient | undefined => undefined,
|
||||
getUrlPreview: (url: string) => new Promise(() => {}),
|
||||
isGuest: () => false,
|
||||
mxcUrlToHttp: (s: string) => s,
|
||||
});
|
||||
mocked(matrixClient.getRoom).mockReturnValue(mkStubRoom("room_id", "room name", matrixClient));
|
||||
DMRoomMap.makeShared(defaultMatrixClient);
|
||||
});
|
||||
|
||||
|
||||
@@ -77,40 +77,38 @@ exports[`<TextualBody /> renders formatted m.text correctly pills appear for an
|
||||
dir="auto"
|
||||
>
|
||||
Chat with
|
||||
<span>
|
||||
<bdi>
|
||||
<a
|
||||
class="mx_Pill mx_UserPill"
|
||||
href="https://matrix.to/#/@user:example.com"
|
||||
<bdi>
|
||||
<a
|
||||
class="mx_Pill mx_UserPill"
|
||||
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
|
||||
aria-hidden="true"
|
||||
aria-label="Profile picture"
|
||||
class="_avatar_1qbcf_8 mx_BaseAvatar"
|
||||
data-color="2"
|
||||
data-testid="avatar-img"
|
||||
<img
|
||||
alt=""
|
||||
class="_image_1qbcf_41"
|
||||
data-type="round"
|
||||
style="--cpd-avatar-size: 16px;"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
class="_image_1qbcf_41"
|
||||
data-type="round"
|
||||
height="16px"
|
||||
loading="lazy"
|
||||
referrerpolicy="no-referrer"
|
||||
src="mxc://avatar.url/image.png"
|
||||
width="16px"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
class="mx_Pill_text"
|
||||
>
|
||||
Member
|
||||
</span>
|
||||
</a>
|
||||
</bdi>
|
||||
</span>
|
||||
height="16px"
|
||||
loading="lazy"
|
||||
referrerpolicy="no-referrer"
|
||||
src="mxc://avatar.url/image.png"
|
||||
width="16px"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
class="mx_Pill_text"
|
||||
>
|
||||
Member
|
||||
</span>
|
||||
</a>
|
||||
</bdi>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -124,40 +122,38 @@ exports[`<TextualBody /> renders formatted m.text correctly pills appear for eve
|
||||
dir="auto"
|
||||
>
|
||||
See this message
|
||||
<span>
|
||||
<bdi>
|
||||
<a
|
||||
class="mx_Pill mx_EventPill"
|
||||
href="https://matrix.to/#/!ZxbRYPQXDXKGmDnJNg:example.com/$16085560162aNpaH:example.com?via=example.com"
|
||||
<bdi>
|
||||
<a
|
||||
class="mx_Pill mx_EventPill"
|
||||
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
|
||||
aria-hidden="true"
|
||||
aria-label="Avatar"
|
||||
class="_avatar_1qbcf_8 mx_BaseAvatar"
|
||||
data-color="1"
|
||||
data-testid="avatar-img"
|
||||
<img
|
||||
alt=""
|
||||
class="_image_1qbcf_41"
|
||||
data-type="round"
|
||||
style="--cpd-avatar-size: 16px;"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
class="_image_1qbcf_41"
|
||||
data-type="round"
|
||||
height="16px"
|
||||
loading="lazy"
|
||||
referrerpolicy="no-referrer"
|
||||
src="mxc://avatar.url/room.png"
|
||||
width="16px"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
class="mx_Pill_text"
|
||||
>
|
||||
Message in room name
|
||||
</span>
|
||||
</a>
|
||||
</bdi>
|
||||
</span>
|
||||
height="16px"
|
||||
loading="lazy"
|
||||
referrerpolicy="no-referrer"
|
||||
src="mxc://avatar.url/room.png"
|
||||
width="16px"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
class="mx_Pill_text"
|
||||
>
|
||||
Message in room name
|
||||
</span>
|
||||
</a>
|
||||
</bdi>
|
||||
</div>
|
||||
</div>
|
||||
</DocumentFragment>
|
||||
@@ -173,40 +169,38 @@ exports[`<TextualBody /> renders formatted m.text correctly pills appear for roo
|
||||
dir="auto"
|
||||
>
|
||||
A
|
||||
<span>
|
||||
<bdi>
|
||||
<a
|
||||
class="mx_Pill mx_RoomPill"
|
||||
href="https://matrix.to/#/!ZxbRYPQXDXKGmDnJNg:example.com?via=example.com&via=bob.com"
|
||||
<bdi>
|
||||
<a
|
||||
class="mx_Pill mx_RoomPill"
|
||||
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
|
||||
aria-hidden="true"
|
||||
aria-label="Avatar"
|
||||
class="_avatar_1qbcf_8 mx_BaseAvatar"
|
||||
data-color="1"
|
||||
data-testid="avatar-img"
|
||||
<img
|
||||
alt=""
|
||||
class="_image_1qbcf_41"
|
||||
data-type="round"
|
||||
style="--cpd-avatar-size: 16px;"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
class="_image_1qbcf_41"
|
||||
data-type="round"
|
||||
height="16px"
|
||||
loading="lazy"
|
||||
referrerpolicy="no-referrer"
|
||||
src="mxc://avatar.url/room.png"
|
||||
width="16px"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
class="mx_Pill_text"
|
||||
>
|
||||
room name
|
||||
</span>
|
||||
</a>
|
||||
</bdi>
|
||||
</span>
|
||||
height="16px"
|
||||
loading="lazy"
|
||||
referrerpolicy="no-referrer"
|
||||
src="mxc://avatar.url/room.png"
|
||||
width="16px"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
class="mx_Pill_text"
|
||||
>
|
||||
room name
|
||||
</span>
|
||||
</a>
|
||||
</bdi>
|
||||
with vias
|
||||
</div>
|
||||
</div>
|
||||
@@ -287,40 +281,38 @@ exports[`<TextualBody /> renders formatted m.text correctly pills get injected c
|
||||
dir="auto"
|
||||
>
|
||||
Hey
|
||||
<span>
|
||||
<bdi>
|
||||
<a
|
||||
class="mx_Pill mx_UserPill"
|
||||
href="https://matrix.to/#/@user:server"
|
||||
<bdi>
|
||||
<a
|
||||
class="mx_Pill mx_UserPill"
|
||||
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
|
||||
aria-hidden="true"
|
||||
aria-label="Profile picture"
|
||||
class="_avatar_1qbcf_8 mx_BaseAvatar"
|
||||
data-color="2"
|
||||
data-testid="avatar-img"
|
||||
<img
|
||||
alt=""
|
||||
class="_image_1qbcf_41"
|
||||
data-type="round"
|
||||
style="--cpd-avatar-size: 16px;"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
class="_image_1qbcf_41"
|
||||
data-type="round"
|
||||
height="16px"
|
||||
loading="lazy"
|
||||
referrerpolicy="no-referrer"
|
||||
src="mxc://avatar.url/image.png"
|
||||
width="16px"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
class="mx_Pill_text"
|
||||
>
|
||||
Member
|
||||
</span>
|
||||
</a>
|
||||
</bdi>
|
||||
</span>
|
||||
height="16px"
|
||||
loading="lazy"
|
||||
referrerpolicy="no-referrer"
|
||||
src="mxc://avatar.url/image.png"
|
||||
width="16px"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
class="mx_Pill_text"
|
||||
>
|
||||
Member
|
||||
</span>
|
||||
</a>
|
||||
</bdi>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -466,25 +458,21 @@ exports[`<TextualBody /> renders formatted m.text correctly spoilers get injecte
|
||||
dir="auto"
|
||||
>
|
||||
Hey
|
||||
<span>
|
||||
<button
|
||||
class="mx_EventTile_spoiler"
|
||||
<button
|
||||
class="mx_EventTile_spoiler"
|
||||
>
|
||||
<span
|
||||
class="mx_EventTile_spoiler_reason"
|
||||
>
|
||||
<span
|
||||
class="mx_EventTile_spoiler_reason"
|
||||
>
|
||||
(movie)
|
||||
</span>
|
||||
|
||||
<span
|
||||
class="mx_EventTile_spoiler_content"
|
||||
>
|
||||
<span>
|
||||
the movie was awesome
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</span>
|
||||
(movie)
|
||||
</span>
|
||||
|
||||
<span
|
||||
class="mx_EventTile_spoiler_content"
|
||||
>
|
||||
the movie was awesome
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -522,9 +510,9 @@ exports[`<TextualBody /> renders plain-text m.text correctly linkification get a
|
||||
</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`] = `
|
||||
<div
|
||||
@@ -532,32 +520,30 @@ exports[`<TextualBody /> renders plain-text m.text correctly should pillify a pe
|
||||
dir="auto"
|
||||
>
|
||||
Visit
|
||||
<span>
|
||||
<bdi>
|
||||
<a
|
||||
class="mx_Pill mx_EventPill"
|
||||
href="https://matrix.to/#/!room1:example.com/!abc123"
|
||||
<bdi>
|
||||
<a
|
||||
class="mx_Pill mx_EventPill"
|
||||
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
|
||||
class="mx_Pill_LinkIcon mx_BaseAvatar"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<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"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
class="mx_Pill_text"
|
||||
>
|
||||
Message
|
||||
</span>
|
||||
</a>
|
||||
</bdi>
|
||||
</span>
|
||||
<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"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
class="mx_Pill_text"
|
||||
>
|
||||
Message
|
||||
</span>
|
||||
</a>
|
||||
</bdi>
|
||||
</div>
|
||||
`;
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import React from "react";
|
||||
import { MatrixEvent, Room, EventType } from "matrix-js-sdk/src/matrix";
|
||||
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 { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
|
||||
|
||||
@@ -28,7 +28,10 @@ describe("SearchResultTile", () => {
|
||||
});
|
||||
|
||||
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", () => {
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
103
test/unit-tests/renderer/__snapshots__/pill-test.tsx.snap
Normal file
103
test/unit-tests/renderer/__snapshots__/pill-test.tsx.snap
Normal file
@@ -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>
|
||||
`;
|
||||
47
test/unit-tests/renderer/link-tooltip-test.tsx
Normal file
47
test/unit-tests/renderer/link-tooltip-test.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
Copyright 2024-2025 New Vector Ltd.
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { screen, fireEvent, render, type RenderResult } from "jest-matrix-react";
|
||||
import parse from "html-react-parser";
|
||||
|
||||
import { ambiguousLinkTooltipRenderer, combineRenderers } from "../../../src/renderer";
|
||||
import PlatformPeg from "../../../src/PlatformPeg";
|
||||
import type BasePlatform from "../../../src/BasePlatform";
|
||||
|
||||
describe("link-tooltip", () => {
|
||||
jest.spyOn(PlatformPeg, "get").mockReturnValue({ needsUrlTooltips: () => true } as unknown as BasePlatform);
|
||||
|
||||
function renderTooltips(input: string): RenderResult {
|
||||
return render(
|
||||
<>
|
||||
{parse(input, {
|
||||
replace: combineRenderers(ambiguousLinkTooltipRenderer)({ isHtml: true }),
|
||||
})}
|
||||
</>,
|
||||
);
|
||||
}
|
||||
|
||||
it("does nothing for empty element", () => {
|
||||
const { asFragment } = renderTooltips("<div></div>");
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("wraps single anchor", () => {
|
||||
const { container, asFragment } = renderTooltips(`
|
||||
<div>
|
||||
<a href="/foo">click</a>
|
||||
</div>
|
||||
`);
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
const anchor = container.querySelector("a")!;
|
||||
expect(anchor.getAttribute("href")).toEqual("/foo");
|
||||
fireEvent.focus(anchor.parentElement!);
|
||||
expect(screen.getByLabelText("http://localhost/foo")).toBe(anchor.parentElement!);
|
||||
});
|
||||
});
|
||||
228
test/unit-tests/renderer/pill-test.tsx
Normal file
228
test/unit-tests/renderer/pill-test.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
/*
|
||||
Copyright 2024-2025 New Vector Ltd.
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { render, type RenderResult } from "jest-matrix-react";
|
||||
import {
|
||||
MatrixEvent,
|
||||
ConditionKind,
|
||||
EventType,
|
||||
PushRuleActionName,
|
||||
Room,
|
||||
TweakName,
|
||||
type MatrixClient,
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
import { mocked } from "jest-mock";
|
||||
import parse from "html-react-parser";
|
||||
import { PushProcessor } from "matrix-js-sdk/src/pushprocessor";
|
||||
|
||||
import { keywordPillRenderer, mentionPillRenderer, combineRenderers } from "../../../src/renderer";
|
||||
import { stubClient, withClientContextRenderOptions } from "../../test-utils";
|
||||
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
|
||||
import DMRoomMap from "../../../src/utils/DMRoomMap";
|
||||
|
||||
describe("mention pills", () => {
|
||||
let cli: MatrixClient;
|
||||
let room: Room;
|
||||
const roomId = "!room:id";
|
||||
const event = new MatrixEvent({
|
||||
room_id: roomId,
|
||||
type: EventType.RoomMessage,
|
||||
content: {
|
||||
body: "@room",
|
||||
},
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
stubClient();
|
||||
cli = MatrixClientPeg.safeGet();
|
||||
// @ts-expect-error
|
||||
cli.pushProcessor = new PushProcessor(cli);
|
||||
room = new Room(roomId, cli, cli.getUserId()!);
|
||||
room.currentState.mayTriggerNotifOfType = jest.fn().mockReturnValue(true);
|
||||
(cli.getRoom as jest.Mock).mockReturnValue(room);
|
||||
cli.pushRules!.global = {
|
||||
override: [
|
||||
{
|
||||
rule_id: ".m.rule.roomnotif",
|
||||
default: true,
|
||||
enabled: true,
|
||||
conditions: [
|
||||
{
|
||||
kind: ConditionKind.EventMatch,
|
||||
key: "content.body",
|
||||
pattern: "@room",
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
PushRuleActionName.Notify,
|
||||
{
|
||||
set_tweak: TweakName.Highlight,
|
||||
value: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
rule_id: ".m.rule.is_room_mention",
|
||||
default: true,
|
||||
enabled: true,
|
||||
conditions: [
|
||||
{
|
||||
kind: ConditionKind.EventPropertyIs,
|
||||
key: "content.m\\.mentions.room",
|
||||
value: true,
|
||||
},
|
||||
{
|
||||
kind: ConditionKind.SenderNotificationPermission,
|
||||
key: "room",
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
PushRuleActionName.Notify,
|
||||
{
|
||||
set_tweak: TweakName.Highlight,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
DMRoomMap.makeShared(cli);
|
||||
});
|
||||
|
||||
function renderPills(input: string, mxEvent?: MatrixEvent): RenderResult {
|
||||
return render(
|
||||
<>
|
||||
{parse(input, {
|
||||
replace: combineRenderers(mentionPillRenderer)({
|
||||
mxEvent: mxEvent ?? event,
|
||||
room,
|
||||
isHtml: true,
|
||||
}),
|
||||
})}
|
||||
</>,
|
||||
withClientContextRenderOptions(cli),
|
||||
);
|
||||
}
|
||||
|
||||
it("should do nothing for empty element", () => {
|
||||
const input = "<div></div>";
|
||||
const { asFragment } = renderPills(input);
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should pillify @room", () => {
|
||||
const input = "<div>@room</div>";
|
||||
const { container, asFragment } = renderPills(input);
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
expect(container.querySelector(".mx_Pill.mx_AtRoomPill")?.textContent).toBe("!@room");
|
||||
});
|
||||
|
||||
it("should pillify @room in an intentional mentions world", () => {
|
||||
mocked(MatrixClientPeg.safeGet().supportsIntentionalMentions).mockReturnValue(true);
|
||||
const { container, asFragment } = renderPills(
|
||||
"<div>@room</div>",
|
||||
new MatrixEvent({
|
||||
room_id: roomId,
|
||||
type: EventType.RoomMessage,
|
||||
content: {
|
||||
"body": "@room",
|
||||
"m.mentions": {
|
||||
room: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
expect(container.querySelector(".mx_Pill.mx_AtRoomPill")?.textContent).toBe("!@room");
|
||||
});
|
||||
});
|
||||
|
||||
describe("keyword pills", () => {
|
||||
let cli: MatrixClient;
|
||||
const keywordRegexpPattern = /(test)/i;
|
||||
|
||||
beforeEach(() => {
|
||||
stubClient();
|
||||
cli = MatrixClientPeg.safeGet();
|
||||
cli.pushRules!.global = {
|
||||
override: [
|
||||
{
|
||||
rule_id: ".m.rule.roomnotif",
|
||||
default: true,
|
||||
enabled: true,
|
||||
conditions: [
|
||||
{
|
||||
kind: ConditionKind.EventMatch,
|
||||
key: "content.body",
|
||||
pattern: "@room",
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
PushRuleActionName.Notify,
|
||||
{
|
||||
set_tweak: TweakName.Highlight,
|
||||
value: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
rule_id: ".m.rule.is_room_mention",
|
||||
default: true,
|
||||
enabled: true,
|
||||
conditions: [
|
||||
{
|
||||
kind: ConditionKind.EventPropertyIs,
|
||||
key: "content.m\\.mentions.room",
|
||||
value: true,
|
||||
},
|
||||
{
|
||||
kind: ConditionKind.SenderNotificationPermission,
|
||||
key: "room",
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
PushRuleActionName.Notify,
|
||||
{
|
||||
set_tweak: TweakName.Highlight,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
DMRoomMap.makeShared(cli);
|
||||
});
|
||||
|
||||
function renderPills(input: string): RenderResult {
|
||||
return render(
|
||||
<>
|
||||
{parse(input, {
|
||||
replace: combineRenderers(keywordPillRenderer)({
|
||||
isHtml: true,
|
||||
keywordRegexpPattern,
|
||||
}),
|
||||
})}
|
||||
</>,
|
||||
withClientContextRenderOptions(cli),
|
||||
);
|
||||
}
|
||||
|
||||
it("should do nothing for empty element", () => {
|
||||
const input = "<div></div>";
|
||||
const { asFragment } = renderPills(input);
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should pillify", () => {
|
||||
const input = "<div>Foo TeST Bar</div>";
|
||||
const { container, asFragment } = renderPills(input);
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
expect(container.querySelector(".mx_Pill.mx_KeywordPill")?.textContent).toBe("TeST");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
83
yarn.lock
83
yarn.lock
@@ -5670,6 +5670,13 @@ domexception@^4.0.0:
|
||||
dependencies:
|
||||
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:
|
||||
version "4.3.1"
|
||||
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:
|
||||
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:
|
||||
version "2.8.0"
|
||||
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"
|
||||
domhandler "^4.2.0"
|
||||
|
||||
domutils@^3.0.1:
|
||||
domutils@^3.0.1, domutils@^3.2.1, domutils@^3.2.2:
|
||||
version "3.2.2"
|
||||
resolved "https://registry.yarnpkg.com/domutils/-/domutils-3.2.2.tgz#edbfe2b668b0c1d97c24baf0f1062b132221bc78"
|
||||
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"
|
||||
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:
|
||||
version "3.0.1"
|
||||
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"
|
||||
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:
|
||||
version "3.0.0"
|
||||
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"
|
||||
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:
|
||||
version "3.3.1"
|
||||
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"
|
||||
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:
|
||||
version "6.1.0"
|
||||
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"
|
||||
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:
|
||||
version "1.1.0"
|
||||
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"
|
||||
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:
|
||||
version "4.0.1"
|
||||
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"
|
||||
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:
|
||||
version "7.2.9"
|
||||
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-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:
|
||||
version "2.2.1"
|
||||
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"
|
||||
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:
|
||||
version "7.0.4"
|
||||
resolved "https://registry.yarnpkg.com/stylehacks/-/stylehacks-7.0.4.tgz#9c21f7374f4bccc0082412b859b3c89d77d3277c"
|
||||
|
||||
Reference in New Issue
Block a user