UNPKG

matrix-react-sdk

Version:
186 lines (157 loc) 6.97 kB
/* 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 Please see LICENSE files in the repository root for full details. */ import { richToPlain, plainToRich } from "@vector-im/matrix-wysiwyg"; import { IContent, IEventRelation, MatrixEvent, MsgType } from "matrix-js-sdk/src/matrix"; import { ReplacementEvent, RoomMessageEventContent, RoomMessageTextEventContent } from "matrix-js-sdk/src/types"; 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<RoomMessageEventContent> { 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 = { msgtype: isEmote ? MsgType.Emote : MsgType.Text, body: isEditing ? `${bodyPrefix} * ${body}` : body, } as RoomMessageTextEventContent & ReplacementEvent<RoomMessageTextEventContent>; // 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; }