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

Improve message body output from plain text editor #11124

Merged
merged 5 commits into from
Jun 21, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@ import { richToPlain, plainToRich } from "@matrix-org/matrix-wysiwyg";
import { IContent, IEventRelation, MatrixEvent, MsgType } from "matrix-js-sdk/src/matrix";

import SettingsStore from "../../../../../settings/SettingsStore";
import { RoomPermalinkCreator } from "../../../../../utils/permalinks/Permalinks";
import { parsePermalink, RoomPermalinkCreator } from "../../../../../utils/permalinks/Permalinks";
import { addReplyToMessageContent } from "../../../../../utils/Reply";
import { isNotNull } from "../../../../../Typeguards";

export const EMOTE_PREFIX = "/me ";

Expand Down Expand Up @@ -94,8 +95,8 @@ export async function createMessageContent(
}

// if we're editing rich text, the message content is pure html
// BUT if we're not, the message content will be plain text
const body = isHTML ? await richToPlain(message) : message;
// BUT if we're not, the message content will be plain text where we need to convert the mentions
const body = isHTML ? await richToPlain(message) : convertPlainTextToBody(message);
const bodyPrefix = (isReplyAndEditing && getTextReplyFallback(editedEvent)) || "";
const formattedBodyPrefix = (isReplyAndEditing && getHtmlReplyFallback(editedEvent)) || "";

Expand Down Expand Up @@ -141,3 +142,51 @@ export async function createMessageContent(

return content;
}

