Revision control
Copy as Markdown
Other Tools
/* 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
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
});
ChromeUtils.defineLazyGetter(lazy, "domParser", () => new DOMParser());
ChromeUtils.defineLazyGetter(lazy, "TXTToHTML", function () {
  const cs = Cc["@mozilla.org/txttohtmlconv;1"].getService(
    Ci.mozITXTToHTMLConv
  );
  return aTxt => cs.scanTXT(aTxt, cs.kEntities);
});
ChromeUtils.defineLazyGetter(
  lazy,
  "l10n",
  () => new Localization(["chat/matrix-properties.ftl"], true)
);
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 {function(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 {function(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(
  );
  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) {
        const 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 {function(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.l10n.formatValueSync("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.l10n.formatValueSync("message-decrypting");
      }
    } else if (type == MatrixSDK.EventType.Sticker) {
      const attachmentUrl = getAttachmentUrl(content, homeserverUrl);
      if (attachmentUrl) {
        return attachmentUrl;
      }
    } else if (type == MatrixSDK.EventType.Reaction) {
      const annotatedEvent = getEvent(content["m.relates_to"]?.event_id);
      if (annotatedEvent && content["m.relates_to"]?.key) {
        return lazy.l10n.formatValueSync("message-reaction", {
          userThatReacted: event.getSender(),
          userThatSentMessage: annotatedEvent.getSender(),
          reaction: 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 {function(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.l10n.formatValueSync("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) {
      const annotatedEvent = getEvent(content["m.relates_to"]?.event_id);
      if (annotatedEvent && content["m.relates_to"]?.key) {
        return lazy.l10n.formatValueSync("message-reaction", {
          userThatReacted: `<span class="ib-person">${event.getSender()}</span>`,
          userThatSentMessage: `<span class="ib-person">${annotatedEvent.getSender()}</span>`,
          reaction: lazy.TXTToHTML(content["m.relates_to"].key),
        });
      }
    }
    return MatrixMessageContent.getIncomingPlain(
      event,
      homeserverUrl,
      getEvent
    );
  },
};