diff options
Diffstat (limited to 'comm/chat/protocols/matrix/matrixMessageContent.sys.mjs')
-rw-r--r-- | comm/chat/protocols/matrix/matrixMessageContent.sys.mjs | 377 |
1 files changed, 377 insertions, 0 deletions
diff --git a/comm/chat/protocols/matrix/matrixMessageContent.sys.mjs b/comm/chat/protocols/matrix/matrixMessageContent.sys.mjs new file mode 100644 index 0000000000..27e0ff6680 --- /dev/null +++ b/comm/chat/protocols/matrix/matrixMessageContent.sys.mjs @@ -0,0 +1,377 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; +import { l10nHelper } from "resource:///modules/imXPCOMUtils.sys.mjs"; +import { MatrixSDK } from "resource:///modules/matrix-sdk.sys.mjs"; + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + getMatrixTextForEvent: "resource:///modules/matrixTextForEvent.sys.mjs", +}); +XPCOMUtils.defineLazyGetter(lazy, "domParser", () => new DOMParser()); +XPCOMUtils.defineLazyGetter(lazy, "TXTToHTML", function () { + let cs = Cc["@mozilla.org/txttohtmlconv;1"].getService(Ci.mozITXTToHTMLConv); + return aTxt => cs.scanTXT(aTxt, cs.kEntities); +}); +XPCOMUtils.defineLazyGetter(lazy, "_", () => + l10nHelper("chrome://chat/locale/matrix.properties") +); + +const kRichBodiedTypes = [ + MatrixSDK.MsgType.Text, + MatrixSDK.MsgType.Notice, + MatrixSDK.MsgType.Emote, +]; +const kHtmlFormat = "org.matrix.custom.html"; +const kAttachmentTypes = [ + MatrixSDK.MsgType.Image, + MatrixSDK.MsgType.File, + MatrixSDK.MsgType.Audio, + MatrixSDK.MsgType.Video, +]; + +/** + * Gets the user-consumable URI to an attachment from an mxc URI and + * potentially encrypted file. + * + * @param {IContent} content - Event content to get the attachment URL from. + * @param {string} homeserverUrl - Homeserver URL to load the attachment from. + * @returns {string} https or data URI to the attachment file. + */ +function getAttachmentUrl(content, homeserverUrl) { + if (content.file?.v == "v2") { + return MatrixSDK.getHttpUriForMxc(homeserverUrl, content.file.url); + //TODO Actually handle encrypted file contents. + } + if (!content.url.startsWith("mxc:")) { + // Ignore content not served by the homeserver's media repo + return ""; + } + return MatrixSDK.getHttpUriForMxc(homeserverUrl, content.url); +} + +/** + * Turn an attachment event into a link to the attached file. + * + * @param {IContent} content - The event contents. + * @param {string} homeserverUrl - The base URL of the homeserver. + * @returns {string} HTML string to link to the attachment. + */ +function formatMediaAttachment(content, homeserverUrl) { + const realUrl = getAttachmentUrl(content, homeserverUrl); + if (!realUrl) { + return content.body; + } + return `<a href="${realUrl}">${content.body}</a>`; +} + +/** + * Format a user ID so it always gets a user tooltip. + * + * @param {string} userId - User ID to mention. + * @param {DOMDocument} doc - DOM Document the mention will appear in. + * @returns {HTMLSpanElement} Element to insert for the mention. + */ +function formatMention(userId, doc) { + const ibPerson = doc.createElement("span"); + ibPerson.classList.add("ib-person"); + ibPerson.textContent = userId; + return ibPerson; +} + +/** + * Get the raw text content of the reply event. + * + * @param {MatrixEvent} replyEvent - Event to quote. + * @param {string} homeserverUrl - The base URL of the homeserver. + * @param {string => MatrixEvent} getEvent - Get the event with the given ID. + * Used to fetch the replied to event. + * @param {boolean} rich - When true prefers the HTML representation of the + * event body. + * @returns {string} Formatted text body of the event to quote. + */ +function getReplyContent(replyEvent, homeserverUrl, getEvent, rich) { + let replyContent = + (rich && + MatrixMessageContent.getIncomingHTML( + replyEvent, + homeserverUrl, + getEvent, + false + )) || + MatrixMessageContent.getIncomingPlain( + replyEvent, + homeserverUrl, + getEvent, + false + ); + if (replyEvent.getContent()?.msgtype === MatrixSDK.MsgType.Emote) { + replyContent = `* ${replyEvent.getSender()} ${replyContent} *`; + } + return replyContent; +} + +/** + * Adapts the plain text body of an event for display. + * + * @param {MatrixEvent} event - The event to format the body of. + * @param {string} homeserverUrl - The base URL of the homeserver. + * @param {(string) => MatrixEvent} getEvent - Get the event with the given ID. + * @param {boolean} [includeReply=true] - If the message should contain the message it's replying to. + * @returns {string} Plain text message for the event. + */ +function formatPlainBody(event, homeserverUrl, getEvent, includeReply = true) { + const content = event.getContent(); + let body = lazy.TXTToHTML(content.body); + const eventId = event.replyEventId; + if (body.startsWith(">") && eventId) { + let nonQuote = Number.MAX_SAFE_INTEGER; + const replyEvent = getEvent(eventId); + if (!includeReply || replyEvent) { + // Strip the fallback quote + body = body + .split("\n") + .filter((line, index) => { + const isQuoteLine = line.startsWith(">"); + if (!isQuoteLine && nonQuote > index) { + nonQuote = index; + } + return nonQuote < index || !isQuoteLine; + }) + .join("\n"); + } + if ( + includeReply && + replyEvent && + content.msgtype != MatrixSDK.MsgType.Emote + ) { + let replyContent = getReplyContent( + replyEvent, + homeserverUrl, + getEvent, + false + ); + const isEmoteReply = + replyEvent.getContent()?.msgtype == MatrixSDK.MsgType.Emote; + replyContent = replyContent + .split("\n") + .map(line => `> ${line}`) + .join("\n"); + if (!isEmoteReply) { + replyContent = `${replyEvent.getSender()}: +${replyContent}`; + } + body = replyContent + "\n" + body; + } + } + return body; +} + +/** + * Adapts the formatted body of an event for display. + * + * @param {MatrixEvent} event - The event to format the body of. + * @param {string} homeserverUrl - The base URL of the homeserver. + * @param {(string) => MatrixEvent} getEvent - Get the event with the given ID. + * Used to fetch the replied to event. + * @param {boolean} [includeReply=true] - If the message should contain the + * message it's replying to. + * @returns {string} Formatted body of the event. + */ +function formatHTMLBody(event, homeserverUrl, getEvent, includeReply = true) { + const content = event.getContent(); + const parsedBody = lazy.domParser.parseFromString( + `<!DOCTYPE html><html><body>${content.formatted_body}</body></html>`, + "text/html" + ); + const textColors = parsedBody.querySelectorAll( + "span[data-mx-color], font[data-mx-color]" + ); + for (const coloredElement of textColors) { + coloredElement.style.color = `#${coloredElement.dataset.mxColor}`; + delete coloredElement.dataset.mxColor; + } + //TODO background color + const userMentions = parsedBody.querySelectorAll( + 'a[href^="https://matrix.to/#/@"],a[href^="https://matrix.to/#/%40"]' + ); + for (const mention of userMentions) { + let endIndex = mention.hash.indexOf("?"); + if (endIndex == -1) { + endIndex = undefined; + } + const userId = decodeURIComponent(mention.hash.slice(2, endIndex)); + const ibPerson = formatMention(userId, parsedBody); + mention.replaceWith(ibPerson); + } + //TODO handle room mentions but avoid event permalinks + const inlineImages = parsedBody.querySelectorAll("img"); + for (const image of inlineImages) { + if (image.alt) { + if (image.src.startsWith("mxc:")) { + const link = parsedBody.createElement("a"); + link.href = MatrixSDK.getHttpUriForMxc(homeserverUrl, image.src); + link.textContent = image.alt; + if (image.title) { + link.title = image.title; + } + image.replaceWith(link); + } else { + image.replaceWith(image.alt); + } + } + } + const reply = parsedBody.querySelector("mx-reply"); + if (reply) { + if (includeReply && content.msgtype != MatrixSDK.MsgType.Emote) { + const eventId = event.replyEventId; + const replyEvent = getEvent(eventId); + if (replyEvent) { + let replyContent = getReplyContent( + replyEvent, + homeserverUrl, + getEvent, + true + ); + const isEmoteReply = + replyEvent.getContent()?.msgtype == MatrixSDK.MsgType.Emote; + const newReply = parsedBody.createDocumentFragment(); + if (!isEmoteReply) { + const replyTo = formatMention(replyEvent.getSender(), parsedBody); + newReply.append(replyTo, ":"); + } + const quote = parsedBody.createElement("blockquote"); + newReply.append(quote); + // eslint-disable-next-line no-unsanitized/method + quote.insertAdjacentHTML("afterbegin", replyContent); + reply.replaceWith(newReply); + } else { + // Strip mx-reply from DOM + reply.normalize(); + reply.replaceWith(...reply.childNodes); + } + } else { + reply.remove(); + } + } + //TODO spoilers + return parsedBody.body.innerHTML; +} + +export var MatrixMessageContent = { + /** + * Format the plain text body of an incoming message for display. + * + * @param {MatrixEvent} event - Event to format the body of. + * @param {string} homeserverUrl - The base URL of the homserver used to + * resolve mxc URIs. + * @param {string => MatrixEvent} getEvent - Get the event with the given ID. + * Used to fetch the replied to event. + * @param {boolean} [includeReply=true] - If the message should contain the + * message it's replying to. + * @returns {string} Returns the formatted body ready for display or an empty + * string if formatting wasn't possible. + */ + getIncomingPlain(event, homeserverUrl, getEvent, includeReply = true) { + if ( + !event || + (event.status !== null && event.status !== MatrixSDK.EventStatus.SENT) + ) { + return ""; + } + const type = event.getType(); + const content = event.getContent(); + if (event.isRedacted()) { + return lazy._("message.redacted"); + } + const textForEvent = lazy.getMatrixTextForEvent(event); + if (textForEvent) { + return textForEvent; + } else if ( + type == MatrixSDK.EventType.RoomMessage || + type == MatrixSDK.EventType.RoomMessageEncrypted + ) { + if (kRichBodiedTypes.includes(content?.msgtype)) { + return formatPlainBody(event, homeserverUrl, getEvent, includeReply); + } else if (kAttachmentTypes.includes(content?.msgtype)) { + const attachmentUrl = getAttachmentUrl(content, homeserverUrl); + if (attachmentUrl) { + return attachmentUrl; + } + } else if (event.isBeingDecrypted() || event.shouldAttemptDecryption()) { + return lazy._("message.decrypting"); + } + } else if (type == MatrixSDK.EventType.Sticker) { + const attachmentUrl = getAttachmentUrl(content, homeserverUrl); + if (attachmentUrl) { + return attachmentUrl; + } + } else if (type == MatrixSDK.EventType.Reaction) { + let annotatedEvent = getEvent(content["m.relates_to"]?.event_id); + if (annotatedEvent && content["m.relates_to"]?.key) { + return lazy._( + "message.reaction", + event.getSender(), + annotatedEvent.getSender(), + lazy.TXTToHTML(content["m.relates_to"].key) + ); + } + } + return lazy.TXTToHTML(content.body ?? ""); + }, + /** + * Format the HTML body of an incoming message for display. + * + * @param {MatrixEvent} event - Event to format the body of. + * @param {string} homeserverUrl - The base URL of the homserver used to + * resolve mxc URIs. + * @param {string => MatrixEvent} getEvent - Get the event with the given ID. + * @param {boolean} [includeReply=true] - If the message should contain the + * message it's replying to. + * @returns {string} Returns a formatted body ready for display or an empty + * string if formatting wasn't possible. + */ + getIncomingHTML(event, homeserverUrl, getEvent, includeReply = true) { + if ( + !event || + (event.status !== null && event.status !== MatrixSDK.EventStatus.SENT) + ) { + return ""; + } + const type = event.getType(); + const content = event.getContent(); + if (event.isRedacted()) { + return lazy._("message.redacted"); + } + if (type == MatrixSDK.EventType.RoomMessage) { + if ( + kRichBodiedTypes.includes(content.msgtype) && + content.format == kHtmlFormat && + content.formatted_body + ) { + return formatHTMLBody(event, homeserverUrl, getEvent, includeReply); + } else if (kAttachmentTypes.includes(content.msgtype)) { + return formatMediaAttachment(content, homeserverUrl); + } + } else if (type == MatrixSDK.EventType.Sticker) { + return formatMediaAttachment(content, homeserverUrl); + } else if (type == MatrixSDK.EventType.Reaction) { + let annotatedEvent = getEvent(content["m.relates_to"]?.event_id); + if (annotatedEvent && content["m.relates_to"]?.key) { + return lazy._( + "message.reaction", + `<span class="ib-person">${event.getSender()}</span>`, + `<span class="ib-person">${annotatedEvent.getSender()}</span>`, + lazy.TXTToHTML(content["m.relates_to"].key) + ); + } + } + return MatrixMessageContent.getIncomingPlain( + event, + homeserverUrl, + getEvent + ); + }, +}; |