Skip to content
This repository was archived by the owner on Sep 11, 2024. It is now read-only.

Commit a86a8e7

Browse files
authored
Pillify permalinks to rooms and users (#10388)
1 parent d850c95 commit a86a8e7

File tree

5 files changed

+128
-105
lines changed

5 files changed

+128
-105
lines changed

src/components/views/messages/TextualBody.tsx

+1-4
Original file line numberDiff line numberDiff line change
@@ -92,11 +92,8 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
9292
const showLineNumbers = SettingsStore.getValue("showCodeLineNumbers");
9393
this.activateSpoilers([content]);
9494

95-
// pillifyLinks BEFORE linkifyElement because plain room/user URLs in the composer
96-
// are still sent as plaintext URLs. If these are ever pillified in the composer,
97-
// we should be pillify them here by doing the linkifying BEFORE the pillifying.
98-
pillifyLinks([content], this.props.mxEvent, this.pills);
9995
HtmlUtils.linkifyElement(content);
96+
pillifyLinks([content], this.props.mxEvent, this.pills);
10097

10198
this.calculateUrlPreview();
10299

src/utils/pillify.tsx

+21-3
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,25 @@ import { MatrixClientPeg } from "../MatrixClientPeg";
2323
import SettingsStore from "../settings/SettingsStore";
2424
import { Pill, PillType, pillRoomNotifLen, pillRoomNotifPos } from "../components/views/elements/Pill";
2525
import { parsePermalink } from "./permalinks/Permalinks";
26+
import { PermalinkParts } from "./permalinks/PermalinkConstructor";
27+
28+
/**
29+
* A node here is an A element with a href attribute tag.
30+
*
31+
* It should not be pillified if the permalink parser result contains an event Id.
32+
*
33+
* It should be pillified if the permalink parser returns a result and one of the following conditions match:
34+
* - Text content equals href. This is the case when sending a plain permalink inside a message.
35+
* - The link does not have the "linkified" class.
36+
* Composer completions already create an A tag.
37+
* Linkify will not linkify things again. → There won't be a "linkified" class.
38+
*/
39+
const shouldBePillified = (node: Element, href: string, parts: PermalinkParts | null): boolean => {
40+
if (!parts || parts.eventId) return false;
41+
42+
const textContent = node.textContent;
43+
return href === textContent || !node.classList.contains("linkified");
44+
};
2645

2746
/**
2847
* Recurses depth-first through a DOM tree, converting matrix.to links
@@ -51,9 +70,8 @@ export function pillifyLinks(nodes: ArrayLike<Element>, mxEvent: MatrixEvent, pi
5170
} else if (node.tagName === "A" && node.getAttribute("href")) {
5271
const href = node.getAttribute("href")!;
5372
const parts = parsePermalink(href);
54-
// If the link is a (localised) matrix.to link, replace it with a pill
55-
// We don't want to pill event permalinks, so those are ignored.
56-
if (parts && !parts.eventId) {
73+
74+
if (shouldBePillified(node, href, parts)) {
5775
const pillContainer = document.createElement("span");
5876

5977
const pill = (

test/components/views/messages/TextualBody-test.tsx

+68-98
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,17 @@ const mkRoomTextMessage = (body: string): MatrixEvent => {
3737
});
3838
};
3939

40+
const mkFormattedMessage = (body: string, formattedBody: string): MatrixEvent => {
41+
return mkMessage({
42+
msg: body,
43+
formattedMsg: formattedBody,
44+
format: "org.matrix.custom.html",
45+
room: "room_id",
46+
user: "sender",
47+
event: true,
48+
});
49+
};
50+
4051
describe("<TextualBody />", () => {
4152
afterEach(() => {
4253
jest.spyOn(MatrixClientPeg, "get").mockRestore();
@@ -156,6 +167,15 @@ describe("<TextualBody />", () => {
156167
);
157168
});
158169

170+
it("should pillify an MXID permalink", () => {
171+
const ev = mkRoomTextMessage("Chat with https://matrix.to/#/@user:example.com");
172+
const { container } = getComponent({ mxEvent: ev });
173+
const content = container.querySelector(".mx_EventTile_body");
174+
expect(content.innerHTML).toMatchInlineSnapshot(
175+
`"Chat with <span><bdi><a class="mx_Pill mx_UserPill mx_UserPill_me" href="https://matrix.to/#/@user:example.com"><img class="mx_BaseAvatar mx_BaseAvatar_image" src="mxc://avatar.url/image.png" style="width: 16px; height: 16px;" alt="" data-testid="avatar-img" aria-hidden="true"><span class="mx_Pill_linkText">Member</span></a></bdi></span>"`,
176+
);
177+
});
178+
159179
it("should not pillify room aliases", () => {
160180
const ev = mkRoomTextMessage("Visit #room:example.com");
161181
const { container } = getComponent({ mxEvent: ev });
@@ -164,6 +184,15 @@ describe("<TextualBody />", () => {
164184
`"Visit <a href="https://matrix.to/#/#room:example.com" class="linkified" rel="noreferrer noopener">#room:example.com</a>"`,
165185
);
166186
});
187+
188+
it("should pillify a room alias permalink", () => {
189+
const ev = mkRoomTextMessage("Visit https://matrix.to/#/#room:example.com");
190+
const { container } = getComponent({ mxEvent: ev });
191+
const content = container.querySelector(".mx_EventTile_body");
192+
expect(content.innerHTML).toMatchInlineSnapshot(
193+
`"Visit <span><bdi><a class="mx_Pill mx_RoomPill" href="https://matrix.to/#/#room:example.com"><span class="mx_Pill_linkText">#room:example.com</span></a></bdi></span>"`,
194+
);
195+
});
167196
});
168197

169198
describe("renders formatted m.text correctly", () => {
@@ -183,19 +212,10 @@ describe("<TextualBody />", () => {
183212
});
184213

185214
it("italics, bold, underline and strikethrough render as expected", () => {
186-
const ev = mkEvent({
187-
type: "m.room.message",
188-
room: "room_id",
189-
user: "sender",
190-
content: {
191-
body: "foo *baz* __bar__ <del>del</del> <u>u</u>",
192-
msgtype: "m.text",
193-
format: "org.matrix.custom.html",
194-
formatted_body: "foo <em>baz</em> <strong>bar</strong> <del>del</del> <u>u</u>",
195-
},
196-
event: true,
197-
});
198-
215+
const ev = mkFormattedMessage(
216+
"foo *baz* __bar__ <del>del</del> <u>u</u>",
217+
"foo <em>baz</em> <strong>bar</strong> <del>del</del> <u>u</u>",
218+
);
199219
const { container } = getComponent({ mxEvent: ev }, matrixClient);
200220
expect(container).toHaveTextContent("foo baz bar del u");
201221
const content = container.querySelector(".mx_EventTile_body");
@@ -207,19 +227,10 @@ describe("<TextualBody />", () => {
207227
});
208228

209229
it("spoilers get injected properly into the DOM", () => {
210-
const ev = mkEvent({
211-
type: "m.room.message",
212-
room: "room_id",
213-
user: "sender",
214-
content: {
215-
body: "Hey [Spoiler for movie](mxc://someserver/somefile)",
216-
msgtype: "m.text",
217-
format: "org.matrix.custom.html",
218-
formatted_body: 'Hey <span data-mx-spoiler="movie">the movie was awesome</span>',
219-
},
220-
event: true,
221-
});
222-
230+
const ev = mkFormattedMessage(
231+
"Hey [Spoiler for movie](mxc://someserver/somefile)",
232+
'Hey <span data-mx-spoiler="movie">the movie was awesome</span>',
233+
);
223234
const { container } = getComponent({ mxEvent: ev }, matrixClient);
224235
expect(container).toHaveTextContent("Hey (movie) the movie was awesome");
225236
const content = container.querySelector(".mx_EventTile_body");
@@ -234,19 +245,10 @@ describe("<TextualBody />", () => {
234245
});
235246

236247
it("linkification is not applied to code blocks", () => {
237-
const ev = mkEvent({
238-
type: "m.room.message",
239-
room: "room_id",
240-
user: "sender",
241-
content: {
242-
body: "Visit `https://matrix.org/`\n```\nhttps://matrix.org/\n```",
243-
msgtype: "m.text",
244-
format: "org.matrix.custom.html",
245-
formatted_body: "<p>Visit <code>https://matrix.org/</code></p>\n<pre>https://matrix.org/\n</pre>\n",
246-
},
247-
event: true,
248-
});
249-
248+
const ev = mkFormattedMessage(
249+
"Visit `https://matrix.org/`\n```\nhttps://matrix.org/\n```",
250+
"<p>Visit <code>https://matrix.org/</code></p>\n<pre>https://matrix.org/\n</pre>\n",
251+
);
250252
const { container } = getComponent({ mxEvent: ev }, matrixClient);
251253
expect(container).toHaveTextContent("Visit https://matrix.org/ 1https://matrix.org/");
252254
const content = container.querySelector(".mx_EventTile_body");
@@ -255,63 +257,32 @@ describe("<TextualBody />", () => {
255257

256258
// If pills were rendered within a Portal/same shadow DOM then it'd be easier to test
257259
it("pills get injected correctly into the DOM", () => {
258-
const ev = mkEvent({
259-
type: "m.room.message",
260-
room: "room_id",
261-
user: "sender",
262-
content: {
263-
body: "Hey User",
264-
msgtype: "m.text",
265-
format: "org.matrix.custom.html",
266-
formatted_body: 'Hey <a href="https://matrix.to/#/@user:server">Member</a>',
267-
},
268-
event: true,
269-
});
270-
260+
const ev = mkFormattedMessage("Hey User", 'Hey <a href="https://matrix.to/#/@user:server">Member</a>');
271261
const { container } = getComponent({ mxEvent: ev }, matrixClient);
272262
expect(container).toHaveTextContent("Hey Member");
273263
const content = container.querySelector(".mx_EventTile_body");
274264
expect(content).toMatchSnapshot();
275265
});
276266

277267
it("pills do not appear in code blocks", () => {
278-
const ev = mkEvent({
279-
type: "m.room.message",
280-
room: "room_id",
281-
user: "sender",
282-
content: {
283-
body: "`@room`\n```\n@room\n```",
284-
msgtype: "m.text",
285-
format: "org.matrix.custom.html",
286-
formatted_body: "<p><code>@room</code></p>\n<pre><code>@room\n</code></pre>\n",
287-
},
288-
event: true,
289-
});
290-
268+
const ev = mkFormattedMessage(
269+
"`@room`\n```\n@room\n```",
270+
"<p><code>@room</code></p>\n<pre><code>@room\n</code></pre>\n",
271+
);
291272
const { container } = getComponent({ mxEvent: ev });
292273
expect(container).toHaveTextContent("@room 1@room");
293274
const content = container.querySelector(".mx_EventTile_body");
294275
expect(content).toMatchSnapshot();
295276
});
296277

297278
it("pills do not appear for event permalinks", () => {
298-
const ev = mkEvent({
299-
type: "m.room.message",
300-
room: "room_id",
301-
user: "sender",
302-
content: {
303-
body:
304-
"An [event link](https://matrix.to/#/!ZxbRYPQXDXKGmDnJNg:example.com/" +
305-
"$16085560162aNpaH:example.com?via=example.com) with text",
306-
msgtype: "m.text",
307-
format: "org.matrix.custom.html",
308-
formatted_body:
309-
'An <a href="https://matrix.to/#/!ZxbRYPQXDXKGmDnJNg:example.com/' +
310-
'$16085560162aNpaH:example.com?via=example.com">event link</a> with text',
311-
},
312-
event: true,
313-
});
279+
const ev = mkFormattedMessage(
280+
"An [event link](https://matrix.to/#/!ZxbRYPQXDXKGmDnJNg:example.com/" +
281+
"$16085560162aNpaH:example.com?via=example.com) with text",
314282

283+
'An <a href="https://matrix.to/#/!ZxbRYPQXDXKGmDnJNg:example.com/' +
284+
'$16085560162aNpaH:example.com?via=example.com">event link</a> with text',
285+
);
315286
const { container } = getComponent({ mxEvent: ev }, matrixClient);
316287
expect(container).toHaveTextContent("An event link with text");
317288
const content = container.querySelector(".mx_EventTile_body");
@@ -324,23 +295,12 @@ describe("<TextualBody />", () => {
324295
});
325296

326297
it("pills appear for room links with vias", () => {
327-
const ev = mkEvent({
328-
type: "m.room.message",
329-
room: "room_id",
330-
user: "sender",
331-
content: {
332-
body:
333-
"A [room link](https://matrix.to/#/!ZxbRYPQXDXKGmDnJNg:example.com" +
334-
"?via=example.com&via=bob.com) with vias",
335-
msgtype: "m.text",
336-
format: "org.matrix.custom.html",
337-
formatted_body:
338-
'A <a href="https://matrix.to/#/!ZxbRYPQXDXKGmDnJNg:example.com' +
339-
'?via=example.com&amp;via=bob.com">room link</a> with vias',
340-
},
341-
event: true,
342-
});
343-
298+
const ev = mkFormattedMessage(
299+
"A [room link](https://matrix.to/#/!ZxbRYPQXDXKGmDnJNg:example.com" +
300+
"?via=example.com&via=bob.com) with vias",
301+
'A <a href="https://matrix.to/#/!ZxbRYPQXDXKGmDnJNg:example.com' +
302+
'?via=example.com&amp;via=bob.com">room link</a> with vias',
303+
);
344304
const { container } = getComponent({ mxEvent: ev }, matrixClient);
345305
expect(container).toHaveTextContent("A room name with vias");
346306
const content = container.querySelector(".mx_EventTile_body");
@@ -356,6 +316,16 @@ describe("<TextualBody />", () => {
356316
);
357317
});
358318

319+
it("pills appear for an MXID permalink", () => {
320+
const ev = mkFormattedMessage(
321+
"Chat with [@user:example.com](https://matrix.to/#/@user:example.com)",
322+
'Chat with <a href="https://matrix.to/#/@user:example.com">@user:example.com</a>',
323+
);
324+
const { container } = getComponent({ mxEvent: ev }, matrixClient);
325+
const content = container.querySelector(".mx_EventTile_body");
326+
expect(content).toMatchSnapshot();
327+
});
328+
359329
it("renders formatted body without html corretly", () => {
360330
const ev = mkEvent({
361331
type: "m.room.message",

test/components/views/messages/__snapshots__/TextualBody-test.tsx.snap

+31
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,37 @@ exports[`<TextualBody /> renders formatted m.text correctly linkification is not
4141
</span>
4242
`;
4343

44+
exports[`<TextualBody /> renders formatted m.text correctly pills appear for an MXID permalink 1`] = `
45+
<span
46+
class="mx_EventTile_body markdown-body"
47+
dir="auto"
48+
>
49+
Chat with
50+
<span>
51+
<bdi>
52+
<a
53+
class="mx_Pill mx_UserPill"
54+
href="https://matrix.to/#/@user:example.com"
55+
>
56+
<img
57+
alt=""
58+
aria-hidden="true"
59+
class="mx_BaseAvatar mx_BaseAvatar_image"
60+
data-testid="avatar-img"
61+
src="mxc://avatar.url/image.png"
62+
style="width: 16px; height: 16px;"
63+
/>
64+
<span
65+
class="mx_Pill_linkText"
66+
>
67+
Member
68+
</span>
69+
</a>
70+
</bdi>
71+
</span>
72+
</span>
73+
`;
74+
4475
exports[`<TextualBody /> renders formatted m.text correctly pills do not appear in code blocks 1`] = `
4576
<span
4677
class="mx_EventTile_body markdown-body"

test/test-utils/test-utils.ts

+7
Original file line numberDiff line numberDiff line change
@@ -473,15 +473,21 @@ export type MessageEventProps = MakeEventPassThruProps & {
473473
* @param {number} opts.ts The timestamp for the event.
474474
* @param {boolean} opts.event True to make a MatrixEvent.
475475
* @param {string=} opts.msg Optional. The content.body for the event.
476+
* @param {string=} opts.format Optional. The content.format for the event.
477+
* @param {string=} opts.formattedMsg Optional. The content.formatted_body for the event.
476478
* @return {Object|MatrixEvent} The event
477479
*/
478480
export function mkMessage({
479481
msg,
482+
format,
483+
formattedMsg,
480484
relatesTo,
481485
...opts
482486
}: MakeEventPassThruProps & {
483487
room: Room["roomId"];
484488
msg?: string;
489+
format?: string;
490+
formattedMsg?: string;
485491
}): MatrixEvent {
486492
if (!opts.room || !opts.user) {
487493
throw new Error("Missing .room or .user from options");
@@ -493,6 +499,7 @@ export function mkMessage({
493499
content: {
494500
msgtype: "m.text",
495501
body: message,
502+
...(format && formattedMsg ? { format, formatted_body: formattedMsg } : {}),
496503
["m.relates_to"]: relatesTo,
497504
},
498505
};

0 commit comments

Comments
 (0)