Switch away from nesting React trees and mangling the DOM (#29586)

* Switch away from nesting React trees and mangling the DOM

By parsing HTML events and manipulating the AST before passing it to React

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Use MatrixClientContext in Pill now that we are in the main React tree

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Add missing import

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Break import cycles

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Minimise

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Docs

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
Michael Telatynski
2025-03-26 20:25:03 +00:00
committed by GitHub
parent 89e22e00fb
commit 3f47487472
37 changed files with 1488 additions and 1134 deletions

View File

@@ -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
*/

View File

@@ -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);

View File

@@ -6,12 +6,12 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
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 &lt;b&gt;bar"`);
});
it("generates big emoji for emoji made of multiple characters", () => {
const { asFragment } = render(bodyToSpan({ body: "👨‍👩‍👧‍👦 ↔️ 🇮🇸", msgtype: "m.text" }, [], {}) as ReactElement);
expect(asFragment()).toMatchSnapshot();
});
it("should generate big emoji for an emoji-only reply to a message", () => {
const { asFragment } = render(
bodyToSpan(
{
"body": "> <@sender1:server> Test\n\n🥰",
"format": "org.matrix.custom.html",
"formatted_body":
'<mx-reply><blockquote><a href="https://matrix.to/#/!roomId:server/$eventId">In reply to</a> <a href="https://matrix.to/#/@sender1:server">@sender1:server</a><br>Test</blockquote></mx-reply>🥰',
"m.relates_to": {
"m.in_reply_to": {
event_id: "$eventId",
},
},
"msgtype": "m.text",
},
[],
{
stripReplyFallback: true,
},
) as ReactElement,
);
expect(asFragment()).toMatchSnapshot();
});
it("does not mistake characters in text presentation mode for emoji", () => {
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();
});
});

View File

@@ -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"

View File

@@ -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),

View File

@@ -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]]));
});

View File

@@ -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),
);
};

View File

@@ -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);
});

View File

@@ -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>
`;

View File

@@ -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", () => {

View File

@@ -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>
`;

