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/mail/modules | |
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/mail/modules')
34 files changed, 15672 insertions, 0 deletions
diff --git a/comm/mail/modules/AttachmentChecker.jsm b/comm/mail/modules/AttachmentChecker.jsm new file mode 100644 index 0000000000..88da3a1e83 --- /dev/null +++ b/comm/mail/modules/AttachmentChecker.jsm @@ -0,0 +1,118 @@ +/* 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/. */ + +const EXPORTED_SYMBOLS = ["AttachmentChecker"]; + +var AttachmentChecker = { + getAttachmentKeywords, +}; + +/** + * Check whether the character is a CJK character or not. + * + * @returns true if it is a CJK character. + */ +function IsCJK(code) { + if (code >= 0x2000 && code <= 0x9fff) { + // Hiragana, Katakana and Kanaji + return true; + } else if (code >= 0xac00 && code <= 0xd7ff) { + // Hangul + return true; + } else if (code >= 0xf900 && code <= 0xffff) { + // Hiragana, Katakana and Kanaji + return true; + } + return false; +} + +/** + * Get the (possibly-empty) list of attachment keywords in this message. + * + * @returns the (possibly-empty) list of attachment keywords in this message + */ +function getAttachmentKeywords(mailData, keywordsInCsv) { + // The empty string would get split to an array of size 1. Avoid that... + var keywordsArray = keywordsInCsv ? keywordsInCsv.split(",") : []; + + function escapeRegxpSpecials(inputString) { + const specials = [ + ".", + "\\", + "^", + "$", + "*", + "+", + "?", + "|", + "(", + ")", + "[", + "]", + "{", + "}", + ]; + var re = new RegExp("(\\" + specials.join("|\\") + ")", "g"); + inputString = inputString.replace(re, "\\$1"); + return inputString.replace(" ", "\\s+"); + } + + // NOT_W is the character class that isn't in the Unicode classes "Ll", + // "Lu" and "Lt". It should work like \W, if \W knew about Unicode. + const NOT_W = + "[^\\u0041-\\u005a\\u0061-\\u007a\\u00aa\\u00b5\\u00ba\\u00c0-\\u00d6\\u00d8-\\u00f6\\u00f8-\\u01ba\\u01bc-\\u01bf\\u01c4-\\u02ad\\u0386\\u0388-\\u0481\\u048c-\\u0556\\u0561-\\u0587\\u10a0-\\u10c5\\u1e00-\\u1fbc\\u1fbe\\u1fc2-\\u1fcc\\u1fd0-\\u1fdb\\u1fe0-\\u1fec\\u1ff2-\\u1ffc\\u207f\\u2102\\u2107\\u210a-\\u2113\\u2115\\u2119-\\u211d\\u2124\\u2126\\u2128\\u212a-\\u212d\\u212f-\\u2131\\u2133\\u2134\\u2139\\ufb00-\\ufb17\\uff21-\\uff3a\\uff41-\\uff5a]"; + + var keywordsFound = []; + for (var i = 0; i < keywordsArray.length; i++) { + var kw = escapeRegxpSpecials(keywordsArray[i]); + // If the keyword starts (ends) with a CJK character, we don't care + // what the previous (next) character is, because the words aren't + // space delimited. + if (keywordsArray[i].charAt(0) == ".") { + // like .pdf + // For this case we want to match the whole document name. + let start = "(([^\\s]*)\\b)"; + let end = IsCJK(kw.charCodeAt(kw.length - 1)) ? "" : "(\\s|$)"; + let re = new RegExp(start + kw + end, "ig"); + let matching = mailData.match(re); + if (matching) { + for (var j = 0; j < matching.length; j++) { + // Ignore the match if it was in a URL. + if (!/^(https?|ftp):\/\//i.test(matching[j])) { + // We can have several *different* matches for one dot-keyword. + // E.g. foo.pdf and bar.pdf would both match for .pdf. + var m = matching[j].trim(); + if (!keywordsFound.includes(m)) { + keywordsFound.push(m); + } + } + } + } + } else { + let start = IsCJK(kw.charCodeAt(0)) ? "" : "((^|\\s)\\S*)"; + let end = IsCJK(kw.charCodeAt(kw.length - 1)) ? "" : "(" + NOT_W + "|$)"; + let re = new RegExp(start + kw + end, "ig"); + let matching; + while ((matching = re.exec(mailData)) !== null) { + // Ignore the match if it was in a URL. + if (!/^(https?|ftp):\/\//i.test(matching[0].trim())) { + keywordsFound.push(keywordsArray[i]); + break; + } + } + } + } + return keywordsFound; +} + +// This file is also used as a Worker. +/* exported onmessage */ +/* globals postMessage */ +var onmessage = function (event) { + var keywordsFound = AttachmentChecker.getAttachmentKeywords( + event.data[0], + event.data[1] + ); + postMessage(keywordsFound); +}; diff --git a/comm/mail/modules/AttachmentInfo.sys.mjs b/comm/mail/modules/AttachmentInfo.sys.mjs new file mode 100644 index 0000000000..8d7fa7920b --- /dev/null +++ b/comm/mail/modules/AttachmentInfo.sys.mjs @@ -0,0 +1,626 @@ +/* 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 = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + DownloadPaths: "resource://gre/modules/DownloadPaths.sys.mjs", + Downloads: "resource://gre/modules/Downloads.sys.mjs", + FileUtils: "resource://gre/modules/FileUtils.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(lazy, { + GlodaUtils: "resource:///modules/gloda/GlodaUtils.jsm", + MailUtils: "resource:///modules/MailUtils.jsm", + NetUtil: "resource://gre/modules/NetUtil.jsm", +}); + +XPCOMUtils.defineLazyServiceGetters(lazy, { + gHandlerService: [ + "@mozilla.org/uriloader/handler-service;1", + "nsIHandlerService", + ], + gMIMEService: ["@mozilla.org/mime;1", "nsIMIMEService"], +}); + +XPCOMUtils.defineLazyGetter(lazy, "messengerBundle", () => { + return Services.strings.createBundle( + "chrome://messenger/locale/messenger.properties" + ); +}); + +/** + * A class to handle attachment information and actions. + */ +export class AttachmentInfo { + /** + * A cache of message/rfc822 attachments saved to temporary files for display. + * Saving the same attachment again is avoided. + * + * @type {Map<string, nsIFile>} + */ + #temporaryFiles = new Map(); + + /** + * A function to call when checking to see if an attachment exists or not, so + * that the display can be updated. + * + * @type {Function} + */ + #updateAttachmentsDisplayFn = null; + + /** + * Create a new attachment object which goes into the data attachment array. + * This method checks whether the passed attachment is empty or not. + * + * @param {object} options + * @param {string} options.contentType - The attachment's mimetype. + * @param {string} options.url - The URL for the attachment. + * @param {string} options.name - The name to be displayed for this attachment + * (usually the filename). + * @param {string} options.uri - The URI for the message containing the + * attachment. + * @param {boolean} options.isExternalAttachment - True if the attachment has + * been detached to file or is a link attachment. + * @param {object} options.message - The message object associated to this + * attachment. + * @param {function} [updateAttachmentsDisplayFn] - An optional callback + * function that is called to update the attachment display at appropriate + * times. + */ + constructor({ + contentType, + url, + name, + uri, + isExternalAttachment, + message, + updateAttachmentsDisplayFn, + }) { + this.message = message; + this.contentType = contentType; + this.name = name; + this.url = url; + this.uri = uri; + this.isExternalAttachment = isExternalAttachment; + this.#updateAttachmentsDisplayFn = updateAttachmentsDisplayFn; + // A |size| value of -1 means we don't have a valid size. Check again if + // |sizeResolved| is false. For internal attachments and link attachments + // with a reported size, libmime streams values to addAttachmentField() + // which updates this object. For external file attachments, |size| is updated + // in the #() function when the list is built. Deleted attachments + // are resolved to -1. + this.size = -1; + this.sizeResolved = this.isDeleted; + + // Remove [?&]part= from remote urls, after getting the partID. + // Remote urls, unlike non external mail part urls, may also contain query + // strings starting with ?; PART_RE does not handle this. + if (this.isLinkAttachment || this.isFileAttachment) { + let match = url.match(/[?&]part=[^&]+$/); + match = match && match[0]; + this.partID = match && match.split("part=")[1]; + this.url = url.replace(match, ""); + } else { + let match = lazy.GlodaUtils.PART_RE.exec(url); + this.partID = match && match[1]; + } + } + + /** + * Save this attachment to a file. + * + * @param {nsIMessenger} messenger + * The messenger object associated with the window. + */ + async save(messenger) { + if (!this.hasFile) { + return; + } + + let empty = await this.isEmpty(); + if (empty) { + return; + } + + messenger.saveAttachment( + this.contentType, + this.url, + encodeURIComponent(this.name), + this.uri, + this.isExternalAttachment + ); + } + + /** + * Open this attachment. + * + * @param {integer} [browsingContextId] + * The browsingContext of the browser that this attachment is being opened + * from. + */ + async open(browsingContext) { + if (!this.hasFile) { + return; + } + + let win = browsingContext.topChromeWindow; + let empty = await this.isEmpty(); + if (empty) { + let prompt = lazy.messengerBundle.GetStringFromName( + this.isExternalAttachment + ? "externalAttachmentNotFound" + : "emptyAttachment" + ); + Services.prompt.alert(win, null, prompt); + } else { + // @see MsgComposeCommands.js which has simililar opening functionality + let dotPos = this.name.lastIndexOf("."); + let extension = + dotPos >= 0 ? this.name.substring(dotPos + 1).toLowerCase() : ""; + if (this.contentType == "application/pdf" || extension == "pdf") { + let handlerInfo = lazy.gMIMEService.getFromTypeAndExtension( + this.contentType, + extension + ); + // Only open a new tab for pdfs if we are handling them internally. + if ( + !handlerInfo.alwaysAskBeforeHandling && + handlerInfo.preferredAction == Ci.nsIHandlerInfo.handleInternally + ) { + // Add the content type to avoid a "how do you want to open this?" + // dialog. The type may already be there, but that doesn't matter. + let url = this.url; + if (!url.includes("type=")) { + url += url.includes("?") ? "&" : "?"; + url += "type=application/pdf"; + } + let tabmail = win.document.getElementById("tabmail"); + if (!tabmail) { + // If no tabmail available in this window, try and find it in + // another. + let win = Services.wm.getMostRecentWindow("mail:3pane"); + tabmail = win?.document.getElementById("tabmail"); + } + if (tabmail) { + tabmail.openTab("contentTab", { + url, + background: false, + linkHandler: "single-page", + }); + tabmail.ownerGlobal.focus(); + return; + } + // If no tabmail, open PDF same as other attachments. + } + } + + // Just use the old method for handling messages, it works. + + let { name, url } = this; + + let sourceURI = Services.io.newURI(url); + async function saveToFile(path, isTmp = false) { + let buffer = await new Promise((resolve, reject) => { + lazy.NetUtil.asyncFetch( + { + uri: sourceURI, + loadUsingSystemPrincipal: true, + }, + (inputStream, status) => { + if (Components.isSuccessCode(status)) { + resolve(lazy.NetUtil.readInputStream(inputStream)); + } else { + reject( + new Components.Exception(`Failed to fetch ${path}`, status) + ); + } + } + ); + }); + await IOUtils.write(path, new Uint8Array(buffer)); + + if (!isTmp) { + // Create a download so that saved files show up under... Saved Files. + let file = await IOUtils.getFile(path); + lazy.Downloads.createDownload({ + source: { + url: sourceURI.spec, + }, + target: file, + startTime: new Date(), + }) + .then(async download => { + await download.start(); + let list = await lazy.Downloads.getList(lazy.Downloads.ALL); + await list.add(download); + }) + .catch(console.error); + } + } + + if (this.contentType == "message/rfc822") { + let tempFile = this.#temporaryFiles.get(url); + if (!tempFile?.exists()) { + tempFile = Services.dirsvc.get("TmpD", Ci.nsIFile); + tempFile.append("subPart.eml"); + tempFile.createUnique(0, 0o600); + await saveToFile(tempFile.path, true); + + this.#temporaryFiles.set(url, tempFile); + } + + lazy.MailUtils.openEMLFile( + win, + tempFile, + Services.io.newFileURI(tempFile) + ); + return; + } + + // Get the MIME info from the service. + + let mimeInfo; + try { + mimeInfo = lazy.gMIMEService.getFromTypeAndExtension( + this.contentType, + extension + ); + } catch (ex) { + // If the call above fails, which can happen on Windows where there's + // nothing registered for the file type, assume this generic type. + mimeInfo = lazy.gMIMEService.getFromTypeAndExtension( + "application/octet-stream", + "" + ); + } + // The default action is saveToDisk, which is not what we want. + // If we don't have a stored handler, ask before handling. + if (!lazy.gHandlerService.exists(mimeInfo)) { + mimeInfo.alwaysAskBeforeHandling = true; + mimeInfo.preferredAction = Ci.nsIHandlerInfo.alwaysAsk; + } + + // If we know what to do, do it. + + name = lazy.DownloadPaths.sanitize(name); + + let createTemporaryFileAndOpen = async mimeInfo => { + let tmpPath = PathUtils.join( + Services.dirsvc.get("TmpD", Ci.nsIFile).path, + "pid-" + Services.appinfo.processID + ); + await IOUtils.makeDirectory(tmpPath, { permissions: 0o700 }); + let tempFile = Cc["@mozilla.org/file/local;1"].createInstance( + Ci.nsIFile + ); + tempFile.initWithPath(tmpPath); + + tempFile.append(name); + tempFile.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, 0o600); + tempFile.remove(false); + + Cc["@mozilla.org/uriloader/external-helper-app-service;1"] + .getService(Ci.nsPIExternalAppLauncher) + .deleteTemporaryFileOnExit(tempFile); + + await saveToFile(tempFile.path, true); + // Before opening from the temp dir, make the file read-only so that + // users don't edit and lose their edits... + await IOUtils.setPermissions(tempFile.path, 0o400); // Set read-only + this._openFile(mimeInfo, tempFile); + }; + + let openLocalFile = mimeInfo => { + let fileHandler = Services.io + .getProtocolHandler("file") + .QueryInterface(Ci.nsIFileProtocolHandler); + + try { + let externalFile = fileHandler.getFileFromURLSpec(this.displayUrl); + this._openFile(mimeInfo, externalFile); + } catch (ex) { + console.error( + "AttachmentInfo.open: file - " + this.displayUrl + ", " + ex + ); + } + }; + + if (!mimeInfo.alwaysAskBeforeHandling) { + switch (mimeInfo.preferredAction) { + case Ci.nsIHandlerInfo.saveToDisk: + if (Services.prefs.getBoolPref("browser.download.useDownloadDir")) { + let destFile = new lazy.FileUtils.File( + await lazy.Downloads.getPreferredDownloadsDirectory() + ); + destFile.append(name); + destFile.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, 0o755); + destFile.remove(false); + await saveToFile(destFile.path); + } else { + let filePicker = Cc["@mozilla.org/filepicker;1"].createInstance( + Ci.nsIFilePicker + ); + filePicker.defaultString = this.name; + filePicker.defaultExtension = extension; + filePicker.init( + win, + lazy.messengerBundle.GetStringFromName("SaveAttachment"), + Ci.nsIFilePicker.modeSave + ); + let rv = await new Promise(resolve => filePicker.open(resolve)); + if (rv != Ci.nsIFilePicker.returnCancel) { + await saveToFile(filePicker.file.path); + } + } + return; + case Ci.nsIHandlerInfo.useHelperApp: + case Ci.nsIHandlerInfo.useSystemDefault: + // Attachments can be detached and, if this is the case, opened from + // their location on disk instead of copied to a temporary file. + if (this.isExternalAttachment) { + openLocalFile(mimeInfo); + return; + } + + await createTemporaryFileAndOpen(mimeInfo); + return; + } + } + + // Ask what to do, then do it. + let appLauncherDialog = Cc[ + "@mozilla.org/helperapplauncherdialog;1" + ].createInstance(Ci.nsIHelperAppLauncherDialog); + appLauncherDialog.show( + { + QueryInterface: ChromeUtils.generateQI(["nsIHelperAppLauncher"]), + MIMEInfo: mimeInfo, + source: Services.io.newURI(this.url), + suggestedFileName: this.name, + cancel(reason) {}, + promptForSaveDestination() { + appLauncherDialog.promptForSaveToFileAsync( + this, + win, + this.suggestedFileName, + "." + extension, // Dot stripped by promptForSaveToFileAsync. + false + ); + }, + launchLocalFile() { + openLocalFile(mimeInfo); + }, + async setDownloadToLaunch(handleInternally, file) { + await createTemporaryFileAndOpen(mimeInfo); + }, + async saveDestinationAvailable(file) { + if (file) { + await saveToFile(file.path); + } + }, + setWebProgressListener(webProgressListener) {}, + targetFile: null, + targetFileIsExecutable: null, + timeDownloadStarted: null, + contentLength: this.size, + browsingContextId: browsingContext.id, + }, + win, + null + ); + } + } + + /** + * Unless overridden by a test, opens a saved attachment when called by `open`. + * + * @param {nsIMIMEInfo} mimeInfo + * @param {nsIFile} file + */ + _openFile(mimeInfo, file) { + mimeInfo.launchWithFile(file); + } + + /** + * Detach this attachment from the message. + * + * @param {nsIMessenger} messenger + * The messenger object associated with the window. + * @param {boolean} aSaveFirst - true if the attachment should be saved + * before detaching, false otherwise. + */ + detach(messenger, aSaveFirst) { + messenger.detachAttachment( + this.contentType, + this.url, + encodeURIComponent(this.name), + this.uri, + aSaveFirst + ); + } + + /** + * This method checks whether the attachment has been deleted or not. + * + * @returns true if the attachment has been deleted, false otherwise. + */ + get isDeleted() { + return this.contentType == "text/x-moz-deleted"; + } + + /** + * This method checks whether the attachment is a detached file. + * + * @returns true if the attachment is a detached file, false otherwise. + */ + get isFileAttachment() { + return this.isExternalAttachment && this.url.startsWith("file:"); + } + + /** + * This method checks whether the attachment is an http link. + * + * @returns true if the attachment is an http link, false otherwise. + */ + get isLinkAttachment() { + return this.isExternalAttachment && /^https?:/.test(this.url); + } + + /** + * This method checks whether the attachment has an associated file or not. + * Deleted attachments or detached attachments with missing external files + * do *not* have a file. + * + * @returns true if the attachment has an associated file, false otherwise. + */ + get hasFile() { + if (this.sizeResolved && this.size == -1) { + return false; + } + + return true; + } + + /** + * Return display url, decoded and converted to utf8 from IDN punycode ascii, + * if the attachment is external (http or file schemes). + * + * @returns {string} url. + */ + get displayUrl() { + if (this.isExternalAttachment) { + // For status bar url display purposes, we want the displaySpec. + // The ?part= has already been removed. + return decodeURI(Services.io.newURI(this.url).displaySpec); + } + + return this.url; + } + + /** + * This method checks whether the attachment url location exists and + * is accessible. For http and file urls, fetch() will have the size + * in the content-length header. + * + * @returns {Boolean} + * true if the attachment is empty or error, false otherwise. + */ + async isEmpty() { + if (this.isDeleted) { + return true; + } + + const isFetchable = url => { + let uri = Services.io.newURI(url); + return !(uri.username || uri.userPass); + }; + + // We have a resolved size. + if (this.sizeResolved) { + return this.size < 1; + } + + if (!isFetchable(this.url)) { + return false; + } + + let empty = true; + let size = -1; + let options = { method: "GET" }; + + let request = new Request(this.url, options); + + if (this.isExternalAttachment && this.#updateAttachmentsDisplayFn) { + this.#updateAttachmentsDisplayFn(this, true); + } + + await fetch(request) + .then(response => { + if (!response.ok) { + console.warn( + "AttachmentInfo.#: fetch response error - " + + response.statusText + + ", response.url - " + + response.url + ); + return null; + } + + if (this.isLinkAttachment) { + if (response.status < 200 || response.status > 304) { + console.warn( + "AttachmentInfo.#: link fetch response status - " + + response.status + + ", response.url - " + + response.url + ); + return null; + } + } + + return response; + }) + .then(async response => { + if (this.isExternalAttachment) { + size = response ? response.headers.get("content-length") : -1; + } else { + // Check the attachment again if addAttachmentField() sets a + // libmime -1 return value for size in this object. + // Note: just test for a non zero size, don't need to drain the + // stream. We only get here if the url is fetchable. + // The size for internal attachments is not calculated here but + // will come from libmime. + let reader = response.body.getReader(); + let result = await reader.read(); + reader.cancel(); + size = result && result.value ? result.value.length : -1; + } + + if (size > 0) { + empty = false; + } + }) + .catch(error => { + console.warn(`AttachmentInfo.#: ${error.message} url - ${this.url}`); + }); + + this.sizeResolved = true; + + if (this.isExternalAttachment) { + // For link attachments, we may have had a published value or -1 + // indicating unknown value. We now know the real size, so set it and + // update the ui. For detached file attachments, get the size here + // instead of the old xpcom way. + this.size = size; + this.#updateAttachmentsDisplayFn?.(this, false); + } + + return empty; + } + + /** + * Open a file attachment's containing folder. + */ + openFolder() { + if (!this.isFileAttachment || !this.hasFile) { + return; + } + + // The file url is stored in the attachment info part with unix path and + // needs to be converted to os path for nsIFile. + let fileHandler = Services.io + .getProtocolHandler("file") + .QueryInterface(Ci.nsIFileProtocolHandler); + try { + fileHandler.getFileFromURLSpec(this.displayUrl).reveal(); + } catch (ex) { + console.error( + "AttachmentInfo.openFolder: file - " + this.displayUrl + ", " + ex + ); + } + } +} diff --git a/comm/mail/modules/BrowserWindowTracker.jsm b/comm/mail/modules/BrowserWindowTracker.jsm new file mode 100644 index 0000000000..619fc78268 --- /dev/null +++ b/comm/mail/modules/BrowserWindowTracker.jsm @@ -0,0 +1,8 @@ +/* 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/. */ + +// This module is deliberately not implemented. It only exists to keep +// the automated tests happy. See bug 1782621. + +var EXPORTED_SYMBOLS = []; diff --git a/comm/mail/modules/ConversationOpener.jsm b/comm/mail/modules/ConversationOpener.jsm new file mode 100644 index 0000000000..4414e7d659 --- /dev/null +++ b/comm/mail/modules/ConversationOpener.jsm @@ -0,0 +1,69 @@ +/* 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/. */ + +const EXPORTED_SYMBOLS = ["ConversationOpener"]; + +const { Gloda } = ChromeUtils.import("resource:///modules/gloda/Gloda.jsm"); +const { GlodaSyntheticView } = ChromeUtils.import( + "resource:///modules/gloda/GlodaSyntheticView.jsm" +); + +class ConversationOpener { + static isMessageIndexed(message) { + if ( + !Services.prefs.getBoolPref("mailnews.database.global.indexer.enabled") + ) { + return false; + } + if (!message || !message.folder) { + return false; + } + return Gloda.isMessageIndexed(message); + } + + constructor(window) { + this.window = window; + } + openConversationForMessages(messages) { + if (messages.length < 1) { + return; + } + try { + this._items = []; + this._msgHdr = messages[0]; + this._queries = [Gloda.getMessageCollectionForHeaders(messages, this)]; + } catch (e) { + console.error(e); + } + } + onItemsAdded(items) {} + onItemsModified(items) {} + onItemsRemoved(items) {} + onQueryCompleted(collection) { + try { + if (!collection.items.length) { + console.error("Couldn't find a collection for msg: " + this._msgHdr); + } else { + let message = collection.items[0]; + let tabmail = this.window.top.document.getElementById("tabmail"); + if (!tabmail) { + tabmail = Services.wm + .getMostRecentWindow("mail:3pane") + .document.getElementById("tabmail"); + } + tabmail.openTab("mail3PaneTab", { + folderPaneVisible: false, + syntheticView: new GlodaSyntheticView({ + conversation: message.conversation, + message, + }), + title: message.conversation.subject, + background: false, + }); + } + } catch (e) { + console.error(e); + } + } +} diff --git a/comm/mail/modules/DBViewWrapper.jsm b/comm/mail/modules/DBViewWrapper.jsm new file mode 100644 index 0000000000..e88ac3dc05 --- /dev/null +++ b/comm/mail/modules/DBViewWrapper.jsm @@ -0,0 +1,2250 @@ +/* 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/. */ + +const EXPORTED_SYMBOLS = ["DBViewWrapper", "IDBViewWrapperListener"]; + +const { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" +); +const { MailViewConstants, MailViewManager } = ChromeUtils.import( + "resource:///modules/MailViewManager.jsm" +); +const { SearchSpec } = ChromeUtils.import("resource:///modules/SearchSpec.jsm"); +const { VirtualFolderHelper } = ChromeUtils.import( + "resource:///modules/VirtualFolderWrapper.jsm" +); + +var MSG_VIEW_FLAG_DUMMY = 0x20000000; + +var nsMsgViewIndex_None = 0xffffffff; + +/** + * Helper singleton for DBViewWrapper that tells instances when something + * interesting is happening to the folder(s) they care about. The rationale + * for this is to: + * - reduce listener overhead (although arguably the events we listen to are + * fairly rare) + * - make testing / verification easier by centralizing and exposing listeners. + * + */ +var FolderNotificationHelper = { + /** + * Maps URIs of pending folder loads to the DBViewWrapper instances that + * are waiting on the loads. The value is a list of instances in case + * a quick-clicking user is able to do something unexpected. + */ + _pendingFolderUriToViewWrapperLists: {}, + + /** + * Map URIs of folders to view wrappers interested in hearing about their + * deletion. + */ + _interestedWrappers: {}, + + /** + * Array of wrappers that are interested in all folders, used for + * search results wrappers. + */ + _curiousWrappers: [], + + /** + * Initialize our listeners. We currently don't bother cleaning these up + * because we are a singleton and if anyone imports us, they probably want + * us for as long as their application so shall live. + */ + _init() { + // register with the session for our folded loaded notifications + MailServices.mailSession.AddFolderListener( + this, + Ci.nsIFolderListener.event | Ci.nsIFolderListener.intPropertyChanged + ); + + // register with the notification service for deleted folder notifications + MailServices.mfn.addListener( + this, + Ci.nsIMsgFolderNotificationService.folderDeleted | + // we need to track renames because we key off of URIs. frick. + Ci.nsIMsgFolderNotificationService.folderRenamed | + Ci.nsIMsgFolderNotificationService.folderMoveCopyCompleted + ); + }, + + /** + * Call updateFolder, and assuming all goes well, request that the provided + * FolderDisplayWidget be notified when the folder is loaded. This method + * performs the updateFolder call for you so there is less chance of leaking. + * In the event the updateFolder call fails, we will propagate the exception. + */ + updateFolderAndNotifyOnLoad(aFolder, aFolderDisplay, aMsgWindow) { + // set up our datastructure first in case of wacky event sequences + let folderURI = aFolder.URI; + let wrappers = this._pendingFolderUriToViewWrapperLists[folderURI]; + if (wrappers == null) { + wrappers = this._pendingFolderUriToViewWrapperLists[folderURI] = []; + } + wrappers.push(aFolderDisplay); + try { + aFolder.updateFolder(aMsgWindow); + } catch (ex) { + // uh-oh, that didn't work. tear down the data structure... + wrappers.pop(); + if (wrappers.length == 0) { + delete this._pendingFolderUriToViewWrapperLists[folderURI]; + } + throw ex; + } + }, + + /** + * Request notification of every little thing these folders do. + * + * @param aFolders The folders. + * @param aNotherFolder A folder that may or may not be in aFolders. + * @param aViewWrapper The view wrapper that is up to no good. + */ + stalkFolders(aFolders, aNotherFolder, aViewWrapper) { + let folders = aFolders ? aFolders.concat() : []; + if (aNotherFolder && !folders.includes(aNotherFolder)) { + folders.push(aNotherFolder); + } + for (let folder of folders) { + let wrappers = this._interestedWrappers[folder.URI]; + if (wrappers == null) { + wrappers = this._interestedWrappers[folder.URI] = []; + } + wrappers.push(aViewWrapper); + } + }, + + /** + * Request notification of every little thing every folder does. + * + * @param aViewWrapper - the viewWrapper interested in every notification. + * This will be a search results view of some sort. + */ + noteCuriosity(aViewWrapper) { + this._curiousWrappers.push(aViewWrapper); + }, + + /** + * Removal helper for use by removeNotifications. + * + * @param aTable The table mapping URIs to list of view wrappers. + * @param aFolder The folder we care about. + * @param aViewWrapper The view wrapper of interest. + */ + _removeWrapperFromListener(aTable, aFolder, aViewWrapper) { + let wrappers = aTable[aFolder.URI]; + if (wrappers) { + let index = wrappers.indexOf(aViewWrapper); + if (index >= 0) { + wrappers.splice(index, 1); + } + if (wrappers.length == 0) { + delete aTable[aFolder.URI]; + } + } + }, + /** + * Remove notification requests on the provided folders by the given view + * wrapper. + */ + removeNotifications(aFolders, aViewWrapper) { + if (!aFolders) { + this._curiousWrappers.splice( + this._curiousWrappers.indexOf(aViewWrapper), + 1 + ); + return; + } + for (let folder of aFolders) { + this._removeWrapperFromListener( + this._interestedWrappers, + folder, + aViewWrapper + ); + this._removeWrapperFromListener( + this._pendingFolderUriToViewWrapperLists, + folder, + aViewWrapper + ); + } + }, + + /** + * @returns true if there are any listeners still registered. This is intended + * to support debugging code. If you are not debug code, you are a bad + * person/code. + */ + haveListeners() { + if (Object.keys(this._pendingFolderUriToViewWrapperLists).length > 0) { + return true; + } + if (Object.keys(this._interestedWrappers).length > 0) { + return true; + } + return this._curiousWrappers.length != 0; + }, + + /* ***** Notifications ***** */ + _notifyHelper(aFolder, aHandlerName) { + let wrappers = this._interestedWrappers[aFolder.URI]; + if (wrappers) { + // clone the list to avoid confusing mutation by listeners + for (let wrapper of wrappers.concat()) { + wrapper[aHandlerName](aFolder); + } + } + for (let wrapper of this._curiousWrappers) { + wrapper[aHandlerName](aFolder); + } + }, + + onFolderEvent(aFolder, aEvent) { + if (aEvent == "FolderLoaded") { + let folderURI = aFolder.URI; + let widgets = this._pendingFolderUriToViewWrapperLists[folderURI]; + if (widgets) { + for (let widget of widgets) { + // we are friends, this is an explicit relationship. + // (we don't use a generic callback mechanism because the 'this' stuff + // gets ugly and no one else should be hooking in at this level.) + try { + widget._folderLoaded(aFolder); + } catch (ex) { + dump( + "``` EXCEPTION DURING NOTIFY: " + + ex.fileName + + ":" + + ex.lineNumber + + ": " + + ex + + "\n" + ); + if (ex.stack) { + dump("STACK: " + ex.stack + "\n"); + } + console.error(ex); + } + } + delete this._pendingFolderUriToViewWrapperLists[folderURI]; + } + } else if (aEvent == "AboutToCompact") { + this._notifyHelper(aFolder, "_aboutToCompactFolder"); + } else if (aEvent == "CompactCompleted") { + this._notifyHelper(aFolder, "_compactedFolder"); + } else if (aEvent == "DeleteOrMoveMsgCompleted") { + this._notifyHelper(aFolder, "_deleteCompleted"); + } else if (aEvent == "DeleteOrMoveMsgFailed") { + this._notifyHelper(aFolder, "_deleteFailed"); + } else if (aEvent == "RenameCompleted") { + this._notifyHelper(aFolder, "_renameCompleted"); + } + }, + + onFolderIntPropertyChanged(aFolder, aProperty, aOldValue, aNewValue) { + if (aProperty == "TotalMessages" || aProperty == "TotalUnreadMessages") { + this._notifyHelper(aFolder, "_messageCountsChanged"); + } + }, + + _folderMoveHelper(aOldFolder, aNewFolder) { + let oldURI = aOldFolder.URI; + let newURI = aNewFolder.URI; + // fix up our listener tables. + if (oldURI in this._pendingFolderUriToViewWrapperLists) { + this._pendingFolderUriToViewWrapperLists[newURI] = + this._pendingFolderUriToViewWrapperLists[oldURI]; + delete this._pendingFolderUriToViewWrapperLists[oldURI]; + } + if (oldURI in this._interestedWrappers) { + this._interestedWrappers[newURI] = this._interestedWrappers[oldURI]; + delete this._interestedWrappers[oldURI]; + } + + let wrappers = this._interestedWrappers[newURI]; + if (wrappers) { + // clone the list to avoid confusing mutation by listeners + for (let wrapper of wrappers.concat()) { + wrapper._folderMoved(aOldFolder, aNewFolder); + } + } + }, + + /** + * Update our URI mapping tables when renames happen. + */ + folderRenamed(aOrigFolder, aNewFolder) { + this._folderMoveHelper(aOrigFolder, aNewFolder); + }, + + folderMoveCopyCompleted(aMove, aSrcFolder, aDestFolder) { + if (aMove) { + let aNewFolder = aDestFolder.getChildNamed(aSrcFolder.prettyName); + this._folderMoveHelper(aSrcFolder, aNewFolder); + } + }, + + folderDeleted(aFolder) { + let wrappers = this._interestedWrappers[aFolder.URI]; + if (wrappers) { + // clone the list to avoid confusing mutation by listeners + for (let wrapper of wrappers.concat()) { + wrapper._folderDeleted(aFolder); + } + // if the folder is deleted, it's not going to ever do anything again + delete this._interestedWrappers[aFolder.URI]; + } + }, +}; +FolderNotificationHelper._init(); + +/** + * Defines the DBViewWrapper listener interface. This class exists exclusively + * for documentation purposes and should never be instantiated. + */ +function IDBViewWrapperListener() {} +IDBViewWrapperListener.prototype = { + // uh, this is secretly exposed for debug purposes. DO NOT LOOK AT ME! + _FNH: FolderNotificationHelper, + + /* ===== Exposure of UI Globals ===== */ + messenger: null, + msgWindow: null, + threadPaneCommandUpdater: null, + + /* ===== Guidance ===== */ + /** + * Indicate whether mail view settings should be used/honored. A UI oddity + * is that we only have mail views be sticky if its combo box UI is visible. + * (Without the view combobox, it may not be obvious that the mail is + * filtered.) + */ + get shouldUseMailViews() { + return false; + }, + + /** + * Should we defer displaying the messages in this folder until after we have + * talked to the server? This is for our poor man's password protection + * via the "mail.password_protect_local_cache" pref. We add this specific + * check rather than internalizing the logic in the wrapper because the + * password protection is a shoddy UI-only protection. + */ + get shouldDeferMessageDisplayUntilAfterServerConnect() { + return false; + }, + + /** + * Should we mark all messages in a folder as read on exit? + * This is nominally controlled by the "mailnews.mark_message_read.SERVERTYPE" + * preference (on a per-server-type basis). + * For the record, this functionality should not remotely be in the core. + * + * @param aMsgFolder The folder we are leaving and are unsure if we should + * mark all its messages read. I pass the folder instead of the server + * type because having a crazy feature like this will inevitably lead to + * a more full-featured crazy feature (why not on a per-folder basis, eh?) + * @returns true if we should mark all the dudes as read, false if not. + */ + shouldMarkMessagesReadOnLeavingFolder(aMsgFolder) { + return false; + }, + + /* ===== Event Notifications ===== */ + /* === Status Changes === */ + /** + * We tell you when we start and stop loading the folder. This is a good + * time to mess with the hour-glass cursor machinery if you are inclined to + * do so. + */ + onFolderLoading(aIsFolderLoading) {}, + + /** + * We tell you when we start and stop searching. This is a good time to mess + * with progress spinners (meteors) and the like, if you are so inclined. + */ + onSearching(aIsSearching) {}, + + /** + * This event is generated when a new view has been created. It is intended + * to be used to provide the MsgCreateDBView notification so that custom + * columns can add themselves to the view. + * The notification is not generated by the DBViewWrapper itself because this + * is fundamentally a UI issue. Additionally, because the MsgCreateDBView + * notification consumers assume gDBView whose exposure is affected by tabs, + * the tab logic needs to be involved. + */ + onCreatedView() {}, + + /** + * This event is generated just before we close/destroy a message view. + * + * @param aFolderIsComingBack Indicates whether we are planning to create a + * new view to display the same folder after we destroy this view. This + * will be the case unless we are switching to display a new folder or + * closing the view wrapper entirely. + */ + onDestroyingView(aFolderIsComingBack) {}, + + /** + * Generated when we are loading information about the folder from its + * dbFolderInfo. The dbFolderInfo object is passed in. + * The DBViewWrapper has already restored its state when this function is + * called, but has not yet created the dbView. A view update is in process, + * so the view settings can be changed and will take effect when the update + * is closed. + * |onDisplayingFolder| is the next expected notification following this + * notification. + */ + onLoadingFolder(aDbFolderInfo) {}, + + /** + * Generated when the folder is being entered for display. This is the chance + * for the listener to affect any UI-related changes to the folder required. + * Currently, this just means setting the header cache size (which needs to + * be proportional to the number of lines in the tree view, and is thus a + * UI issue.) + * The dbView has already been created and is valid when this function is + * called. + * |onLoadingFolder| is called before this notification. + */ + onDisplayingFolder() {}, + + /** + * Generated when we are leaving a folder. + */ + onLeavingFolder() {}, + + /** + * Things to do once all the messages that should show up in a folder have + * shown up. For a real folder, this happens when the folder is entered. + * For a (multi-folder) virtual folder, this happens when the search + * completes. + * You may get onMessagesLoaded called with aAll false immediately after + * the view is opened. You will definitely get onMessagesLoaded(true) + * when we've finished getting the headers for the view. + */ + onMessagesLoaded(aAll) {}, + + /** + * The mail view changed. The mail view widget is likely to care about this. + */ + onMailViewChanged() {}, + + /** + * The active sort changed, and that is all that changed. If the sort is + * changing because the view is being destroyed and re-created, this event + * will not be generated. + */ + onSortChanged() {}, + + /** + * This event is generated when messages in one of the folders backing the + * view have been removed by message moves / deletion. If there is a search + * in effect, it is possible that the removed messages were not visible in + * the view in the first place. + */ + onMessagesRemoved() {}, + + /** + * Like onMessagesRemoved, but something went awry in the move/deletion and + * it failed. Although this is not a very interesting event on its own, + * it is useful in cases where the listener was expecting an + * onMessagesRemoved and might need to clean some state up. + */ + onMessageRemovalFailed() {}, + + /** + * The total message count or total unread message counts changed. + */ + onMessageCountsChanged() {}, +}; + +/** + * Encapsulates everything related to working with our nsIMsgDBView + * implementations. + * + * Things we do not do and why we do not do them: + * - Selection. This depends on having an nsITreeSelection object and we choose + * to avoid entanglement with XUL/layout code. Selection accordingly must be + * handled a layer up in the FolderDisplayWidget. + */ +function DBViewWrapper(aListener) { + this.displayedFolder = null; + this.listener = aListener; + + this._underlyingData = this.kUnderlyingNone; + this._underlyingFolders = null; + this._syntheticView = null; + + this._viewUpdateDepth = 0; + + this._mailViewIndex = MailViewConstants.kViewItemAll; + this._mailViewData = null; + + this._specialView = null; + + this._sort = []; + // see the _viewFlags getter and setter for info on our use of __viewFlags. + this.__viewFlags = null; + + /** + * It's possible to support grouped view thread expand/collapse, and also sort + * by thread despite the back end (see nsMsgQuickSearchDBView::SortThreads). + * Also, nsMsgQuickSearchDBView does not respect the kExpandAll flag, fix that. + */ + this._threadExpandAll = true; + + this.dbView = null; + this.search = null; + + this._folderLoading = false; + this._searching = false; +} +DBViewWrapper.prototype = { + /* = constants explaining the nature of the underlying data = */ + /** + * We currently don't have any underlying data. + */ + kUnderlyingNone: 0, + /** + * The underlying data source is a single folder. + */ + kUnderlyingRealFolder: 1, + /** + * The underlying data source is a virtual folder that is operating over + * multiple underlying folders. + */ + kUnderlyingMultipleFolder: 2, + /** + * Our data source is transient, most likely a gloda search that crammed the + * results into us. This is different from a search view. + */ + kUnderlyingSynthetic: 3, + /** + * We are a search view, which translates into a search that has underlying + * folders, just like kUnderlyingMultipleFolder, but we have no + * displayedFolder. We differ from kUnderlyingSynthetic in that we are + * not just a bunch of message headers randomly crammed in. + */ + kUnderlyingSearchView: 4, + + /** + * @returns true if the folder being displayed is backed by a single 'real' + * folder. This folder can be a saved search on that folder or just + * an outright un-filtered display of that folder. + */ + get isSingleFolder() { + return this._underlyingData == this.kUnderlyingRealFolder; + }, + + /** + * @returns true if the folder being displayed is a virtual folder backed by + * multiple 'real' folders or a search view. This corresponds to a + * cross-folder saved search. + */ + get isMultiFolder() { + return ( + this._underlyingData == this.kUnderlyingMultipleFolder || + this._underlyingData == this.kUnderlyingSearchView + ); + }, + + /** + * @returns true if the folder being displayed is not a real folder at all, + * but rather the result of an un-scoped search, such as a gloda search. + */ + get isSynthetic() { + return this._underlyingData == this.kUnderlyingSynthetic; + }, + + /** + * @returns true if the folder being displayed is not a real folder at all, + * but rather the result of a search. + */ + get isSearch() { + return this._underlyingData == this.kUnderlyingSearchView; + }, + + /** + * Check if the folder in question backs the currently displayed folder. For + * a virtual folder, this is a test of whether the virtual folder includes + * messages from the given folder. For a 'real' single folder, this is + * effectively a test against displayedFolder. + * If you want to see if the displayed folder is a folder, just compare + * against the displayedFolder attribute. + */ + isUnderlyingFolder(aFolder) { + return this._underlyingFolders.some( + underlyingFolder => aFolder == underlyingFolder + ); + }, + + /** + * Refresh the view by re-creating the view. You would do this to get rid of + * messages that no longer match the view but are kept around for view + * stability reasons. (In other words, in an unread-messages view, you would + * go insane if when you clicked on a message it immediately disappeared + * because it no longer matched.) + * This method was adding for testing purposes and does not have a (legacy) UI + * reason for existing. (The 'open' method is intended to behave identically + * to the legacy UI if you click on the currently displayed folder.) + */ + refresh() { + this._applyViewChanges(); + }, + + /** + * Null out the folder's database to avoid memory bloat if we don't have a + * reason to keep the database around. Currently, we keep all Inboxes + * around and null out everyone else. This is a standard stopgap measure + * until we have something more clever going on. + * In general, there is little potential downside to nulling out the message + * database reference when it is in use. As long as someone is holding onto + * a message header from the database, the database will be kept open, and + * therefore the database service will still have a reference to the db. + * When the folder goes to ask for the database again, the service will have + * it, and it will not need to be re-opened. + * + * Another heuristic we could theoretically use is use the mail session's + * isFolderOpenInWindow call, except that uses the outmoded concept that each + * window will have at most one folder open. So nuts to that. + * + * Note: regrettably a unit test cannot verify that we did this; msgDatabase + * is a getter that will always try and load the message database! + */ + _releaseFolderDatabase(aFolder) { + if (!aFolder.isSpecialFolder(Ci.nsMsgFolderFlags.Inbox, false)) { + aFolder.msgDatabase = null; + } + }, + + /** + * Clone this DBViewWrapper and its underlying nsIMsgDBView. + * + * @param aListener {IDBViewWrapperListener} The listener to use on the new view. + */ + clone(aListener) { + let doppel = new DBViewWrapper(aListener); + + // -- copy attributes + doppel.displayedFolder = this.displayedFolder; + doppel._underlyingData = this._underlyingData; + doppel._underlyingFolders = this._underlyingFolders + ? this._underlyingFolders.concat() + : null; + doppel._syntheticView = this._syntheticView; + + // _viewUpdateDepth should stay at its initial value of zero + doppel._mailViewIndex = this._mailViewIndex; + doppel._mailViewData = this._mailViewData; + + doppel._specialView = this._specialView; + // a shallow copy is all that is required for sort; we do not mutate entries + doppel._sort = this._sort.concat(); + + // -- register listeners... + // note: this does not get us a folder loaded notification. Our expected + // use case for cloning is displaying a single message already visible in + // the original view, which implies we don't need to hang about for folder + // loaded notification messages. + FolderNotificationHelper.stalkFolders( + doppel._underlyingFolders, + doppel.displayedFolder, + doppel + ); + + // -- clone the view + if (this.dbView) { + doppel.dbView = this.dbView + .cloneDBView( + aListener.messenger, + aListener.msgWindow, + aListener.threadPaneCommandUpdater + ) + .QueryInterface(Ci.nsITreeView); + } + // -- clone the search + if (this.search) { + doppel.search = this.search.clone(doppel); + } + + if ( + doppel._underlyingData == this.kUnderlyingSearchView || + doppel._underlyingData == this.kUnderlyingSynthetic + ) { + FolderNotificationHelper.noteCuriosity(doppel); + } + + return doppel; + }, + + /** + * Close the current view. You would only do this if you want to clean up all + * the resources associated with this view wrapper. You would not do this + * for UI reasons like the user de-selecting the node in the tree; we should + * always be displaying something when used in a UI context! + * + * @param {boolean} folderIsDead - If true, tells us not to try and tidy up + * on our way out by virtue of the fact that the folder is dead and should + * not be messed with. + */ + close(folderIsDead) { + if (this.displayedFolder != null) { + // onLeavingFolder does all the application-level stuff related to leaving + // the folder (marking as read, etc.) We only do this when the folder + // is not dead (for obvious reasons). + if (!folderIsDead) { + // onLeavingFolder must be called before we potentially null out its + // msgDatabase, which we will do in the upcoming underlyingFolders loop + this.onLeavingFolder(); // application logic + this.listener.onLeavingFolder(); // display logic + } + // (potentially) zero out the display folder if we are dealing with a + // virtual folder and so the next loop won't take care of it. + if (this.isVirtual) { + FolderNotificationHelper.removeNotifications( + [this.displayedFolder], + this + ); + this._releaseFolderDatabase(this.displayedFolder); + } + + this.folderLoading = false; + this.displayedFolder = null; + } + + FolderNotificationHelper.removeNotifications(this._underlyingFolders, this); + if (this.isSearch || this.isSynthetic) { + // Opposite of FolderNotificationHelper.noteCuriosity(this) + FolderNotificationHelper.removeNotifications(null, this); + } + + if (this._underlyingFolders) { + // (potentially) zero out the underlying msgDatabase references + for (let folder of this._underlyingFolders) { + this._releaseFolderDatabase(folder); + } + } + + // kill off the view and its search association + if (this.dbView) { + this.listener.onDestroyingView(false); + this.search.dissociateView(this.dbView); + this.dbView.setTree(null); + this.dbView.setJSTree(null); + this.dbView.selection = null; + this.dbView.close(); + this.dbView = null; + } + + // zero out the view update depth here. We don't do it on open because it's + // theoretically be nice to be able to start a view update before you open + // something so you can defer the open. In practice, that is not yet + // tested. + this._viewUpdateDepth = 0; + + this._underlyingData = this.kUnderlyingNone; + this._underlyingFolders = null; + this._syntheticView = null; + + this._mailViewIndex = MailViewConstants.kViewItemAll; + this._mailViewData = null; + + this._specialView = null; + + this._sort = []; + this.__viewFlags = null; + + this.search = null; + }, + + /** + * Open the passed-in nsIMsgFolder folder. Use openSynthetic for synthetic + * view providers. + */ + open(aFolder) { + if (aFolder == null) { + this.close(); + return; + } + + // If we are in the same folder, there is nothing to do unless we are a + // virtual folder. Virtual folders apparently want to try and get updated. + if (this.displayedFolder == aFolder) { + if (!this.isVirtual) { + return; + } + // note: we intentionally (for consistency with old code, not that the + // code claimed to have a good reason) fall through here and call + // onLeavingFolder via close even though that's debatable in this case. + } + this.close(); + + this.displayedFolder = aFolder; + this._enteredFolder = false; + + this.search = new SearchSpec(this); + this._sort = []; + + if (aFolder.isServer) { + this._showServer(); + return; + } + + let typeForTelemetry = + [ + "Inbox", + "Drafts", + "Trash", + "SentMail", + "Templates", + "Junk", + "Archive", + "Queue", + "Virtual", + ].find(x => aFolder.getFlag(Ci.nsMsgFolderFlags[x])) || "Other"; + Services.telemetry.keyedScalarAdd( + "tb.mails.folder_opened", + typeForTelemetry, + 1 + ); + + this.beginViewUpdate(); + let msgDatabase; + try { + // This will throw an exception if the .msf file is missing, + // out of date (e.g., the local folder has changed), or corrupted. + msgDatabase = this.displayedFolder.msgDatabase; + } catch (e) {} + if (msgDatabase) { + this._prepareToLoadView(msgDatabase, aFolder); + } + + if (!this.isVirtual) { + this.folderLoading = true; + FolderNotificationHelper.updateFolderAndNotifyOnLoad( + this.displayedFolder, + this, + this.listener.msgWindow + ); + } + + // we do this after kicking off the update because this could initiate a + // search which could fight our explicit updateFolder call if the search + // is already outstanding. + if (this.shouldShowMessagesForFolderImmediately()) { + this._enterFolder(); + } + }, + + /** + * Open a synthetic view provider as backing our view. + */ + openSynthetic(aSyntheticView) { + this.close(); + + this._underlyingData = this.kUnderlyingSynthetic; + this._syntheticView = aSyntheticView; + + this.search = new SearchSpec(this); + this._sort = this._syntheticView.defaultSort.concat(); + + this._applyViewChanges(); + FolderNotificationHelper.noteCuriosity(this); + this.listener.onDisplayingFolder(); + }, + + /** + * Makes us irrevocavbly be a search view, for use in search windows. + * Once you call this, you are not allowed to use us for anything + * but a search view! + * We add a 'searchFolders' property that allows you to control what + * folders we are searching over. + */ + openSearchView() { + this.close(); + + this._underlyingData = this.kUnderlyingSearchView; + this._underlyingFolders = []; + + let dis = this; + this.__defineGetter__("searchFolders", function () { + return dis._underlyingFolders; + }); + this.__defineSetter__("searchFolders", function (aSearchFolders) { + dis._underlyingFolders = aSearchFolders; + dis._applyViewChanges(); + }); + + this.search = new SearchSpec(this); + // the search view uses the order in which messages are added as the + // order by default. + this._sort = [ + [Ci.nsMsgViewSortType.byNone, Ci.nsMsgViewSortOrder.ascending], + ]; + this.__viewFlags = Ci.nsMsgViewFlagsType.kNone; + + FolderNotificationHelper.noteCuriosity(this); + this._applyViewChanges(); + }, + + get folderLoading() { + return this._folderLoading; + }, + set folderLoading(aFolderLoading) { + if (this._folderLoading == aFolderLoading) { + return; + } + this._folderLoading = aFolderLoading; + // tell the folder about what is going on so it can remove its db change + // listener and restore it, respectively. + if (aFolderLoading) { + this.displayedFolder.startFolderLoading(); + } else { + this.displayedFolder.endFolderLoading(); + } + this.listener.onFolderLoading(aFolderLoading); + }, + + get searching() { + return this._searching; + }, + set searching(aSearching) { + if (aSearching == this._searching) { + return; + } + this._searching = aSearching; + this.listener.onSearching(aSearching); + // notify that all messages are loaded if searching has concluded + if (!aSearching) { + this.listener.onMessagesLoaded(true); + } + }, + + /** + * Do we want to show the messages immediately, or should we wait for + * updateFolder to complete? The historical heuristic is: + * - Virtual folders get shown immediately (and updateFolder has no + * meaning for them anyways.) + * - If _underlyingFolders == null, we failed to open the database, + * so we need to wait for UpdateFolder to reparse the folder (in the + * local folder case). + * - Wait on updateFolder if our poor man's security via + * "mail.password_protect_local_cache" preference is enabled and the + * server requires a password to login. This is accomplished by asking our + * listener via shouldDeferMessageDisplayUntilAfterServerConnect. Note that + * there is an obvious hole in this logic because of the virtual folder case + * above. + * + * @pre this.folderDisplayed is the folder we are talking about. + * + * @returns true if the folder should be shown immediately, false if we should + * wait for updateFolder to complete. + */ + shouldShowMessagesForFolderImmediately() { + return ( + this.isVirtual || + !( + this._underlyingFolders == null || + this.listener.shouldDeferMessageDisplayUntilAfterServerConnect + ) + ); + }, + /** + * Extract information about the view from the dbFolderInfo (e.g., sort type, + * sort order, current view flags, etc), and save in the view wrapper. + */ + _prepareToLoadView(msgDatabase, aFolder) { + let dbFolderInfo = msgDatabase.dBFolderInfo; + // - retrieve persisted sort information + this._sort = [[dbFolderInfo.sortType, dbFolderInfo.sortOrder]]; + + // - retrieve persisted display settings + this.__viewFlags = dbFolderInfo.viewFlags; + // - retrieve persisted thread last expanded state. + this._threadExpandAll = Boolean( + this.__viewFlags & Ci.nsMsgViewFlagsType.kExpandAll + ); + + // Make sure the threaded bit is set if group-by-sort is set. The views + // encode 3 states in 2-bits, and we want to avoid that odd-man-out + // state. + // The expand flag must be set when opening a single virtual folder + // (quicksearch) in grouped view. The user's last set expand/collapse state + // for grouped/threaded in this use case is restored later. + if (this.__viewFlags & Ci.nsMsgViewFlagsType.kGroupBySort) { + this.__viewFlags |= Ci.nsMsgViewFlagsType.kThreadedDisplay; + this.__viewFlags |= Ci.nsMsgViewFlagsType.kExpandAll; + this._ensureValidSort(); + } + + // See if the last-used view was one of the special views. If so, put us in + // that special view mode. We intentionally do this after restoring the + // view flags because _setSpecialView enforces threading. + // The nsMsgDBView is the one who persists this information for us. In this + // case the nsMsgThreadedDBView superclass of the special views triggers it + // when opened. + let viewType = dbFolderInfo.viewType; + if ( + viewType == Ci.nsMsgViewType.eShowThreadsWithUnread || + viewType == Ci.nsMsgViewType.eShowWatchedThreadsWithUnread + ) { + this._setSpecialView(viewType); + } + + // - retrieve virtual folder configuration + if (aFolder.flags & Ci.nsMsgFolderFlags.Virtual) { + let virtFolder = VirtualFolderHelper.wrapVirtualFolder(aFolder); + + if (virtFolder.searchFolderURIs == "*") { + // This is a special virtual folder that searches all folders in all + // accounts (except the unwanted types listed). Get those folders now. + let unwantedFlags = + Ci.nsMsgFolderFlags.Trash | + Ci.nsMsgFolderFlags.Junk | + Ci.nsMsgFolderFlags.Queue | + Ci.nsMsgFolderFlags.Virtual; + this._underlyingFolders = []; + for (let server of MailServices.accounts.allServers) { + for (let f of server.rootFolder.descendants) { + if (!f.isSpecialFolder(unwantedFlags, true)) { + this._underlyingFolders.push(f); + } + } + } + } else { + // Filter out the server roots; they only exist for UI reasons. + this._underlyingFolders = virtFolder.searchFolders.filter( + folder => !folder.isServer + ); + } + this._underlyingData = + this._underlyingFolders.length > 1 + ? this.kUnderlyingMultipleFolder + : this.kUnderlyingRealFolder; + + // figure out if we are using online IMAP searching + this.search.onlineSearch = virtFolder.onlineSearch; + + // retrieve and chew the search query + this.search.virtualFolderTerms = virtFolder.searchTerms; + } else { + this._underlyingData = this.kUnderlyingRealFolder; + this._underlyingFolders = [this.displayedFolder]; + } + + FolderNotificationHelper.stalkFolders( + this._underlyingFolders, + this.displayedFolder, + this + ); + + // - retrieve mail view configuration + if (this.listener.shouldUseMailViews) { + // if there is a view tag (basically ":tagname"), then it's a + // mailview tag. clearly. + let mailViewTag = dbFolderInfo.getCharProperty( + MailViewConstants.kViewCurrentTag + ); + // "0" and "1" are all and unread views, respectively, from 2.0 + if (mailViewTag && mailViewTag != "0" && mailViewTag != "1") { + // the tag gets stored with a ":" on the front, presumably done + // as a means of name-spacing that was never subsequently leveraged. + if (mailViewTag.startsWith(":")) { + mailViewTag = mailViewTag.substr(1); + } + // (the true is so we don't persist) + this.setMailView(MailViewConstants.kViewItemTags, mailViewTag, true); + } else { + // otherwise it's just an index. we kinda-sorta migrate from old-school + // $label tags, except someone reused one of the indices for + // kViewItemNotDeleted, which means that $label2 can no longer be + // migrated. + let mailViewIndex = dbFolderInfo.getUint32Property( + MailViewConstants.kViewCurrent, + MailViewConstants.kViewItemAll + ); + // label migration per above + if ( + mailViewIndex == MailViewConstants.kViewItemTags || + (MailViewConstants.kViewItemTags + 2 <= mailViewIndex && + mailViewIndex < MailViewConstants.kViewItemVirtual) + ) { + this.setMailView( + MailViewConstants.kViewItemTags, + "$label" + (mailViewIndex - 1) + ); + } else { + this.setMailView(mailViewIndex); + } + } + } + + this.listener.onLoadingFolder(dbFolderInfo); + }, + + /** + * Creates a view appropriate to the current settings of the folder display + * widget, returning it. The caller is responsible to assign the result to + * this.dbView (or whatever it wants to do with it.) + */ + _createView() { + let dbviewContractId = "@mozilla.org/messenger/msgdbview;1?type="; + + // we will have saved these off when closing our view + let viewFlags = + this.__viewFlags ?? + Services.prefs.getIntPref("mailnews.default_view_flags", 1); + + // real folders are subject to the most interest set of possibilities... + if (this._underlyingData == this.kUnderlyingRealFolder) { + // quick-search inherits from threaded which inherits from group, so this + // is right to choose it first. + if (this.search.hasSearchTerms) { + dbviewContractId += "quicksearch"; + } else if (this.showGroupedBySort) { + dbviewContractId += "group"; + } else if (this.specialViewThreadsWithUnread) { + dbviewContractId += "threadswithunread"; + } else if (this.specialViewWatchedThreadsWithUnread) { + dbviewContractId += "watchedthreadswithunread"; + } else { + dbviewContractId += "threaded"; + } + } else if (this._underlyingData == this.kUnderlyingMultipleFolder) { + // if we're dealing with virtual folders, the answer is always an xfvf + dbviewContractId += "xfvf"; + } else { + // kUnderlyingSynthetic or kUnderlyingSearchView + dbviewContractId += "search"; + } + + // and now zero the saved-off flags. + this.__viewFlags = null; + + let dbView = Cc[dbviewContractId].createInstance(Ci.nsIMsgDBView); + dbView.init( + this.listener.messenger, + this.listener.msgWindow, + this.listener.threadPaneCommandUpdater + ); + // Excluding Group By views, use the least-specific sort so we can clock + // them back through to build up the correct sort order, + const index = + viewFlags & Ci.nsMsgViewFlagsType.kGroupBySort + ? 0 + : this._sort.length - 1; + let [sortType, sortOrder, sortCustomCol] = this._getSortDetails(index); + let outCount = {}; + // when the underlying folder is a single real folder (virtual or no), we + // tell the view about the underlying folder. + if (this.isSingleFolder) { + // If the folder is virtual, m_viewFolder needs to be set before the + // folder is opened, otherwise persisted sort info will not be restored + // from the right dbFolderInfo. The use case is for a single folder + // backed saved search. Currently, sort etc. changes in quick filter are + // persisted (gloda list and quick filter in gloda list are not involved). + if (this.isVirtual) { + dbView.viewFolder = this.displayedFolder; + } + + // Open the folder. + dbView.open( + this._underlyingFolders[0], + sortType, + sortOrder, + viewFlags, + outCount + ); + + // If there are any search terms, we need to tell the db view about the + // the display (/virtual) folder so it can store all the view-specific + // data there (things like the active mail view and such that go in + // dbFolderInfo.) This also goes for cases where the quick search is + // active; the C++ code explicitly nulls out the view folder for no + // good/documented reason, so we need to set it again if we want changes + // made with the quick filter applied. (We don't just change the C++ + // code because there could be SeaMonkey fallout.) See bug 502767 for + // info about the quick-search part of the problem. + if (this.search.hasSearchTerms) { + dbView.viewFolder = this.displayedFolder; + } + } else { + // when we're dealing with a multi-folder virtual folder, we just tell the + // db view about the display folder. (It gets its own XFVF view, so it + // knows what to do.) + // and for a synthetic folder, displayedFolder is null anyways + dbView.open( + this.displayedFolder, + sortType, + sortOrder, + viewFlags, + outCount + ); + } + if (sortCustomCol) { + dbView.curCustomColumn = sortCustomCol; + } + + // we all know it's a tree view, make sure the interface is available + // so no one else has to do this. + dbView.QueryInterface(Ci.nsITreeView); + + // If Grouped By, the view has already been opened with the most specific + // sort (groups themselves are always sorted by date). + if (!(viewFlags & Ci.nsMsgViewFlagsType.kGroupBySort)) { + // clock through the rest of the sorts, if there are any + for (let iSort = this._sort.length - 2; iSort >= 0; iSort--) { + [sortType, sortOrder, sortCustomCol] = this._getSortDetails(iSort); + if (sortCustomCol) { + dbView.curCustomColumn = sortCustomCol; + } + dbView.sort(sortType, sortOrder); + } + } + + return dbView; + }, + + /** + * Callback method invoked by FolderNotificationHelper when our folder is + * loaded. Assuming we are still interested in the folder, we enter the + * folder via _enterFolder. + */ + _folderLoaded(aFolder) { + if (aFolder == this.displayedFolder) { + this.folderLoading = false; + // If _underlyingFolders is null, DBViewWrapper_open probably got + // an exception trying to open the db, but after reparsing the local + // folder, we should have a db, so set up the view based on info + // from the db. + if (this._underlyingFolders == null) { + this._prepareToLoadView(aFolder.msgDatabase, aFolder); + } + this._enterFolder(); + } + }, + + /** + * Enter this.displayedFolder if we have not yet entered it. + * + * Things we do on entering a folder: + * - clear the folder's biffState! + * - set the message database's header cache size + */ + _enterFolder() { + if (this._enteredFolder) { + this.listener.onMessagesLoaded(true); + return; + } + + this.displayedFolder.biffState = Ci.nsIMsgFolder.nsMsgBiffState_NoMail; + + // we definitely want a view at this point; force the view. + this._viewUpdateDepth = 0; + this._applyViewChanges(); + + this.listener.onDisplayingFolder(); + + this._enteredFolder = true; + }, + + /** + * Renames, moves to the trash, it's all crazy. We have to update all our + * references when this happens. + */ + _folderMoved(aOldFolder, aNewFolder) { + if (aOldFolder == this.displayedFolder) { + this.displayedFolder = aNewFolder; + } + + if (!this._underlyingFolders) { + // View is closed already. + return; + } + + let i = this._underlyingFolders.findIndex(f => f == aOldFolder); + if (i >= 0) { + this._underlyingFolders[i] = aNewFolder; + } + + // re-populate the view. + this._applyViewChanges(); + }, + + /** + * FolderNotificationHelper tells us when folders we care about are deleted + * (because we asked it to in |open|). If it was the folder we were + * displaying (real or virtual), this closes it. If we are virtual and + * backed by a single folder, this closes us. If we are backed by multiple + * folders, we just update ourselves. (Currently, cross-folder views are + * not clever enough to purge the mooted messages, so we need to do this to + * help them out.) + * We do not update virtual folder definitions as a result of deletion; we are + * a display abstraction. That (hopefully) happens elsewhere. + */ + _folderDeleted(aFolder) { + // XXX When we empty the trash, we're actually sending a folder deleted + // notification around. This check ensures we don't think we've really + // deleted the trash folder in the DBViewWrapper, and that stops nasty + // things happening, like forgetting we've got the trash folder selected. + if (aFolder.isSpecialFolder(Ci.nsMsgFolderFlags.Trash, false)) { + return; + } + + if (aFolder == this.displayedFolder) { + this.close(); + return; + } + + // indexOf doesn't work for this (reliably) + for (let [i, underlyingFolder] of this._underlyingFolders.entries()) { + if (aFolder == underlyingFolder) { + this._underlyingFolders.splice(i, 1); + break; + } + } + + if (this._underlyingFolders.length == 0) { + this.close(); + return; + } + // if we are virtual, this will update the search session which draws its + // search scopes from this._underlyingFolders anyways. + this._applyViewChanges(); + }, + + /** + * Compacting a local folder nukes its message keys, requiring the view to be + * rebuilt. If the folder is IMAP, it doesn't matter because the UIDs are + * the message keys and we can ignore it. In the local case we want to + * notify our listener so they have a chance to save the selected messages. + */ + _aboutToCompactFolder(aFolder) { + // IMAP compaction does not affect us unless we are holding headers + if (aFolder.server.type == "imap") { + return; + } + + // we will have to re-create the view, so nuke the view now. + if (this.dbView) { + this.listener.onDestroyingView(true); + this.search.dissociateView(this.dbView); + this.dbView.close(); + this.dbView = null; + } + }, + + /** + * Compaction is all done, let's re-create the view! (Unless the folder is + * IMAP, in which case we are ignoring this event sequence.) + */ + _compactedFolder(aFolder) { + // IMAP compaction does not affect us unless we are holding headers + if (aFolder.server.type == "imap") { + return; + } + + this.refresh(); + }, + + /** + * DB Views need help to know when their move / deletion operations complete. + * This happens in both single-folder and multiple-folder backed searches. + * In the latter case, there is potential danger that we tell a view that did + * not initiate the move / deletion but has kicked off its own about the + * completion and confuse it. However, that's on the view code. + */ + _deleteCompleted(aFolder) { + if (this.dbView) { + this.dbView.onDeleteCompleted(true); + } + this.listener.onMessagesRemoved(); + }, + + /** + * See _deleteCompleted for an explanation of what is going on. + */ + _deleteFailed(aFolder) { + if (this.dbView) { + this.dbView.onDeleteCompleted(false); + } + this.listener.onMessageRemovalFailed(); + }, + + _forceOpen(aFolder) { + this.displayedFolder = null; + this.open(aFolder); + }, + + _renameCompleted(aFolder) { + if (aFolder == this.displayedFolder) { + this._forceOpen(aFolder); + } + }, + + /** + * If the displayed folder had its total message count or total unread message + * count change, notify the listener. (Note: only for the display folder; + * not the underlying folders!) + */ + _messageCountsChanged(aFolder) { + if (aFolder == this.displayedFolder) { + this.listener.onMessageCountsChanged(); + } + }, + + /** + * @returns the current set of viewFlags. This may be: + * - A modified set of flags that are pending application because a view + * update is in effect and we don't want to modify the view when it's just + * going to get destroyed. + * - The live set of flags from the current dbView. + * - The 'limbo' set of flags because we currently lack a view but will have + * one soon (and then we will apply the flags). + */ + get _viewFlags() { + if (this.__viewFlags != null) { + return this.__viewFlags; + } + if (this.dbView) { + return this.dbView.viewFlags; + } + return 0; + }, + /** + * Update the view flags to use on the view. If we are in a view update or + * currently don't have a view, we save the view flags for later usage when + * the view gets (re)built. If we have a view, depending on what's happening + * we may re-create the view or just set the bits. The rules/reasons are: + * - XFVF views can handle the flag changes, just set the flags. + * - XFVF threaded/unthreaded change must re-sort, the backend forgot. + * - Single-folder virtual folders (quicksearch) can handle viewFlag changes, + * to/from grouped included, so set it. + * - Single-folder threaded/unthreaded can handle a change to/from unthreaded/ + * threaded, so set it. + * - Single-folder can _not_ handle a change between grouped and not-grouped, + * so re-generate the view. Also it can't handle a change involving + * kUnreadOnly or kShowIgnored. + */ + set _viewFlags(aViewFlags) { + if (this._viewUpdateDepth || !this.dbView) { + this.__viewFlags = aViewFlags; + return; + } + + // For viewFlag changes, do not make a random selection if there is not + // actually anything selected; some views do this (looking at xfvf). + if (this.dbView.selection && this.dbView.selection.count == 0) { + this.dbView.selection.currentIndex = -1; + } + + let setViewFlags = true; + let reSort = false; + let oldFlags = this.dbView.viewFlags; + let changedFlags = oldFlags ^ aViewFlags; + + if (this.isVirtual) { + if ( + this.isMultiFolder && + changedFlags & Ci.nsMsgViewFlagsType.kThreadedDisplay && + !(changedFlags & Ci.nsMsgViewFlagsType.kGroupBySort) + ) { + reSort = true; + } + if (this.isSingleFolder) { + // ugh, and the single folder case needs us to re-apply his sort... + reSort = true; + } + } else { + // The regular single folder case. + if ( + changedFlags & + (Ci.nsMsgViewFlagsType.kGroupBySort | + Ci.nsMsgViewFlagsType.kUnreadOnly | + Ci.nsMsgViewFlagsType.kShowIgnored) + ) { + setViewFlags = false; + } + // ugh, and the single folder case needs us to re-apply his sort... + reSort = true; + } + + if (setViewFlags) { + this.dbView.viewFlags = aViewFlags; + if (reSort) { + this.dbView.sort(this.dbView.sortType, this.dbView.sortOrder); + } + this.listener.onSortChanged(); + } else { + this.__viewFlags = aViewFlags; + this._applyViewChanges(); + } + }, + + /** + * Apply accumulated changes to the view. If we are in a batch, we do + * nothing, relying on endDisplayUpdate to call us. + */ + _applyViewChanges() { + // if we are in a batch, wait for endDisplayUpdate to be called to get us + // out to zero. + if (this._viewUpdateDepth) { + return; + } + // make the dbView stop being a search listener if it is one + if (this.dbView) { + // save the view's flags if it has any and we haven't already overridden + // them. + if (this.__viewFlags == null) { + this.__viewFlags = this.dbView.viewFlags; + } + this.listener.onDestroyingView(true); // we will re-create it! + this.search.dissociateView(this.dbView); + this.dbView.close(); + this.dbView = null; + } + + this.dbView = this._createView(); + // if the synthetic view defines columns, add those for it + if (this.isSynthetic) { + for (let customCol of this._syntheticView.customColumns) { + customCol.bindToView(this.dbView); + this.dbView.addColumnHandler(customCol.id, customCol); + } + } + this.listener.onCreatedView(); + + // this ends up being a no-op if there are no search terms + this.search.associateView(this.dbView); + + // If we are searching, then the search will generate the all messages + // loaded notification. Although in some cases the search may have + // completed by now, that is not a guarantee. The search logic is + // time-slicing, which is why this can vary. (If it uses up its time + // slices, it will re-schedule itself, returning to us before completing.) + // Which is why we always defer to the search if one is active. + // If we are loading the folder, the load completion will also notify us, + // so we should not generate all messages loaded right now. + if (!this.searching && !this.folderLoading) { + this.listener.onMessagesLoaded(true); + } else if (this.dbView.numMsgsInView > 0) { + this.listener.onMessagesLoaded(false); + } + }, + + get isMailFolder() { + return Boolean( + this.displayedFolder && + this.displayedFolder.flags & Ci.nsMsgFolderFlags.Mail + ); + }, + + get isNewsFolder() { + return Boolean( + this.displayedFolder && + this.displayedFolder.flags & Ci.nsMsgFolderFlags.Newsgroup + ); + }, + + get isFeedFolder() { + return Boolean( + this.displayedFolder && this.displayedFolder.server.type == "rss" + ); + }, + + OUTGOING_FOLDER_FLAGS: + Ci.nsMsgFolderFlags.SentMail | + Ci.nsMsgFolderFlags.Drafts | + Ci.nsMsgFolderFlags.Queue | + Ci.nsMsgFolderFlags.Templates, + /** + * @returns true if the folder is an outgoing folder by virtue of being a + * sent mail folder, drafts folder, queue folder, or template folder, + * or being a sub-folder of one of those types of folders. + */ + get isOutgoingFolder() { + return ( + this.displayedFolder && + this.displayedFolder.isSpecialFolder(this.OUTGOING_FOLDER_FLAGS, true) + ); + }, + /** + * @returns true if the folder is not known to be a special outgoing folder + * or the descendent of a special outgoing folder. + */ + get isIncomingFolder() { + return !this.isOutgoingFolder; + }, + + get isVirtual() { + return Boolean( + this.displayedFolder && + this.displayedFolder.flags & Ci.nsMsgFolderFlags.Virtual + ); + }, + + /** + * Prevent view updates from running until a paired |endViewUpdate| call is + * made. This is an advisory method intended to aid us in performing + * redundant view re-computations and does not forbid us from building the + * view earlier if we have a good reason. + * Since calling endViewUpdate will compel a view update when the update + * depth reaches 0, you should only call this method if you are sure that + * you will need the view to be re-built. If you are doing things like + * changing to/from threaded mode that do not cause the view to be rebuilt, + * you should just set those attributes directly. + */ + beginViewUpdate() { + this._viewUpdateDepth++; + }, + + /** + * Conclude a paired call to |beginViewUpdate|. Assuming the view depth has + * reached 0 with this call, the view will be re-created with the current + * settings. + */ + endViewUpdate(aForceLevel) { + if (--this._viewUpdateDepth == 0) { + this._applyViewChanges(); + } + // Avoid pathological situations. + if (this._viewUpdateDepth < 0) { + this._viewUpdateDepth = 0; + } + }, + + /** + * @returns the primary sort type (as one of the numeric constants from + * nsMsgViewSortType). + */ + get primarySortType() { + return this._sort[0][0]; + }, + + /** + * @returns the primary sort order (as one of the numeric constants from + * nsMsgViewSortOrder.) + */ + get primarySortOrder() { + return this._sort[0][1]; + }, + + /** + * @returns true if the dominant sort is ascending. + */ + get isSortedAscending() { + return ( + this._sort.length && this._sort[0][1] == Ci.nsMsgViewSortOrder.ascending + ); + }, + /** + * @returns true if the dominant sort is descending. + */ + get isSortedDescending() { + return ( + this._sort.length && this._sort[0][1] == Ci.nsMsgViewSortOrder.descending + ); + }, + /** + * Indicate if we are sorting by time or something correlated with time. + * + * @returns true if the dominant sort is by time. + */ + get sortImpliesTemporalOrdering() { + if (!this._sort.length) { + return false; + } + let sortType = this._sort[0][0]; + return ( + sortType == Ci.nsMsgViewSortType.byDate || + sortType == Ci.nsMsgViewSortType.byReceived || + sortType == Ci.nsMsgViewSortType.byId || + sortType == Ci.nsMsgViewSortType.byThread + ); + }, + + sortAscending() { + if (!this.isSortedAscending) { + this.magicSort(this._sort[0][0], Ci.nsMsgViewSortOrder.ascending); + } + }, + sortDescending() { + if (!this.isSortedDescending) { + this.magicSort(this._sort[0][0], Ci.nsMsgViewSortOrder.descending); + } + }, + + /** + * Explicit sort command. We ignore all previous sort state and only apply + * what you tell us. If you want implied secondary sort, use |magicSort|. + * You must use this sort command, and never directly call the sort commands + * on the underlying db view! If you do not, make sure to fight us every + * step of the way, because we will keep clobbering your manually applied + * sort. + * For secondary and multiple custom column support, a byCustom aSortType and + * aSecondaryType must be the column name string. + */ + sort(aSortType, aSortOrder, aSecondaryType, aSecondaryOrder) { + // For sort changes, do not make a random selection if there is not + // actually anything selected; some views do this (looking at xfvf). + if (this.dbView.selection && this.dbView.selection.count == 0) { + this.dbView.selection.currentIndex = -1; + } + + this._sort = [[aSortType, aSortOrder]]; + if (aSecondaryType != null && aSecondaryOrder != null) { + this._sort.push([aSecondaryType, aSecondaryOrder]); + } + // make sure the sort won't make the view angry... + this._ensureValidSort(); + // if we are not in a view update, invoke the sort. + if (this._viewUpdateDepth == 0 && this.dbView) { + for (let iSort = this._sort.length - 1; iSort >= 0; iSort--) { + // apply them in the reverse order + let [sortType, sortOrder, sortCustomCol] = this._getSortDetails(iSort); + if (sortCustomCol) { + this.dbView.curCustomColumn = sortCustomCol; + } + this.dbView.sort(sortType, sortOrder); + } + // (only generate the event since we're not in a update batch) + this.listener.onSortChanged(); + } + // (if we are in a view update, then a new view will be created when the + // update ends, and it will just use the new sort order anyways.) + }, + + /** + * Logic that compensates for custom column identifiers being provided as + * sort types. + * + * @returns [sort type, sort order, sort custom column name] + */ + _getSortDetails(aIndex) { + let [sortType, sortOrder] = this._sort[aIndex]; + let sortCustomColumn = null; + let sortTypeType = typeof sortType; + if (sortTypeType != "number") { + sortCustomColumn = sortTypeType == "string" ? sortType : sortType.id; + sortType = Ci.nsMsgViewSortType.byCustom; + } + + return [sortType, sortOrder, sortCustomColumn]; + }, + + /** + * Accumulates implied secondary sorts based on multiple calls to this method. + * This is intended to be hooked up to be controlled by the UI. + * Because we are lazy, we actually just poke the view's sort method and save + * the apparent secondary sort. This also allows perfect compliance with the + * way this used to be implemented! + * For secondary and multiple custom column support, a byCustom aSortType must + * be the column name string. + */ + magicSort(aSortType, aSortOrder) { + if (this.dbView) { + // For sort changes, do not make a random selection if there is not + // actually anything selected; some views do this (looking at xfvf). + if (this.dbView.selection && this.dbView.selection.count == 0) { + this.dbView.selection.currentIndex = -1; + } + + // so, the thing we just set obviously will be there + this._sort = [[aSortType, aSortOrder]]; + // (make sure it is valid...) + this._ensureValidSort(); + // get sort details, handle custom column as string sortType + let [sortType, sortOrder, sortCustomCol] = this._getSortDetails(0); + if (sortCustomCol) { + this.dbView.curCustomColumn = sortCustomCol; + } + // apply the sort to see what happens secondary-wise + this.dbView.sort(sortType, sortOrder); + // there is only a secondary sort if it's not none and not the same. + if ( + this.dbView.secondarySortType != Ci.nsMsgViewSortType.byNone && + (this.dbView.secondarySortType != sortType || + (this.dbView.secondarySortType == Ci.nsMsgViewSortType.byCustom && + this.dbView.secondaryCustomColumn != sortCustomCol)) + ) { + this._sort.push([ + this.dbView.secondaryCustomColumn || this.dbView.secondarySortType, + this.dbView.secondarySortOrder, + ]); + } + // only tell our listener if we're not in a view update batch + if (this._viewUpdateDepth == 0) { + this.listener.onSortChanged(); + } + } + }, + + /** + * Make sure the current sort is valid under our other constraints, make it + * safe if it is not. Most specifically, some sorts are illegal when + * grouping by sort, and we reset the sort to date in those cases. + * + * @param aViewFlags Optional set of view flags to consider instead of the + * potentially live view flags. + */ + _ensureValidSort(aViewFlags) { + if ( + (aViewFlags != null ? aViewFlags : this._viewFlags) & + Ci.nsMsgViewFlagsType.kGroupBySort + ) { + // We cannot be sorting by thread, id, none, or size. If we are, switch + // to sorting by date. + for (let sortPair of this._sort) { + let sortType = sortPair[0]; + if ( + sortType == Ci.nsMsgViewSortType.byThread || + sortType == Ci.nsMsgViewSortType.byId || + sortType == Ci.nsMsgViewSortType.byNone || + sortType == Ci.nsMsgViewSortType.bySize + ) { + this._sort = [[Ci.nsMsgViewSortType.byDate, this._sort[0][1]]]; + break; + } + } + } + }, + + /** + * @returns {boolean} true if we are grouped-by-sort, false if not. If we are + * not grouped-by-sort, then we are either threaded or unthreaded; check + * the showThreaded property to find out which of those it is. + */ + get showGroupedBySort() { + return Boolean(this._viewFlags & Ci.nsMsgViewFlagsType.kGroupBySort); + }, + /** + * Enable grouped-by-sort which is mutually exclusive with threaded display + * (as controlled/exposed by showThreaded). Grouped-by-sort is not legal + * for sorts by thread/id/size/none and enabling this will cause us to change + * our sort to by date in those cases. + */ + set showGroupedBySort(aShowGroupBySort) { + if (this.showGroupedBySort != aShowGroupBySort) { + if (aShowGroupBySort) { + // For virtual single folders, the kExpandAll flag must be set. + // Do not apply the flag change until we have made the sort safe. + let viewFlags = + this._viewFlags | + Ci.nsMsgViewFlagsType.kGroupBySort | + Ci.nsMsgViewFlagsType.kExpandAll | + Ci.nsMsgViewFlagsType.kThreadedDisplay; + this._ensureValidSort(viewFlags); + this._viewFlags = viewFlags; + } else { + // maybe we shouldn't do anything in this case? + this._viewFlags &= ~( + Ci.nsMsgViewFlagsType.kGroupBySort | + Ci.nsMsgViewFlagsType.kThreadedDisplay + ); + } + } + }, + + /** + * Are we showing ignored/killed threads? + */ + get showIgnored() { + return Boolean(this._viewFlags & Ci.nsMsgViewFlagsType.kShowIgnored); + }, + /** + * Set whether we are showing ignored/killed threads. + */ + set showIgnored(aShowIgnored) { + if (this.showIgnored == aShowIgnored) { + return; + } + + if (aShowIgnored) { + this._viewFlags |= Ci.nsMsgViewFlagsType.kShowIgnored; + } else { + this._viewFlags &= ~Ci.nsMsgViewFlagsType.kShowIgnored; + } + }, + + /** + * @returns {boolean} true if we are in threaded mode (as opposed to unthreaded + * or grouped-by-sort). + */ + get showThreaded() { + return Boolean( + this._viewFlags & Ci.nsMsgViewFlagsType.kThreadedDisplay && + !(this._viewFlags & Ci.nsMsgViewFlagsType.kGroupBySort) + ); + }, + /** + * Set us to threaded display mode when set to true. If we are already in + * threaded display mode, we do nothing. If you want to set us to unthreaded + * mode, set |showUnthreaded| to true. (Because we have three modes of + * operation: unthreaded, threaded, and grouped-by-sort, we are a tri-state + * and setting us to false is ambiguous. We should probably be using a + * single attribute with three constants...) + */ + set showThreaded(aShowThreaded) { + if (this.showThreaded != aShowThreaded) { + let viewFlags = this._viewFlags; + if (aShowThreaded) { + viewFlags |= Ci.nsMsgViewFlagsType.kThreadedDisplay; + } else { + // Maybe we shouldn't do anything in this case? + viewFlags &= ~Ci.nsMsgViewFlagsType.kThreadedDisplay; + } + // lose the group bit... + viewFlags &= ~Ci.nsMsgViewFlagsType.kGroupBySort; + this._viewFlags = viewFlags; + } + }, + + /** + * @returns {boolean} true if we are in unthreaded mode (which means not + * threaded and not grouped-by-sort). + */ + get showUnthreaded() { + return Boolean( + !( + this._viewFlags & + (Ci.nsMsgViewFlagsType.kGroupBySort | + Ci.nsMsgViewFlagsType.kThreadedDisplay) + ) + ); + }, + /** + * Set to true to put us in unthreaded mode (which means not threaded and + * not grouped-by-sort). + */ + set showUnthreaded(aShowUnthreaded) { + if (this.showUnthreaded != aShowUnthreaded) { + if (aShowUnthreaded) { + this._viewFlags &= ~( + Ci.nsMsgViewFlagsType.kGroupBySort | + Ci.nsMsgViewFlagsType.kThreadedDisplay + ); + } else { + // Maybe we shouldn't do anything in this case? + this._viewFlags = + (this._viewFlags & ~Ci.nsMsgViewFlagsType.kGroupBySort) | + Ci.nsMsgViewFlagsType.kThreadedDisplay; + } + } + }, + + /** + * @returns true if we are showing only unread messages. + */ + get showUnreadOnly() { + return Boolean(this._viewFlags & Ci.nsMsgViewFlagsType.kUnreadOnly); + }, + /** + * Enable/disable showing only unread messages using the view's flag-based + * mechanism. This functionality can also be approximated using a mail + * view (or other search) for unread messages. There also exist special + * views for showing messages with unread threads which is different and + * has serious limitations because of its nature. + * Setting anything to this value clears any active special view because the + * actual UI use case (the "View... Threads..." menu) uses this setter + * intentionally as a mutually exclusive UI choice from the special views. + */ + set showUnreadOnly(aShowUnreadOnly) { + if (this._specialView || this.showUnreadOnly != aShowUnreadOnly) { + let viewRebuildRequired = this._specialView != null; + this._specialView = null; + if (viewRebuildRequired) { + this.beginViewUpdate(); + } + + if (aShowUnreadOnly) { + this._viewFlags |= Ci.nsMsgViewFlagsType.kUnreadOnly; + } else { + this._viewFlags &= ~Ci.nsMsgViewFlagsType.kUnreadOnly; + } + + if (viewRebuildRequired) { + this.endViewUpdate(); + } + } + }, + + /** + * Read-only attribute indicating if a 'special view' is in use. There are + * two special views in existence, both of which are concerned about + * showing you threads that have any unread messages in them. They are views + * rather than search predicates because the search mechanism is not capable + * of expressing such a thing. (Or at least it didn't use to be? We might + * be able to whip something up these days...) + */ + get specialView() { + return this._specialView != null; + }, + /** + * Private helper for use by the specialView* setters that handles the common + * logic. We don't want this method to be public because we want it to be + * feasible for the view hierarchy and its enumerations to go away without + * code outside this class having to care so much. + */ + _setSpecialView(aViewEnum) { + // special views simply cannot work for virtual folders. explode. + if (this.isVirtual) { + throw new Error("Virtual folders cannot use special views!"); + } + this.beginViewUpdate(); + // all special views imply a threaded view + this.showThreaded = true; + this._specialView = aViewEnum; + // We clear the search for paranoia/correctness reasons. However, the UI + // layer is currently responsible for making sure these are already zeroed + // out. + this.search.clear(); + this.endViewUpdate(); + }, + /** + * @returns true if the special view that shows threads with unread messages + * in them is active. + */ + get specialViewThreadsWithUnread() { + return this._specialView == Ci.nsMsgViewType.eShowThreadsWithUnread; + }, + /** + * If true is assigned, attempts to enable the special view that shows threads + * with unread messages in them. This will not work on virtual folders + * because of the inheritance hierarchy. + * Any mechanism that requires search terms (quick search, mailviews) will be + * reset/disabled when enabling this view. + */ + set specialViewThreadsWithUnread(aSpecial) { + this._setSpecialView(Ci.nsMsgViewType.eShowThreadsWithUnread); + }, + /** + * @returns true if the special view that shows watched threads with unread + * messages in them is active. + */ + get specialViewWatchedThreadsWithUnread() { + return this._specialView == Ci.nsMsgViewType.eShowWatchedThreadsWithUnread; + }, + /** + * If true is assigned, attempts to enable the special view that shows watched + * threads with unread messages in them. This will not work on virtual + * folders because of the inheritance hierarchy. + * Any mechanism that requires search terms (quick search, mailviews) will be + * reset/disabled when enabling this view. + */ + set specialViewWatchedThreadsWithUnread(aSpecial) { + this._setSpecialView(Ci.nsMsgViewType.eShowWatchedThreadsWithUnread); + }, + + get mailViewIndex() { + return this._mailViewIndex; + }, + + get mailViewData() { + return this._mailViewData; + }, + + /** + * Set the current mail view to the given mail view index with the provided + * data (normally only used for the 'tag' mail views.) We persist the state + * change + * + * @param aMailViewIndex The view to use, one of the kViewItem* constants from + * msgViewPickerOverlay.js OR the name of a custom view. (It's really up + * to MailViewManager.getMailViewByIndex...) + * @param aData Some piece of data appropriate to the mail view, currently + * this is only used for the tag name for kViewItemTags (sans the ":"). + * @param aDoNotPersist If true, we don't save this change to the db folder + * info. This is intended for internal use only. + */ + setMailView(aMailViewIndex, aData, aDoNotPersist) { + let mailViewDef = MailViewManager.getMailViewByIndex(aMailViewIndex); + + this._mailViewIndex = aMailViewIndex; + this._mailViewData = aData; + + // - update the search terms + // (this triggers a view update if we are not in a batch) + this.search.viewTerms = mailViewDef.makeTerms(this.search.session, aData); + + // - persist the view to the folder. + if (!aDoNotPersist && this.displayedFolder) { + let msgDatabase = this.displayedFolder.msgDatabase; + if (msgDatabase) { + let dbFolderInfo = msgDatabase.dBFolderInfo; + dbFolderInfo.setUint32Property( + MailViewConstants.kViewCurrent, + this._mailViewIndex + ); + // _mailViewData attempts to be sane and be the tag name, as opposed to + // magic-value ":"-prefixed value historically stored on disk. Because + // we want to be forwards and backwards compatible, we put this back on + // when we persist it. It's not like the property is really generic + // anyways. + dbFolderInfo.setCharProperty( + MailViewConstants.kViewCurrentTag, + this._mailViewData ? ":" + this._mailViewData : "" + ); + } + } + + this.listener.onMailViewChanged(); + }, + + /** + * @returns true if the row at the given index contains a collapsed thread, + * false if the row is a collapsed group or anything else. + */ + isCollapsedThreadAtIndex(aViewIndex) { + let flags = this.dbView.getFlagsAt(aViewIndex); + return ( + flags & Ci.nsMsgMessageFlags.Elided && + !(flags & MSG_VIEW_FLAG_DUMMY) && + this.dbView.isContainer(aViewIndex) + ); + }, + + /** + * @returns true if the row at the given index is a grouped view dummy header + * row, false if anything else. + */ + isGroupedByHeaderAtIndex(aViewIndex) { + if ( + !this.dbView || + aViewIndex < 0 || + aViewIndex >= this.dbView.rowCount || + !this.showGroupedBySort + ) { + return false; + } + return Boolean(this.dbView.getFlagsAt(aViewIndex) & MSG_VIEW_FLAG_DUMMY); + }, + + /** + * Perform application-level behaviors related to leaving a folder that have + * nothing to do with our abstraction. + * + * Things we do on leaving a folder: + * - Mark the folder's messages as no longer new + * - Mark all messages read in the folder _if so configured_. + */ + onLeavingFolder() { + // Suppress useless InvalidateRange calls to the tree by the dbView. + if (this.dbView) { + this.dbView.suppressChangeNotifications = true; + } + this.displayedFolder.clearNewMessages(); + this.displayedFolder.hasNewMessages = false; + try { + // For legacy reasons, we support marking all messages as read when we + // leave a folder based on the server type. It's this listener's job + // to do the legwork to figure out if this is desired. + // + // Mark all messages of aFolder as read: + // We can't use the command controller, because it is already tuned in to + // the new folder, so we just mimic its behaviour wrt + // goDoCommand('cmd_markAllRead'). + if ( + this.dbView && + this.listener.shouldMarkMessagesReadOnLeavingFolder( + this.displayedFolder + ) + ) { + this.dbView.doCommand(Ci.nsMsgViewCommandType.markAllRead); + } + } catch (e) {} + }, + + /** + * Returns the view index for this message header in this view. + * + * - If this is a single folder view, we first check whether the folder is the + * right one. If it is, we call the db view's findIndexOfMsgHdr. We do the + * first check because findIndexOfMsgHdr only checks for whether the message + * key matches, which might lead to false positives. + * + * - If this isn't, we trust findIndexOfMsgHdr to do the right thing. + * + * @param aMsgHdr The message header for which the view index should be + * returned. + * @param [aForceFind] If the message is not in the view and this is true, we + * will drop any applied view filters to look for the + * message. The dropping of view filters is persistent, so + * use with care. Defaults to false. + * + * @returns the view index for this header, or nsMsgViewIndex_None if it isn't + * found. + * + * @public + */ + getViewIndexForMsgHdr(aMsgHdr, aForceFind) { + if (this.dbView) { + if (this.isSingleFolder && aMsgHdr.folder != this.dbView.msgFolder) { + return nsMsgViewIndex_None; + } + + let viewIndex = this.dbView.findIndexOfMsgHdr(aMsgHdr, true); + + if (aForceFind && viewIndex == nsMsgViewIndex_None) { + // Consider dropping view filters. + // - If we're not displaying all messages, switch to All + if ( + viewIndex == nsMsgViewIndex_None && + this.mailViewIndex != MailViewConstants.kViewItemAll + ) { + this.setMailView(MailViewConstants.kViewItemAll, null); + viewIndex = this.dbView.findIndexOfMsgHdr(aMsgHdr, true); + } + + // - Don't just show unread only + if (viewIndex == nsMsgViewIndex_None) { + this.showUnreadOnly = false; + viewIndex = this.dbView.findIndexOfMsgHdr(aMsgHdr, true); + } + } + + // We've done all we can. + return viewIndex; + } + + // No db view, so we can't do anything + return nsMsgViewIndex_None; + }, + + /** + * Convenience function to retrieve the first nsIMsgDBHdr in any of the + * folders backing this view with the given message-id header. This + * is for the benefit of FolderDisplayWidget's selection logic. + * When thinking about using this, please keep in mind that, currently, this + * is O(n) for the total number of messages across all the backing folders. + * Since the folder database should already be in memory, this should + * ideally not involve any disk I/O. + * Additionally, duplicate message-ids can and will happen, but since we + * are using the message database's getMsgHdrForMessageID method to be fast, + * our semantics are limited to telling you about only the first one we find. + * + * @param aMessageId The message-id of the message you want. + * @returns The first nsIMsgDBHdr found in any of the underlying folders with + * the given message header, null if none are found. The fact that we + * return something does not guarantee that it is actually visible in the + * view. (The search may be filtering it out.) + */ + getMsgHdrForMessageID(aMessageId) { + if (this._syntheticView) { + return this._syntheticView.getMsgHdrForMessageID(aMessageId); + } + if (!this._underlyingFolders) { + return null; + } + for (let folder of this._underlyingFolders) { + let msgHdr = folder.msgDatabase.getMsgHdrForMessageID(aMessageId); + if (msgHdr) { + return msgHdr; + } + } + return null; + }, +}; diff --git a/comm/mail/modules/DNS.jsm b/comm/mail/modules/DNS.jsm new file mode 100644 index 0000000000..de913aa5cd --- /dev/null +++ b/comm/mail/modules/DNS.jsm @@ -0,0 +1,493 @@ +/* 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/. */ + +/** + * This module is responsible for performing DNS queries using ctypes for + * loading system DNS libraries on Linux, Mac and Windows. + */ + +const EXPORTED_SYMBOLS = ["DNS", "SRVRecord"]; + +var DNS = null; + +if (typeof Components !== "undefined") { + var { ctypes } = ChromeUtils.importESModule( + "resource://gre/modules/ctypes.sys.mjs" + ); + var { BasePromiseWorker } = ChromeUtils.importESModule( + "resource://gre/modules/PromiseWorker.sys.mjs" + ); +} + +var LOCATION = "resource:///modules/DNS.jsm"; + +// These constants are luckily shared, but with different names +var NS_T_TXT = 16; // DNS_TYPE_TXT +var NS_T_SRV = 33; // DNS_TYPE_SRV +var NS_T_MX = 15; // DNS_TYPE_MX + +// For Linux and Mac. +function load_libresolv(os) { + this._open(os); +} + +load_libresolv.prototype = { + library: null, + + // Tries to find and load library. + _open(os) { + function findLibrary() { + let lastException = null; + let candidates = []; + if (os == "FreeBSD") { + candidates = [{ name: "c", suffix: ".7" }]; + } else if (os == "OpenBSD") { + candidates = [{ name: "c", suffix: "" }]; + } else { + candidates = [ + { name: "resolv.9", suffix: "" }, + { name: "resolv", suffix: ".2" }, + { name: "resolv", suffix: "" }, + ]; + } + let tried = []; + for (let candidate of candidates) { + try { + let name = ctypes.libraryName(candidate.name) + candidate.suffix; + tried.push(name); + return ctypes.open(name); + } catch (ex) { + lastException = ex; + } + } + throw new Error( + "Could not find libresolv in any of " + + tried + + " Exception: " + + lastException + + "\n" + ); + } + + // Declaring functions to be able to call them. + function declare(aSymbolNames, ...aArgs) { + let lastException = null; + if (!Array.isArray(aSymbolNames)) { + aSymbolNames = [aSymbolNames]; + } + + for (let name of aSymbolNames) { + try { + return library.declare(name, ...aArgs); + } catch (ex) { + lastException = ex; + } + } + library.close(); + throw new Error( + "Failed to declare " + + aSymbolNames + + " Exception: " + + lastException + + "\n" + ); + } + + let library = (this.library = findLibrary()); + this.res_search = declare( + ["res_9_search", "res_search", "__res_search"], + ctypes.default_abi, + ctypes.int, + ctypes.char.ptr, + ctypes.int, + ctypes.int, + ctypes.unsigned_char.ptr, + ctypes.int + ); + this.res_query = declare( + ["res_9_query", "res_query", "__res_query"], + ctypes.default_abi, + ctypes.int, + ctypes.char.ptr, + ctypes.int, + ctypes.int, + ctypes.unsigned_char.ptr, + ctypes.int + ); + this.dn_expand = declare( + ["res_9_dn_expand", "dn_expand", "__dn_expand"], + ctypes.default_abi, + ctypes.int, + ctypes.unsigned_char.ptr, + ctypes.unsigned_char.ptr, + ctypes.unsigned_char.ptr, + ctypes.char.ptr, + ctypes.int + ); + this.dn_skipname = declare( + ["res_9_dn_skipname", "dn_skipname", "__dn_skipname"], + ctypes.default_abi, + ctypes.int, + ctypes.unsigned_char.ptr, + ctypes.unsigned_char.ptr + ); + this.ns_get16 = declare( + ["res_9_ns_get16", "ns_get16", "_getshort"], + ctypes.default_abi, + ctypes.unsigned_int, + ctypes.unsigned_char.ptr + ); + this.ns_get32 = declare( + ["res_9_ns_get32", "ns_get32", "_getlong"], + ctypes.default_abi, + ctypes.unsigned_long, + ctypes.unsigned_char.ptr + ); + + this.QUERYBUF_SIZE = 1024; + this.NS_MAXCDNAME = 255; + this.NS_HFIXEDSZ = 12; + this.NS_QFIXEDSZ = 4; + this.NS_RRFIXEDSZ = 10; + this.NS_C_IN = 1; + }, + + close() { + this.library.close(); + this.library = null; + }, + + // Maps record to SRVRecord, TXTRecord, or MXRecord according to aTypeID and + // returns it. + _mapAnswer(aTypeID, aAnswer, aIdx, aLength) { + if (aTypeID == NS_T_SRV) { + let prio = this.ns_get16(aAnswer.addressOfElement(aIdx)); + let weight = this.ns_get16(aAnswer.addressOfElement(aIdx + 2)); + let port = this.ns_get16(aAnswer.addressOfElement(aIdx + 4)); + + let hostbuf = ctypes.char.array(this.NS_MAXCDNAME)(); + let hostlen = this.dn_expand( + aAnswer.addressOfElement(0), + aAnswer.addressOfElement(aLength), + aAnswer.addressOfElement(aIdx + 6), + hostbuf, + this.NS_MAXCDNAME + ); + let host = hostlen > -1 ? hostbuf.readString() : null; + return new SRVRecord(prio, weight, host, port); + } else if (aTypeID == NS_T_TXT) { + // TODO should only read dataLength characters. + let data = ctypes.unsigned_char.ptr(aAnswer.addressOfElement(aIdx + 1)); + + return new TXTRecord(data.readString()); + } else if (aTypeID == NS_T_MX) { + let prio = this.ns_get16(aAnswer.addressOfElement(aIdx)); + + let hostbuf = ctypes.char.array(this.NS_MAXCDNAME)(); + let hostlen = this.dn_expand( + aAnswer.addressOfElement(0), + aAnswer.addressOfElement(aLength), + aAnswer.addressOfElement(aIdx + 2), + hostbuf, + this.NS_MAXCDNAME + ); + let host = hostlen > -1 ? hostbuf.readString() : null; + return new MXRecord(prio, host); + } + return {}; + }, + + // Performs a DNS query for aTypeID on a certain address (aName) and returns + // array of records of aTypeID. + lookup(aName, aTypeID) { + let qname = ctypes.char.array()(aName); + let answer = ctypes.unsigned_char.array(this.QUERYBUF_SIZE)(); + let length = this.res_search( + qname, + this.NS_C_IN, + aTypeID, + answer, + this.QUERYBUF_SIZE + ); + + // There is an error. + if (length < 0) { + return []; + } + + let results = []; + let idx = this.NS_HFIXEDSZ; + + let qdcount = this.ns_get16(answer.addressOfElement(4)); + let ancount = this.ns_get16(answer.addressOfElement(6)); + + for (let qdidx = 0; qdidx < qdcount && idx < length; qdidx++) { + idx += + this.NS_QFIXEDSZ + + this.dn_skipname( + answer.addressOfElement(idx), + answer.addressOfElement(length) + ); + } + + for (let anidx = 0; anidx < ancount && idx < length; anidx++) { + idx += this.dn_skipname( + answer.addressOfElement(idx), + answer.addressOfElement(length) + ); + let rridx = idx; + let type = this.ns_get16(answer.addressOfElement(rridx)); + let dataLength = this.ns_get16(answer.addressOfElement(rridx + 8)); + + idx += this.NS_RRFIXEDSZ; + + if (type === aTypeID) { + let resource = this._mapAnswer(aTypeID, answer, idx, length); + resource.type = type; + resource.nsclass = this.ns_get16(answer.addressOfElement(rridx + 2)); + resource.ttl = this.ns_get32(answer.addressOfElement(rridx + 4)) | 0; + results.push(resource); + } + idx += dataLength; + } + return results; + }, +}; + +// For Windows. +function load_dnsapi() { + this._open(); +} + +load_dnsapi.prototype = { + library: null, + + // Tries to find and load library. + _open() { + function declare(aSymbolName, ...aArgs) { + try { + return library.declare(aSymbolName, ...aArgs); + } catch (ex) { + throw new Error( + "Failed to declare " + aSymbolName + " Exception: " + ex + "\n" + ); + } + } + + let library = (this.library = ctypes.open(ctypes.libraryName("DnsAPI"))); + + this.DNS_SRV_DATA = ctypes.StructType("DNS_SRV_DATA", [ + { pNameTarget: ctypes.jschar.ptr }, + { wPriority: ctypes.unsigned_short }, + { wWeight: ctypes.unsigned_short }, + { wPort: ctypes.unsigned_short }, + { Pad: ctypes.unsigned_short }, + ]); + + this.DNS_TXT_DATA = ctypes.StructType("DNS_TXT_DATA", [ + { dwStringCount: ctypes.unsigned_long }, + { pStringArray: ctypes.jschar.ptr.array(1) }, + ]); + + this.DNS_MX_DATA = ctypes.StructType("DNS_MX_DATA", [ + { pNameTarget: ctypes.jschar.ptr }, + { wPriority: ctypes.unsigned_short }, + { Pad: ctypes.unsigned_short }, + ]); + + this.DNS_RECORD = ctypes.StructType("_DnsRecord"); + this.DNS_RECORD.define([ + { pNext: this.DNS_RECORD.ptr }, + { pName: ctypes.jschar.ptr }, + { wType: ctypes.unsigned_short }, + { wDataLength: ctypes.unsigned_short }, + { Flags: ctypes.unsigned_long }, + { dwTtl: ctypes.unsigned_long }, + { dwReserved: ctypes.unsigned_long }, + { Data: this.DNS_SRV_DATA }, // it's a union, can be cast to many things + ]); + + this.PDNS_RECORD = ctypes.PointerType(this.DNS_RECORD); + this.DnsQuery_W = declare( + "DnsQuery_W", + ctypes.winapi_abi, + ctypes.long, + ctypes.jschar.ptr, + ctypes.unsigned_short, + ctypes.unsigned_long, + ctypes.voidptr_t, + this.PDNS_RECORD.ptr, + ctypes.voidptr_t.ptr + ); + this.DnsRecordListFree = declare( + "DnsRecordListFree", + ctypes.winapi_abi, + ctypes.void_t, + this.PDNS_RECORD, + ctypes.int + ); + + this.ERROR_SUCCESS = ctypes.Int64(0); + this.DNS_QUERY_STANDARD = 0; + this.DnsFreeRecordList = 1; + }, + + close() { + this.library.close(); + this.library = null; + }, + + // Maps record to SRVRecord, TXTRecord, or MXRecord according to aTypeID and + // returns it. + _mapAnswer(aTypeID, aData) { + if (aTypeID == NS_T_SRV) { + let srvdata = ctypes.cast(aData, this.DNS_SRV_DATA); + + return new SRVRecord( + srvdata.wPriority, + srvdata.wWeight, + srvdata.pNameTarget.readString(), + srvdata.wPort + ); + } else if (aTypeID == NS_T_TXT) { + let txtdata = ctypes.cast(aData, this.DNS_TXT_DATA); + if (txtdata.dwStringCount > 0) { + return new TXTRecord(txtdata.pStringArray[0].readString()); + } + } else if (aTypeID == NS_T_MX) { + let mxdata = ctypes.cast(aData, this.DNS_MX_DATA); + + return new MXRecord(mxdata.wPriority, mxdata.pNameTarget.readString()); + } + return {}; + }, + + // Performs a DNS query for aTypeID on a certain address (aName) and returns + // array of records of aTypeID (e.g. SRVRecord, TXTRecord, or MXRecord). + lookup(aName, aTypeID) { + let queryResultsSet = this.PDNS_RECORD(); + let qname = ctypes.jschar.array()(aName); + let dnsStatus = this.DnsQuery_W( + qname, + aTypeID, + this.DNS_QUERY_STANDARD, + null, + queryResultsSet.address(), + null + ); + + // There is an error. + if (ctypes.Int64.compare(dnsStatus, this.ERROR_SUCCESS) != 0) { + return []; + } + + let results = []; + for ( + let presult = queryResultsSet; + presult && !presult.isNull(); + presult = presult.contents.pNext + ) { + let result = presult.contents; + if (result.wType == aTypeID) { + let resource = this._mapAnswer(aTypeID, result.Data); + resource.type = result.wType; + resource.nsclass = 0; + resource.ttl = result.dwTtl | 0; + results.push(resource); + } + } + + this.DnsRecordListFree(queryResultsSet, this.DnsFreeRecordList); + return results; + }, +}; + +// Used to make results of different libraries consistent for SRV queries. +function SRVRecord(aPrio, aWeight, aHost, aPort) { + this.prio = aPrio; + this.weight = aWeight; + this.host = aHost; + this.port = aPort; +} + +// Used to make results of different libraries consistent for TXT queries. +function TXTRecord(aData) { + this.data = aData; +} + +// Used to make results of different libraries consistent for MX queries. +function MXRecord(aPrio, aHost) { + this.prio = aPrio; + this.host = aHost; +} + +if (typeof Components === "undefined") { + /* eslint-env worker */ + + // We are in a worker, wait for our message then execute the wanted method. + /* import-globals-from /toolkit/components/workerloader/require.js */ + importScripts("resource://gre/modules/workers/require.js"); + let PromiseWorker = require("resource://gre/modules/workers/PromiseWorker.js"); + + let worker = new PromiseWorker.AbstractWorker(); + worker.dispatch = function (aMethod, aArgs = []) { + return self[aMethod](...aArgs); + }; + worker.postMessage = function (...aArgs) { + self.postMessage(...aArgs); + }; + worker.close = function () { + self.close(); + }; + self.addEventListener("message", msg => worker.handleMessage(msg)); + + // eslint-disable-next-line no-unused-vars + function execute(aOS, aMethod, aArgs) { + let DNS = aOS == "WINNT" ? new load_dnsapi() : new load_libresolv(aOS); + return DNS[aMethod].apply(DNS, aArgs); + } +} else { + // We are loaded as a JSM, provide the async front that will start the + // worker. + var dns_async_front = { + /** + * Constants for use with the lookup function. + */ + TXT: NS_T_TXT, + SRV: NS_T_SRV, + MX: NS_T_MX, + + /** + * Do an asynchronous DNS lookup. The returned promise resolves with + * one of the Answer objects as defined above, or rejects with the + * error from the worker. + * + * Example: DNS.lookup("_caldavs._tcp.example.com", DNS.SRV) + * + * @param aName The aName to look up. + * @param aTypeID The RR type to look up as a constant. + * @returns A promise resolved when completed. + */ + lookup(aName, aTypeID) { + let worker = new BasePromiseWorker(LOCATION); + return worker.post("execute", [ + Services.appinfo.OS, + "lookup", + [...arguments], + ]); + }, + + /** Convenience functions */ + srv(aName) { + return this.lookup(aName, NS_T_SRV); + }, + txt(aName) { + return this.lookup(aName, NS_T_TXT); + }, + mx(aName) { + return this.lookup(aName, NS_T_MX); + }, + }; + DNS = dns_async_front; +} diff --git a/comm/mail/modules/DisplayNameUtils.jsm b/comm/mail/modules/DisplayNameUtils.jsm new file mode 100644 index 0000000000..bee32796fd --- /dev/null +++ b/comm/mail/modules/DisplayNameUtils.jsm @@ -0,0 +1,130 @@ +/* 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/. */ + +const EXPORTED_SYMBOLS = ["DisplayNameUtils"]; + +const { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" +); + +var DisplayNameUtils = { + formatDisplayName, + formatDisplayNameList, +}; + +// XXX: Maybe the strings for this file should go in a separate bundle? +var gMessengerBundle = Services.strings.createBundle( + "chrome://messenger/locale/messenger.properties" +); + +function _getIdentityForAddress(aEmailAddress) { + let emailAddress = aEmailAddress.toLowerCase(); + for (let identity of MailServices.accounts.allIdentities) { + if (!identity.email) { + continue; + } + if (emailAddress == identity.email.toLowerCase()) { + return identity; + } + } + return null; +} + +/** + * Take an email address and compose a sensible display name based on the + * header display name and/or the display name from the address book. If no + * appropriate name can be made (e.g. there is no card for this address), + * returns |null|. + * + * @param {string} emailAddress - The email address to format. + * @param {string} headerDisplayName - The display name from the header, if any. + * @param {string} context - The field being formatted (e.g. "to", "from"). + * @returns The formatted display name, or null. + */ +function formatDisplayName(emailAddress, headerDisplayName, context) { + let displayName = null; + let identity = _getIdentityForAddress(emailAddress); + let card = MailServices.ab.cardForEmailAddress(emailAddress); + + // If this address is one of the user's identities... + if (identity) { + if ( + MailServices.accounts.allIdentities.length == 1 && + (!headerDisplayName || identity.fullName == headerDisplayName) + ) { + // ...pick a localized version of the word "Me" appropriate to this + // specific header; fall back to the version used by the "to" header + // if nothing else is available. + try { + displayName = gMessengerBundle.GetStringFromName( + `header${context}FieldMe` + ); + } catch (e) { + displayName = gMessengerBundle.GetStringFromName("headertoFieldMe"); + } + } else { + // Use the full address. It's not the expected name, maybe a customized + // one the user sent, or one the sender got wrong, or we have multiple + // identities making the "Me" short string ambiguous. + displayName = MailServices.headerParser + .makeMailboxObject(headerDisplayName, emailAddress) + .toString(); + } + } + + // If we don't have a card, refuse to generate a display name. Places calling + // this are then responsible for falling back to something else (e.g. the + // value from the message header). + if (card) { + // getProperty may return a "1" or "0" string, we want a boolean + if (card.getProperty("PreferDisplayName", "1") == "1") { + displayName = card.displayName || null; + } + + // Note: headerDisplayName is not used as a fallback as confusion could be + // caused by a collected address using an e-mail address as display name. + } + + return displayName; +} + +/** + * Format the display name from a list of addresses. First, try using + * formatDisplayName, then fall back to the header's display name or the + * address. + * + * @param aHeaderValue The decoded header value (e.g. mime2DecodedAuthor). + * @param aContext The context of the header field (e.g. "to", "from"). + * @returns The formatted display name. + */ +function formatDisplayNameList(aHeaderValue, aContext) { + let addresses = MailServices.headerParser.parseDecodedHeader(aHeaderValue); + if (addresses.length > 0) { + let displayName = formatDisplayName( + addresses[0].email, + addresses[0].name, + aContext + ); + let andOthersStr = ""; + if (addresses.length > 1) { + andOthersStr = " " + gMessengerBundle.GetStringFromName("andOthers"); + } + + if (displayName) { + return displayName + andOthersStr; + } + + // Construct default display. + if (addresses[0].email) { + return ( + MailServices.headerParser + .makeMailboxObject(addresses[0].name, addresses[0].email) + .toString() + andOthersStr + ); + } + } + + // Something strange happened, just return the raw header value. + return aHeaderValue; +} diff --git a/comm/mail/modules/ExtensionSupport.jsm b/comm/mail/modules/ExtensionSupport.jsm new file mode 100644 index 0000000000..cbf00bdb76 --- /dev/null +++ b/comm/mail/modules/ExtensionSupport.jsm @@ -0,0 +1,240 @@ +/* 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/. */ + +/** + * Helper functions for use by extensions that should ease them plug + * into the application. + */ + +const EXPORTED_SYMBOLS = ["ExtensionSupport"]; + +var extensionHooks = new Map(); +var openWindowList; + +var ExtensionSupport = { + /** + * Register listening for windows getting opened that will run the specified callback function + * when a matching window is loaded. + * + * @param aID {String} - Some identification of the caller, usually the extension ID. + * @param aExtensionHook {Object} - The object describing the hook the caller wants to register. + * Members of the object can be (all optional, but one callback must be supplied): + * chromeURLs {Array} An array of strings of document URLs on which + * the given callback should run. If not specified, + * run on all windows. + * onLoadWindow {function} The callback function to run when window loads + * the matching document. + * onUnloadWindow {function} The callback function to run when window + * unloads the matching document. + * Both callbacks receive the matching window object as argument. + * + * @returns {boolean} True if the passed arguments were valid and the caller could be registered. + * False otherwise. + */ + registerWindowListener(aID, aExtensionHook) { + if (!aID) { + console.error("No extension ID provided for the window listener"); + return false; + } + + if (extensionHooks.has(aID)) { + console.error( + "Window listener for extension + '" + aID + "' already registered" + ); + return false; + } + + if ( + !("onLoadWindow" in aExtensionHook) && + !("onUnloadWindow" in aExtensionHook) + ) { + console.error( + "The extension + '" + aID + "' does not provide any callbacks" + ); + return false; + } + + extensionHooks.set(aID, aExtensionHook); + + // Add our global listener if there isn't one already + // (only when we have first caller). + if (extensionHooks.size == 1) { + Services.wm.addListener(this._windowListener); + } + + if (openWindowList) { + // We already have a list of open windows, notify the caller about them. + openWindowList.forEach(domWindow => + ExtensionSupport._checkAndRunMatchingExtensions(domWindow, "load", aID) + ); + } else { + openWindowList = new Set(); + // Get the list of windows already open. + let windows = Services.wm.getEnumerator(null); + while (windows.hasMoreElements()) { + let domWindow = windows.getNext(); + if (domWindow.document.location.href === "about:blank") { + ExtensionSupport._waitForLoad(domWindow, aID); + } else { + ExtensionSupport._addToListAndNotify(domWindow, aID); + } + } + } + + return true; + }, + + /** + * Unregister listening for windows for the given caller. + * + * @param aID {String} - Some identification of the caller, usually the extension ID. + * + * @returns {boolean} True if the passed arguments were valid and the caller could be unregistered. + * False otherwise. + */ + unregisterWindowListener(aID) { + if (!aID) { + console.error("No extension ID provided for the window listener"); + return false; + } + + let windowListener = extensionHooks.get(aID); + if (!windowListener) { + console.error( + "Couldn't remove window listener for extension + '" + aID + "'" + ); + return false; + } + + extensionHooks.delete(aID); + // Remove our global listener if there are no callers registered anymore. + if (extensionHooks.size == 0) { + Services.wm.removeListener(this._windowListener); + openWindowList.clear(); + openWindowList = undefined; + } + + return true; + }, + + get openWindows() { + if (!openWindowList) { + return []; + } + return openWindowList.values(); + }, + + _windowListener: { + // nsIWindowMediatorListener functions + onOpenWindow(appWindow) { + // A new window has opened. + let domWindow = appWindow.docShell.domWindow; + + // Here we pass no caller ID, so all registered callers get notified. + ExtensionSupport._waitForLoad(domWindow); + }, + + onCloseWindow(appWindow) { + // One of the windows has closed. + let domWindow = appWindow.docShell.domWindow; + openWindowList.delete(domWindow); + }, + }, + + /** + * Set up listeners to run the callbacks on the given window. + * + * @param aWindow {nsIDOMWindow} - The window to set up. + * @param aID {String} Optional. ID of the new caller that has registered right now. + */ + _waitForLoad(aWindow, aID) { + // Wait for the load event of the window. At that point + // aWindow.document.location.href will not be "about:blank" any more. + aWindow.addEventListener( + "load", + function () { + ExtensionSupport._addToListAndNotify(aWindow, aID); + }, + { once: true } + ); + }, + + /** + * Once the window is fully loaded with the href referring to the XUL document, + * add it to our list, attach the "unload" listener to it and notify interested + * callers. + * + * @param aWindow {nsIDOMWindow} - The window to process. + * @param aID {String} Optional. ID of the new caller that has registered right now. + */ + _addToListAndNotify(aWindow, aID) { + openWindowList.add(aWindow); + aWindow.addEventListener( + "unload", + function () { + ExtensionSupport._checkAndRunMatchingExtensions(aWindow, "unload"); + }, + { once: true } + ); + ExtensionSupport._checkAndRunMatchingExtensions(aWindow, "load", aID); + }, + + /** + * Check if the caller matches the given window and run its callback function. + * + * @param aWindow {nsIDOMWindow} - The window to run the callbacks on. + * @param aEventType {String} - Which callback to run if caller matches (load/unload). + * @param aID {String} - Optional ID of the caller whose callback is to be run. + * If not given, all registered callers are notified. + */ + _checkAndRunMatchingExtensions(aWindow, aEventType, aID) { + if (aID) { + checkAndRunExtensionCode(extensionHooks.get(aID)); + } else { + for (let extensionHook of extensionHooks.values()) { + checkAndRunExtensionCode(extensionHook); + } + } + + /** + * Check if the single given caller matches the given window + * and run its callback function. + * + * @param aExtensionHook {Object} - The object describing the hook the caller + * has registered. + */ + function checkAndRunExtensionCode(aExtensionHook) { + try { + let windowChromeURL = aWindow.document.location.href; + // Check if extension applies to this document URL. + if ( + "chromeURLs" in aExtensionHook && + !aExtensionHook.chromeURLs.some(url => url == windowChromeURL) + ) { + return; + } + + // Run the relevant callback. + switch (aEventType) { + case "load": + if ("onLoadWindow" in aExtensionHook) { + aExtensionHook.onLoadWindow(aWindow); + } + break; + case "unload": + if ("onUnloadWindow" in aExtensionHook) { + aExtensionHook.onUnloadWindow(aWindow); + } + break; + } + } catch (ex) { + console.error(ex); + } + } + }, + + get registeredWindowListenerCount() { + return extensionHooks.size; + }, +}; diff --git a/comm/mail/modules/ExtensionsUI.jsm b/comm/mail/modules/ExtensionsUI.jsm new file mode 100644 index 0000000000..cc969f2d5a --- /dev/null +++ b/comm/mail/modules/ExtensionsUI.jsm @@ -0,0 +1,1461 @@ +/* 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/. */ +"use strict"; + +var EXPORTED_SYMBOLS = ["ExtensionsUI"]; + +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); +const { EventEmitter } = ChromeUtils.importESModule( + "resource://gre/modules/EventEmitter.sys.mjs" +); + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AMTelemetry: "resource://gre/modules/AddonManager.sys.mjs", + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", + AddonManagerPrivate: "resource://gre/modules/AddonManager.sys.mjs", + AppMenuNotifications: "resource://gre/modules/AppMenuNotifications.sys.mjs", + ExtensionData: "resource://gre/modules/Extension.sys.mjs", + ExtensionParent: "resource://gre/modules/ExtensionParent.sys.mjs", + SITEPERMS_ADDON_TYPE: + "resource://gre/modules/addons/siteperms-addon-utils.sys.mjs", +}); + +const { PERMISSIONS_WITH_MESSAGE, PERMISSION_L10N } = + ChromeUtils.importESModule( + "resource://gre/modules/ExtensionPermissionMessages.sys.mjs" + ); + +// Add the Thunderbird specific permission description locale file, to allow +// Extension.sys.mjs to resolve our permissions strings. +PERMISSION_L10N.addResourceIds(["messenger/extensionPermissions.ftl"]); + +XPCOMUtils.defineLazyGetter( + lazy, + "l10n", + () => + new Localization( + [ + "branding/brand.ftl", + "messenger/extensionsUI.ftl", + "messenger/addonNotifications.ftl", + ], + true + ) +); + +const DEFAULT_EXTENSION_ICON = + "chrome://mozapps/skin/extensions/extensionGeneric.svg"; + +const HTML_NS = "http://www.w3.org/1999/xhtml"; + +const THUNDERBIRD_ANCHOR_ID = "addons-notification-icon"; + +// Thunderbird shim of PopupNotifications for usage in this module. +var PopupNotifications = { + get isPanelOpen() { + return getTopWindow().PopupNotifications.isPanelOpen; + }, + + getNotification(id, browser) { + return getTopWindow().PopupNotifications.getNotification(id, browser); + }, + + remove(notification, isCancel) { + return getTopWindow().PopupNotifications.remove(notification, isCancel); + }, + + show(browser, id, message, anchorID, mainAction, secondaryActions, options) { + let notifications = getTopWindow().PopupNotifications; + if (options.popupIconURL == "chrome://browser/content/extension.svg") { + options.popupIconURL = DEFAULT_EXTENSION_ICON; + } + return notifications.show( + browser, + id, + message, + anchorID, + mainAction, + secondaryActions, + options + ); + }, +}; + +function getTopWindow() { + return Services.wm.getMostRecentWindow("mail:3pane"); +} + +function getTabBrowser(browser) { + while (browser.ownerGlobal.docShell.itemType !== Ci.nsIDocShell.typeChrome) { + browser = browser.ownerGlobal.docShell.chromeEventHandler; + } + if (browser.getAttribute("webextension-view-type") == "popup") { + browser = browser.ownerGlobal.gBrowser.selectedBrowser; + } + return { browser, window: browser.ownerGlobal }; +} + +// Removes a doorhanger notification if all of the installs it was notifying +// about have ended in some way. +function removeNotificationOnEnd(notification, installs) { + let count = installs.length; + + function maybeRemove(install) { + install.removeListener(this); + + if (--count == 0) { + // Check that the notification is still showing + let current = PopupNotifications.getNotification( + notification.id, + notification.browser + ); + if (current === notification) { + notification.remove(); + } + } + } + + for (let install of installs) { + install.addListener({ + onDownloadCancelled: maybeRemove, + onDownloadFailed: maybeRemove, + onInstallFailed: maybeRemove, + onInstallEnded: maybeRemove, + }); + } +} + +// Copied from browser/base/content/browser-addons.js +function buildNotificationAction(msg, callback) { + let label = ""; + let accessKey = ""; + for (let { name, value } of msg.attributes) { + switch (name) { + case "label": + label = value; + break; + case "accesskey": + accessKey = value; + break; + } + } + return { label, accessKey, callback }; +} + +/** + * Mapping of error code -> [error-id, local-error-id] + * + * error-id is used for errors in DownloadedAddonInstall, + * local-error-id for errors in LocalAddonInstall. + * + * The error codes are defined in AddonManager's _errors Map. + * Not all error codes listed there are translated, + * since errors that are only triggered during updates + * will never reach this code. + * + * @see browser/base/content/browser-addons.js (where this is copied from) + */ +const ERROR_L10N_IDS = new Map([ + [ + -1, + [ + "addon-install-error-network-failure", + "addon-local-install-error-network-failure", + ], + ], + [ + -2, + [ + "addon-install-error-incorrect-hash", + "addon-local-install-error-incorrect-hash", + ], + ], + [ + -3, + [ + "addon-install-error-corrupt-file", + "addon-local-install-error-corrupt-file", + ], + ], + [ + -4, + [ + "addon-install-error-file-access", + "addon-local-install-error-file-access", + ], + ], + [ + -5, + ["addon-install-error-not-signed", "addon-local-install-error-not-signed"], + ], + [-8, ["addon-install-error-invalid-domain"]], +]); + +// Add Thunderbird specific permissions so localization will work. Add entries +// to PERMISSION_L10N_ID_OVERRIDES here in case a permission string needs to be +// overridden. +for (let perm of [ + "accountsFolders", + "accountsIdentities", + "accountsRead", + "addressBooks", + "compose", + "compose-send", + "compose-save", + "experiment", + "messagesImport", + "messagesModify", + "messagesMove", + "messagesDelete", + "messagesRead", + "messagesTags", + "sensitiveDataUpload", +]) { + PERMISSIONS_WITH_MESSAGE.add(perm); +} + +/** + * This object is Thunderbird's version of the same object in + * browser/base/content/browser-addons.js. Firefox has one of these objects + * per window but Thunderbird has only one total, because we simply pick the + * most recent window for notifications, rather than the window related to a + * particular tab. + */ +var gXPInstallObserver = { + pendingInstalls: new WeakMap(), + + // Themes do not have a permission prompt and instead call for an install + // confirmation. + showInstallConfirmation(browser, installInfo, height = undefined) { + let document = getTopWindow().document; + // If the confirmation notification is already open cache the installInfo + // and the new confirmation will be shown later + if ( + PopupNotifications.getNotification("addon-install-confirmation", browser) + ) { + let pending = this.pendingInstalls.get(browser); + if (pending) { + pending.push(installInfo); + } else { + this.pendingInstalls.set(browser, [installInfo]); + } + return; + } + + let showNextConfirmation = () => { + let pending = this.pendingInstalls.get(browser); + if (pending && pending.length) { + this.showInstallConfirmation(browser, pending.shift()); + } + }; + + // If all installs have already been cancelled in some way then just show + // the next confirmation. + if ( + installInfo.installs.every( + i => i.state != lazy.AddonManager.STATE_DOWNLOADED + ) + ) { + showNextConfirmation(); + return; + } + + // Make notifications persistent + var options = { + displayURI: installInfo.originatingURI, + persistent: true, + hideClose: true, + popupOptions: { + position: "bottomright topright", + }, + }; + + let acceptInstallation = () => { + for (let install of installInfo.installs) { + install.install(); + } + installInfo = null; + + Services.telemetry + .getHistogramById("SECURITY_UI") + .add( + Ci.nsISecurityUITelemetry.WARNING_CONFIRM_ADDON_INSTALL_CLICK_THROUGH + ); + }; + + let cancelInstallation = () => { + if (installInfo) { + for (let install of installInfo.installs) { + // The notification may have been closed because the add-ons got + // cancelled elsewhere, only try to cancel those that are still + // pending install. + if (install.state != lazy.AddonManager.STATE_CANCELLED) { + install.cancel(); + } + } + } + + showNextConfirmation(); + }; + + options.eventCallback = event => { + switch (event) { + case "removed": + cancelInstallation(); + break; + case "shown": + let addonList = document.getElementById( + "addon-install-confirmation-content" + ); + while (addonList.lastChild) { + addonList.lastChild.remove(); + } + + for (let install of installInfo.installs) { + let container = document.createXULElement("hbox"); + + let name = document.createXULElement("label"); + name.setAttribute("value", install.addon.name); + name.setAttribute("class", "addon-install-confirmation-name"); + container.appendChild(name); + + addonList.appendChild(container); + } + break; + } + }; + + let msgId; + let notification = document.getElementById( + "addon-install-confirmation-notification" + ); + msgId = "addon-confirm-install-message"; + notification.removeAttribute("warning"); + options.learnMoreURL = + "https://support.thunderbird.net/kb/installing-addon-thunderbird"; + const addonCount = installInfo.installs.length; + const messageString = lazy.l10n.formatValueSync(msgId, { addonCount }); + + const [acceptMsg, cancelMsg] = lazy.l10n.formatMessagesSync([ + "addon-install-accept-button", + "addon-install-cancel-button", + ]); + const action = buildNotificationAction(acceptMsg, acceptInstallation); + const secondaryAction = buildNotificationAction(cancelMsg, () => {}); + + if (height) { + notification.style.minHeight = height + "px"; + } + + let popup = PopupNotifications.show( + browser, + "addon-install-confirmation", + messageString, + THUNDERBIRD_ANCHOR_ID, + action, + [secondaryAction], + options + ); + removeNotificationOnEnd(popup, installInfo.installs); + + Services.telemetry + .getHistogramById("SECURITY_UI") + .add(Ci.nsISecurityUITelemetry.WARNING_CONFIRM_ADDON_INSTALL); + }, + + // IDs of addon install related notifications + NOTIFICATION_IDS: [ + "addon-install-blocked", + "addon-install-confirmation", + "addon-install-failed", + "addon-install-origin-blocked", + "addon-install-webapi-blocked", + "addon-install-policy-blocked", + "addon-progress", + "addon-webext-permissions", + "xpinstall-disabled", + ], + + /** + * Remove all opened addon installation notifications + * + * @param {*} browser - Browser to remove notifications for + * @returns {boolean} - true if notifications have been removed. + */ + removeAllNotifications(browser) { + let notifications = this.NOTIFICATION_IDS.map(id => + PopupNotifications.getNotification(id, browser) + ).filter(notification => notification != null); + + PopupNotifications.remove(notifications, true); + + return !!notifications.length; + }, + + async observe(aSubject, aTopic, aData) { + let installInfo = aSubject.wrappedJSObject; + let browser = installInfo.browser; + + // Make notifications persistent + let options = { + displayURI: installInfo.originatingURI, + persistent: true, + hideClose: true, + timeout: Date.now() + 30000, + popupOptions: { + position: "bottomright topright", + }, + }; + + switch (aTopic) { + case "addon-install-disabled": { + let msgId, action, secondaryActions; + if (Services.prefs.prefIsLocked("xpinstall.enabled")) { + msgId = "xpinstall-disabled-locked"; + action = null; + secondaryActions = null; + } else { + msgId = "xpinstall-disabled"; + const [disabledMsg, cancelMsg] = await lazy.l10n.formatMessages([ + "xpinstall-disabled-button", + "addon-install-cancel-button", + ]); + action = buildNotificationAction(disabledMsg, () => { + Services.prefs.setBoolPref("xpinstall.enabled", true); + }); + secondaryActions = [buildNotificationAction(cancelMsg, () => {})]; + } + + PopupNotifications.show( + browser, + "xpinstall-disabled", + await lazy.l10n.formatValue(msgId), + THUNDERBIRD_ANCHOR_ID, + action, + secondaryActions, + options + ); + break; + } + case "addon-install-fullscreen-blocked": { + // AddonManager denied installation because we are in DOM fullscreen + this.logWarningFullScreenInstallBlocked(); + break; + } + case "addon-install-webapi-blocked": + case "addon-install-policy-blocked": + case "addon-install-origin-blocked": { + const msgId = + aTopic == "addon-install-policy-blocked" + ? "addon-domain-blocked-by-policy" + : "xpinstall-prompt"; + let messageString = await lazy.l10n.formatValue(msgId); + if (Services.policies) { + let extensionSettings = Services.policies.getExtensionSettings("*"); + if ( + extensionSettings && + "blocked_install_message" in extensionSettings + ) { + messageString += " " + extensionSettings.blocked_install_message; + } + } + + options.removeOnDismissal = true; + options.persistent = false; + + let secHistogram = Services.telemetry.getHistogramById("SECURITY_UI"); + secHistogram.add( + Ci.nsISecurityUITelemetry.WARNING_ADDON_ASKING_PREVENTED + ); + let popup = PopupNotifications.show( + browser, + aTopic, + messageString, + THUNDERBIRD_ANCHOR_ID, + null, + null, + options + ); + removeNotificationOnEnd(popup, installInfo.installs); + break; + } + case "addon-install-blocked": { + let window = getTopWindow(); + await window.ensureCustomElements("moz-support-link"); + // Dismiss the progress notification. Note that this is bad if + // there are multiple simultaneous installs happening, see + // bug 1329884 for a longer explanation. + let progressNotification = PopupNotifications.getNotification( + "addon-progress", + browser + ); + if (progressNotification) { + progressNotification.remove(); + } + + // The informational content differs somewhat for site permission + // add-ons. AOM no longer supports installing multiple addons, + // so the array handling here is vestigial. + let isSitePermissionAddon = installInfo.installs.every( + ({ addon }) => addon?.type === lazy.SITEPERMS_ADDON_TYPE + ); + let hasHost = false; + let headerId, msgId; + if (isSitePermissionAddon) { + // At present, WebMIDI is the only consumer of the site permission + // add-on infrastructure, and so we can hard-code a midi string here. + // If and when we use it for other things, we'll need to plumb that + // information through. See bug 1826747. + headerId = "site-permission-install-first-prompt-midi-header"; + msgId = "site-permission-install-first-prompt-midi-message"; + } else if (options.displayURI) { + // PopupNotifications.show replaces <> with options.name. + headerId = { id: "xpinstall-prompt-header", args: { host: "<>" } }; + // getLocalizedFragment replaces %1$S with options.name. + msgId = { id: "xpinstall-prompt-message", args: { host: "%1$S" } }; + options.name = options.displayURI.displayHost; + hasHost = true; + } else { + headerId = "xpinstall-prompt-header-unknown"; + msgId = "xpinstall-prompt-message-unknown"; + } + const [headerString, msgString] = await lazy.l10n.formatValues([ + headerId, + msgId, + ]); + + // displayURI becomes it's own label, so we unset it for this panel. It will become part of the + // messageString above. + let displayURI = options.displayURI; + options.displayURI = undefined; + + options.eventCallback = topic => { + if (topic !== "showing") { + return; + } + let doc = browser.ownerDocument; + let message = doc.getElementById("addon-install-blocked-message"); + // We must remove any prior use of this panel message in this window. + while (message.firstChild) { + message.firstChild.remove(); + } + + if (!hasHost) { + message.textContent = msgString; + } else { + let b = doc.createElementNS("http://www.w3.org/1999/xhtml", "b"); + b.textContent = options.name; + let fragment = getLocalizedFragment(doc, msgString, b); + message.appendChild(fragment); + } + + let article = isSitePermissionAddon + ? "site-permission-addons" + : "unlisted-extensions-risks"; + let learnMore = doc.getElementById("addon-install-blocked-info"); + learnMore.setAttribute("support-page", article); + }; + + let secHistogram = Services.telemetry.getHistogramById("SECURITY_UI"); + secHistogram.add( + Ci.nsISecurityUITelemetry.WARNING_ADDON_ASKING_PREVENTED + ); + + const [ + installMsg, + dontAllowMsg, + neverAllowMsg, + neverAllowAndReportMsg, + ] = await lazy.l10n.formatMessages([ + "xpinstall-prompt-install", + "xpinstall-prompt-dont-allow", + "xpinstall-prompt-never-allow", + "xpinstall-prompt-never-allow-and-report", + ]); + + const action = buildNotificationAction(installMsg, () => { + secHistogram.add( + Ci.nsISecurityUITelemetry + .WARNING_ADDON_ASKING_PREVENTED_CLICK_THROUGH + ); + installInfo.install(); + }); + + const neverAllowCallback = () => { + // SitePermissions is browser/ only. + // lazy.SitePermissions.setForPrincipal( + // browser.contentPrincipal, + // "install", + // lazy.SitePermissions.BLOCK + // ); + for (let install of installInfo.installs) { + if (install.state != lazy.AddonManager.STATE_CANCELLED) { + install.cancel(); + } + } + if (installInfo.cancel) { + installInfo.cancel(); + } + }; + + const declineActions = [ + buildNotificationAction(dontAllowMsg, () => { + for (let install of installInfo.installs) { + if (install.state != lazy.AddonManager.STATE_CANCELLED) { + install.cancel(); + } + } + if (installInfo.cancel) { + installInfo.cancel(); + } + }), + buildNotificationAction(neverAllowMsg, neverAllowCallback), + ]; + + if (isSitePermissionAddon) { + // Restrict this to site permission add-ons for now pending a decision + // from product about how to approach this for extensions. + declineActions.push( + buildNotificationAction(neverAllowAndReportMsg, () => { + lazy.AMTelemetry.recordEvent({ + method: "reportSuspiciousSite", + object: "suspiciousSite", + value: displayURI?.displayHost ?? "(unknown)", + extra: {}, + }); + neverAllowCallback(); + }) + ); + } + + let popup = PopupNotifications.show( + browser, + aTopic, + headerString, + THUNDERBIRD_ANCHOR_ID, + action, + declineActions, + options + ); + removeNotificationOnEnd(popup, installInfo.installs); + break; + } + case "addon-install-started": { + // If all installs have already been downloaded then there is no need to + // show the download progress + if ( + installInfo.installs.every( + aInstall => aInstall.state == lazy.AddonManager.STATE_DOWNLOADED + ) + ) { + return; + } + + const messageString = lazy.l10n.formatValueSync( + "addon-downloading-and-verifying", + { addonCount: installInfo.installs.length } + ); + options.installs = installInfo.installs; + options.contentWindow = browser.contentWindow; + options.sourceURI = browser.currentURI; + options.eventCallback = function (aEvent) { + switch (aEvent) { + case "removed": + options.contentWindow = null; + options.sourceURI = null; + break; + } + }; + + const [acceptMsg, cancelMsg] = lazy.l10n.formatMessagesSync([ + "addon-install-accept-button", + "addon-install-cancel-button", + ]); + + const action = buildNotificationAction(acceptMsg, () => {}); + action.disabled = true; + + const secondaryAction = buildNotificationAction(cancelMsg, () => { + for (let install of installInfo.installs) { + if (install.state != lazy.AddonManager.STATE_CANCELLED) { + install.cancel(); + } + } + }); + + let notification = PopupNotifications.show( + browser, + "addon-progress", + messageString, + THUNDERBIRD_ANCHOR_ID, + action, + [secondaryAction], + options + ); + notification._startTime = Date.now(); + + break; + } + case "addon-install-failed": { + options.removeOnDismissal = true; + options.persistent = false; + + // TODO This isn't terribly ideal for the multiple failure case + for (let install of installInfo.installs) { + let host; + try { + host = options.displayURI.host; + } catch (e) { + // displayURI might be missing or 'host' might throw for non-nsStandardURL nsIURIs. + } + + if (!host) { + host = + install.sourceURI instanceof Ci.nsIStandardURL && + install.sourceURI.host; + } + + let messageString; + if ( + install.addon && + !Services.policies.mayInstallAddon(install.addon) + ) { + messageString = lazy.l10n.formatValueSync( + "addon-install-blocked-by-policy", + { addonName: install.name, addonId: install.addon.id } + ); + let extensionSettings = Services.policies.getExtensionSettings( + install.addon.id + ); + if ( + extensionSettings && + "blocked_install_message" in extensionSettings + ) { + messageString += " " + extensionSettings.blocked_install_message; + } + } else { + // TODO bug 1834484: simplify computation of isLocal. + const isLocal = !host; + let errorId = ERROR_L10N_IDS.get(install.error)?.[isLocal ? 1 : 0]; + const args = { addonName: install.name }; + if (!errorId) { + if ( + install.addon.blocklistState == + Ci.nsIBlocklistService.STATE_BLOCKED + ) { + errorId = "addon-install-error-blocklisted"; + } else { + errorId = "addon-install-error-incompatible"; + args.appVersion = Services.appinfo.version; + } + } + messageString = lazy.l10n.formatValueSync(errorId, args); + } + + // Add Learn More link when refusing to install an unsigned add-on + if (install.error == lazy.AddonManager.ERROR_SIGNEDSTATE_REQUIRED) { + options.learnMoreURL = + Services.urlFormatter.formatURLPref("app.support.baseURL") + + "unsigned-addons"; + } + + PopupNotifications.show( + browser, + aTopic, + messageString, + THUNDERBIRD_ANCHOR_ID, + null, + null, + options + ); + + // Can't have multiple notifications with the same ID, so stop here. + break; + } + this._removeProgressNotification(browser); + break; + } + case "addon-install-confirmation": { + let showNotification = () => { + let height; + if (PopupNotifications.isPanelOpen) { + let rect = getTopWindow() + .document.getElementById("addon-progress-notification") + .getBoundingClientRect(); + height = rect.height; + } + + this._removeProgressNotification(browser); + this.showInstallConfirmation(browser, installInfo, height); + }; + + let progressNotification = PopupNotifications.getNotification( + "addon-progress", + browser + ); + if (progressNotification) { + let downloadDuration = Date.now() - progressNotification._startTime; + let securityDelay = + Services.prefs.getIntPref("security.dialog_enable_delay") - + downloadDuration; + if (securityDelay > 0) { + getTopWindow().setTimeout(() => { + // The download may have been cancelled during the security delay + if ( + PopupNotifications.getNotification("addon-progress", browser) + ) { + showNotification(); + } + }, securityDelay); + break; + } + } + showNotification(); + } + } + }, + _removeProgressNotification(aBrowser) { + let notification = PopupNotifications.getNotification( + "addon-progress", + aBrowser + ); + if (notification) { + notification.remove(); + } + }, +}; + +Services.obs.addObserver(gXPInstallObserver, "addon-install-disabled"); +Services.obs.addObserver(gXPInstallObserver, "addon-install-origin-blocked"); +Services.obs.addObserver(gXPInstallObserver, "addon-install-policy-blocked"); +Services.obs.addObserver(gXPInstallObserver, "addon-install-webapi-blocked"); +Services.obs.addObserver(gXPInstallObserver, "addon-install-blocked"); +Services.obs.addObserver(gXPInstallObserver, "addon-install-started"); +Services.obs.addObserver(gXPInstallObserver, "addon-install-failed"); +Services.obs.addObserver(gXPInstallObserver, "addon-install-confirmation"); + +/** + * This object is Thunderbird's version of the same object in + * browser/modules/ExtensionsUI.jsm + */ +var ExtensionsUI = { + sideloaded: new Set(), + updates: new Set(), + sideloadListener: null, + + pendingNotifications: new WeakMap(), + + async init() { + Services.obs.addObserver(this, "webextension-permission-prompt"); + Services.obs.addObserver(this, "webextension-update-permissions"); + Services.obs.addObserver(this, "webextension-install-notify"); + Services.obs.addObserver(this, "webextension-optional-permission-prompt"); + Services.obs.addObserver(this, "webextension-defaultsearch-prompt"); + + await Services.wm.getMostRecentWindow("mail:3pane").delayedStartupPromise; + this._checkForSideloaded(); + }, + + async _checkForSideloaded() { + let sideloaded = await lazy.AddonManagerPrivate.getNewSideloads(); + + if (!sideloaded.length) { + // No new side-loads. We're done. + return; + } + + // The ordering shouldn't matter, but tests depend on notifications + // happening in a specific order. + sideloaded.sort((a, b) => a.id.localeCompare(b.id)); + + if (!this.sideloadListener) { + this.sideloadListener = { + onEnabled: addon => { + if (!this.sideloaded.has(addon)) { + return; + } + + this.sideloaded.delete(addon); + this._updateNotifications(); + + if (this.sideloaded.size == 0) { + lazy.AddonManager.removeAddonListener(this.sideloadListener); + this.sideloadListener = null; + } + }, + }; + lazy.AddonManager.addAddonListener(this.sideloadListener); + } + + for (let addon of sideloaded) { + this.sideloaded.add(addon); + } + this._updateNotifications(); + }, + + _updateNotifications() { + if (this.sideloaded.size + this.updates.size == 0) { + lazy.AppMenuNotifications.removeNotification("addon-alert"); + } else { + lazy.AppMenuNotifications.showBadgeOnlyNotification("addon-alert"); + } + this.emit("change"); + }, + + showAddonsManager(tabbrowser, strings, icon) { + // This is for compatibility. Thunderbird just shows the prompt. + return this.showPermissionsPrompt(tabbrowser, strings, icon); + }, + + showSideloaded(tabbrowser, addon) { + addon.markAsSeen(); + this.sideloaded.delete(addon); + this._updateNotifications(); + + let strings = this._buildStrings({ + addon, + permissions: addon.userPermissions, + type: "sideload", + }); + + lazy.AMTelemetry.recordManageEvent(addon, "sideload_prompt", { + num_strings: strings.msgs.length, + }); + + this.showAddonsManager(tabbrowser, strings, addon.iconURL).then( + async answer => { + if (answer) { + await addon.enable(); + + this._updateNotifications(); + + // The user has just enabled a sideloaded extension, if the permission + // can be changed for the extension, show the post-install panel to + // give the user that opportunity. + if ( + addon.permissions & + lazy.AddonManager.PERM_CAN_CHANGE_PRIVATEBROWSING_ACCESS + ) { + await this.showInstallNotification( + tabbrowser.selectedBrowser, + addon + ); + } + } + this.emit("sideload-response"); + } + ); + }, + + showUpdate(browser, info) { + lazy.AMTelemetry.recordInstallEvent(info.install, { + step: "permissions_prompt", + num_strings: info.strings.msgs.length, + }); + + this.showAddonsManager(browser, info.strings, info.addon.iconURL).then( + answer => { + if (answer) { + info.resolve(); + } else { + info.reject(); + } + // At the moment, this prompt will re-appear next time we do an update + // check. See bug 1332360 for proposal to avoid this. + this.updates.delete(info); + this._updateNotifications(); + } + ); + }, + + async observe(subject, topic, data) { + if (topic == "webextension-permission-prompt") { + let { target, info } = subject.wrappedJSObject; + + let { browser, window } = getTabBrowser(target); + + // Dismiss the progress notification. Note that this is bad if + // there are multiple simultaneous installs happening, see + // bug 1329884 for a longer explanation. + let progressNotification = window.PopupNotifications.getNotification( + "addon-progress", + browser + ); + if (progressNotification) { + progressNotification.remove(); + } + + let strings = this._buildStrings(info); + let data = new lazy.ExtensionData(info.addon.getResourceURI()); + await data.loadManifest(); + if (data.manifest.experiment_apis) { + // Add the experiment permission text and use the header for + // extensions with permissions. + let [experimentWarning] = await lazy.l10n.formatValues([ + "webext-experiment-warning", + ]); + let [header, msg] = await PERMISSION_L10N.formatValues([ + { + id: "webext-perms-header-with-perms", + args: { extension: "<>" }, + }, + "webext-perms-description-experiment", + ]); + strings.header = header; + strings.msgs = [msg]; + if (info.source != "AMO") { + strings.experimentWarning = experimentWarning; + } + } + + // Thunderbird doesn't care about signing and does not check + // info.addon.signedState as Firefox is doing it. + info.unsigned = false; + + // If this is an update with no promptable permissions, just apply it. Skip + // prompts also, if this add-on already has full access via experiment_apis. + if (info.type == "update") { + let extension = lazy.ExtensionParent.GlobalManager.getExtension( + info.addon.id + ); + if ( + !strings.msgs.length || + (extension && extension.manifest.experiment_apis) + ) { + info.resolve(); + return; + } + } + + let icon = info.unsigned + ? "chrome://global/skin/icons/warning.svg" + : info.icon; + + if (info.type == "sideload") { + lazy.AMTelemetry.recordManageEvent(info.addon, "sideload_prompt", { + num_strings: strings.msgs.length, + }); + } else { + lazy.AMTelemetry.recordInstallEvent(info.install, { + step: "permissions_prompt", + num_strings: strings.msgs.length, + }); + } + + // Reject add-ons using the legacy API. We cannot use the general "ignore + // unknown APIs" policy, as add-ons using the Legacy API from TB68 will + // not do anything, confusing the user. + if (data.manifest.legacy) { + let subject = { + wrappedJSObject: { + browser, + originatingURI: null, + installs: [ + { + addon: info.addon, + name: info.addon.name, + error: 0, + }, + ], + install: null, + cancel: null, + }, + }; + Services.obs.notifyObservers(subject, "addon-install-failed"); + info.reject(); + return; + } + + this.showPermissionsPrompt(browser, strings, icon).then(answer => { + if (answer) { + info.resolve(); + } else { + info.reject(); + } + }); + } else if (topic == "webextension-update-permissions") { + let info = subject.wrappedJSObject; + info.type = "update"; + let strings = this._buildStrings(info); + + // If we don't prompt for any new permissions, just apply it. Skip prompts + // also, if this add-on already has full access via experiment_apis. + let extension = lazy.ExtensionParent.GlobalManager.getExtension( + info.addon.id + ); + if ( + !strings.msgs.length || + (extension && extension.manifest.experiment_apis) + ) { + info.resolve(); + return; + } + + let update = { + strings, + permissions: info.permissions, + install: info.install, + addon: info.addon, + resolve: info.resolve, + reject: info.reject, + }; + + this.updates.add(update); + this._updateNotifications(); + } else if (topic == "webextension-install-notify") { + let { target, addon, callback } = subject.wrappedJSObject; + this.showInstallNotification(target, addon).then(() => { + if (callback) { + callback(); + } + }); + } else if (topic == "webextension-optional-permission-prompt") { + let browser = + getTopWindow().document.getElementById("tabmail").selectedBrowser; + let { name, icon, permissions, resolve } = subject.wrappedJSObject; + let strings = this._buildStrings({ + type: "optional", + addon: { name }, + permissions, + }); + + // If we don't have any promptable permissions, just proceed + if (!strings.msgs.length) { + resolve(true); + return; + } + resolve(this.showPermissionsPrompt(browser, strings, icon)); + } else if (topic == "webextension-defaultsearch-prompt") { + let { browser, name, icon, respond, currentEngine, newEngine } = + subject.wrappedJSObject; + + // FIXME: These only exist in mozilla/browser/locales/en-US/browser/extensionsUI.ftl. + const [searchDesc, searchYes, searchNo] = lazy.l10n.formatMessagesSync([ + { + id: "webext-default-search-description", + args: { addonName: "<>", currentEngine, newEngine }, + }, + "webext-default-search-yes", + "webext-default-search-no", + ]); + + const strings = { addonName: name, text: searchDesc.value }; + for (let attr of searchYes.attributes) { + if (attr.name === "label") { + strings.acceptText = attr.value; + } else if (attr.name === "accesskey") { + strings.acceptKey = attr.value; + } + } + for (let attr of searchNo.attributes) { + if (attr.name === "label") { + strings.cancelText = attr.value; + } else if (attr.name === "accesskey") { + strings.cancelKey = attr.value; + } + } + + this.showDefaultSearchPrompt(browser, strings, icon).then(respond); + } + }, + + // Create a set of formatted strings for a permission prompt + _buildStrings(info) { + const strings = lazy.ExtensionData.formatPermissionStrings(info, { + collapseOrigins: true, + }); + strings.addonName = info.addon.name; + strings.learnMore = lazy.l10n.formatValueSync("webext-perms-learn-more"); + return strings; + }, + + async showPermissionsPrompt(target, strings, icon) { + let { browser } = getTabBrowser(target); + + // Wait for any pending prompts to complete before showing the next one. + let pending; + while ((pending = this.pendingNotifications.get(browser))) { + await pending; + } + + let promise = new Promise(resolve => { + function eventCallback(topic) { + let doc = this.browser.ownerDocument; + if (topic == "showing") { + let textEl = doc.getElementById("addon-webext-perm-text"); + textEl.textContent = strings.text; + textEl.hidden = !strings.text; + + // By default, multiline strings don't get formatted properly. These + // are presently only used in site permission add-ons, so we treat it + // as a special case to avoid unintended effects on other things. + let isMultiline = strings.text.includes("\n\n"); + textEl.classList.toggle( + "addon-webext-perm-text-multiline", + isMultiline + ); + + let listIntroEl = doc.getElementById("addon-webext-perm-intro"); + listIntroEl.textContent = strings.listIntro; + listIntroEl.hidden = !strings.msgs.length || !strings.listIntro; + + let listInfoEl = doc.getElementById("addon-webext-perm-info"); + listInfoEl.textContent = strings.learnMore; + listInfoEl.href = + Services.urlFormatter.formatURLPref("app.support.baseURL") + + "extension-permissions"; + listInfoEl.hidden = !strings.msgs.length; + + let list = doc.getElementById("addon-webext-perm-list"); + while (list.firstChild) { + list.firstChild.remove(); + } + let singleEntryEl = doc.getElementById( + "addon-webext-perm-single-entry" + ); + singleEntryEl.textContent = ""; + singleEntryEl.hidden = true; + list.hidden = true; + + if (strings.msgs.length === 1) { + singleEntryEl.textContent = strings.msgs[0]; + singleEntryEl.hidden = false; + } else if (strings.msgs.length) { + for (let msg of strings.msgs) { + let item = doc.createElementNS(HTML_NS, "li"); + item.textContent = msg; + list.appendChild(item); + } + list.hidden = false; + } + + let experimentsEl = doc.getElementById( + "addon-webext-experiment-warning" + ); + experimentsEl.textContent = strings.experimentWarning; + experimentsEl.hidden = !strings.experimentWarning; + } else if (topic == "swapping") { + return true; + } + if (topic == "removed") { + Services.tm.dispatchToMainThread(() => { + resolve(false); + }); + } + return false; + } + + let options = { + hideClose: true, + popupIconURL: icon || DEFAULT_EXTENSION_ICON, + popupIconClass: icon ? "" : "addon-warning-icon", + persistent: true, + eventCallback, + removeOnDismissal: true, + popupOptions: { + position: "bottomright topright", + }, + }; + // The prompt/notification machinery has a special affordance wherein + // certain subsets of the header string can be designated "names", and + // referenced symbolically as "<>" and "{}" to receive special formatting. + // That code assumes that the existence of |name| and |secondName| in the + // options object imply the presence of "<>" and "{}" (respectively) in + // in the string. + // + // At present, WebExtensions use this affordance while SitePermission + // add-ons don't, so we need to conditionally set the |name| field. + // + // NB: This could potentially be cleaned up, see bug 1799710. + if (strings.header.includes("<>")) { + options.name = strings.addonName; + } + + let action = { + label: strings.acceptText, + accessKey: strings.acceptKey, + callback: () => { + resolve(true); + }, + }; + let secondaryActions = [ + { + label: strings.cancelText, + accessKey: strings.cancelKey, + callback: () => { + resolve(false); + }, + }, + ]; + + PopupNotifications.show( + browser, + "addon-webext-permissions", + strings.header, + THUNDERBIRD_ANCHOR_ID, + action, + secondaryActions, + options + ); + }); + + this.pendingNotifications.set(browser, promise); + promise.finally(() => this.pendingNotifications.delete(browser)); + return promise; + }, + + showDefaultSearchPrompt(target, strings, icon) { + return new Promise(resolve => { + let options = { + hideClose: true, + popupIconURL: icon || DEFAULT_EXTENSION_ICON, + persistent: true, + removeOnDismissal: true, + eventCallback(topic) { + if (topic == "removed") { + resolve(false); + } + }, + name: strings.addonName, + }; + + let action = { + label: strings.acceptText, + accessKey: strings.acceptKey, + callback: () => { + resolve(true); + }, + }; + let secondaryActions = [ + { + label: strings.cancelText, + accessKey: strings.cancelKey, + callback: () => { + resolve(false); + }, + }, + ]; + + let { browser } = getTabBrowser(target); + + PopupNotifications.show( + browser, + "addon-webext-defaultsearch", + strings.text, + THUNDERBIRD_ANCHOR_ID, + action, + secondaryActions, + options + ); + }); + }, + + async showInstallNotification(target, addon) { + let { browser, window } = getTabBrowser(target); + + const message = await lazy.l10n.formatValue("addon-post-install-message", { + addonName: "<>", + }); + + let icon = addon.isWebExtension + ? lazy.AddonManager.getPreferredIconURL(addon, 32, window) || + DEFAULT_EXTENSION_ICON + : "chrome://messenger/skin/addons/addon-install-installed.svg"; + + let options = { + hideClose: true, + timeout: Date.now() + 30000, + popupIconURL: icon, + name: addon.name, + }; + + return PopupNotifications.show( + browser, + "addon-installed", + message, + THUNDERBIRD_ANCHOR_ID, + null, + null, + options + ); + }, +}; + +EventEmitter.decorate(ExtensionsUI); + +/** + * Generate a document fragment for a localized string that has DOM + * node replacements. This avoids using getFormattedString followed + * by assigning to innerHTML. Fluent can probably replace this when + * it is in use everywhere. + * + * Lifted from BrowserUIUtils.jsm. + * + * @param {Document} doc + * @param {string} msg + * The string to put replacements in. Fetch from + * a stringbundle using getString or GetStringFromName, + * or even an inserted dtd string. + * @param {Node | string} nodesOrStrings + * The replacement items. Can be a mix of Nodes + * and Strings. However, for correct behaviour, the + * number of items provided needs to exactly match + * the number of replacement strings in the l10n string. + * @returns {DocumentFragment} + * A document fragment. In the trivial case (no + * replacements), this will simply be a fragment with 1 + * child, a text node containing the localized string. + */ +function getLocalizedFragment(doc, msg, ...nodesOrStrings) { + // Ensure replacement points are indexed: + for (let i = 1; i <= nodesOrStrings.length; i++) { + if (!msg.includes("%" + i + "$S")) { + msg = msg.replace(/%S/, "%" + i + "$S"); + } + } + let numberOfInsertionPoints = msg.match(/%\d+\$S/g).length; + if (numberOfInsertionPoints != nodesOrStrings.length) { + console.error( + `Message has ${numberOfInsertionPoints} insertion points, ` + + `but got ${nodesOrStrings.length} replacement parameters!` + ); + } + + let fragment = doc.createDocumentFragment(); + let parts = [msg]; + let insertionPoint = 1; + for (let replacement of nodesOrStrings) { + let insertionString = "%" + insertionPoint++ + "$S"; + let partIndex = parts.findIndex( + part => typeof part == "string" && part.includes(insertionString) + ); + if (partIndex == -1) { + fragment.appendChild(doc.createTextNode(msg)); + return fragment; + } + + if (typeof replacement == "string") { + parts[partIndex] = parts[partIndex].replace(insertionString, replacement); + } else { + let [firstBit, lastBit] = parts[partIndex].split(insertionString); + parts.splice(partIndex, 1, firstBit, replacement, lastBit); + } + } + + // Put everything in a document fragment: + for (let part of parts) { + if (typeof part == "string") { + if (part) { + fragment.appendChild(doc.createTextNode(part)); + } + } else { + fragment.appendChild(part); + } + } + return fragment; +} diff --git a/comm/mail/modules/FolderTreeProperties.jsm b/comm/mail/modules/FolderTreeProperties.jsm new file mode 100644 index 0000000000..6931c2794a --- /dev/null +++ b/comm/mail/modules/FolderTreeProperties.jsm @@ -0,0 +1,84 @@ +/* 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/. */ + +/** + * Persistent storage for various properties of items on the folder tree. + * Data is serialised to the file folderTree.json in the profile directory. + */ + +const EXPORTED_SYMBOLS = ["FolderTreeProperties"]; + +const { JSONFile } = ChromeUtils.importESModule( + "resource://gre/modules/JSONFile.sys.mjs" +); + +var jsonFile = new JSONFile({ + path: PathUtils.join(PathUtils.profileDir, "folderTree.json"), +}); +var readyPromise = jsonFile.load(); + +function ensureReady() { + if (!jsonFile.dataReady) { + throw new Error("Folder tree properties cache not ready."); + } +} + +var FolderTreeProperties = { + get ready() { + return readyPromise; + }, + + /** + * Get the colour associated with a folder. + * + * @param {string} folderURI + * @returns {?string} + */ + getColor(folderURI) { + ensureReady(); + return jsonFile.data.colors?.[folderURI]; + }, + + /** + * Set the colour associated with a folder. + * + * @param {string} folderURI + * @param {string} color + */ + setColor(folderURI, color) { + ensureReady(); + jsonFile.data.colors = jsonFile.data.colors ?? {}; + jsonFile.data.colors[folderURI] = color; + jsonFile.saveSoon(); + }, + + resetColors() { + ensureReady(); + delete jsonFile.data.colors; + jsonFile.saveSoon(); + }, + + getIsExpanded(folderURI, mode) { + ensureReady(); + if (!Array.isArray(jsonFile.data.open?.[mode])) { + return false; + } + return jsonFile.data.open[mode].includes(folderURI); + }, + + setIsExpanded(folderURI, mode, isExpanded) { + ensureReady(); + jsonFile.data.open = jsonFile.data.open ?? {}; + jsonFile.data.open[mode] = jsonFile.data.open[mode] ?? []; + let index = jsonFile.data.open[mode].indexOf(folderURI); + if (isExpanded) { + if (index < 0) { + jsonFile.data.open[mode].push(folderURI); + } + } else if (index >= 0) { + jsonFile.data.open[mode].splice(index, 1); + } + jsonFile.saveSoon(); + }, +}; diff --git a/comm/mail/modules/GlobalPopupNotifications.jsm b/comm/mail/modules/GlobalPopupNotifications.jsm new file mode 100644 index 0000000000..6d7837614c --- /dev/null +++ b/comm/mail/modules/GlobalPopupNotifications.jsm @@ -0,0 +1,1606 @@ +/* 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/. */ + +/** This file is a semi-fork of PopupNotifications.jsm */ + +var EXPORTED_SYMBOLS = ["PopupNotifications"]; + +const { PromiseUtils } = ChromeUtils.importESModule( + "resource://gre/modules/PromiseUtils.sys.mjs" +); + +const NOTIFICATION_EVENT_DISMISSED = "dismissed"; +const NOTIFICATION_EVENT_REMOVED = "removed"; +const NOTIFICATION_EVENT_SHOWING = "showing"; +const NOTIFICATION_EVENT_SHOWN = "shown"; +const NOTIFICATION_EVENT_SWAPPING = "swapping"; + +const ICON_SELECTOR = ".notification-anchor-icon"; +const ICON_ATTRIBUTE_SHOWING = "showing"; + +const PREF_SECURITY_DELAY = "security.notification_enable_delay"; + +// Enumerated values for the POPUP_NOTIFICATION_STATS telemetry histogram. +const TELEMETRY_STAT_OFFERED = 0; +const TELEMETRY_STAT_ACTION_1 = 1; +const TELEMETRY_STAT_ACTION_2 = 2; +// const TELEMETRY_STAT_ACTION_3 = 3; +const TELEMETRY_STAT_ACTION_LAST = 4; +const TELEMETRY_STAT_DISMISSAL_CLICK_ELSEWHERE = 5; +const TELEMETRY_STAT_DISMISSAL_LEAVE_PAGE = 6; +const TELEMETRY_STAT_DISMISSAL_CLOSE_BUTTON = 7; +const TELEMETRY_STAT_OPEN_SUBMENU = 10; +const TELEMETRY_STAT_LEARN_MORE = 11; + +const TELEMETRY_STAT_REOPENED_OFFSET = 20; + +var popupNotificationsMap = []; +var gNotificationParents = new WeakMap(); + +/** + * Given a DOM node inside a <popupnotification>, return the parent <popupnotification>. + */ +function getNotificationFromElement(aElement) { + return aElement.closest("popupnotification"); +} + +/** + * Notification object describes a single popup notification. + * + * @see PopupNotifications.show() + */ +function Notification( + id, + message, + anchorID, + mainAction, + secondaryActions, + browser, + owner, + options +) { + this.id = id; + this.message = message; + this.anchorID = anchorID; + this.mainAction = mainAction; + this.secondaryActions = secondaryActions || []; + this.browser = browser; + this.owner = owner; + this.options = options || {}; + + this._dismissed = false; + // Will become a boolean when manually toggled by the user. + this._checkboxChecked = null; + this.wasDismissed = false; + this.recordedTelemetryStats = new Set(); + this.timeCreated = this.owner.window.performance.now(); +} + +Notification.prototype = { + id: null, + message: null, + anchorID: null, + mainAction: null, + secondaryActions: null, + browser: null, + owner: null, + options: null, + timeShown: null, + + /** + * Indicates whether the notification is currently dismissed. + */ + set dismissed(value) { + this._dismissed = value; + if (value) { + // Keep the dismissal into account when recording telemetry. + this.wasDismissed = true; + } + }, + get dismissed() { + return this._dismissed; + }, + + /** + * Removes the notification and updates the popup accordingly if needed. + */ + remove() { + this.owner.remove(this); + }, + + get anchorElement() { + let iconBox = this.owner.iconBox; + return iconBox.querySelector("#" + this.anchorID); + }, + + reshow() { + this.owner._reshowNotifications(this.anchorElement, this.browser); + }, + + /** + * Adds a value to the specified histogram, that must be keyed by ID. + */ + _recordTelemetry(histogramId, value) { + let histogram = Services.telemetry.getKeyedHistogramById(histogramId); + histogram.add("(all)", value); + histogram.add(this.id, value); + }, + + /** + * Adds an enumerated value to the POPUP_NOTIFICATION_STATS histogram, + * ensuring that it is recorded at most once for each distinct Notification. + * + * Statistics for reopened notifications are recorded in separate buckets. + * + * @param value + * One of the TELEMETRY_STAT_ constants. + */ + _recordTelemetryStat(value) { + if (this.wasDismissed) { + value += TELEMETRY_STAT_REOPENED_OFFSET; + } + if (!this.recordedTelemetryStats.has(value)) { + this.recordedTelemetryStats.add(value); + this._recordTelemetry("POPUP_NOTIFICATION_STATS", value); + } + }, +}; + +/** + * The PopupNotifications object manages popup notifications for a given browser + * window. + * + * @param tabbrowser + * window's TabBrowser. Used to observe tab switching events and + * for determining the active browser element. + * @param panel + * The <xul:panel/> element to use for notifications. The panel is + * populated with <popupnotification> children and displayed it as + * needed. + * @param iconBox + * Reference to a container element that should be hidden or + * unhidden when notifications are hidden or shown. It should be the + * parent of anchor elements whose IDs are passed to show(). + * It is used as a fallback popup anchor if notifications specify + * invalid or non-existent anchor IDs. + * @param options + * An optional object with the following optional properties: + * { + * shouldSuppress: + * If this function returns true, then all notifications are + * suppressed for this window. This state is checked on construction + * and when the "anchorVisibilityChange" method is called. + * } + */ +function PopupNotifications(tabbrowser, panel, iconBox, options = {}) { + if (!tabbrowser) { + throw new Error("Invalid tabbrowser"); + } + if (iconBox && ChromeUtils.getClassName(iconBox) != "HTMLDivElement") { + throw new Error("Invalid iconBox"); + } + if (ChromeUtils.getClassName(panel) != "XULPopupElement") { + throw new Error("Invalid panel"); + } + + this._shouldSuppress = options.shouldSuppress || (() => false); + this._suppress = this._shouldSuppress(); + + this.window = tabbrowser.ownerGlobal; + this.panel = panel; + this.tabbrowser = tabbrowser; + this.iconBox = iconBox; + this.buttonDelay = Services.prefs.getIntPref(PREF_SECURITY_DELAY); + + this.panel.addEventListener("popuphidden", this, true); + this.panel.classList.add("popup-notification-panel", "panel-no-padding"); + + // This listener will be attached to the chrome window whenever a notification + // is showing, to allow the user to dismiss notifications using the escape key. + this._handleWindowKeyPress = aEvent => { + if (aEvent.keyCode != aEvent.DOM_VK_ESCAPE) { + return; + } + + // Esc key cancels the topmost notification, if there is one. + let notification = this.panel.firstElementChild; + if (!notification) { + return; + } + + let doc = this.window.document; + let focusedElement = Services.focus.focusedElement; + + // If the chrome window has a focused element, let it handle the ESC key instead. + if ( + !focusedElement || + focusedElement == doc.body || + focusedElement == this.tabbrowser.selectedBrowser || + notification.contains(focusedElement) + ) { + this._onButtonEvent( + aEvent, + "secondarybuttoncommand", + "esc-press", + notification + ); + } + }; + + let documentElement = this.window.document.documentElement; + let locationBarHidden = documentElement + .getAttribute("chromehidden") + .includes("location"); + let isFullscreen = !!this.window.document.fullscreenElement; + + this.panel.setAttribute("followanchor", !locationBarHidden && !isFullscreen); + + // There are no anchor icons in DOM fullscreen mode, but we would + // still like to show the popup notification. To avoid an infinite + // loop of showing and hiding, we have to disable followanchor + // (which hides the element without an anchor) in fullscreen. + this.window.addEventListener( + "MozDOMFullscreen:Entered", + () => { + this.panel.setAttribute("followanchor", "false"); + }, + true + ); + this.window.addEventListener( + "MozDOMFullscreen:Exited", + () => { + this.panel.setAttribute("followanchor", !locationBarHidden); + }, + true + ); + + this.window.addEventListener("activate", this, true); + if (this.tabbrowser.tabContainer) { + this.tabbrowser.tabContainer.addEventListener("TabSelect", this, true); + } +} + +PopupNotifications.prototype = { + window: null, + panel: null, + tabbrowser: null, + + _iconBox: null, + set iconBox(iconBox) { + // Remove the listeners on the old iconBox, if needed + if (this._iconBox) { + this._iconBox.removeEventListener("click", this); + this._iconBox.removeEventListener("keypress", this); + } + this._iconBox = iconBox; + if (iconBox) { + iconBox.addEventListener("click", this); + iconBox.addEventListener("keypress", this); + } + }, + get iconBox() { + return this._iconBox; + }, + + /** + * Retrieve a Notification object associated with the browser/ID pair. + * + * @param id + * The Notification ID to search for. + * @param browser + * The browser whose notifications should be searched. If null, the + * currently selected browser's notifications will be searched. + * + * @returns the corresponding Notification object, or null if no such + * notification exists. + */ + getNotification(id) { + return popupNotificationsMap.find(x => x.id == id) || null; + }, + + /** + * Adds a new popup notification. + * + * @param browser + * The <xul:browser> element associated with the notification. Must not + * be null. + * @param id + * A unique ID that identifies the type of notification (e.g. + * "geolocation"). Only one notification with a given ID can be visible + * at a time. If a notification already exists with the given ID, it + * will be replaced. + * @param message + * A string containing the text to be displayed as the notification header. + * The string may optionally contain "<>" as a placeholder which is later + * replaced by a host name or an addon name that is formatted to look bold, + * in which case the options.name property needs to be specified. + * @param anchorID + * The ID of the element that should be used as this notification + * popup's anchor. May be null, in which case the notification will be + * anchored to the iconBox. + * @param mainAction + * A JavaScript object literal describing the notification button's + * action. If present, it must have the following properties: + * - label (string): the button's label. + * - accessKey (string): the button's accessKey. + * - callback (function): a callback to be invoked when the button is + * pressed, is passed an object that contains the following fields: + * - checkboxChecked: (boolean) If the optional checkbox is checked. + * - source: (string): the source of the action that initiated the + * callback, either: + * - "button" if popup buttons were directly activated, or + * - "esc-press" if the user pressed the escape key, or + * - "menucommand" if a menu was activated. + * - [optional] dismiss (boolean): If this is true, the notification + * will be dismissed instead of removed after running the callback. + * - [optional] disableHighlight (boolean): If this is true, the button + * will not apply the default highlight style. + * If null, the notification will have a default "OK" action button + * that can be used to dismiss the popup and secondaryActions will be ignored. + * @param secondaryActions + * An optional JavaScript array describing the notification's alternate + * actions. The array should contain objects with the same properties + * as mainAction. These are used to populate the notification button's + * dropdown menu. + * @param options + * An options JavaScript object holding additional properties for the + * notification. The following properties are currently supported: + * persistence: An integer. The notification will not automatically + * dismiss for this many page loads. + * timeout: A time in milliseconds. The notification will not + * automatically dismiss before this time. + * persistWhileVisible: + * A boolean. If true, a visible notification will always + * persist across location changes. + * persistent: A boolean. If true, the notification will always + * persist even across tab and app changes (but not across + * location changes), until the user accepts or rejects + * the request. The notification will never be implicitly + * dismissed. + * dismissed: Whether the notification should be added as a dismissed + * notification. Dismissed notifications can be activated + * by clicking on their anchorElement. + * autofocus: Whether the notification should be autofocused on + * showing, stealing focus from any other focused element. + * eventCallback: + * Callback to be invoked when the notification changes + * state. The callback's first argument is a string + * identifying the state change: + * "dismissed": notification has been dismissed by the + * user (e.g. by clicking away or switching + * tabs) + * "removed": notification has been removed (due to + * location change or user action) + * "showing": notification is about to be shown + * (this can be fired multiple times as + * notifications are dismissed and re-shown) + * If the callback returns true, the notification + * will be dismissed. + * "shown": notification has been shown (this can be fired + * multiple times as notifications are dismissed + * and re-shown) + * "swapping": the docshell of the browser that created + * the notification is about to be swapped to + * another browser. A second parameter contains + * the browser that is receiving the docshell, + * so that the event callback can transfer stuff + * specific to this notification. + * If the callback returns true, the notification + * will be moved to the new browser. + * If the callback isn't implemented, returns false, + * or doesn't return any value, the notification + * will be removed. + * neverShow: Indicate that no popup should be shown for this + * notification. Useful for just showing the anchor icon. + * removeOnDismissal: + * Notifications with this parameter set to true will be + * removed when they would have otherwise been dismissed + * (i.e. any time the popup is closed due to user + * interaction). + * hideClose: Indicate that the little close button in the corner of + * the panel should be hidden. + * checkbox: An object that allows you to add a checkbox and + * control its behavior with these fields: + * label: + * (required) Label to be shown next to the checkbox. + * checked: + * (optional) Whether the checkbox should be checked + * by default. Defaults to false. + * checkedState: + * (optional) An object that allows you to customize + * the notification state when the checkbox is checked. + * disableMainAction: + * (optional) Whether the mainAction is disabled. + * Defaults to false. + * warningLabel: + * (optional) A (warning) text that is shown below the + * checkbox. Pass null to hide. + * uncheckedState: + * (optional) An object that allows you to customize + * the notification state when the checkbox is not checked. + * Has the same attributes as checkedState. + * popupIconClass: + * A string. A class (or space separated list of classes) + * that will be applied to the icon in the popup so that + * several notifications using the same panel can use + * different icons. + * popupIconURL: + * A string. URL of the image to be displayed in the popup. + * Normally specified in CSS using list-style-image and the + * .popup-notification-icon[popupid=...] selector. + * learnMoreURL: + * A string URL. Setting this property will make the + * prompt display a "Learn More" link that, when clicked, + * opens the URL in a new tab. + * displayURI: + * The nsIURI of the page the notification came + * from. If present, this will be displayed above the message. + * If the nsIURI represents a file, the path will be displayed, + * otherwise the hostPort will be displayed. + * name: + * An optional string formatted to look bold and used in the + * notifiation description header text. Usually a host name or + * addon name. + * @returns the Notification object corresponding to the added notification. + */ + show(browser, id, message, anchorID, mainAction, secondaryActions, options) { + function isInvalidAction(a) { + return ( + !a || !(typeof a.callback == "function") || !a.label || !a.accessKey + ); + } + + if (!id) { + throw new Error("PopupNotifications_show: invalid ID"); + } + if (mainAction && isInvalidAction(mainAction)) { + throw new Error("PopupNotifications_show: invalid mainAction"); + } + if (secondaryActions && secondaryActions.some(isInvalidAction)) { + throw new Error("PopupNotifications_show: invalid secondaryActions"); + } + + let notification = new Notification( + id, + message, + anchorID, + mainAction, + secondaryActions, + browser, + this, + options + ); + + if (options && options.dismissed) { + notification.dismissed = true; + } + + let existingNotification = this.getNotification(id); + if (existingNotification) { + this._remove(existingNotification); + } + + popupNotificationsMap.push(notification); + + let isActiveWindow = Services.focus.activeWindow == this.window; + + if (isActiveWindow) { + // Autofocus if the notification requests focus. + if (options && !options.dismissed && options.autofocus) { + this.panel.removeAttribute("noautofocus"); + } else { + this.panel.setAttribute("noautofocus", "true"); + } + + // show panel now + this._update( + popupNotificationsMap, + new Set([notification.anchorElement]), + true + ); + } else { + // indicate attention and update the icon if necessary + if (!notification.dismissed) { + this.window.getAttention(); + } + this._updateAnchorIcons( + popupNotificationsMap, + this._getAnchorsForNotifications( + popupNotificationsMap, + notification.anchorElement + ) + ); + this._notify("backgroundShow"); + } + + return notification; + }, + + /** + * Returns true if the notification popup is currently being displayed. + */ + get isPanelOpen() { + let panelState = this.panel.state; + + return panelState == "showing" || panelState == "open"; + }, + + /** + * Removes a Notification. + * + * @param notification + * The Notification object to remove. + */ + remove(notification) { + this._remove(notification); + + let notifications = this._getNotificationsForBrowser(notification.browser); + this._update(notifications); + }, + + handleEvent(aEvent) { + switch (aEvent.type) { + case "popuphidden": + this._onPopupHidden(aEvent); + break; + case "activate": + if (this.isPanelOpen) { + for (let elt of this.panel.children) { + elt.notification.timeShown = this.window.performance.now(); + } + break; + } + // Falls through + case "TabSelect": + let self = this; + // This is where we could detect if the panel is dismissed if the page + // was switched. Unfortunately, the user usually has clicked elsewhere + // at this point so this value only gets recorded for programmatic + // reasons, like the "Learn More" link being clicked and resulting in a + // tab switch. + this.nextDismissReason = TELEMETRY_STAT_DISMISSAL_LEAVE_PAGE; + // setTimeout(..., 0) needed, otherwise openPopup from "activate" event + // handler results in the popup being hidden again for some reason... + this.window.setTimeout(function () { + self._update(); + }, 0); + break; + case "click": + case "keypress": + this._onIconBoxCommand(aEvent); + break; + } + }, + + // Utility methods + + _ignoreDismissal: null, + _currentAnchorElement: null, + + /** + * Gets notifications for the currently selected browser. + */ + get _currentNotifications() { + return this.tabbrowser.selectedBrowser + ? this._getNotificationsForBrowser(this.tabbrowser.selectedBrowser) + : []; + }, + + _remove(notification) { + // This notification may already be removed, in which case let's just fail + // silently. + var index = popupNotificationsMap.indexOf(notification); + if (index == -1) { + return; + } + + // remove the notification + popupNotificationsMap.splice(index, 1); + this._fireCallback(notification, NOTIFICATION_EVENT_REMOVED); + }, + + /** + * Dismisses the notification without removing it. + */ + _dismiss(event, telemetryReason) { + if (telemetryReason) { + this.nextDismissReason = telemetryReason; + } + + // An explicitly dismissed persistent notification effectively becomes + // non-persistent. + if (event && telemetryReason == TELEMETRY_STAT_DISMISSAL_CLOSE_BUTTON) { + let notificationEl = getNotificationFromElement(event.target); + if (notificationEl) { + notificationEl.notification.options.persistent = false; + } + } + + let browser = + this.panel.firstElementChild && + this.panel.firstElementChild.notification.browser; + this.panel.hidePopup(); + if (browser) { + browser.focus(); + } + }, + + /** + * Hides the notification popup. + */ + _hidePanel() { + if (this.panel.state == "closed") { + return Promise.resolve(); + } + if (this._ignoreDismissal) { + return this._ignoreDismissal.promise; + } + let deferred = PromiseUtils.defer(); + this._ignoreDismissal = deferred; + this.panel.hidePopup(); + return deferred.promise; + }, + + /** + * Removes all notifications from the notification popup. + */ + _clearPanel() { + let popupnotification; + while ((popupnotification = this.panel.lastElementChild)) { + this.panel.removeChild(popupnotification); + + // If this notification was provided by the chrome document rather than + // created ad hoc, move it back to where we got it from. + let originalParent = gNotificationParents.get(popupnotification); + if (originalParent) { + popupnotification.notification = null; + + // Re-hide the notification such that it isn't rendered in the chrome + // document. _refreshPanel will unhide it again when needed. + popupnotification.hidden = true; + + originalParent.appendChild(popupnotification); + } + } + }, + + /** + * Formats the notification description message before we display it + * and splits it into three parts if the message contains "<>" as + * placeholder. + * + * param notification + * The Notification object which contains the message to format. + * + * @returns a Javascript object that has the following properties: + * start: A start label string containing the first part of the message. + * It may contain the whole string if the description message + * does not have "<>" as a placeholder. For example, local + * file URIs with description messages that don't display hostnames. + * name: A string that is formatted to look bold. It replaces the + * placeholder with the options.name property from the notification + * object which is usually an addon name or a host name. + * end: The last part of the description message. + */ + _formatDescriptionMessage(n) { + let text = {}; + let array = n.message.split("<>"); + text.start = array[0] || ""; + text.name = n.options.name || ""; + text.end = array[1] || ""; + return text; + }, + + _refreshPanel(notificationsToShow) { + this._clearPanel(); + + notificationsToShow.forEach(function (n) { + let doc = this.window.document; + + // Append "-notification" to the ID to try to avoid ID conflicts with other stuff + // in the document. + let popupnotificationID = n.id + "-notification"; + + // If the chrome document provides a popupnotification with this id, use + // that. Otherwise create it ad-hoc. + let popupnotification = doc.getElementById(popupnotificationID); + if (popupnotification) { + gNotificationParents.set( + popupnotification, + popupnotification.parentNode + ); + } else { + popupnotification = doc.createXULElement("popupnotification"); + } + + // Create the notification description element. + let desc = this._formatDescriptionMessage(n); + popupnotification.setAttribute("label", desc.start); + popupnotification.setAttribute("name", desc.name); + popupnotification.setAttribute("endlabel", desc.end); + + popupnotification.setAttribute("id", popupnotificationID); + popupnotification.setAttribute("popupid", n.id); + popupnotification.setAttribute( + "oncommand", + "PopupNotifications._onCommand(event);" + ); + popupnotification.setAttribute( + "closebuttoncommand", + `PopupNotifications._dismiss(event, ${TELEMETRY_STAT_DISMISSAL_CLOSE_BUTTON});` + ); + + if (n.mainAction) { + popupnotification.setAttribute("buttonlabel", n.mainAction.label); + popupnotification.setAttribute( + "buttonaccesskey", + n.mainAction.accessKey + ); + popupnotification.setAttribute( + "buttonhighlight", + !n.mainAction.disableHighlight + ); + popupnotification.setAttribute( + "buttoncommand", + "PopupNotifications._onButtonEvent(event, 'buttoncommand');" + ); + popupnotification.setAttribute( + "dropmarkerpopupshown", + "PopupNotifications._onButtonEvent(event, 'dropmarkerpopupshown');" + ); + popupnotification.setAttribute( + "learnmoreclick", + "PopupNotifications._onButtonEvent(event, 'learnmoreclick');" + ); + popupnotification.setAttribute( + "menucommand", + "PopupNotifications._onMenuCommand(event);" + ); + } else { + // Enable the default button to let the user close the popup if the close button is hidden + popupnotification.setAttribute( + "buttoncommand", + "PopupNotifications._onButtonEvent(event, 'buttoncommand');" + ); + popupnotification.setAttribute("buttonhighlight", "true"); + popupnotification.removeAttribute("buttonlabel"); + popupnotification.removeAttribute("buttonaccesskey"); + popupnotification.removeAttribute("dropmarkerpopupshown"); + popupnotification.removeAttribute("learnmoreclick"); + popupnotification.removeAttribute("menucommand"); + } + + if (n.options.popupIconClass) { + let classes = "popup-notification-icon " + n.options.popupIconClass; + popupnotification.setAttribute("iconclass", classes); + } + if (n.options.popupIconURL) { + popupnotification.setAttribute("icon", n.options.popupIconURL); + } + + if (n.options.learnMoreURL) { + popupnotification.setAttribute("learnmoreurl", n.options.learnMoreURL); + } else { + popupnotification.removeAttribute("learnmoreurl"); + } + + if (n.options.displayURI) { + let uri; + try { + if (n.options.displayURI instanceof Ci.nsIFileURL) { + uri = n.options.displayURI.pathQueryRef; + } else { + try { + uri = n.options.displayURI.hostPort; + } catch (e) { + uri = n.options.displayURI.spec; + } + } + popupnotification.setAttribute("origin", uri); + } catch (e) { + console.error(e); + popupnotification.removeAttribute("origin"); + } + } else { + popupnotification.removeAttribute("origin"); + } + + if (n.options.hideClose) { + popupnotification.setAttribute("closebuttonhidden", "true"); + } + + popupnotification.notification = n; + let menuitems = []; + + if (n.mainAction && n.secondaryActions && n.secondaryActions.length > 0) { + let telemetryStatId = TELEMETRY_STAT_ACTION_2; + + let secondaryAction = n.secondaryActions[0]; + popupnotification.setAttribute( + "secondarybuttonlabel", + secondaryAction.label + ); + popupnotification.setAttribute( + "secondarybuttonaccesskey", + secondaryAction.accessKey + ); + popupnotification.setAttribute( + "secondarybuttoncommand", + "PopupNotifications._onButtonEvent(event, 'secondarybuttoncommand');" + ); + popupnotification.removeAttribute("secondarybuttonhidden"); + + for (let i = 1; i < n.secondaryActions.length; i++) { + let action = n.secondaryActions[i]; + let item = doc.createXULElement("menuitem"); + item.setAttribute("label", action.label); + item.setAttribute("accesskey", action.accessKey); + item.notification = n; + item.action = action; + + menuitems.push(item); + + // We can only record a limited number of actions in telemetry. If + // there are more, the latest are all recorded in the last bucket. + item.action.telemetryStatId = telemetryStatId; + if (telemetryStatId < TELEMETRY_STAT_ACTION_LAST) { + telemetryStatId++; + } + } + + if (n.secondaryActions.length < 2) { + popupnotification.setAttribute("dropmarkerhidden", "true"); + } + } else { + popupnotification.setAttribute("secondarybuttonhidden", "true"); + popupnotification.setAttribute("dropmarkerhidden", "true"); + } + + let checkbox = n.options.checkbox; + if (checkbox && checkbox.label) { + let checked = + n._checkboxChecked != null ? n._checkboxChecked : !!checkbox.checked; + + popupnotification.checkboxState = { + checked, + label: checkbox.label, + }; + + if (checked) { + this._setNotificationUIState( + popupnotification, + checkbox.checkedState + ); + } else { + this._setNotificationUIState( + popupnotification, + checkbox.uncheckedState + ); + } + } else { + popupnotification.setAttribute("checkboxhidden", "true"); + popupnotification.setAttribute("warninghidden", "true"); + } + + this.panel.appendChild(popupnotification); + + // The popupnotification may be hidden if we got it from the chrome + // document rather than creating it ad hoc. + popupnotification.show(); + + popupnotification.menupopup.textContent = ""; + popupnotification.menupopup.append(...menuitems); + }, this); + }, + + _setNotificationUIState(notification, state = {}) { + if ( + state.disableMainAction || + notification.hasAttribute("invalidselection") + ) { + notification.setAttribute("mainactiondisabled", "true"); + } else { + notification.removeAttribute("mainactiondisabled"); + } + if (state.warningLabel) { + notification.setAttribute("warninglabel", state.warningLabel); + notification.removeAttribute("warninghidden"); + } else { + notification.setAttribute("warninghidden", "true"); + } + }, + + _showPanel(notificationsToShow, anchorElement) { + this.panel.hidden = false; + + notificationsToShow = notificationsToShow.filter(n => { + if (anchorElement != n.anchorElement) { + return false; + } + + let dismiss = this._fireCallback(n, NOTIFICATION_EVENT_SHOWING); + if (dismiss) { + n.dismissed = true; + } + return !dismiss; + }); + if (!notificationsToShow.length) { + return; + } + let notificationIds = notificationsToShow.map(n => n.id); + + this._refreshPanel(notificationsToShow); + + function isNullOrHidden(elem) { + if (!elem) { + return true; + } + + let anchorRect = elem.getBoundingClientRect(); + return anchorRect.width == 0 && anchorRect.height == 0; + } + + // If the anchor element is hidden or null, fall back to the identity icon. + if (isNullOrHidden(anchorElement)) { + anchorElement = this.window.document.getElementById("identity-icon"); + + // If the identity icon is not available in this window, or maybe the + // entire location bar is hidden for any reason, use the tab as the + // anchor. We only ever show notifications for the current browser, so we + // can just use the current tab. + if (isNullOrHidden(anchorElement)) { + anchorElement = this.tabbrowser.selectedTab; + + // If we're in an entirely chromeless environment, set the anchorElement + // to null and let openPopup show the notification at (0,0) later. + if (isNullOrHidden(anchorElement)) { + anchorElement = null; + } + } + } + + if (this.isPanelOpen && this._currentAnchorElement == anchorElement) { + notificationsToShow.forEach(function (n) { + this._fireCallback(n, NOTIFICATION_EVENT_SHOWN); + }, this); + + // Make sure we update the noautohide attribute on the panel, in case it changed. + if (notificationsToShow.some(n => n.options.persistent)) { + this.panel.setAttribute("noautohide", "true"); + } else { + this.panel.removeAttribute("noautohide"); + } + + // Let tests know that the panel was updated and what notifications it was + // updated with so that tests can wait for the correct notifications to be + // added. + let event = new this.window.CustomEvent("PanelUpdated", { + detail: notificationIds, + }); + this.panel.dispatchEvent(event); + return; + } + + // If the panel is already open but we're changing anchors, we need to hide + // it first. Otherwise it can appear in the wrong spot. (_hidePanel is + // safe to call even if the panel is already hidden.) + this._hidePanel().then(() => { + this._currentAnchorElement = anchorElement; + + if (notificationsToShow.some(n => n.options.persistent)) { + this.panel.setAttribute("noautohide", "true"); + } else { + this.panel.removeAttribute("noautohide"); + } + + notificationsToShow.forEach(function (n) { + // Record that the notification was actually displayed on screen. + // Notifications that were opened a second time or that were originally + // shown with "options.dismissed" will be recorded in a separate bucket. + n._recordTelemetryStat(TELEMETRY_STAT_OFFERED); + // Remember the time the notification was shown for the security delay. + n.timeShown = this.window.performance.now(); + }, this); + + // Unless the panel closing is triggered by a specific known code path, + // the next reason will be that the user clicked elsewhere. + this.nextDismissReason = TELEMETRY_STAT_DISMISSAL_CLICK_ELSEWHERE; + + let target = this.panel; + if (target.parentNode) { + // NOTIFICATION_EVENT_SHOWN should be fired for the panel before + // anyone listening for popupshown on the panel gets run. Otherwise, + // the panel will not be initialized when the popupshown event + // listeners run. + // By targeting the panel's parent and using a capturing listener, we + // can have our listener called before others waiting for the panel to + // be shown (which probably expect the panel to be fully initialized) + target = target.parentNode; + } + if (this._popupshownListener) { + target.removeEventListener( + "popupshown", + this._popupshownListener, + true + ); + } + this._popupshownListener = function (e) { + target.removeEventListener( + "popupshown", + this._popupshownListener, + true + ); + this._popupshownListener = null; + + notificationsToShow.forEach(function (n) { + this._fireCallback(n, NOTIFICATION_EVENT_SHOWN); + }, this); + // These notifications are used by tests to know when all the processing + // required to display the panel has happened. + this.panel.dispatchEvent(new this.window.CustomEvent("Shown")); + let event = new this.window.CustomEvent("PanelUpdated", { + detail: notificationIds, + }); + this.panel.dispatchEvent(event); + }; + this._popupshownListener = this._popupshownListener.bind(this); + target.addEventListener("popupshown", this._popupshownListener, true); + + this.panel.openPopup(anchorElement, "after_end", 0, 0, true); + }); + }, + + /** + * Updates the notification state in response to window activation or tab + * selection changes. + * + * @param notifications an array of Notification instances. if null, + * notifications will be retrieved off the current + * browser tab + * @param anchors is a XUL element or a Set of XUL elements that the + * notifications panel(s) will be anchored to. + * @param dismissShowing if true, dismiss any currently visible notifications + * if there are no notifications to show. Otherwise, + * currently displayed notifications will be left alone. + */ + _update(notifications, anchors = new Set(), dismissShowing = false) { + if (ChromeUtils.getClassName(anchors) == "XULElement") { + anchors = new Set([anchors]); + } + + if (!notifications) { + notifications = this._currentNotifications; + } + + let haveNotifications = notifications.length > 0; + if (!anchors.size && haveNotifications) { + anchors = this._getAnchorsForNotifications(notifications); + } + + let useIconBox = !!this.iconBox; + if (useIconBox && anchors.size) { + for (let anchor of anchors) { + if (anchor.parentNode == this.iconBox) { + continue; + } + useIconBox = false; + break; + } + } + + // Filter out notifications that have been dismissed, unless they are + // persistent. Also check if we should not show any notification. + let notificationsToShow = []; + if (!this._suppress) { + notificationsToShow = notifications.filter( + n => (!n.dismissed || n.options.persistent) && !n.options.neverShow + ); + } + + if (useIconBox) { + // Hide icons of the previous tab. + this._hideIcons(); + } + + if (haveNotifications) { + // Also filter out notifications that are for a different anchor. + notificationsToShow = notificationsToShow.filter(function (n) { + return anchors.has(n.anchorElement); + }); + + if (useIconBox) { + this._showIcons(notifications); + this.iconBox.hidden = false; + // Make sure that panels can only be attached to anchors of shown + // notifications inside an iconBox. + anchors = this._getAnchorsForNotifications(notificationsToShow); + } else if (anchors.size) { + this._updateAnchorIcons(notifications, anchors); + } + } + + if (notificationsToShow.length > 0) { + let anchorElement = anchors.values().next().value; + if (anchorElement) { + this._showPanel(notificationsToShow, anchorElement); + } + + // Setup a capturing event listener on the whole window to catch the + // escape key while persistent notifications are visible. + this.window.addEventListener( + "keypress", + this._handleWindowKeyPress, + true + ); + } else { + // Notify observers that we're not showing the popup (useful for testing) + this._notify("updateNotShowing"); + + // Close the panel if there are no notifications to show. + // When called from PopupNotifications.show() we should never close the + // panel, however. It may just be adding a dismissed notification, in + // which case we want to continue showing any existing notifications. + if (!dismissShowing) { + this._dismiss(); + } + + // Only hide the iconBox if we actually have no notifications (as opposed + // to not having any showable notifications) + if (!haveNotifications) { + if (useIconBox) { + this.iconBox.hidden = true; + } else if (anchors.size) { + for (let anchorElement of anchors) { + anchorElement.removeAttribute(ICON_ATTRIBUTE_SHOWING); + } + } + } + + // Stop listening to keyboard events for notifications. + this.window.removeEventListener( + "keypress", + this._handleWindowKeyPress, + true + ); + } + }, + + _updateAnchorIcons(notifications, anchorElements) { + for (let anchorElement of anchorElements) { + anchorElement.setAttribute(ICON_ATTRIBUTE_SHOWING, "true"); + // Use the anchorID as a class along with the default icon class as a + // fallback if anchorID is not defined in CSS. We always use the first + // notifications icon, so in the case of multiple notifications we'll + // only use the default icon. + if (anchorElement.classList.contains("notification-anchor-icon")) { + // remove previous icon classes + let className = anchorElement.className.replace( + /([-\w]+-notification-icon\s?)/g, + "" + ); + if (notifications.length > 0) { + // Find the first notification this anchor used for. + let notification = notifications[0]; + for (let n of notifications) { + if (n.anchorElement == anchorElement) { + notification = n; + break; + } + } + // With this notification we can better approximate the most fitting + // style. + className = notification.anchorID + " " + className; + } + anchorElement.className = className; + } + } + }, + + _showIcons(aCurrentNotifications) { + for (let notification of aCurrentNotifications) { + let anchorElm = notification.anchorElement; + if (anchorElm) { + anchorElm.setAttribute(ICON_ATTRIBUTE_SHOWING, "true"); + + if (notification.options.extraAttr) { + anchorElm.setAttribute("extraAttr", notification.options.extraAttr); + } + } + } + }, + + _hideIcons() { + let icons = this.iconBox.querySelectorAll(ICON_SELECTOR); + for (let icon of icons) { + icon.removeAttribute(ICON_ATTRIBUTE_SHOWING); + } + }, + + _getNotificationsForBrowser(browser) { + return popupNotificationsMap; + }, + _setNotificationsForBrowser(browser, notifications) { + popupNotificationsMap = notifications; + return notifications; + }, + + _getAnchorsForNotifications(notifications, defaultAnchor) { + let anchors = new Set(); + for (let notification of notifications) { + if (notification.anchorElement) { + anchors.add(notification.anchorElement); + } + } + if (defaultAnchor && !anchors.size) { + anchors.add(defaultAnchor); + } + return anchors; + }, + + _onIconBoxCommand(event) { + // Left click, space or enter only + let type = event.type; + if (type == "click" && event.button != 0) { + return; + } + + if ( + type == "keypress" && + !( + event.charCode == event.DOM_VK_SPACE || + event.keyCode == event.DOM_VK_RETURN + ) + ) { + return; + } + + if (this._currentNotifications.length == 0) { + return; + } + + event.stopPropagation(); + + // Get the anchor that is the immediate child of the icon box + let anchor = event.target; + while (anchor && anchor.parentNode != this.iconBox) { + anchor = anchor.parentNode; + } + + if (!anchor) { + return; + } + + // If the panel is not closed, and the anchor is different, immediately mark all + // active notifications for the previous anchor as dismissed + if (this.panel.state != "closed" && anchor != this._currentAnchorElement) { + this._dismissOrRemoveCurrentNotifications(); + } + + // Avoid reshowing notifications that are already shown and have not been dismissed. + if (this.panel.state == "closed" || anchor != this._currentAnchorElement) { + // As soon as the panel is shown, focus the first element in the selected notification. + this.panel.addEventListener( + "popupshown", + () => + this.window.document.commandDispatcher.advanceFocusIntoSubtree( + this.panel + ), + { once: true } + ); + + this._reshowNotifications(anchor); + } else { + // Focus the first element in the selected notification. + this.window.document.commandDispatcher.advanceFocusIntoSubtree( + this.panel + ); + } + }, + + _reshowNotifications(anchor, browser) { + // Mark notifications anchored to this anchor as un-dismissed + browser = browser || this.tabbrowser.selectedBrowser; + let notifications = this._getNotificationsForBrowser(browser); + notifications.forEach(function (n) { + if (n.anchorElement == anchor) { + n.dismissed = false; + } + }); + + // ...and then show them. + this._update(notifications, anchor); + }, + + _swapBrowserNotifications(ourBrowser, otherBrowser) { + // When swapping browser docshells (e.g. dragging tab to new window) we need + // to update our notification map. + + let ourNotifications = this._getNotificationsForBrowser(ourBrowser); + let other = otherBrowser.ownerGlobal.PopupNotifications; + if (!other) { + if (ourNotifications.length > 0) { + console.error( + "unable to swap notifications: otherBrowser doesn't support notifications" + ); + } + return; + } + let otherNotifications = other._getNotificationsForBrowser(otherBrowser); + if (ourNotifications.length < 1 && otherNotifications.length < 1) { + // No notification to swap. + return; + } + + otherNotifications = otherNotifications.filter(n => { + if (this._fireCallback(n, NOTIFICATION_EVENT_SWAPPING, ourBrowser)) { + n.browser = ourBrowser; + n.owner = this; + return true; + } + other._fireCallback(n, NOTIFICATION_EVENT_REMOVED); + return false; + }); + + ourNotifications = ourNotifications.filter(n => { + if (this._fireCallback(n, NOTIFICATION_EVENT_SWAPPING, otherBrowser)) { + n.browser = otherBrowser; + n.owner = other; + return true; + } + this._fireCallback(n, NOTIFICATION_EVENT_REMOVED); + return false; + }); + + this._setNotificationsForBrowser(otherBrowser, ourNotifications); + other._setNotificationsForBrowser(ourBrowser, otherNotifications); + + if (otherNotifications.length > 0) { + this._update(otherNotifications); + } + if (ourNotifications.length > 0) { + other._update(ourNotifications); + } + }, + + _fireCallback(n, event, ...args) { + try { + if (n.options.eventCallback) { + return n.options.eventCallback.call(n, event, ...args); + } + } catch (error) { + console.error(error); + } + return undefined; + }, + + _onPopupHidden(event) { + if (event.target != this.panel) { + return; + } + + // We may have removed the "noautofocus" attribute before showing the panel + // if the notification specified it wants to autofocus on first show. + // When the panel is closed, we have to restore the attribute to its default + // value, so we don't autofocus it if it's subsequently opened from a different code path. + this.panel.setAttribute("noautofocus", "true"); + + // Handle the case where the panel was closed programmatically. + if (this._ignoreDismissal) { + this._ignoreDismissal.resolve(); + this._ignoreDismissal = null; + return; + } + + this._dismissOrRemoveCurrentNotifications(); + + this._clearPanel(); + + this._update(); + }, + + _dismissOrRemoveCurrentNotifications() { + let browser = + this.panel.firstElementChild && + this.panel.firstElementChild.notification.browser; + if (!browser) { + return; + } + + let notifications = this._getNotificationsForBrowser(browser); + // Mark notifications as dismissed and call dismissal callbacks + for (let nEl of this.panel.children) { + let notificationObj = nEl.notification; + // Never call a dismissal handler on a notification that's been removed. + if (!notifications.includes(notificationObj)) { + return; + } + + // Record the time of the first notification dismissal if the main action + // was not triggered in the meantime. + let timeSinceShown = + this.window.performance.now() - notificationObj.timeShown; + if ( + !notificationObj.wasDismissed && + !notificationObj.recordedTelemetryMainAction + ) { + notificationObj._recordTelemetry( + "POPUP_NOTIFICATION_DISMISSAL_MS", + timeSinceShown + ); + } + notificationObj._recordTelemetryStat(this.nextDismissReason); + + // Do not mark the notification as dismissed or fire NOTIFICATION_EVENT_DISMISSED + // if the notification is removed. + if (notificationObj.options.removeOnDismissal) { + this._remove(notificationObj); + } else { + notificationObj.dismissed = true; + this._fireCallback(notificationObj, NOTIFICATION_EVENT_DISMISSED); + } + } + }, + + _onCheckboxCommand(event) { + let notificationEl = getNotificationFromElement(event.target); + let checked = notificationEl.checkbox.checked; + let notification = notificationEl.notification; + + // Save checkbox state to be able to persist it when re-opening the doorhanger. + notification._checkboxChecked = checked; + + if (checked) { + this._setNotificationUIState( + notificationEl, + notification.options.checkbox.checkedState + ); + } else { + this._setNotificationUIState( + notificationEl, + notification.options.checkbox.uncheckedState + ); + } + event.stopPropagation(); + }, + + _onCommand(event) { + // Ignore events from buttons as they are submitting and so don't need checks + if (event.target.localName == "button") { + return; + } + let notificationEl = getNotificationFromElement(event.target); + this._setNotificationUIState(notificationEl); + }, + + _onButtonEvent(event, type, source = "button", notificationEl = null) { + if (!notificationEl) { + notificationEl = getNotificationFromElement(event.target); + } + + if (!notificationEl) { + throw new Error( + "PopupNotifications._onButtonEvent: couldn't find notification element" + ); + } + + if (!notificationEl.notification) { + throw new Error( + "PopupNotifications._onButtonEvent: couldn't find notification" + ); + } + + let notification = notificationEl.notification; + + if (type == "dropmarkerpopupshown") { + notification._recordTelemetryStat(TELEMETRY_STAT_OPEN_SUBMENU); + return; + } + + if (type == "learnmoreclick") { + notification._recordTelemetryStat(TELEMETRY_STAT_LEARN_MORE); + return; + } + + if (type == "buttoncommand") { + // Record the total timing of the main action since the notification was + // created, even if the notification was dismissed in the meantime. + let timeSinceCreated = + this.window.performance.now() - notification.timeCreated; + if (!notification.recordedTelemetryMainAction) { + notification.recordedTelemetryMainAction = true; + notification._recordTelemetry( + "POPUP_NOTIFICATION_MAIN_ACTION_MS", + timeSinceCreated + ); + } + } + + if (type == "buttoncommand" || type == "secondarybuttoncommand") { + if (Services.focus.activeWindow != this.window) { + Services.console.logStringMessage( + "PopupNotifications._onButtonEvent: " + + "Button click happened before the window was focused" + ); + this.window.focus(); + return; + } + + let timeSinceShown = + this.window.performance.now() - notification.timeShown; + if (timeSinceShown < this.buttonDelay) { + Services.console.logStringMessage( + "PopupNotifications._onButtonEvent: " + + "Button click happened before the security delay: " + + timeSinceShown + + "ms" + ); + return; + } + } + + let action = notification.mainAction; + let telemetryStatId = TELEMETRY_STAT_ACTION_1; + + if (type == "secondarybuttoncommand") { + action = notification.secondaryActions[0]; + telemetryStatId = TELEMETRY_STAT_ACTION_2; + } + + notification._recordTelemetryStat(telemetryStatId); + + if (action) { + try { + action.callback.call(undefined, { + checkboxChecked: notificationEl.checkbox.checked, + source, + }); + } catch (error) { + console.error(error); + } + + if (action.dismiss) { + this._dismiss(); + return; + } + } + + this._remove(notification); + this._update(); + }, + + _onMenuCommand(event) { + let target = event.target; + if (!target.action || !target.notification) { + throw new Error( + "menucommand target has no associated action/notification" + ); + } + + let notificationEl = getNotificationFromElement(target); + event.stopPropagation(); + + target.notification._recordTelemetryStat(target.action.telemetryStatId); + + try { + target.action.callback.call(undefined, { + checkboxChecked: notificationEl.checkbox.checked, + source: "menucommand", + }); + } catch (error) { + console.error(error); + } + + if (target.action.dismiss) { + this._dismiss(); + return; + } + + this._remove(target.notification); + this._update(); + }, + + _notify(topic) { + Services.obs.notifyObservers(null, "PopupNotifications-" + topic); + }, +}; diff --git a/comm/mail/modules/MailConsts.jsm b/comm/mail/modules/MailConsts.jsm new file mode 100644 index 0000000000..38c219702a --- /dev/null +++ b/comm/mail/modules/MailConsts.jsm @@ -0,0 +1,39 @@ +/* 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/. */ + +/** + * This is a place to store constants and enumerations that are needed only by + * JavaScript code, especially component/module code. + */ + +var EXPORTED_SYMBOLS = ["MailConsts"]; + +var MailConsts = { + /** + * Determine how to open a message when it is double-clicked or selected and + * Enter pressed. The preference to set this is mail.openMessageBehavior. + */ + OpenMessageBehavior: { + /** + * Open the message in a new window. If multiple messages are selected, all + * of them are opened in separate windows. + */ + NEW_WINDOW: 0, + + /** + * Open the message in an existing window. If multiple messages are + * selected, the fallback is to "new window" behavior. If no standalone + * windows are open, the message is opened in a new standalone window. + */ + EXISTING_WINDOW: 1, + + /** + * Open the message in a new tab. If multiple messages are selected, all of + * them are opened as tabs, with the last tab in the foreground and all the + * rest in the background. If no 3-pane window is open, the message is + * opened in a new standalone window. + */ + NEW_TAB: 2, + }, +}; diff --git a/comm/mail/modules/MailE10SUtils.jsm b/comm/mail/modules/MailE10SUtils.jsm new file mode 100644 index 0000000000..224dd50a71 --- /dev/null +++ b/comm/mail/modules/MailE10SUtils.jsm @@ -0,0 +1,98 @@ +/* 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/. */ + +const EXPORTED_SYMBOLS = ["MailE10SUtils"]; + +const { E10SUtils } = ChromeUtils.importESModule( + "resource://gre/modules/E10SUtils.sys.mjs" +); +const { ExtensionParent } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionParent.sys.mjs" +); + +var MailE10SUtils = { + /** + * Loads about:blank in `browser` without switching remoteness. about:blank + * can load in a local browser or a remote browser, and `loadURI` will make + * it load in a remote browser even if you don't want it to. + * + * @param {nsIBrowser} browser + */ + loadAboutBlank(browser) { + if (!browser.currentURI || browser.currentURI.spec == "about:blank") { + return; + } + browser.loadURI(Services.io.newURI("about:blank"), { + triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), + remoteTypeOverride: browser.remoteType, + }); + }, + + /** + * Loads `uri` in `browser`, changing to a remote/local browser if necessary. + * + * @see `nsIWebNavigation.loadURI` + * + * @param {nsIBrowser} browser + * @param {string} uri + * @param {object} params + */ + loadURI(browser, uri, params = {}) { + let multiProcess = browser.ownerGlobal.docShell.QueryInterface( + Ci.nsILoadContext + ).useRemoteTabs; + let remoteSubframes = browser.ownerGlobal.docShell.QueryInterface( + Ci.nsILoadContext + ).useRemoteSubframes; + + let isRemote = browser.getAttribute("remote") == "true"; + let remoteType = E10SUtils.getRemoteTypeForURI( + uri, + multiProcess, + remoteSubframes + ); + let shouldBeRemote = remoteType !== E10SUtils.NOT_REMOTE; + + if (shouldBeRemote != isRemote) { + this.changeRemoteness(browser, remoteType); + } + + params.triggeringPrincipal = + params.triggeringPrincipal || + Services.scriptSecurityManager.getSystemPrincipal(); + browser.fixupAndLoadURIString(uri, params); + }, + + /** + * Force `browser` to be a remote/local browser. + * + * @see E10SUtils.jsm for remote types. + * + * @param {nsIBrowser} browser - the browser to enforce the remoteness of. + * @param {string} remoteType - the remoteness to enforce. + * @returns {boolean} true if any change happened on the browser (which would + * not be the case if its remoteness is already in the correct state). + */ + changeRemoteness(browser, remoteType) { + if (browser.remoteType == remoteType) { + return false; + } + + browser.destroy(); + + if (remoteType) { + browser.setAttribute("remote", "true"); + browser.setAttribute("remoteType", remoteType); + } else { + browser.setAttribute("remote", "false"); + browser.removeAttribute("remoteType"); + } + + browser.changeRemoteness({ remoteType }); + browser.construct(); + ExtensionParent.apiManager.emit("extension-browser-inserted", browser); + + return true; + }, +}; diff --git a/comm/mail/modules/MailMigrator.jsm b/comm/mail/modules/MailMigrator.jsm new file mode 100644 index 0000000000..129ff5e835 --- /dev/null +++ b/comm/mail/modules/MailMigrator.jsm @@ -0,0 +1,1200 @@ +/* -*- Mode: JavaScript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* 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/. */ + +/** + * This module handles migrating mail-specific preferences, etc. Migration has + * traditionally been a part of messenger.js, but separating the code out into + * a module makes unit testing much easier. + */ + +const EXPORTED_SYMBOLS = ["MailMigrator", "MigrationTasks"]; + +const { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" +); + +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", + migrateToolbarForSpace: "resource:///modules/ToolbarMigration.sys.mjs", + clearXULToolbarState: "resource:///modules/ToolbarMigration.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(lazy, { + FolderUtils: "resource:///modules/FolderUtils.jsm", + migrateMailnews: "resource:///modules/MailnewsMigrator.jsm", +}); + +var MailMigrator = { + _migrateXULStoreForDocument(fromURL, toURL) { + Array.from(Services.xulStore.getIDsEnumerator(fromURL)).forEach(id => { + Array.from(Services.xulStore.getAttributeEnumerator(fromURL, id)).forEach( + attr => { + let value = Services.xulStore.getValue(fromURL, id, attr); + Services.xulStore.setValue(toURL, id, attr, value); + } + ); + }); + }, + + _migrateXULStoreForElement(url, fromID, toID) { + Array.from(Services.xulStore.getAttributeEnumerator(url, fromID)).forEach( + attr => { + let value = Services.xulStore.getValue(url, fromID, attr); + Services.xulStore.setValue(url, toID, attr, value); + Services.xulStore.removeValue(url, fromID, attr); + } + ); + }, + + /* eslint-disable complexity */ + /** + * Determine if the UI has been upgraded in a way that requires us to reset + * some user configuration. If so, performs the resets. + */ + _migrateUI() { + // The code for this was ported from + // mozilla/browser/components/nsBrowserGlue.js + const UI_VERSION = 40; + const MESSENGER_DOCURL = "chrome://messenger/content/messenger.xhtml"; + const MESSENGERCOMPOSE_DOCURL = + "chrome://messenger/content/messengercompose/messengercompose.xhtml"; + const UI_VERSION_PREF = "mail.ui-rdf.version"; + let currentUIVersion = Services.prefs.getIntPref(UI_VERSION_PREF, 0); + + if (currentUIVersion >= UI_VERSION) { + return; + } + + let xulStore = Services.xulStore; + + let newProfile = currentUIVersion == 0; + if (newProfile) { + // Collapse the main menu by default if the override pref + // "mail.main_menu.collapse_by_default" is set to true. + if (Services.prefs.getBoolPref("mail.main_menu.collapse_by_default")) { + xulStore.setValue( + MESSENGER_DOCURL, + "toolbar-menubar", + "autohide", + "true" + ); + } + + // Set to current version to skip all the migration below. + currentUIVersion = UI_VERSION; + } + + try { + // UI versions below 5 could only exist in an old profile with localstore.rdf + // file used for the XUL store. Since TB55 this file is no longer read. + // Since UI version 5, the xulstore.json file is being used, so we only + // support those versions here, see bug 1371898. + + // In UI version 6, we move the otherActionsButton button to the + // header-view-toolbar. + if (currentUIVersion < 6) { + let cs = xulStore.getValue( + MESSENGER_DOCURL, + "header-view-toolbar", + "currentset" + ); + if (cs && !cs.includes("otherActionsButton")) { + // Put the otherActionsButton button at the end. + cs = cs + ",otherActionsButton"; + xulStore.setValue( + MESSENGER_DOCURL, + "header-view-toolbar", + "currentset", + cs + ); + } + } + + // In UI version 7, the three-state doNotTrack setting was reverted back + // to two-state. This reverts a (no longer supported) setting of "please + // track me" to the default "don't say anything". + if (currentUIVersion < 7) { + try { + if ( + Services.prefs.getBoolPref("privacy.donottrackheader.enabled") && + Services.prefs.getIntPref("privacy.donottrackheader.value") != 1 + ) { + Services.prefs.clearUserPref("privacy.donottrackheader.enabled"); + Services.prefs.clearUserPref("privacy.donottrackheader.value"); + } + } catch (ex) {} + } + + // In UI version 8, we change from boolean browser.display.use_document_colors + // to the tri-state browser.display.document_color_use. + if (currentUIVersion < 8) { + const kOldColorPref = "browser.display.use_document_colors"; + if ( + Services.prefs.prefHasUserValue(kOldColorPref) && + !Services.prefs.getBoolPref(kOldColorPref) + ) { + Services.prefs.setIntPref("browser.display.document_color_use", 2); + } + } + + // This one is needed also in all new profiles. + // Add an expanded entry for All Address Books. + if (currentUIVersion < 10 || newProfile) { + // If the file exists, read its contents, prepend the "All ABs" URI + // and save it, else, just write the "All ABs" URI to the file. + let spec = PathUtils.join( + Services.dirsvc.get("ProfD", Ci.nsIFile).path, + "directoryTree.json" + ); + IOUtils.readJSON(spec) + .then(data => { + data.unshift("moz-abdirectory://?"); + IOUtils.writeJSON(spec, data); + }) + .catch(ex => { + if (["NotFoundError"].includes(ex.name)) { + IOUtils.writeJSON(spec, ["moz-abdirectory://?"]); + } else { + console.error(ex); + } + }); + } + + // Several Latin language groups were consolidated into x-western. + if (currentUIVersion < 11) { + let group = null; + try { + group = Services.prefs.getComplexValue( + "font.language.group", + Ci.nsIPrefLocalizedString + ); + } catch (ex) {} + if ( + group && + ["tr", "x-baltic", "x-central-euro"].some(g => g == group.data) + ) { + group.data = "x-western"; + Services.prefs.setComplexValue( + "font.language.group", + Ci.nsIPrefLocalizedString, + group + ); + } + } + + // Untangle starting in Paragraph mode from Enter key preference. + if (currentUIVersion < 13) { + Services.prefs.setBoolPref( + "mail.compose.default_to_paragraph", + Services.prefs.getBoolPref("editor.CR_creates_new_p") + ); + Services.prefs.clearUserPref("editor.CR_creates_new_p"); + } + + // Migrate remote content exceptions for email addresses which are + // encoded as chrome URIs. + if (currentUIVersion < 14) { + let permissionsDB = Services.dirsvc.get("ProfD", Ci.nsIFile); + permissionsDB.append("permissions.sqlite"); + let db = Services.storage.openDatabase(permissionsDB); + + try { + let statement = db.createStatement( + "select origin,permission from moz_perms where " + + // Avoid 'like' here which needs to be escaped. + "substr(origin, 1, 28)='chrome://messenger/content/?';" + ); + try { + while (statement.executeStep()) { + let origin = statement.getUTF8String(0); + let permission = statement.getInt32(1); + Services.perms.removeFromPrincipal( + Services.scriptSecurityManager.createContentPrincipal( + Services.io.newURI(origin), + {} + ), + "image" + ); + origin = origin.replace( + "chrome://messenger/content/?", + "chrome://messenger/content/messenger.xhtml" + ); + Services.perms.addFromPrincipal( + Services.scriptSecurityManager.createContentPrincipal( + Services.io.newURI(origin), + {} + ), + "image", + permission + ); + } + } finally { + statement.finalize(); + } + + // Sadly we still need to clear the database manually. Experiments + // showed that the permissions manager deleted only one record. + db.defaultTransactionType = + Ci.mozIStorageConnection.TRANSACTION_EXCLUSIVE; + db.beginTransaction(); + try { + db.executeSimpleSQL( + "delete from moz_perms where " + + "substr(origin, 1, 28)='chrome://messenger/content/?';" + ); + db.commitTransaction(); + } catch (ex) { + db.rollbackTransaction(); + throw ex; + } + } finally { + db.close(); + } + } + + // Changed notification sound behaviour on OS X. + if (currentUIVersion < 15) { + var { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" + ); + if (AppConstants.platform == "macosx") { + // For people updating from versions < 52 who had "Play system sound" + // selected for notifications. As TB no longer plays system sounds, + // uncheck the pref to match the new behaviour. + const soundPref = "mail.biff.play_sound"; + if ( + Services.prefs.getBoolPref(soundPref) && + Services.prefs.getIntPref(soundPref + ".type") == 0 + ) { + Services.prefs.setBoolPref(soundPref, false); + } + } + } + + if (currentUIVersion < 16) { + // Migrate the old requested locales prefs to use the new model + const SELECTED_LOCALE_PREF = "general.useragent.locale"; + const MATCHOS_LOCALE_PREF = "intl.locale.matchOS"; + + if ( + Services.prefs.prefHasUserValue(MATCHOS_LOCALE_PREF) || + Services.prefs.prefHasUserValue(SELECTED_LOCALE_PREF) + ) { + if (Services.prefs.getBoolPref(MATCHOS_LOCALE_PREF, false)) { + Services.locale.requestedLocales = []; + } else { + let locale = Services.prefs.getComplexValue( + SELECTED_LOCALE_PREF, + Ci.nsIPrefLocalizedString + ); + if (locale) { + try { + Services.locale.requestedLocales = [locale.data]; + } catch (e) { + /* Don't panic if the value is not a valid locale code. */ + } + } + } + Services.prefs.clearUserPref(SELECTED_LOCALE_PREF); + Services.prefs.clearUserPref(MATCHOS_LOCALE_PREF); + } + } + + if (currentUIVersion < 17) { + // Move composition's [Attach |v] button to the right end of Composition + // Toolbar (unless the button was removed by user), so that it is + // right above the attachment pane. + // First, get value of currentset (string of comma-separated button ids). + let cs = xulStore.getValue( + MESSENGERCOMPOSE_DOCURL, + "composeToolbar2", + "currentset" + ); + if (cs && cs.includes("button-attach")) { + // Get array of button ids from currentset string. + let csArray = cs.split(","); + let attachButtonIndex = csArray.indexOf("button-attach"); + // Remove attach button id from current array position. + csArray.splice(attachButtonIndex, 1); + // If the currentset string does not contain a spring which causes + // elements after the spring to be right-aligned, add it now at the + // end of the array. Note: Prior to this UI version, only MAC OS + // defaultset contained a spring; in any case, user might have added + // or removed it via customization. + if (!cs.includes("spring")) { + csArray.push("spring"); + } + // Add attach button id to the end of the array. + csArray.push("button-attach"); + // Join array values back into comma-separated string. + cs = csArray.join(","); + // Apply changes to currentset. + xulStore.setValue( + MESSENGERCOMPOSE_DOCURL, + "composeToolbar2", + "currentset", + cs + ); + } + } + + if (currentUIVersion < 18) { + for (let url of [ + "chrome://calendar/content/calendar-event-dialog-attendees.xul", + "chrome://calendar/content/calendar-event-dialog.xul", + "chrome://messenger/content/addressbook/addressbook.xul", + "chrome://messenger/content/messageWindow.xul", + "chrome://messenger/content/messenger.xul", + "chrome://messenger/content/messengercompose/messengercompose.xul", + ]) { + this._migrateXULStoreForDocument( + url, + url.replace(/\.xul$/, ".xhtml") + ); + } + // See bug 1653168. messagepanebox is the problematic one, but ensure + // messagepaneboxwrapper doesn't cause problems as well. + Services.xulStore.removeValue( + "chrome://messenger/content/messenger.xhtml", + "messagepanebox", + "collapsed" + ); + Services.xulStore.removeValue( + "chrome://messenger/content/messenger.xhtml", + "messagepaneboxwrapper", + "collapsed" + ); + + Services.xulStore.removeValue( + "chrome://messenger/content/messageWindow.xhtml", + "messagepanebox", + "collapsed" + ); + Services.xulStore.removeValue( + "chrome://messenger/content/messageWindow.xhtml", + "messagepaneboxwrapper", + "collapsed" + ); + } + + if (currentUIVersion < 19) { + // Clear socks proxy values if they were shared from http, to prevent + // websocket breakage after bug 1577862 (see bug 1606679). + if ( + Services.prefs.getBoolPref( + "network.proxy.share_proxy_settings", + false + ) && + Services.prefs.getIntPref("network.proxy.type", 0) == 1 + ) { + let httpProxy = Services.prefs.getCharPref("network.proxy.http", ""); + let httpPort = Services.prefs.getIntPref( + "network.proxy.http_port", + 0 + ); + let socksProxy = Services.prefs.getCharPref( + "network.proxy.socks", + "" + ); + let socksPort = Services.prefs.getIntPref( + "network.proxy.socks_port", + 0 + ); + if (httpProxy && httpProxy == socksProxy && httpPort == socksPort) { + Services.prefs.setCharPref( + "network.proxy.socks", + Services.prefs.getCharPref("network.proxy.backup.socks", "") + ); + Services.prefs.setIntPref( + "network.proxy.socks_port", + Services.prefs.getIntPref("network.proxy.backup.socks_port", 0) + ); + } + } + } + + // Clear unused socks proxy backup values - see bug 1625773. + if (currentUIVersion < 20) { + let backup = Services.prefs.getCharPref( + "network.proxy.backup.socks", + "" + ); + let backupPort = Services.prefs.getIntPref( + "network.proxy.backup.socks_port", + 0 + ); + let socksProxy = Services.prefs.getCharPref("network.proxy.socks", ""); + let socksPort = Services.prefs.getIntPref( + "network.proxy.socks_port", + 0 + ); + if (backup == socksProxy) { + Services.prefs.clearUserPref("network.proxy.backup.socks"); + } + if (backupPort == socksPort) { + Services.prefs.clearUserPref("network.proxy.backup.socks_port"); + } + } + + // Make "bad" msgcompose.font_face value "tt" be "monospace" instead. + if (currentUIVersion < 21) { + if (Services.prefs.getStringPref("msgcompose.font_face") == "tt") { + Services.prefs.setStringPref("msgcompose.font_face", "monospace"); + } + } + + // Migrate Yahoo users to OAuth2, since "normal password" is going away + // on October 20, 2020. + if (currentUIVersion < 22) { + this._migrateIncomingToOAuth2("mail.yahoo.com"); + this._migrateSMTPToOAuth2("mail.yahoo.com"); + } + // ... and same thing for AOL users. + if (currentUIVersion < 23) { + this._migrateIncomingToOAuth2("imap.aol.com"); + this._migrateIncomingToOAuth2("pop.aol.com"); + this._migrateSMTPToOAuth2("smtp.aol.com"); + } + + // Version 24 was used and backed out. + + // Some elements changed ID, move their persisted values to the new ID. + if (currentUIVersion < 25) { + let url = "chrome://messenger/content/messenger.xhtml"; + this._migrateXULStoreForElement(url, "view-deck", "view-box"); + this._migrateXULStoreForElement(url, "displayDeck", "displayBox"); + } + + // Migrate the old Folder Pane modes dropdown. + if (currentUIVersion < 26) { + this._migrateXULStoreForElement( + "chrome://messenger/content/messenger.xhtml", + "folderPane-toolbar", + "folderPaneHeader" + ); + } + + if (currentUIVersion < 27) { + let accountList = MailServices.accounts.accounts.filter( + a => a.incomingServer + ); + accountList.sort(lazy.FolderUtils.compareAccounts); + let accountKeyList = accountList.map(account => account.key); + try { + MailServices.accounts.reorderAccounts(accountKeyList); + } catch (error) { + console.error( + "Migrating account list order failed. Error message was: " + + error + + " -- Will not reattempt migration." + ); + } + } + + // Migrating the preference of the font size in the message compose window + // to use in document.execCommand. + if (currentUIVersion < 28) { + let fontSize = Services.prefs.getCharPref("msgcompose.font_size"); + let newFontSize; + switch (fontSize) { + case "x-small": + newFontSize = "1"; + break; + case "small": + newFontSize = "2"; + break; + case "medium": + newFontSize = "3"; + break; + case "large": + newFontSize = "4"; + break; + case "x-large": + newFontSize = "5"; + break; + case "xx-large": + newFontSize = "6"; + break; + default: + newFontSize = "3"; + } + Services.prefs.setCharPref("msgcompose.font_size", newFontSize); + } + + // Migrate mail.biff.use_new_count_in_mac_dock to + // mail.biff.use_new_count_in_badge. + if (currentUIVersion < 29) { + if ( + Services.prefs.getBoolPref( + "mail.biff.use_new_count_in_mac_dock", + false + ) + ) { + Services.prefs.setBoolPref("mail.biff.use_new_count_in_badge", true); + Services.prefs.clearUserPref("mail.biff.use_new_count_in_mac_dock"); + } + } + + // Clear ui.systemUsesDarkTheme after bug 1736252. + if (currentUIVersion < 30) { + Services.prefs.clearUserPref("ui.systemUsesDarkTheme"); + } + + if (currentUIVersion < 32) { + this._migrateIncomingToOAuth2("imap.gmail.com"); + this._migrateIncomingToOAuth2("pop.gmail.com"); + this._migrateSMTPToOAuth2("smtp.gmail.com"); + } + + if (currentUIVersion < 33) { + // Put button-encryption and button-encryption-options on the + // Composition Toolbar. + // First, get value of currentset (string of comma-separated button ids). + let cs = xulStore.getValue( + MESSENGERCOMPOSE_DOCURL, + "composeToolbar2", + "currentset" + ); + if (cs) { + // Button ids from currentset string. + let buttonIds = cs.split(","); + + // We want to insert the two buttons at index 2 and 3. + buttonIds.splice(2, 0, "button-encryption"); + buttonIds.splice(3, 0, "button-encryption-options"); + + cs = buttonIds.join(","); + // Apply changes to currentset. + xulStore.setValue( + MESSENGERCOMPOSE_DOCURL, + "composeToolbar2", + "currentset", + cs + ); + } + } + + if (currentUIVersion < 34) { + // Migrate from + // + mailnews.sendformat.auto_downgrade - Whether we should + // auto-downgrade to plain text when the message is plain. + // + mail.default_html_action - The default sending format if we didn't + // auto-downgrade. + // to mail.default_send_format + let defaultHTMLAction = Services.prefs.getIntPref( + "mail.default_html_action", + 3 + ); + Services.prefs.clearUserPref("mail.default_html_action"); + let autoDowngrade = Services.prefs.getBoolPref( + "mailnews.sendformat.auto_downgrade", + true + ); + Services.prefs.clearUserPref("mailnews.sendformat.auto_downgrade"); + + let sendFormat; + switch (defaultHTMLAction) { + case 0: + // Was AskUser. Move to the new Auto default. + sendFormat = Ci.nsIMsgCompSendFormat.Auto; + break; + case 1: + // Was PlainText only. Keep as plain text. Note, autoDowngrade has + // no effect on this option. + sendFormat = Ci.nsIMsgCompSendFormat.PlainText; + break; + case 2: + // Was HTML. Keep as HTML if autoDowngrade was false, otherwise use + // the Auto default. + sendFormat = autoDowngrade + ? Ci.nsIMsgCompSendFormat.Auto + : Ci.nsIMsgCompSendFormat.HTML; + break; + case 3: + // Was Both. If autoDowngrade was true, this is the same as the + // new Auto default. Otherwise, keep as Both. + sendFormat = autoDowngrade + ? Ci.nsIMsgCompSendFormat.Auto + : Ci.nsIMsgCompSendFormat.Both; + break; + default: + sendFormat = Ci.nsIMsgCompSendFormat.Auto; + break; + } + Services.prefs.setIntPref("mail.default_send_format", sendFormat); + } + + if (currentUIVersion < 35) { + // Both IMAP and POP settings currently use this domain + this._migrateIncomingToOAuth2("outlook.office365.com"); + this._migrateSMTPToOAuth2("smtp.office365.com"); + } + + if (currentUIVersion < 36) { + lazy.migrateToolbarForSpace("mail"); + } + + if (currentUIVersion < 37) { + if (!Services.prefs.prefHasUserValue("mail.uidensity")) { + Services.prefs.setIntPref("mail.uidensity", 0); + } + } + + if (currentUIVersion < 38) { + lazy.migrateToolbarForSpace("calendar"); + lazy.migrateToolbarForSpace("tasks"); + lazy.migrateToolbarForSpace("chat"); + lazy.migrateToolbarForSpace("settings"); + lazy.migrateToolbarForSpace("addressbook"); + // Clear menubar and tabbar XUL toolbar state. + lazy.clearXULToolbarState("tabbar-toolbar"); + lazy.clearXULToolbarState("toolbar-menubar"); + } + + if (currentUIVersion < 39) { + // Set old defaults for message header customization in existing + // profiles without any customization settings. + if ( + !Services.xulStore.hasValue( + "chrome://messenger/content/messenger.xhtml", + "messageHeader", + "layout" + ) + ) { + Services.xulStore.setValue( + "chrome://messenger/content/messenger.xhtml", + "messageHeader", + "layout", + JSON.stringify({ + showAvatar: false, + showBigAvatar: false, + showFullAddress: false, + hideLabels: false, + subjectLarge: false, + buttonStyle: "default", + }) + ); + } + } + + if (currentUIVersion < 40) { + // Keep the view to table for existing profiles if the user never + // customized the thread pane view. + if ( + !Services.xulStore.hasValue( + "chrome://messenger/content/messenger.xhtml", + "threadPane", + "view" + ) + ) { + Services.xulStore.setValue( + "chrome://messenger/content/messenger.xhtml", + "threadPane", + "view", + "table" + ); + } + + // Maintain the default horizontal layout for existing profiles if the + // user never changed it. + if (!Services.prefs.prefHasUserValue("mail.pane_config.dynamic")) { + Services.prefs.setIntPref("mail.pane_config.dynamic", 0); + } + } + + // Migration tasks that may take a long time are not run immediately, but + // added to the MigrationTasks object then run at the end. + // + // See the documentation on MigrationTask and MigrationTasks for how to + // add a task. + MigrationTasks.runTasks(); + + // Update the migration version. + Services.prefs.setIntPref(UI_VERSION_PREF, UI_VERSION); + } catch (e) { + console.error( + "Migrating from UI version " + + currentUIVersion + + " to " + + UI_VERSION + + " failed. Error message was: " + + e + + " -- " + + "Will reattempt on next start." + ); + } + }, + /* eslint-enable complexity */ + + /** + * Migrate incoming server to using OAuth2 as authMethod. + * + * @param {string} hostnameHint - What the hostname should end with. + */ + _migrateIncomingToOAuth2(hostnameHint) { + for (let account of MailServices.accounts.accounts) { + // Skip if not a matching account. + if (!account.incomingServer.hostName.endsWith(hostnameHint)) { + continue; + } + + // Change Incoming server to OAuth2. + account.incomingServer.authMethod = Ci.nsMsgAuthMethod.OAuth2; + } + }, + + /** + * Migrate outgoing server to using OAuth2 as authMethod. + * + * @param {string} hostnameHint - What the hostname should end with. + */ + _migrateSMTPToOAuth2(hostnameHint) { + for (let server of MailServices.smtp.servers) { + // Skip if not a matching server. + if (!server.hostname.endsWith(hostnameHint)) { + continue; + } + + // Change Outgoing SMTP server to OAuth2. + server.authMethod = Ci.nsMsgAuthMethod.OAuth2; + } + }, + + /** + * RSS subscriptions and items used to be stored in .rdf files, but now + * we've changed to use JSON files instead. This migration routine checks + * for the old format files and upgrades them as appropriate. + * The feeds and items migration are handled as separate (hopefully atomic) + * steps. It is careful to not overwrite new-style .json files. + * + * @returns {void} + */ + async _migrateRSS() { + // Find all the RSS IncomingServers. + let rssServers = []; + for (let server of MailServices.accounts.allServers) { + if (server && server.type == "rss") { + rssServers.push(server); + } + } + + // For each one... + for (let server of rssServers) { + await this._migrateRSSServer(server); + } + }, + + async _migrateRSSServer(server) { + let rssServer = server.QueryInterface(Ci.nsIRssIncomingServer); + + // Convert feeds.rdf to feeds.json (if needed). + let feedsFile = rssServer.subscriptionsPath; + let legacyFeedsFile = server.localPath; + legacyFeedsFile.append("feeds.rdf"); + + try { + await this._migrateRSSSubscriptions(legacyFeedsFile, feedsFile); + } catch (err) { + console.error( + "Failed to migrate '" + + feedsFile.path + + "' to '" + + legacyFeedsFile.path + + "': " + + err + ); + } + + // Convert feeditems.rdf to feeditems.json (if needed). + let itemsFile = rssServer.feedItemsPath; + let legacyItemsFile = server.localPath; + legacyItemsFile.append("feeditems.rdf"); + try { + await this._migrateRSSItems(legacyItemsFile, itemsFile); + } catch (err) { + console.error( + "Failed to migrate '" + + itemsFile.path + + "' to '" + + legacyItemsFile.path + + "': " + + err + ); + } + }, + + // Assorted namespace strings required for the feed migrations. + FZ_NS: "urn:forumzilla:", + DC_NS: "http://purl.org/dc/elements/1.1/", + RSS_NS: "http://purl.org/rss/1.0/", + RDF_SYNTAX_NS: "http://www.w3.org/1999/02/22-rdf-syntax-ns#", + RDF_SYNTAX_TYPE: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type", + + /** + * Convert rss subscriptions in a legacy feeds.rdf file into feeds.json. + * If the conversion is successful, the legacy file will be removed. + * + * @param {nsIFile} legacyFile - Location of the rdf file. + * @param {nsIFile} jsonFile - Location for the output JSON file. + * @returns {void} + */ + async _migrateRSSSubscriptions(legacyFile, jsonFile) { + // Load .rdf file into an XMLDocument. + let rawXMLRDF; + try { + rawXMLRDF = await IOUtils.readUTF8(legacyFile.path); + } catch (ex) { + if (["NotFoundError"].includes(ex.name)) { + return; // nothing legacy file to migrate + } + } + let parser = new DOMParser(); + let doc = parser.parseFromString(rawXMLRDF, "text/xml"); + + let feeds = []; + // Skip the fz:root->fz:feeds->etc structure. Just grab fz:feed nodes. + let feedNodes = doc.documentElement.getElementsByTagNameNS( + this.FZ_NS, + "feed" + ); + + let toBool = function (val) { + return val == "true"; + }; + + // Map RDF feed property names to js. + let propMap = [ + { ns: this.DC_NS, name: "title", dest: "title" }, + { ns: this.DC_NS, name: "lastModified", dest: "lastModified" }, + { ns: this.DC_NS, name: "identifier", dest: "url" }, + { ns: this.FZ_NS, name: "quickMode", dest: "quickMode", cook: toBool }, + { ns: this.FZ_NS, name: "options", dest: "options", cook: JSON.parse }, + { ns: this.FZ_NS, name: "destFolder", dest: "destFolder" }, + { ns: this.RSS_NS, name: "link", dest: "link" }, + ]; + + for (let f of feedNodes) { + let feed = {}; + for (let p of propMap) { + // The data could be in either an attribute or an element. + let val = f.getAttributeNS(p.ns, p.name); + if (!val) { + let el = f.getElementsByTagNameNS(p.ns, p.name).item(0); + if (el) { + // Might be a RDF:resource... + val = el.getAttributeNS(this.RDF_SYNTAX_NS, "resource"); + if (!val) { + // ...or a literal string. + val = el.textContent; + } + } + } + if (!val) { + // log.warn(`feeds.rdf: ${p.name} missing`); + continue; + } + // Conversion needed? + if ("cook" in p) { + val = p.cook(val); + } + feed[p.dest] = val; + } + + if (feed.url) { + feeds.push(feed); + } + } + + await IOUtils.writeJSON(jsonFile.path, feeds); + legacyFile.remove(false); + }, + + /** + * Convert a legacy feeditems.rdf file into feeditems.json. + * If the conversion is successful, the legacy file will be removed. + * + * @param {nsIFile} legacyFile - Location of the rdf file. + * @param {nsIFile} jsonFile - Location for the output JSON file. + * @returns {void} + */ + async _migrateRSSItems(legacyFile, jsonFile) { + // Load .rdf file into an XMLDocument. + let rawXMLRDF; + try { + rawXMLRDF = await IOUtils.readUTF8(legacyFile.path); + } catch (ex) { + if (["NotFoundError"].includes(ex.name)) { + return; // nothing legacy file to migrate + } + } + let parser = new DOMParser(); + let doc = parser.parseFromString(rawXMLRDF, "text/xml"); + + let items = {}; + + let demangleURL = function (itemURI) { + // Reverse the mapping that originally turned links/guids into URIs. + let url = itemURI; + url = url.replace("urn:feeditem:", ""); + url = url.replace(/%23/g, "#"); + url = url.replace(/%2f/g, "/"); + url = url.replace(/%3f/g, "?"); + url = url.replace(/%26/g, "&"); + url = url.replace(/%7e/g, "~"); + url = decodeURI(url); + return url; + }; + + let toBool = function (s) { + return s == "true"; + }; + + let toInt = function (s) { + let t = parseInt(s); + return Number.isNaN(t) ? 0 : t; + }; + + let itemNodes = doc.documentElement.getElementsByTagNameNS( + this.RDF_SYNTAX_NS, + "Description" + ); + + // Map RDF feed property names to js. + let propMap = [ + { ns: this.FZ_NS, name: "stored", dest: "stored", cook: toBool }, + { ns: this.FZ_NS, name: "valid", dest: "valid", cook: toBool }, + { + ns: this.FZ_NS, + name: "last-seen-timestamp", + dest: "lastSeenTime", + cook: toInt, + }, + ]; + + for (let itemNode of itemNodes) { + let item = {}; + for (let p of propMap) { + // The data could be in either an attribute or an element. + let val = itemNode.getAttributeNS(p.ns, p.name); + if (!val) { + let elements = itemNode.getElementsByTagNameNS(p.ns, p.name); + if (elements.length > 0) { + val = elements.item(0).textContent; + } + } + if (!val) { + // log.warn(`feeditems.rdf: ${p.name} missing`); + continue; + } + // Conversion needed? + if ("cook" in p) { + val = p.cook(val); + } + item[p.dest] = val; + } + + item.feedURLs = []; + let feedNodes = itemNode.getElementsByTagNameNS(this.FZ_NS, "feed"); + for (let feedNode of feedNodes) { + let feedURL = feedNode.getAttributeNS(this.RDF_SYNTAX_NS, "resource"); + item.feedURLs.push(feedURL); + } + + let id = itemNode.getAttributeNS(this.RDF_SYNTAX_NS, "about"); + id = demangleURL(id); + if (id) { + items[id] = item; + } + } + + await IOUtils.writeJSON(jsonFile.path, items); + legacyFile.remove(false); + }, + + /** + * Perform any migration work that needs to occur once the user profile has + * been loaded. + */ + migrateAtProfileStartup() { + lazy.migrateMailnews(); + this._migrateUI(); + this._migrateRSS(); + }, +}; + +/** + * Controls migration tasks, including (if the migration is taking a while) + * presenting the user with a pop-up window showing the current status. + */ +var MigrationTasks = { + _finished: false, + _progressWindow: null, + _start: null, + _tasks: [], + _waitThreshold: 1000, + + /** + * Adds a simple task to be completed. + * + * @param {string} [fluentID] - The name of this task. If specified, a string + * for this name MUST be in migration.ftl. If not specified, this task + * won't appear in the list of migration tasks. + * @param {Function} action + */ + addSimpleTask(fluentID, action) { + this._tasks.push(new MigrationTask(fluentID, action)); + }, + + /** + * Adds a task to be completed. Subclasses of MigrationTask are allowed, + * allowing more complex tasks than `addSimpleTask`. + * + * @param {MigrationTask} task + */ + addComplexTask(task) { + if (!(task instanceof MigrationTask)) { + throw new Error("Task is not a MigrationTask"); + } + this._tasks.push(task); + }, + + /** + * Runs the tasks in sequence. + */ + async _runTasksInternal() { + this._start = Date.now(); + + // Do not optimise this for-loop. More tasks could be added. + for (let t = 0; t < this._tasks.length; t++) { + let task = this._tasks[t]; + task.status = "running"; + + await task.action(); + + for (let i = 0; i < task.subTasks.length; i++) { + task.emit("progress", i, task.subTasks.length); + let subTask = task.subTasks[i]; + subTask.status = "running"; + + await subTask.action(); + subTask.status = "finished"; + } + if (task.subTasks.length) { + task.emit("progress", task.subTasks.length, task.subTasks.length); + // Pause long enough for the user to see the progress bar at 100%. + await new Promise(resolve => lazy.setTimeout(resolve, 150)); + } + + task.status = "finished"; + } + + this._tasks.length = 0; + this._finished = true; + }, + + /** + * Runs the migration tasks. Controls the opening and closing of the pop-up. + */ + runTasks() { + this._runTasksInternal(); + + Services.tm.spinEventLoopUntil("MigrationTasks", () => { + if (this._finished) { + return true; + } + + if ( + !this._progressWindow && + Date.now() - this._start > this._waitThreshold + ) { + this._progressWindow = Services.ww.openWindow( + null, + "chrome://messenger/content/migrationProgress.xhtml", + "_blank", + "centerscreen,width=640", + Services.ww + ); + this.addSimpleTask(undefined, async () => { + await new Promise(r => lazy.setTimeout(r, 1000)); + this._progressWindow.close(); + }); + } + + return false; + }); + + delete this._progressWindow; + }, + + /** + * @type MigrationTask[] + */ + get tasks() { + return this._tasks; + }, +}; + +/** + * A single task to be completed. + */ +class MigrationTask { + /** + * The name of this task. If specified, a string for this name MUST be in + * migration.ftl. If not specified, this task won't appear in the list of + * migration tasks. + * + * @type string + */ + fluentID = null; + + /** + * Smaller tasks for this task. If there are sub-tasks, a progress bar will + * be displayed to the user, showing how many sub-tasks are complete. + * + * @note A sub-task may not have sub-sub-tasks. + * + * @type MigrationTask[] + */ + subTasks = []; + + /** + * Current status of the task. Either "pending", "running" or "finished". + * + * @type string + */ + _status = "pending"; + + /** + * @param {string} [fluentID] + * @param {Function} action + */ + constructor(fluentID, action) { + this.fluentID = fluentID; + this.action = action; + lazy.EventEmitter.decorate(this); + } + + /** + * Current status of the task. Either "pending", "running" or "finished". + * Emits a "status-change" notification on change. + * + * @type string + */ + get status() { + return this._status; + } + + set status(value) { + this._status = value; + this.emit("status-change", value); + } +} diff --git a/comm/mail/modules/MailUsageTelemetry.jsm b/comm/mail/modules/MailUsageTelemetry.jsm new file mode 100644 index 0000000000..5fe99bee02 --- /dev/null +++ b/comm/mail/modules/MailUsageTelemetry.jsm @@ -0,0 +1,362 @@ +/* -*- js-indent-level: 2; indent-tabs-mode: nil -*- */ +/* 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/. */ + +"use strict"; + +const EXPORTED_SYMBOLS = ["MailUsageTelemetry"]; + +// Observed topic names. +const DOMWINDOW_OPENED_TOPIC = "domwindowopened"; + +// Window types we're interested in. +const WINDOW_TYPES = ["mail:3pane", "mail:messageWindow"]; + +// Window URLs we're interested in. +const WINDOW_URLS = [ + "chrome://messenger/content/messenger.xhtml", + "chrome://messenger/content/messageWindow.xhtml", + "about:3pane", + "about:message", +]; + +// The elements we consider to be interactive. +const UI_TARGET_ELEMENTS = [ + "menuitem", + "toolbarbutton", + "key", + "command", + "checkbox", + "input", + "button", + "image", + "radio", + "richlistitem", +]; + +// The containers of interactive elements that we care about and their pretty +// names. These should be listed in order of most-specific to least-specific, +// when iterating JavaScript will guarantee that ordering and so we will find +// the most specific area first. +const MESSENGER_UI_CONTAINER_IDS = { + // Calendar. + "today-pane-panel": "calendar", + calendarTabPanel: "calendar", + "calendar-popupset": "calendar", + + // Chat. + chatTabPanel: "chat", + buddyListContextMenu: "chat", + chatConversationContextMenu: "chat", + "chat-toolbar-context-menu": "chat", + chatContextMenu: "chat", + participantListContextMenu: "chat", + + // Anything to do with the 3-pane tab or message window. + folderPaneHeaderBar: "message-display", + folderPaneGetMessagesContext: "message-display", + folderPaneMoreContext: "message-display", + folderPaneContext: "message-display", + threadPaneHeaderBar: "message-display", + threadPaneDisplayContext: "message-display", + aboutPagesContext: "message-display", + browserContext: "message-display", + mailContext: "message-display", + singleMessage: "message-display", + emailAddressPopup: "message-display", + copyPopup: "message-display", + messageIdContext: "message-display", + attachmentItemContext: "message-display", + attachmentListContext: "message-display", + "attachment-toolbar-context-menu": "message-display", + copyUrlPopup: "message-display", + newsgroupPopup: "message-display", + + // The tab bar and the toolbox. + "navigation-toolbox": "toolbox", + "mail-toolbox": "toolbox", + "quick-filter-bar": "toolbox", + "appMenu-popup": "toolbox", + tabContextMenu: "toolbox", + spacesToolbar: "toolbox", +}; + +const KNOWN_ADDONS = []; + +function telemetryId(widgetId, obscureAddons = true) { + // Add-on IDs need to be obscured. + function addonId(id) { + if (!obscureAddons) { + return id; + } + + let pos = KNOWN_ADDONS.indexOf(id); + if (pos < 0) { + pos = KNOWN_ADDONS.length; + KNOWN_ADDONS.push(id); + } + return `addon${pos}`; + } + + if (widgetId.endsWith("-browserAction-toolbarbutton")) { + widgetId = addonId( + widgetId.substring( + 0, + widgetId.length - "-browserAction-toolbarbutton".length + ) + ); + } else if (widgetId.endsWith("-messageDisplayAction-toolbarbutton")) { + widgetId = addonId( + widgetId.substring( + 0, + widgetId.length - "-messageDisplayAction-toolbarbutton".length + ) + ); + } else if (widgetId.startsWith("ext-keyset-id-")) { + // Webextension command shortcuts don't have an id on their key element so + // we see the id from the keyset that contains them. + widgetId = addonId(widgetId.substring("ext-keyset-id-".length)); + } else if (widgetId.includes("-menuitem--")) { + widgetId = addonId(widgetId.substring(0, widgetId.indexOf("-menuitem--"))); + } else if (/^qfb-tag-(?!\$label\d$)/.test(widgetId)) { + // Only record the full ID of buttons for tags named label0...label9. + // The data for other tags are of no use to us and could contain personal + // information, so hide it behind this generic ID. + widgetId = "qfb-tag-"; + } + // Collapse these IDs as each element is given a unique ID. + widgetId = widgetId.replace(/^folderPanelView\d+/, "folderPanelView"); + // Strip UUIDs in widget IDs. + widgetId = widgetId.replace( + /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, + "" + ); + // Strip mail URLs as they could contain personal information. + widgetId = widgetId.replace(/(imap|mailbox):\/\/.*/, ""); + return widgetId.replace(/_/g, "-"); +} + +let MailUsageTelemetry = { + QueryInterface: ChromeUtils.generateQI([ + "nsIObserver", + "nsISupportsWeakReference", + ]), + + _inited: false, + + init() { + // Make sure to catch new chrome windows and subsession splits. + Services.obs.addObserver(this, DOMWINDOW_OPENED_TOPIC, true); + + // Attach the handlers to the existing Windows. + for (let winType of WINDOW_TYPES) { + for (let win of Services.wm.getEnumerator(winType)) { + this._registerWindow(win); + } + } + + this._inited = true; + }, + + uninit() { + if (!this._inited) { + return; + } + Services.obs.removeObserver(this, DOMWINDOW_OPENED_TOPIC); + }, + + observe(subject, topic, data) { + switch (topic) { + case DOMWINDOW_OPENED_TOPIC: + this._onWindowOpen(subject); + break; + } + }, + + handleEvent(event) { + switch (event.type) { + case "unload": + this._unregisterWindow(event.target); + break; + } + }, + + _getWidgetID(node) { + // We want to find a sensible ID for this element. + if (!node) { + return null; + } + + if (node.id) { + return node.id; + } + + // Special case in the tabs. + if (node.classList.contains("tab-close-button")) { + return "tab-close-button"; + } + + // One of these will at least let us know what the widget is for. + let possibleAttributes = [ + "preference", + "command", + "observes", + "data-l10n-id", + ]; + + // The key attribute on key elements is the actual key to listen for. + if (node.localName != "key") { + possibleAttributes.unshift("key"); + } + + for (let idAttribute of possibleAttributes) { + if (node.hasAttribute(idAttribute)) { + return node.getAttribute(idAttribute); + } + } + + return this._getWidgetID(node.parentElement); + }, + + _getBrowserWidgetContainer(node) { + // Find the container holding this element. + for (let containerId of Object.keys(MESSENGER_UI_CONTAINER_IDS)) { + let container = node.ownerDocument.getElementById(containerId); + if (container && container.contains(node)) { + return MESSENGER_UI_CONTAINER_IDS[containerId]; + } + } + return null; + }, + + _getWidgetContainer(node) { + if (node.localName == "key") { + return "keyboard"; + } + + const { URL } = node.ownerDocument; + if (WINDOW_URLS.includes(URL)) { + return this._getBrowserWidgetContainer(node); + } + return null; + }, + + lastClickTarget: null, + + _recordCommand(event) { + let types = [event.type]; + let sourceEvent = event; + while (sourceEvent.sourceEvent) { + sourceEvent = sourceEvent.sourceEvent; + types.push(sourceEvent.type); + } + + let lastTarget = this.lastClickTarget?.get(); + if ( + lastTarget && + sourceEvent.type == "command" && + sourceEvent.target.contains(lastTarget) + ) { + // Ignore a command event triggered by a click. + this.lastClickTarget = null; + return; + } + + this.lastClickTarget = null; + + if (sourceEvent.type == "click") { + // Only care about main button clicks. + if (sourceEvent.button != 0) { + return; + } + + // This click may trigger a command event so retain the target to be able + // to dedupe that event. + this.lastClickTarget = Cu.getWeakReference(sourceEvent.target); + } + + // We should never see events from web content as they are fired in a + // content process, but let's be safe. + let url = sourceEvent.target.ownerDocument.documentURIObject; + if (!url.schemeIs("chrome") && !url.schemeIs("about")) { + return; + } + + // This is what events targeted at content will actually look like. + if (sourceEvent.target.localName == "browser") { + return; + } + + // Find the actual element we're interested in. + let node = sourceEvent.target; + while (!UI_TARGET_ELEMENTS.includes(node.localName)) { + node = node.parentNode; + if (!node) { + // A click on a space or label or something we're not interested in. + return; + } + } + + let item = this._getWidgetID(node); + let source = this._getWidgetContainer(node); + + if (item && source) { + let scalar = `tb.ui.interaction.${source.replace("-", "_")}`; + Services.telemetry.keyedScalarAdd(scalar, telemetryId(item), 1); + } + }, + + /** + * Listens for UI interactions in the window. + */ + _addUsageListeners(win) { + // Listen for command events from the UI. + win.addEventListener("command", event => this._recordCommand(event), true); + win.addEventListener("click", event => this._recordCommand(event), true); + }, + + /** + * Adds listeners to a single chrome window. + */ + _registerWindow(win) { + this._addUsageListeners(win); + + win.addEventListener("unload", this); + }, + + /** + * Removes listeners from a single chrome window. + */ + _unregisterWindow(win) { + win.removeEventListener("unload", this); + }, + + /** + * Tracks the window count and registers the listeners for the tab count. + * + * @param{Object} win The window object. + */ + _onWindowOpen(win) { + // Make sure to have a |nsIDOMWindow|. + if (!(win instanceof Ci.nsIDOMWindow)) { + return; + } + + let onLoad = () => { + win.removeEventListener("load", onLoad); + + // Ignore non browser windows. + if ( + !WINDOW_TYPES.includes( + win.document.documentElement.getAttribute("windowtype") + ) + ) { + return; + } + + this._registerWindow(win); + }; + win.addEventListener("load", onLoad); + }, +}; diff --git a/comm/mail/modules/MailUtils.jsm b/comm/mail/modules/MailUtils.jsm new file mode 100644 index 0000000000..7ba78005b1 --- /dev/null +++ b/comm/mail/modules/MailUtils.jsm @@ -0,0 +1,820 @@ +/* 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/. */ + +var EXPORTED_SYMBOLS = ["MailUtils"]; + +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +const lazy = {}; + +XPCOMUtils.defineLazyModuleGetters(lazy, { + MailConsts: "resource:///modules/MailConsts.jsm", + MailServices: "resource:///modules/MailServices.jsm", + MimeParser: "resource:///modules/mimeParser.jsm", + NetUtil: "resource://gre/modules/NetUtil.jsm", +}); + +ChromeUtils.defineESModuleGetters(lazy, { + PluralForm: "resource://gre/modules/PluralForm.sys.mjs", +}); + +/** + * This module has several utility functions for use by both core and + * third-party code. Some functions are aimed at code that doesn't have a + * window context, while others can be used anywhere. + */ +var MailUtils = { + /** + * Restarts the application, keeping it in + * safe mode if it is already in safe mode. + */ + restartApplication() { + let cancelQuit = Cc["@mozilla.org/supports-PRBool;1"].createInstance( + Ci.nsISupportsPRBool + ); + Services.obs.notifyObservers( + cancelQuit, + "quit-application-requested", + "restart" + ); + if (cancelQuit.data) { + return; + } + // If already in safe mode restart in safe mode. + if (Services.appinfo.inSafeMode) { + Services.startup.restartInSafeMode( + Ci.nsIAppStartup.eAttemptQuit | Ci.nsIAppStartup.eRestart + ); + return; + } + Services.startup.quit( + Ci.nsIAppStartup.eAttemptQuit | Ci.nsIAppStartup.eRestart + ); + }, + + /** + * Discover all folders. This is useful during startup, when you have code + * that deals with folders and that executes before the main 3pane window is + * open (the folder tree wouldn't have been initialized yet). + */ + discoverFolders() { + for (let server of lazy.MailServices.accounts.allServers) { + // Bug 466311 Sometimes this can throw file not found, we're unsure + // why, but catch it and log the fact. + try { + server.rootFolder.subFolders; + } catch (ex) { + Services.console.logStringMessage( + "Discovering folders for account failed with exception: " + ex + ); + } + } + }, + + /** + * Get the nsIMsgFolder corresponding to this file. This just looks at all + * folders and does a direct match. + * + * One of the places this is used is desktop search integration -- to open + * the search result corresponding to a mozeml/wdseml file, we need to figure + * out the folder using the file's path. + * + * @param aFile the nsIFile to convert to a folder + * @returns the nsIMsgFolder corresponding to aFile, or null if the folder + * isn't found + */ + getFolderForFileInProfile(aFile) { + for (let folder of lazy.MailServices.accounts.allFolders) { + if (folder.filePath.equals(aFile)) { + return folder; + } + } + return null; + }, + + /** + * Get the nsIMsgFolder corresponding to this URI. + * + * @param aFolderURI the URI of the target folder + * @returns {nsIMsgFolder} Folder corresponding to this URI, or null if + * the folder doesn't already exist. + */ + getExistingFolder(aFolderURI) { + let fls = Cc["@mozilla.org/mail/folder-lookup;1"].getService( + Ci.nsIFolderLookupService + ); + return fls.getFolderForURL(aFolderURI); + }, + + /** + * Get the nsIMsgFolder corresponding to this URI, or create a detached + * folder if it doesn't already exist. + * + * @param aFolderURI the URI of the target folder + * @returns {nsIMsgFolder} Folder corresponding to this URI. + */ + getOrCreateFolder(aFolderURI) { + let fls = Cc["@mozilla.org/mail/folder-lookup;1"].getService( + Ci.nsIFolderLookupService + ); + return fls.getOrCreateFolderForURL(aFolderURI); + }, + + /** + * Display this message header in a new tab, a new window or an existing + * window, depending on the preference and whether a 3pane or standalone + * window is already open. This function should be called when you'd like to + * display a message to the user according to the pref set. + * + * @note Do not use this if you want to open multiple messages at once. Use + * |displayMessages| instead. + * + * @param {nsIMsgHdr} aMsgHdr - The message header to display. + * @param {DBViewWrapper} [aViewWrapperToClone] - A view wrapper to clone. + * If null or not given, the message header's folder's default view will + * be used. + * @param {Element} [aTabmail] - A tabmail element to use in case we need to + * open tabs. If null or not given: + * - if one or more 3pane windows are open, the most recent one's tabmail + * is used, and the window is brought to the front + * - if no 3pane windows are open, a standalone window is opened instead + * of a tab + */ + displayMessage(aMsgHdr, aViewWrapperToClone, aTabmail) { + this.displayMessages([aMsgHdr], aViewWrapperToClone, aTabmail); + }, + + /** + * Display the warning if the number of messages to be displayed is greater than + * the limit set in preferences. + * + * @param aNumMessages: number of messages to be displayed + * @param aConfirmTitle: title ID + * @param aConfirmMsg: message ID + * @param aLiitingPref: the name of the pref to retrieve the limit from + */ + confirmAction(aNumMessages, aConfirmTitle, aConfirmMsg, aLimitingPref) { + let openWarning = Services.prefs.getIntPref(aLimitingPref); + if (openWarning > 1 && aNumMessages >= openWarning) { + let bundle = Services.strings.createBundle( + "chrome://messenger/locale/messenger.properties" + ); + let title = bundle.GetStringFromName(aConfirmTitle); + let message = lazy.PluralForm.get( + aNumMessages, + bundle.GetStringFromName(aConfirmMsg) + ).replace("#1", aNumMessages); + if (!Services.prompt.confirm(null, title, message)) { + return true; + } + } + return false; + }, + /** + * Display these message headers in new tabs, new windows or existing + * windows, depending on the preference, the number of messages, and whether + * a 3pane or standalone window is already open. This function should be + * called when you'd like to display multiple messages to the user according + * to the pref set. + * + * @param {nsIMsgHdr[]} aMsgHdrs - An array containing the message headers to + * display. The array should contain at least one message header. + * @param {DBViewWrapper} [aViewWrapperToClone] - A DB view wrapper to clone + * for each of the tabs or windows. + * @param {Element} [aTabmail] - A tabmail element to use in case we need to + * open tabs. If given, the window containing the tabmail is assumed to be + * in front. If null or not given: + * - if one or more 3pane windows are open, the most recent one's tabmail + * is used, and the window is brought to the front + * - if no 3pane windows are open, a standalone window is opened instead + * of a tab + */ + displayMessages( + aMsgHdrs, + aViewWrapperToClone, + aTabmail, + useBackgroundPref = false + ) { + let openMessageBehavior = Services.prefs.getIntPref( + "mail.openMessageBehavior" + ); + + if (openMessageBehavior == lazy.MailConsts.OpenMessageBehavior.NEW_WINDOW) { + this.openMessagesInNewWindows(aMsgHdrs, aViewWrapperToClone); + } else if ( + openMessageBehavior == lazy.MailConsts.OpenMessageBehavior.EXISTING_WINDOW + ) { + // Try reusing an existing window. If we can't, fall back to opening new + // windows + if ( + aMsgHdrs.length > 1 || + !this.openMessageInExistingWindow(aMsgHdrs[0]) + ) { + this.openMessagesInNewWindows(aMsgHdrs, aViewWrapperToClone); + } + } else if ( + openMessageBehavior == lazy.MailConsts.OpenMessageBehavior.NEW_TAB + ) { + let mail3PaneWindow = null; + if (!aTabmail) { + // Try opening new tabs in a 3pane window + mail3PaneWindow = Services.wm.getMostRecentWindow("mail:3pane"); + if (mail3PaneWindow) { + aTabmail = mail3PaneWindow.document.getElementById("tabmail"); + } + } + + if (aTabmail) { + if ( + this.confirmAction( + aMsgHdrs.length, + "openTabWarningTitle", + "openTabWarningConfirmation", + "mailnews.open_tab_warning" + ) + ) { + return; + } + const loadInBackground = useBackgroundPref + ? Services.prefs.getBoolPref("mail.tabs.loadInBackground") + : false; + + // Open all the tabs in the background, except for the last one + for (let [i, msgHdr] of aMsgHdrs.entries()) { + aTabmail.openTab("mailMessageTab", { + messageURI: msgHdr.folder.getUriForMsg(msgHdr), + viewWrapper: aViewWrapperToClone, + background: i < aMsgHdrs.length - 1 || loadInBackground, + disregardOpener: aMsgHdrs.length > 1, + }); + } + + if (mail3PaneWindow) { + mail3PaneWindow.focus(); + } + } else { + // We still haven't found a tabmail, so we'll need to open new windows + this.openMessagesInNewWindows(aMsgHdrs, aViewWrapperToClone); + } + } + }, + + /** + * Show this message in an existing window. + * + * @param {nsIMsgHdr} aMsgHdr - The message header to display. + * @param {DBViewWrapper} [aViewWrapperToClone] - A DB view wrapper to clone + * for the message window. + * @returns {boolean} true if an existing window was found and the message + * header was displayed, false otherwise. + */ + openMessageInExistingWindow(aMsgHdr, aViewWrapperToClone) { + let messageWindow = Services.wm.getMostRecentWindow("mail:messageWindow"); + if (messageWindow) { + messageWindow.displayMessage(aMsgHdr, aViewWrapperToClone); + return true; + } + return false; + }, + + /** + * Open a new standalone message window with this header. + * + * @param {nsIMsgHdr} aMsgHdr the message header to display + * @param {DBViewWrapper} [aViewWrapperToClone] - A DB view wrapper to clone + * for the message window. + * @returns {DOMWindow} the opened window + */ + openMessageInNewWindow(aMsgHdr, aViewWrapperToClone) { + // It sucks that we have to go through XPCOM for this. + let args = { msgHdr: aMsgHdr, viewWrapperToClone: aViewWrapperToClone }; + args.wrappedJSObject = args; + + return Services.ww.openWindow( + null, + "chrome://messenger/content/messageWindow.xhtml", + "", + "all,chrome,dialog=no,status,toolbar", + args + ); + }, + + /** + * Open new standalone message windows for these headers. This will prompt + * for confirmation if the number of windows to be opened is greater than the + * value of the mailnews.open_window_warning preference. + * + * @param {nsIMsgHdr[]} aMsgHdrs - An array containing the message headers + * to display. + * @param {DBViewWrapper} [aViewWrapperToClone] - A DB view wrapper to clone + * for each message window. + */ + openMessagesInNewWindows(aMsgHdrs, aViewWrapperToClone) { + if ( + this.confirmAction( + aMsgHdrs.length, + "openWindowWarningTitle", + "openWindowWarningConfirmation", + "mailnews.open_window_warning" + ) + ) { + return; + } + + for (let msgHdr of aMsgHdrs) { + this.openMessageInNewWindow(msgHdr, aViewWrapperToClone); + } + }, + + /** + * Display the given folder in the 3pane of the most recent 3pane window. + * + * @param {string} folderURI - The URI of the folder to display + */ + displayFolderIn3Pane(folderURI) { + // Try opening new tabs in a 3pane window + let win = Services.wm.getMostRecentWindow("mail:3pane"); + let tabmail = win.document.getElementById("tabmail"); + if (!tabmail.currentAbout3Pane) { + tabmail.switchToTab(tabmail.tabInfo[0]); + tabmail.updateCurrentTab(); + } + tabmail.currentAbout3Pane.displayFolder(folderURI); + win.focus(); + }, + + /** + * Display this message header in a folder tab in a 3pane window. This is + * useful when the message needs to be displayed in the context of its folder + * or thread. + * + * @param {nsIMsgHdr} msgHdr - The message header to display. + * @param {boolean} [openIfMessagePaneHidden] - If true, and the folder tab's + * message pane is hidden, opens the message in a new tab or window. + * Otherwise uses the folder tab. + */ + displayMessageInFolderTab(msgHdr, openIfMessagePaneHidden) { + // Try opening new tabs in a 3pane window + let mail3PaneWindow = Services.wm.getMostRecentWindow("mail:3pane"); + if (mail3PaneWindow) { + if (openIfMessagePaneHidden) { + let tab = mail3PaneWindow.document.getElementById("tabmail").tabInfo[0]; + if (!tab.chromeBrowser.contentWindow.paneLayout.messagePaneVisible) { + this.displayMessage(msgHdr); + return; + } + } + + mail3PaneWindow.MsgDisplayMessageInFolderTab(msgHdr); + if (Ci.nsIMessengerWindowsIntegration) { + Cc["@mozilla.org/messenger/osintegration;1"] + .getService(Ci.nsIMessengerWindowsIntegration) + .showWindow(mail3PaneWindow); + } + mail3PaneWindow.focus(); + } else { + let args = { msgHdr }; + args.wrappedJSObject = args; + Services.ww.openWindow( + null, + "chrome://messenger/content/messenger.xhtml", + "", + "all,chrome,dialog=no,status,toolbar", + args + ); + } + }, + + /** + * Open a message from a message id. + * + * @param {string} msgId - The message id string without the brackets. + */ + openMessageByMessageId(msgId) { + let msgHdr = this.getMsgHdrForMsgId(msgId); + if (msgHdr) { + this.displayMessage(msgHdr); + return; + } + let bundle = Services.strings.createBundle( + "chrome://messenger/locale/messenger.properties" + ); + let errorTitle = bundle.GetStringFromName( + "errorOpenMessageForMessageIdTitle" + ); + let errorMessage = bundle.formatStringFromName( + "errorOpenMessageForMessageIdMessage", + [msgId] + ); + Services.prompt.alert(null, errorTitle, errorMessage); + }, + + /** + * Open the given .eml file. + * + * @param {DOMWindow} win - The window which the file is being opened within. + * @param {nsIFile} aFile - The file being opened. + * @param {nsIURL} aURL - The full file URL. + */ + openEMLFile(win, aFile, aURL) { + let url = aURL + .mutate() + .setQuery("type=application/x-message-display") + .finalize(); + + let fstream = null; + let headers = new Map(); + // Read this eml and extract its headers to check for X-Unsent. + try { + fstream = Cc["@mozilla.org/network/file-input-stream;1"].createInstance( + Ci.nsIFileInputStream + ); + fstream.init(aFile, -1, 0, 0); + let data = lazy.NetUtil.readInputStreamToString( + fstream, + fstream.available() + ); + headers = lazy.MimeParser.extractHeaders(data); + } catch (e) { + // Ignore errors on reading the eml or extracting its headers. The test for + // the X-Unsent header below will fail and the message window will take care + // of any error handling. + } finally { + if (fstream) { + fstream.close(); + } + } + + if (headers.get("X-Unsent") == "1") { + let msgWindow = Cc["@mozilla.org/messenger/msgwindow;1"].createInstance( + Ci.nsIMsgWindow + ); + lazy.MailServices.compose.OpenComposeWindow( + null, + {}, + url.spec, + Ci.nsIMsgCompType.Draft, + Ci.nsIMsgCompFormat.Default, + null, + headers.get("from"), + msgWindow + ); + } else if ( + Services.prefs.getIntPref("mail.openMessageBehavior") == + lazy.MailConsts.OpenMessageBehavior.NEW_TAB && + win.document.getElementById("tabmail") + ) { + win.document + .getElementById("tabmail") + .openTab("mailMessageTab", { messageURI: url.spec }); + } else { + win.openDialog( + "chrome://messenger/content/messageWindow.xhtml", + "_blank", + "all,chrome,dialog=no,status,toolbar", + url + ); + } + }, + + /** + * The number of milliseconds to wait between loading of folders in + * |takeActionOnFolderAndDescendents|. We wait at all because + * opening msf databases is a potentially expensive synchronous operation that + * can approach the order of a second in pathological cases like gmail's + * all mail folder. + * + * If we did not use a timer or otherwise spin the event loop we would + * completely lock up the UI. In theory we would still maintain some degree + * of UI responsiveness if we just used postMessage to break up our work so + * that the event loop still got a chance to run between our folder openings. + * The use of any delay between processing folders is to try and avoid causing + * system-wide interactivity problems from dominating the system's available + * disk seeks to such an extent that other applications start experiencing + * non-trivial I/O waits. + * + * The specific choice of delay remains an arbitrary one to maintain app + * and system responsiveness per the above while also processing as many + * folders as quickly as possible. + * + * This is exposed primarily to allow unit tests to set this to 0 to minimize + * throttling. + */ + INTER_FOLDER_PROCESSING_DELAY_MS: 10, + + /** + * Set a string property on a folder and all of its descendents, taking care + * to avoid locking up the main thread and to avoid leaving folder databases + * open. To avoid locking up the main thread we operate in an asynchronous + * fashion; we invoke a callback when we have completed our work. + * + * Using this function will write the value into the folder cache + * as well as the folder itself. Hopefully you want this; if + * you do not, keep in mind that the only way to avoid that is to retrieve + * the nsIMsgDatabase and then the nsIDbFolderInfo. You would want to avoid + * that as much as possible because once those are exposed to you, XPConnect + * is going to hold onto them creating a situation where you are going to be + * in severe danger of extreme memory bloat unless you force garbage + * collections after every time you close a database. + * + * @param {nsIMsgFolder} folder - The parent folder; we take action on it and all + * of its descendents. + * @param {Function} action - the function to call on each folder. + */ + async takeActionOnFolderAndDescendents(folder, action) { + // We need to add the base folder as it is not included by .descendants. + let allFolders = [folder, ...folder.descendants]; + + // - worker function + function* folderWorker() { + for (let folder of allFolders) { + action(folder); + yield undefined; + } + } + let worker = folderWorker(); + + return new Promise((resolve, reject) => { + // - driver logic + let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + function folderDriver() { + try { + if (worker.next().done) { + timer.cancel(); + resolve(); + } + } catch (ex) { + // Any type of exception kills the generator. + timer.cancel(); + reject(ex); + } + } + // make sure there is at least 100 ms of not us between doing things. + timer.initWithCallback( + folderDriver, + this.INTER_FOLDER_PROCESSING_DELAY_MS, + Ci.nsITimer.TYPE_REPEATING_SLACK + ); + }); + }, + + /** + * Get the identity that most likely is the best one to use, given the hint. + * + * @param {nsIMsgIdentity[]} identities - The candidates to pick from. + * @param {string} [optionalHint] - String containing comma separated mailboxes. + * @param {boolean} useDefault - If true, use the default identity of the + * account as last choice. This is useful when all default account as last + * choice. This is useful when all identities are passed in. Otherwise, use + * the first entity in the list. + * @returns {Array} - An array of two elements, [identity, matchingHint]. + * identity is an nsIMsgIdentity and matchingHint is a string. + */ + getBestIdentity(identities, optionalHint, useDefault = false) { + let identityCount = identities.length; + if (identityCount < 1) { + return [null, null]; + } + + // If we have a hint to help us pick one identity, search for a match. + // Even if we only have one identity, check which hint might match. + if (optionalHint) { + let hints = + lazy.MailServices.headerParser.makeFromDisplayAddress(optionalHint); + + for (let hint of hints) { + for (let identity of identities.filter(i => i.email)) { + if (hint.email.toLowerCase() == identity.email.toLowerCase()) { + return [identity, hint]; + } + } + } + + // Lets search again, this time for a match from catchAll. + for (let hint of hints) { + for (let identity of identities.filter( + i => i.email && i.catchAll && i.catchAllHint + )) { + for (let caHint of identity.catchAllHint.toLowerCase().split(",")) { + // If the hint started with *@, it applies to the whole domain. In + // this case return the hint so it can be used for replying. + // If the hint was for a more specific hint, don't return a hint + // so that the normal from address for the identity is used. + let wholeDomain = caHint.trim().startsWith("*@"); + caHint = caHint.trim().replace(/^\*/, ""); // Remove initial star. + if (hint.email.toLowerCase().includes(caHint)) { + return wholeDomain ? [identity, hint] : [identity, null]; + } + } + } + } + } + + // Still no matches? Give up and pick the default or the first one. + if (useDefault) { + let defaultAccount = lazy.MailServices.accounts.defaultAccount; + if (defaultAccount && defaultAccount.defaultIdentity) { + return [defaultAccount.defaultIdentity, null]; + } + } + + return [identities[0], null]; + }, + + getIdentityForServer(server, optionalHint) { + let identities = lazy.MailServices.accounts.getIdentitiesForServer(server); + return this.getBestIdentity(identities, optionalHint); + }, + + /** + * Get the identity for the given header. + * + * @param {nsIMsgHdr} hdr - Message header. + * @param {nsIMsgCompType} type - Compose type the identity is used for. + * @returns {Array} - An array of two elements, [identity, matchingHint]. + * identity is an nsIMsgIdentity and matchingHint is a string. + */ + getIdentityForHeader(hdr, type, hint = "") { + let server = null; + let identity = null; + let matchingHint = null; + let folder = hdr.folder; + if (folder) { + server = folder.server; + identity = folder.customIdentity; + if (identity) { + return [identity, null]; + } + } + + if (!server) { + let accountKey = hdr.accountKey; + if (accountKey) { + let account = lazy.MailServices.accounts.getAccount(accountKey); + if (account) { + server = account.incomingServer; + } + } + } + + let hintForIdentity = ""; + if (type == Ci.nsIMsgCompType.ReplyToList) { + hintForIdentity = hint; + } else if ( + type == Ci.nsIMsgCompType.Template || + type == Ci.nsIMsgCompType.EditTemplate || + type == Ci.nsIMsgCompType.EditAsNew + ) { + hintForIdentity = hdr.author; + } else { + hintForIdentity = hdr.recipients + "," + hdr.ccList + "," + hint; + } + + if (server) { + [identity, matchingHint] = this.getIdentityForServer( + server, + hintForIdentity + ); + } + + if (!identity) { + [identity, matchingHint] = this.getBestIdentity( + lazy.MailServices.accounts.allIdentities, + hintForIdentity, + true + ); + } + return [identity, matchingHint]; + }, + + getInboxFolder(server) { + try { + var rootMsgFolder = server.rootMsgFolder; + + // Now find the Inbox. + return rootMsgFolder.getFolderWithFlags(Ci.nsMsgFolderFlags.Inbox); + } catch (ex) { + dump(ex + "\n"); + } + return null; + }, + + /** + * Finds a mailing list anywhere in the address books. + * + * @param {string} entryName - Value against which dirName is checked. + * @returns {nsIAbDirectory|null} - Found list or null. + */ + findListInAddressBooks(entryName) { + for (let abDir of lazy.MailServices.ab.directories) { + if (abDir.supportsMailingLists) { + for (let dir of abDir.childNodes) { + if (dir.isMailList && dir.dirName == entryName) { + return dir; + } + } + } + } + return null; + }, + + /** + * Recursively search for message id in a given folder and its subfolders, + * return the first one found. + * + * @param {string} msgId - The message id to find. + * @param {nsIMsgFolder} folder - The folder to check. + * @returns {nsIMsgDBHdr} + */ + findMsgIdInFolder(msgId, folder) { + let msgHdr; + + // Search in folder. + if (!folder.isServer) { + try { + msgHdr = folder.msgDatabase.getMsgHdrForMessageID(msgId); + if (msgHdr) { + return msgHdr; + } + folder.closeDBIfFolderNotOpen(true); + } catch (ex) { + console.error(`Database for ${folder.name} not accessible`); + } + } + + // Search subfolders recursively. + for (let currentFolder of folder.subFolders) { + msgHdr = this.findMsgIdInFolder(msgId, currentFolder); + if (msgHdr) { + return msgHdr; + } + } + return null; + }, + + /** + * Recursively search for message id in all msg folders, return the first one + * found. + * + * @param {string} msgId - The message id to search for. + * @param {nsIMsgIncomingServer} [startServer] - The server to check first. + * @returns {nsIMsgDBHdr} + */ + getMsgHdrForMsgId(msgId, startServer) { + let allServers = lazy.MailServices.accounts.allServers; + if (startServer) { + allServers = [startServer].concat( + allServers.filter(s => s.key != startServer.key) + ); + } + for (let server of allServers) { + if (server && server.canSearchMessages && !server.isDeferredTo) { + let msgHdr = this.findMsgIdInFolder(msgId, server.rootFolder); + if (msgHdr) { + return msgHdr; + } + } + } + return null; + }, +}; + +/** + * A class that listens to notifications about folders, and deals with them + * appropriately. + * @implements {nsIObserver} + */ +class FolderNotificationManager { + QueryInterface = ChromeUtils.generateQI(["nsIObserver"]); + + static #manager = null; + + static init() { + if (FolderNotificationManager.#manager) { + return; + } + FolderNotificationManager.#manager = new FolderNotificationManager(); + } + + constructor() { + Services.obs.addObserver(this, "profile-before-change"); + Services.obs.addObserver(this, "folder-attention"); + } + + observe(subject, topic, data) { + switch (topic) { + case "profile-before-change": + Services.obs.removeObserver(this, "profile-before-change"); + Services.obs.removeObserver(this, "folder-attention"); + return; + case "folder-attention": + MailUtils.displayFolderIn3Pane( + subject.QueryInterface(Ci.nsIMsgFolder).URI + ); + } + } +} +FolderNotificationManager.init(); diff --git a/comm/mail/modules/MailViewManager.jsm b/comm/mail/modules/MailViewManager.jsm new file mode 100644 index 0000000000..f81221e609 --- /dev/null +++ b/comm/mail/modules/MailViewManager.jsm @@ -0,0 +1,169 @@ +/* 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/. */ + +const EXPORTED_SYMBOLS = ["MailViewManager", "MailViewConstants"]; + +/** + * Put the MailViewConstants in an object so we can export them to + * msgViewPickerOverlay in one blob without contaminating everyone's address + * space who might want to import us. + */ +var MailViewConstants = { + // tag views have kViewTagMarker + their key as value + kViewItemAll: 0, + kViewItemUnread: 1, + kViewItemTags: 2, // former labels used values 2-6 + kViewItemNotDeleted: 3, + // not a real view! a sentinel value to pop up a dialog + kViewItemVirtual: 7, + // not a real view! a sentinel value to pop up a dialog + kViewItemCustomize: 8, + kViewItemFirstCustom: 9, + + kViewCurrent: "current-view", + kViewCurrentTag: "current-view-tag", + kViewTagMarker: ":", +}; + +/** + * MailViews are view 'filters' implemented using search terms. DBViewWrapper + * uses the SearchSpec class to combine the search terms of the mailview with + * those of the virtual folder (if applicable) and the quicksearch (if + * applicable). + */ +var MailViewManager = { + _views: {}, + _customMailViews: Cc["@mozilla.org/messenger/mailviewlist;1"].getService( + Ci.nsIMsgMailViewList + ), + + /** + * Define one of the built-in mail-views. If you want to define your own + * view, you need to define a custom view using nsIMsgMailViewList. + * + * We define our own little view definition abstraction because some day this + * functionality may want to be generalized to be usable by gloda as well. + * + * @param aViewDef The view definition, three attributes are required: + * - name: A string name for the view, for debugging purposes only. This + * should not be localized! + * - index: The index to assign to the view. + * - makeTerms: A function to invoke that returns a list of search terms. + */ + defineView(aViewDef) { + this._views[aViewDef.index] = aViewDef; + }, + + /** + * Wrap a custom view into our cute little view abstraction. We do not cache + * these because views should not change often enough for it to matter from + * a performance perspective, but they will change enough to make stale + * caches a potential issue. + */ + _wrapCustomView(aCustomViewIndex) { + let mailView = this._customMailViews.getMailViewAt(aCustomViewIndex); + return { + name: mailView.prettyName, // since the user created it it's localized + index: aCustomViewIndex, + makeTerms(aSession, aData) { + return mailView.searchTerms; + }, + }; + }, + + _findCustomViewByName(aName) { + let count = this._customMailViews.mailViewCount; + for (let i = 0; i < count; i++) { + let mailView = this._customMailViews.getMailViewAt(i); + if (mailView.mailViewName == aName) { + return this._wrapCustomView(i); + } + } + throw new Error("No custom view with name: " + aName); + }, + + /** + * Return the view definition associated with the given view index. + * + * @param aViewIndex If the value is an integer it references the built-in + * view with the view index from MailViewConstants, or if the index + * is >= MailViewConstants.kViewItemFirstCustom, it is a reference to + * a custom view definition. If the value is a string, it is the name + * of a custom view. The string case is mainly intended for testing + * purposes. + */ + getMailViewByIndex(aViewIndex) { + if (typeof aViewIndex == "string") { + return this._findCustomViewByName(aViewIndex); + } + if (aViewIndex < MailViewConstants.kViewItemFirstCustom) { + return this._views[aViewIndex]; + } + return this._wrapCustomView( + aViewIndex - MailViewConstants.kViewItemFirstCustom + ); + }, +}; + +MailViewManager.defineView({ + name: "all mail", // debugging assistance only! not localized! + index: MailViewConstants.kViewItemAll, + makeTerms(aSession, aData) { + return null; + }, +}); + +MailViewManager.defineView({ + name: "new mail / unread", // debugging assistance only! not localized! + index: MailViewConstants.kViewItemUnread, + makeTerms(aSession, aData) { + let term = aSession.createTerm(); + let value = term.value; + + value.status = Ci.nsMsgMessageFlags.Read; + value.attrib = Ci.nsMsgSearchAttrib.MsgStatus; + term.value = value; + term.attrib = Ci.nsMsgSearchAttrib.MsgStatus; + term.op = Ci.nsMsgSearchOp.Isnt; + term.booleanAnd = true; + + return [term]; + }, +}); + +MailViewManager.defineView({ + name: "tags", // debugging assistance only! not localized! + index: MailViewConstants.kViewItemTags, + makeTerms(aSession, aKeyword) { + let term = aSession.createTerm(); + let value = term.value; + + value.str = aKeyword; + value.attrib = Ci.nsMsgSearchAttrib.Keywords; + term.value = value; + term.attrib = Ci.nsMsgSearchAttrib.Keywords; + term.op = Ci.nsMsgSearchOp.Contains; + term.booleanAnd = true; + + return [term]; + }, +}); + +MailViewManager.defineView({ + name: "not deleted", // debugging assistance only! not localized! + index: MailViewConstants.kViewItemNotDeleted, + makeTerms(aSession, aKeyword) { + let term = aSession.createTerm(); + let value = term.value; + + value.status = Ci.nsMsgMessageFlags.IMAPDeleted; + value.attrib = Ci.nsMsgSearchAttrib.MsgStatus; + term.value = value; + term.attrib = Ci.nsMsgSearchAttrib.MsgStatus; + term.op = Ci.nsMsgSearchOp.Isnt; + term.booleanAnd = true; + + return [term]; + }, +}); diff --git a/comm/mail/modules/MessageArchiver.jsm b/comm/mail/modules/MessageArchiver.jsm new file mode 100644 index 0000000000..bf13a17295 --- /dev/null +++ b/comm/mail/modules/MessageArchiver.jsm @@ -0,0 +1,392 @@ +/* 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/. */ + +const EXPORTED_SYMBOLS = ["MessageArchiver"]; + +var { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" +); +const lazy = {}; +ChromeUtils.defineModuleGetter( + lazy, + "MailUtils", + "resource:///modules/MailUtils.jsm" +); + +function MessageArchiver() { + this._batches = {}; + this._currentKey = null; + this._dstFolderParent = null; + this._dstFolderName = null; + + this.msgWindow = null; + this.oncomplete = null; +} + +/** + * The maximum number of messages to try to examine directly to determine if + * they can be archived; if we exceed this count, we'll try to approximate + * the answer by looking at the server's identities. This is only here to + * let tests tweak the value. + */ +MessageArchiver.MAX_COUNT_FOR_CAN_ARCHIVE_CHECK = 100; +MessageArchiver.canArchive = function (messages, isSingleFolder) { + if (messages.length == 0) { + return false; + } + + // If we're looking at a single folder (i.e. not a cross-folder search), we + // can just check to see if all the identities for this folder/server have + // archives enabled (or disabled). This is way faster than checking every + // message. Note: this may be slightly inaccurate if the identity for a + // header is actually on another server. + if ( + messages.length > MessageArchiver.MAX_COUNT_FOR_CAN_ARCHIVE_CHECK && + isSingleFolder + ) { + let folder = messages[0].folder; + let folderIdentity = folder.customIdentity; + if (folderIdentity) { + return folderIdentity.archiveEnabled; + } + + if (folder.server) { + let serverIdentities = MailServices.accounts.getIdentitiesForServer( + folder.server + ); + + // Do all identities have the same archiveEnabled setting? + if (serverIdentities.every(id => id.archiveEnabled)) { + return true; + } + if (serverIdentities.every(id => !id.archiveEnabled)) { + return false; + } + // If we get here it's a mixture, so have to examine all the messages. + } + } + + // Either we've selected a small number of messages or we just can't + // fast-path the result; examine all the messages. + return messages.every(function (msg) { + let [identity] = lazy.MailUtils.getIdentityForHeader(msg); + return Boolean(identity && identity.archiveEnabled); + }); +}; + +// Bad things happen if you have multiple archivers running on the same +// messages (See Bug 1705824). We could probably make this more fine +// grained, and maintain a list of messages/folders already queued up... +// but that'd get complex quick, so let's keep things simple for now and +// only allow one active archiver. +let gIsArchiving = false; + +MessageArchiver.prototype = { + archiveMessages(aMsgHdrs) { + if (!aMsgHdrs.length) { + return; + } + if (gIsArchiving) { + throw new Error("Can only have one MessageArchiver running at once"); + } + gIsArchiving = true; + + for (let i = 0; i < aMsgHdrs.length; i++) { + let msgHdr = aMsgHdrs[i]; + + let server = msgHdr.folder.server; + + // Convert date to JS date object. + let msgDate = new Date(msgHdr.date / 1000); + let msgYear = msgDate.getFullYear().toString(); + let monthFolderName = + msgYear + "-" + (msgDate.getMonth() + 1).toString().padStart(2, "0"); + + let archiveFolderURI; + let archiveGranularity; + let archiveKeepFolderStructure; + + let [identity] = lazy.MailUtils.getIdentityForHeader(msgHdr); + if (!identity || msgHdr.folder.server.type == "rss") { + // If no identity, or a server (RSS) which doesn't have an identity + // and doesn't want the default unrelated identity value, figure + // this out based on the default identity prefs. + let enabled = Services.prefs.getBoolPref( + "mail.identity.default.archive_enabled" + ); + if (!enabled) { + continue; + } + + archiveFolderURI = server.serverURI + "/Archives"; + archiveGranularity = Services.prefs.getIntPref( + "mail.identity.default.archive_granularity" + ); + archiveKeepFolderStructure = Services.prefs.getBoolPref( + "mail.identity.default.archive_keep_folder_structure" + ); + } else { + if (!identity.archiveEnabled) { + continue; + } + + archiveFolderURI = identity.archiveFolder; + archiveGranularity = identity.archiveGranularity; + archiveKeepFolderStructure = identity.archiveKeepFolderStructure; + } + + let copyBatchKey = msgHdr.folder.URI; + if (archiveGranularity >= Ci.nsIMsgIdentity.perYearArchiveFolders) { + copyBatchKey += "\0" + msgYear; + } + + if (archiveGranularity >= Ci.nsIMsgIdentity.perMonthArchiveFolders) { + copyBatchKey += "\0" + monthFolderName; + } + + if (archiveKeepFolderStructure) { + copyBatchKey += msgHdr.folder.URI; + } + + // Add a key to copyBatchKey + if (!(copyBatchKey in this._batches)) { + this._batches[copyBatchKey] = { + srcFolder: msgHdr.folder, + archiveFolderURI, + granularity: archiveGranularity, + keepFolderStructure: archiveKeepFolderStructure, + yearFolderName: msgYear, + monthFolderName, + messages: [], + }; + } + this._batches[copyBatchKey].messages.push(msgHdr); + } + MailServices.mfn.addListener(this, MailServices.mfn.folderAdded); + + // Now we launch the code iterating over all message copies, one in turn. + this.processNextBatch(); + }, + + processNextBatch() { + // get the first defined key and value + for (let key in this._batches) { + this._currentBatch = this._batches[key]; + delete this._batches[key]; + this.filterBatch(); + return; + } + // All done! + this._batches = null; + MailServices.mfn.removeListener(this); + + if (typeof this.oncomplete == "function") { + this.oncomplete(); + } + gIsArchiving = false; + }, + + filterBatch() { + let batch = this._currentBatch; + // Apply filters to this batch. + MailServices.filters.applyFilters( + Ci.nsMsgFilterType.Archive, + batch.messages, + batch.srcFolder, + this.msgWindow, + this + ); + // continues with onStopOperation + }, + + onStopOperation(aResult) { + if (!Components.isSuccessCode(aResult)) { + console.error("Archive filter failed: " + aResult); + // We don't want to effectively disable archiving because a filter + // failed, so we'll continue after reporting the error. + } + // Now do the default archive processing + this.continueBatch(); + }, + + // continue processing of default archive operations + continueBatch() { + let batch = this._currentBatch; + let srcFolder = batch.srcFolder; + let archiveFolderURI = batch.archiveFolderURI; + let archiveFolder = lazy.MailUtils.getOrCreateFolder(archiveFolderURI); + let dstFolder = archiveFolder; + + let moveArray = []; + // Don't move any items that the filter moves or deleted + for (let item of batch.messages) { + if ( + srcFolder.msgDatabase.containsKey(item.messageKey) && + !( + srcFolder.getProcessingFlags(item.messageKey) & + Ci.nsMsgProcessingFlags.FilterToMove + ) + ) { + moveArray.push(item); + } + } + + if (moveArray.length == 0) { + // Continue processing. + this.processNextBatch(); + } + + // For folders on some servers (e.g. IMAP), we need to create the + // sub-folders asynchronously, so we chain the urls using the listener + // called back from createStorageIfMissing. For local, + // createStorageIfMissing is synchronous. + let isAsync = archiveFolder.server.protocolInfo.foldersCreatedAsync; + if (!archiveFolder.parent) { + archiveFolder.setFlag(Ci.nsMsgFolderFlags.Archive); + archiveFolder.createStorageIfMissing(this); + if (isAsync) { + // Continues with OnStopRunningUrl. + return; + } + } + + let granularity = batch.granularity; + let forceSingle = !archiveFolder.canCreateSubfolders; + if ( + !forceSingle && + archiveFolder.server instanceof Ci.nsIImapIncomingServer + ) { + forceSingle = archiveFolder.server.isGMailServer; + } + if (forceSingle) { + granularity = Ci.nsIMsgIncomingServer.singleArchiveFolder; + } + + if (granularity >= Ci.nsIMsgIdentity.perYearArchiveFolders) { + archiveFolderURI += "/" + batch.yearFolderName; + dstFolder = lazy.MailUtils.getOrCreateFolder(archiveFolderURI); + if (!dstFolder.parent) { + dstFolder.createStorageIfMissing(this); + if (isAsync) { + // Continues with OnStopRunningUrl. + return; + } + } + } + if (granularity >= Ci.nsIMsgIdentity.perMonthArchiveFolders) { + archiveFolderURI += "/" + batch.monthFolderName; + dstFolder = lazy.MailUtils.getOrCreateFolder(archiveFolderURI); + if (!dstFolder.parent) { + dstFolder.createStorageIfMissing(this); + if (isAsync) { + // Continues with OnStopRunningUrl. + return; + } + } + } + + // Create the folder structure in Archives. + // For imap folders, we need to create the sub-folders asynchronously, + // so we chain the actions using the listener called back from + // createSubfolder. For local, createSubfolder is synchronous. + if (archiveFolder.canCreateSubfolders && batch.keepFolderStructure) { + // Collect in-order list of folders of source folder structure, + // excluding top-level INBOX folder + let folderNames = []; + let rootFolder = srcFolder.server.rootFolder; + let inboxFolder = lazy.MailUtils.getInboxFolder(srcFolder.server); + let folder = srcFolder; + while (folder != rootFolder && folder != inboxFolder) { + folderNames.unshift(folder.name); + folder = folder.parent; + } + // Determine Archive folder structure. + for (let i = 0; i < folderNames.length; ++i) { + let folderName = folderNames[i]; + if (!dstFolder.containsChildNamed(folderName)) { + // Create Archive sub-folder (IMAP: async). + if (isAsync) { + this._dstFolderParent = dstFolder; + this._dstFolderName = folderName; + } + dstFolder.createSubfolder(folderName, this.msgWindow); + if (isAsync) { + // Continues with folderAdded. + return; + } + } + dstFolder = dstFolder.getChildNamed(folderName); + } + } + + if (dstFolder != srcFolder) { + let isNews = srcFolder.flags & Ci.nsMsgFolderFlags.Newsgroup; + // If the source folder doesn't support deleting messages, we + // make archive a copy, not a move. + MailServices.copy.copyMessages( + srcFolder, + moveArray, + dstFolder, + srcFolder.canDeleteMessages && !isNews, + this, + this.msgWindow, + true + ); + return; // continues with OnStopCopy + } + this.processNextBatch(); // next batch + }, + + // @implements {nsIUrlListener} + OnStartRunningUrl(url) {}, + OnStopRunningUrl(url, exitCode) { + // this will always be a create folder url, afaik. + if (Components.isSuccessCode(exitCode)) { + this.continueBatch(); + } else { + console.error("Archive failed to create folder: " + exitCode); + this._batches = null; + this.processNextBatch(); // for cleanup and exit + } + }, + + // also implements nsIMsgCopyServiceListener, but we only care + // about the OnStopCopy + // @implements {nsIMsgCopyServiceListener} + OnStartCopy() {}, + OnProgress(aProgress, aProgressMax) {}, + SetMessageKey(aKey) {}, + GetMessageId() {}, + OnStopCopy(aStatus) { + if (Components.isSuccessCode(aStatus)) { + this.processNextBatch(); + } else { + // stop on error + console.error("Archive failed to copy: " + aStatus); + this._batches = null; + this.processNextBatch(); // for cleanup and exit + } + }, + + // This also implements nsIMsgFolderListener, but we only care about the + // folderAdded (createSubfolder callback). + // @implements {nsIMsgFolderListener} + folderAdded(aFolder) { + // Check that this is the folder we're interested in. + if ( + aFolder.parent == this._dstFolderParent && + aFolder.name == this._dstFolderName + ) { + this._dstFolderParent = null; + this._dstFolderName = null; + this.continueBatch(); + } + }, + + QueryInterface: ChromeUtils.generateQI([ + "nsIUrlListener", + "nsIMsgCopyServiceListener", + "nsIMsgOperationListener", + ]), +}; diff --git a/comm/mail/modules/MsgHdrSyntheticView.jsm b/comm/mail/modules/MsgHdrSyntheticView.jsm new file mode 100644 index 0000000000..0219e13a1c --- /dev/null +++ b/comm/mail/modules/MsgHdrSyntheticView.jsm @@ -0,0 +1,67 @@ +/* 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/. */ + +/* + * This object provides you a way to have a synthetic nsIMsgDBView for a single + * message header. + */ + +var EXPORTED_SYMBOLS = ["MsgHdrSyntheticView"]; + +/** + * Create a synthetic view suitable for passing to |FolderDisplayWidget.show|. + * You must pass a single message header in. + * + * @param aMsgHdr The message header to create the synthetic view for. + */ +function MsgHdrSyntheticView(aMsgHdr) { + this.msgHdr = aMsgHdr; + + this.customColumns = []; +} + +MsgHdrSyntheticView.prototype = { + defaultSort: [ + [Ci.nsMsgViewSortType.byDate, Ci.nsMsgViewSortOrder.descending], + ], + + /** + * Request the search be performed and notifications provided to + * aSearchListener. Since we already have the result with us, this is + * synchronous. + */ + search(aSearchListener, aCompletionCallback) { + this.searchListener = aSearchListener; + this.completionCallback = aCompletionCallback; + aSearchListener.onNewSearch(); + aSearchListener.onSearchHit(this.msgHdr, this.msgHdr.folder); + // we're not really aborting, but it closes things out nicely + this.abortSearch(); + }, + + /** + * Aborts or completes the search -- we do not make a distinction. + */ + abortSearch() { + if (this.searchListener) { + this.searchListener.onSearchDone(Cr.NS_OK); + } + if (this.completionCallback) { + this.completionCallback(); + } + this.searchListener = null; + this.completionCallback = null; + }, + + /** + * Helper function used by |DBViewWrapper.getMsgHdrForMessageID|. + */ + getMsgHdrForMessageID(aMessageId) { + if (this.msgHdr.messageId == aMessageId) { + return this.msgHdr; + } + + return null; + }, +}; diff --git a/comm/mail/modules/PhishingDetector.jsm b/comm/mail/modules/PhishingDetector.jsm new file mode 100644 index 0000000000..016530fd96 --- /dev/null +++ b/comm/mail/modules/PhishingDetector.jsm @@ -0,0 +1,335 @@ +/* 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/. */ + +const EXPORTED_SYMBOLS = ["PhishingDetector"]; + +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +const lazy = {}; + +XPCOMUtils.defineLazyModuleGetters(lazy, { + isLegalIPAddress: "resource:///modules/hostnameUtils.jsm", + isLegalLocalIPAddress: "resource:///modules/hostnameUtils.jsm", +}); + +const PhishingDetector = new (class PhishingDetector { + mEnabled = true; + mCheckForIPAddresses = true; + mCheckForMismatchedHosts = true; + mDisallowFormActions = true; + + constructor() { + XPCOMUtils.defineLazyPreferenceGetter( + this, + "mEnabled", + "mail.phishing.detection.enabled", + true + ); + XPCOMUtils.defineLazyPreferenceGetter( + this, + "mCheckForIPAddresses", + "mail.phishing.detection.ipaddresses", + true + ); + XPCOMUtils.defineLazyPreferenceGetter( + this, + "mCheckForMismatchedHosts", + "mail.phishing.detection.mismatched_hosts", + true + ); + XPCOMUtils.defineLazyPreferenceGetter( + this, + "mDisallowFormActions", + "mail.phishing.detection.disallow_form_actions", + true + ); + } + + /** + * Analyze the currently loaded message in the message pane, looking for signs + * of a phishing attempt. Also checks for forms with action URLs, which are + * disallowed. + * Assumes the message has finished loading in the message pane (i.e. + * OnMsgParsed has fired). + * + * @param {nsIMsgMailNewsUrl} aUrl + * Url for the message being analyzed. + * @param {Element} browser + * The browser element where the message is loaded. + * @returns {boolean} + * Returns true if this does have phishing urls. Returns false if we + * do not check this message or the phishing message does not need to be + * displayed. + */ + analyzeMsgForPhishingURLs(aUrl, browser) { + if (!aUrl || !this.mEnabled) { + return false; + } + + try { + // nsIMsgMailNewsUrl.folder can throw an NS_ERROR_FAILURE, especially if + // we are opening an .eml file. + var folder = aUrl.folder; + + // Ignore nntp and RSS messages. + if ( + !folder || + folder.server.type == "nntp" || + folder.server.type == "rss" + ) { + return false; + } + + // Also ignore messages in Sent/Drafts/Templates/Outbox. + let outgoingFlags = + Ci.nsMsgFolderFlags.SentMail | + Ci.nsMsgFolderFlags.Drafts | + Ci.nsMsgFolderFlags.Templates | + Ci.nsMsgFolderFlags.Queue; + if (folder.isSpecialFolder(outgoingFlags, true)) { + return false; + } + } catch (ex) { + if (ex.result != Cr.NS_ERROR_FAILURE) { + throw ex; + } + } + + // If the message contains forms with action attributes, warn the user. + let formNodes = browser.contentDocument.querySelectorAll("form[action]"); + + return this.mDisallowFormActions && formNodes.length > 0; + } + + /** + * Analyze the url contained in aLinkNode for phishing attacks. + * + * @param {string} aHref - the url to be analyzed + * @param {string} [aLinkText] - user visible link text associated with aHref + * in case we are dealing with a link node. + * @returns true if link node contains phishing URL. false otherwise. + */ + #analyzeUrl(aUrl, aLinkText) { + if (!aUrl) { + return false; + } + + let hrefURL; + // make sure relative link urls don't make us bail out + try { + hrefURL = Services.io.newURI(aUrl); + } catch (ex) { + return false; + } + + // only check for phishing urls if the url is an http or https link. + // this prevents us from flagging imap and other internally handled urls + if (hrefURL.schemeIs("http") || hrefURL.schemeIs("https")) { + // The link is not suspicious if the visible text is the same as the URL, + // even if the URL is an IP address. URLs are commonly surrounded by + // < > or "" (RFC2396E) - so strip those from the link text before comparing. + if (aLinkText) { + aLinkText = aLinkText.replace(/^<(.+)>$|^"(.+)"$/, "$1$2"); + } + + var failsStaticTests = false; + // If the link text and url differs by something other than a trailing + // slash, do some further checks. + if ( + aLinkText && + aLinkText != aUrl && + aLinkText.replace(/\/+$/, "") != aUrl.replace(/\/+$/, "") + ) { + if (this.mCheckForIPAddresses) { + let unobscuredHostNameValue = lazy.isLegalIPAddress( + hrefURL.host, + true + ); + if (unobscuredHostNameValue) { + failsStaticTests = !lazy.isLegalLocalIPAddress( + unobscuredHostNameValue + ); + } + } + + if (!failsStaticTests && this.mCheckForMismatchedHosts) { + failsStaticTests = + aLinkText && this.misMatchedHostWithLinkText(hrefURL, aLinkText); + } + } + // We don't use dynamic checks anymore. The old implementation was removed + // in bug bug 1085382. Using the toolkit safebrowsing is bug 778611. + // + // Because these static link checks tend to cause false positives + // we delay showing the warning until a user tries to click the link. + if (failsStaticTests) { + return true; + } + } + + return false; + } + + /** + * Opens the default browser to a page where the user can submit the given url + * as a phish. + * + * @param aPhishingURL the url we want to report back as a phishing attack + */ + reportPhishingURL(aPhishingURL) { + let reportUrl = Services.urlFormatter.formatURLPref( + "browser.safebrowsing.reportPhishURL" + ); + reportUrl += "&url=" + encodeURIComponent(aPhishingURL); + + let uri = Services.io.newURI(reportUrl); + let protocolSvc = Cc[ + "@mozilla.org/uriloader/external-protocol-service;1" + ].getService(Ci.nsIExternalProtocolService); + protocolSvc.loadURI(uri); + } + + /** + * Private helper method to determine if the link node contains a user visible + * url with a host name that differs from the actual href the user would get + * taken to. + * i.e. <a href="http://myevilsite.com">http://mozilla.org</a> + * + * @returns true if aHrefURL.host does NOT match the host of the link node text + */ + misMatchedHostWithLinkText(aHrefURL, aLinkNodeText) { + // gatherTextUnder puts a space between each piece of text it gathers, + // so strip the spaces out (see bug 326082 for details). + aLinkNodeText = aLinkNodeText.replace(/ /g, ""); + + // Only worry about http: and https: urls. + if (/^https?:/.test(aLinkNodeText)) { + let linkTextURI = Services.io.newURI(aLinkNodeText); + + // Compare the base domain of the href and the link text. + try { + return ( + Services.eTLD.getBaseDomain(aHrefURL) != + Services.eTLD.getBaseDomain(linkTextURI) + ); + } catch (e) { + // If we throw above, one of the URIs probably has no TLD (e.g. + // http://localhost), so just check the entire host. + return aHrefURL.host != linkTextURI.host; + } + } + + return false; + } + + /** + * If the current message has been identified as an email scam, prompts the + * user with a warning before allowing the link click to be processed. + * The warning prompt includes the unobscured host name of the http(s) url the + * user clicked on. + * + * @param {DOMWindow} win + * The window the message is being displayed within. + * @param {string} aUrl + * The url of the message + * @param {string} [aLinkText] + * User visible link text associated with the link + * @returns {number} + * 0 if the URL implied by aLinkText should be used instead. + * 1 if the request should be blocked. + * 2 if aUrl should be allowed to load. + */ + warnOnSuspiciousLinkClick(win, aUrl, aLinkText) { + if (!this.#analyzeUrl(aUrl, aLinkText)) { + return 2; // No problem with the url. Allow it to load. + } + let bundle = Services.strings.createBundle( + "chrome://messenger/locale/messenger.properties" + ); + + // Analysis said there was a problem. + if (aLinkText && /^https?:/i.test(aLinkText)) { + let actualURI = Services.io.newURI(aUrl); + let displayedURI; + try { + displayedURI = Services.io.newURI(aLinkText); + } catch (e) { + return 1; + } + + let titleMsg = bundle.GetStringFromName("linkMismatchTitle"); + let dialogMsg = bundle.formatStringFromName( + "confirmPhishingUrlAlternate", + [displayedURI.host, actualURI.host] + ); + let warningButtons = + Ci.nsIPromptService.BUTTON_POS_0 * + Ci.nsIPromptService.BUTTON_TITLE_IS_STRING + + Ci.nsIPromptService.BUTTON_POS_1 * + Ci.nsIPromptService.BUTTON_TITLE_CANCEL + + Ci.nsIPromptService.BUTTON_POS_2 * + Ci.nsIPromptService.BUTTON_TITLE_IS_STRING; + let button0Text = bundle.formatStringFromName("confirmPhishingGoDirect", [ + displayedURI.host, + ]); + let button2Text = bundle.formatStringFromName("confirmPhishingGoAhead", [ + actualURI.host, + ]); + return Services.prompt.confirmEx( + win, + titleMsg, + dialogMsg, + warningButtons, + button0Text, + "", + button2Text, + "", + {} + ); + } + + let hrefURL; + try { + // make sure relative link urls don't make us bail out + hrefURL = Services.io.newURI(aUrl); + } catch (e) { + return 1; // block the load + } + + // only prompt for http and https urls + if (hrefURL.schemeIs("http") || hrefURL.schemeIs("https")) { + // unobscure the host name in case it's an encoded ip address.. + let unobscuredHostNameValue = + lazy.isLegalIPAddress(hrefURL.host, true) || hrefURL.host; + + let brandBundle = Services.strings.createBundle( + "chrome://branding/locale/brand.properties" + ); + let brandShortName = brandBundle.GetStringFromName("brandShortName"); + let titleMsg = bundle.GetStringFromName("confirmPhishingTitle"); + let dialogMsg = bundle.formatStringFromName("confirmPhishingUrl", [ + brandShortName, + unobscuredHostNameValue, + ]); + let warningButtons = + Ci.nsIPromptService.STD_YES_NO_BUTTONS + + Ci.nsIPromptService.BUTTON_POS_1_DEFAULT; + let button = Services.prompt.confirmEx( + win, + titleMsg, + dialogMsg, + warningButtons, + "", + "", + "", + "", + {} + ); + return button == 0 ? 2 : 1; // 2 == allow, 1 == block + } + return 2; // allow the link to load + } +})(); diff --git a/comm/mail/modules/QuickFilterManager.jsm b/comm/mail/modules/QuickFilterManager.jsm new file mode 100644 index 0000000000..b92a5eeea7 --- /dev/null +++ b/comm/mail/modules/QuickFilterManager.jsm @@ -0,0 +1,1369 @@ +/* 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/. */ + +const EXPORTED_SYMBOLS = [ + "QuickFilterState", + "QuickFilterManager", + "MessageTextFilter", + "QuickFilterSearchListener", +]; + +const { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" +); + +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +// XXX we need to know whether the gloda indexer is enabled for upsell reasons, +// but this should really just be exposed on the main Gloda public interface. +// we need to be able to create gloda message searcher instances for upsells: +const lazy = {}; +XPCOMUtils.defineLazyModuleGetters(lazy, { + GlodaIndexer: "resource:///modules/gloda/GlodaIndexer.jsm", + GlodaMsgSearcher: "resource:///modules/gloda/GlodaMsgSearcher.jsm", + TagUtils: "resource:///modules/TagUtils.jsm", +}); + +/** + * Shallow object copy. + */ +function shallowObjCopy(obj) { + let newObj = {}; + for (let key in obj) { + newObj[key] = obj[key]; + } + return newObj; +} + +/** + * Should the filter be visible when there's no previous state to propagate it + * from? The idea is that when session persistence is working this should only + * ever affect the first time Thunderbird is started up. Although opening + * additional 3-panes will likely trigger this unless we go out of our way to + * implement propagation across those boundaries (and we're not). + */ +var FILTER_VISIBILITY_DEFAULT = true; + +/** + * Represents the state of a quick filter bar. This mainly decorates the + * manipulation of the filter states with support of tracking the filter most + * recently manipulated so we can maintain a very limited undo stack of sorts. + */ +function QuickFilterState(aTemplateState, aJsonedState) { + if (aJsonedState) { + this.filterValues = aJsonedState.filterValues; + this.visible = aJsonedState.visible; + } else if (aTemplateState) { + this.filterValues = QuickFilterManager.propagateValues( + aTemplateState.filterValues + ); + this.visible = aTemplateState.visible; + } else { + this.filterValues = QuickFilterManager.getDefaultValues(); + this.visible = FILTER_VISIBILITY_DEFAULT; + } + this._lastFilterAttr = null; +} +QuickFilterState.prototype = { + /** + * Maps filter names to their current states. We rely on QuickFilterManager + * to do most of the interesting manipulation of this value. + */ + filterValues: null, + /** + * Is the filter bar visible? Always inherited from the template regardless + * of stickyness. + */ + visible: null, + + /** + * Get a filter state and update lastFilterAttr appropriately. This is + * intended for use when the filter state is a rich object whose state + * cannot be updated just by clobbering as provided by |setFilterValue|. + * + * @param aName The name of the filter we are retrieving. + * @param [aNoChange=false] Is this actually a change for the purposes of + * lastFilterAttr purposes? + */ + getFilterValue(aName, aNoChange) { + if (!aNoChange) { + this._lastFilterAttr = aName; + } + return this.filterValues[aName]; + }, + + /** + * Set a filter state and update lastFilterAttr appropriately. + * + * @param aName The name of the filter we are setting. + * @param aValue The value to set; null/undefined implies deletion. + * @param [aNoChange=false] Is this actually a change for the purposes of + * lastFilterAttr purposes? + */ + setFilterValue(aName, aValue, aNoChange) { + if (aValue == null) { + delete this.filterValues[aName]; + return; + } + + this.filterValues[aName] = aValue; + if (!aNoChange) { + this._lastFilterAttr = aName; + } + }, + + /** + * Track the last filter that was affirmatively applied. If you hit escape + * and this value is non-null, we clear the referenced filter constraint. + * If you hit escape and the value is null, we clear all filters. + */ + _lastFilterAttr: null, + + /** + * The user hit escape; based on _lastFilterAttr and whether there are any + * applied filters, change our constraints. First press clears the last + * added constraint (if any), second press (or if no last constraint) clears + * the state entirely. + * + * @returns true if we relaxed the state, false if there was nothing to relax. + */ + userHitEscape() { + if (this._lastFilterAttr) { + // it's possible the UI state the last attribute has already been cleared, + // in which case we want to fall through... + if ( + QuickFilterManager.clearFilterValue( + this._lastFilterAttr, + this.filterValues + ) + ) { + this._lastFilterAttr = null; + return true; + } + } + + return QuickFilterManager.clearAllFilterValues(this.filterValues); + }, + + /** + * Clear the state without going through any undo-ish steps like + * |userHitEscape| tries to do. + */ + clear() { + QuickFilterManager.clearAllFilterValues(this.filterValues); + }, + + /** + * Create the search terms appropriate to the current filter states. + */ + createSearchTerms(aTermCreator) { + return QuickFilterManager.createSearchTerms( + this.filterValues, + aTermCreator + ); + }, + + persistToObj() { + return { + filterValues: this.filterValues, + visible: this.visible, + }; + }, +}; + +/** + * An nsIMsgSearchNotify listener wrapper to facilitate faceting of messages + * being returned by a search. We have to use a listener because the + * nsMsgDBView includes presentation logic and unless we force all of its + * results to be fully expanded (and dummy headers ignored), we can't get + * at all the messages reliably. + * + * We need to provide a wrapper so that: + * - We can provide better error handling support. + * - We can provide better GC support. + * - We can ensure the right life-cycle stuff happens (unregister ourselves as + * a listener, namely.) + * + * It is nice that we have a wrapper so that: + * - We can provide context to the thing we are calling that it does not need + * to maintain. + * + * The listener should implement the following methods: + * + * - function onSearchStart(aCurState) returning aScratch. + * This function should initialize the scratch object that will be passed to + * onSearchMessage and onSearchDone. This is an attempt to provide a + * friendly API that provides debugging support by dumping the state of + * said object when things go wrong. + * + * - function onSearchMessage(aScratch, aMsgHdr, aFolder) + * Processes messages reported as search hits. Its only context is the + * object you returned from onSearchStart. Take the hint and try and keep + * this method efficient! We will catch all exceptions for you and report + * errors. We will also handle forcing GCs as appropriate. + * + * - function onSearchDone(aCurState, aScratch, aSuccess) returning + * [new state for your filter, should call reflectInDOM, should treat the + * state as if it is a result of user action]. + * This ends up looking exactly the same as the postFilterProcess handler + * + * @param aFilterer The QuickFilterState instance. + * @param aListener The thing on which we invoke methods. + */ +function QuickFilterSearchListener( + aViewWrapper, + aFilterer, + aFilterDef, + aListener, + aMuxer +) { + this.filterer = aFilterer; + this.filterDef = aFilterDef; + this.listener = aListener; + this.muxer = aMuxer; + + this.session = aViewWrapper.search.session; + + this.scratch = null; + this.count = 0; + this.started = false; + + this.session.registerListener(this, Ci.nsIMsgSearchSession.allNotifications); +} +QuickFilterSearchListener.prototype = { + onNewSearch() { + this.started = true; + let curState = + this.filterDef.name in this.filterer.filterValues + ? this.filterer.filterValues[this.filterDef.name] + : null; + this.scratch = this.listener.onSearchStart(curState); + }, + + onSearchHit(aMsgHdr, aFolder) { + // GC sanity demands that we trigger a GC if we have seen a large number + // of headers. Because we are driven by the search mechanism which likes + // to time-slice when it has a lot of messages on its plate, it is + // conceivable something else may trigger a GC for us. Unfortunately, + // we can't guarantee it, as XPConnect does not inform memory pressure, + // so it's us to stop-gap it. + this.count++; + if (!(this.count % 4096)) { + Cu.forceGC(); + } + + try { + this.listener.onSearchMessage(this.scratch, aMsgHdr, aFolder); + } catch (ex) { + console.error(ex); + } + }, + + onSearchDone(aStatus) { + // it's possible we will see the tail end of an existing search. ignore. + if (!this.started) { + return; + } + + this.session.unregisterListener(this); + + let curState = + this.filterDef.name in this.filterer.filterValues + ? this.filterer.filterValues[this.filterDef.name] + : null; + let [newState, update, treatAsUserAction] = this.listener.onSearchDone( + curState, + this.scratch, + aStatus + ); + + this.filterer.setFilterValue( + this.filterDef.name, + newState, + !treatAsUserAction + ); + if (update) { + this.muxer.reflectFiltererState(this.filterDef.name); + } + }, +}; + +/** + * Extensible mechanism for defining filters for the quick filter bar. This + * is the spiritual successor to the mailViewManager and quickSearchManager. + * + * The manager includes and requires UI-relevant metadata for use by its + * counterparts in quickFilterBar.js. New filters are expected to contribute + * DOM nodes to the overlay and tell us about them using their id during + * registration. + * + * We support two types of filtery things. + * - Filters via defineFilter. + * - Text filters via defineTextFilter. These always take the filter text as + * a parameter. + * + * If you are an adventurous extension developer and want to add a magic + * text filter that does the whole "from:bob to:jim subject:shoes" what you + * will want to do is register a normal filter and collapse the normal text + * filter text-box. You add your own text box, etc. + */ +var QuickFilterManager = { + /** + * List of filter definitions, potentially prioritized. + */ + filterDefs: [], + /** + * Keys are filter definition names, values are the filter defs. + */ + filterDefsByName: {}, + /** + * The DOM id of the text widget that should get focused when the user hits + * control-f or the equivalent. This is here so it can get clobbered. + */ + textBoxDomId: null, + + /** + * Define a new filter. + * + * Filter states must always be JSON serializable. A state of undefined means + * that we are not persisting any state for your filter. + * + * @param {string} aFilterDef.name The name of your filter. This is the name + * of the attribute we cram your state into the state dictionary as, so + * the key thing is that it doesn't conflict with other id's. + * @param {string} aFilterDef.domId The id of the DOM node that you have + * overlaid into the quick filter bar. + * @param {function(aTermCreator, aTerms, aState)} aFilterDef.appendTerms + * The function to invoke to contribute your terms to the list of + * search terms in aTerms. Your function will not be invoked if you do + * not have any currently persisted state (as is the case if null or + * undefined was set). If you have nothing to add, then don't do + * anything. If you do add terms, the first term you add needs to have + * the booleanAnd flag set to true. You may optionally return a listener + * that complies with the documentation on QuickFilterSearchListener if + * you want to process all of the messages returned by the filter; doing + * so is not cheap, so don't do that lightly. (Tag faceting uses this.) + * @param {function()} [aFilterDef.getDefaults] Function that returns the + * default state for the filter. If the function is not defined or the + * returned value is == undefined/null, no state is set. + * @param {function(aTemplState, aSticky)} [aFilterDef.propagateState] A + * function that takes the state from another QuickFilterState instance + * for this definition and propagates it to a new state which it returns. + * You would use this to keep the 'sticky' bits of state that you want to + * persist between folder changes and when new tabs are opened. The + * aSticky argument tells you if the user wants all the filters still + * applied or not. When false, the idea is you might keep things like + * which text fields to filter on, but not the text to filter. When true, + * you would keep the text to filter on too. Return undefined if you do + * not want any state stored in the new filter state. If you do not + * define this function and aSticky would be true, we will propagate your + * state verbatim; accordingly functions using rich object state must + * implement this method. + * @param {function(aState)} [aFilterDef.clearState] Function to reset the + * the filter's value for the given state, returning a tuple of the new + * state and a boolean flag indicating whether there was actually state to + * clear. This is used when the user decides to reset the state of the + * filter bar or (just one specific filter). If omitted, we just delete + * the filter state entirely, so you only need to define this if you have + * some sticky meta-state you want to maintain. Return undefined for the + * state value if you do not need any state kept around. + * @param {function(aDocument, aMuxer, aNode)} [aFilterDef.domBindExtra] + * Function invoked at initial UI binding of the quick filter bar after + * we add a command listener to whatever is identified by domId. If you + * have additional widgets to hook up, this is where you do it. aDocument + * and aMuxer are provided to assist in this endeavor. Use aMuxer's + * getFilterValueForMutation/setFilterValue/updateSearch methods from any + * event handlers you register. + * @param {function(aState, aNode, aEvent, aDocument)} [aFilterDef.onCommand] + * If omitted, the default handler assumes your widget has a "checked" + * state that should set your state value to true when checked and delete + * the state when unchecked. Implement this function if that is not what + * you need. The function should return a tuple of [new state, should + * update the search] as its result. + * @param {function(aDomNode, aFilterValue, aDoc, aMuxer, aCallId)} + * [aFilterDef.reflectInDOM] + * If omitted, we assume the widget referenced by domId has a checked + * attribute and assign the filter value coerced to a boolean to the + * checked attribute. Otherwise we call your function and it's up to you + * to reflect your state. aDomNode is the node referred to by domId. + * This function will be called when the tab changes, folder changes, or + * if we called postFilterProcess and you returned a value != undefined. + * @param {function(aState, aViewWrapper, aFiltering)} + * [aFilterDef.postFilterProcess] + * Invoked after all of the message headers for the view have been + * displayed, allowing your code to perform some kind of faceting or other + * clever logic. Return a tuple of [new state, should call reflectInDOM, + * should treat as if the user modified the state]. We call this _even + * when there is no filter_ applied. We tell you what's happening via + * aFiltering; true means we have applied some terms, false means not. + * It's vitally important that you do not just facet things willy nilly + * unless there is expected user payoff and they opted in. Our tagging UI + * only facets when the user clicked the tag facet. If you write an + * extension that provides really sweet visualizations or something like + * that and the user installs you knowing what's what, that is also cool, + * we just can't do it in core for now. + */ + defineFilter(aFilterDef) { + this.filterDefs.push(aFilterDef); + this.filterDefsByName[aFilterDef.name] = aFilterDef; + }, + + /** + * Remove a filter from existence by name. This is for extensions to disable + * existing filters and not a dynamic jetpack-like lifecycle. It falls to + * the code calling killFilter to deal with the DOM nodes themselves for now. + * + * @param aName The name of the filter to kill. + */ + killFilter(aName) { + let filterDef = this.filterDefsByName[aName]; + this.filterDefs.splice(this.filterDefs.indexOf(filterDef), 1); + delete this.filterDefsByName[aName]; + }, + + /** + * Propagate values from an existing state into a new state based on + * propagation rules. For use by QuickFilterState. + * + * @param aTemplValues A set of existing filterValues. + * @returns The new filterValues state. + */ + propagateValues(aTemplValues) { + let values = {}; + let sticky = "sticky" in aTemplValues ? aTemplValues.sticky : false; + + for (let filterDef of this.filterDefs) { + if ("propagateState" in filterDef) { + let curValue = + filterDef.name in aTemplValues + ? aTemplValues[filterDef.name] + : undefined; + let newValue = filterDef.propagateState(curValue, sticky); + if (newValue != null) { + values[filterDef.name] = newValue; + } + } else if (sticky) { + // Always propagate the value if sticky and there was no handler. + if (filterDef.name in aTemplValues) { + values[filterDef.name] = aTemplValues[filterDef.name]; + } + } + } + + return values; + }, + /** + * Get the set of default filterValues for the current set of defined filters. + * + * @returns Thew new filterValues state. + */ + getDefaultValues() { + let values = {}; + for (let filterDef of this.filterDefs) { + if ("getDefaults" in filterDef) { + let newValue = filterDef.getDefaults(); + if (newValue != null) { + values[filterDef.name] = newValue; + } + } + } + return values; + }, + + /** + * Reset the state of a single filter given the provided values. + * + * @returns true if we actually cleared some state, false if there was nothing + * to clear. + */ + clearFilterValue(aFilterName, aValues) { + let filterDef = this.filterDefsByName[aFilterName]; + if (!("clearState" in filterDef)) { + if (aFilterName in aValues) { + delete aValues[aFilterName]; + return true; + } + return false; + } + + let curValue = aFilterName in aValues ? aValues[aFilterName] : undefined; + // Yes, we want to call it to clear its state even if it has no state. + let [newValue, didClear] = filterDef.clearState(curValue); + if (newValue != null) { + aValues[aFilterName] = newValue; + } else { + delete aValues[aFilterName]; + } + return didClear; + }, + + /** + * Reset the state of all filters given the provided values. + * + * @returns true if we actually cleared something, false if there was nothing + * to clear. + */ + clearAllFilterValues(aFilterValues) { + let didClearSomething = false; + for (let filterDef of this.filterDefs) { + if (this.clearFilterValue(filterDef.name, aFilterValues)) { + didClearSomething = true; + } + } + return didClearSomething; + }, + + /** + * Populate and return a list of search terms given the provided state. + * + * We only invoke appendTerms on filters that have state in aFilterValues, + * as per the contract. + */ + createSearchTerms(aFilterValues, aTermCreator) { + let searchTerms = [], + listeners = []; + for (let filterName in aFilterValues) { + let filterValue = aFilterValues[filterName]; + let filterDef = this.filterDefsByName[filterName]; + try { + let listener = filterDef.appendTerms( + aTermCreator, + searchTerms, + filterValue + ); + if (listener) { + listeners.push([listener, filterDef]); + } + } catch (ex) { + console.error(ex); + } + } + return searchTerms.length ? [searchTerms, listeners] : [null, listeners]; + }, +}; + +/** + * Meta-filter, just handles whether or not things are sticky. + */ +QuickFilterManager.defineFilter({ + name: "sticky", + domId: "qfb-sticky", + appendTerms(aTermCreator, aTerms, aFilterValue) {}, + /** + * This should not cause an update, otherwise default logic. + */ + onCommand(aState, aNode, aEvent, aDocument) { + let checked = aNode.pressed; + return [checked, false]; + }, +}); + +/** + * true: must be unread, false: must be read. + */ +QuickFilterManager.defineFilter({ + name: "unread", + domId: "qfb-unread", + menuItemID: "quickFilterButtonsContextUnreadToggle", + appendTerms(aTermCreator, aTerms, aFilterValue) { + let term, value; + term = aTermCreator.createTerm(); + term.attrib = Ci.nsMsgSearchAttrib.MsgStatus; + value = term.value; + value.attrib = term.attrib; + value.status = Ci.nsMsgMessageFlags.Read; + term.value = value; + term.op = aFilterValue ? Ci.nsMsgSearchOp.Isnt : Ci.nsMsgSearchOp.Is; + term.booleanAnd = true; + aTerms.push(term); + }, +}); + +/** + * true: must be starred, false: must not be starred. + */ +QuickFilterManager.defineFilter({ + name: "starred", + domId: "qfb-starred", + menuItemID: "quickFilterButtonsContextStarredToggle", + appendTerms(aTermCreator, aTerms, aFilterValue) { + let term, value; + term = aTermCreator.createTerm(); + term.attrib = Ci.nsMsgSearchAttrib.MsgStatus; + value = term.value; + value.attrib = term.attrib; + value.status = Ci.nsMsgMessageFlags.Marked; + term.value = value; + term.op = aFilterValue ? Ci.nsMsgSearchOp.Is : Ci.nsMsgSearchOp.Isnt; + term.booleanAnd = true; + aTerms.push(term); + }, +}); + +/** + * true: sender must be in a local address book, false: sender must not be. + */ +QuickFilterManager.defineFilter({ + name: "addrBook", + domId: "qfb-inaddrbook", + menuItemID: "quickFilterButtonsContextInaddrbookToggle", + appendTerms(aTermCreator, aTerms, aFilterValue) { + let term, value; + let firstBook = true; + term = null; + for (let addrbook of MailServices.ab.directories) { + if (!addrbook.isRemote) { + term = aTermCreator.createTerm(); + term.attrib = Ci.nsMsgSearchAttrib.Sender; + value = term.value; + value.attrib = term.attrib; + value.str = addrbook.URI; + term.value = value; + term.op = aFilterValue + ? Ci.nsMsgSearchOp.IsInAB + : Ci.nsMsgSearchOp.IsntInAB; + // It's an AND if we're the first book (so the boolean affects the + // group as a whole.) + // It's the negation of whether we're filtering otherwise; demorgans. + term.booleanAnd = firstBook || !aFilterValue; + term.beginsGrouping = firstBook; + aTerms.push(term); + firstBook = false; + } + } + if (term) { + term.endsGrouping = true; + } + }, +}); + +/** + * It's a tag filter that sorta facets! Stealing gloda's thunder! Woo! + * + * Filter on message tags? Meanings: + * - true: Yes, must have at least one tag on it. + * - false: No, no tags on it! + * - dictionary where keys are tag keys and values are tri-state with null + * meaning don't constraint, true meaning yes should be present, false + * meaning no, don't be present + */ +var TagFacetingFilter = { + name: "tags", + domId: "qfb-tags", + menuItemID: "quickFilterButtonsContextTagsToggle", + callID: "", + + /** + * @returns true if the constaint is only on has tags/does not have tags, + * false if there are specific tag constraints in play. + */ + isSimple(aFilterValue) { + // it's the simple case if the value is just a boolean + if (typeof aFilterValue != "object") { + return true; + } + // but also if the object contains no non-null values + let simpleCase = true; + for (let key in aFilterValue.tags) { + let value = aFilterValue.tags[key]; + if (value !== null) { + simpleCase = false; + break; + } + } + return simpleCase; + }, + + /** + * Because we support both inclusion and exclusion we can produce up to two + * groups. One group for inclusion, one group for exclusion. To get listed + * the message must have any/all of the tags marked for inclusion, + * (depending on mode), but it cannot have any of the tags marked for + * exclusion. + */ + appendTerms(aTermCreator, aTerms, aFilterValue) { + if (aFilterValue == null) { + return null; + } + + let term, value; + + // just the true/false case + if (this.isSimple(aFilterValue)) { + term = aTermCreator.createTerm(); + term.attrib = Ci.nsMsgSearchAttrib.Keywords; + value = term.value; + value.str = ""; + term.value = value; + term.op = aFilterValue + ? Ci.nsMsgSearchOp.IsntEmpty + : Ci.nsMsgSearchOp.IsEmpty; + term.booleanAnd = true; + aTerms.push(term); + + // we need to perform faceting if the value is literally true. + if (aFilterValue === true) { + return this; + } + } else { + let firstIncludeClause = true, + firstExcludeClause = true; + let lastIncludeTerm = null; + term = null; + + let excludeTerms = []; + + let mode = aFilterValue.mode; + for (let key in aFilterValue.tags) { + let shouldFilter = aFilterValue.tags[key]; + if (shouldFilter !== null) { + term = aTermCreator.createTerm(); + term.attrib = Ci.nsMsgSearchAttrib.Keywords; + value = term.value; + value.attrib = term.attrib; + value.str = key; + term.value = value; + if (shouldFilter) { + term.op = Ci.nsMsgSearchOp.Contains; + // AND for the group. Inside the group we also want AND if the + // mode is set to "All of". + term.booleanAnd = firstIncludeClause || mode === "AND"; + term.beginsGrouping = firstIncludeClause; + aTerms.push(term); + firstIncludeClause = false; + lastIncludeTerm = term; + } else { + term.op = Ci.nsMsgSearchOp.DoesntContain; + // you need to not include all of the tags marked excluded. + term.booleanAnd = true; + term.beginsGrouping = firstExcludeClause; + excludeTerms.push(term); + firstExcludeClause = false; + } + } + } + if (lastIncludeTerm) { + lastIncludeTerm.endsGrouping = true; + } + + // if we have any exclude terms: + // - we might need to add a "has a tag" clause if there were no explicit + // inclusions. + // - extend the exclusions list in. + if (excludeTerms.length) { + // (we need to add has a tag) + if (!lastIncludeTerm) { + term = aTermCreator.createTerm(); + term.attrib = Ci.nsMsgSearchAttrib.Keywords; + value = term.value; + value.str = ""; + term.value = value; + term.op = Ci.nsMsgSearchOp.IsntEmpty; + term.booleanAnd = true; + aTerms.push(term); + } + + // (extend in the exclusions) + excludeTerms[excludeTerms.length - 1].endsGrouping = true; + aTerms.push.apply(aTerms, excludeTerms); + } + } + return null; + }, + + onSearchStart(aCurState) { + // this becomes aKeywordMap; we want to start with an empty one + return {}; + }, + onSearchMessage(aKeywordMap, aMsgHdr, aFolder) { + let keywords = aMsgHdr.getStringProperty("keywords"); + let keywordList = keywords.split(" "); + for (let iKeyword = 0; iKeyword < keywordList.length; iKeyword++) { + let keyword = keywordList[iKeyword]; + aKeywordMap[keyword] = null; + } + }, + onSearchDone(aCurState, aKeywordMap, aStatus) { + // we are an async operation; if the user turned off the tag facet already, + // then leave that state intact... + if (aCurState == null) { + return [null, false, false]; + } + + // only propagate things that are actually tags though! + let outKeyMap = { tags: {} }; + let tags = MailServices.tags.getAllTags(); + let tagCount = tags.length; + for (let iTag = 0; iTag < tagCount; iTag++) { + let tag = tags[iTag]; + + if (tag.key in aKeywordMap) { + outKeyMap.tags[tag.key] = aKeywordMap[tag.key]; + } + } + return [outKeyMap, true, false]; + }, + + /** + * We need to clone our state if it's an object to avoid bad sharing. + */ + propagateState(aOld, aSticky) { + // stay disabled when disabled, get disabled when not sticky + if (aOld == null || !aSticky) { + return null; + } + if (this.isSimple(aOld)) { + // Could be an object, need to convert. + return !!aOld; + } + return shallowObjCopy(aOld); + }, + + /** + * Default behaviour but: + * - We collapse our expando if we get unchecked. + * - We want to initiate a faceting pass if we just got checked. + */ + onCommand(aState, aNode, aEvent, aDocument) { + let checked; + if (aNode.tagName == "button") { + checked = aNode.pressed ? true : null; + } else { + checked = aNode.hasAttribute("checked") ? true : null; + } + + if (!checked) { + aDocument.getElementById("quickFilterBarTagsContainer").hidden = true; + } + + // return ourselves if we just got checked to have + // onSearchStart/onSearchMessage/onSearchDone get to do their thing. + return [checked, true]; + }, + + domBindExtra(aDocument, aMuxer, aNode) { + // Tag filtering mode menu (All of/Any of) + function commandHandler(aEvent) { + let filterValue = aMuxer.getFilterValueForMutation( + TagFacetingFilter.name + ); + filterValue.mode = aEvent.target.value; + aMuxer.updateSearch(); + } + aDocument + .getElementById("qfb-boolean-mode") + .addEventListener("ValueChange", commandHandler); + }, + + reflectInDOM(aNode, aFilterValue, aDocument, aMuxer, aCallId) { + if (aCallId !== null && aCallId == "menuItem") { + aFilterValue + ? aNode.setAttribute("checked", aFilterValue) + : aNode.removeAttribute("checked"); + } else { + aNode.pressed = aFilterValue; + } + if (aFilterValue != null && typeof aFilterValue == "object") { + this._populateTagBar(aFilterValue, aDocument, aMuxer); + } else { + aDocument.getElementById("quickFilterBarTagsContainer").hidden = true; + } + }, + + _populateTagBar(aState, aDocument, aMuxer) { + let tagbar = aDocument.getElementById("quickFilterBarTagsContainer"); + let keywordMap = aState.tags; + + // If we have a mode stored use that. If we don't have a mode, then update + // our state to agree with what the UI is currently displaying; + // this will happen for fresh profiles. + let qbm = aDocument.getElementById("qfb-boolean-mode"); + if (aState.mode) { + qbm.value = aState.mode; + } else { + aState.mode = qbm.value; + } + + function clickHandler(aEvent) { + let tagKey = this.getAttribute("value"); + let state = aMuxer.getFilterValueForMutation(TagFacetingFilter.name); + state.tags[tagKey] = this.pressed ? true : null; + this.removeAttribute("inverted"); + aMuxer.updateSearch(); + } + + function rightClickHandler(aEvent) { + if (aEvent.button == 2) { + // Toggle isn't triggered by a contextmenu event, so do it here. + this.pressed = !this.pressed; + + let tagKey = this.getAttribute("value"); + let state = aMuxer.getFilterValueForMutation(TagFacetingFilter.name); + state.tags[tagKey] = this.pressed ? false : null; + if (this.pressed) { + this.setAttribute("inverted", "true"); + } else { + this.removeAttribute("inverted"); + } + aMuxer.updateSearch(); + aEvent.preventDefault(); + } + } + + // -- nuke existing exposed tags, but not the mode selector (which is first) + while (tagbar.children.length > 1) { + tagbar.lastElementChild.remove(); + } + + let addCount = 0; + + // -- create an element for each tag + let tags = MailServices.tags.getAllTags(); + let tagCount = tags.length; + for (let iTag = 0; iTag < tagCount; iTag++) { + let tag = tags[iTag]; + + if (tag.key in keywordMap) { + addCount++; + + // Keep in mind that the XBL does not get built for dynamically created + // elements such as these until they get displayed, which definitely + // means not before we append it into the tree. + let button = aDocument.createElement("button", { is: "toggle-button" }); + + button.setAttribute("id", "qfb-tag-" + tag.key); + button.addEventListener("click", clickHandler); + button.addEventListener("contextmenu", rightClickHandler); + if (keywordMap[tag.key] !== null) { + button.pressed = true; + if (!keywordMap[tag.key]) { + button.setAttribute("inverted", "true"); + } + } + button.textContent = tag.tag; + button.setAttribute("value", tag.key); + let color = tag.color; + let contrast = lazy.TagUtils.isColorContrastEnough(color) + ? "black" + : "white"; + // everybody always gets to be an qfb-tag-button. + button.setAttribute("class", "button qfb-tag-button"); + if (color) { + button.setAttribute( + "style", + `--tag-color: ${color}; --tag-contrast-color: ${contrast};` + ); + } + tagbar.appendChild(button); + } + } + tagbar.hidden = !addCount; + }, +}; +QuickFilterManager.defineFilter(TagFacetingFilter); + +/** + * true: must have attachment, false: must not have attachment. + */ +QuickFilterManager.defineFilter({ + name: "attachment", + domId: "qfb-attachment", + menuItemID: "quickFilterButtonsContextAttachmentToggle", + appendTerms(aTermCreator, aTerms, aFilterValue) { + let term, value; + term = aTermCreator.createTerm(); + term.attrib = Ci.nsMsgSearchAttrib.MsgStatus; + value = term.value; + value.attrib = term.attrib; + value.status = Ci.nsMsgMessageFlags.Attachment; + term.value = value; + term.op = aFilterValue ? Ci.nsMsgSearchOp.Is : Ci.nsMsgSearchOp.Isnt; + term.booleanAnd = true; + aTerms.push(term); + }, +}); + +/** + * The traditional quick-search text filter now with added gloda upsell! We + * are mildly extensible in case someone wants to add more specific text filter + * criteria to toggle, but otherwise are intended to be taken out of the + * picture entirely by extensions implementing more featureful text searches. + * + * Our state looks like {text: "", states: {a: true, b: false}} where a and b + * are text filters. + */ +var MessageTextFilter = { + name: "text", + domId: "qfb-qs-textbox", + /** + * Parse the string into terms/phrases by finding matching double-quotes. If + * we find a quote that doesn't have a friend, we assume the user was going + * to put a quote at the end of the string. (This is important because we + * update using a timer and this results in stable behavior.) + * + * This code is cloned from gloda's GlodaMsgSearcher.jsm and known good (enough :). + * I did change the friendless quote situation, though. + * + * @param aSearchString The phrase to parse up. + * @returns A list of terms. + */ + _parseSearchString(aSearchString) { + aSearchString = aSearchString.trim(); + let terms = []; + + /* + * Add the term as long as the trim on the way in didn't obliterate it. + * + * In the future this might have other helper logic; it did once before. + */ + function addTerm(aTerm) { + if (aTerm) { + terms.push(aTerm); + } + } + + /** + * Look for spaces around | (OR operator) and remove them. + */ + aSearchString = aSearchString.replace(/\s*\|\s*/g, "|"); + while (aSearchString) { + if (aSearchString.startsWith('"')) { + let endIndex = aSearchString.indexOf('"', 1); + // treat a quote without a friend as making a phrase containing the + // rest of the string... + if (endIndex == -1) { + endIndex = aSearchString.length; + } + + addTerm(aSearchString.substring(1, endIndex).trim()); + aSearchString = aSearchString.substring(endIndex + 1); + continue; + } + + let searchTerms = aSearchString.split(" "); + searchTerms.forEach(searchTerm => addTerm(searchTerm)); + break; + } + + return terms; + }, + + /** + * For each search phrase, build a group that contains all our active text + * filters OR'ed together. So if the user queries for 'foo bar' with + * sender and recipient enabled, we build: + * ("foo" sender OR "foo" recipient) AND ("bar" sender OR "bar" recipient) + */ + appendTerms(aTermCreator, aTerms, aFilterValue) { + let term, value; + + if (aFilterValue.text) { + let phrases = this._parseSearchString(aFilterValue.text); + for (let groupedPhrases of phrases) { + let firstClause = true; + term = null; + let splitPhrases = groupedPhrases.split("|"); + for (let phrase of splitPhrases) { + for (let [tfName, tfValue] of Object.entries(aFilterValue.states)) { + if (!tfValue) { + continue; + } + let tfDef = this.textFilterDefs[tfName]; + + term = aTermCreator.createTerm(); + term.attrib = tfDef.attrib; + value = term.value; + value.attrib = tfDef.attrib; + value.str = phrase; + term.value = value; + term.op = Ci.nsMsgSearchOp.Contains; + // AND for the group, but OR inside the group + term.booleanAnd = firstClause; + term.beginsGrouping = firstClause; + aTerms.push(term); + firstClause = false; + } + } + if (term) { + term.endsGrouping = true; + } + } + } + }, + getDefaults() { + let states = {}; + for (let name in this._defaultStates) { + states[name] = this._defaultStates[name]; + } + return { + text: null, + states, + }; + }, + propagateState(aOld, aSticky) { + return { + text: aSticky ? aOld.text : null, + states: shallowObjCopy(aOld.states), + }; + }, + clearState(aState) { + let hadState = Boolean(aState.text); + aState.text = null; + return [aState, hadState]; + }, + + /** + * We need to create and bind our expando-bar toggle buttons. We also need to + * add a special down keypress handler that escapes the textbox into the + * thread pane. + */ + domBindExtra(aDocument, aMuxer, aNode) { + // -- Keypresses for focus transferral and upsell + aNode.addEventListener("keypress", function (aEvent) { + // - Down key into the thread pane. Calls `preventDefault` to stop the + // event from causing scrolling, but that prevents the tree from + // selecting a message if necessary, so we must do it here. + if (aEvent.keyCode == aEvent.DOM_VK_DOWN) { + let threadTree = aDocument.getElementById("threadTree"); + threadTree.table.body.focus(); + if (threadTree.selectedIndex == -1) { + threadTree.selectedIndex = 0; + } + aEvent.preventDefault(); + } + }); + + // -- Blurring kills upsell. + aNode.addEventListener( + "blur", + function (aEvent) { + let panel = aDocument.getElementById("qfb-text-search-upsell"); + if ( + (Services.focus.activeWindow != aDocument.defaultView || + aDocument.commandDispatcher.focusedElement != aNode.inputField) && + panel.state == "open" + ) { + panel.hidePopup(); + } + }, + true + ); + + // -- Expando Buttons! + function commandHandler(aEvent) { + let state = aMuxer.getFilterValueForMutation(MessageTextFilter.name); + let filterDef = MessageTextFilter.textFilterDefsByDomId[this.id]; + state.states[filterDef.name] = this.pressed; + aMuxer.updateSearch(); + } + + for (let name in this.textFilterDefs) { + let textFilter = this.textFilterDefs[name]; + aDocument + .getElementById(textFilter.domId) + .addEventListener("click", commandHandler); + } + }, + + onCommand(aState, aNode, aEvent, aDocument) { + let text = aNode.value.length ? aNode.value : null; + if (text == aState.text) { + let upsell = aDocument.getElementById("qfb-text-search-upsell"); + if (upsell.state == "open") { + upsell.hidePopup(); + let tabmail = + aDocument.ownerGlobal.top.document.getElementById("tabmail"); + tabmail.openTab("glodaFacet", { + searcher: new lazy.GlodaMsgSearcher(null, aState.text), + }); + } + return [aState, false]; + } + + aState.text = text; + aDocument.getElementById("quick-filter-bar-filter-text-bar").hidden = + text == null; + return [aState, true]; + }, + + reflectInDOM(aNode, aFilterValue, aDocument, aMuxer, aFromPFP) { + let panel = aDocument.getElementById("qfb-text-search-upsell"); + + if (aFromPFP == "nosale") { + if (panel.state != "closed") { + panel.hidePopup(); + } + return; + } + + if (aFromPFP == "upsell") { + let line2 = aDocument.getElementById("qfb-upsell-line-two"); + aDocument.l10n.setAttributes( + line2, + "quick-filter-bar-gloda-upsell-line2", + { text: aFilterValue.text } + ); + + if (panel.state == "closed" && aDocument.activeElement == aNode) { + aDocument.ownerGlobal.setTimeout(() => { + panel.openPopup( + aDocument.getElementById("quick-filter-bar"), + "after_end", + -7, + 7, + false, + true + ); + }); + } + return; + } + + // Make sure we have no visible upsell on state change while our textbox + // retains focus. + if (panel.state != "closed") { + panel.hidePopup(); + } + + // Update the text if it has changed (linux does weird things with empty + // text if we're transitioning emptytext to emptytext). + let desiredValue = aFilterValue.text || ""; + if (aNode.value != desiredValue && aNode != aMuxer.activeElement) { + aNode.value = desiredValue; + } + + // Update our expanded filters buttons. + let states = aFilterValue.states; + for (let name in this.textFilterDefs) { + let textFilter = this.textFilterDefs[name]; + aDocument.getElementById(textFilter.domId).pressed = + states[textFilter.name]; + } + + // Toggle the expanded filters visibility. + aDocument.getElementById("quick-filter-bar-filter-text-bar").hidden = + aFilterValue.text == null; + }, + + /** + * In order to do our upsell we need to know when we are not getting any + * results. + */ + postFilterProcess(aState, aViewWrapper, aFiltering) { + // If we're not filtering, not filtering on text, there are results, or + // gloda is not enabled so upselling makes no sense, then bail. + // (Currently we always return "nosale" to make sure our panel is closed; + // this might be overkill but unless it becomes a performance problem, it + // keeps us safe from weird stuff.) + if ( + !aFiltering || + !aState.text || + aViewWrapper.dbView.numMsgsInView || + !lazy.GlodaIndexer.enabled + ) { + return [aState, "nosale", false]; + } + + // since we're filtering, filtering on text, and there are no results, tell + // the upsell code to get bizzay + return [aState, "upsell", false]; + }, + + /** maps text filter names to whether they are enabled by default (bool) */ + _defaultStates: {}, + /** maps text filter name to text filter def */ + textFilterDefs: {}, + /** maps dom id to text filter def */ + textFilterDefsByDomId: {}, + defineTextFilter(aTextDef) { + this.textFilterDefs[aTextDef.name] = aTextDef; + this.textFilterDefsByDomId[aTextDef.domId] = aTextDef; + if (aTextDef.defaultState) { + this._defaultStates[aTextDef.name] = true; + } + }, +}; +// Note that we definitely want this filter defined AFTER the cheap message +// status filters, so don't reorder this invocation willy nilly. +QuickFilterManager.defineFilter(MessageTextFilter); +QuickFilterManager.textBoxDomId = "qfb-qs-textbox"; + +MessageTextFilter.defineTextFilter({ + name: "sender", + domId: "qfb-qs-sender", + attrib: Ci.nsMsgSearchAttrib.Sender, + defaultState: true, +}); +MessageTextFilter.defineTextFilter({ + name: "recipients", + domId: "qfb-qs-recipients", + attrib: Ci.nsMsgSearchAttrib.ToOrCC, + defaultState: true, +}); +MessageTextFilter.defineTextFilter({ + name: "subject", + domId: "qfb-qs-subject", + attrib: Ci.nsMsgSearchAttrib.Subject, + defaultState: true, +}); +MessageTextFilter.defineTextFilter({ + name: "body", + domId: "qfb-qs-body", + attrib: Ci.nsMsgSearchAttrib.Body, + defaultState: false, +}); + +/** + * The results label says whether there were any matches and, if so, how many. + */ +QuickFilterManager.defineFilter({ + name: "results", + domId: "qfb-results-label", + appendTerms(aTermCreator, aTerms, aFilterValue) {}, + + /** + * Our state is meaningless; we implement this to avoid clearState ever + * thinking we were a facet. + */ + clearState(aState) { + return [null, false]; + }, + + /** + * We never have any state to propagate! + */ + propagateState(aOld, aSticky) { + return null; + }, + + reflectInDOM(aNode, aFilterValue, aDocument) { + if (aFilterValue == null) { + aNode.removeAttribute("data-l10n-id"); + aNode.removeAttribute("data-l10n-attrs"); + aNode.textContent = ""; + aNode.style.visibility = "hidden"; + } else if (aFilterValue == 0) { + aDocument.l10n.setAttributes(aNode, "quick-filter-bar-no-results"); + aNode.style.visibility = "visible"; + } else { + aDocument.l10n.setAttributes(aNode, "quick-filter-bar-results", { + count: aFilterValue, + }); + aNode.style.visibility = "visible"; + } + }, + /** + * We slightly abuse the filtering hook to figure out how many messages there + * are and whether a filter is active. What makes this reasonable is that + * a more complicated widget that visualized the results as a timeline would + * definitely want to be hooked up like this. (Although they would want + * to implement propagateState since the state they store would be pretty + * expensive.) + */ + postFilterProcess(aState, aViewWrapper, aFiltering) { + return [aFiltering ? aViewWrapper.dbView.numMsgsInView : null, true, false]; + }, +}); diff --git a/comm/mail/modules/SearchSpec.jsm b/comm/mail/modules/SearchSpec.jsm new file mode 100644 index 0000000000..50bbfbaa64 --- /dev/null +++ b/comm/mail/modules/SearchSpec.jsm @@ -0,0 +1,562 @@ +/* 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/. */ + +const EXPORTED_SYMBOLS = ["SearchSpec"]; + +const { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" +); + +/** + * Wrapper abstraction around a view's search session. This is basically a + * friend class of FolderDisplayWidget and is privy to some of its internals. + */ +function SearchSpec(aViewWrapper) { + this.owner = aViewWrapper; + + this._viewTerms = null; + this._virtualFolderTerms = null; + this._userTerms = null; + + this._session = null; + this._sessionListener = null; + this._listenersRegistered = false; + + this._onlineSearch = false; +} +SearchSpec.prototype = { + /** + * Clone this SearchSpec; intended to be used by DBViewWrapper.clone(). + */ + clone(aViewWrapper) { + let doppel = new SearchSpec(aViewWrapper); + + // we can just copy the terms since we never mutate them + doppel._viewTerms = this._viewTerms; + doppel._virtualFolderTerms = this._virtualFolderTerms; + doppel._userTerms = this._userTerms; + + // _session can stay null + // no listener is required, so we can keep _sessionListener and + // _listenersRegistered at their default values + + return doppel; + }, + + get hasSearchTerms() { + return this._viewTerms || this._virtualFolderTerms || this._userTerms; + }, + + get hasOnlyVirtualTerms() { + return this._virtualFolderTerms && !this._viewTerms && !this._userTerms; + }, + + /** + * On-demand creation of the nsIMsgSearchSession. Automatically creates a + * SearchSpecListener at the same time and registers it as a listener. The + * DBViewWrapper is responsible for adding (and removing) the db view + * as a listener. + * + * Code should only access this attribute when it wants to manipulate the + * session. Callers should use hasSearchTerms if they want to determine if + * a search session is required. + */ + get session() { + if (this._session == null) { + this._session = Cc[ + "@mozilla.org/messenger/searchSession;1" + ].createInstance(Ci.nsIMsgSearchSession); + } + return this._session; + }, + + /** + * (Potentially) add the db view as a search listener and kick off the search. + * We only do that if we have search terms. The intent is to allow you to + * call this all the time, even if you don't need to. + * DBViewWrapper._applyViewChanges used to handle a lot more of this, but our + * need to make sure that the session listener gets added after the DBView + * caused us to introduce this method. (We want the DB View's OnDone method + * to run before our listener, as it may do important work.) + */ + associateView(aDBView) { + if (this.hasSearchTerms) { + this.updateSession(); + + if (this.owner.isSynthetic) { + this.owner._syntheticView.search(new FilteringSyntheticListener(this)); + } else { + if (!this._sessionListener) { + this._sessionListener = new SearchSpecListener(this); + } + + this.session.registerListener( + aDBView, + Ci.nsIMsgSearchSession.allNotifications + ); + aDBView.searchSession = this._session; + this._session.registerListener( + this._sessionListener, + Ci.nsIMsgSearchSession.onNewSearch | + Ci.nsIMsgSearchSession.onSearchDone + ); + this._listenersRegistered = true; + + this.owner.searching = true; + this.session.search(this.owner.listener.msgWindow); + } + } else if (this.owner.isSynthetic) { + // If it's synthetic but we have no search terms, hook the output of the + // synthetic view directly up to the search nsIMsgDBView. + let owner = this.owner; + owner.searching = true; + this.owner._syntheticView.search( + aDBView.QueryInterface(Ci.nsIMsgSearchNotify), + function () { + owner.searching = false; + } + ); + } + }, + /** + * Stop any active search and stop the db view being a search listener (if it + * is one). + */ + dissociateView(aDBView) { + // If we are currently searching, interrupt the search. This will + // immediately notify the listeners that the search is done with and + // clear the searching flag for us. + if (this.owner.searching) { + if (this.owner.isSynthetic) { + this.owner._syntheticView.abortSearch(); + } else { + this.session.interruptSearch(); + } + } + + if (this._listenersRegistered) { + this._session.unregisterListener(this._sessionListener); + this._session.unregisterListener(aDBView); + aDBView.searchSession = null; + this._listenersRegistered = false; + } + }, + + /** + * Given a list of terms, mutate them so that they form a single boolean + * group. + * + * @param aTerms The search terms + * @param aCloneTerms Do we need to clone the terms? + */ + _flattenGroupifyTerms(aTerms, aCloneTerms) { + let iTerm = 0, + term; + let outTerms = aCloneTerms ? [] : aTerms; + for (term of aTerms) { + if (aCloneTerms) { + let cloneTerm = this.session.createTerm(); + cloneTerm.value = term.value; + cloneTerm.attrib = term.attrib; + cloneTerm.arbitraryHeader = term.arbitraryHeader; + cloneTerm.hdrProperty = term.hdrProperty; + cloneTerm.customId = term.customId; + cloneTerm.op = term.op; + cloneTerm.booleanAnd = term.booleanAnd; + cloneTerm.matchAll = term.matchAll; + term = cloneTerm; + outTerms.push(term); + } + if (iTerm == 0) { + term.beginsGrouping = true; + term.endsGrouping = false; + term.booleanAnd = true; + } else { + term.beginsGrouping = false; + term.endsGrouping = false; + } + iTerm++; + } + if (term) { + term.endsGrouping = true; + } + + return outTerms; + }, + + /** + * Normalize the provided list of terms so that all of the 'groups' in it are + * ANDed together. If any OR clauses are detected outside of a group, we + * defer to |_flattenGroupifyTerms| to force the terms to be bundled up into + * a single group, maintaining the booleanAnd state of terms. + * + * This particular logic is desired because it allows the quick filter bar to + * produce interesting and useful filters. + * + * @param aTerms The search terms + * @param aCloneTerms Do we need to clone the terms? + */ + _groupifyTerms(aTerms, aCloneTerms) { + let term; + let outTerms = aCloneTerms ? [] : aTerms; + let inGroup = false; + for (term of aTerms) { + // If we're in a group, all that is forbidden is the creation of new + // groups. + if (inGroup) { + if (term.beginsGrouping) { + // forbidden! + return this._flattenGroupifyTerms(aTerms, aCloneTerms); + } else if (term.endsGrouping) { + inGroup = false; + } + } else { + // If we're not in a group, the boolean must be AND. It's okay for a group + // to start. + // If it's not an AND then it needs to be in a group and we use the other + // function to take care of it. (This function can't back up...) + if (!term.booleanAnd) { + return this._flattenGroupifyTerms(aTerms, aCloneTerms); + } + + inGroup = term.beginsGrouping; + } + + if (aCloneTerms) { + let cloneTerm = this.session.createTerm(); + cloneTerm.attrib = term.attrib; + cloneTerm.value = term.value; + cloneTerm.arbitraryHeader = term.arbitraryHeader; + cloneTerm.hdrProperty = term.hdrProperty; + cloneTerm.customId = term.customId; + cloneTerm.op = term.op; + cloneTerm.booleanAnd = term.booleanAnd; + cloneTerm.matchAll = term.matchAll; + cloneTerm.beginsGrouping = term.beginsGrouping; + cloneTerm.endsGrouping = term.endsGrouping; + term = cloneTerm; + outTerms.push(term); + } + } + + return outTerms; + }, + + /** + * Set search terms that are defined by the 'view', which translates to that + * weird combo-box that lets you view your unread messages, messages by tag, + * messages that aren't deleted, etc. + * + * @param aViewTerms The list of terms. We take ownership and mutate it. + */ + set viewTerms(aViewTerms) { + if (aViewTerms) { + this._viewTerms = this._groupifyTerms(aViewTerms); + } else if (this._viewTerms === null) { + // If they are nulling out already null values, do not apply view changes! + return; + } else { + this._viewTerms = null; + } + this.owner._applyViewChanges(); + }, + /** + * @returns the view terms currently in effect. Do not mutate this. + */ + get viewTerms() { + return this._viewTerms; + }, + /** + * Set search terms that are defined by the 'virtual folder' definition. This + * could also be thought of as the 'saved search' part of a saved search. + * + * @param aVirtualFolderTerms The list of terms. We make our own copy and + * do not mutate yours. + */ + set virtualFolderTerms(aVirtualFolderTerms) { + if (aVirtualFolderTerms) { + // we need to clone virtual folder terms because they are pulled from a + // persistent location rather than created on demand + this._virtualFolderTerms = this._groupifyTerms(aVirtualFolderTerms, true); + } else if (this._virtualFolderTerms === null) { + // If they are nulling out already null values, do not apply view changes! + return; + } else { + this._virtualFolderTerms = null; + } + this.owner._applyViewChanges(); + }, + /** + * @returns the Virtual folder terms currently in effect. Do not mutate this. + */ + get virtualFolderTerms() { + return this._virtualFolderTerms; + }, + + /** + * Set the terms that the user is explicitly searching on. These will be + * augmented with the 'context' search terms potentially provided by + * viewTerms and virtualFolderTerms. + * + * @param aUserTerms The list of terms. We take ownership and mutate it. + */ + set userTerms(aUserTerms) { + if (aUserTerms) { + this._userTerms = this._groupifyTerms(aUserTerms); + } else if (this._userTerms === null) { + // If they are nulling out already null values, do not apply view changes! + return; + } else { + this._userTerms = null; + } + this.owner._applyViewChanges(); + }, + /** + * @returns the user terms currently in effect as set via the |userTerms| + * attribute or via the |quickSearch| method. Do not mutate this. + */ + get userTerms() { + return this._userTerms; + }, + + clear() { + if (this.hasSearchTerms) { + this._viewTerms = null; + this._virtualFolderTerms = null; + this._userTerms = null; + this.owner._applyViewChanges(); + } + }, + + get onlineSearch() { + return this._onlineSearch; + }, + /** + * Virtual folders have a concept of 'online search' which affects the logic + * in updateSession that builds our search scopes. If onlineSearch is false, + * then when displaying the virtual folder unaffected by mail views or quick + * searches, we will most definitely perform an offline search. If + * onlineSearch is true, we will perform an online search only for folders + * which are not available offline and for which the server is configured + * to have an online 'searchScope'. + * When mail views or quick searches are in effect our search is always + * offline unless the only way to satisfy the needs of the constraints is an + * online search (read: the message body is required but not available + * offline.) + */ + set onlineSearch(aOnlineSearch) { + this._onlineSearch = aOnlineSearch; + }, + + /** + * Populate the search session using viewTerms, virtualFolderTerms, and + * userTerms. The way this works is that each of the 'context' sets of + * terms gets wrapped into a group which is boolean anded together with + * everything else. + */ + updateSession() { + let session = this.session; + + // clear out our current terms and scope + session.searchTerms = []; + session.clearScopes(); + + // -- apply terms + if (this._virtualFolderTerms) { + for (let term of this._virtualFolderTerms) { + session.appendTerm(term); + } + } + + if (this._viewTerms) { + for (let term of this._viewTerms) { + session.appendTerm(term); + } + } + + if (this._userTerms) { + for (let term of this._userTerms) { + session.appendTerm(term); + } + } + + // -- apply scopes + // If it is a synthetic view, create a single bogus scope so that we can use + // MatchHdr. + if (this.owner.isSynthetic) { + // We don't want to pass in a folder, and we don't want to use the + // allSearchableGroups scope, so we cheat and use AddDirectoryScopeTerm. + session.addDirectoryScopeTerm(Ci.nsMsgSearchScope.offlineMail); + return; + } + + let filtering = this._userTerms != null || this._viewTerms != null; + let validityManager = Cc[ + "@mozilla.org/mail/search/validityManager;1" + ].getService(Ci.nsIMsgSearchValidityManager); + for (let folder of this.owner._underlyingFolders) { + // we do not need to check isServer here because _underlyingFolders + // filtered it out when it was initialized. + + let scope; + let serverScope = folder.server.searchScope; + // If we're offline, or this is a local folder, or there's no separate + // online scope, use server scope. + if ( + Services.io.offline || + serverScope == Ci.nsMsgSearchScope.offlineMail || + folder instanceof Ci.nsIMsgLocalMailFolder + ) { + scope = serverScope; + } else { + // we need to test the validity in online and offline tables + let onlineValidityTable = validityManager.getTable(serverScope); + + let offlineScope; + if (folder.flags & Ci.nsMsgFolderFlags.Offline) { + offlineScope = Ci.nsMsgSearchScope.offlineMail; + } else { + // The onlineManual table is used for local search when there is no + // body available. + offlineScope = Ci.nsMsgSearchScope.onlineManual; + } + + let offlineValidityTable = validityManager.getTable(offlineScope); + let offlineAvailable = true; + let onlineAvailable = true; + for (let term of session.searchTerms) { + if (!term.matchAll) { + // for custom terms, we need to getAvailable from the custom term + if (term.attrib == Ci.nsMsgSearchAttrib.Custom) { + let customTerm = MailServices.filters.getCustomTerm( + term.customId + ); + if (customTerm) { + offlineAvailable = customTerm.getAvailable( + offlineScope, + term.op + ); + onlineAvailable = customTerm.getAvailable(serverScope, term.op); + } else { + // maybe an extension with a custom term was unloaded? + console.error( + "Custom search term " + term.customId + " missing" + ); + } + } else { + if (!offlineValidityTable.getAvailable(term.attrib, term.op)) { + offlineAvailable = false; + } + if (!onlineValidityTable.getAvailable(term.attrib, term.op)) { + onlineAvailable = false; + } + } + } + } + // If both scopes work, honor the onlineSearch request, for saved search folders (!filtering) + // and the search dialog (!displayedFolder). + // If only one works, use it. Otherwise, default to offline + if (onlineAvailable && offlineAvailable) { + scope = + (!filtering || !this.owner.displayedFolder) && this.onlineSearch + ? serverScope + : offlineScope; + } else if (onlineAvailable) { + scope = serverScope; + } else { + scope = offlineScope; + } + } + session.addScopeTerm(scope, folder); + } + }, + + prettyStringOfSearchTerms(aSearchTerms) { + if (aSearchTerms == null) { + return " (none)\n"; + } + + let s = ""; + + for (let term of aSearchTerms) { + s += " " + term.termAsString + "\n"; + } + + return s; + }, + + prettyString() { + let s = " Search Terms:\n"; + s += " Virtual Folder Terms:\n"; + s += this.prettyStringOfSearchTerms(this._virtualFolderTerms); + s += " View Terms:\n"; + s += this.prettyStringOfSearchTerms(this._viewTerms); + s += " User Terms:\n"; + s += this.prettyStringOfSearchTerms(this._userTerms); + s += " Scope (Folders):\n"; + for (let folder of this.owner._underlyingFolders) { + s += " " + folder.prettyName + "\n"; + } + return s; + }, +}; + +/** + * A simple nsIMsgSearchNotify listener that only listens for search start/stop + * so that it can tell the DBViewWrapper when the search has completed. + */ +function SearchSpecListener(aSearchSpec) { + this.searchSpec = aSearchSpec; +} +SearchSpecListener.prototype = { + onNewSearch() { + // searching should already be true by the time this happens. if it's not, + // it means some code is poking at the search session. bad! + if (!this.searchSpec.owner.searching) { + console.error("Search originated from unknown initiator! Confusion!"); + this.searchSpec.owner.searching = true; + } + }, + + onSearchHit(aMsgHdr, aFolder) { + // this method is never invoked! + }, + + onSearchDone(aStatus) { + this.searchSpec.owner.searching = false; + }, +}; + +/** + * Pretend to implement the nsIMsgSearchNotify interface, checking all matches + * we are given against the search session on the search spec. If they pass, + * relay them to the underlying db view, otherwise quietly eat them. + * This is what allows us to use mail-views and quick searches against + * gloda-backed searches. + */ +function FilteringSyntheticListener(aSearchSpec) { + this.searchSpec = aSearchSpec; + this.session = this.searchSpec.session; + this.dbView = this.searchSpec.owner.dbView.QueryInterface( + Ci.nsIMsgSearchNotify + ); +} +FilteringSyntheticListener.prototype = { + onNewSearch() { + this.searchSpec.owner.searching = true; + this.dbView.onNewSearch(); + }, + onSearchHit(aMsgHdr, aFolder) { + // We don't need to worry about msgDatabase opening the database. + // It is (obviously) already open, and presumably gloda is already on the + // hook to perform the cleanup (assuming gloda is backing this search). + if (this.session.MatchHdr(aMsgHdr, aFolder.msgDatabase)) { + this.dbView.onSearchHit(aMsgHdr, aFolder); + } + }, + onSearchDone(aStatus) { + this.searchSpec.owner.searching = false; + this.dbView.onSearchDone(aStatus); + }, +}; diff --git a/comm/mail/modules/SelectionWidgetController.jsm b/comm/mail/modules/SelectionWidgetController.jsm new file mode 100644 index 0000000000..267ff7902b --- /dev/null +++ b/comm/mail/modules/SelectionWidgetController.jsm @@ -0,0 +1,1355 @@ +/* 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/. */ + +const EXPORTED_SYMBOLS = ["SelectionWidgetController"]; + +var { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); + +/** + * @callback GetLayoutDirectionMethod + * + * @returns {"horizontal"|"vertical"} - The direction in which the widget + * visually lays out its items. "vertical" for top to bottom, "horizontal" for + * following the text direction. + */ +/** + * Details about the sizing of the widget in the same direction as its layout. + * + * @typedef {object} PageSizeDetails + * @param {number} viewSize - The size of the widget's "view" of its items. If + * the items are placed under a scrollable area with 0 padding, this would + * usually be the clientHeight or clientWidth, which exclude the border and + * the scroll bars. + * @param {number} viewOffset - The offset of the widget's "view" from the + * starting item. If the items are placed under a scrollable area with 0 + * padding, this would usually be its scrollTop, or the absolute value of its + * scrollLeft (to account for negative values in right-to-left). + * @param {?number} itemSize - The size of an item. If the items have no spacing + * between them, then this would usually correspond to their bounding client + * widths or heights. If the items do not share the same size, or there are no + * items this should return null. + */ +/** + * @callback GetPageSizeDetailsMethod + * + * @returns {?PageSizeDetails} Details about the currently visible items. Or null + * if page navigation should not be allowed: either because the required + * conditions do not apply or PageUp and PageDown should be used for something + * else. + */ +/** + * @callback IndexFromTargetMethod + * + * @param {EventTarget} target - An event target. + * + * @returns {?number} - The index for the selectable item that contains the event + * target, or null if there is none. + */ +/** + * @callback SetFocusableItemMethod + * + * @param {?number} index - The index for the selectable item that should become + * focusable, replacing any previous focusable item. Or null if the widget + * itself should become focusable instead. If the corresponding item was not + * previously the focused item and it is not yet visible, it should be scrolled + * into view. + * @param {boolean} focus - Whether to also focus the specified item after it + * becomes focusable. + */ +/** + * @callback SetItemSelectionStateMethod + * + * @param {number} index - The index of the first selectable items to set the + * selection state of. + * @param {number} number - The number of subsequent selectable items that + * should be set to the same selection state, including the first item and any + * immediately following it. + * @param {boolean} selected - Whether the specified items should be selected or + * unselected. + */ + +/** + * A class for handling the focus and selection controls for a widget. + * + * The widget is assumed to control a totally ordered set of selectable items, + * each of which may be referenced by their index in this ordering. The visual + * display of these items has an ordering that is faithful to this ordering. + * Note, a "selectable item" is any item that may receive focus and can be + * selected or unselected. + * + * A SelectionWidgetController instance will keep track of its widget's focus + * and selection states, and will provide a standard set of keyboard and mouse + * controls to the widget that handle changes in these states. + * + * The SelectionWidgetController instance will communicate with the widget to + * inform it of any changes in these states that the widget should adjust to. It + * may also query the widget for information as needed. + * + * The widget must inform its SelectionWidgetController instance of any changes + * in the index of selectable items. In particular, the widget should call the + * addedSelectableItems method to inform the controller of any initial set of + * items or any additional items that are added to the widget. It should also + * use the removeSelectableItems and moveSelectableItems methods when it wishes + * to remove or move items. + * + * The communication between the widget and its SelectionWidgetController + * instance will use the item's index to reference the item. This means that the + * representation of the item itself is left up to the widget. + * + * # Selection models + * + * The controller implements a number of selection models. Each of which has + * different selection features and controls suited to them. A model appropriate + * to the specific situation should be chosen. + * + * Model behaviour table: + * + * Model Name | Selection follows focus | Multi selectable + * ========================================================================== + * focus always no + * browse default no + * browse-multi default yes + * + * + * ## Behaviour: Selection follows focus + * + * This determines whether the focused item is selected. + * + * "always" means a focused item will always be selected, and no other item will + * be selected, which makes the selection redundant to the focus. This should be + * used if a change in the selection has no side effect beyond what a change in + * focus should trigger. + * + * "default" means the default action when navigating to a focused item is to + * change the selection to just that item, but the user may press a modifier + * (Control) to move the focus without selecting an item. The side effects to + * selecting an item should be light and non-disruptive since a user will likely + * change the selection regularly as they navigate the items without a modifier. + * Moreover, this behaviour will prefer selecting a single item, and so is not + * appropriate if the primary use case is to select multiple, or zero, items. + * + * ## Behaviour: Multi selectable + * + * This determines whether the user can select more than one item. If the + * selection follows the focus (by default) the user can use a modifier to + * select more than one item. + * + * Note, if this is "no", then in most usage, exactly one item will be selected. + * However, it is still possible to get into a state where no item is selected + * when the widget is empty or the selected item is deleted when it doesn't have + * focus. + */ +class SelectionWidgetController { + /** + * The widget this controller controls. + * + * @type {Element} + */ + #widget = null; + /** + * A collection of methods passed to the controller at initialization. + * + * @type {object} + */ + #methods = null; + /** + * The number of items the controller controls. + * + * @type {number} + */ + #numItems = 0; + /** + * A range that points to all selectable items whose index `i` obeys + * `start <= i < end` + * Note, the `start` is inclusive of the index but the `end` is not. + * + * @typedef {object} SelectionRange + * @property {number} start - The starting point of the range. + * @property {number} end - The ending point of the range. + */ + /** + * The ranges of selected indices, ordered by their `start` property. + * + * Each range is kept "disjoint": no natural number N obeys + * `#ranges[i].start <= N <= #ranges[i].end` + * for more than one index `i`. Essentially, this means that no range of + * selected items will overlap, or even be immediately adjacent to + * another set of selected items. Instead, if two ranges would be adjacent or + * overlap, they will be merged into one range instead. + * + * We use ranges, rather than a list of indices to reduce the footprint when a + * large number of items are selected. Similarly, we also avoid looping over + * all selected indices. + * + * @type {SelectionRange[]} + */ + #ranges = []; + /** + * The direction of travel when holding the Shift modifier, or null if some + * other selection has broken the Shift selection sequence. + * + * @type {"forward"|"backward"|null} + */ + #shiftRangeDirection = null; + /** + * The index of the focused selectable item, or null if the widget is focused + * instead. + * + * @type {?number} + */ + #focusIndex = null; + /** + * Whether the focused item must always be selected. + * + * @type {boolean} + */ + #focusIsSelected = false; + /** + * Whether the user can select multiple items. + * + * @type {boolean} + */ + #multiSelectable = false; + + /** + * Creates a new selection controller for the given widget. + * + * @param {widget} widget - The widget to control. + * @param {"focus"|"browse"|"browse-multi"} model - The selection model to + * follow. + * @param {object} methods - Methods for the controller to communicate with + * the widget. + * @param {GetLayoutDirectionMethod} methods.getLayoutDirection - Used to + * get the layout direction of the widget. + * @param {IndexFromTargetMethod} methods.indexFromTarget - Used to get the + * corresponding item index from an event target. + * @param {GetPageSizeDetailsMethod} method.getPageSizeDetails - Used to get + * details about the visible display of the widget items for page + * navigation. + * @param {SetFocusableItemMethod} methods.setFocusableItem - Used to update + * the widget on which item should receive focus. + * @param {SetItemSelectionStateMethod} methods.setItemSelectionState - Used + * to update the widget on whether a range of items should be selected. + */ + constructor(widget, model, methods) { + this.#widget = widget; + switch (model) { + case "focus": + this.#focusIsSelected = true; + this.#multiSelectable = false; + break; + case "browse": + this.#focusIsSelected = false; + this.#multiSelectable = false; + break; + case "browse-multi": + this.#focusIsSelected = false; + this.#multiSelectable = true; + break; + default: + throw new RangeError(`The model "${model}" is not a supported model`); + } + this.#methods = methods; + + widget.addEventListener("mousedown", event => this.#handleMouseDown(event)); + if (this.#multiSelectable) { + widget.addEventListener("click", event => this.#handleClick(event)); + } + widget.addEventListener("keydown", event => this.#handleKeyDown(event)); + widget.addEventListener("focusin", event => this.#handleFocusIn(event)); + } + + #assertIntegerInRange(integer, lower, upper, name) { + if (!Number.isInteger(integer)) { + throw new RangeError(`"${name}" ${integer} is not an integer`); + } + if (lower != null && integer < lower) { + throw new RangeError( + `"${name}" ${integer} is not greater than or equal to ${lower}` + ); + } + if (upper != null && integer > upper) { + throw new RangeError( + `"${name}" ${integer} is not less than or equal to ${upper}` + ); + } + } + + /** + * Update the widget's selection state for the specified items. + * + * @param {number} index - The index at which to start. + * @param {number} number - The number of items to set the state of. + */ + #updateWidgetSelectionState(index, number) { + // First, inform the widget of the selection state of the new items. + let prevRangeEnd = index; + for (let { start, end } of this.#ranges) { + // Deselect the items in the gap between the previous range and this one. + // For the first range, there may not be a gap. + if (start > prevRangeEnd) { + this.#methods.setItemSelectionState( + prevRangeEnd, + start - prevRangeEnd, + false + ); + } + // Select the items in the range. + this.#methods.setItemSelectionState(start, end - start, true); + prevRangeEnd = end; + } + // Deselect the items in the gap between the final range and the end of the + // new items, if there is a gap. + if (index + number > prevRangeEnd) { + this.#methods.setItemSelectionState( + prevRangeEnd, + index + number - prevRangeEnd, + false + ); + } + } + + /** + * Informs the controller that a set of selectable items were added to the + * widget. It is important to call this *after* the widget has indexed the new + * items. + * + * @param {number} index - The index at which the selectable items were added. + * Between 0 and the current number of items (inclusive). + * @param {number} number - The number of selectable items that were added at + * this index. + */ + addedSelectableItems(index, number) { + this.#assertIntegerInRange(index, 0, this.#numItems, "index"); + this.#assertIntegerInRange(number, 1, null, "number"); + // Newly added items are unselected. + this.#adjustRangesOnAddItems(index, number, []); + this.#numItems += number; + + if (this.#focusIndex != null && this.#focusIndex >= index) { + // Focus remains on the same item, but is adjusted in index. + this.#focusIndex += number; + } + + this.#updateWidgetSelectionState(index, number); + } + + /** + * Adjust the #ranges to account for additional inserted items. + * + * @param {number} index - The index at which items are added. + * @param {number} number - The number of items that are added at this index. + * @param {SelectionRange[]} insertSelection - The selection state of the + * inserted items. The ranges should be "disjoint" and only overlap the + * added indices. The given array is owned by the method. + */ + #adjustRangesOnAddItems(index, number, insertSelection) { + // We want to insert whatever ranges are specified in insertSelection into + // the #ranges Array. insertRangeIndex tracks the index at which we will + // insert the given insertSelection. + let insertRangeIndex = 0; + // However, if insertSelection touches the start or end of the new items, it + // may be possible to merge it with an existing SelectionRange that touches + // the same edge. + let touchStartRange = + insertSelection.length && insertSelection[0].start == index + ? insertSelection[0] + : null; + let touchEndRange = + insertSelection.length && + insertSelection[insertSelection.length - 1].end == index + number + ? insertSelection[insertSelection.length - 1] + : null; + + // Go through ranges from last to first. + for (let i = this.#ranges.length - 1; i >= 0; i--) { + let { start, end } = this.#ranges[i]; + if (touchStartRange && end == index) { + // Merge the range with touchStartRange. + touchStartRange.start = start; + this.#ranges.splice(i, 1, ...insertSelection); + // All earlier ranges should end strictly before the index. + return; + } + if (end <= index) { + // A B [ C D E ] F G + // ^start end^ + // ^index (or higher) + // No change, and all earlier ranges are also before. + // This is the last range that lies before the inserted items, so we + // want to insert the given insertSelection after this range. + insertRangeIndex = i + 1; + break; + } + if (start < index) { + // start < index < end + // A B [ C D E ] F G + // ^start end^ + // ^index + // The range is split in two parts by the index. + if (touchEndRange) { + // Extend touchEndRange to the end part of the current range. + // We add "number" to account for the inserted indices. + touchEndRange.end = end + number; + } else { + // Append a new range for the end part of the current range. + insertSelection.push({ start: index + number, end: end + number }); + } + if (touchStartRange) { + // We merge touchStartRange with the first part of the current range. + touchStartRange.start = start; + this.#ranges.splice(i, 1, ...insertSelection); + } else { + // We adjust the first part to end where the inserted indices begin. + this.#ranges[i].end = index; + this.#ranges.splice(i + 1, 0, ...insertSelection); + } + // All earlier ranges should end strictly before the index. + return; + } + // A B [ C D E ] F G + // ^start end^ + // ^index (or lower) + if (touchEndRange && start == index) { + // Merge the range with the touchEndRange. + // We add "number" to account for the inserted indices. + touchEndRange.end = end + number; + this.#ranges.splice(i, 1, ...insertSelection); + // All earlier ranges should end strictly before the index. + return; + } + // Shift the range to account for the inserted indices. + this.#ranges[i].start = start + number; + this.#ranges[i].end = end + number; + } + + // Add the insert ranges in the gap. + if (insertSelection.length) { + this.#ranges.splice(insertRangeIndex, 0, ...insertSelection); + } + } + + /** + * Remove a set of selectable items from the widget. The actual removing of + * the items and their elements from the widget is controlled by the widget + * through a callback, and the controller will update its internals. The + * controller may also change the selection state and focus of the widget + * if need be. + * + * @param {number} index - The index of the first selectable item to be + * removed. + * @param {number} number - The number of subsequent selectable items that + * will be removed, including the first item and any immediately following + * it. + * @param {Function} removeCallback - A function to call with no arguments + * that removes the specified items from the widget. After this call the + * widget should no longer be tracking the specified items and should have + * shifted the indices of the remaining items to fill the gap. + */ + removeSelectableItems(index, number, removeCallback) { + this.#assertIntegerInRange(index, 0, this.#numItems - 1, "index"); + this.#assertIntegerInRange(number, 1, this.#numItems - index, "number"); + + let focusWasSelected = + this.#focusIndex != null && this.itemIsSelected(this.#focusIndex); + // Get whether the focus is within the widget now in case it is lost when + // the items are removed. + let focusInWidget = this.#focusInWidget(); + + removeCallback(); + + this.#adjustRangesOnRemoveItems(index, number); + this.#numItems -= number; + + if (!this.#ranges.length) { + // Ends any shift range. + this.#shiftRangeDirection = null; + } + + // Adjust focus. + if (this.#focusIndex == null || this.#focusIndex < index) { + // No change in index if on widget or before the removed index. + return; + } + if (this.#focusIndex >= index + number) { + // Reduce index if after the removed items. + this.#focusIndex -= number; + return; + } + // Focus is lost. + // Try to move to the first item after the removed items. If this does + // not exist, it will be capped to the last item overall in #moveFocus. + let newFocus = index; + if (focusWasSelected && this.#shiftRangeDirection) { + // As a special case, if the focused item was inside a shift selection + // range when it was removed, and the range still exists after, we keep + // the focus within the selection boundary that is opposite the "pivot" + // point. I.e. when selecting forwards we keep the focus below the + // selection end, and when selecting backwards we keep the focus above the + // selection start. This is to prevent the focused item becoming + // unselected in the middle of an ongoing shift range selection. + // NOTE: When selecting forwards, we do not keep the focus above the + // selection start because the user would only be here (at the selection + // "pivot") if they navigated with Ctrl+Space to this position, so we do + // not override the default behaviour. Similarly when selecting backwards + // we do not require the focus to remain above the selection end. + switch (this.#shiftRangeDirection) { + case "forward": + newFocus = Math.min( + newFocus, + this.#ranges[this.#ranges.length - 1].end - 1 + ); + break; + case "backward": + newFocus = Math.max(newFocus, this.#ranges[0].start); + } + } + // TODO: if we have a tree structure, we will want to move the focus + // within the nearest parent by clamping the focus to lie between the + // parent index (inclusive) and its last descendant (inclusive). If + // there are no children left, this will fallback to focusing the + // parent. + this.#moveFocus(newFocus, focusInWidget); + // #focusIndex may now be different from newFocus if the deleted indices + // were the final ones, and may be null if no items remain. + if (!this.#ranges.length && this.#focusIndex != null) { + // If the focus was moved, and now we have no selection, we select it. + // This is deemed relatively safe to do since it only effects the state of + // the focused item. And it is convenient to have selection resume. + this.#selectSingle(this.#focusIndex); + } + } + + /** + * Adjust the #ranges to remove items. + * + * @param {number} index - The index at which items are removed. + * @param {number} number - The number of items that are removed. + * + * @returns {SelectionRange[]} - The removed SelectionRange objects. This will + * contain all the ranges that touched or overlapped the selected items. + * Owned by the caller. + */ + #adjustRangesOnRemoveItems(index, number) { + // The ranges to remove. + let deleteRangesStart = 0; + let deleteRangesNumber = 0; + // The range to insert by combining overlapping ranges on either side of the + // deleted indices. + let insertRange = { start: index, end: index }; + + // Go through ranges from last to first. + for (let i = this.#ranges.length - 1; i >= 0; i--) { + let { start, end } = this.#ranges[i]; + if (end < index) { + // <- removed -> + // A B C D E [ F G H ] I J K L M + // ^start end^ + // ^index (or higher) + deleteRangesStart = i + 1; + // This and all earlier ranges do not need to be updated. + break; + } else if (start > index + number) { + // <- removed -> + // A B C D E [ F G H ] I J K L M + // ^start end^ + // ^index + number (or lower) + // Shift the range. + this.#ranges[i].start = start - number; + this.#ranges[i].end = end - number; + continue; + } + deleteRangesNumber++; + if (end > index + number) { + // start <= (index + number) < end + // <- removed -> + // A B C D E [ F G H ] I J K L M + // ^start end^ + // ^index ^index + number + // + // <- removed -> + // A B C D E [ F G H ] I J K L M + // ^start end^ + // ^index ^index + number + // + // <- removed -> + // A B C [ D E F G H I ] J K L M + // ^start end^ + // ^index ^index + number + // + // Overlaps or touches the end of the removed indices, but is not + // entirely contained within the removed region. + // Extend the insertRange to the end of this range, and then shift it to + // remove the deleted indices. + insertRange.end = end - number; + } + if (start < index) { + // start < index <= end + // <- removed -> + // A B C D E [ F G H ] I J K L M + // ^start end^ + // ^index ^index + number + // + // <- removed -> + // A B C D E [ F G H ] I J K L M + // ^start end^ + // ^index ^index + number + // + // <- removed -> + // A B C [ D E F G H I ] J K L M + // ^start end^ + // ^index ^index + number + // + // Overlaps or touches the start of the removed indices, but is not + // entirely contained within the removed region. + // Extend the insertRange to the start of this range. + insertRange.start = start; + // Expect break on next loop. + } + } + if (!deleteRangesNumber) { + // No change in selection. + return []; + } + if (insertRange.end > insertRange.start) { + return this.#ranges.splice( + deleteRangesStart, + deleteRangesNumber, + insertRange + ); + } + // No range to insert. + return this.#ranges.splice(deleteRangesStart, deleteRangesNumber); + } + + /** + * Move a set of selectable items within the widget. The actual moving of + * the items and their elements in the widget is controlled by the widget + * through a callback, and the controller will update its internals. + * + * Unlike simply adding and then removing indices, this will transfer the + * focus and selection states along with the moved items. + * + * @param {number} from - The index of the first selectable item to be + * moved, before the move. + * @param {number} to - The index that the first selectable item will be moved + * to, after the move. + * @param {number} number - The number of subsequent selectable items that + * will be moved along with the first item, including the first item and any + * immediately following it. Their relative positions should remain the + * same. + * @param {Function} moveCallback - A function to call with no arguments + * that moves the specified items within the widget to the specified + * position. After this call the widget should have adjusted the indices + * of its items accordingly. + */ + moveSelectableItems(from, to, number, moveCallback) { + this.#assertIntegerInRange(from, 0, this.#numItems - 1, "from"); + this.#assertIntegerInRange(number, 1, this.#numItems - from, "number"); + this.#assertIntegerInRange(to, 0, this.#numItems - number, "to"); + // Get whether the focus is within the widget now in case it is lost when + // the items are moved. + let focusInWidget = this.#focusInWidget(); + + moveCallback(); + + let movedSelection = this.#adjustRangesOnRemoveItems(from, number); + // Descend the removed ranges. + for (let i = movedSelection.length - 1; i >= 0; i--) { + let range = movedSelection[i]; + if (range.end <= from || range.start >= from + number) { + // Touched the start or end, but did not overlap. + movedSelection.splice(i, 1); + // NOTE: Since we are descending it is safe to continue the loop by + // decreasing i by 1. + continue; + } + // Translate and clip the range. + range.start = to + Math.max(0, range.start - from); + range.end = to + Math.min(number, range.end - from); + } + this.#adjustRangesOnAddItems(to, number, movedSelection); + + // End any range selection. + this.#shiftRangeDirection = null; + + // Adjust focus. + if (this.#focusIndex != null) { + if (this.#focusIndex >= from && this.#focusIndex < from + number) { + // Focus was in the moved range. + // We adjust the #focusIndex, but we also force the widget to reset the + // focus in case it needs to apply it to a newly created items. + this.#moveFocus(this.#focusIndex + to - from, focusInWidget); + } else { + // Adjust for removing `number` items at `from`. + if (this.#focusIndex >= from + number) { + this.#focusIndex -= number; + } + // Adjust for then adding `number` items at `to`. + if (this.#focusIndex >= to) { + this.#focusIndex += number; + } + } + } + // Reset the selection state for the moved items in case it needs to be + // applied to newly created items. + this.#updateWidgetSelectionState(to, number); + } + + /** + * Select the specified item and deselect all other items. The next time the + * widget is entered by the user, the specified item will also receive the + * focus. + * + * This should normally not be used in a situation were the focus may already + * be within the widget because it will actively move the focus, which can be + * disruptive if unexpected. It is mostly exposed to set an initial selection + * after creating the widget, or when changing its dataset. + * + * @param {number} index - The index for the item to select. This must not + * exceed the number of items controlled by the widget. + */ + selectSingleItem(index) { + this.#selectSingle(index); + let focusInWidget = this.#focusInWidget(); + if (this.#focusIndex == null && !focusInWidget) { + // Wait until handleFocusIn to move the focus to the selected item in case + // other items become selected through setItemSelected. + return; + } + this.#moveFocus(index, focusInWidget); + } + + /** + * Set the selection state of the specified item, but otherwise leave the + * selection state of other items the same. + * + * Note that this will throw if the selection model does not support multi + * selection. Generally, you should try and use selectSingleItem instead + * because this also moves the focus appropriately and works for all models. + * + * @param {number} index - The index for the item to set the selection state + * of. + * @param {boolean} selected - Whether the item should be selected or + * unselected. + */ + setItemSelected(index, selected) { + if (!this.#multiSelectable) { + throw new Error("Widget does not support multi-selection"); + } + this.#toggleSelection(index, !!selected); + } + + /** + * Get the ranges of all selected items. + * + * Note that ranges are returned rather than individual indices to keep this + * method fast. Unlike the selected indices which might become very large with + * a single user operation, like Select-All, the number of ranges will + * increase by order-one range per user interaction or public method call. + * + * Note that the SelectionRange objects specify the range with a `start` and + * `end` index. The `start` is inclusive of the index, but the `end` is + * not. + * + * Note that the returned Array is static (it will not update as the selection + * changes). + * + * @returns {SelectionRange[]} - An array of all non-overlapping selection + * ranges, order by their start index. + */ + getSelectionRanges() { + return Array.from(this.#ranges, r => { + return { start: r.start, end: r.end }; + }); + } + + /** + * Query whether the specified item is selected or not. + * + * @param {number} index - The index for the item to query. + * + * @returns {boolean} - Whether the item is selected. + */ + itemIsSelected(index) { + this.#assertIntegerInRange(index, 0, this.#numItems - 1, "index"); + for (let { start, end } of this.#ranges) { + if (index < start) { + // index was not in any lower ranges and is before the start of this + // range, so should be unselected. + return false; + } + if (index < end) { + // start <= index < end + return true; + } + } + return false; + } + + /** + * Select the specified range of indices, and nothing else. + * + * @param {number} index - The first index to select. + * @param {number} number - The number of indices to select. + */ + #selectRange(index, number) { + this.#assertIntegerInRange(index, 0, this.#numItems - 1, "index"); + this.#assertIntegerInRange(number, 1, this.#numItems - index, "number"); + + let prevRanges = this.#ranges; + let start = index; + let end = index + number; + if ( + prevRanges.length == 1 && + prevRanges[0].start == start && + prevRanges[0].end == end + ) { + // No change. + return; + } + + this.#ranges = [{ start, end }]; + // Adjust the selection state to match the new range. + // NOTE: For simplicity, we do a blanket re-selection across the whole + // region, even items in between ranges that are not selected. + // NOTE: If the new range overlaps the previous range then the selection + // state be set more than once for an item, but it will be to the same + // value. + if (prevRanges.length) { + let firstRangeStart = prevRanges[0].start; + let lastRangeEnd = prevRanges[prevRanges.length - 1].end; + this.#updateWidgetSelectionState( + firstRangeStart, + lastRangeEnd - firstRangeStart + ); + } + this.#updateWidgetSelectionState(index, number); + } + + /** + * Select one index and nothing else. + * + * @param {number} index - The index to select. + */ + #selectSingle(index) { + this.#selectRange(index, 1); + // Cancel any shift range. + this.#shiftRangeDirection = null; + } + + /** + * Toggle the selection state at a single index. + * + * @param {number} index - The index to toggle the selection state of. + * @param {boolean} [selectState] - The state to force the selection state of + * the item to, or leave undefined to toggle the state. + */ + #toggleSelection(index, selectState) { + this.#assertIntegerInRange(index, 0, this.#numItems - 1, "index"); + + let wasSelected = false; + let i; + // We traverse over the ranges. + for (i = 0; i < this.#ranges.length; i++) { + let { start, end } = this.#ranges[i]; + // Test if in a gap between the end of last range and the start of the + // current one. + // NOTE: Since we did not break on the previous loop, we already know that + // the index is above the end of the previous range. + if (index < start) { + // This index is not selected. + break; + } + // Test if in the range. + if (index < end) { + // start <= index < end + wasSelected = true; + if (selectState) { + // Already selected and we want to keep it that way. + break; + } + if (start == index && end == index + 1) { + // A B C [ D ] E F G + // start^ ^end + // ^index + // + // Remove the range entirely. + this.#ranges.splice(i, 1); + } else if (start == index) { + // A [ B C D E F ] G + // ^start end^ + // ^index + // + // Remove the start of the range. + this.#ranges[i].start = index + 1; + } else if (end == index + 1) { + // A [ B C D E F ] G + // ^start end^ + // ^index + // + // Remove the end of the range. + this.#ranges[i].end = index; + } else { + // A [ B C D E F ] G + // ^start end^ + // ^index + // + // Split the range in two. + // + // A [ B C ] D [ E F ] G + this.#ranges[i].end = index; + this.#ranges.splice(i + 1, 0, { start: index + 1, end }); + } + break; + } + } + if (!wasSelected && (selectState == undefined || selectState)) { + // The index i points to a *gap* between existing ranges, so lies in + // [0, numItems]. Note, the space between the start and the first range, + // or the end and the last range count as gaps, even if they are zero + // width. + // We want to know whether the index touches the borders of the range + // either side of the gap. + let touchesRangeEnd = i > 0 && index == this.#ranges[i - 1].end; + // A [ B C D ] E F G H I + // end(i-1)^ + // ^index + let touchesRangeStart = + i < this.#ranges.length && index + 1 == this.#ranges[i].start; + // A B C D E [ F G H ] I + // ^start(i) + // ^index + if (touchesRangeEnd && touchesRangeStart) { + // A [ B C D ] E [ F G H ] I + // ^index + // Merge the two ranges together. + this.#ranges[i - 1].end = this.#ranges[i].end; + this.#ranges.splice(i, 1); + } else if (touchesRangeEnd) { + // Grow the range forwards to include the index. + this.#ranges[i - 1].end = index + 1; + } else if (touchesRangeStart) { + // Grow the range backwards to include the index. + this.#ranges[i].start = index; + } else { + // Create a new range. + this.#ranges.splice(i, 0, { start: index, end: index + 1 }); + } + } + this.#methods.setItemSelectionState(index, 1, selectState ?? !wasSelected); + // Cancel any shift range. + this.#shiftRangeDirection = null; + } + + /** + * Determine whether the focus lies within the widget or elsewhere. + * + * @returns {boolean} - Whether the active element is the widget or one of its + * descendants. + */ + #focusInWidget() { + return this.#widget.contains(this.#widget.ownerDocument.activeElement); + } + + /** + * Make the specified element focusable. Also move focus to this element if + * the widget already has focus. + * + * @param {?number} index - The index of the item to focus, or null to focus + * the widget. If the index is out of range, it will be truncated. + * @param {boolean} [forceInWidget] - Whether the focus was in the widget + * before the specified element becomes focusable. This should be given to + * reference an earlier focus state, otherwise leave undefined to use the + * current focus state. + */ + #moveFocus(index, focusInWidget) { + let numItems = this.#numItems; + if (index != null) { + if (index >= numItems) { + index = numItems ? numItems - 1 : null; + } else if (index < 0) { + index = numItems ? 0 : null; + } + } + if (focusInWidget == undefined) { + focusInWidget = this.#focusInWidget(); + } + + this.#focusIndex = index; + // If focus is within the widget, we move focus onto the new item. + this.#methods.setFocusableItem(index, focusInWidget); + } + + #handleFocusIn(event) { + if ( + // No item is focused, + this.#focusIndex == null && + // and we have at least one item, + this.#numItems && + // and the focus moved from outside the widget. + // NOTE: relatedTarget may be null, but Node.contains will also return + // false for this case, as desired. + !this.#widget.contains(event.relatedTarget) + ) { + // If nothing is selected, select the first item. + if (!this.#ranges.length) { + this.#selectSingle(0); + } + // Focus first selected item. + this.#moveFocus(this.#ranges[0].start); + return; + } + if (this.#focusIndex != this.#methods.indexFromTarget(event.target)) { + // Restore focus to where it needs to be. + this.#moveFocus(this.#focusIndex); + } + } + + /** + * Adjust the focus and selection in response to a user generated event. + * + * @param {?number} [focusIndex] - The new index to move focus to, or null to + * move the focus to the widget, or undefined to leave the focus as it is. + * Note that the focusIndex will be clamped to lie within the current index + * range. + * @param {string} [select] - The change in selection to trigger, relative to + * the #focusIndex. "single" to select the #focusIndex, "toggle" to swap its + * selection state, "range" to start or continue a range selection, or "all" + * to select all items. + */ + #adjustFocusAndSelection(focusIndex, select) { + let prevFocusIndex = this.#focusIndex; + if (focusIndex !== undefined) { + // NOTE: We need a strict inequality since focusIndex may be null. + this.#moveFocus(focusIndex); + } + // Change selection relative to the focused index. + // NOTE: We use the #focusIndex value rather than the focusIndex variable. + if (this.#focusIndex != null) { + switch (select) { + case "single": + this.#selectSingle(this.#focusIndex); + break; + case "toggle": + this.#toggleSelection(this.#focusIndex); + break; + case "range": + // We want to select all items between a "pivot" point and the focused + // index. If we do not have a "pivot" point, we use the previously + // focused index. + // This "pivot" point is lost every time the user performs a single + // selection or a toggle selection. I.e. if the selection changes by + // any means other than "range" selection. + // + // NOTE: We represent the presence of such a "pivot" point using the + // #shiftRangeDirection property. If it is null, no such point exists, + // if it is "forward" then the "pivot" point is the first selected + // index, and if it is "backward" then the "pivot" point is the last + // selected index. + // Usually, we only have one #ranges entry whilst doing such a Shift + // selection, but if items are added in the middle of such a range, + // then the selection can be split, but subsequent Shift selection + // will reselect all of them. + // NOTE: We do not keep track of this "pivot" index explicitly in a + // property because otherwise we would have to adjust its value every + // time items are removed, and handle cases where the "pivot" index is + // removed. Instead, we just borrow the logic of how the #ranges array + // is updated, and continue to derive the "pivot" point from the + // #shiftRangeDirection and #ranges properties. + let start; + switch (this.#shiftRangeDirection) { + case "forward": + // When selecting forward, the range start is the first selected + // index. + start = this.#ranges[0].start; + break; + case "backward": + // When selecting backward, the range end is the last selected + // index. + start = this.#ranges[this.#ranges.length - 1].end - 1; + break; + default: + // We start a new range selection between the previously focused + // index and the newly focused index. + start = prevFocusIndex || 0; + break; + } + let number; + // NOTE: Selection may transition from "forward" to "backward" if the + // user moves the selection in the other direction. + if (start > this.#focusIndex) { + this.#shiftRangeDirection = "backward"; + number = start - this.#focusIndex + 1; + start = this.#focusIndex; + } else { + this.#shiftRangeDirection = "forward"; + number = this.#focusIndex - start + 1; + } + this.#selectRange(start, number); + break; + } + } + + // Selecting all does not require focus. + if (select == "all" && this.#numItems) { + this.#shiftRangeDirection = null; + this.#selectRange(0, this.#numItems); + } + } + + #handleMouseDown(event) { + // NOTE: The default handler for mousedown will move focus onto the clicked + // item or the widget, but #handleFocusIn will re-assign it to the current + // #focusIndex if it differs. + if (event.button != 0 || event.metaKey || event.altKey) { + return; + } + let { shiftKey, ctrlKey } = event; + if ( + (ctrlKey && shiftKey) || + // Both modifiers pressed. + ((ctrlKey || shiftKey) && !this.#multiSelectable) + // Attempting multi-selection when not supported + ) { + return; + } + let clickIndex = this.#methods.indexFromTarget(event.target); + if (clickIndex == null) { + // Clicked empty space. + return; + } + if (ctrlKey) { + this.#adjustFocusAndSelection(clickIndex, "toggle"); + } else if (shiftKey) { + this.#adjustFocusAndSelection(clickIndex, "range"); + } else if (this.#multiSelectable && this.itemIsSelected(clickIndex)) { + // We set the focus now, but wait until "click" to select a single item. + // We do this to allow the user to drag a multi selection. + this.#adjustFocusAndSelection(clickIndex, undefined); + } else { + this.#adjustFocusAndSelection(clickIndex, "single"); + } + } + + #handleClick(event) { + // NOTE: This handler is only used if we have #multiSelectable. + // See #handleMouseDown + if ( + event.button != 0 || + event.metaKey || + event.altKey || + event.shiftKey || + event.ctrlKey + ) { + return; + } + let clickIndex = this.#methods.indexFromTarget(event.target); + if (clickIndex == null) { + return; + } + this.#adjustFocusAndSelection(clickIndex, "single"); + } + + #handleKeyDown(event) { + if (event.altKey) { + // Not handled. + return; + } + + let { shiftKey, ctrlKey, metaKey } = event; + if ( + this.#multiSelectable && + event.key == "a" && + !shiftKey && + (AppConstants.platform == "macosx") == metaKey && + (AppConstants.platform != "macosx") == ctrlKey + ) { + this.#adjustFocusAndSelection(undefined, "all"); + event.stopPropagation(); + event.preventDefault(); + return; + } + + if (metaKey) { + // Not handled. + return; + } + + if (event.key == " ") { + // Always reserve the Space press. + event.stopPropagation(); + event.preventDefault(); + + if (shiftKey) { + // Not handled. + return; + } + + if (ctrlKey) { + if (this.#multiSelectable) { + this.#adjustFocusAndSelection(undefined, "toggle"); + } + // Else, do nothing. + return; + } + + this.#adjustFocusAndSelection(undefined, "single"); + return; + } + + let forwardKey; + let backwardKey; + if (this.#methods.getLayoutDirection() == "vertical") { + forwardKey = "ArrowDown"; + backwardKey = "ArrowUp"; + } else if (this.#widget.matches(":dir(ltr)")) { + forwardKey = "ArrowRight"; + backwardKey = "ArrowLeft"; + } else { + forwardKey = "ArrowLeft"; + backwardKey = "ArrowRight"; + } + + // NOTE: focusIndex may be set to an out of range index, but it will be + // clipped in #moveFocus. + let focusIndex; + switch (event.key) { + case "Home": + focusIndex = 0; + break; + case "End": + focusIndex = this.#numItems - 1; + break; + case "PageUp": + case "PageDown": + let sizeDetails = this.#methods.getPageSizeDetails(); + if (!sizeDetails) { + // Do not handle and allow PageUp or PageDown to propagate. + return; + } + if (!sizeDetails.itemSize || !sizeDetails.viewSize) { + // Still reserve PageUp and PageDown + break; + } + let { itemSize, viewSize, viewOffset } = sizeDetails; + // We want to determine what items are visible. We count an item as + // "visible" if more than half of it is in view. + // + // Consider an item at index i that follows the assumed model: + // + // [ item content ] + // <---- itemSize ----> + // ---->start_i = i * itemSize + // + // where start_i is the offset of the starting edge of the item relative + // to the starting edge of the first item. + // + // As such, an item will be visible if + // start_i + itemSize / 2 > viewOffset + // and + // start_i + itemSize / 2 < viewOffset + viewSize + // <=> + // i > (viewOffset / itemSize) - 1/2 + // and + // i < ((viewOffset + viewSize) / itemSize) - 1/2 + + // First, we want to know the number of items we can visibly fit on a + // page. I.e. when the viewOffset is 0, the number of items whose midway + // point is lower than the viewSize. This is given by (i + 1), where i + // is the largest index i that satisfies + // i < (viewSize / itemSize) - 1/2 + // This is given by taking the ceiling - 1, which cancels with the +1. + let itemsPerPage = Math.ceil(viewSize / itemSize - 0.5); + if (itemsPerPage <= 1) { + break; + } + if (event.key == "PageUp") { + // We want to know what the first visible index is. I.e. the smallest + // i that satisfies + // i > (viewOffset / itemSize) - 1/2 + // This is equivalent to flooring the right hand side + 1. + let pageStart = Math.floor(viewOffset / itemSize - 0.5) + 1; + if (this.#focusIndex == null || this.#focusIndex > pageStart) { + // Move focus to the top of the page. + focusIndex = pageStart; + } else { + // Reduce focusIndex by one page. + // We add "1" index to try and keep the previous focusIndex visible + // at the bottom of the view. + focusIndex = this.#focusIndex - itemsPerPage + 1; + } + } else { + // We want to know what the last visible index is. I.e. the largest i + // that satisfies + // i < (viewOffset + viewSize) / itemSize - 1/2 + // This is equivalent to ceiling the right hand side - 1. + let pageEnd = Math.ceil((viewOffset + viewSize) / itemSize - 0.5) - 1; + if (this.#focusIndex == null || this.#focusIndex < pageEnd) { + // Move focus to the end of the page. + focusIndex = pageEnd; + } else { + // Increase focusIndex by one page. + // We minus "1" index to try and keep the previous focusIndex + // visible at the top of the view. + focusIndex = this.#focusIndex + itemsPerPage - 1; + } + } + break; + case forwardKey: + if (this.#focusIndex == null) { + // Move to first item. + focusIndex = 0; + } else { + focusIndex = this.#focusIndex + 1; + } + break; + case backwardKey: + if (this.#focusIndex == null) { + // Move to first item. + focusIndex = 0; + } else { + focusIndex = this.#focusIndex - 1; + } + break; + default: + // Not a navigation key. + return; + } + + // NOTE: We always reserve control over these keys, regardless of whether + // we respond to them. + event.stopPropagation(); + event.preventDefault(); + + if (focusIndex === undefined) { + return; + } + + if (shiftKey && ctrlKey) { + // Both modifiers not handled. + return; + } + + if (ctrlKey) { + // Move the focus without changing the selection. + if (!this.#focusIsSelected) { + this.#adjustFocusAndSelection(focusIndex, undefined); + } + return; + } + + if (shiftKey) { + // Range selection. + if (this.#multiSelectable) { + this.#adjustFocusAndSelection(focusIndex, "range"); + } + return; + } + + this.#adjustFocusAndSelection(focusIndex, "single"); + } +} diff --git a/comm/mail/modules/SessionStore.jsm b/comm/mail/modules/SessionStore.jsm new file mode 100644 index 0000000000..162abfecef --- /dev/null +++ b/comm/mail/modules/SessionStore.jsm @@ -0,0 +1,16 @@ +/* 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/. */ + +"use strict"; + +var EXPORTED_SYMBOLS = ["SessionStore"]; + +/** + * This is a shim for SessionStore in moz-central to prevent bug 1713801. Only + * the methods that appear to be hit by comm-central are implemented. + */ +var SessionStore = { + updateSessionStoreFromTablistener(aBrowser, aBrowsingContext, aData) {}, + maybeExitCrashedState() {}, +}; diff --git a/comm/mail/modules/SessionStoreManager.jsm b/comm/mail/modules/SessionStoreManager.jsm new file mode 100644 index 0000000000..1c8ea6bec6 --- /dev/null +++ b/comm/mail/modules/SessionStoreManager.jsm @@ -0,0 +1,302 @@ +/* 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/. */ + +const EXPORTED_SYMBOLS = ["SessionStoreManager"]; + +const { JSONFile } = ChromeUtils.importESModule( + "resource://gre/modules/JSONFile.sys.mjs" +); + +/** + * asuth arbitrarily chose this value to trade-off powersaving, + * processor usage, and recency of state in the face of the impossibility of + * our crashing; he also worded this. + */ +var SESSION_AUTO_SAVE_DEFAULT_MS = 300000; // 5 minutes + +var SessionStoreManager = { + _initialized: false, + + /** + * Session restored successfully on startup; use this to test for an early + * failed startup which does not restore user tab state to ensure a session + * save on close will not overwrite the last good session state. + */ + _restored: false, + + _sessionAutoSaveTimer: null, + + _sessionAutoSaveTimerIntervalMS: SESSION_AUTO_SAVE_DEFAULT_MS, + + /** + * The persisted state of the previous session. This is resurrected + * from disk when the module is initialized and cleared when all + * required windows have been restored. + */ + _initialState: null, + + /** + * A flag indicating whether the state "just before shutdown" of the current + * session has been persisted to disk. See |observe| and |unloadingWindow| + * for justification on why we need this. + */ + _shutdownStateSaved: false, + + /** + * The JSONFile store object. + */ + get store() { + if (this._store) { + return this._store; + } + + return (this._store = new JSONFile({ + path: this.sessionFile.path, + backupTo: this.sessionFile.path + ".backup", + })); + }, + + /** + * Gets the nsIFile used for session storage. + */ + get sessionFile() { + let sessionFile = Services.dirsvc.get("ProfD", Ci.nsIFile); + sessionFile.append("session.json"); + return sessionFile; + }, + + /** + * This is called on startup, and when a new 3 pane window is opened after + * the last 3 pane window was closed (e.g., on the mac, closing the last + * window doesn't shut down the app). + */ + async _init() { + await this._loadSessionFile(); + + // we listen for "quit-application-granted" instead of + // "quit-application-requested" because other observers of the + // latter can cancel the shutdown. + Services.obs.addObserver(this, "quit-application-granted"); + + this.startPeriodicSave(); + + this._initialized = true; + }, + + /** + * Loads the session file into _initialState. This should only be called by + * _init and a unit test. + */ + async _loadSessionFile() { + if (!this.sessionFile.exists()) { + return; + } + + // Read the session state data from file, asynchronously. + // An error on the json file returns an empty object which corresponds + // to a null |_initialState|. + await this.store.load(); + this._initialState = + this.store.data.toSource() == {}.toSource() ? null : this.store.data; + }, + + /** + * Opens the windows that were open in the previous session. + */ + _openOtherRequiredWindows(aWindow) { + // XXX we might want to display a restore page and let the user decide + // whether to restore the other windows, just like Firefox does. + + if (!this._initialState || !this._initialState.windows || !aWindow) { + return; + } + + for (var i = 0; i < this._initialState.windows.length; ++i) { + aWindow.open( + "chrome://messenger/content/messenger.xhtml", + "_blank", + "chrome,extrachrome,menubar,resizable,scrollbars,status,toolbar" + ); + } + }, + + /** + * Writes the state object to disk. + */ + _saveStateObject(aStateObj) { + if (!this.store) { + console.error( + "SessionStoreManager: could not create data store from file" + ); + return; + } + + let currentStateString = JSON.stringify(aStateObj); + let storedStateString = + this.store.dataReady && this.store.data + ? JSON.stringify(this.store.data) + : null; + + // Do not save state (overwrite last good state) in case of a failed startup. + // Write async to disk only if state changed since last write. + if (!this._restored || currentStateString == storedStateString) { + return; + } + + this.store.data = aStateObj; + this.store.saveSoon(); + }, + + /** + * @returns an empty state object that can be populated with window states. + */ + _createStateObject() { + return { + rev: 0, + windows: [], + }; + }, + + /** + * Writes the state of all currently open 3pane windows to disk. + */ + _saveState() { + let state = this._createStateObject(); + + // XXX we'd like to support other window types in future, but for now + // only get the 3pane windows. + for (let win of Services.wm.getEnumerator("mail:3pane")) { + if ( + win && + "complete" == win.document.readyState && + win.getWindowStateForSessionPersistence + ) { + state.windows.push(win.getWindowStateForSessionPersistence()); + } + } + + this._saveStateObject(state); + }, + + // Timer Callback + _sessionAutoSaveTimerCallback() { + SessionStoreManager._saveState(); + }, + + // Observer Notification Handler + observe(aSubject, aTopic, aData) { + switch (aTopic) { + // This is observed before any windows start unloading if something other + // than the last 3pane window closing requested the application be + // shutdown. For example, when the user quits via the file menu. + case "quit-application-granted": + if (!this._shutdownStateSaved) { + this.stopPeriodicSave(); + this._saveState(); + + // this is to ensure we don't clobber the saved state when the + // 3pane windows unload. + this._shutdownStateSaved = true; + } + break; + } + }, + + // Public API + + /** + * Called by each 3pane window instance when it loads. + * + * @returns a window state object if aWindow was opened as a result of a + * session restoration, null otherwise. + */ + async loadingWindow(aWindow) { + let firstWindow = !this._initialized || this._shutdownStateSaved; + if (firstWindow) { + await this._init(); + } + + // If we are seeing a new 3-pane, we are obviously not in a shutdown + // state anymore. (This would happen if all the 3panes got closed but + // we did not quit because another window was open and then a 3pane showed + // up again. This can happen in both unit tests and real life.) + // We treat this case like the first window case, and do a session restore. + this._shutdownStateSaved = false; + + let windowState = null; + if (this._initialState && this._initialState.windows) { + windowState = this._initialState.windows.pop(); + if (0 == this._initialState.windows.length) { + this._initialState = null; + } + } + + if (firstWindow) { + this._openOtherRequiredWindows(aWindow); + } + + return windowState; + }, + + /** + * Called by each 3pane window instance when it unloads. If aWindow is the + * last 3pane window, its state is persisted. The last 3pane window unloads + * first before the "quit-application-granted" event is generated. + */ + unloadingWindow(aWindow) { + if (!this._shutdownStateSaved) { + // determine whether aWindow is the last open window + let lastWindow = true; + for (let win of Services.wm.getEnumerator("mail:3pane")) { + if (win != aWindow) { + lastWindow = false; + } + } + + if (lastWindow) { + // last chance to save any state for the current session since + // aWindow is the last 3pane window and the "quit-application-granted" + // event is observed AFTER this. + this.stopPeriodicSave(); + + let state = this._createStateObject(); + state.windows.push(aWindow.getWindowStateForSessionPersistence()); + this._saveStateObject(state); + + // XXX this is to ensure we don't clobber the saved state when we + // observe the "quit-application-granted" event. + this._shutdownStateSaved = true; + } + } + }, + + /** + * Stops periodic session persistence. + */ + stopPeriodicSave() { + if (this._sessionAutoSaveTimer) { + this._sessionAutoSaveTimer.cancel(); + + delete this._sessionAutoSaveTimer; + this._sessionAutoSaveTimer = null; + } + }, + + /** + * Starts periodic session persistence. + */ + startPeriodicSave() { + if (!this._sessionAutoSaveTimer) { + this._sessionAutoSaveTimer = Cc["@mozilla.org/timer;1"].createInstance( + Ci.nsITimer + ); + + this._sessionAutoSaveTimer.initWithCallback( + this._sessionAutoSaveTimerCallback, + this._sessionAutoSaveTimerIntervalMS, + Ci.nsITimer.TYPE_REPEATING_SLACK + ); + } + }, +}; diff --git a/comm/mail/modules/ShortcutsManager.jsm b/comm/mail/modules/ShortcutsManager.jsm new file mode 100644 index 0000000000..38167d887b --- /dev/null +++ b/comm/mail/modules/ShortcutsManager.jsm @@ -0,0 +1,345 @@ +/* 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/. */ + +/** + * Module used to collect all global shortcuts that can (will) be customizable. + * Use the shortcuts[] array to add global shortcuts that need to work on the + * whole window. The `context` property allows using the same shortcut for + * different context. The event handling needs to be defined in the window. + */ + +const EXPORTED_SYMBOLS = ["ShortcutsManager"]; + +const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +const ShortcutsManager = { + /** + * Fluent strings mapping to allow updating strings without changing all the + * IDs in the shortcuts.ftl file. This is needed because the IDs are + * dynamically generated. + * + * @type {object} + */ + fluentMapping: { + "meta-shift-alt-shortcut-key": "meta-shift-alt-shortcut-key2", + "ctrl-shift-alt-shortcut-key": "ctrl-shift-alt-shortcut-key2", + "meta-ctrl-shift-alt-shortcut-key": "meta-ctrl-shift-alt-shortcut-key2", + }, + + /** + * Data set for a shortcut. + * + * @typedef {object} Shortcut + * @property {string} id - The id for this shortcut. + * @property {string} name - The name of this shortcut. TODO: This should use + * fluent to be translatable in the future, once we decide to expose this + * array and make it customizable. + * @property {?string} key - The keyboard key used by this shortcut, or null + * if the shortcut is disabled. + * @property {object} modifiers - The list of modifiers expected by this + * shortcut in order to be triggered, organized per platform. + * @property {string[]} context - An array of strings representing the context + * string to filter out duplicated shortcuts, if necessary. + */ + /** + * @type {Shortcut[]} + */ + shortcuts: [ + /* Numbers. */ + { + id: "space-mail", + name: "Open the Mail space", + key: "1", + modifiers: { + win: { + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: true, + }, + macosx: { + metaKey: false, + ctrlKey: true, + shiftKey: false, + altKey: false, + }, + linux: { + metaKey: false, + ctrlKey: true, + shiftKey: false, + altKey: false, + }, + }, + context: [], + }, + { + id: "space-addressbook", + name: "Open the Address Book space", + key: "2", + modifiers: { + win: { + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: true, + }, + macosx: { + metaKey: false, + ctrlKey: true, + shiftKey: false, + altKey: false, + }, + linux: { + metaKey: false, + ctrlKey: true, + shiftKey: false, + altKey: false, + }, + }, + context: [], + }, + { + id: "space-calendar", + name: "Open the Calendar space", + key: "3", + modifiers: { + win: { + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: true, + }, + macosx: { + metaKey: false, + ctrlKey: true, + shiftKey: false, + altKey: false, + }, + linux: { + metaKey: false, + ctrlKey: true, + shiftKey: false, + altKey: false, + }, + }, + context: [], + }, + { + id: "space-tasks", + name: "Open the Tasks space", + key: "4", + modifiers: { + win: { + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: true, + }, + macosx: { + metaKey: false, + ctrlKey: true, + shiftKey: false, + altKey: false, + }, + linux: { + metaKey: false, + ctrlKey: true, + shiftKey: false, + altKey: false, + }, + }, + context: [], + }, + { + id: "space-chat", + name: "Open the Chat space", + key: "5", + modifiers: { + win: { + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: true, + }, + macosx: { + metaKey: false, + ctrlKey: true, + shiftKey: false, + altKey: false, + }, + linux: { + metaKey: false, + ctrlKey: true, + shiftKey: false, + altKey: false, + }, + }, + context: [], + }, + { + id: "space-toggle", + name: "Toggle the Spaces Toolbar", + key: null, // Disabled shortcut. + code: null, + modifiers: { + win: { + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + }, + macosx: { + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + }, + linux: { + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + }, + }, + context: [], + }, + /* Characters. */ + /* Special characters. */ + ], + + /** + * Find the matching shortcut from a keydown DOM Event. + * + * @param {Event} event - The keydown DOM Event. + * @param {?string} context - The context string to filter out duplicated + * shortcuts, if necessary. + * @returns {?Shortcut} - The matching shortcut, or null if nothing matches. + */ + matches(event, context = null) { + let found = []; + for (let shortcut of this.shortcuts) { + // No need to run any other condition if the base key doesn't match. + if (shortcut.key != event.key) { + continue; + } + + // Skip this key if we require a context not present, or we don't require + // a context and key has some. + if ( + (context && !shortcut.context.includes(context)) || + (!context && shortcut.context.length) + ) { + continue; + } + + found.push(shortcut); + } + + if (found.length > 1) { + // Trigger an error since we don't want to allow multiple shortcuts to + // run at the same time. If this happens, we got a problem! + throw new Error( + `Multiple shortcuts (${found + .map(f => f.id) + .join(",")}) are conflicting with the keydown event:\n + - KEY: ${event.key}\n + - CTRL: ${event.ctrlKey}\n + - META: ${event.metaKey}\n + - SHIFT: ${event.shiftKey}\n + - ALT: ${event.altKey}\n + - CONTEXT: ${context}\n` + ); + } + + if (!found.length) { + return null; + } + + let shortcut = found[0]; + let mods = shortcut.modifiers[AppConstants.platform]; + // Return the shortcut if it doesn't require any modifier and no modifier + // is present in the key press event. + if ( + !Object.keys(mods).length && + !(event.ctrlKey || event.metaKey) && + !event.shiftKey && + !event.altKey + ) { + return shortcut; + } + + // Perfectly match all modifiers to prevent false positives. + return mods.metaKey == event.metaKey && + mods.ctrlKey == event.ctrlKey && + mods.shiftKey == event.shiftKey && + mods.altKey == event.altKey + ? shortcut + : null; + }, + + /** + * Generate a string that will be used to create the fluent ID to visually + * represent the keyboard shortcut. + * + * @param {string} id - The ID of the requested shortcut. + * @returns {?object} - An object containing the generate shortcut and aria + * string, if available. + * @property {string} localizedShortcut - The shortcut in a human-readable, + * localized and platform-specific form. + * @property {string} ariaKeyShortcuts - The shortcut in a form appropriate + * for the aria-keyshortcuts attribute. + */ + async getShortcutStrings(id) { + let shortcut = this.shortcuts.find(s => s.id == id); + if (!shortcut?.key) { + return null; + } + + let platform = AppConstants.platform; + let string = []; + let aria = []; + if (shortcut.modifiers[platform].metaKey) { + string.push("meta"); + aria.push("Meta"); + } + + if (shortcut.modifiers[platform].ctrlKey) { + string.push("ctrl"); + aria.push("Control"); + } + + if (shortcut.modifiers[platform].shiftKey) { + string.push("shift"); + aria.push("Shift"); + } + + if (shortcut.modifiers[platform].altKey) { + string.push("alt"); + aria.push("Alt"); + } + string.push("shortcut-key"); + aria.push(shortcut.key.toUpperCase()); + + // Check if the ID was updated in the fluent file and replace it. + let stringId = string.join("-"); + stringId = this.fluentMapping[stringId] || stringId; + + let value = await this.l10n.formatValue(stringId, { + key: shortcut.key.toUpperCase(), + }); + + return { localizedShortcut: value, ariaKeyShortcuts: aria.join("+") }; + }, +}; + +XPCOMUtils.defineLazyGetter( + ShortcutsManager, + "l10n", + () => new Localization(["messenger/shortcuts.ftl"]) +); diff --git a/comm/mail/modules/SummaryFrameManager.jsm b/comm/mail/modules/SummaryFrameManager.jsm new file mode 100644 index 0000000000..dc8261eb3a --- /dev/null +++ b/comm/mail/modules/SummaryFrameManager.jsm @@ -0,0 +1,103 @@ +/* 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/. */ + +const EXPORTED_SYMBOLS = ["SummaryFrameManager"]; + +/** + * The SummaryFrameManager manages the source attribute of iframes which can + * be multi-purposed. For example, the thread/multimessage summary and the + * folder summary both use it. The SummaryFrameManager takes care of + * causing the content file to be reloaded as necessary, and manages event + * handlers, so that the right callback is called when the specified + * document is loaded. + * + * @param aFrame the iframe that we're managing + */ +function SummaryFrameManager(aFrame) { + this.iframe = aFrame; + this.iframe.addEventListener( + "DOMContentLoaded", + this._onLoad.bind(this), + true + ); + this.pendingCallback = null; + this.pendingOrLoadedUrl = this.iframe.docShell + ? this.iframe.contentDocument.location.href + : "about:blank"; + this.callback = null; + this.url = ""; +} + +SummaryFrameManager.prototype = { + /** + * Clear the summary frame. + */ + clear() { + this.loadAndCallback("about:blank"); + }, + + /** + * Load the specified URL if necessary, and cause the specified callback to be + * called either when the document is loaded, or immediately if the document + * is already loaded. + * + * @param aUrl the URL to load + * @param aCallback the callback to run when the URL has loaded; this function + * is passed a single boolean indicating if the URL was changed + */ + loadAndCallback(aUrl, aCallback) { + this.url = aUrl; + if (this.pendingOrLoadedUrl != aUrl) { + // We're changing the document. Stash the callback that we want to call + // when it's done loading + this.pendingCallback = aCallback; + this.callback = null; // clear it + this.iframe.contentDocument.location.href = aUrl; + this.pendingOrLoadedUrl = aUrl; + } else if (!this.pendingCallback) { + // We're being called, but the document has been set already -- either + // we've already received the DOMContentLoaded event, in which case we can + // just call the callback directly, or we're still loading in which case + // we should just wait for the dom event handler, but update the callback. + + this.callback = aCallback; + if (this.callback) { + this.callback(false); + } + } else { + this.pendingCallback = aCallback; + } + }, + + _onLoad(event) { + try { + // Make sure we're responding to the summary frame being loaded, and not + // some subnode. + if ( + event.target != this.iframe.contentDocument || + this.pendingOrLoadedUrl == "about:blank" + ) { + return; + } + if (event.target.ownerGlobal.location.href == "about:blank") { + return; + } + + this.callback = this.pendingCallback; + this.pendingCallback = null; + if ( + this.pendingOrLoadedUrl != this.iframe.contentDocument.location.href + ) { + console.error( + "Please do not load stuff in the multimessage browser directly, " + + "use the SummaryFrameManager instead." + ); + } else if (this.callback) { + this.callback(true); + } + } catch (e) { + console.error(e); + } + }, +}; diff --git a/comm/mail/modules/TBDistCustomizer.jsm b/comm/mail/modules/TBDistCustomizer.jsm new file mode 100644 index 0000000000..0de0c3ceb3 --- /dev/null +++ b/comm/mail/modules/TBDistCustomizer.jsm @@ -0,0 +1,162 @@ +/* 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/. */ + +const EXPORTED_SYMBOLS = ["TBDistCustomizer"]; + +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +var TBDistCustomizer = { + applyPrefDefaults() { + this._prefDefaultsApplied = true; + if (!this._ini) { + return; + } + // Grab the sections of the ini file + let sections = enumToObject(this._ini.getSections()); + + // The global section, and several of its fields, is required + // Function exits if this section and its fields are not present + if (!sections.Global) { + return; + } + + // Get the keys in the "Global" section of the ini file + let globalPrefs = enumToObject(this._ini.getKeys("Global")); + if (!(globalPrefs.id && globalPrefs.version && globalPrefs.about)) { + return; + } + + // Get the entire preferences tree (defaults is an instance of nsIPrefBranch) + let defaults = Services.prefs.getDefaultBranch(null); + + // Set the following user prefs + defaults.setCharPref( + "distribution.id", + this._ini.getString("Global", "id") + ); + defaults.setCharPref( + "distribution.version", + this._ini.getString("Global", "version") + ); + let partnerAbout; + if (globalPrefs["about." + this._locale]) { + partnerAbout = this._ini.getString("Global", "about." + this._locale); + } else { + partnerAbout = this._ini.getString("Global", "about"); + } + defaults.setStringPref("distribution.about", partnerAbout); + + if (sections.Preferences) { + let keys = this._ini.getKeys("Preferences"); + for (let key of keys) { + try { + // Get the string value of the key + let value = this.parseValue(this._ini.getString("Preferences", key)); + // After determining what type it is, set the pref + switch (typeof value) { + case "boolean": + defaults.setBoolPref(key, value); + break; + case "number": + defaults.setIntPref(key, value); + break; + case "string": + defaults.setCharPref(key, value); + break; + case "undefined": + // In case of custom pref created by partner + defaults.setCharPref(key, value); + break; + } + } catch (e) { + console.error(e); + } + } + } + + // Set the prefs in the other sections + let localizedStr = Cc["@mozilla.org/pref-localizedstring;1"].createInstance( + Ci.nsIPrefLocalizedString + ); + + if (sections.LocalizablePreferences) { + let keys = this._ini.getKeys("LocalizablePreferences"); + for (let key of keys) { + try { + let value = this.parseValue( + this._ini.getString("LocalizablePreferences", key) + ); + value = value.replace(/%LOCALE%/g, this._locale); + localizedStr.data = "data:text/plain," + key + "=" + value; + defaults.setComplexValue( + key, + Ci.nsIPrefLocalizedString, + localizedStr + ); + } catch (e) { + console.error(e); + } + } + } + + if (sections["LocalizablePreferences-" + this._locale]) { + let keys = this._ini.getKeys("LocalizablePreferences-" + this._locale); + for (let key of keys) { + try { + let value = this.parseValue( + this._ini.getString("LocalizablePreferences-" + this._locale, key) + ); + localizedStr.data = "data:text/plain," + key + "=" + value; + defaults.setComplexValue( + key, + Ci.nsIPrefLocalizedString, + localizedStr + ); + } catch (e) { + console.error(e); + } + } + } + }, + + parseValue(value) { + try { + value = JSON.parse(value); + } catch (e) { + // JSON.parse catches numbers and booleans. + // Anything else, we assume is a string. + // Remove the quotes that aren't needed anymore. + value = value.replace(/^"/, ""); + value = value.replace(/"$/, ""); + } + return value; + }, +}; + +XPCOMUtils.defineLazyGetter(TBDistCustomizer, "_ini", function () { + let ini = null; + let iniFile = Services.dirsvc.get("XCurProcD", Ci.nsIFile); + iniFile.append("distribution"); + iniFile.append("distribution.ini"); + if (iniFile.exists()) { + ini = Cc["@mozilla.org/xpcom/ini-parser-factory;1"] + .getService(Ci.nsIINIParserFactory) + .createINIParser(iniFile); + } + return ini; +}); + +XPCOMUtils.defineLazyGetter(TBDistCustomizer, "_locale", function () { + return Services.locale.requestedLocale; +}); + +function enumToObject(UTF8Enumerator) { + let ret = {}; + for (let UTF8Obj of UTF8Enumerator) { + ret[UTF8Obj] = 1; + } + return ret; +} diff --git a/comm/mail/modules/TabStateFlusher.jsm b/comm/mail/modules/TabStateFlusher.jsm new file mode 100644 index 0000000000..79771f133d --- /dev/null +++ b/comm/mail/modules/TabStateFlusher.jsm @@ -0,0 +1,8 @@ +/* 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/. */ + +// This module is deliberately not implemented. It only exists to keep +// the automated tests (those in services/sync) happy. + +var EXPORTED_SYMBOLS = []; diff --git a/comm/mail/modules/TagUtils.jsm b/comm/mail/modules/TagUtils.jsm new file mode 100644 index 0000000000..05747e7b8c --- /dev/null +++ b/comm/mail/modules/TagUtils.jsm @@ -0,0 +1,152 @@ +/* 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/. */ + +const { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" +); + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + Color: "resource://gre/modules/Color.sys.mjs", +}); + +var EXPORTED_SYMBOLS = ["TagUtils"]; + +var TagUtils = { + loadTagsIntoCSS, + addTagToAllDocumentSheets, + isColorContrastEnough, +}; + +function loadTagsIntoCSS(aDocument) { + let tagSheet = findTagColorSheet(aDocument); + let tagArray = MailServices.tags.getAllTags(); + for (let tag of tagArray) { + // tag.key is the internal key, like "$label1" for "Important". + // For user defined keys with non-ASCII characters, key is + // the MUTF-7 encoded name. + addTagToSheet(tag.key, tag.color, tagSheet); + } +} + +function addTagToAllDocumentSheets(aKey, aColor) { + for (let nextWin of Services.wm.getEnumerator("mail:3pane", true)) { + addTagToSheet(aKey, aColor, findTagColorSheet(nextWin.document)); + } + + for (let nextWin of Services.wm.getEnumerator("mailnews:search", true)) { + addTagToSheet(aKey, aColor, findTagColorSheet(nextWin.document)); + } +} + +function addTagToSheet(aKey, aColor, aSheet) { + if (!aSheet) { + return; + } + + // Add rules to sheet. + let ruleString1; + let ruleString2; + let ruleString3; + let ruleString4; + let selector = MailServices.tags.getSelectorForKey(aKey); + if (!aColor) { + ruleString1 = + ":root[lwt-tree] treechildren::-moz-tree-row(" + + selector + + ", selected, focus) { background-color: " + + "var(--sidebar-highlight-background-color) !important; }"; + ruleString2 = + "treechildren::-moz-tree-cell-text(" + + selector + + ", selected, focus) { color: SelectedItemText !important; }"; + ruleString3 = + "tree:-moz-lwtheme treechildren::-moz-tree-cell-text(" + + selector + + ", selected) { color: currentColor !important; }"; + ruleString4 = + ":root[lwt-tree] treechildren::-moz-tree-cell-text(" + + selector + + ", selected, focus) { color: var(--sidebar-highlight-text-color, " + + "var(--sidebar-text-color)) !important; }"; + } else { + ruleString1 = + "treechildren::-moz-tree-row(" + + selector + + ", selected, focus) { background-color: " + + aColor + + " !important; outline-color: color-mix(in srgb, " + + aColor + + ", black 25%); }"; + ruleString2 = + "treechildren::-moz-tree-cell-text(" + + selector + + ") { color: " + + aColor + + "; }"; + let textColor = "black"; + if (!isColorContrastEnough(aColor)) { + textColor = "white"; + } + ruleString3 = + "treechildren::-moz-tree-cell-text(" + + selector + + ", selected, focus) { color: " + + textColor + + " }"; + ruleString4 = + "treechildren::-moz-tree-image(" + + selector + + ", selected, focus)," + + "treechildren::-moz-tree-twisty(" + + selector + + ", selected, focus) { --select-focus-text-color: " + + textColor + + "; }"; + } + try { + aSheet.insertRule(ruleString1, aSheet.cssRules.length); + aSheet.insertRule(ruleString2, aSheet.cssRules.length); + aSheet.insertRule(ruleString3, aSheet.cssRules.length); + aSheet.insertRule(ruleString4, aSheet.cssRules.length); + } catch (ex) { + aSheet.ownerNode.addEventListener( + "load", + () => addTagToSheet(aKey, aColor, aSheet), + { once: true } + ); + } +} + +function findTagColorSheet(aDocument) { + const cssUri = "chrome://messenger/skin/tagColors.css"; + let tagSheet = null; + for (let sheet of aDocument.styleSheets) { + if (sheet.href == cssUri) { + tagSheet = sheet; + break; + } + } + if (!tagSheet) { + console.error("TagUtils.findTagColorSheet: tagColors.css not found"); + } + return tagSheet; +} + +/* Checks if black writing on 'aColor' background has enough contrast */ +function isColorContrastEnough(aColor) { + // Is a color set? If not, return "true" to use the default color. + if (!aColor) { + return true; + } + // Zero-pad the number just to make sure that it is 8 digits. + let colorHex = ("00000000" + aColor).substr(-8); + let colorArray = colorHex.match(/../g); + let [, cR, cG, cB] = colorArray.map(val => parseInt(val, 16)); + return new lazy.Color(cR, cG, cB).isContrastRatioAcceptable( + new lazy.Color(0, 0, 0), + "AAA" + ); +} diff --git a/comm/mail/modules/UIDensity.jsm b/comm/mail/modules/UIDensity.jsm new file mode 100644 index 0000000000..be94ed1408 --- /dev/null +++ b/comm/mail/modules/UIDensity.jsm @@ -0,0 +1,76 @@ +/* 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/. */ + +const EXPORTED_SYMBOLS = ["UIDensity"]; + +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +var registeredWindows = new Set(); + +function updateWindow(win) { + switch (UIDensity.prefValue) { + case UIDensity.MODE_COMPACT: + win.document.documentElement.setAttribute("uidensity", "compact"); + break; + case UIDensity.MODE_TOUCH: + win.document.documentElement.setAttribute("uidensity", "touch"); + break; + default: + win.document.documentElement.removeAttribute("uidensity"); + break; + } + + if (win.TabsInTitlebar !== undefined) { + win.TabsInTitlebar.update(); + } + + win.dispatchEvent( + new win.CustomEvent("uidensitychange", { detail: UIDensity.prefValue }) + ); +} + +function updateAllWindows() { + for (let win of registeredWindows) { + updateWindow(win); + } +} + +var UIDensity = { + MODE_COMPACT: 0, + MODE_NORMAL: 1, + MODE_TOUCH: 2, + + prefName: "mail.uidensity", + + /** + * Set the UI density. + * + * @param {integer} mode - One of the MODE constants. + */ + setMode(mode) { + Services.prefs.setIntPref(this.prefName, mode); + }, + + /** + * Register a window to be updated if the mode ever changes. The current + * value is applied to the window. Deregistration is automatic. + * + * @param {Window} win + */ + registerWindow(win) { + registeredWindows.add(win); + win.addEventListener("unload", () => registeredWindows.delete(win)); + updateWindow(win); + }, +}; + +XPCOMUtils.defineLazyPreferenceGetter( + UIDensity, + "prefValue", + UIDensity.prefName, + null, + updateAllWindows +); diff --git a/comm/mail/modules/UIFontSize.jsm b/comm/mail/modules/UIFontSize.jsm new file mode 100644 index 0000000000..dd5a6db101 --- /dev/null +++ b/comm/mail/modules/UIFontSize.jsm @@ -0,0 +1,346 @@ +/* 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/. */ + +"use strict"; + +const EXPORTED_SYMBOLS = ["UIFontSize"]; + +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +var langGroup = Services.prefs.getComplexValue( + "font.language.group", + Ci.nsIPrefLocalizedString +).data; + +var registeredWindows = new Set(); + +/** + * Update the font size of the registered window. + * + * @param {Window} win - The window to be registered. + */ +function updateWindow(win) { + let tabmail = win.document.getElementById("tabmail"); + let browser = + tabmail?.getBrowserForSelectedTab() || + win.document.getElementById("messagepane"); + + if ( + UIFontSize.prefValue == UIFontSize.DEFAULT || + UIFontSize.prefValue == UIFontSize.user_value + ) { + win.document.documentElement.style.removeProperty("font-size"); + UIFontSize.updateMessageBrowser(browser); + UIFontSize.updateAppMenuButton(win); + return; + } + + // Prevent any font update if the defined value can make the UI unusable. + if ( + UIFontSize.prefValue < UIFontSize.MIN_VALUE || + UIFontSize.prefValue > UIFontSize.MAX_VALUE + ) { + // Reset to the default font size. + UIFontSize.size = 0; + Services.console.logStringMessage( + `Unsupported font size: ${UIFontSize.prefValue}` + ); + return; + } + + // Add the font size to the HTML document element. + win.document.documentElement.style.setProperty( + "font-size", + `${UIFontSize.prefValue}px` + ); + + UIFontSize.updateMessageBrowser(browser); + UIFontSize.updateAppMenuButton(win); +} + +/** + * Loop through all registered windows and update the font size. + */ +function updateAllWindows() { + for (let win of registeredWindows) { + updateWindow(win); + } +} + +/** + * The object controlling the global font size. + */ +var UIFontSize = { + // Default value is 0 so we know the font wasn't changed. + DEFAULT: 0, + // Font size limit to avoid unusable UI. + MIN_VALUE: 9, + MAX_VALUE: 30, + // The default font size of the user's OS, rounded to integer. We use this in + // order to prevent issues in case the user has a float default font size + // (e.g.: 14.345px). By rounding to an INT, we can always go back the original + // default font size and the rounding doesn't affect the actual sizing but + // only the value shown to the user. + user_value: 0, + + // Keeps track of the custom value while in safe mode. + safe_mode_value: 0, + + // Keep track of the state of the custom font size. We use this instead of the + // size attribute because we need to keep track of when things changed back to + // a default state, and using the size attribute wouldn't be accurate. + isEdited: false, + + /** + * Set the font size. + * + * @param {integer} size - The new size value. + */ + set size(size) { + this.isEdited = true; + Services.prefs.setIntPref("mail.uifontsize", size); + }, + + /** + * Get the font size. + * + * @returns {integer} - The current font size defined in the pref or the value + * defined by the OS, extracted from the messenger window computed style. + */ + get size() { + // If the pref is set to 0, it means the user never changed font size so we + // return the default OS font size. + return this.prefValue || this.user_value; + }, + + /** + * Get the font size to be applied to the message browser. + * + * @param {boolean} isPlainText - If the current message is in plain text. + * @returns {int} - The font size to apply to the message, changed relative to + * the default preferences. + */ + browserSize(isPlainText) { + if (isPlainText) { + let monospaceSize = Services.prefs.getIntPref( + "font.size.monospace." + langGroup, + this.size + ); + return monospaceSize + (this.size - this.user_value); + } + let variableSize = Services.prefs.getIntPref( + "font.size.variable." + langGroup, + this.size + ); + return variableSize + (this.size - this.user_value); + }, + + /** + * Register a window to be updated if the size ever changes. The current + * value is applied to the window. Deregistration is automatic. + * + * @param {Window} win - The window to be registered. + */ + registerWindow(win) { + // Save the edited pref so we can restore it, and set the user value to the + // default if the app is in safe mode to make sure we start from a clean + // state. + if (Services.appinfo.inSafeMode) { + this.safe_mode_value = this.size; + this.size = 0; + } + + // Fetch the default font size defined by the OS as soon as we register the + // first window. Don't do it again if we already have a value. + if (!this.user_value) { + let style = win + .getComputedStyle(win.document.documentElement) + .getPropertyValue("font-size"); + + // Store the rounded default value. + this.user_value = Math.round(parseFloat(style)); + } + + registeredWindows.add(win); + win.addEventListener("unload", () => { + registeredWindows.delete(win); + // If we deregistered all the windows (application is getting closed) and + // we're in safe mode, reset the font size value to the original one in + // case the user edited the font size while in safe mode. + if (!registeredWindows.size && Services.appinfo.inSafeMode) { + Services.prefs.setIntPref("mail.uifontsize", this.safe_mode_value); + } + }); + updateWindow(win); + }, + + /** + * Update the label of the PanelUI app menu to reflect the current font size. + * + * @param {Window} win - The window from where the app menu is visible. + */ + updateAppMenuButton(win) { + let panelButton = win.document.getElementById( + "appMenu-fontSizeReset-button" + ); + if (panelButton) { + win.document.l10n.setAttributes( + panelButton, + "appmenuitem-font-size-reset", + { + size: this.size, + } + ); + } + + win.document + .getElementById("appMenu-fontSizeReduce-button") + ?.toggleAttribute("disabled", this.size <= this.MIN_VALUE); + win.document + .getElementById("appMenu-fontSizeEnlarge-button") + ?.toggleAttribute("disabled", this.size >= this.MAX_VALUE); + }, + + reduceSize() { + if (this.size <= this.MIN_VALUE) { + return; + } + this.size--; + }, + + resetSize() { + this.size = 0; + }, + + increaseSize() { + if (this.size >= this.MAX_VALUE) { + return; + } + this.size++; + }, + + /** + * Update the font size of the document body element of a browser content. + * This is used primarily for each loaded message in the message pane. + * + * @param {XULElement} browser - The message browser element. + */ + updateMessageBrowser(browser) { + // Bail out if the font size wasn't changed, or we don't have a browser. + // This might happen if the method is called before the message browser is + // available in the DOM. + if (!this.isEdited || !browser) { + return; + } + + if (this.prefValue == this.DEFAULT || this.prefValue == this.user_value) { + browser.contentDocument?.body?.style.removeProperty("font-size"); + // Update the state indicator here only after we cleared the font size + // from the message browser. + this.isEdited = false; + return; + } + + // Check if the current message is in plain text. + let isPlainText = browser.contentDocument?.querySelector( + ".moz-text-plain, .moz-text-flowed" + ); + + browser.contentDocument?.body?.style.setProperty( + "font-size", + `${UIFontSize.browserSize(isPlainText)}px` + ); + + // We need to remove the inline font size written in the div wrapper of the + // body content in order to let our inline style take effect. + if (isPlainText) { + isPlainText.style.removeProperty("font-size"); + } + }, + + observe(win, topic, data) { + // Observe any new window or dialog that is opened and register it to + // inherit the font sizing variation. + switch (topic) { + // FIXME! Temporarily disabled until we can properly manage all dialogs. + // case "domwindowopened": + // win.addEventListener( + // "load", + // () => { + // this.registerWindow(win); + // }, + // { once: true } + // ); + // break; + + default: + break; + } + }, + + /** + * Ensure the subdialogs are properly resized to fit larger font size + * variations. + * This is copied from SubDialog.jsm:resizeDialog(), and we need to do that + * because that method triggers again the `resizeCallback` and `dialogopen` + * Event, which we use to detect the opening of a dialog, therefore calling + * the `resizeDialog()` method would cause an infinite loop. + * + * @param {SubDialog} dialog - The dialog prototype. + */ + resizeSubDialog(dialog) { + // No need to update the dialog size if the font size wasn't changed. + if (this.prefValue == this.DEFAULT) { + return; + } + let docEl = dialog._frame.contentDocument.documentElement; + + // These are deduced from styles which we don't change, so it's safe to get + // them now: + let boxHorizontalBorder = + 2 * + parseFloat(dialog._window.getComputedStyle(dialog._box).borderLeftWidth); + let frameHorizontalMargin = + 2 * parseFloat(dialog._window.getComputedStyle(dialog._frame).marginLeft); + + // Then determine and set a bunch of width stuff: + let { scrollWidth } = docEl.ownerDocument.body || docEl; + let frameMinWidth = docEl.style.width || scrollWidth + "px"; + let frameWidth = docEl.getAttribute("width") + ? docEl.getAttribute("width") + "px" + : frameMinWidth; + + if (dialog._box.getAttribute("sizeto") != "available") { + dialog._frame.style.width = frameWidth; + } + + let boxMinWidth = `calc(${ + boxHorizontalBorder + frameHorizontalMargin + }px + ${frameMinWidth})`; + + // Temporary fix to allow parent chrome to collapse properly to min width. + // See Bug 1658722. + if (dialog._window.isChromeWindow) { + boxMinWidth = `min(80vw, ${boxMinWidth})`; + } + dialog._box.style.minWidth = boxMinWidth; + + dialog.resizeVertically(); + }, +}; + +/** + * Bind the font size pref change to the updateAllWindows method. + */ +XPCOMUtils.defineLazyPreferenceGetter( + UIFontSize, + "prefValue", + "mail.uifontsize", + null, + updateAllWindows +); + +Services.ww.registerNotification(UIFontSize); diff --git a/comm/mail/modules/WindowsJumpLists.jsm b/comm/mail/modules/WindowsJumpLists.jsm new file mode 100644 index 0000000000..8bce51a3a3 --- /dev/null +++ b/comm/mail/modules/WindowsJumpLists.jsm @@ -0,0 +1,262 @@ +/* -*- Mode: JavaScript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* 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/. */ + +var EXPORTED_SYMBOLS = ["WinTaskbarJumpList"]; + +var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +// Prefs +var PREF_TASKBAR_BRANCH = "mail.taskbar.lists."; +var PREF_TASKBAR_ENABLED = "enabled"; +var PREF_TASKBAR_TASKS = "tasks.enabled"; + +const lazy = {}; + +XPCOMUtils.defineLazyGetter(lazy, "_stringBundle", function () { + return Services.strings.createBundle( + "chrome://messenger/locale/taskbar.properties" + ); +}); + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "_taskbarService", + "@mozilla.org/windows-taskbar;1", + "nsIWinTaskbar" +); + +XPCOMUtils.defineLazyGetter(lazy, "_prefs", function () { + return Services.prefs.getBranch(PREF_TASKBAR_BRANCH); +}); + +function _getString(aName) { + return lazy._stringBundle.GetStringFromName(aName); +} + +/** + * Task list + */ +var gTasks = [ + // Write new message + { + get title() { + return _getString("taskbar.tasks.composeMessage.label"); + }, + get description() { + return _getString("taskbar.tasks.composeMessage.description"); + }, + args: "-compose", + iconIndex: 2, // Write message icon + }, + + // Open address book + { + get title() { + return _getString("taskbar.tasks.openAddressBook.label"); + }, + get description() { + return _getString("taskbar.tasks.openAddressBook.description"); + }, + args: "-addressbook", + iconIndex: 3, // Open address book icon + }, +]; + +var WinTaskbarJumpList = { + /** + * Startup, shutdown, and update + */ + + startup() { + // exit if this isn't win7 or higher. + if (!this._initTaskbar()) { + return; + } + + // Store our task list config data + this._tasks = gTasks; + + // retrieve taskbar related prefs. + this._refreshPrefs(); + + // observer for our prefs branch + this._initObs(); + + this.update(); + }, + + update() { + // are we disabled via prefs? don't do anything! + if (!this._enabled) { + return; + } + + // do what we came here to do, update the taskbar jumplist + this._buildList(); + }, + + _shutdown() { + this._shuttingDown = true; + + this._free(); + }, + + /** + * List building + */ + + _buildList() { + // anything to build? + if (!this._showTasks) { + // don't leave the last list hanging on the taskbar. + this._deleteActiveJumpList(); + return; + } + + if (!this._startBuild()) { + return; + } + + if (this._showTasks) { + this._buildTasks(); + } + + this._commitBuild(); + }, + + /** + * Taskbar api wrappers + */ + + _startBuild() { + // This is useful if there are any async tasks pending. Since we don't right + // now, it's just harmless. + this._builder.abortListBuild(); + // Since our list is static right now, we won't actually get back any + // removed items. + let removedItems = Cc["@mozilla.org/array;1"].createInstance( + Ci.nsIMutableArray + ); + return this._builder.initListBuild(removedItems); + }, + + _commitBuild() { + this._builder.commitListBuild(succeed => { + if (!succeed) { + this._builder.abortListBuild(); + } + }); + }, + + _buildTasks() { + if (this._tasks.length > 0) { + var items = Cc["@mozilla.org/array;1"].createInstance(Ci.nsIMutableArray); + for (let item of this._tasks.map(task => + this._createHandlerAppItem(task) + )) { + items.appendElement(item); + } + this._builder.addListToBuild( + this._builder.JUMPLIST_CATEGORY_TASKS, + items + ); + } + }, + + _deleteActiveJumpList() { + this._builder.deleteActiveList(); + }, + + /** + * Jump list item creation helpers + */ + + _createHandlerAppItem(aTask) { + let file = Services.dirsvc.get("XCurProcD", Ci.nsIFile); + + // XXX where can we grab this from in the build? Do we need to? + file.append("thunderbird.exe"); + + let handlerApp = Cc[ + "@mozilla.org/uriloader/local-handler-app;1" + ].createInstance(Ci.nsILocalHandlerApp); + handlerApp.executable = file; + // handlers default to the leaf name if a name is not specified + let title = aTask.title; + if (title && title.length != 0) { + handlerApp.name = title; + } + handlerApp.detailedDescription = aTask.description; + handlerApp.appendParameter(aTask.args); + + let item = Cc["@mozilla.org/windows-jumplistshortcut;1"].createInstance( + Ci.nsIJumpListShortcut + ); + item.app = handlerApp; + item.iconIndex = aTask.iconIndex; + return item; + }, + + _createSeparatorItem() { + return Cc["@mozilla.org/windows-jumplistseparator;1"].createInstance( + Ci.nsIJumpListSeparator + ); + }, + + /** + * Prefs utilities + */ + + _refreshPrefs() { + this._enabled = lazy._prefs.getBoolPref(PREF_TASKBAR_ENABLED); + this._showTasks = lazy._prefs.getBoolPref(PREF_TASKBAR_TASKS); + }, + + /** + * Init and shutdown utilities + */ + + _initTaskbar() { + this._builder = lazy._taskbarService.createJumpListBuilder(false); + if (!this._builder || !this._builder.available) { + return false; + } + + return true; + }, + + _initObs() { + Services.obs.addObserver(this, "profile-before-change"); + lazy._prefs.addObserver("", this); + }, + + _freeObs() { + Services.obs.removeObserver(this, "profile-before-change"); + lazy._prefs.removeObserver("", this); + }, + + observe(aSubject, aTopic, aData) { + switch (aTopic) { + case "nsPref:changed": + if (this._enabled && !lazy._prefs.getBoolPref(PREF_TASKBAR_ENABLED)) { + this._deleteActiveJumpList(); + } + this._refreshPrefs(); + this.update(); + break; + + case "profile-before-change": + this._shutdown(); + break; + } + }, + + _free() { + this._freeObs(); + delete this._builder; + }, +}; diff --git a/comm/mail/modules/moz.build b/comm/mail/modules/moz.build new file mode 100644 index 0000000000..b171e6ba1a --- /dev/null +++ b/comm/mail/modules/moz.build @@ -0,0 +1,47 @@ +# vim: set filetype=python: +# 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/. + +EXTRA_JS_MODULES += [ + "AttachmentChecker.jsm", + "AttachmentInfo.sys.mjs", + "BrowserWindowTracker.jsm", + "ConversationOpener.jsm", + "DBViewWrapper.jsm", + "DisplayNameUtils.jsm", + "DNS.jsm", + "ExtensionsUI.jsm", + "ExtensionSupport.jsm", + "FolderTreeProperties.jsm", + "GlobalPopupNotifications.jsm", + "MailConsts.jsm", + "MailE10SUtils.jsm", + "MailMigrator.jsm", + "MailUsageTelemetry.jsm", + "MailUtils.jsm", + "MailViewManager.jsm", + "MessageArchiver.jsm", + "MsgHdrSyntheticView.jsm", + "PhishingDetector.jsm", + "QuickFilterManager.jsm", + "SearchSpec.jsm", + "SelectionWidgetController.jsm", + "SessionStoreManager.jsm", + "ShortcutsManager.jsm", + "SummaryFrameManager.jsm", + "TagUtils.jsm", + "TBDistCustomizer.jsm", + "UIDensity.jsm", + "UIFontSize.jsm", +] + +EXTRA_JS_MODULES.sessionstore += [ + "SessionStore.jsm", + "TabStateFlusher.jsm", +] + +if CONFIG["MOZ_WIDGET_TOOLKIT"] == "windows": + EXTRA_JS_MODULES += [ + "WindowsJumpLists.jsm", + ] |