diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
commit | 6bf0a5cb5034a7e684dcc3500e841785237ce2dd (patch) | |
tree | a68f146d7fa01f0134297619fbe7e33db084e0aa /comm/chat/modules/imThemes.sys.mjs | |
parent | Initial commit. (diff) | |
download | thunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.tar.xz thunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.zip |
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'comm/chat/modules/imThemes.sys.mjs')
-rw-r--r-- | comm/chat/modules/imThemes.sys.mjs | 1333 |
1 files changed, 1333 insertions, 0 deletions
diff --git a/comm/chat/modules/imThemes.sys.mjs b/comm/chat/modules/imThemes.sys.mjs new file mode 100644 index 0000000000..5b7f0ee824 --- /dev/null +++ b/comm/chat/modules/imThemes.sys.mjs @@ -0,0 +1,1333 @@ +/* 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 => + '<span class="ib-msg-txt">' + + (aMsg.autoResponse ? formatAutoResponce(aMsg.message) : aMsg.message) + + "</span>", + 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 `<span class="ib-sender${otr}">${lazy.TXTToHTML(aName)}</span>`; +} +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 = '<div id="Chat" aria-live="polite"></div>'; + 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)}"]`); +} |