View 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>
`;

View 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!);
});
});

View File

@@ -0,0 +1,228 @@
/*
Copyright 2024-2025 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { render, type RenderResult } from "jest-matrix-react";
import {
MatrixEvent,
ConditionKind,
EventType,
PushRuleActionName,
Room,
TweakName,
type MatrixClient,
} from "matrix-js-sdk/src/matrix";
import { mocked } from "jest-mock";
import parse from "html-react-parser";
import { PushProcessor } from "matrix-js-sdk/src/pushprocessor";
import { keywordPillRenderer, mentionPillRenderer, combineRenderers } from "../../../src/renderer";
import { stubClient, withClientContextRenderOptions } from "../../test-utils";
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
import DMRoomMap from "../../../src/utils/DMRoomMap";
describe("mention pills", () => {
let cli: MatrixClient;
let room: Room;
const roomId = "!room:id";
const event = new MatrixEvent({
room_id: roomId,
type: EventType.RoomMessage,
content: {
body: "@room",
},
});
beforeEach(() => {
stubClient();
cli = MatrixClientPeg.safeGet();
// @ts-expect-error
cli.pushProcessor = new PushProcessor(cli);
room = new Room(roomId, cli, cli.getUserId()!);
room.currentState.mayTriggerNotifOfType = jest.fn().mockReturnValue(true);
(cli.getRoom as jest.Mock).mockReturnValue(room);
cli.pushRules!.global = {
override: [
{
rule_id: ".m.rule.roomnotif",
default: true,
enabled: true,
conditions: [
{
kind: ConditionKind.EventMatch,
key: "content.body",
pattern: "@room",
},
],
actions: [
PushRuleActionName.Notify,
{
set_tweak: TweakName.Highlight,
value: true,
},
],
},
{
rule_id: ".m.rule.is_room_mention",
default: true,
enabled: true,
conditions: [
{
kind: ConditionKind.EventPropertyIs,
key: "content.m\\.mentions.room",
value: true,
},
{
kind: ConditionKind.SenderNotificationPermission,
key: "room",
},
],
actions: [
PushRuleActionName.Notify,
{
set_tweak: TweakName.Highlight,
},
],
},
],
};
DMRoomMap.makeShared(cli);
});
function renderPills(input: string, mxEvent?: MatrixEvent): RenderResult {
return render(
<>
{parse(input, {
replace: combineRenderers(mentionPillRenderer)({
mxEvent: mxEvent ?? event,
room,
isHtml: true,
}),
})}
</>,
withClientContextRenderOptions(cli),
);
}
it("should do nothing for empty element", () => {
const input = "<div></div>";
const { asFragment } = renderPills(input);
expect(asFragment()).toMatchSnapshot();
});
it("should pillify @room", () => {
const input = "<div>@room</div>";
const { container, asFragment } = renderPills(input);
expect(asFragment()).toMatchSnapshot();
expect(container.querySelector(".mx_Pill.mx_AtRoomPill")?.textContent).toBe("!@room");
});
it("should pillify @room in an intentional mentions world", () => {
mocked(MatrixClientPeg.safeGet().supportsIntentionalMentions).mockReturnValue(true);
const { container, asFragment } = renderPills(
"<div>@room</div>",
new MatrixEvent({
room_id: roomId,
type: EventType.RoomMessage,
content: {
"body": "@room",
"m.mentions": {
room: true,
},
},
}),
);
expect(asFragment()).toMatchSnapshot();
expect(container.querySelector(".mx_Pill.mx_AtRoomPill")?.textContent).toBe("!@room");
});
});
describe("keyword pills", () => {
let cli: MatrixClient;
const keywordRegexpPattern = /(test)/i;
beforeEach(() => {
stubClient();
cli = MatrixClientPeg.safeGet();
cli.pushRules!.global = {
override: [
{
rule_id: ".m.rule.roomnotif",
default: true,
enabled: true,
conditions: [
{
kind: ConditionKind.EventMatch,
key: "content.body",
pattern: "@room",
},
],
actions: [
PushRuleActionName.Notify,
{
set_tweak: TweakName.Highlight,
value: true,
},
],
},
{
rule_id: ".m.rule.is_room_mention",
default: true,
enabled: true,
conditions: [
{
kind: ConditionKind.EventPropertyIs,
key: "content.m\\.mentions.room",
value: true,
},
{
kind: ConditionKind.SenderNotificationPermission,
key: "room",
},
],
actions: [
PushRuleActionName.Notify,
{
set_tweak: TweakName.Highlight,
},
],
},
],
};
DMRoomMap.makeShared(cli);
});
function renderPills(input: string): RenderResult {
return render(
<>
{parse(input, {
replace: combineRenderers(keywordPillRenderer)({
isHtml: true,
keywordRegexpPattern,
}),
})}
</>,
withClientContextRenderOptions(cli),
);
}
it("should do nothing for empty element", () => {
const input = "<div></div>";
const { asFragment } = renderPills(input);
expect(asFragment()).toMatchSnapshot();
});
it("should pillify", () => {
const input = "<div>Foo TeST Bar</div>";
const { container, asFragment } = renderPills(input);
expect(asFragment()).toMatchSnapshot();
expect(container.querySelector(".mx_Pill.mx_KeywordPill")?.textContent).toBe("TeST");
});
});

View File

@@ -1,142 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { act, render } from "jest-matrix-react";
import { MatrixEvent, ConditionKind, EventType, PushRuleActionName, Room, TweakName } from "matrix-js-sdk/src/matrix";
import { mocked } from "jest-mock";
import { PushProcessor } from "matrix-js-sdk/src/pushprocessor";
import { pillifyLinks } from "../../../src/utils/pillify";
import { stubClient } from "../../test-utils";
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
import DMRoomMap from "../../../src/utils/DMRoomMap";
import { ReactRootManager } from "../../../src/utils/react.tsx";
describe("pillify", () => {
const roomId = "!room:id";
const event = new MatrixEvent({
room_id: roomId,
type: EventType.RoomMessage,
content: {
body: "@room",
},
});
beforeEach(() => {
stubClient();
const cli = MatrixClientPeg.safeGet();
const room = new Room(roomId, cli, cli.getUserId()!);
room.currentState.mayTriggerNotifOfType = jest.fn().mockReturnValue(true);
(cli.getRoom as jest.Mock).mockReturnValue(room);
cli.pushRules!.global = {
override: [
{
rule_id: ".m.rule.roomnotif",
default: true,
enabled: true,
conditions: [
{
kind: ConditionKind.EventMatch,
key: "content.body",
pattern: "@room",
},
],
actions: [
PushRuleActionName.Notify,
{
set_tweak: TweakName.Highlight,
value: true,
},
],
},
{
rule_id: ".m.rule.is_room_mention",
default: true,
enabled: true,
conditions: [
{
kind: ConditionKind.EventPropertyIs,
key: "content.m\\.mentions.room",
value: true,
},
{
kind: ConditionKind.SenderNotificationPermission,
key: "room",
},
],
actions: [
PushRuleActionName.Notify,
{
set_tweak: TweakName.Highlight,
},
],
},
],
};
// @ts-expect-error
cli.pushProcessor = new PushProcessor(cli);
DMRoomMap.makeShared(cli);
});
it("should do nothing for empty element", () => {
const { container } = render(<div />);
const originalHtml = container.outerHTML;
const containers = new ReactRootManager();
pillifyLinks(MatrixClientPeg.safeGet(), [container], event, containers);
expect(containers.elements).toHaveLength(0);
expect(container.outerHTML).toEqual(originalHtml);
});
it("should pillify @room", () => {
const { container } = render(<div>@room</div>);
const containers = new ReactRootManager();
act(() => pillifyLinks(MatrixClientPeg.safeGet(), [container], event, containers));
expect(containers.elements).toHaveLength(1);
expect(container.querySelector(".mx_Pill.mx_AtRoomPill")?.textContent).toBe("!@room");
});
it("should pillify @room in an intentional mentions world", () => {
mocked(MatrixClientPeg.safeGet().supportsIntentionalMentions).mockReturnValue(true);
const { container } = render(<div>@room</div>);
const containers = new ReactRootManager();
act(() =>
pillifyLinks(
MatrixClientPeg.safeGet(),
[container],
new MatrixEvent({
room_id: roomId,
type: EventType.RoomMessage,
content: {
"body": "@room",
"m.mentions": {
room: true,
},
},
}),
containers,
),
);
expect(containers.elements).toHaveLength(1);
expect(container.querySelector(".mx_Pill.mx_AtRoomPill")?.textContent).toBe("!@room");
});
it("should not double up pillification on repeated calls", () => {
const { container } = render(<div>@room</div>);
const containers = new ReactRootManager();
act(() => {
pillifyLinks(MatrixClientPeg.safeGet(), [container], event, containers);
pillifyLinks(MatrixClientPeg.safeGet(), [container], event, containers);
pillifyLinks(MatrixClientPeg.safeGet(), [container], event, containers);
pillifyLinks(MatrixClientPeg.safeGet(), [container], event, containers);
});
expect(containers.elements).toHaveLength(1);
expect(container.querySelector(".mx_Pill.mx_AtRoomPill")?.textContent).toBe("!@room");
});
});

View File

@@ -1,77 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { act, render } from "jest-matrix-react";
import { tooltipifyLinks } from "../../../src/utils/tooltipify";
import PlatformPeg from "../../../src/PlatformPeg";
import type BasePlatform from "../../../src/BasePlatform";
import { ReactRootManager } from "../../../src/utils/react.tsx";
describe("tooltipify", () => {
jest.spyOn(PlatformPeg, "get").mockReturnValue({ needsUrlTooltips: () => true } as unknown as BasePlatform);
it("does nothing for empty element", () => {
const { container: root } = render(<div />);
const originalHtml = root.outerHTML;
const containers = new ReactRootManager();
tooltipifyLinks([root], [], containers);
expect(containers.elements).toHaveLength(0);
expect(root.outerHTML).toEqual(originalHtml);
});
it("wraps single anchor", () => {
const { container: root } = render(
<div>
<a href="/foo">click</a>
</div>,
);
const containers = new ReactRootManager();
tooltipifyLinks([root], [], containers);
expect(containers.elements).toHaveLength(1);
const anchor = root.querySelector("a");
expect(anchor?.getAttribute("href")).toEqual("/foo");
const tooltip = anchor!.querySelector(".mx_TextWithTooltip_target");
expect(tooltip).toBeDefined();
});
it("ignores node", () => {
const { container: root } = render(
<div>
<a href="/foo">click</a>
</div>,
);
const originalHtml = root.outerHTML;
const containers = new ReactRootManager();
tooltipifyLinks([root], [root.children[0]], containers);
expect(containers.elements).toHaveLength(0);
expect(root.outerHTML).toEqual(originalHtml);
});
it("does not re-wrap if called multiple times", async () => {
const { container: root, unmount } = render(
<div>
<a href="/foo">click</a>
</div>,
);
const containers = new ReactRootManager();
tooltipifyLinks([root], [], containers);
tooltipifyLinks([root], [], containers);
tooltipifyLinks([root], [], containers);
tooltipifyLinks([root], [], containers);
expect(containers.elements).toHaveLength(1);
const anchor = root.querySelector("a");
expect(anchor?.getAttribute("href")).toEqual("/foo");
const tooltip = anchor!.querySelector(".mx_TextWithTooltip_target");
expect(tooltip).toBeDefined();
await act(async () => {
unmount();
});
});
});