summaryrefslogtreecommitdiffstats
path: root/comm/mail/modules
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 17:32:43 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 17:32:43 +0000
commit6bf0a5cb5034a7e684dcc3500e841785237ce2dd (patch)
treea68f146d7fa01f0134297619fbe7e33db084e0aa /comm/mail/modules
parentInitial commit. (diff)
downloadthunderbird-upstream.tar.xz
thunderbird-upstream.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')
-rw-r--r--comm/mail/modules/AttachmentChecker.jsm118
-rw-r--r--comm/mail/modules/AttachmentInfo.sys.mjs626
-rw-r--r--comm/mail/modules/BrowserWindowTracker.jsm8
-rw-r--r--comm/mail/modules/ConversationOpener.jsm69
-rw-r--r--comm/mail/modules/DBViewWrapper.jsm2250
-rw-r--r--comm/mail/modules/DNS.jsm493
-rw-r--r--comm/mail/modules/DisplayNameUtils.jsm130
-rw-r--r--comm/mail/modules/ExtensionSupport.jsm240
-rw-r--r--comm/mail/modules/ExtensionsUI.jsm1461
-rw-r--r--comm/mail/modules/FolderTreeProperties.jsm84
-rw-r--r--comm/mail/modules/GlobalPopupNotifications.jsm1606
-rw-r--r--comm/mail/modules/MailConsts.jsm39
-rw-r--r--comm/mail/modules/MailE10SUtils.jsm98
-rw-r--r--comm/mail/modules/MailMigrator.jsm1200
-rw-r--r--comm/mail/modules/MailUsageTelemetry.jsm362
-rw-r--r--comm/mail/modules/MailUtils.jsm820
-rw-r--r--comm/mail/modules/MailViewManager.jsm169
-rw-r--r--comm/mail/modules/MessageArchiver.jsm392
-rw-r--r--comm/mail/modules/MsgHdrSyntheticView.jsm67
-rw-r--r--comm/mail/modules/PhishingDetector.jsm335
-rw-r--r--comm/mail/modules/QuickFilterManager.jsm1369
-rw-r--r--comm/mail/modules/SearchSpec.jsm562
-rw-r--r--comm/mail/modules/SelectionWidgetController.jsm1355
-rw-r--r--comm/mail/modules/SessionStore.jsm16
-rw-r--r--comm/mail/modules/SessionStoreManager.jsm302
-rw-r--r--comm/mail/modules/ShortcutsManager.jsm345
-rw-r--r--comm/mail/modules/SummaryFrameManager.jsm103
-rw-r--r--comm/mail/modules/TBDistCustomizer.jsm162
-rw-r--r--comm/mail/modules/TabStateFlusher.jsm8
-rw-r--r--comm/mail/modules/TagUtils.jsm152
-rw-r--r--comm/mail/modules/UIDensity.jsm76
-rw-r--r--comm/mail/modules/UIFontSize.jsm346
-rw-r--r--comm/mail/modules/WindowsJumpLists.jsm262
-rw-r--r--comm/mail/modules/moz.build47
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",
+ ]