/* 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"; const lazy = {}; const ParserUtils = Cc["@mozilla.org/parserutils;1"].getService( Ci.nsIParserUtils ); var kMessagesStylePrefBranch = "messenger.options.messagesStyle."; var kThemePref = "theme"; var kVariantPref = "variant"; var kCombineConsecutivePref = "combineConsecutive"; var kCombineConsecutiveIntervalPref = "combineConsecutiveInterval"; var DEFAULT_THEME = "bubbles"; var DEFAULT_THEMES = ["bubbles", "dark", "mail", "papersheets", "simple"]; var kLineBreak = "@mozilla.org/windows-registry-key;1" in Cc ? "\r\n" : "\n"; XPCOMUtils.defineLazyGetter(lazy, "gPrefBranch", () => Services.prefs.getBranch(kMessagesStylePrefBranch) ); 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, "gTimeFormatter", () => { return new Services.intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit", }); }); ChromeUtils.defineESModuleGetters(lazy, { DownloadUtils: "resource://gre/modules/DownloadUtils.sys.mjs", ToLocaleFormat: "resource:///modules/ToLocaleFormat.sys.mjs", }); var gCurrentTheme = null; function getChromeFile(aURI) { try { let channel = Services.io.newChannel( aURI, null, null, null, Services.scriptSecurityManager.getSystemPrincipal(), null, Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL, Ci.nsIContentPolicy.TYPE_OTHER ); let stream = channel.open(); let sstream = Cc["@mozilla.org/scriptableinputstream;1"].createInstance( Ci.nsIScriptableInputStream ); sstream.init(stream); let text = sstream.read(sstream.available()); sstream.close(); return text; } catch (e) { if (e.result != Cr.NS_ERROR_FILE_NOT_FOUND) { dump("Getting " + aURI + ": " + e + "\n"); } return null; } } function HTMLTheme(aBaseURI) { let files = { footer: "Footer.html", header: "Header.html", status: "Status.html", statusNext: "NextStatus.html", incomingContent: "Incoming/Content.html", incomingContext: "Incoming/Context.html", incomingNextContent: "Incoming/NextContent.html", incomingNextContext: "Incoming/NextContext.html", outgoingContent: "Outgoing/Content.html", outgoingContext: "Outgoing/Context.html", outgoingNextContent: "Outgoing/NextContent.html", outgoingNextContext: "Outgoing/NextContext.html", }; for (let id in files) { let html = getChromeFile(aBaseURI + files[id]); if (html) { Object.defineProperty(this, id, { value: html }); } } if (!("incomingContent" in files)) { throw new Error("Invalid theme: Incoming/Content.html is missing!"); } } HTMLTheme.prototype = { get footer() { return ""; }, get header() { return ""; }, get status() { return this.incomingContent; }, get statusNext() { return this.status; }, get incomingContent() { throw new Error("Incoming/Content.html is a required file"); }, get incomingNextContent() { return this.incomingContent; }, get outgoingContent() { return this.incomingContent; }, get outgoingNextContent() { return this.incomingNextContent; }, get incomingContext() { return this.incomingContent; }, get incomingNextContext() { return this.incomingNextContent; }, get outgoingContext() { return this.hasOwnProperty("outgoingContent") ? this.outgoingContent : this.incomingContext; }, get outgoingNextContext() { return this.hasOwnProperty("outgoingNextContent") ? this.outgoingNextContent : this.incomingNextContext; }, }; function plistToJSON(aElt) { switch (aElt.localName) { case "true": return true; case "false": return false; case "string": case "data": return aElt.textContent; case "real": return parseFloat(aElt.textContent); case "integer": return parseInt(aElt.textContent, 10); case "dict": let res = {}; let nodes = aElt.children; for (let i = 0; i < nodes.length; ++i) { if (nodes[i].nodeName == "key") { let key = nodes[i].textContent; ++i; while (!Element.isInstance(nodes[i])) { ++i; } res[key] = plistToJSON(nodes[i]); } } return res; case "array": let array = []; nodes = aElt.children; for (let i = 0; i < nodes.length; ++i) { if (Element.isInstance(nodes[i])) { array.push(plistToJSON(nodes[i])); } } return array; default: throw new Error("Unknown tag in plist file"); } } function getInfoPlistContent(aBaseURI) { try { let channel = Services.io.newChannel( aBaseURI + "Info.plist", null, null, null, Services.scriptSecurityManager.getSystemPrincipal(), null, Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL, Ci.nsIContentPolicy.TYPE_OTHER ); let stream = channel.open(); let parser = new DOMParser(); let doc = parser.parseFromStream( stream, null, stream.available(), "text/xml" ); if (doc.documentElement.localName != "plist") { throw new Error("Invalid Info.plist file"); } let node = doc.documentElement.firstElementChild; while (node && !Element.isInstance(node)) { node = node.nextElementSibling; } if (!node || node.localName != "dict") { throw new Error("Empty or invalid Info.plist file"); } return plistToJSON(node); } catch (e) { console.error(e); return null; } } function getChromeBaseURI(aThemeName) { if (DEFAULT_THEMES.includes(aThemeName)) { return "chrome://messenger-messagestyles/skin/" + aThemeName + "/"; } return "chrome://" + aThemeName + "/skin/"; } export function getThemeByName(aName) { let baseURI = getChromeBaseURI(aName); let metadata = getInfoPlistContent(baseURI); if (!metadata) { throw new Error("Cannot load theme " + aName); } return { name: aName, variant: "default", baseURI, metadata, html: new HTMLTheme(baseURI), combineConsecutive: lazy.gPrefBranch.getBoolPref(kCombineConsecutivePref), combineConsecutiveInterval: lazy.gPrefBranch.getIntPref( kCombineConsecutiveIntervalPref ), }; } export function getCurrentTheme() { let name = lazy.gPrefBranch.getCharPref(kThemePref); let variant = lazy.gPrefBranch.getCharPref(kVariantPref); if ( gCurrentTheme && gCurrentTheme.name == name && gCurrentTheme.variant == variant ) { return gCurrentTheme; } try { gCurrentTheme = getThemeByName(name); gCurrentTheme.variant = variant; } catch (e) { console.error(e); gCurrentTheme = getThemeByName(DEFAULT_THEME); gCurrentTheme.variant = "default"; } return gCurrentTheme; } function getDirectoryEntries(aDir) { let ios = Services.io; let uri = ios.newURI(aDir); let cr = Cc["@mozilla.org/chrome/chrome-registry;1"].getService( Ci.nsIXULChromeRegistry ); while (uri.scheme == "chrome") { uri = cr.convertChromeURL(uri); } // remove any trailing file name added by convertChromeURL let spec = uri.spec.replace(/[^\/]+$/, ""); uri = ios.newURI(spec); let results = []; if (uri.scheme == "jar") { uri.QueryInterface(Ci.nsIJARURI); let strEntry = uri.JAREntry; if (!strEntry) { return []; } let zr = Cc["@mozilla.org/libjar/zip-reader;1"].createInstance( Ci.nsIZipReader ); let jarFile = uri.JARFile; if (jarFile instanceof Ci.nsIJARURI) { let innerZr = Cc["@mozilla.org/libjar/zip-reader;1"].createInstance( Ci.nsIZipReader ); innerZr.open(jarFile.JARFile.QueryInterface(Ci.nsIFileURL).file); zr.openInner(innerZr, jarFile.JAREntry); } else { zr.open(jarFile.QueryInterface(Ci.nsIFileURL).file); } if (!zr.hasEntry(strEntry) || !zr.getEntry(strEntry).isDirectory) { zr.close(); return []; } let escapedEntry = strEntry.replace(/([*?$[\]^~()\\])/g, "\\$1"); let filter = escapedEntry + "?*~" + escapedEntry + "?*/?*"; let entries = zr.findEntries(filter); let parentLength = strEntry.length; for (let entry of entries) { results.push(entry.substring(parentLength)); } zr.close(); } else if (uri.scheme == "file") { uri.QueryInterface(Ci.nsIFileURL); let dir = uri.file; if (!dir.exists() || !dir.isDirectory()) { return []; } for (let file of dir.directoryEntries) { results.push(file.leafName); } } return results; } export function getThemeVariants(aTheme) { let variants = getDirectoryEntries(aTheme.baseURI + "Variants/"); return variants .filter(v => v.endsWith(".css")) .map(v => v.substring(0, v.length - 4)); } /* helper function for replacements in messages */ function getBuddyFromMessage(aMsg) { if (aMsg.incoming) { let conv = aMsg.conversation; if (!conv.isChat) { return conv.buddy; } } return null; } function getStatusIconFromBuddy(aBuddy) { let status = "unknown"; if (aBuddy) { if (!aBuddy.online) { status = "offline"; } else if (aBuddy.idle) { status = "idle"; } else if (!aBuddy.available) { status = "away"; } else { status = "available"; } } return "chrome://chat/skin/" + status + "-16.png"; } var footerReplacements = { chatName: aConv => lazy.TXTToHTML(aConv.title), sourceName: aConv => lazy.TXTToHTML(aConv.account.alias || aConv.account.name), destinationName: aConv => lazy.TXTToHTML(aConv.name), destinationDisplayName: aConv => lazy.TXTToHTML(aConv.title), incomingIconPath(aConv) { let buddy; return ( (!aConv.isChat && (buddy = aConv.buddy) && buddy.buddyIconFilename) || "incoming_icon.png" ); }, outgoingIconPath: aConv => "outgoing_icon.png", timeOpened(aConv, aFormat) { let date = new Date(aConv.startDate / 1000); if (aFormat) { return lazy.ToLocaleFormat(aFormat, date); } return lazy.gTimeFormatter.format(date); }, }; function formatAutoResponce(aTxt) { return Services.strings .createBundle("chrome://chat/locale/conversations.properties") .formatStringFromName("autoReply", [aTxt]); } var statusMessageReplacements = { message: aMsg => '' + (aMsg.autoResponse ? formatAutoResponce(aMsg.message) : aMsg.message) + "", time(aMsg, aFormat) { let date = new Date(aMsg.time * 1000); if (aFormat) { return lazy.ToLocaleFormat(aFormat, date); } return lazy.gTimeFormatter.format(date); }, timestamp: aMsg => aMsg.time, shortTime(aMsg) { return lazy.gTimeFormatter.format(new Date(aMsg.time * 1000)); }, messageClasses(aMsg) { let msgClass = []; if (aMsg.system) { msgClass.push("event"); } else { msgClass.push("message"); if (aMsg.incoming) { msgClass.push("incoming"); } else if (aMsg.outgoing) { msgClass.push("outgoing"); } if (aMsg.action) { msgClass.push("action"); } if (aMsg.autoResponse) { msgClass.push("autoreply"); } } if (aMsg.containsNick) { msgClass.push("nick"); } if (aMsg.error) { msgClass.push("error"); } if (aMsg.delayed) { msgClass.push("delayed"); } if (aMsg.notification) { msgClass.push("notification"); } if (aMsg.noFormat) { msgClass.push("monospaced"); } if (aMsg.noCollapse) { msgClass.push("no-collapse"); } return msgClass.join(" "); }, }; function formatSender(aName, isEncrypted = false) { let otr = isEncrypted ? " message-encrypted" : ""; return `${lazy.TXTToHTML(aName)}`; } var messageReplacements = { userIconPath(aMsg) { // If the protocol plugin provides an icon for the message, use it. let iconURL = aMsg.iconURL; if (iconURL) { return iconURL; } // For outgoing messages, use the current user icon. if (aMsg.outgoing) { iconURL = aMsg.conversation.account.statusInfo.getUserIcon(); if (iconURL) { return iconURL.spec; } } // Fallback to the theme's default icons. return (aMsg.incoming ? "Incoming" : "Outgoing") + "/buddy_icon.svg"; }, senderScreenName: aMsg => formatSender(aMsg.who, aMsg.isEncrypted), sender: aMsg => formatSender(aMsg.alias || aMsg.who, aMsg.isEncrypted), senderColor: aMsg => aMsg.color, senderStatusIcon: aMsg => getStatusIconFromBuddy(getBuddyFromMessage(aMsg)), messageDirection: aMsg => "ltr", // no theme actually use this, don't bother making sure this is the real // serverside alias senderDisplayName: aMsg => formatSender(aMsg.alias || aMsg.who, aMsg.isEncrypted), service: aMsg => aMsg.conversation.account.protocol.name, textbackgroundcolor: (aMsg, aFormat) => "transparent", // FIXME? __proto__: statusMessageReplacements, }; var statusReplacements = { status: aMsg => "", // FIXME statusIcon(aMsg) { let conv = aMsg.conversation; let buddy = null; if (!conv.isChat) { buddy = conv.buddy; } return getStatusIconFromBuddy(buddy); }, __proto__: statusMessageReplacements, }; var kReplacementRegExp = /%([a-zA-Z]*)(\{([^\}]*)\})?%/g; function replaceKeywordsInHTML(aHTML, aReplacements, aReplacementArg) { kReplacementRegExp.lastIndex = 0; let previousIndex = 0; let result = ""; let match; while ((match = kReplacementRegExp.exec(aHTML))) { let content = ""; if (match[1] in aReplacements) { content = aReplacements[match[1]](aReplacementArg, match[3]); } else { console.error( "Unknown replacement string %" + match[1] + "% in message styles." ); } result += aHTML.substring(previousIndex, match.index) + content; previousIndex = kReplacementRegExp.lastIndex; } return result + aHTML.slice(previousIndex); } /** * Determine if a message should be grouped with a previous message. * * @param {object} aTheme - The theme the messages will be displayed in. * @param {imIMessage} aMsg - The message that is about to be appended. * @param {imIMessage} aPreviousMsg - The last message that was displayed. * @returns {boolean} If the message should be grouped with the previous one. */ export function isNextMessage(aTheme, aMsg, aPreviousMsg) { if ( !aTheme.combineConsecutive || (hasMetadataKey(aTheme, "DisableCombineConsecutive") && getMetadata(aTheme, "DisableCombineConsecutive")) ) { return false; } if (!aPreviousMsg) { return false; } if (aMsg.system && aPreviousMsg.system) { return true; } if ( aMsg.who != aPreviousMsg.who || aMsg.outgoing != aPreviousMsg.outgoing || aMsg.incoming != aPreviousMsg.incoming || aMsg.system != aPreviousMsg.system ) { return false; } let timeDifference = aMsg.time - aPreviousMsg.time; return ( timeDifference >= 0 && timeDifference <= aTheme.combineConsecutiveInterval ); } /** * Determine whether the message was a next message when it was initially * inserted. * * @param {imIMessage} msg * @param {DOMDocument} doc * @returns {boolean} If the message is a next message. Returns false if the * message doesn't already exist in the conversation. */ export function wasNextMessage(msg, doc) { return Boolean( doc.querySelector(`#Chat [data-remote-id="${CSS.escape(msg.remoteId)}"]`) ?.dataset.isNext ); } /** * Create an HTML string to insert the message into the conversation. * * @param {imIMessage} aMsg * @param {object} aTheme * @param {boolean} aIsNext - If this message is immediately following a * message of the same origin. Used for visual grouping. * @param {boolean} aIsContext - If this message was already read by the user * previously and just provided for context. * @returns {string} Raw HTML for the message. */ export function getHTMLForMessage(aMsg, aTheme, aIsNext, aIsContext) { let html, replacements; if (aMsg.system) { html = aIsNext ? aTheme.html.statusNext : aTheme.html.status; replacements = statusReplacements; } else { html = aMsg.incoming ? "incoming" : "outgoing"; if (aIsNext) { html += "Next"; } html += aIsContext ? "Context" : "Content"; html = aTheme.html[html]; replacements = messageReplacements; if (aMsg.action) { let actionMessageTemplate = "* %message% *"; if (hasMetadataKey(aTheme, "ActionMessageTemplate")) { actionMessageTemplate = getMetadata(aTheme, "ActionMessageTemplate"); } html = html.replace(/%message%/g, actionMessageTemplate); } } return replaceKeywordsInHTML(html, replacements, aMsg); } /** * * @param {imIMessage} aMsg * @param {string} aHTML * @param {DOMDocument} aDoc * @param {boolean} aIsNext * @returns {Element} */ export function insertHTMLForMessage(aMsg, aHTML, aDoc, aIsNext) { let insert = aDoc.getElementById("insert"); if (insert && !aIsNext) { insert.remove(); insert = null; } let parent = insert ? insert.parentNode : aDoc.getElementById("Chat"); let documentFragment = getDocumentFragmentFromHTML(aDoc, aHTML); // If the parent already has a remote ID, we remove it, since it now contains // multiple different messages. if (parent.dataset.remoteId) { for (let child of parent.children) { child.dataset.remoteId = parent.dataset.remoteId; child.dataset.isNext = true; } delete parent.dataset.remoteId; } let result = documentFragment.firstElementChild; // store the prplIMessage object in each of the "root" node that // will be inserted into the document, so that selection code can // retrieve the message by just looking at the parent node until it // finds something. for (let root = result; root; root = root.nextElementSibling) { // Skip the insert placeholder. if (root.id === "insert") { continue; } root._originalMsg = aMsg; // Store remote ID of the message in the DOM for fast retrieval root.dataset.remoteId = aMsg.remoteId; if (aIsNext) { root.dataset.isNext = aIsNext; } } // make sure the result is an HTMLElement and not some text (whitespace)... while ( result && !( result.nodeType == result.ELEMENT_NODE && result.namespaceURI == "http://www.w3.org/1999/xhtml" ) ) { result = result.nextElementSibling; } if (insert) { parent.replaceChild(documentFragment, insert); } else { parent.appendChild(documentFragment); } return result; } /** * Replace the HTML of an already displayed message based on the matching * remote ID. * * @param {imIMessage} msg - Message to insert the updated contents of. * @param {string} html - The HTML contents to insert. * @param {Document} doc - The HTML document the message should be replaced * in. * @param {boolean} isNext - If this message is immediately following a * message of the same origin. Used for visual grouping. */ export function replaceHTMLForMessage(msg, html, doc, isNext) { // If the updated message has no remote ID, do nothing. if (!msg.remoteId) { return; } let message = getExistingMessage(msg.remoteId, doc); // If we couldn't find a matching message, do nothing. if (!message.length) { return; } let documentFragment = getDocumentFragmentFromHTML(doc, html); // We don't want to add an insert point when replacing a message. documentFragment.querySelector("#insert")?.remove(); // store the prplIMessage object in each of the "root" nodes that // will be inserted into the document, so that the selection code can // retrieve the message by just looking at the parent node until it // finds something. for ( let root = documentFragment.firstElementChild; root; root = root.nextElementSibling ) { root._originalMsg = msg; root.dataset.remoteId = msg.remoteId; if (isNext) { root.dataset.isNext = isNext; } } // Remove all but the first element of the original message if (message.length > 1) { let range = doc.createRange(); range.setStartBefore(message[1]); range.setEndAfter(message[message.length - 1]); range.deleteContents(); } // Insert the new message into the DOM message[0].replaceWith(documentFragment); } /** * Remove all elements belonging to a message from the document, based on the * remote ID of the message. * * @param {string} remoteId * @param {Document} doc */ export function removeMessage(remoteId, doc) { let message = getExistingMessage(remoteId, doc); // If we couldn't find a matching message, do nothing. if (!message.length) { return; } // Remove all elements of the original message let range = doc.createRange(); range.setStartBefore(message[0]); range.setEndAfter(message[message.length - 1]); range.deleteContents(); } function hasMetadataKey(aTheme, aKey) { return ( aKey in aTheme.metadata || (aTheme.variant != "default" && aKey + ":" + aTheme.variant in aTheme.metadata) || ("DefaultVariant" in aTheme.metadata && aKey + ":" + aTheme.metadata.DefaultVariant in aTheme.metadata) ); } function getMetadata(aTheme, aKey) { if ( aTheme.variant != "default" && aKey + ":" + aTheme.variant in aTheme.metadata ) { return aTheme.metadata[aKey + ":" + aTheme.variant]; } if ( "DefaultVariant" in aTheme.metadata && aKey + ":" + aTheme.metadata.DefaultVariant in aTheme.metadata ) { return aTheme.metadata[aKey + ":" + aTheme.metadata.DefaultVariant]; } return aTheme.metadata[aKey]; } export function initHTMLDocument(aConv, aTheme, aDoc) { let base = aDoc.createElement("base"); base.href = aTheme.baseURI; aDoc.head.appendChild(base); // Screen readers may read the title of the document, so provide one // to avoid an ugly fallback to the URL (see bug 1165). aDoc.title = aConv.title; function addCSS(aHref) { let link = aDoc.createElement("link"); link.setAttribute("rel", "stylesheet"); link.setAttribute("href", aHref); link.setAttribute("type", "text/css"); aDoc.head.appendChild(link); } addCSS("chrome://chat/skin/conv.css"); addCSS("chrome://messenger/skin/icons.css"); // add css to handle DefaultFontFamily and DefaultFontSize let cssText = ""; if (hasMetadataKey(aTheme, "DefaultFontFamily")) { cssText += "font-family: " + getMetadata(aTheme, "DefaultFontFamily") + ";"; } if (hasMetadataKey(aTheme, "DefaultFontSize")) { cssText += "font-size: " + getMetadata(aTheme, "DefaultFontSize") + ";"; } if (cssText) { addCSS("data:text/css,*{ " + cssText + " }"); } // add the main CSS file of the theme if (aTheme.metadata.MessageViewVersion >= 3 || aTheme.variant == "default") { addCSS("main.css"); } // add the CSS file of the variant if (aTheme.variant != "default") { addCSS("Variants/" + aTheme.variant + ".css"); } else if ("DefaultVariant" in aTheme.metadata) { addCSS("Variants/" + aTheme.metadata.DefaultVariant + ".css"); } aDoc.body.id = "ibcontent"; // We insert the whole content of body: chat div, footer let html = '
'; html += replaceKeywordsInHTML(aTheme.html.footer, footerReplacements, aConv); let frag = getDocumentFragmentFromHTML(aDoc, html); aDoc.body.appendChild(frag); if (!aTheme.metadata.NoScript) { const scriptTag = aDoc.createElement("script"); scriptTag.src = "inline.js"; aDoc.body.appendChild(scriptTag); } aDoc.defaultView.convertTimeUnits = lazy.DownloadUtils.convertTimeUnits; } /* Selection stuff */ function getEllipsis() { let ellipsis = "[\u2026]"; try { ellipsis = Services.prefs.getComplexValue( "messenger.conversations.selections.ellipsis", Ci.nsIPrefLocalizedString ).data; } catch (e) {} return ellipsis; } function _serializeDOMObject(aDocument, aInitFunction) { // This shouldn't really be a constant, as we want to support // text/html too in the future. const type = "text/plain"; let encoder = Cu.createDocumentEncoder(type); encoder.init(aDocument, type, Ci.nsIDocumentEncoder.OutputPreformatted); aInitFunction(encoder); let result = encoder.encodeToString(); return result; } function serializeRange(aRange) { return _serializeDOMObject( aRange.startContainer.ownerDocument, function (aEncoder) { aEncoder.setRange(aRange); } ); } function serializeNode(aNode) { return _serializeDOMObject(aNode.ownerDocument, function (aEncoder) { aEncoder.setNode(aNode); }); } /* This function is used to pretty print a selection inside a conversation area */ export function serializeSelection(aSelection) { // We have two kinds of selection serialization: // - The short version, used when only a part of message is // selected, or if nothing interesting is selected let shortSelection = ""; // - The long version, which is used: // * when both some of the message text and some of the context // (sender, time, ...) is selected; // * when several messages are selected at once // This version uses an array, with each message formatted // through the theme system. let longSelection = []; // We first assume that we are going to use the short version, but // while working on creating the short version, we prepare // everything to be able to switch to the long version if we later // discover that it is in fact needed. let shortVersionPossible = true; // Sometimes we need to know if a selection range is inside the same // message as the previous selection range, so we keep track of the // last message we have processed. let lastMessage = null; for (let i = 0; i < aSelection.rangeCount; ++i) { let range = aSelection.getRangeAt(i); let messages = getMessagesForRange(range); // If at least one selected message has some of its text selected, // remove from the selection all the messages that have no text // selected let testFunction = msg => msg.isTextSelected(); if (messages.some(testFunction)) { messages = messages.filter(testFunction); } if (!messages.length) { // Do it only if it wouldn't override a better already found selection if (!shortSelection) { shortSelection = serializeRange(range); } continue; } if ( shortVersionPossible && messages.length == 1 && (!messages[0].isTextSelected() || messages[0].onlyTextSelected()) && (!lastMessage || lastMessage.msg == messages[0].msg || lastMessage.msg.who == messages[0].msg.who) ) { if (shortSelection) { if (lastMessage.msg != messages[0].msg) { // Add the ellipsis only if the previous message was cut if (lastMessage.cutEnd) { shortSelection += " " + getEllipsis(); } shortSelection += kLineBreak; } else { shortSelection += " " + getEllipsis() + " "; } } shortSelection += serializeRange(range); longSelection.push(messages[0].getFormattedMessage()); } else { shortVersionPossible = false; for (let m = 0; m < messages.length; ++m) { let message = messages[m]; if (m == 0 && lastMessage && lastMessage.msg == message.msg) { let text = message.getSelectedText(); if (message.cutEnd) { text += " " + getEllipsis(); } longSelection[longSelection.length - 1] += " " + text; } else { longSelection.push(message.getFormattedMessage()); } } } lastMessage = messages[messages.length - 1]; } if (shortVersionPossible) { return shortSelection || aSelection.toString(); } return longSelection.join(kLineBreak); } function SelectedMessage(aRootNode, aRange) { this._rootNodes = [aRootNode]; this._range = aRange; } SelectedMessage.prototype = { get msg() { return this._rootNodes[0]._originalMsg; }, addRoot(aRootNode) { this._rootNodes.push(aRootNode); }, // Helper function that returns the first span node of class // ib-msg-text under the rootNodes of the selected message. _getSpanNode() { // first use the cached value if any if (this._spanNode) { return this._spanNode; } let spanNode = null; // If we could use NodeFilter.webidl, we wouldn't have to make up our own // object. FILTER_REJECT is not used here, but included for completeness. const NodeFilter = { SHOW_ELEMENT: 0x1, FILTER_ACCEPT: 1, FILTER_REJECT: 2, FILTER_SKIP: 3, }; // helper filter function for the tree walker let filter = function (node) { return node.className == "ib-msg-txt" ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP; }; // walk the DOM subtrees of each root, keep the first correct span node for (let i = 0; !spanNode && i < this._rootNodes.length; ++i) { let rootNode = this._rootNodes[i]; // the TreeWalker doesn't test the root node, special case it first if (filter(rootNode) == NodeFilter.FILTER_ACCEPT) { spanNode = rootNode; break; } let treeWalker = rootNode.ownerDocument.createTreeWalker( rootNode, NodeFilter.SHOW_ELEMENT, { acceptNode: filter }, false ); spanNode = treeWalker.nextNode(); } return (this._spanNode = spanNode); }, // Initialize _textSelected and _otherSelected; if _textSelected is true, // also initialize _selectedText and _cutBegin/End. _initSelectedText() { if ("_textSelected" in this) { // Already initialized. return; } let spanNode = this._getSpanNode(); if (!spanNode) { // can happen if the message text is under a separate root node // that isn't selected at all this._textSelected = false; this._otherSelected = true; return; } let startPoint = this._range.comparePoint(spanNode, 0); // Note that we are working on the HTML DOM, including text nodes, // so we need to use childNodes here and below. let endPoint = this._range.comparePoint( spanNode, spanNode.childNodes.length ); if (startPoint <= 0 && endPoint >= 0) { let range = this._range.cloneRange(); if (startPoint >= 0) { range.setStart(spanNode, 0); } if (endPoint <= 0) { range.setEnd(spanNode, spanNode.childNodes.length); } this._selectedText = serializeRange(range); // if the selected text is empty, set _selectedText to false // this happens if the carret is at the offset 0 in the span node this._textSelected = this._selectedText != ""; } else { this._textSelected = false; } if (this._textSelected) { // to check if the start or end is cut, the result of // comparePoint is not enough because the selection range may // start or end in a text node instead of the span node if (startPoint == -1) { let range = spanNode.ownerDocument.createRange(); range.setStart(spanNode, 0); range.setEnd(this._range.startContainer, this._range.startOffset); this._cutBegin = serializeRange(range) != ""; } else { this._cutBegin = false; } if (endPoint == 1) { let range = spanNode.ownerDocument.createRange(); range.setStart(this._range.endContainer, this._range.endOffset); range.setEnd(spanNode, spanNode.childNodes.length); this._cutEnd = !/^(\r?\n)?$/.test(serializeRange(range)); } else { this._cutEnd = false; } } this._otherSelected = (startPoint >= 0 || endPoint <= 0) && // eliminate most negative cases (!this._textSelected || serializeRange(this._range).length > this._selectedText.length); }, get cutBegin() { this._initSelectedText(); return this._textSelected && this._cutBegin; }, get cutEnd() { this._initSelectedText(); return this._textSelected && this._cutEnd; }, isTextSelected() { this._initSelectedText(); return this._textSelected; }, onlyTextSelected() { this._initSelectedText(); return !this._otherSelected; }, getSelectedText() { this._initSelectedText(); return this._textSelected ? this._selectedText : ""; }, getFormattedMessage() { // First, get the selected text this._initSelectedText(); let msg = this.msg; let text; if (this._textSelected) { // Add ellipsis is needed text = (this._cutBegin ? getEllipsis() + " " : "") + this._selectedText + (this._cutEnd ? " " + getEllipsis() : ""); } else { let div = this._rootNodes[0].ownerDocument.createElement("div"); let divChildren = getDocumentFragmentFromHTML( div.ownerDocument, msg.autoResponse ? formatAutoResponce(msg.message) : msg.message ); div.appendChild(divChildren); text = serializeNode(div); } // then get the suitable replacements and templates for this message let getLocalizedPrefWithDefault = function (aName, aDefault) { try { let prefBranch = Services.prefs.getBranch( "messenger.conversations.selections." ); return prefBranch.getComplexValue(aName, Ci.nsIPrefLocalizedString) .data; } catch (e) { return aDefault; } }; let html, replacements; if (msg.system) { replacements = statusReplacements; html = getLocalizedPrefWithDefault( "systemMessagesTemplate", "%time% - %message%" ); } else { replacements = messageReplacements; if (msg.action) { html = getLocalizedPrefWithDefault( "actionMessagesTemplate", "%time% * %sender% %message%" ); } else { html = getLocalizedPrefWithDefault( "contentMessagesTemplate", "%time% - %sender%: %message%" ); } } // Overrides default replacements so that they don't add a span node. // Also, this uses directly the text variable so that we don't // have to change the content of msg.message and revert it // afterwards. replacements = { message: aMsg => text, sender: aMsg => aMsg.alias || aMsg.who, __proto__: replacements, }; // Finally, let the theme system do the magic! return replaceKeywordsInHTML(html, replacements, msg); }, }; export function getMessagesForRange(aRange) { let result = []; // will hold the final result let messages = {}; // used to prevent duplicate messages in the result array // cache the range boundaries, they will be used a lot let endNode = aRange.endContainer; let startNode = aRange.startContainer; // Helper function to recursively look for _originalMsg JS // properties on DOM nodes, and stop when endNode is reached. // Found nodes are pushed into the rootNodes array. let processSubtree = function (aNode) { if (aNode._originalMsg) { // store the result if (!(aNode._originalMsg.id in messages)) { // we've found a new message! let newMessage = new SelectedMessage(aNode, aRange); messages[aNode._originalMsg.id] = newMessage; result.push(newMessage); } else { // we've found another root of an already known message messages[aNode._originalMsg.id].addRoot(aNode); } } // check if we have reached the end node if (aNode == endNode) { return true; } // recurse through children if ( aNode.nodeType == aNode.ELEMENT_NODE && aNode.namespaceURI == "http://www.w3.org/1999/xhtml" ) { for (let i = 0; i < aNode.children.length; ++i) { if (processSubtree(aNode.children[i])) { return true; } } } return false; }; let currentNode = aRange.commonAncestorContainer; if ( currentNode.nodeType == currentNode.ELEMENT_NODE && currentNode.namespaceURI == "http://www.w3.org/1999/xhtml" ) { // Determine the index of the first and last children of currentNode // that we should process. let found = false; let start = 0; if (currentNode == startNode) { // we want to process all children found = true; start = aRange.startOffset; } else { // startNode needs to be a direct child of currentNode while (startNode.parentNode != currentNode) { startNode = startNode.parentNode; } } let end; if (currentNode == endNode) { end = aRange.endOffset; } else { end = currentNode.children.length; } for (let i = start; i < end; ++i) { let node = currentNode.children[i]; // don't do anything until we find the startNode found = found || node == startNode; if (!found) { continue; } if (processSubtree(node)) { break; } } } // The selection may not include any root node of the first touched // message, in this case, the DOM traversal of the DOM range // couldn't give us the first message. Make sure we actually have // the message in which the range starts. let firstRoot = aRange.startContainer; while (firstRoot && !firstRoot._originalMsg) { firstRoot = firstRoot.parentNode; } if (firstRoot && !(firstRoot._originalMsg.id in messages)) { result.unshift(new SelectedMessage(firstRoot, aRange)); } return result; } /** * Turns a raw HTML string into a DocumentFragment usable in the provided * document. * * @param {Document} doc - The Document the fragment will belong to. * @param {string} html - The target HTML to be parsed. * * @returns {DocumentFragment} */ export function getDocumentFragmentFromHTML(doc, html) { let uri = Services.io.newURI(doc.baseURI); let flags = Ci.nsIParserUtils.SanitizerAllowStyle; let context = doc.createElement("div"); return ParserUtils.parseFragment(html, flags, false, uri, context); } /** * Get all nodes that make up the given message if any. * * @param {string} remoteId - Remote ID of the message to get * @param {Document} doc - Document the message is in. * @returns {NodeList} Node list of all the parts of the message, or an empty * list if the message is not found. */ function getExistingMessage(remoteId, doc) { let parent = doc.getElementById("Chat"); return parent.querySelectorAll(`[data-remote-id="${CSS.escape(remoteId)}"]`); }