mirror of https://github.com/vector-im/riot-web
Pillify permalinks to rooms and users (#10388)
parent
d850c95099
commit
a86a8e7f8e
|
@ -92,11 +92,8 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
|
||||||
const showLineNumbers = SettingsStore.getValue("showCodeLineNumbers");
|
const showLineNumbers = SettingsStore.getValue("showCodeLineNumbers");
|
||||||
this.activateSpoilers([content]);
|
this.activateSpoilers([content]);
|
||||||
|
|
||||||
// pillifyLinks BEFORE linkifyElement because plain room/user URLs in the composer
|
|
||||||
// are still sent as plaintext URLs. If these are ever pillified in the composer,
|
|
||||||
// we should be pillify them here by doing the linkifying BEFORE the pillifying.
|
|
||||||
pillifyLinks([content], this.props.mxEvent, this.pills);
|
|
||||||
HtmlUtils.linkifyElement(content);
|
HtmlUtils.linkifyElement(content);
|
||||||
|
pillifyLinks([content], this.props.mxEvent, this.pills);
|
||||||
|
|
||||||
this.calculateUrlPreview();
|
this.calculateUrlPreview();
|
||||||
|
|
||||||
|
|
|
@ -23,6 +23,25 @@ import { MatrixClientPeg } from "../MatrixClientPeg";
|
||||||
import SettingsStore from "../settings/SettingsStore";
|
import SettingsStore from "../settings/SettingsStore";
|
||||||
import { Pill, PillType, pillRoomNotifLen, pillRoomNotifPos } from "../components/views/elements/Pill";
|
import { Pill, PillType, pillRoomNotifLen, pillRoomNotifPos } from "../components/views/elements/Pill";
|
||||||
import { parsePermalink } from "./permalinks/Permalinks";
|
import { parsePermalink } from "./permalinks/Permalinks";
|
||||||
|
import { PermalinkParts } from "./permalinks/PermalinkConstructor";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A node here is an A element with a href attribute tag.
|
||||||
|
*
|
||||||
|
* It should not be pillified if the permalink parser result contains an event Id.
|
||||||
|
*
|
||||||
|
* 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 => {
|
||||||
|
if (!parts || parts.eventId) return false;
|
||||||
|
|
||||||
|
const textContent = node.textContent;
|
||||||
|
return href === textContent || !node.classList.contains("linkified");
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recurses depth-first through a DOM tree, converting matrix.to links
|
* Recurses depth-first through a DOM tree, converting matrix.to links
|
||||||
|
@ -51,9 +70,8 @@ export function pillifyLinks(nodes: ArrayLike<Element>, mxEvent: MatrixEvent, pi
|
||||||
} else if (node.tagName === "A" && node.getAttribute("href")) {
|
} else if (node.tagName === "A" && node.getAttribute("href")) {
|
||||||
const href = node.getAttribute("href")!;
|
const href = node.getAttribute("href")!;
|
||||||
const parts = parsePermalink(href);
|
const parts = parsePermalink(href);
|
||||||
// If the link is a (localised) matrix.to link, replace it with a pill
|
|
||||||
// We don't want to pill event permalinks, so those are ignored.
|
if (shouldBePillified(node, href, parts)) {
|
||||||
if (parts && !parts.eventId) {
|
|
||||||
const pillContainer = document.createElement("span");
|
const pillContainer = document.createElement("span");
|
||||||
|
|
||||||
const pill = (
|
const pill = (
|
||||||
|
|
|
@ -37,6 +37,17 @@ const mkRoomTextMessage = (body: string): MatrixEvent => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mkFormattedMessage = (body: string, formattedBody: string): MatrixEvent => {
|
||||||
|
return mkMessage({
|
||||||
|
msg: body,
|
||||||
|
formattedMsg: formattedBody,
|
||||||
|
format: "org.matrix.custom.html",
|
||||||
|
room: "room_id",
|
||||||
|
user: "sender",
|
||||||
|
event: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
describe("<TextualBody />", () => {
|
describe("<TextualBody />", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
jest.spyOn(MatrixClientPeg, "get").mockRestore();
|
jest.spyOn(MatrixClientPeg, "get").mockRestore();
|
||||||
|
@ -156,6 +167,15 @@ describe("<TextualBody />", () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should pillify an MXID permalink", () => {
|
||||||
|
const ev = mkRoomTextMessage("Chat with https://matrix.to/#/@user:example.com");
|
||||||
|
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"><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>"`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("should not pillify room aliases", () => {
|
it("should not pillify room aliases", () => {
|
||||||
const ev = mkRoomTextMessage("Visit #room:example.com");
|
const ev = mkRoomTextMessage("Visit #room:example.com");
|
||||||
const { container } = getComponent({ mxEvent: ev });
|
const { container } = getComponent({ mxEvent: ev });
|
||||||
|
@ -164,6 +184,15 @@ describe("<TextualBody />", () => {
|
||||||
`"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" class="linkified" rel="noreferrer noopener">#room:example.com</a>"`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should pillify a room alias permalink", () => {
|
||||||
|
const ev = mkRoomTextMessage("Visit https://matrix.to/#/#room:example.com");
|
||||||
|
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"><span class="mx_Pill_linkText">#room:example.com</span></a></bdi></span>"`,
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("renders formatted m.text correctly", () => {
|
describe("renders formatted m.text correctly", () => {
|
||||||
|
@ -183,19 +212,10 @@ describe("<TextualBody />", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("italics, bold, underline and strikethrough render as expected", () => {
|
it("italics, bold, underline and strikethrough render as expected", () => {
|
||||||
const ev = mkEvent({
|
const ev = mkFormattedMessage(
|
||||||
type: "m.room.message",
|
"foo *baz* __bar__ <del>del</del> <u>u</u>",
|
||||||
room: "room_id",
|
"foo <em>baz</em> <strong>bar</strong> <del>del</del> <u>u</u>",
|
||||||
user: "sender",
|
);
|
||||||
content: {
|
|
||||||
body: "foo *baz* __bar__ <del>del</del> <u>u</u>",
|
|
||||||
msgtype: "m.text",
|
|
||||||
format: "org.matrix.custom.html",
|
|
||||||
formatted_body: "foo <em>baz</em> <strong>bar</strong> <del>del</del> <u>u</u>",
|
|
||||||
},
|
|
||||||
event: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { container } = getComponent({ mxEvent: ev }, matrixClient);
|
const { container } = getComponent({ mxEvent: ev }, matrixClient);
|
||||||
expect(container).toHaveTextContent("foo baz bar del u");
|
expect(container).toHaveTextContent("foo baz bar del u");
|
||||||
const content = container.querySelector(".mx_EventTile_body");
|
const content = container.querySelector(".mx_EventTile_body");
|
||||||
|
@ -207,19 +227,10 @@ describe("<TextualBody />", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("spoilers get injected properly into the DOM", () => {
|
it("spoilers get injected properly into the DOM", () => {
|
||||||
const ev = mkEvent({
|
const ev = mkFormattedMessage(
|
||||||
type: "m.room.message",
|
"Hey [Spoiler for movie](mxc://someserver/somefile)",
|
||||||
room: "room_id",
|
'Hey <span data-mx-spoiler="movie">the movie was awesome</span>',
|
||||||
user: "sender",
|
);
|
||||||
content: {
|
|
||||||
body: "Hey [Spoiler for movie](mxc://someserver/somefile)",
|
|
||||||
msgtype: "m.text",
|
|
||||||
format: "org.matrix.custom.html",
|
|
||||||
formatted_body: 'Hey <span data-mx-spoiler="movie">the movie was awesome</span>',
|
|
||||||
},
|
|
||||||
event: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { container } = getComponent({ mxEvent: ev }, matrixClient);
|
const { container } = getComponent({ mxEvent: ev }, matrixClient);
|
||||||
expect(container).toHaveTextContent("Hey (movie) the movie was awesome");
|
expect(container).toHaveTextContent("Hey (movie) the movie was awesome");
|
||||||
const content = container.querySelector(".mx_EventTile_body");
|
const content = container.querySelector(".mx_EventTile_body");
|
||||||
|
@ -234,19 +245,10 @@ describe("<TextualBody />", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("linkification is not applied to code blocks", () => {
|
it("linkification is not applied to code blocks", () => {
|
||||||
const ev = mkEvent({
|
const ev = mkFormattedMessage(
|
||||||
type: "m.room.message",
|
"Visit `https://matrix.org/`\n```\nhttps://matrix.org/\n```",
|
||||||
room: "room_id",
|
"<p>Visit <code>https://matrix.org/</code></p>\n<pre>https://matrix.org/\n</pre>\n",
|
||||||
user: "sender",
|
);
|
||||||
content: {
|
|
||||||
body: "Visit `https://matrix.org/`\n```\nhttps://matrix.org/\n```",
|
|
||||||
msgtype: "m.text",
|
|
||||||
format: "org.matrix.custom.html",
|
|
||||||
formatted_body: "<p>Visit <code>https://matrix.org/</code></p>\n<pre>https://matrix.org/\n</pre>\n",
|
|
||||||
},
|
|
||||||
event: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { container } = getComponent({ mxEvent: ev }, matrixClient);
|
const { container } = getComponent({ mxEvent: ev }, matrixClient);
|
||||||
expect(container).toHaveTextContent("Visit https://matrix.org/ 1https://matrix.org/");
|
expect(container).toHaveTextContent("Visit https://matrix.org/ 1https://matrix.org/");
|
||||||
const content = container.querySelector(".mx_EventTile_body");
|
const content = container.querySelector(".mx_EventTile_body");
|
||||||
|
@ -255,19 +257,7 @@ describe("<TextualBody />", () => {
|
||||||
|
|
||||||
// If pills were rendered within a Portal/same shadow DOM then it'd be easier to test
|
// If pills were rendered within a Portal/same shadow DOM then it'd be easier to test
|
||||||
it("pills get injected correctly into the DOM", () => {
|
it("pills get injected correctly into the DOM", () => {
|
||||||
const ev = mkEvent({
|
const ev = mkFormattedMessage("Hey User", 'Hey <a href="https://matrix.to/#/@user:server">Member</a>');
|
||||||
type: "m.room.message",
|
|
||||||
room: "room_id",
|
|
||||||
user: "sender",
|
|
||||||
content: {
|
|
||||||
body: "Hey User",
|
|
||||||
msgtype: "m.text",
|
|
||||||
format: "org.matrix.custom.html",
|
|
||||||
formatted_body: 'Hey <a href="https://matrix.to/#/@user:server">Member</a>',
|
|
||||||
},
|
|
||||||
event: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { container } = getComponent({ mxEvent: ev }, matrixClient);
|
const { container } = getComponent({ mxEvent: ev }, matrixClient);
|
||||||
expect(container).toHaveTextContent("Hey Member");
|
expect(container).toHaveTextContent("Hey Member");
|
||||||
const content = container.querySelector(".mx_EventTile_body");
|
const content = container.querySelector(".mx_EventTile_body");
|
||||||
|
@ -275,19 +265,10 @@ describe("<TextualBody />", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("pills do not appear in code blocks", () => {
|
it("pills do not appear in code blocks", () => {
|
||||||
const ev = mkEvent({
|
const ev = mkFormattedMessage(
|
||||||
type: "m.room.message",
|
"`@room`\n```\n@room\n```",
|
||||||
room: "room_id",
|
"<p><code>@room</code></p>\n<pre><code>@room\n</code></pre>\n",
|
||||||
user: "sender",
|
);
|
||||||
content: {
|
|
||||||
body: "`@room`\n```\n@room\n```",
|
|
||||||
msgtype: "m.text",
|
|
||||||
format: "org.matrix.custom.html",
|
|
||||||
formatted_body: "<p><code>@room</code></p>\n<pre><code>@room\n</code></pre>\n",
|
|
||||||
},
|
|
||||||
event: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { container } = getComponent({ mxEvent: ev });
|
const { container } = getComponent({ mxEvent: ev });
|
||||||
expect(container).toHaveTextContent("@room 1@room");
|
expect(container).toHaveTextContent("@room 1@room");
|
||||||
const content = container.querySelector(".mx_EventTile_body");
|
const content = container.querySelector(".mx_EventTile_body");
|
||||||
|
@ -295,23 +276,13 @@ describe("<TextualBody />", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("pills do not appear for event permalinks", () => {
|
it("pills do not appear for event permalinks", () => {
|
||||||
const ev = mkEvent({
|
const ev = mkFormattedMessage(
|
||||||
type: "m.room.message",
|
"An [event link](https://matrix.to/#/!ZxbRYPQXDXKGmDnJNg:example.com/" +
|
||||||
room: "room_id",
|
"$16085560162aNpaH:example.com?via=example.com) with text",
|
||||||
user: "sender",
|
|
||||||
content: {
|
|
||||||
body:
|
|
||||||
"An [event link](https://matrix.to/#/!ZxbRYPQXDXKGmDnJNg:example.com/" +
|
|
||||||
"$16085560162aNpaH:example.com?via=example.com) with text",
|
|
||||||
msgtype: "m.text",
|
|
||||||
format: "org.matrix.custom.html",
|
|
||||||
formatted_body:
|
|
||||||
'An <a href="https://matrix.to/#/!ZxbRYPQXDXKGmDnJNg:example.com/' +
|
|
||||||
'$16085560162aNpaH:example.com?via=example.com">event link</a> with text',
|
|
||||||
},
|
|
||||||
event: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
|
'An <a href="https://matrix.to/#/!ZxbRYPQXDXKGmDnJNg:example.com/' +
|
||||||
|
'$16085560162aNpaH:example.com?via=example.com">event link</a> with text',
|
||||||
|
);
|
||||||
const { container } = getComponent({ mxEvent: ev }, matrixClient);
|
const { container } = getComponent({ mxEvent: ev }, matrixClient);
|
||||||
expect(container).toHaveTextContent("An event link with text");
|
expect(container).toHaveTextContent("An event link with text");
|
||||||
const content = container.querySelector(".mx_EventTile_body");
|
const content = container.querySelector(".mx_EventTile_body");
|
||||||
|
@ -324,23 +295,12 @@ describe("<TextualBody />", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("pills appear for room links with vias", () => {
|
it("pills appear for room links with vias", () => {
|
||||||
const ev = mkEvent({
|
const ev = mkFormattedMessage(
|
||||||
type: "m.room.message",
|
"A [room link](https://matrix.to/#/!ZxbRYPQXDXKGmDnJNg:example.com" +
|
||||||
room: "room_id",
|
"?via=example.com&via=bob.com) with vias",
|
||||||
user: "sender",
|
'A <a href="https://matrix.to/#/!ZxbRYPQXDXKGmDnJNg:example.com' +
|
||||||
content: {
|
'?via=example.com&via=bob.com">room link</a> with vias',
|
||||||
body:
|
);
|
||||||
"A [room link](https://matrix.to/#/!ZxbRYPQXDXKGmDnJNg:example.com" +
|
|
||||||
"?via=example.com&via=bob.com) with vias",
|
|
||||||
msgtype: "m.text",
|
|
||||||
format: "org.matrix.custom.html",
|
|
||||||
formatted_body:
|
|
||||||
'A <a href="https://matrix.to/#/!ZxbRYPQXDXKGmDnJNg:example.com' +
|
|
||||||
'?via=example.com&via=bob.com">room link</a> with vias',
|
|
||||||
},
|
|
||||||
event: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { container } = getComponent({ mxEvent: ev }, matrixClient);
|
const { container } = getComponent({ mxEvent: ev }, matrixClient);
|
||||||
expect(container).toHaveTextContent("A room name with vias");
|
expect(container).toHaveTextContent("A room name with vias");
|
||||||
const content = container.querySelector(".mx_EventTile_body");
|
const content = container.querySelector(".mx_EventTile_body");
|
||||||
|
@ -356,6 +316,16 @@ describe("<TextualBody />", () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("pills appear for an MXID permalink", () => {
|
||||||
|
const ev = mkFormattedMessage(
|
||||||
|
"Chat with [@user:example.com](https://matrix.to/#/@user:example.com)",
|
||||||
|
'Chat with <a href="https://matrix.to/#/@user:example.com">@user:example.com</a>',
|
||||||
|
);
|
||||||
|
const { container } = getComponent({ mxEvent: ev }, matrixClient);
|
||||||
|
const content = container.querySelector(".mx_EventTile_body");
|
||||||
|
expect(content).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
it("renders formatted body without html corretly", () => {
|
it("renders formatted body without html corretly", () => {
|
||||||
const ev = mkEvent({
|
const ev = mkEvent({
|
||||||
type: "m.room.message",
|
type: "m.room.message",
|
||||||
|
|
|
@ -41,6 +41,37 @@ exports[`<TextualBody /> renders formatted m.text correctly linkification is not
|
||||||
</span>
|
</span>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
exports[`<TextualBody /> renders formatted m.text correctly pills appear for an MXID permalink 1`] = `
|
||||||
|
<span
|
||||||
|
class="mx_EventTile_body markdown-body"
|
||||||
|
dir="auto"
|
||||||
|
>
|
||||||
|
Chat with
|
||||||
|
<span>
|
||||||
|
<bdi>
|
||||||
|
<a
|
||||||
|
class="mx_Pill mx_UserPill"
|
||||||
|
href="https://matrix.to/#/@user:example.com"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
class="mx_BaseAvatar mx_BaseAvatar_image"
|
||||||
|
data-testid="avatar-img"
|
||||||
|
src="mxc://avatar.url/image.png"
|
||||||
|
style="width: 16px; height: 16px;"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
class="mx_Pill_linkText"
|
||||||
|
>
|
||||||
|
Member
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</bdi>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
`;
|
||||||
|
|
||||||
exports[`<TextualBody /> renders formatted m.text correctly pills do not appear in code blocks 1`] = `
|
exports[`<TextualBody /> renders formatted m.text correctly pills do not appear in code blocks 1`] = `
|
||||||
<span
|
<span
|
||||||
class="mx_EventTile_body markdown-body"
|
class="mx_EventTile_body markdown-body"
|
||||||
|
|
|
@ -473,15 +473,21 @@ export type MessageEventProps = MakeEventPassThruProps & {
|
||||||
* @param {number} opts.ts The timestamp for the event.
|
* @param {number} opts.ts The timestamp for the event.
|
||||||
* @param {boolean} opts.event True to make a MatrixEvent.
|
* @param {boolean} opts.event True to make a MatrixEvent.
|
||||||
* @param {string=} opts.msg Optional. The content.body for the event.
|
* @param {string=} opts.msg Optional. The content.body for the event.
|
||||||
|
* @param {string=} opts.format Optional. The content.format for the event.
|
||||||
|
* @param {string=} opts.formattedMsg Optional. The content.formatted_body for the event.
|
||||||
* @return {Object|MatrixEvent} The event
|
* @return {Object|MatrixEvent} The event
|
||||||
*/
|
*/
|
||||||
export function mkMessage({
|
export function mkMessage({
|
||||||
msg,
|
msg,
|
||||||
|
format,
|
||||||
|
formattedMsg,
|
||||||
relatesTo,
|
relatesTo,
|
||||||
...opts
|
...opts
|
||||||
}: MakeEventPassThruProps & {
|
}: MakeEventPassThruProps & {
|
||||||
room: Room["roomId"];
|
room: Room["roomId"];
|
||||||
msg?: string;
|
msg?: string;
|
||||||
|
format?: string;
|
||||||
|
formattedMsg?: string;
|
||||||
}): MatrixEvent {
|
}): MatrixEvent {
|
||||||
if (!opts.room || !opts.user) {
|
if (!opts.room || !opts.user) {
|
||||||
throw new Error("Missing .room or .user from options");
|
throw new Error("Missing .room or .user from options");
|
||||||
|
@ -493,6 +499,7 @@ export function mkMessage({
|
||||||
content: {
|
content: {
|
||||||
msgtype: "m.text",
|
msgtype: "m.text",
|
||||||
body: message,
|
body: message,
|
||||||
|
...(format && formattedMsg ? { format, formatted_body: formattedMsg } : {}),
|
||||||
["m.relates_to"]: relatesTo,
|
["m.relates_to"]: relatesTo,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
Loading…
Reference in New Issue