/**
* Without a model, we need to manually amend mentions in uncontrolled message content
* to make sure that mentions meet the matrix specification.
*
* @param content - the output from the `MessageComposer` state when in plain text mode
* @returns - a string formatted with the mentions replaced as required
*/
function convertPlainTextToBody(content: string): string {
const document = new DOMParser().parseFromString(content, "text/html");
const mentions = Array.from(document.querySelectorAll("a[data-mention-type]"));

mentions.forEach((mention) => {
const mentionType = mention.getAttribute("data-mention-type");
switch (mentionType) {
case "at-room": {
mention.replaceWith("@room");
break;
}
case "user": {
const innerText = mention.innerHTML;
mention.replaceWith(innerText);
break;
}
case "room": {
// for this case we use parsePermalink to try and get the mx id
const href = mention.getAttribute("href");

// if the mention has no href attribute, leave it alone
if (href === null) break;

// otherwise, attempt to parse the room alias or id from the href
const permalinkParts = parsePermalink(href);

// then if we have permalink parts with a valid roomIdOrAlias, replace the
// room mention with that text
if (isNotNull(permalinkParts) && isNotNull(permalinkParts.roomIdOrAlias)) {
mention.replaceWith(permalinkParts.roomIdOrAlias);
}
break;
}
default:
break;
}
});

return document.body.innerHTML;
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,117 +41,146 @@ describe("createMessageContent", () => {
jest.resetAllMocks();
});

it("Should create html message", async () => {
// When
const content = await createMessageContent(message, true, { permalinkCreator });

// Then
expect(content).toEqual({
body: "*__hello__ world*",
format: "org.matrix.custom.html",
formatted_body: message,
msgtype: "m.text",
describe("Richtext composer input", () => {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adds nesting to separate tests for rich text/plain text

it("Should create html message", async () => {
// When
const content = await createMessageContent(message, true, { permalinkCreator });

// Then
expect(content).toEqual({
body: "*__hello__ world*",
format: "org.matrix.custom.html",
formatted_body: message,
msgtype: "m.text",
});
});
});

it("Should add reply to message content", async () => {
// When
const content = await createMessageContent(message, true, { permalinkCreator, replyToEvent: mockEvent });

// Then
expect(content).toEqual({
"body": "> <myfakeuser> Replying to this\n\n*__hello__ world*",
"format": "org.matrix.custom.html",
"formatted_body":
'<mx-reply><blockquote><a href="$$permalink$$">In reply to</a>' +
' <a href="https://matrix.to/#/myfakeuser">myfakeuser</a>' +
"<br>Replying to this</blockquote></mx-reply><em><b>hello</b> world</em>",
"msgtype": "m.text",
"m.relates_to": {
"m.in_reply_to": {
event_id: mockEvent.getId(),
it("Should add reply to message content", async () => {
// When
const content = await createMessageContent(message, true, { permalinkCreator, replyToEvent: mockEvent });

// Then
expect(content).toEqual({
"body": "> <myfakeuser> Replying to this\n\n*__hello__ world*",
"format": "org.matrix.custom.html",
"formatted_body":
'<mx-reply><blockquote><a href="$$permalink$$">In reply to</a>' +
' <a href="https://matrix.to/#/myfakeuser">myfakeuser</a>' +
"<br>Replying to this</blockquote></mx-reply><em><b>hello</b> world</em>",
"msgtype": "m.text",
"m.relates_to": {
"m.in_reply_to": {
event_id: mockEvent.getId(),
},
},
},
});
});
});

it("Should add relation to message", async () => {
// When
const relation = {
rel_type: "m.thread",
event_id: "myFakeThreadId",
};
const content = await createMessageContent(message, true, { permalinkCreator, relation });

// Then
expect(content).toEqual({
"body": "*__hello__ world*",
"format": "org.matrix.custom.html",
"formatted_body": message,
"msgtype": "m.text",
"m.relates_to": {
event_id: "myFakeThreadId",
it("Should add relation to message", async () => {
// When
const relation = {
rel_type: "m.thread",
},
event_id: "myFakeThreadId",
};
const content = await createMessageContent(message, true, { permalinkCreator, relation });

// Then
expect(content).toEqual({
"body": "*__hello__ world*",
"format": "org.matrix.custom.html",
"formatted_body": message,
"msgtype": "m.text",
"m.relates_to": {
event_id: "myFakeThreadId",
rel_type: "m.thread",
},
});
});
});

it("Should add fields related to edition", async () => {
// When
const editedEvent = mkEvent({
type: "m.room.message",
room: "myfakeroom",
user: "myfakeuser2",
content: {
it("Should add fields related to edition", async () => {
// When
const editedEvent = mkEvent({
type: "m.room.message",
room: "myfakeroom",
user: "myfakeuser2",
content: {
"msgtype": "m.text",
"body": "First message",
"formatted_body": "<b>First Message</b>",
"m.relates_to": {
"m.in_reply_to": {
event_id: "eventId",
},
},
},
event: true,
});
const content = await createMessageContent(message, true, { permalinkCreator, editedEvent });

// Then
expect(content).toEqual({
"body": " * *__hello__ world*",
"format": "org.matrix.custom.html",
"formatted_body": ` * ${message}`,
"msgtype": "m.text",
"body": "First message",
"formatted_body": "<b>First Message</b>",
"m.new_content": {
body: "*__hello__ world*",
format: "org.matrix.custom.html",
formatted_body: message,
msgtype: "m.text",
},
"m.relates_to": {
"m.in_reply_to": {
event_id: "eventId",
},
event_id: editedEvent.getId(),
rel_type: "m.replace",
},
},
event: true,
});
});
const content = await createMessageContent(message, true, { permalinkCreator, editedEvent });

// Then
expect(content).toEqual({
"body": " * *__hello__ world*",
"format": "org.matrix.custom.html",
"formatted_body": ` * ${message}`,
"msgtype": "m.text",
"m.new_content": {
body: "*__hello__ world*",
format: "org.matrix.custom.html",
formatted_body: message,
msgtype: "m.text",
},
"m.relates_to": {
event_id: editedEvent.getId(),
rel_type: "m.replace",
},

it("Should strip the /me prefix from a message", async () => {
const textBody = "some body text";
const content = await createMessageContent(EMOTE_PREFIX + textBody, true, { permalinkCreator });

expect(content).toMatchObject({ body: textBody, formatted_body: textBody });
});
});

it("Should strip the /me prefix from a message", async () => {
const textBody = "some body text";
const content = await createMessageContent(EMOTE_PREFIX + textBody, true, { permalinkCreator });
it("Should strip single / from message prefixed with //", async () => {
const content = await createMessageContent("//twoSlashes", true, { permalinkCreator });

expect(content).toMatchObject({ body: textBody, formatted_body: textBody });
});
expect(content).toMatchObject({ body: "/twoSlashes", formatted_body: "/twoSlashes" });
});

it("Should strip single / from message prefixed with //", async () => {
const content = await createMessageContent("//twoSlashes", true, { permalinkCreator });
it("Should set the content type to MsgType.Emote when /me prefix is used", async () => {
const textBody = "some body text";
const content = await createMessageContent(EMOTE_PREFIX + textBody, true, { permalinkCreator });

expect(content).toMatchObject({ body: "/twoSlashes", formatted_body: "/twoSlashes" });
expect(content).toMatchObject({ msgtype: MsgType.Emote });
});
});

it("Should set the content type to MsgType.Emote when /me prefix is used", async () => {
const textBody = "some body text";
const content = await createMessageContent(EMOTE_PREFIX + textBody, true, { permalinkCreator });
describe("Plaintext composer input", () => {
it("Should replace at-room mentions with `@room` in body", async () => {
const messageComposerState = `<a href="#" contenteditable="false" data-mention-type="at-room" style="some styling">@room</a> `;

const content = await createMessageContent(messageComposerState, false, { permalinkCreator });
expect(content).toMatchObject({ body: "@room " });
});

it("Should replace user mentions with user name in body", async () => {
const messageComposerState = `<a href="https://matrix.to/#/@test_user:element.io" contenteditable="false" data-mention-type="user" style="some styling">a test user</a> `;

const content = await createMessageContent(messageComposerState, false, { permalinkCreator });

expect(content).toMatchObject({ msgtype: MsgType.Emote });
expect(content).toMatchObject({ body: "a test user " });
});

it("Should replace room mentions with room mxid in body", async () => {
const messageComposerState = `<a href="https://matrix.to/#/#test_room:element.io" contenteditable="false" data-mention-type="room" style="some styling">a test room</a> `;

const content = await createMessageContent(messageComposerState, false, { permalinkCreator });

expect(content).toMatchObject({
body: "#test_room:element.io ",
});
});
Comment on lines +160 to +184
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are the new tests, all other changes in this file result from increased nesting

});
});