element-web/src/components/views/rooms/wysiwyg_composer/utils/createMessageContent.ts

193 lines
7.0 KiB
TypeScript

/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
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 { parsePermalink, RoomPermalinkCreator } from "../../../../../utils/permalinks/Permalinks";
import { addReplyToMessageContent } from "../../../../../utils/Reply";
import { isNotNull } from "../../../../../Typeguards";
export const EMOTE_PREFIX = "/me ";
// Merges favouring the given relation
function attachRelation(content: IContent, relation?: IEventRelation): void {
if (relation) {
content["m.relates_to"] = {
...(content["m.relates_to"] || {}),
...relation,
};
}
}
function getHtmlReplyFallback(mxEvent: MatrixEvent): string {
const html = mxEvent.getContent().formatted_body;
if (!html) {
return "";
}
const rootNode = new DOMParser().parseFromString(html, "text/html").body;
const mxReply = rootNode.querySelector("mx-reply");
return (mxReply && mxReply.outerHTML) || "";
}
function getTextReplyFallback(mxEvent: MatrixEvent): string {
const body = mxEvent.getContent().body;
if (typeof body !== "string") {
return "";
}
const lines = body.split("\n").map((l) => l.trim());
if (lines.length > 2 && lines[0].startsWith("> ") && lines[1].length === 0) {
return `${lines[0]}\n\n`;
}
return "";
}
interface CreateMessageContentParams {
relation?: IEventRelation;
replyToEvent?: MatrixEvent;
permalinkCreator?: RoomPermalinkCreator;
includeReplyLegacyFallback?: boolean;
editedEvent?: MatrixEvent;
}
const isMatrixEvent = (e: MatrixEvent | undefined): e is MatrixEvent => e instanceof MatrixEvent;
export async function createMessageContent(
message: string,
isHTML: boolean,
{
relation,
replyToEvent,
permalinkCreator,
includeReplyLegacyFallback = true,
editedEvent,
}: CreateMessageContentParams,
): Promise<IContent> {
const isEditing = isMatrixEvent(editedEvent);
const isReply = isEditing ? Boolean(editedEvent.replyEventId) : isMatrixEvent(replyToEvent);
const isReplyAndEditing = isEditing && isReply;
const isEmote = message.startsWith(EMOTE_PREFIX);
if (isEmote) {
// if we are dealing with an emote we want to remove the prefix so that `/me` does not
// appear after the `* <userName>` text in the timeline
message = message.slice(EMOTE_PREFIX.length);
}
if (message.startsWith("//")) {
// if user wants to enter a single slash at the start of a message, this
// is how they have to do it (due to it clashing with commands), so here we
// remove the first character to make sure //word displays as /word
message = message.slice(1);
}
// if we're editing rich text, the message content is pure html
// 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, false) : convertPlainTextToBody(message);
const bodyPrefix = (isReplyAndEditing && getTextReplyFallback(editedEvent)) || "";
const formattedBodyPrefix = (isReplyAndEditing && getHtmlReplyFallback(editedEvent)) || "";
const content: IContent = {
msgtype: isEmote ? MsgType.Emote : MsgType.Text,
body: isEditing ? `${bodyPrefix} * ${body}` : body,
};
// TODO markdown support
const isMarkdownEnabled = SettingsStore.getValue<boolean>("MessageComposerInput.useMarkdown");
const formattedBody = isHTML ? message : isMarkdownEnabled ? await plainToRich(message, true) : null;
if (formattedBody) {
content.format = "org.matrix.custom.html";
content.formatted_body = isEditing ? `${formattedBodyPrefix} * ${formattedBody}` : formattedBody;
}
if (isEditing) {
content["m.new_content"] = {
msgtype: content.msgtype,
body: body,
};
if (formattedBody) {
content["m.new_content"].format = "org.matrix.custom.html";
content["m.new_content"]["formatted_body"] = formattedBody;
}
}
const newRelation = isEditing ? { ...relation, rel_type: "m.replace", event_id: editedEvent.getId() } : relation;
// TODO Do we need to attach mentions here?
// TODO Handle editing?
attachRelation(content, newRelation);
if (!isEditing && replyToEvent && permalinkCreator) {
addReplyToMessageContent(content, replyToEvent, {
permalinkCreator,
includeLegacyFallback: includeReplyLegacyFallback,
});
}
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;
}