From 6bf0a5cb5034a7e684dcc3500e841785237ce2dd Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Sun, 7 Apr 2024 19:32:43 +0200 Subject: Adding upstream version 1:115.7.0. Signed-off-by: Daniel Baumann --- comm/mail/base/content/about3Pane.js | 7260 ++++++++++++++++++++++++++++++++++ 1 file changed, 7260 insertions(+) create mode 100644 comm/mail/base/content/about3Pane.js (limited to 'comm/mail/base/content/about3Pane.js') diff --git a/comm/mail/base/content/about3Pane.js b/comm/mail/base/content/about3Pane.js new file mode 100644 index 0000000000..43e09a0acc --- /dev/null +++ b/comm/mail/base/content/about3Pane.js @@ -0,0 +1,7260 @@ +/* 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/. */ + +/* globals MozElements */ + +// mailCommon.js +/* globals commandController, DBViewWrapper, dbViewWrapperListener, + nsMsgViewIndex_None, VirtualFolderHelper */ +/* globals gDBView: true, gFolder: true, gViewWrapper: true */ + +// mailContext.js +/* globals mailContextMenu */ + +// globalOverlay.js +/* globals goDoCommand, goUpdateCommand */ + +// mail-offline.js +/* globals MailOfflineMgr */ + +// junkCommands.js +/* globals analyzeMessagesForJunk deleteJunkInFolder filterFolderForJunk */ + +// quickFilterBar.js +/* globals quickFilterBar */ + +// utilityOverlay.js +/* globals validateFileName */ + +var { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); +var { FolderTreeProperties } = ChromeUtils.import( + "resource:///modules/FolderTreeProperties.jsm" +); +var { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" +); +var { UIDensity } = ChromeUtils.import("resource:///modules/UIDensity.jsm"); +var { UIFontSize } = ChromeUtils.import("resource:///modules/UIFontSize.jsm"); +var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + FeedUtils: "resource:///modules/FeedUtils.jsm", + FolderUtils: "resource:///modules/FolderUtils.jsm", + MailUtils: "resource:///modules/MailUtils.jsm", + MailE10SUtils: "resource:///modules/MailE10SUtils.jsm", + MailStringUtils: "resource:///modules/MailStringUtils.jsm", + TagUtils: "resource:///modules/TagUtils.jsm", +}); + +const XULSTORE_URL = "chrome://messenger/content/messenger.xhtml"; + +const messengerBundle = Services.strings.createBundle( + "chrome://messenger/locale/messenger.properties" +); + +const { getDefaultColumns, getDefaultColumnsForCardsView, isOutgoing } = + ChromeUtils.importESModule( + "chrome://messenger/content/thread-pane-columns.mjs" + ); + +// As defined in nsMsgDBView.h. +const MSG_VIEW_FLAG_DUMMY = 0x20000000; + +/** + * The TreeListbox widget that displays folders. + */ +var folderTree; +/** + * The TreeView widget that displays the message list. + */ +var threadTree; +/** + * A XUL browser that displays web pages when required. + */ +var webBrowser; +/** + * A XUL browser that displays single messages. This browser always has + * about:message loaded. + */ +var messageBrowser; +/** + * A XUL browser that displays summaries of multiple messages or threads. + * This browser always has multimessageview.xhtml loaded. + */ +var multiMessageBrowser; +/** + * A XUL browser that displays Account Central when an account's root folder + * is selected. + */ +var accountCentralBrowser; + +window.addEventListener("DOMContentLoaded", async event => { + if (event.target != document) { + return; + } + + UIDensity.registerWindow(window); + UIFontSize.registerWindow(window); + + folderTree = document.getElementById("folderTree"); + accountCentralBrowser = document.getElementById("accountCentralBrowser"); + + paneLayout.init(); + folderPaneContextMenu.init(); + await folderPane.init(); + await threadPane.init(); + threadPaneHeader.init(); + await messagePane.init(); + + // Set up the initial state using information which may have been provided + // by mailTabs.js, or the saved state from the XUL store, or the defaults. + try { + // Do this in a try so that errors (e.g. bad data) don't prevent doing the + // rest of the important 3pane initialization below. + restoreState(window.openingState); + } catch (e) { + console.warn(`Couldn't restore state: ${e.message}`, e); + } + delete window.openingState; + + // Finally, add the folderTree listener and trigger it. Earlier events + // (triggered by `folderPane.init` and possibly `restoreState`) are ignored + // to avoid unnecessarily loading the thread tree or Account Central. + folderTree.addEventListener("select", folderPane); + folderTree.dispatchEvent(new CustomEvent("select")); + + // Attach the progress listener for the webBrowser. For the messageBrowser this + // happens in the "aboutMessageLoaded" event from aboutMessage.js. + // For the webBrowser, we can do it here directly. + top.contentProgress.addProgressListenerToBrowser(webBrowser); + + mailContextMenu.init(); +}); + +window.addEventListener("unload", () => { + MailServices.mailSession.RemoveFolderListener(folderListener); + gViewWrapper?.close(); + folderPane.uninit(); + threadPane.uninit(); + threadPaneHeader.uninit(); +}); + +var paneLayout = { + init() { + this.folderPaneSplitter = document.getElementById("folderPaneSplitter"); + this.messagePaneSplitter = document.getElementById("messagePaneSplitter"); + + for (let [splitter, properties, storeID] of [ + [this.folderPaneSplitter, ["width"], "folderPaneBox"], + [this.messagePaneSplitter, ["height", "width"], "messagepaneboxwrapper"], + ]) { + for (let property of properties) { + let value = Services.xulStore.getValue(XULSTORE_URL, storeID, property); + if (value) { + splitter[property] = value; + } + } + + splitter.storeAttr = function (attrName, attrValue) { + Services.xulStore.setValue(XULSTORE_URL, storeID, attrName, attrValue); + }; + + splitter.addEventListener("splitter-resized", () => { + if (splitter.resizeDirection == "vertical") { + splitter.storeAttr("height", splitter.height); + } else { + splitter.storeAttr("width", splitter.width); + } + }); + } + + this.messagePaneSplitter.addEventListener("splitter-collapsed", () => { + // Clear any loaded page or messages. + messagePane.clearAll(); + this.messagePaneSplitter.storeAttr("collapsed", true); + }); + + this.messagePaneSplitter.addEventListener("splitter-expanded", () => { + // Load the selected messages. + threadTree.dispatchEvent(new CustomEvent("select")); + this.messagePaneSplitter.storeAttr("collapsed", false); + }); + + XPCOMUtils.defineLazyPreferenceGetter( + this, + "layoutPreference", + "mail.pane_config.dynamic", + null, + (name, oldValue, newValue) => this.setLayout(newValue) + ); + this.setLayout(this.layoutPreference); + threadPane.updateThreadView( + Services.xulStore.getValue(XULSTORE_URL, "threadPane", "view") + ); + }, + + setLayout(preference) { + document.body.classList.remove( + "layout-classic", + "layout-vertical", + "layout-wide" + ); + switch (preference) { + case 1: + document.body.classList.add("layout-wide"); + this.messagePaneSplitter.resizeDirection = "vertical"; + break; + case 2: + document.body.classList.add("layout-vertical"); + this.messagePaneSplitter.resizeDirection = "horizontal"; + break; + default: + document.body.classList.add("layout-classic"); + this.messagePaneSplitter.resizeDirection = "vertical"; + break; + } + }, + + get accountCentralVisible() { + return document.body.classList.contains("account-central"); + }, + get folderPaneVisible() { + return !this.folderPaneSplitter.isCollapsed; + }, + set folderPaneVisible(visible) { + this.folderPaneSplitter.isCollapsed = !visible; + }, + get messagePaneVisible() { + return !this.messagePaneSplitter?.isCollapsed; + }, + set messagePaneVisible(visible) { + this.messagePaneSplitter.isCollapsed = !visible; + }, +}; + +var folderPaneContextMenu = { + /** + * @type {XULPopupElement} + */ + _menupopup: null, + + /** + * Commands handled by commandController. + * + * @type {Object.} + */ + _commands: { + "folderPaneContext-new": "cmd_newFolder", + "folderPaneContext-remove": "cmd_deleteFolder", + "folderPaneContext-rename": "cmd_renameFolder", + "folderPaneContext-compact": "cmd_compactFolder", + "folderPaneContext-properties": "cmd_properties", + "folderPaneContext-favoriteFolder": "cmd_toggleFavoriteFolder", + }, + + /** + * Current state of commandController commands. Set to null to invalidate + * the states. + * + * @type {Object.|null} + */ + _commandStates: null, + + init() { + this._menupopup = document.getElementById("folderPaneContext"); + this._menupopup.addEventListener("popupshowing", this); + this._menupopup.addEventListener("popuphidden", this); + this._menupopup.addEventListener("command", this); + folderTree.addEventListener("select", this); + }, + + handleEvent(event) { + switch (event.type) { + case "popupshowing": + this.onPopupShowing(event); + break; + case "popuphidden": + this.onPopupHidden(event); + break; + case "command": + this.onCommand(event); + break; + case "select": + this._commandStates = null; + break; + } + }, + + /** + * The folder that this context menu is operating on. This will be `gFolder` + * unless the menu was opened by right-clicking on another folder. + * + * @type {nsIMsgFolder} + */ + get activeFolder() { + return this._overrideFolder || gFolder; + }, + + /** + * Override the folder that this context menu should operate on. The effect + * lasts until `clearOverrideFolder` is called by `onPopupHidden`. + * + * @param {nsIMsgFolder} folder + */ + setOverrideFolder(folder) { + this._overrideFolder = folder; + this._commandStates = null; + }, + + /** + * Clear the overriding folder, and go back to using `gFolder`. + */ + clearOverrideFolder() { + this._overrideFolder = null; + this._commandStates = null; + }, + + /** + * Gets the enabled state of a command. If the state is unknown (because the + * selected folder has changed) the states of all the commands are worked + * out together to save unnecessary work. + * + * @param {string} command + */ + getCommandState(command) { + let folder = this.activeFolder; + if (!folder || FolderUtils.isSmartTagsFolder(folder)) { + return false; + } + if (this._commandStates === null) { + let { + canCompact, + canCreateSubfolders, + canRename, + deletable, + flags, + isServer, + server, + URI, + } = folder; + let isJunk = flags & Ci.nsMsgFolderFlags.Junk; + let isVirtual = flags & Ci.nsMsgFolderFlags.Virtual; + let isNNTP = server.type == "nntp"; + if (isNNTP && !isServer) { + // `folderPane.deleteFolder` has a special case for this. + deletable = true; + } + let isSmartTagsFolder = FolderUtils.isSmartTagsFolder(folder); + let showNewFolderItem = + (!isNNTP && canCreateSubfolders) || flags & Ci.nsMsgFolderFlags.Inbox; + + this._commandStates = { + cmd_newFolder: showNewFolderItem, + cmd_deleteFolder: isJunk + ? FolderUtils.canRenameDeleteJunkMail(URI) + : deletable, + cmd_renameFolder: + (!isServer && + canRename && + !(flags & Ci.nsMsgFolderFlags.SpecialUse)) || + isVirtual || + (isJunk && FolderUtils.canRenameDeleteJunkMail(URI)), + cmd_compactFolder: + !isVirtual && + (isServer || canCompact) && + folder.isCommandEnabled("cmd_compactFolder"), + cmd_emptyTrash: !isNNTP, + cmd_properties: !isServer && !isSmartTagsFolder, + cmd_toggleFavoriteFolder: !isServer && !isSmartTagsFolder, + }; + } + return this._commandStates[command]; + }, + + onPopupShowing(event) { + if (event.target != this._menupopup) { + return; + } + + function showItem(id, show) { + let item = document.getElementById(id); + if (item) { + item.hidden = !show; + } + } + + function checkItem(id, checked) { + let item = document.getElementById(id); + if (item) { + // Always convert truthy/falsy to boolean before string. + item.setAttribute("checked", !!checked); + } + } + + // Ask commandController about the commands it controls. + for (let [id, command] of Object.entries(this._commands)) { + showItem(id, commandController.isCommandEnabled(command)); + } + + let folder = this.activeFolder; + let { canCreateSubfolders, flags, isServer, isSpecialFolder, server } = + folder; + let isJunk = flags & Ci.nsMsgFolderFlags.Junk; + let isTrash = isSpecialFolder(Ci.nsMsgFolderFlags.Trash, true); + let isVirtual = flags & Ci.nsMsgFolderFlags.Virtual; + let isRealFolder = !isServer && !isVirtual; + let isSmartVirtualFolder = FolderUtils.isSmartVirtualFolder(folder); + let isSmartTagsFolder = FolderUtils.isSmartTagsFolder(folder); + let serverType = server.type; + + showItem( + "folderPaneContext-getMessages", + (isServer && serverType != "none") || + (["nntp", "rss"].includes(serverType) && !isTrash && !isVirtual) + ); + let showPauseAll = isServer && FeedUtils.isFeedFolder(folder); + showItem("folderPaneContext-pauseAllUpdates", showPauseAll); + if (showPauseAll) { + let optionsAcct = FeedUtils.getOptionsAcct(server); + checkItem("folderPaneContext-pauseAllUpdates", !optionsAcct.doBiff); + } + let showPaused = !isServer && FeedUtils.getFeedUrlsInFolder(folder); + showItem("folderPaneContext-pauseUpdates", showPaused); + if (showPaused) { + let properties = FeedUtils.getFolderProperties(folder); + checkItem( + "folderPaneContext-pauseUpdates", + properties.includes("isPaused") + ); + } + + showItem("folderPaneContext-searchMessages", !isVirtual); + if (isVirtual) { + showItem("folderPaneContext-subscribe", false); + } else if (serverType == "rss" && !isTrash) { + showItem("folderPaneContext-subscribe", true); + } else { + showItem( + "folderPaneContext-subscribe", + isServer && ["imap", "nntp"].includes(serverType) + ); + } + showItem( + "folderPaneContext-newsUnsubscribe", + isRealFolder && serverType == "nntp" + ); + + let showNewFolderItem = + (serverType != "nntp" && canCreateSubfolders) || + flags & Ci.nsMsgFolderFlags.Inbox; + if (showNewFolderItem) { + document + .getElementById("folderPaneContext-new") + .setAttribute( + "label", + messengerBundle.GetStringFromName( + isServer || flags & Ci.nsMsgFolderFlags.Inbox + ? "newFolder" + : "newSubfolder" + ) + ); + } + + showItem( + "folderPaneContext-markMailFolderAllRead", + !isServer && !isSmartTagsFolder && serverType != "nntp" + ); + showItem( + "folderPaneContext-markNewsgroupAllRead", + isRealFolder && serverType == "nntp" + ); + showItem( + "folderPaneContext-emptyTrash", + isSpecialFolder(Ci.nsMsgFolderFlags.Trash, true) + ); + showItem("folderPaneContext-emptyJunk", isJunk); + showItem( + "folderPaneContext-sendUnsentMessages", + flags & Ci.nsMsgFolderFlags.Queue + ); + + checkItem( + "folderPaneContext-favoriteFolder", + flags & Ci.nsMsgFolderFlags.Favorite + ); + showItem("folderPaneContext-markAllFoldersRead", isServer); + + showItem("folderPaneContext-settings", isServer); + + showItem("folderPaneContext-manageTags", isSmartTagsFolder); + + // If source folder is virtual, allow only "move" within its own server. + // Don't show "copy" and "again" and don't show "recent" and "favorite". + // Also, check if this is a top-level smart folder, e.g., virtual "Inbox" + // in unified folder view or a Tags folder. If so, don't show "move". + let movePopup = document.getElementById("folderContext-movePopup"); + if (isVirtual) { + showItem("folderPaneContext-copyMenu", false); + let showMove = true; + if (isSmartVirtualFolder || isSmartTagsFolder) { + showMove = false; + } + showItem("folderPaneContext-moveMenu", showMove); + if (showMove) { + let rootURI = MailUtils.getOrCreateFolder( + this.activeFolder.rootFolder.URI + ); + movePopup.parentFolder = rootURI; + } + } else { + // Non-virtual. Don't allow move or copy of special use or root folder. + let okToMoveCopy = !(isServer || flags & Ci.nsMsgFolderFlags.SpecialUse); + if (okToMoveCopy) { + // Set the move menu to show all accounts. + movePopup.parentFolder = null; + } + showItem("folderPaneContext-moveMenu", okToMoveCopy); + showItem("folderPaneContext-copyMenu", okToMoveCopy); + } + + let lastItem; + for (let child of document.getElementById("folderPaneContext").children) { + if (child.localName == "menuseparator") { + child.hidden = !lastItem || lastItem.localName == "menuseparator"; + } + if (!child.hidden) { + lastItem = child; + } + } + if (lastItem.localName == "menuseparator") { + lastItem.hidden = true; + } + }, + + onPopupHidden(event) { + if (event.target != this._menupopup) { + return; + } + + folderTree + .querySelector(".context-menu-target") + ?.classList.remove("context-menu-target"); + this.clearOverrideFolder(); + }, + + /** + * Check if the transfer mode selected from folder context menu is "copy". + * If "copy" (!isMove) is selected and the copy is within the same server, + * silently change to mode "move". + * Do the transfer and return true if moved, false if copied. + * + * @param {boolean} isMove + * @param {nsIMsgFolder} sourceFolder + * @param {nsIMsgFolder} targetFolder + */ + transferFolder(isMove, sourceFolder, targetFolder) { + if (!isMove && sourceFolder.server == targetFolder.server) { + // Don't allow folder copy within the same server; only move allowed. + // Can't copy folder intra-server, change to move. + isMove = true; + } + // Do the transfer. A slight delay in calling copyFolder() helps the + // folder-menupopup chain of items get properly closed so the next folder + // context popup can occur. + setTimeout(() => + MailServices.copy.copyFolder( + sourceFolder, + targetFolder, + isMove, + null, + top.msgWindow + ) + ); + return isMove; + }, + + onCommand(event) { + let folder = this.activeFolder; + // If commandController handles this command, ask it to do so. + if (event.target.id in this._commands) { + commandController.doCommand(this._commands[event.target.id], folder); + return; + } + + let topChromeWindow = window.browsingContext.topChromeWindow; + switch (event.target.id) { + case "folderPaneContext-getMessages": + topChromeWindow.MsgGetMessage([folder]); + break; + case "folderPaneContext-pauseAllUpdates": + topChromeWindow.MsgPauseUpdates( + [folder], + event.target.getAttribute("checked") == "true" + ); + break; + case "folderPaneContext-pauseUpdates": + topChromeWindow.MsgPauseUpdates( + [folder], + event.target.getAttribute("checked") == "true" + ); + break; + case "folderPaneContext-openNewTab": + topChromeWindow.MsgOpenNewTabForFolders([folder], { + event, + folderPaneVisible: !paneLayout.folderPaneSplitter.isCollapsed, + messagePaneVisible: !paneLayout.messagePaneSplitter.isCollapsed, + }); + break; + case "folderPaneContext-openNewWindow": + topChromeWindow.MsgOpenNewWindowForFolder(folder.URI, -1); + break; + case "folderPaneContext-searchMessages": + commandController.doCommand("cmd_searchMessages", folder); + break; + case "folderPaneContext-subscribe": + topChromeWindow.MsgSubscribe(folder); + break; + case "folderPaneContext-newsUnsubscribe": + topChromeWindow.MsgUnsubscribe([folder]); + break; + case "folderPaneContext-markMailFolderAllRead": + case "folderPaneContext-markNewsgroupAllRead": + if (folder.flags & Ci.nsMsgFolderFlags.Virtual) { + topChromeWindow.MsgMarkAllRead( + VirtualFolderHelper.wrapVirtualFolder(folder).searchFolders + ); + } else { + topChromeWindow.MsgMarkAllRead([folder]); + } + break; + case "folderPaneContext-emptyTrash": + folderPane.emptyTrash(folder); + break; + case "folderPaneContext-emptyJunk": + folderPane.emptyJunk(folder); + break; + case "folderPaneContext-sendUnsentMessages": + topChromeWindow.SendUnsentMessages(); + break; + case "folderPaneContext-properties": + folderPane.editFolder(folder); + break; + case "folderPaneContext-markAllFoldersRead": + topChromeWindow.MsgMarkAllFoldersRead([folder]); + break; + case "folderPaneContext-settings": + folderPane.editFolder(folder); + break; + case "folderPaneContext-manageTags": + goDoCommand("cmd_manageTags"); + break; + default: { + // Handle folder context menu items move to, copy to. + let isMove = false; + let isCopy = false; + let targetFolder; + if ( + document + .getElementById("folderPaneContext-moveMenu") + .contains(event.target) + ) { + // A move is requested via foldermenu-popup. + isMove = true; + } else if ( + document + .getElementById("folderPaneContext-copyMenu") + .contains(event.target) + ) { + // A copy is requested via foldermenu-popup. + isCopy = true; + } + if (isMove || isCopy) { + if (!targetFolder) { + targetFolder = event.target._folder; + } + isMove = this.transferFolder(isMove, folder, targetFolder); + // Save in prefs the target folder URI and if this was a move or + // copy. This is to fill in the next folder or message context + // menu item "Move|Copy to Again". + Services.prefs.setStringPref( + "mail.last_msg_movecopy_target_uri", + targetFolder.URI + ); + Services.prefs.setBoolPref("mail.last_msg_movecopy_was_move", isMove); + } + break; + } + } + }, +}; + +var folderPane = { + _initialized: false, + + /** + * If the local folders should be hidden. + * @type {boolean} + */ + _hideLocalFolders: false, + + _modes: { + all: { + name: "all", + active: false, + canBeCompact: false, + + initServer(server) { + let serverRow = folderPane._createServerRow(this.name, server); + folderPane._insertInServerOrder(this.containerList, serverRow); + folderPane._addSubFolders(server.rootFolder, serverRow, this.name); + }, + + addFolder(parentFolder, childFolder) { + FolderTreeProperties.setIsExpanded(childFolder.URI, this.name, true); + if ( + childFolder.server.hidden || + folderPane.getRowForFolder(childFolder, this.name) + ) { + // We're not displaying this server, or the folder already exists in + // the folder tree. Was `addFolder` called twice? + return; + } + if (!parentFolder) { + folderPane._insertInServerOrder( + this.containerList, + folderPane._createServerRow(this.name, childFolder.server) + ); + return; + } + + let parentRow = folderPane.getRowForFolder(parentFolder, this.name); + if (!parentRow) { + console.error("no parentRow for ", parentFolder.URI, childFolder.URI); + } + // To auto-expand non-root imap folders, imap URL "discoverchildren" is + // triggered -- but actually only occurs if server settings configured + // to ignore subscriptions. (This also occurs in _onExpanded() for + // manual folder expansion.) + if (parentFolder.server.type == "imap" && !parentFolder.isServer) { + parentFolder.QueryInterface(Ci.nsIMsgImapMailFolder); + parentFolder.performExpand(top.msgWindow); + } + folderTree.expandRow(parentRow); + let childRow = folderPane._createFolderRow(this.name, childFolder); + folderPane._addSubFolders(childFolder, childRow, "all"); + parentRow.insertChildInOrder(childRow); + }, + + removeFolder(parentFolder, childFolder) { + folderPane.getRowForFolder(childFolder, this.name)?.remove(); + }, + + changeAccountOrder() { + folderPane._reapplyServerOrder(this.containerList); + }, + }, + smart: { + name: "smart", + active: false, + canBeCompact: false, + + _folderTypes: [ + { flag: Ci.nsMsgFolderFlags.Inbox, name: "Inbox" }, + { flag: Ci.nsMsgFolderFlags.Drafts, name: "Drafts" }, + { flag: Ci.nsMsgFolderFlags.Templates, name: "Templates" }, + { flag: Ci.nsMsgFolderFlags.SentMail, name: "Sent" }, + { flag: Ci.nsMsgFolderFlags.Archive, name: "Archives" }, + { flag: Ci.nsMsgFolderFlags.Junk, name: "Junk" }, + { flag: Ci.nsMsgFolderFlags.Trash, name: "Trash" }, + // { flag: Ci.nsMsgFolderFlags.Queue, name: "Outbox" }, + ], + + init() { + this._smartServer = MailServices.accounts.findServer( + "nobody", + "smart mailboxes", + "none" + ); + if (!this._smartServer) { + this._smartServer = MailServices.accounts.createIncomingServer( + "nobody", + "smart mailboxes", + "none" + ); + // We don't want the "smart" server/account leaking out into the ui in + // other places, so set it as hidden. + this._smartServer.hidden = true; + let account = MailServices.accounts.createAccount(); + account.incomingServer = this._smartServer; + } + this._smartServer.prettyName = + messengerBundle.GetStringFromName("unifiedAccountName"); + let smartRoot = this._smartServer.rootFolder.QueryInterface( + Ci.nsIMsgLocalMailFolder + ); + + let allFlags = 0; + this._folderTypes.forEach(folderType => (allFlags |= folderType.flag)); + + for (let folderType of this._folderTypes) { + let folder = smartRoot.getChildWithURI( + `${smartRoot.URI}/${folderType.name}`, + false, + true + ); + if (!folder) { + try { + let searchFolders = []; + + function recurse(folder) { + let subFolders; + try { + subFolders = folder.subFolders; + } catch (ex) { + console.error( + new Error( + `Unable to access the subfolders of ${folder.URI}`, + { cause: ex } + ) + ); + } + if (!subFolders?.length) { + return; + } + + for (let sf of subFolders) { + // Add all of the subfolders except the ones that belong to + // a different folder type. + if (!(sf.flags & allFlags)) { + searchFolders.push(sf); + recurse(sf); + } + } + } + + for (let server of MailServices.accounts.allServers) { + for (let f of server.rootFolder.getFoldersWithFlags( + folderType.flag + )) { + searchFolders.push(f); + recurse(f); + } + } + + folder = smartRoot.createLocalSubfolder(folderType.name); + folder.flags |= Ci.nsMsgFolderFlags.Virtual | folderType.flag; + + let msgDatabase = folder.msgDatabase; + let folderInfo = msgDatabase.dBFolderInfo; + + folderInfo.setCharProperty("searchStr", "ALL"); + folderInfo.setCharProperty( + "searchFolderUri", + searchFolders.map(f => f.URI).join("|") + ); + folderInfo.setUint32Property("searchFolderFlag", folderType.flag); + folderInfo.setBooleanProperty("searchOnline", true); + msgDatabase.summaryValid = true; + msgDatabase.close(true); + + smartRoot.notifyFolderAdded(folder); + } catch (ex) { + console.error(ex); + continue; + } + } + let row = folderPane._createFolderRow(this.name, folder); + this.containerList.appendChild(row); + folderType.folderURI = folder.URI; + folderType.list = row.childList; + + // Display the searched folders for this type. + let wrappedFolder = VirtualFolderHelper.wrapVirtualFolder(folder); + for (let searchFolder of wrappedFolder.searchFolders) { + if (searchFolder != folder) { + this._addSearchedFolder( + folderType, + folderPane._getNonGmailParent(searchFolder), + searchFolder + ); + } + } + } + MailServices.accounts.saveVirtualFolders(); + }, + + regenerateMode() { + if (this._smartServer) { + MailServices.accounts.removeIncomingServer(this._smartServer, true); + } + this.init(); + }, + + _addSearchedFolder(folderType, parentFolder, childFolder) { + if (folderType.flag & childFolder.flags) { + // The folder has the flag for this type. + let folderRow = folderPane._createFolderRow( + this.name, + childFolder, + "server" + ); + folderPane._insertInServerOrder(folderType.list, folderRow); + return; + } + + if (!childFolder.isSpecialFolder(folderType.flag, true)) { + // This folder is searched by the virtual folder but it hasn't got + // the flag of this type and no ancestor has the flag of this type. + // We don't have a good way of displaying it. + return; + } + + // The folder is a descendant of one which has the flag. + let parentRow = folderPane.getRowForFolder(parentFolder, this.name); + if (!parentRow) { + // This is awkward: `childFolder` is searched but `parentFolder` is + // not. Displaying the unsearched folder is probably the least + // confusing way to handle this situation. + this._addSearchedFolder( + folderType, + folderPane._getNonGmailParent(parentFolder), + parentFolder + ); + parentRow = folderPane.getRowForFolder(parentFolder, this.name); + } + parentRow.insertChildInOrder( + folderPane._createFolderRow(this.name, childFolder) + ); + }, + + changeSearchedFolders(smartFolder) { + let folderType = this._folderTypes.find( + ft => ft.folderURI == smartFolder.URI + ); + if (!folderType) { + // This virtual folder isn't one of the smart folders. It's probably + // one of the tags virtual folders. + return; + } + + let wrappedFolder = VirtualFolderHelper.wrapVirtualFolder(smartFolder); + let smartFolderRow = folderPane.getRowForFolder(smartFolder, this.name); + let searchFolderURIs = wrappedFolder.searchFolders.map(sf => sf.URI); + let serversToCheck = new Set(); + + // Remove any rows which may belong to folders that aren't searched. + for (let row of [...smartFolderRow.querySelectorAll("li")]) { + if (!searchFolderURIs.includes(row.uri)) { + row.remove(); + let folder = MailServices.folderLookup.getFolderForURL(row.uri); + if (folder) { + serversToCheck.add(folder.server); + } + } + } + + // Add missing rows for folders that are searched. + let existingRowURIs = Array.from( + smartFolderRow.querySelectorAll("li"), + row => row.uri + ); + for (let searchFolder of wrappedFolder.searchFolders) { + if ( + searchFolder == smartFolder || + existingRowURIs.includes(searchFolder.URI) + ) { + continue; + } + let existingRow = folderPane.getRowForFolder(searchFolder, this.name); + if (existingRow) { + // A row for this folder exists, but not under the smart folder. + // Remove it and display under the smart folder. + folderPane._removeFolderAndAncestors(searchFolder, this.name, f => + searchFolderURIs.includes(f.URI) + ); + } + this._addSearchedFolder( + folderType, + folderPane._getNonGmailParent(searchFolder), + searchFolder + ); + } + + // For any rows we removed, check they are added back to the tree. + for (let server of serversToCheck) { + this.initServer(server); + } + }, + + initServer(server) { + // Find all folders in this server, and display the ones that aren't + // currently displayed. + let descendants = new Map( + server.rootFolder.descendants.map(d => [d.URI, d]) + ); + if (!descendants.size) { + return; + } + let remainingFolderURIs = Array.from(descendants.keys()); + + // Get a list of folders that already exist in the folder tree. + let existingRows = this.containerList.getElementsByTagName("li"); + let existingURIs = Array.from(existingRows, li => li.uri); + do { + let folderURI = remainingFolderURIs.shift(); + if (existingURIs.includes(folderURI)) { + continue; + } + let folder = descendants.get(folderURI); + if (folderPane._isGmailFolder(folder)) { + continue; + } + this.addFolder(folderPane._getNonGmailParent(folder), folder); + // Update the list of existing folders. `existingRows` is a live + // list, so we don't need to call `getElementsByTagName` again. + existingURIs = Array.from(existingRows, li => li.uri); + } while (remainingFolderURIs.length); + }, + + addFolder(parentFolder, childFolder) { + if (folderPane.getRowForFolder(childFolder, this.name)) { + // If a row for this folder exists, do nothing. + return; + } + if (!parentFolder) { + // If this folder is the root folder for a server, do nothing. + return; + } + if (childFolder.server.hidden) { + // If this folder is from a hidden server, do nothing. + return; + } + + let folderType = this._folderTypes.find(ft => + childFolder.isSpecialFolder(ft.flag, true) + ); + if (folderType) { + let virtualFolder = VirtualFolderHelper.wrapVirtualFolder( + MailServices.folderLookup.getFolderForURL(folderType.folderURI) + ); + let searchFolders = virtualFolder.searchFolders; + if (searchFolders.includes(childFolder)) { + // This folder is included in the virtual folder, do nothing. + return; + } + + if (searchFolders.includes(parentFolder)) { + // This folder's parent is included in the virtual folder, but the + // folder itself isn't. Add it to the list of non-special folders. + // Note that `_addFolderAndAncestors` can't be used here, as that + // would add the row in the wrong place. + let serverRow = folderPane.getRowForFolder( + childFolder.rootFolder, + this.name + ); + if (!serverRow) { + serverRow = folderPane._createServerRow( + this.name, + childFolder.server + ); + folderPane._insertInServerOrder(this.containerList, serverRow); + } + let folderRow = folderPane._createFolderRow(this.name, childFolder); + serverRow.insertChildInOrder(folderRow); + folderPane._addSubFolders(childFolder, folderRow, this.name); + return; + } + } + + // Nothing special about this folder. Add it to the end of the list. + let folderRow = folderPane._addFolderAndAncestors( + this.containerList, + childFolder, + this.name + ); + folderPane._addSubFolders(childFolder, folderRow, this.name); + }, + + removeFolder(parentFolder, childFolder) { + let childRow = folderPane.getRowForFolder(childFolder, this.name); + if (!childRow) { + return; + } + let parentRow = childRow.parentNode.closest("li"); + childRow.remove(); + if ( + parentRow.parentNode == this.containerList && + parentRow.dataset.serverType && + !parentRow.querySelector("li") + ) { + parentRow.remove(); + } + }, + + changeAccountOrder() { + folderPane._reapplyServerOrder(this.containerList); + + for (let smartFolderRow of this.containerList.children) { + if (smartFolderRow.dataset.serverKey == this._smartServer.key) { + folderPane._reapplyServerOrder(smartFolderRow.childList); + } + } + }, + }, + unread: { + name: "unread", + active: false, + canBeCompact: true, + + _unreadFilter(folder, includeSubFolders = true) { + return folder.getNumUnread(includeSubFolders) > 0; + }, + + initServer(server) { + this.addFolder(null, server.rootFolder); + }, + + _recurseSubFolders(parentFolder) { + let subFolders; + try { + subFolders = parentFolder.subFolders; + } catch (ex) { + console.error( + new Error( + `Unable to access the subfolders of ${parentFolder.URI}`, + { cause: ex } + ) + ); + } + if (!subFolders?.length) { + return; + } + + for (let i = 0; i < subFolders.length; i++) { + let folder = subFolders[i]; + if (folderPane._isGmailFolder(folder)) { + subFolders.splice(i, 1, ...folder.subFolders); + } + } + + subFolders.sort((a, b) => a.compareSortKeys(b)); + + for (let folder of subFolders) { + if (!this._unreadFilter(folder)) { + continue; + } + if (this._unreadFilter(folder, false)) { + this._addFolder(folder); + } + this._recurseSubFolders(folder); + } + }, + + addFolder(unused, folder) { + if (!this._unreadFilter(folder)) { + return; + } + this._addFolder(folder); + this._recurseSubFolders(folder); + }, + + _addFolder(folder) { + if (folderPane.getRowForFolder(folder, this.name)) { + // Don't do anything. `folderPane.changeUnreadCount` already did it. + return; + } + + if (!this._unreadFilter(folder, !folderPane._isCompact)) { + return; + } + + if (folderPane._isCompact) { + let folderRow = folderPane._createFolderRow( + this.name, + folder, + "both" + ); + folderPane._insertInServerOrder(this.containerList, folderRow); + return; + } + + folderPane._addFolderAndAncestors( + this.containerList, + folder, + this.name + ); + }, + + removeFolder(parentFolder, childFolder) { + folderPane._removeFolderAndAncestors( + childFolder, + this.name, + this._unreadFilter + ); + + // If the folder is being moved, `childFolder.parent` is null so the + // above code won't remove ancestors. Do this now. + if (!childFolder.parent && parentFolder) { + folderPane._removeFolderAndAncestors( + parentFolder, + this.name, + this._unreadFilter, + true + ); + } + + // Remove any stray rows that might be descendants of `childFolder`. + for (let row of [...this.containerList.querySelectorAll("li")]) { + if (row.uri.startsWith(childFolder.URI + "/")) { + row.remove(); + } + } + }, + + changeUnreadCount(folder, newValue) { + if (newValue > 0) { + this._addFolder(folder); + } + }, + + changeAccountOrder() { + folderPane._reapplyServerOrder(this.containerList); + }, + }, + favorite: { + name: "favorite", + active: false, + canBeCompact: true, + + _favoriteFilter(folder) { + return folder.flags & Ci.nsMsgFolderFlags.Favorite; + }, + + initServer(server) { + this.addFolder(null, server.rootFolder); + }, + + addFolder(unused, folder) { + this._addFolder(folder); + for (let subFolder of folder.getFoldersWithFlags( + Ci.nsMsgFolderFlags.Favorite + )) { + this._addFolder(subFolder); + } + }, + + _addFolder(folder) { + if ( + !this._favoriteFilter(folder) || + folderPane.getRowForFolder(folder, this.name) + ) { + return; + } + + if (folderPane._isCompact) { + folderPane._insertInServerOrder( + this.containerList, + folderPane._createFolderRow(this.name, folder, "both") + ); + return; + } + + folderPane._addFolderAndAncestors( + this.containerList, + folder, + this.name + ); + }, + + removeFolder(parentFolder, childFolder) { + folderPane._removeFolderAndAncestors( + childFolder, + this.name, + this._favoriteFilter + ); + + // If the folder is being moved, `childFolder.parent` is null so the + // above code won't remove ancestors. Do this now. + if (!childFolder.parent && parentFolder) { + folderPane._removeFolderAndAncestors( + parentFolder, + this.name, + this._favoriteFilter, + true + ); + } + + // Remove any stray rows that might be descendants of `childFolder`. + for (let row of [...this.containerList.querySelectorAll("li")]) { + if (row.uri.startsWith(childFolder.URI + "/")) { + row.remove(); + } + } + }, + + changeFolderFlag(folder, oldValue, newValue) { + oldValue &= Ci.nsMsgFolderFlags.Favorite; + newValue &= Ci.nsMsgFolderFlags.Favorite; + + if (oldValue == newValue) { + return; + } + + if (oldValue) { + if ( + folderPane._isCompact || + !folder.getFolderWithFlags(Ci.nsMsgFolderFlags.Favorite) + ) { + folderPane._removeFolderAndAncestors( + folder, + this.name, + this._favoriteFilter + ); + } + } else { + this._addFolder(folder); + } + }, + + changeAccountOrder() { + folderPane._reapplyServerOrder(this.containerList); + }, + }, + recent: { + name: "recent", + active: false, + canBeCompact: false, + + init() { + let folders = FolderUtils.getMostRecentFolders( + MailServices.accounts.allFolders, + Services.prefs.getIntPref("mail.folder_widget.max_recent"), + "MRUTime" + ); + for (let folder of folders) { + let folderRow = folderPane._createFolderRow( + this.name, + folder, + "both" + ); + this.containerList.appendChild(folderRow); + } + }, + + removeFolder(parentFolder, childFolder) { + folderPane.getRowForFolder(childFolder)?.remove(); + }, + }, + tags: { + name: "tags", + active: false, + canBeCompact: false, + + init() { + this._smartServer = MailServices.accounts.findServer( + "nobody", + "smart mailboxes", + "none" + ); + if (!this._smartServer) { + this._smartServer = MailServices.accounts.createIncomingServer( + "nobody", + "smart mailboxes", + "none" + ); + // We don't want the "smart" server/account leaking out into the ui in + // other places, so set it as hidden. + this._smartServer.hidden = true; + let account = MailServices.accounts.createAccount(); + account.incomingServer = this._smartServer; + } + this._smartServer.prettyName = + messengerBundle.GetStringFromName("unifiedAccountName"); + let smartRoot = this._smartServer.rootFolder.QueryInterface( + Ci.nsIMsgLocalMailFolder + ); + this._tagsFolder = + smartRoot.getChildWithURI(`${smartRoot.URI}/tags`, false, false) ?? + smartRoot.createLocalSubfolder("tags"); + this._tagsFolder.QueryInterface(Ci.nsIMsgLocalMailFolder); + + for (let tag of MailServices.tags.getAllTags()) { + try { + let folder = this._getVirtualFolder(tag); + this.containerList.appendChild( + folderPane._createTagRow(this.name, folder, tag) + ); + } catch (ex) { + console.error(ex); + } + } + MailServices.accounts.saveVirtualFolders(); + }, + + /** + * Get or create a virtual folder searching messages for `tag`. + * + * @param {nsIMsgTag} tag + * @returns {nsIMsgFolder} + */ + _getVirtualFolder(tag) { + let folder = this._tagsFolder.getChildWithURI( + `${this._tagsFolder.URI}/${encodeURIComponent(tag.key)}`, + false, + false + ); + if (folder) { + return folder; + } + + folder = this._tagsFolder.createLocalSubfolder(tag.key); + folder.flags |= Ci.nsMsgFolderFlags.Virtual; + folder.prettyName = tag.tag; + + let msgDatabase = folder.msgDatabase; + let folderInfo = msgDatabase.dBFolderInfo; + + folderInfo.setCharProperty( + "searchStr", + `AND (tag,contains,${tag.key})` + ); + folderInfo.setCharProperty("searchFolderUri", "*"); + folderInfo.setUint32Property( + "searchFolderFlag", + Ci.nsMsgFolderFlags.Inbox + ); + folderInfo.setBooleanProperty("searchOnline", false); + msgDatabase.summaryValid = true; + msgDatabase.close(true); + + this._tagsFolder.notifyFolderAdded(folder); + return folder; + }, + + /** + * Update the UI to match changes in a tag. If the tag is no longer + * valid (i.e. it's been deleted) the row representing it will be + * removed. If the tag is new, a row for it will be created. + * + * @param {string} prefName - The full name of the preference that + * changed causing this code to run. + */ + changeTagFromPrefChange(prefName) { + let [, , key] = prefName.split("."); + if (!MailServices.tags.isValidKey(key)) { + let uri = `${this._tagsFolder.URI}/${encodeURIComponent(key)}`; + folderPane.getRowForFolder(uri)?.remove(); + return; + } + + let tag = MailServices.tags.getAllTags().find(t => t.key == key); + let folder = this._getVirtualFolder(tag); + let row = folderPane.getRowForFolder(folder); + folder.prettyName = tag.tag; + if (row) { + row.name = tag.tag; + row.icon.style.setProperty("--icon-color", tag.color); + } else { + this.containerList.appendChild( + folderPane._createTagRow(this.name, folder, tag) + ); + } + }, + }, + }, + + /** + * Initialize the folder pane if needed. + * @returns {Promise} when the folder pane is initialized. + */ + async init() { + if (this._initialized) { + return; + } + if (window.openingState?.syntheticView) { + // Just avoid initialising the pane. We won't be using it. The folder + // listener is still required, because it does other things too. + MailServices.mailSession.AddFolderListener( + folderListener, + Ci.nsIFolderListener.all + ); + return; + } + + try { + // We could be here before `loadPostAccountWizard` loads the virtual + // folders, and we need them, so do it now. + MailServices.accounts.loadVirtualFolders(); + } catch (e) { + console.error(e); + } + + await FolderTreeProperties.ready; + + this._modeTemplate = document.getElementById("modeTemplate"); + this._folderTemplate = document.getElementById("folderTemplate"); + + this._isCompact = + Services.xulStore.getValue(XULSTORE_URL, "folderTree", "compact") === + "true"; + let activeModes = Services.xulStore.getValue( + XULSTORE_URL, + "folderTree", + "mode" + ); + activeModes = activeModes.split(","); + this.activeModes = activeModes; + + // Don't await anything between the active modes being initialised (the + // line above) and the listener being added. Otherwise folders may appear + // while we're not listening. + MailServices.mailSession.AddFolderListener( + folderListener, + Ci.nsIFolderListener.all + ); + + Services.prefs.addObserver("mail.accountmanager.accounts", this); + Services.prefs.addObserver("mailnews.tags.", this); + + Services.obs.addObserver(this, "folder-color-changed"); + Services.obs.addObserver(this, "folder-color-preview"); + Services.obs.addObserver(this, "search-folders-changed"); + Services.obs.addObserver(this, "folder-properties-changed"); + + folderTree.addEventListener("auxclick", this); + folderTree.addEventListener("contextmenu", this); + folderTree.addEventListener("collapsed", this); + folderTree.addEventListener("expanded", this); + folderTree.addEventListener("dragstart", this); + folderTree.addEventListener("dragover", this); + folderTree.addEventListener("dragleave", this); + folderTree.addEventListener("drop", this); + + document.getElementById("folderPaneHeaderBar").hidden = + this.isFolderPaneHeaderHidden(); + const folderPaneGetMessages = document.getElementById( + "folderPaneGetMessages" + ); + folderPaneGetMessages.addEventListener("click", () => { + top.MsgGetMessagesForAccount(); + }); + folderPaneGetMessages.addEventListener("contextmenu", event => { + document + .getElementById("folderPaneGetMessagesContext") + .openPopup(event.target, { triggerEvent: event }); + }); + document + .getElementById("folderPaneWriteMessage") + .addEventListener("click", event => { + top.MsgNewMessage(event); + }); + folderPaneGetMessages.hidden = this.isFolderPaneGetMsgsBtnHidden(); + document.getElementById("folderPaneWriteMessage").hidden = + this.isFolderPaneNewMsgBtnHidden(); + this.moreContext = document.getElementById("folderPaneMoreContext"); + this.folderPaneModeContext = document.getElementById( + "folderPaneModeContext" + ); + + document + .getElementById("folderPaneMoreButton") + .addEventListener("click", event => { + this.moreContext.openPopup(event.target, { triggerEvent: event }); + }); + this.subFolderContext = document.getElementById( + "folderModesContextMenuPopup" + ); + document + .getElementById("folderModesContextMenuPopup") + .addEventListener("click", event => { + this.subFolderContext.openPopup(event.target, { triggerEvent: event }); + }); + this.updateFolderRowUIElements(); + this.updateWidgets(); + + this._initialized = true; + }, + + uninit() { + if (!this._initialized) { + return; + } + Services.prefs.removeObserver("mail.accountmanager.accounts", this); + Services.prefs.removeObserver("mailnews.tags.", this); + Services.obs.removeObserver(this, "folder-color-changed"); + Services.obs.removeObserver(this, "folder-color-preview"); + Services.obs.removeObserver(this, "search-folders-changed"); + Services.obs.removeObserver(this, "folder-properties-changed"); + }, + + handleEvent(event) { + switch (event.type) { + case "select": + this._onSelect(event); + break; + case "auxclick": + if (event.button == 1) { + this._onMiddleClick(event); + } + break; + case "contextmenu": + this._onContextMenu(event); + break; + case "collapsed": + this._onCollapsed(event); + break; + case "expanded": + this._onExpanded(event); + break; + case "dragstart": + this._onDragStart(event); + break; + case "dragover": + this._onDragOver(event); + break; + case "dragleave": + this._clearDropTarget(event); + break; + case "drop": + this._onDrop(event); + break; + } + }, + + observe(subject, topic, data) { + switch (topic) { + case "nsPref:changed": + if (data == "mail.accountmanager.accounts") { + this._forAllActiveModes("changeAccountOrder"); + } else if ( + data.startsWith("mailnews.tags.") && + this._modes.tags.active + ) { + // The tags service isn't updated until immediately after the + // preferences change, so go to the back of the event queue before + // updating the UI. + setTimeout(() => this._modes.tags.changeTagFromPrefChange(data)); + } + break; + case "search-folders-changed": + if (this._modes.smart.active) { + subject.QueryInterface(Ci.nsIMsgFolder); + if (subject.server == this._modes.smart._smartServer) { + this._modes.smart.changeSearchedFolders(subject); + } + } + break; + case "folder-properties-changed": + this.updateFolderProperties(subject.QueryInterface(Ci.nsIMsgFolder)); + break; + case "folder-color-changed": + case "folder-color-preview": + this._changeRows(subject, row => row.setIconColor(data)); + break; + } + }, + + /** + * Whether the folder pane has been initialized. + * + * @type {boolean} + */ + get isInitialized() { + return this._initialized; + }, + + /** + * If the local folders are currently hidden. + * + * @returns {boolean} + */ + get hideLocalFolders() { + this._hideLocalFolders = this.isItemHidden("folderPaneLocalFolders"); + return this._hideLocalFolders; + }, + + /** + * Reload the folder tree when the option changes. + * + * @param {boolean} - True if local folders should be hidden. + */ + set hideLocalFolders(value) { + if (value == this._hideLocalFolders) { + return; + } + + this._hideLocalFolders = value; + for (let mode of Object.values(this._modes)) { + if (!mode.active) { + continue; + } + mode.containerList.replaceChildren(); + this._initMode(mode); + } + this.updateFolderRowUIElements(); + }, + + /** + * Toggle the folder modes requested by the user. + * + * @param {Event} event - The DOMEvent. + */ + toggleFolderMode(event) { + let currentModes = this.activeModes; + let mode = event.target.getAttribute("value"); + let index = this.activeModes.indexOf(mode); + + if (event.target.hasAttribute("checked")) { + if (index == -1) { + currentModes.push(mode); + } + } else if (index >= 0) { + currentModes.splice(index, 1); + } + this.activeModes = currentModes; + this.toggleCompactViewMenuItem(); + + if (this.activeModes.length == 1 && this.activeModes.at(0) == "all") { + this.updateContextCheckedFolderMode(); + } + }, + + toggleCompactViewMenuItem() { + let subMenuCompactBtn = document.querySelector( + "#folderPaneMoreContextCompactToggle" + ); + if (this.canBeCompact) { + subMenuCompactBtn.removeAttribute("disabled"); + return; + } + subMenuCompactBtn.setAttribute("disabled", "true"); + }, + + /** + * Ensure all the folder modes menuitems in the pane header context menu are + * checked to reflect the currently active modes. + */ + updateContextCheckedFolderMode() { + for (let item of document.querySelectorAll(".folder-pane-mode")) { + if (this.activeModes.includes(item.value)) { + item.setAttribute("checked", true); + continue; + } + item.removeAttribute("checked"); + } + }, + + /** + * Ensures all the folder pane mode context menuitems in the folder + * pane mode context menu are checked to reflect the current compact mode. + * @param {Event} event - The DOMEvent. + */ + onFolderPaneModeContextOpening(event) { + this.mode = event.target.closest("[data-mode]")?.dataset.mode; + + // If folder mode is at the top or the only one, + // it can't be moved up, so disable "Move Up". + const moveUpMenuItem = this.folderPaneModeContext.querySelector( + "#folderPaneModeMoveUp" + ); + moveUpMenuItem.removeAttribute("disabled"); + // Apply attribute mode to context menu option to allow + // for sorting later + if (this.activeModes.at(0) == this.mode) { + moveUpMenuItem.setAttribute("disabled", "true"); + } + + // If folder mode is at the bottom or the only one, + // it can't be moved down, so disable "Move Down". + const moveDownMenuItem = this.folderPaneModeContext.querySelector( + "#folderPaneModeMoveDown" + ); + moveDownMenuItem.removeAttribute("disabled"); + // Apply attribute mode to context menu option to allow + // for sorting later + if (this.activeModes.at(-1) == this.mode) { + moveDownMenuItem.setAttribute("disabled", "true"); + } + + let compactMenuItem = this.folderPaneModeContext.querySelector( + "#compactFolderButton" + ); + compactMenuItem.removeAttribute("checked"); + compactMenuItem.removeAttribute("disabled"); + if (!this.canModeBeCompact(this.mode)) { + compactMenuItem.setAttribute("disabled", "true"); + return; + } + if (this.isCompact) { + compactMenuItem.setAttribute("checked", true); + } + }, + + /** + * Toggles the compact mode of the active modes that allow it. + * + * @param {Event} event - The DOMEvent. + */ + compactFolderToggle(event) { + this.isCompact = event.target.hasAttribute("checked"); + }, + + /** + * Moves active folder mode up + * + * @param {Event} event - The DOMEvent. + */ + moveFolderModeUp(event) { + let currentModes = this.activeModes; + const mode = this.mode; + const index = currentModes.indexOf(mode); + + if (index > 0) { + const prev = currentModes[index - 1]; + currentModes[index - 1] = currentModes[index]; + currentModes[index] = prev; + } + this.activeModes = currentModes; + }, + + /** + * Moves active folder mode down + * + * @param {Event} event - The DOMEvent. + */ + moveFolderModeDown(event) { + let currentModes = this.activeModes; + const mode = this.mode; + const index = currentModes.indexOf(mode); + + if (index < currentModes.length - 1) { + const next = currentModes[index + 1]; + currentModes[index + 1] = currentModes[index]; + currentModes[index] = next; + } + this.activeModes = currentModes; + }, + + /** + * The names of all active modes. + * + * @type {string[]} + */ + get activeModes() { + return Array.from(folderTree.children, li => li.dataset.mode); + }, + + set activeModes(modes) { + modes = modes.filter(m => m in this._modes); + if (modes.length == 0) { + modes = ["all"]; + } + for (let name of Object.keys(this._modes)) { + this._toggleMode(name, modes.includes(name)); + } + for (let name of modes) { + let { container, containerHeader } = this._modes[name]; + containerHeader.hidden = modes.length == 1; + folderTree.appendChild(container); + } + Services.xulStore.setValue( + XULSTORE_URL, + "folderTree", + "mode", + this.activeModes.join(",") + ); + this.updateFolderRowUIElements(); + }, + + /** + * Do any of the active modes have a compact variant? + * + * @type {boolean} + */ + get canBeCompact() { + return Object.values(this._modes).some( + mode => mode.active && mode.canBeCompact + ); + }, + + /** + * Do any of the active modes have a compact variant? + * + * @param {string} mode + * @type {boolean} + */ + canModeBeCompact(mode) { + return Object.values(this._modes).some( + m => m.name == mode && m.active && m.canBeCompact + ); + }, + + /** + * Are compact variants enabled? + * + * @type {boolean} + */ + get isCompact() { + return this._isCompact; + }, + + set isCompact(value) { + if (this._isCompact == value) { + return; + } + this._isCompact = value; + for (let mode of Object.values(this._modes)) { + if (!mode.active || !mode.canBeCompact) { + continue; + } + + mode.containerList.replaceChildren(); + this._initMode(mode); + } + Services.xulStore.setValue(XULSTORE_URL, "folderTree", "compact", value); + }, + + /** + * Show or hide a folder tree mode. + * + * @param {string} modeName + * @param {boolean} active + */ + _toggleMode(modeName, active) { + if (!(modeName in this._modes)) { + throw new Error(`Unknown folder tree mode: ${modeName}`); + } + let mode = this._modes[modeName]; + if (mode.active == active) { + return; + } + + if (!active) { + mode.container.remove(); + delete mode.container; + mode.active = false; + return; + } + + let container = + this._modeTemplate.content.firstElementChild.cloneNode(true); + container.dataset.mode = modeName; + + mode.container = container; + mode.containerHeader = container.querySelector(".mode-container"); + mode.containerHeader.querySelector(".mode-name").textContent = + messengerBundle.GetStringFromName( + modeName == "tags" ? "tag" : `folderPaneModeHeader_${modeName}` + ); + mode.containerList = container.querySelector("ul"); + this._initMode(mode); + mode.active = true; + container.querySelector(".mode-button").addEventListener("click", event => { + this.onFolderPaneModeContextOpening(event); + this.folderPaneModeContext.openPopup(event.target, { + triggerEvent: event, + }); + }); + }, + + /** + * Initialize a folder mode with all visible accounts. + * + * @param {object} mode - One of the folder modes from `folderPane._modes`. + */ + _initMode(mode) { + if (typeof mode.init == "function") { + try { + mode.init(); + } catch (e) { + console.warn(`Error intiating ${mode.name} mode.`, e); + if (typeof mode.regenerateMode != "function") { + return; + } + mode.containerList.replaceChildren(); + mode.regenerateMode(); + } + } + if (typeof mode.initServer != "function") { + return; + } + + // `.accounts` is used here because it is ordered, `.allServers` isn't. + for (let account of MailServices.accounts.accounts) { + // Skip local folders if they're hidden. + if ( + account.incomingServer.type == "none" && + folderPane.hideLocalFolders + ) { + continue; + } + // Skip IM accounts. + if (account.incomingServer.type == "im") { + continue; + } + // Skip POP3 accounts that are deferred to another account. + if ( + account.incomingServer instanceof Ci.nsIPop3IncomingServer && + account.incomingServer.deferredToAccount + ) { + continue; + } + mode.initServer(account.incomingServer); + } + }, + + /** + * Create a FolderTreeRow representing a server. + * + * @param {string} modeName - The name of the mode this row belongs to. + * @param {nsIMsgIncomingServer} server - The server the row represents. + * @returns {FolderTreeRow} + */ + _createServerRow(modeName, server) { + let row = document.createElement("li", { is: "folder-tree-row" }); + row.modeName = modeName; + row.setServer(server); + return row; + }, + + /** + * Create a FolderTreeRow representing a folder. + * + * @param {string} modeName - The name of the mode this row belongs to. + * @param {nsIMsgFolder} folder - The folder the row represents. + * @param {"folder"|"server"|"both"} nameStyle + * @returns {FolderTreeRow} + */ + _createFolderRow(modeName, folder, nameStyle) { + let row = document.createElement("li", { is: "folder-tree-row" }); + row.modeName = modeName; + row.setFolder(folder, nameStyle); + return row; + }, + + /** + * Create a FolderTreeRow representing a virtual folder for a tag. + * + * @param {string} modeName - The name of the mode this row belongs to. + * @param {nsIMsgFolder} folder - The virtual folder the row represents. + * @param {nsIMsgTag} tag - The tag the virtual folder searches for. + * @returns {FolderTreeRow} + */ + _createTagRow(modeName, folder, tag) { + let row = document.createElement("li", { is: "folder-tree-row" }); + row.modeName = modeName; + row.setFolder(folder); + row.dataset.tagKey = tag.key; + row.icon.style.setProperty("--icon-color", tag.color); + return row; + }, + + /** + * Add a server row to the given list in the correct sort order. + * + * @param {HTMLUListElement} list + * @param {FolderTreeRow} serverRow + * @returns {FolderTreeRow} + */ + _insertInServerOrder(list, serverRow) { + let serverKeys = MailServices.accounts.accounts.map( + a => a.incomingServer.key + ); + let index = serverKeys.indexOf(serverRow.dataset.serverKey); + for (let row of list.children) { + let i = serverKeys.indexOf(row.dataset.serverKey); + + if (i > index) { + return list.insertBefore(serverRow, row); + } + if (i < index) { + continue; + } + + if (row.folderSortOrder > serverRow.folderSortOrder) { + return list.insertBefore(serverRow, row); + } + if (row.folderSortOrder < serverRow.folderSortOrder) { + continue; + } + + if (FolderTreeRow.nameCollator.compare(row.name, serverRow.name) > 0) { + return list.insertBefore(serverRow, row); + } + } + return list.appendChild(serverRow); + }, + + _reapplyServerOrder(list) { + let selected = list.querySelector("li.selected"); + let serverKeys = MailServices.accounts.accounts.map( + a => a.incomingServer.key + ); + let serverRows = [...list.children]; + serverRows.sort( + (a, b) => + serverKeys.indexOf(a.dataset.serverKey) - + serverKeys.indexOf(b.dataset.serverKey) + ); + list.replaceChildren(...serverRows); + if (selected) { + setTimeout(() => selected.classList.add("selected")); + } + }, + + /** + * Adds a row representing a folder and any missing rows for ancestors of + * the folder. + * + * @param {HTMLUListElement} containerList - The list to add folders to. + * @param {nsIMsgFolder} folder + * @param {string} modeName - The name of the mode this row belongs to. + * @returns {FolderTreeRow} + */ + _addFolderAndAncestors(containerList, folder, modeName) { + let folderRow = folderPane.getRowForFolder(folder, modeName); + if (folderRow) { + return folderRow; + } + + if (folder.isServer) { + let serverRow = folderPane._createServerRow(modeName, folder.server); + this._insertInServerOrder(containerList, serverRow); + return serverRow; + } + + let parentRow = this._addFolderAndAncestors( + containerList, + folderPane._getNonGmailParent(folder), + modeName + ); + folderRow = folderPane._createFolderRow(modeName, folder); + parentRow.insertChildInOrder(folderRow); + return folderRow; + }, + + /** + * @callback folderFilterCallback + * @param {FolderTreeRow} row + * @returns {boolean} - True if the folder should have a row in the tree. + */ + /** + * Removes the row representing a folder and the rows for any ancestors of + * the folder, as long as they don't have other descendants or match + * `filterFunction`. + * + * @param {nsIMsgFolder} folder + * @param {string} modeName - The name of the mode this row belongs to. + * @param {folderFilterCallback} [filterFunction] - Optional callback to stop + * ascending. + * @param {boolean=false} childAlreadyGone - Is this function being called + * to remove the parent of a row that's already been removed? + */ + _removeFolderAndAncestors( + folder, + modeName, + filterFunction, + childAlreadyGone = false + ) { + let folderRow = folderPane.getRowForFolder(folder, modeName); + if (folderPane._isCompact) { + folderRow?.remove(); + return; + } + + // If we get to a row for a folder that doesn't exist, or has children + // other than the one being removed, don't go any further. + if ( + !folderRow || + folderRow.childList.childElementCount > (childAlreadyGone ? 0 : 1) + ) { + return; + } + + // Otherwise, move up the folder tree. + let parentFolder = folderPane._getNonGmailParent(folder); + if ( + parentFolder && + (typeof filterFunction != "function" || !filterFunction(parentFolder)) + ) { + this._removeFolderAndAncestors(parentFolder, modeName, filterFunction); + } + + // Remove the row for this folder. + folderRow.remove(); + }, + + /** + * Add all subfolders to a row representing a folder. Called recursively, + * so all descendants are ultimately added. + * + * @param {nsIMsgFolder} parentFolder + * @param {FolderTreeRow} parentRow - The row representing `parentFolder`. + * @param {string} modeName - The name of the mode this row belongs to. + * @param {folderFilterCallback} [filterFunction] - Optional callback to add + * only some subfolders to the row. + */ + _addSubFolders(parentFolder, parentRow, modeName, filterFunction) { + let subFolders; + try { + subFolders = parentFolder.subFolders; + } catch (ex) { + console.error( + new Error(`Unable to access the subfolders of ${parentFolder.URI}`, { + cause: ex, + }) + ); + } + if (!subFolders?.length) { + return; + } + + for (let i = 0; i < subFolders.length; i++) { + let folder = subFolders[i]; + if (this._isGmailFolder(folder)) { + subFolders.splice(i, 1, ...folder.subFolders); + } + } + + subFolders.sort((a, b) => a.compareSortKeys(b)); + + for (let folder of subFolders) { + if (typeof filterFunction == "function" && !filterFunction(folder)) { + continue; + } + let folderRow = folderPane._createFolderRow(modeName, folder); + this._addSubFolders(folder, folderRow, modeName, filterFunction); + parentRow.childList.appendChild(folderRow); + } + }, + + /** + * Get the first row representing a folder, even if it is hidden. + * + * @param {nsIMsgFolder|string} folderOrURI - The folder to find, or its URI. + * @param {string?} modeName - If given, only look in the folders for this + * mode, otherwise look in the whole tree. + * @returns {FolderTreeRow} + */ + getRowForFolder(folderOrURI, modeName) { + if (folderOrURI instanceof Ci.nsIMsgFolder) { + folderOrURI = folderOrURI.URI; + } + + let modeNames = modeName ? [modeName] : this.activeModes; + for (let name of modeNames) { + let id = FolderTreeRow.makeRowID(name, folderOrURI); + // Look in the mode's container. The container may or may not be + // attached to the document at this point. + let row = this._modes[name].containerList.querySelector( + `#${CSS.escape(id)}` + ); + if (row) { + return row; + } + } + + return null; + }, + + /** + * Loop through all currently active modes and call the required function if + * it exists. + * + * @param {string} functionName - The name of the function to call. + * @param {...any} args - The list of arguments to pass to the function. + */ + _forAllActiveModes(functionName, ...args) { + for (let mode of Object.values(this._modes)) { + if (!mode.active || typeof mode[functionName] != "function") { + continue; + } + try { + mode[functionName](...args); + } catch (ex) { + console.error(ex); + } + } + }, + + /** + * We deliberately hide the [Gmail] (or [Google Mail] in some cases) folder + * from the folder tree. This function determines if a folder is that folder. + * + * @param {nsIMsgFolder} folder + * @returns {boolean} + */ + _isGmailFolder(folder) { + return ( + folder?.parent?.isServer && + folder.server instanceof Ci.nsIImapIncomingServer && + folder.server.isGMailServer && + folder.noSelect + ); + }, + + /** + * If a folder is the [Gmail] folder, returns the parent folder, otherwise + * returns the given folder. + * + * @param {nsIMsgFolder} folder + * @returns {nsIMsgFolder} + */ + _getNonGmailFolder(folder) { + return this._isGmailFolder(folder) ? folder.parent : folder; + }, + + /** + * Returns the parent folder of a given folder, or if that is the [Gmail] + * folder returns the grandparent of the given folder. + * + * @param {nsIMsgFolder} folder + * @returns {nsIMsgFolder} + */ + _getNonGmailParent(folder) { + return this._getNonGmailFolder(folder.parent); + }, + + /** + * Update the folder pane UI and add rows for all newly created folders. + * + * @param {?nsIMsgFolder} parentFolder - The parent of the newly created + * folder. + * @param {nsIMsgFolder} childFolder - The newly created folder. + */ + addFolder(parentFolder, childFolder) { + if (!parentFolder) { + // A server folder was added, so check if we need to update actions. + this.updateWidgets(); + } + + if (this._isGmailFolder(childFolder)) { + return; + } + + parentFolder = this._getNonGmailFolder(parentFolder); + this._forAllActiveModes("addFolder", parentFolder, childFolder); + }, + + /** + * Update the folder pane UI and remove rows for all removed folders. + * + * @param {?nsIMsgFolder} parentFolder - The parent of the removed folder. + * @param {nsIMsgFolder} childFolder - The removed folder. + */ + removeFolder(parentFolder, childFolder) { + if (!parentFolder) { + // A server folder was removed, so check if we need to update actions. + this.updateWidgets(); + } + + parentFolder = this._getNonGmailFolder(parentFolder); + this._forAllActiveModes("removeFolder", parentFolder, childFolder); + }, + + /** + * Update the list of folders if the current mode rely on specific flags. + * + * @param {nsIMsgFolder} item - The target folder. + * @param {nsMsgFolderFlags} oldValue - The old flag value. + * @param {nsMsgFolderFlags} newValue - The updated flag value. + */ + changeFolderFlag(item, oldValue, newValue) { + this._forAllActiveModes("changeFolderFlag", item, oldValue, newValue); + this._changeRows(item, row => row.setFolderTypeFromFolder(item)); + }, + + /** + * Update the list of folders to reflect current properties. + * + * @param {nsIMsgFolder} item - The folder whose data to use. + */ + updateFolderProperties(item) { + this._forAllActiveModes("updateFolderProperties", item); + this._changeRows(item, row => row.setFolderPropertiesFromFolder(item)); + }, + + /** + * @callback folderRowChangeCallback + * @param {FolderTreeRow} row + */ + /** + * Perform a function on all rows representing a folder. + * + * @param {nsIMsgFolder|string} folderOrURI - The folder to change, or its URI. + * @param {folderRowChangeCallback} callback + */ + _changeRows(folderOrURI, callback) { + if (folderOrURI instanceof Ci.nsIMsgFolder) { + folderOrURI = folderOrURI.URI; + } + for (let row of folderTree.querySelectorAll("li")) { + if (row.uri == folderOrURI) { + callback(row); + } + } + }, + + /** + * Get the folder from the URI by looping through the list of folders and + * finding a matching URI. + * + * @param {string} uri + * @returns {?FolderTreeRow} + */ + getFolderFromUri(uri) { + for (let folder of folderTree.querySelectorAll("li")) { + if (folder.uri == uri) { + return folder; + } + } + return [...folderTree.querySelectorAll("li")]?.find(f => f.uri == uri); + }, + + /** + * Called when a folder's new messages state changes. + * + * @param {nsIMsgFolder} folder + * @param {boolean} hasNewMessages + */ + changeNewMessages(folder, hasNewMessages) { + this._changeRows(folder, row => { + // Find the nearest visible ancestor and update it. + let collapsedAncestor = row.parentElement?.closest("li.collapsed"); + while (collapsedAncestor) { + const next = collapsedAncestor.parentElement?.closest("li.collapsed"); + if (!next) { + collapsedAncestor.updateNewMessages(hasNewMessages); + break; + } + collapsedAncestor = next; + } + + // Update the row itself. + row.updateNewMessages(hasNewMessages); + }); + }, + + /** + * Called when a folder's unread count changes, to update the UI. + * + * @param {nsIMsgFolder} folder + * @param {integer} newValue + */ + changeUnreadCount(folder, newValue) { + this._changeRows(folder, row => { + // Find the nearest visible ancestor and update it. + let collapsedAncestor = row.parentElement?.closest("li.collapsed"); + while (collapsedAncestor) { + const next = collapsedAncestor.parentElement?.closest("li.collapsed"); + if (!next) { + collapsedAncestor.updateUnreadMessageCount(); + break; + } + collapsedAncestor = next; + } + + // Update the row itself. + row.updateUnreadMessageCount(); + }); + + if (this._modes.unread.active && !folder.server.hidden) { + this._modes.unread.changeUnreadCount(folder, newValue); + } + }, + + /** + * Called when a folder's total count changes, to update the UI. + * + * @param {nsIMsgFolder} folder + * @param {integer} newValue + */ + changeTotalCount(folder, newValue) { + this._changeRows(folder, row => { + // Find the nearest visible ancestor and update it. + let collapsedAncestor = row.parentElement?.closest("li.collapsed"); + while (collapsedAncestor) { + const next = collapsedAncestor.parentElement?.closest("li.collapsed"); + if (!next) { + collapsedAncestor.updateTotalMessageCount(); + break; + } + collapsedAncestor = next; + } + + // Update the row itself. + row.updateTotalMessageCount(); + }); + }, + + /** + * Called when a server's `prettyName` changes, to update the UI. + * + * @param {nsIMsgFolder} folder + * @param {string} name + */ + changeServerName(folder, name) { + for (let row of folderTree.querySelectorAll( + `li[data-server-key="${folder.server.key}"]` + )) { + row.setServerName(name); + } + }, + + /** + * Update the UI widget to reflect the real folder size when the "FolderSize" + * property changes. + * + * @param {nsIMsgFolder} folder + */ + changeFolderSize(folder) { + if (folderPane.isItemVisible("folderPaneFolderSize")) { + this._changeRows(folder, row => row.updateSizeCount(false, folder)); + } + }, + + _onSelect(event) { + const isSynthetic = gViewWrapper?.isSynthetic; + threadPane.saveSelection(); + threadPane.hideIgnoredMessageNotification(); + if (!isSynthetic) { + // Don't clear the message pane for synthetic views, as a message may have + // already been selected in restoreState(). + messagePane.clearAll(); + } + + let uri = folderTree.rows[folderTree.selectedIndex]?.uri; + if (!uri) { + gFolder = null; + return; + } + gFolder = MailServices.folderLookup.getFolderForURL(uri); + + // Bail out if this is synthetic view, such as a gloda search. + if (isSynthetic) { + return; + } + + document.head.querySelector(`link[rel="icon"]`).href = + FolderUtils.getFolderIcon(gFolder); + + // Clean up any existing view wrapper. This will invalidate the thread tree. + gViewWrapper?.close(); + + if (gFolder.isServer) { + document.title = gFolder.server.prettyName; + gViewWrapper = gDBView = threadTree.view = null; + + MailE10SUtils.loadURI( + accountCentralBrowser, + `chrome://messenger/content/msgAccountCentral.xhtml?folderURI=${encodeURIComponent( + gFolder.URI + )}` + ); + document.body.classList.add("account-central"); + accountCentralBrowser.hidden = false; + } else { + document.title = `${gFolder.name} - ${gFolder.server.prettyName}`; + document.body.classList.remove("account-central"); + accountCentralBrowser.hidden = true; + + quickFilterBar.activeElement = null; + threadPane.restoreColumns(); + + gViewWrapper = new DBViewWrapper(dbViewWrapperListener); + + threadPane.scrollToNewMessage = + !(gFolder.flags & Ci.nsMsgFolderFlags.Virtual) && + gFolder.hasNewMessages && + Services.prefs.getBoolPref("mailnews.scroll_to_new_message"); + if (threadPane.scrollToNewMessage) { + threadPane.forgetSelection(uri); + } + + gViewWrapper.open(gFolder); + + // At this point `dbViewWrapperListener.onCreatedView` gets called, + // setting up gDBView and scrolling threadTree to the right end. + + threadPane.updateListRole( + !gViewWrapper?.showThreaded && !gViewWrapper?.showGroupedBySort + ); + threadPane.restoreSortIndicator(); + threadPaneHeader.onFolderSelected(); + } + + this._updateStatusQuota(); + + window.dispatchEvent( + new CustomEvent("folderURIChanged", { bubbles: true, detail: uri }) + ); + }, + + /** + * Update the quotaPanel to reflect current folder quota status. + */ + _updateStatusQuota() { + if (top.window.document.getElementById("status-bar").hidden) { + return; + } + const quotaPanel = top.window.document.getElementById("quotaPanel"); + if (!(gFolder && gFolder instanceof Ci.nsIMsgImapMailFolder)) { + quotaPanel.hidden = true; + return; + } + + let tabListener = event => { + // Hide the pane if the new tab ain't us. + quotaPanel.hidden = + top.window.document.getElementById("tabmail").currentAbout3Pane == + this.window; + }; + top.window.document.removeEventListener("TabSelect", tabListener); + + // For display on main window panel only include quota names containing + // "STORAGE" or "MESSAGE". This will exclude unusual quota names containing + // items like "MAILBOX" and "LEVEL" from the panel bargraph. All quota names + // will still appear on the folder properties quota window. + // Note: Quota name is typically something like "User Quota / STORAGE". + let folderQuota = gFolder + .getQuota() + .filter( + quota => + quota.name.toUpperCase().includes("STORAGE") || + quota.name.toUpperCase().includes("MESSAGE") + ); + if (!folderQuota.length) { + quotaPanel.hidden = true; + return; + } + // If folderQuota not empty, find the index of the element with highest + // percent usage and determine if it is above the panel display threshold. + let quotaUsagePercentage = q => + Number((100n * BigInt(q.usage)) / BigInt(q.limit)); + let highest = folderQuota.reduce((acc, current) => + quotaUsagePercentage(acc) > quotaUsagePercentage(current) ? acc : current + ); + let percent = quotaUsagePercentage(highest); + if ( + percent < + Services.prefs.getIntPref("mail.quota.mainwindow_threshold.show") + ) { + quotaPanel.hidden = true; + } else { + quotaPanel.hidden = false; + top.window.document.addEventListener("TabSelect", tabListener); + + top.window.document + .getElementById("quotaMeter") + .setAttribute("value", percent); + + let usage; + let limit; + if (/STORAGE/i.test(highest.name)) { + let messenger = Cc["@mozilla.org/messenger;1"].createInstance( + Ci.nsIMessenger + ); + usage = messenger.formatFileSize(highest.usage * 1024); + limit = messenger.formatFileSize(highest.limit * 1024); + } else { + usage = highest.usage; + limit = highest.limit; + } + + top.window.document.getElementById("quotaLabel").value = `${percent}%`; + top.window.document.l10n.setAttributes( + top.window.document.getElementById("quotaLabel"), + "quota-panel-percent-used", + { percent, usage, limit } + ); + if ( + percent < + Services.prefs.getIntPref("mail.quota.mainwindow_threshold.warning") + ) { + quotaPanel.classList.remove("alert-warning", "alert-critical"); + } else if ( + percent < + Services.prefs.getIntPref("mail.quota.mainwindow_threshold.critical") + ) { + quotaPanel.classList.remove("alert-critical"); + quotaPanel.classList.add("alert-warning"); + } else { + quotaPanel.classList.remove("alert-warning"); + quotaPanel.classList.add("alert-critical"); + } + } + }, + + _onMiddleClick(event) { + if ( + event.target.closest(".mode-container") || + folderTree.selectedIndex == -1 + ) { + return; + } + const row = event.target.closest("li"); + if (!row) { + return; + } + + top.MsgOpenNewTabForFolders( + [MailServices.folderLookup.getFolderForURL(row.uri)], + { + event, + folderPaneVisible: !paneLayout.folderPaneSplitter.isCollapsed, + messagePaneVisible: !paneLayout.messagePaneSplitter.isCollapsed, + } + ); + }, + + _onContextMenu(event) { + if (folderTree.selectedIndex == -1) { + return; + } + + let popup = document.getElementById("folderPaneContext"); + + if (event.button == 2) { + // Mouse + if (event.target.closest(".mode-container")) { + return; + } + let row = event.target.closest("li"); + if (!row) { + return; + } + if (row.uri != gFolder.URI) { + // The right-clicked-on folder is not `gFolder`. Tell the context menu + // to use it instead. This override lasts until the context menu fires + // a "popuphidden" event. + folderPaneContextMenu.setOverrideFolder( + MailServices.folderLookup.getFolderForURL(row.uri) + ); + row.classList.add("context-menu-target"); + } + popup.openPopupAtScreen(event.screenX, event.screenY, true); + } else { + // Keyboard + let row = folderTree.getRowAtIndex(folderTree.selectedIndex); + popup.openPopup(row, "after_end", 0, 0, true); + } + + event.preventDefault(); + }, + + _onCollapsed({ target }) { + if (target.uri) { + let mode = target.closest("[data-mode]").dataset.mode; + FolderTreeProperties.setIsExpanded(target.uri, mode, false); + } + target.updateUnreadMessageCount(); + target.updateTotalMessageCount(); + target.updateNewMessages(); + }, + + _onExpanded({ target }) { + if (target.uri) { + let mode = target.closest("[data-mode]").dataset.mode; + FolderTreeProperties.setIsExpanded(target.uri, mode, true); + } + + const updateRecursively = row => { + row.updateUnreadMessageCount(); + row.updateTotalMessageCount(); + row.updateNewMessages(); + if (row.classList.contains("collapsed")) { + return; + } + for (const child of row.childList.children) { + updateRecursively(child); + } + }; + + updateRecursively(target); + + // Get server type. IMAP is the only server type that does folder discovery. + let folder = MailServices.folderLookup.getFolderForURL(target.uri); + if (folder.server.type == "imap") { + if (folder.isServer) { + folder.server.performExpand(top.msgWindow); + } else { + folder.QueryInterface(Ci.nsIMsgImapMailFolder); + folder.performExpand(top.msgWindow); + } + } + }, + + _onDragStart(event) { + let row = event.target.closest(`li[is="folder-tree-row"]`); + if (!row) { + event.preventDefault(); + return; + } + + let folder = MailServices.folderLookup.getFolderForURL(row.uri); + if (!folder || folder.isServer) { + event.preventDefault(); + return; + } + if (folder.server.type == "nntp") { + event.dataTransfer.mozSetDataAt("text/x-moz-newsfolder", folder, 0); + event.dataTransfer.effectAllowed = "move"; + return; + } + + event.dataTransfer.mozSetDataAt("text/x-moz-folder", folder, 0); + event.dataTransfer.effectAllowed = "copyMove"; + }, + + _onDragOver(event) { + const copyKey = + AppConstants.platform == "macosx" ? event.altKey : event.ctrlKey; + + event.dataTransfer.dropEffect = "none"; + event.preventDefault(); + + let row = event.target.closest("li"); + this._timedExpand(row); + if (!row) { + return; + } + + let targetFolder = MailServices.folderLookup.getFolderForURL(row.uri); + if (!targetFolder) { + return; + } + + let types = Array.from(event.dataTransfer.mozTypesAt(0)); + if (types.includes("text/x-moz-message")) { + if (targetFolder.isServer || !targetFolder.canFileMessages) { + return; + } + for (let i = 0; i < event.dataTransfer.mozItemCount; i++) { + let msgHdr = top.messenger.msgHdrFromURI( + event.dataTransfer.mozGetDataAt("text/x-moz-message", i) + ); + // Don't allow drop onto original folder. + if (msgHdr.folder == targetFolder) { + return; + } + } + event.dataTransfer.dropEffect = copyKey ? "copy" : "move"; + } else if (types.includes("text/x-moz-folder")) { + // If cannot create subfolders then don't allow drop here. + if (!targetFolder.canCreateSubfolders) { + return; + } + + let sourceFolder = event.dataTransfer + .mozGetDataAt("text/x-moz-folder", 0) + .QueryInterface(Ci.nsIMsgFolder); + + // Don't allow to drop on itself. + if (targetFolder == sourceFolder) { + return; + } + // Don't copy within same server. + if (sourceFolder.server == targetFolder.server && copyKey) { + return; + } + // Don't allow immediate child to be dropped onto its parent. + if (targetFolder == sourceFolder.parent) { + return; + } + // Don't allow dragging of virtual folders across accounts. + if ( + sourceFolder.getFlag(Ci.nsMsgFolderFlags.Virtual) && + sourceFolder.server != targetFolder.server + ) { + return; + } + // Don't allow parent to be dropped on its ancestors. + if (sourceFolder.isAncestorOf(targetFolder)) { + return; + } + // If there is a folder that can't be renamed, don't allow it to be + // dropped if it is not to "Local Folders" or is to the same account. + if ( + !sourceFolder.canRename && + (targetFolder.server.type != "none" || + sourceFolder.server == targetFolder.server) + ) { + return; + } + event.dataTransfer.dropEffect = copyKey ? "copy" : "move"; + } else if (types.includes("application/x-moz-file")) { + if (targetFolder.isServer || !targetFolder.canFileMessages) { + return; + } + for (let i = 0; i < event.dataTransfer.mozItemCount; i++) { + let extFile = event.dataTransfer + .mozGetDataAt("application/x-moz-file", i) + .QueryInterface(Ci.nsIFile); + if (!extFile.isFile() || !/\.eml$/i.test(extFile.leafName)) { + return; + } + } + event.dataTransfer.dropEffect = "copy"; + } else if (types.includes("text/x-moz-newsfolder")) { + let folder = event.dataTransfer + .mozGetDataAt("text/x-moz-newsfolder", 0) + .QueryInterface(Ci.nsIMsgFolder); + if ( + targetFolder.isServer || + targetFolder.server.type != "nntp" || + folder == targetFolder || + folder.server != targetFolder.server + ) { + return; + } + event.dataTransfer.dropEffect = "move"; + } else if ( + types.includes("text/x-moz-url-data") || + types.includes("text/x-moz-url") + ) { + // Allow subscribing to feeds by dragging an url to a feed account. + if ( + targetFolder.server.type == "rss" && + !targetFolder.isSpecialFolder(Ci.nsMsgFolderFlags.Trash, true) && + event.dataTransfer.items.length == 1 && + FeedUtils.getFeedUriFromDataTransfer(event.dataTransfer) + ) { + return; + } + event.dataTransfer.dropEffect = "link"; + } else { + return; + } + + this._clearDropTarget(); + row.classList.add("drop-target"); + }, + + /** + * Set a timer to expand `row` in 500ms. If called again before the timer + * expires and with a different row, the timer is cleared and a new one + * started. If `row` is falsy or isn't collapsed the timer is cleared. + * + * @param {HTMLLIElement?} row + */ + _timedExpand(row) { + if (this._expandRow == row) { + return; + } + if (this._expandTimer) { + clearTimeout(this._expandTimer); + } + if (!row?.classList.contains("collapsed")) { + return; + } + this._expandRow = row; + this._expandTimer = setTimeout(() => { + folderTree.expandRow(this._expandRow); + delete this._expandRow; + delete this._expandTimer; + }, 1000); + }, + + _clearDropTarget() { + folderTree.querySelector(".drop-target")?.classList.remove("drop-target"); + }, + + _onDrop(event) { + this._timedExpand(); + this._clearDropTarget(); + if (event.dataTransfer.dropEffect == "none") { + // Somehow this is possible. It should not be possible. + return; + } + + let row = event.target.closest("li"); + if (!row) { + return; + } + + let targetFolder = MailServices.folderLookup.getFolderForURL(row.uri); + + let types = Array.from(event.dataTransfer.mozTypesAt(0)); + if (types.includes("text/x-moz-message")) { + let array = []; + let sourceFolder; + for (let i = 0; i < event.dataTransfer.mozItemCount; i++) { + let msgHdr = top.messenger.msgHdrFromURI( + event.dataTransfer.mozGetDataAt("text/x-moz-message", i) + ); + if (!i) { + sourceFolder = msgHdr.folder; + } + array.push(msgHdr); + } + let isMove = event.dataTransfer.dropEffect == "move"; + let isNews = sourceFolder.flags & Ci.nsMsgFolderFlags.Newsgroup; + if (!sourceFolder.canDeleteMessages || isNews) { + isMove = false; + } + + Services.prefs.setStringPref( + "mail.last_msg_movecopy_target_uri", + targetFolder.URI + ); + Services.prefs.setBoolPref("mail.last_msg_movecopy_was_move", isMove); + // ### ugh, so this won't work with cross-folder views. We would + // really need to partition the messages by folder. + if (isMove) { + dbViewWrapperListener.threadPaneCommandUpdater.updateNextMessageAfterDelete(); + } + MailServices.copy.copyMessages( + sourceFolder, + array, + targetFolder, + isMove, + null, + top.msgWindow, + true + ); + } else if (types.includes("text/x-moz-folder")) { + let sourceFolder = event.dataTransfer + .mozGetDataAt("text/x-moz-folder", 0) + .QueryInterface(Ci.nsIMsgFolder); + let isMove = event.dataTransfer.dropEffect == "move"; + isMove = folderPaneContextMenu.transferFolder( + isMove, + sourceFolder, + targetFolder + ); + // Save in prefs the target folder URI and if this was a move or copy. + // This is to fill in the next folder or message context menu item + // "Move|Copy to Again". + Services.prefs.setStringPref( + "mail.last_msg_movecopy_target_uri", + targetFolder.URI + ); + Services.prefs.setBoolPref("mail.last_msg_movecopy_was_move", isMove); + } else if (types.includes("application/x-moz-file")) { + for (let i = 0; i < event.dataTransfer.mozItemCount; i++) { + let extFile = event.dataTransfer + .mozGetDataAt("application/x-moz-file", i) + .QueryInterface(Ci.nsIFile); + if (extFile.isFile() && /\.eml$/i.test(extFile.leafName)) { + MailServices.copy.copyFileMessage( + extFile, + targetFolder, + null, + false, + 1, + "", + null, + top.msgWindow + ); + } + } + } else if (types.includes("text/x-moz-newsfolder")) { + let folder = event.dataTransfer + .mozGetDataAt("text/x-moz-newsfolder", 0) + .QueryInterface(Ci.nsIMsgFolder); + + let mode = row.closest("li[data-mode]").dataset.mode; + let newsRoot = targetFolder.rootFolder.QueryInterface( + Ci.nsIMsgNewsFolder + ); + newsRoot.reorderGroup(folder, targetFolder); + setTimeout( + () => (folderTree.selectedRow = this.getRowForFolder(folder, mode)) + ); + } else if ( + types.includes("text/x-moz-url-data") || + types.includes("text/x-moz-url") + ) { + // This is a potential rss feed. A link image as well as link text url + // should be handled; try to extract a url from non moz apps as well. + let feedURI = FeedUtils.getFeedUriFromDataTransfer(event.dataTransfer); + FeedUtils.subscribeToFeed(feedURI.spec, targetFolder); + } + + event.preventDefault(); + }, + + /** + * Opens the dialog to create a new sub-folder, and creates it if the user + * accepts. + * + * @param {?nsIMsgFolder} aParent - The parent for the new subfolder. + */ + newFolder(aParent) { + let folder = aParent; + + // Make sure we actually can create subfolders. + if (!folder?.canCreateSubfolders) { + // Check if we can create them at the root, otherwise use the default + // account as root folder. + let rootMsgFolder = folder.server.rootMsgFolder; + folder = rootMsgFolder.canCreateSubfolders + ? rootMsgFolder + : top.GetDefaultAccountRootFolder(); + } + + if (!folder) { + return; + } + + let dualUseFolders = true; + if (folder.server instanceof Ci.nsIImapIncomingServer) { + dualUseFolders = folder.server.dualUseFolders; + } + + function newFolderCallback(aName, aFolder) { + // createSubfolder can throw an exception, causing the newFolder dialog + // to not close and wait for another input. + // TODO: Rewrite this logic and also move the opening of alert dialogs from + // nsMsgLocalMailFolder::CreateSubfolderInternal to here (bug 831190#c16). + if (!aName) { + return; + } + aFolder.createSubfolder(aName, top.msgWindow); + // Don't call the rebuildAfterChange() here as we'll need to wait for the + // new folder to be properly created before rebuilding the tree. + } + + window.openDialog( + "chrome://messenger/content/newFolderDialog.xhtml", + "", + "chrome,modal,resizable=no,centerscreen", + { folder, dualUseFolders, okCallback: newFolderCallback } + ); + }, + + /** + * Opens the dialog to edit the properties for a folder + * + * @param {nsIMsgFolder} [folder] - Folder to edit, if not the selected one. + * @param {string} [tabID] - Id of initial tab to select in the folder + * properties dialog. + */ + editFolder(folder = gFolder, tabID) { + // If this is actually a server, send it off to that controller + if (folder.isServer) { + top.MsgAccountManager(null, folder.server); + return; + } + + if (folder.getFlag(Ci.nsMsgFolderFlags.Virtual)) { + this.editVirtualFolder(folder); + return; + } + let title = messengerBundle.GetStringFromName("folderProperties"); + + function editFolderCallback(newName, oldName) { + if (newName != oldName) { + folder.rename(newName, top.msgWindow); + } + } + + async function rebuildSummary() { + if (folder.locked) { + folder.throwAlertMsg("operationFailedFolderBusy", top.msgWindow); + return; + } + if (folder.supportsOffline) { + // Remove the offline store, if any. + await IOUtils.remove(folder.filePath.path, { recursive: true }).catch( + console.error + ); + } + + // We may be rebuilding a folder that is not the displayed one. + // TODO: Close any open views of this folder. + + // Send a notification that we are triggering a database rebuild. + MailServices.mfn.notifyFolderReindexTriggered(folder); + + folder.msgDatabase.summaryValid = false; + + const msgDB = folder.msgDatabase; + msgDB.summaryValid = false; + try { + folder.closeAndBackupFolderDB(""); + } catch (e) { + // In a failure, proceed anyway since we're dealing with problems + folder.ForceDBClosed(); + } + if (gFolder == folder) { + gViewWrapper?.close(); + folder.updateFolder(top.msgWindow); + folderTree.dispatchEvent(new CustomEvent("select")); + } else { + folder.updateFolder(top.msgWindow); + } + } + + window.openDialog( + "chrome://messenger/content/folderProps.xhtml", + "", + "chrome,modal,centerscreen", + { + folder, + serverType: folder.server.type, + msgWindow: top.msgWindow, + title, + okCallback: editFolderCallback, + tabID, + name: folder.prettyName, + rebuildSummaryCallback: rebuildSummary, + } + ); + }, + + /** + * Opens the dialog to rename a particular folder, and does the renaming if + * the user clicks OK in that dialog + * + * @param [aFolder] - The folder to rename, if different than the currently + * selected one. + */ + renameFolder(aFolder) { + let folder = aFolder; + + function renameCallback(aName, aUri) { + if (aUri != folder.URI) { + console.error("got back a different folder to rename!"); + } + + // Actually do the rename. + folder.rename(aName, top.msgWindow); + } + window.openDialog( + "chrome://messenger/content/renameFolderDialog.xhtml", + "", + "chrome,modal,centerscreen", + { + preselectedURI: folder.URI, + okCallback: renameCallback, + name: folder.prettyName, + } + ); + }, + + /** + * Deletes a folder from its parent. Also handles unsubscribe from newsgroups + * if the selected folder/s happen to be nntp. + * + * @param [folder] - The folder to delete, if not the selected one. + */ + deleteFolder(folder) { + // For newsgroups, "delete" means "unsubscribe". + if ( + folder.server.type == "nntp" && + !folder.getFlag(Ci.nsMsgFolderFlags.Virtual) + ) { + top.MsgUnsubscribe([folder]); + return; + } + + const canDelete = folder.isSpecialFolder(Ci.nsMsgFolderFlags.Junk, false) + ? FolderUtils.canRenameDeleteJunkMail(folder.URI) + : folder.deletable; + + if (!canDelete) { + throw new Error("Can't delete folder: " + folder.name); + } + + if (folder.getFlag(Ci.nsMsgFolderFlags.Virtual)) { + let confirmation = messengerBundle.GetStringFromName( + "confirmSavedSearchDeleteMessage" + ); + let title = messengerBundle.GetStringFromName("confirmSavedSearchTitle"); + if ( + Services.prompt.confirmEx( + window, + title, + confirmation, + Services.prompt.STD_YES_NO_BUTTONS + + Services.prompt.BUTTON_POS_1_DEFAULT, + "", + "", + "", + "", + {} + ) != 0 + ) { + /* the yes button is in position 0 */ + return; + } + } + + try { + folder.deleteSelf(top.msgWindow); + } catch (ex) { + // Ignore known errors from canceled warning dialogs. + const NS_MSG_ERROR_COPY_FOLDER_ABORTED = 0x8055001a; + if (ex.result != NS_MSG_ERROR_COPY_FOLDER_ABORTED) { + throw ex; + } + } + }, + + /** + * Prompts the user to confirm and empties the trash for the selected folder. + * The folder and its children are only emptied if it has the proper Trash flag. + * + * @param [aFolder] - The trash folder to empty. If unspecified or not a trash + * folder, the currently selected server's trash folder is used. + */ + emptyTrash(aFolder) { + let folder = aFolder; + if (!folder.getFlag(Ci.nsMsgFolderFlags.Trash)) { + folder = folder.rootFolder.getFolderWithFlags(Ci.nsMsgFolderFlags.Trash); + } + if (!folder) { + return; + } + + if (!this._checkConfirmationPrompt("emptyTrash", folder)) { + return; + } + + // Check if this is a top-level smart folder. If so, we're going + // to empty all the trash folders. + if (FolderUtils.isSmartVirtualFolder(folder)) { + for (let server of MailServices.accounts.allServers) { + for (let trash of server.rootFolder.getFoldersWithFlags( + Ci.nsMsgFolderFlags.Trash + )) { + trash.emptyTrash(null); + } + } + } else { + folder.emptyTrash(null); + } + }, + + /** + * Deletes everything (folders and messages) in the selected folder. + * The folder is only emptied if it has the proper Junk flag. + * + * @param {nsIMsgFolder} folder - The folder to empty. + * @param {boolean} [prompt=true] - If the user should be prompted. + */ + emptyJunk(folder, prompt = true) { + if (!folder || !folder.getFlag(Ci.nsMsgFolderFlags.Junk)) { + return; + } + + if (prompt && !this._checkConfirmationPrompt("emptyJunk", folder)) { + return; + } + + if (FolderUtils.isSmartVirtualFolder(folder)) { + // This is the unified junk folder. + let wrappedFolder = VirtualFolderHelper.wrapVirtualFolder(folder); + for (let searchFolder of wrappedFolder.searchFolders) { + this.emptyJunk(searchFolder, false); + } + return; + } + + // Delete any subfolders this folder might have + for (let subFolder of folder.subFolders) { + folder.propagateDelete(subFolder, true); + } + + let messages = [...folder.messages]; + if (!messages.length) { + return; + } + + // Now delete the messages + folder.deleteMessages(messages, top.msgWindow, true, false, null, false); + }, + + /** + * Compacts the given folder. + * + * @param {nsIMsgFolder} folder + */ + compactFolder(folder) { + // Can't compact folders that have just been compacted. + if (folder.server.type != "imap" && !folder.expungedBytes) { + return; + } + + folder.compact(null, top.msgWindow); + }, + + /** + * Compacts all folders for the account that the given folder belongs to. + * + * @param {nsIMsgFolder} folder + */ + compactAllFoldersForAccount(folder) { + folder.rootFolder.compactAll(null, top.msgWindow); + }, + + /** + * Opens the dialog to create a new virtual folder + * + * @param aName - The default name for the new folder. + * @param aSearchTerms - The search terms associated with the folder. + * @param aParent - The folder to run the search terms on. + */ + newVirtualFolder(aName, aSearchTerms, aParent) { + let folder = aParent || top.GetDefaultAccountRootFolder(); + if (!folder) { + return; + } + + let name = folder.prettyName; + if (aName) { + name += "-" + aName; + } + + window.openDialog( + "chrome://messenger/content/virtualFolderProperties.xhtml", + "", + "chrome,modal,centerscreen,resizable=yes", + { + folder, + searchTerms: aSearchTerms, + newFolderName: name, + } + ); + }, + + editVirtualFolder(aFolder) { + let folder = aFolder; + + function editVirtualCallback() { + if (gFolder == folder) { + folderTree.dispatchEvent(new CustomEvent("select")); + } + } + window.openDialog( + "chrome://messenger/content/virtualFolderProperties.xhtml", + "", + "chrome,modal,centerscreen,resizable=yes", + { + folder, + editExistingFolder: true, + onOKCallback: editVirtualCallback, + msgWindow: top.msgWindow, + } + ); + }, + + /** + * Prompts for confirmation, if the user hasn't already chosen the "don't ask + * again" option. + * + * @param aCommand - The command to prompt for. + * @param aFolder - The folder for which the confirmation is requested. + */ + _checkConfirmationPrompt(aCommand, aFolder) { + // If no folder was specified, reject the operation. + if (!aFolder) { + return false; + } + + let showPrompt = !Services.prefs.getBoolPref( + "mailnews." + aCommand + ".dontAskAgain", + false + ); + + if (showPrompt) { + let checkbox = { value: false }; + let title = messengerBundle.formatStringFromName( + aCommand + "FolderTitle", + [aFolder.prettyName] + ); + let msg = messengerBundle.GetStringFromName(aCommand + "FolderMessage"); + let ok = + Services.prompt.confirmEx( + window, + title, + msg, + Services.prompt.STD_YES_NO_BUTTONS, + null, + null, + null, + messengerBundle.GetStringFromName(aCommand + "DontAsk"), + checkbox + ) == 0; + if (checkbox.value) { + Services.prefs.setBoolPref( + "mailnews." + aCommand + ".dontAskAgain", + true + ); + } + if (!ok) { + return false; + } + } + return true; + }, + + /** + * Update those UI elements that rely on the presence of a server to function. + */ + updateWidgets() { + this._updateGetMessagesWidgets(); + this._updateWriteMessageWidgets(); + }, + + _updateGetMessagesWidgets() { + const canGetMessages = MailServices.accounts.allServers.some( + s => s.type != "none" + ); + document.getElementById("folderPaneGetMessages").disabled = !canGetMessages; + }, + + _updateWriteMessageWidgets() { + const canWriteMessages = MailServices.accounts.allIdentities.length; + document.getElementById("folderPaneWriteMessage").disabled = + !canWriteMessages; + }, + + isFolderPaneGetMsgsBtnHidden() { + return this.isItemHidden("folderPaneGetMessages"); + }, + + isFolderPaneNewMsgBtnHidden() { + return this.isItemHidden("folderPaneWriteMessage"); + }, + + isFolderPaneHeaderHidden() { + return this.isItemHidden("folderPaneHeaderBar"); + }, + + isItemHidden(item) { + return Services.xulStore.getValue(XULSTORE_URL, item, "hidden") == "true"; + }, + + isItemVisible(item) { + return Services.xulStore.getValue(XULSTORE_URL, item, "visible") == "true"; + }, + + /** + * Ensure the pane header context menu items are correctly checked. + */ + updateContextMenuCheckedItems() { + for (let item of document.querySelectorAll(".folder-pane-option")) { + switch (item.id) { + case "folderPaneHeaderToggleGetMessages": + this.isFolderPaneGetMsgsBtnHidden() + ? item.removeAttribute("checked") + : item.setAttribute("checked", true); + break; + case "folderPaneHeaderToggleNewMessage": + this.isFolderPaneNewMsgBtnHidden() + ? item.removeAttribute("checked") + : item.setAttribute("checked", true); + break; + case "folderPaneHeaderToggleTotalCount": + this.isTotalMsgCountVisible() + ? item.setAttribute("checked", true) + : item.removeAttribute("checked"); + break; + case "folderPaneMoreContextCompactToggle": + this.isCompact + ? item.setAttribute("checked", true) + : item.removeAttribute("checked"); + this.toggleCompactViewMenuItem(); + break; + case "folderPaneHeaderToggleFolderSize": + this.isItemVisible("folderPaneFolderSize") + ? item.setAttribute("checked", true) + : item.removeAttribute("checked"); + break; + case "folderPaneHeaderToggleLocalFolders": + this.isItemHidden("folderPaneLocalFolders") + ? item.setAttribute("checked", true) + : item.removeAttribute("checked"); + break; + default: + item.removeAttribute("checked"); + break; + } + } + }, + + toggleGetMsgsBtn(event) { + let show = event.target.hasAttribute("checked"); + document.getElementById("folderPaneGetMessages").hidden = !show; + + this.updateXULStoreAttribute("folderPaneGetMessages", "hidden", show); + }, + + toggleNewMsgBtn(event) { + let show = event.target.hasAttribute("checked"); + document.getElementById("folderPaneWriteMessage").hidden = !show; + + this.updateXULStoreAttribute("folderPaneWriteMessage", "hidden", show); + }, + + toggleHeader(show) { + document.getElementById("folderPaneHeaderBar").hidden = !show; + this.updateXULStoreAttribute("folderPaneHeaderBar", "hidden", show); + }, + + updateXULStoreAttribute(element, attribute, value) { + Services.xulStore.setValue( + XULSTORE_URL, + element, + attribute, + value ? "false" : "true" + ); + }, + + /** + * Ensure the folder rows UI elements reflect the state set by the user. + */ + updateFolderRowUIElements() { + this.toggleTotalCountBadge(); + this.toggleFolderSizes(this.isItemVisible("folderPaneFolderSize")); + }, + + /** + * Check XULStore to see if the total message count badges should be hidden. + */ + isTotalMsgCountVisible() { + return this.isItemVisible("totalMsgCount"); + }, + + /** + * Toggle the total message count badges and update the XULStore. + */ + toggleTotal(event) { + let show = !event.target.hasAttribute("checked"); + this.updateXULStoreAttribute("totalMsgCount", "visible", show); + this.toggleTotalCountBadge(); + }, + + toggleTotalCountBadge() { + const isHidden = !this.isTotalMsgCountVisible(); + for (let row of document.querySelectorAll(`li[is="folder-tree-row"]`)) { + row.toggleTotalCountBadgeVisibility(isHidden); + } + }, + + /** + * Toggle the folder size option and update the XULStore. + */ + toggleFolderSize(event) { + let show = !event.target.hasAttribute("checked"); + this.updateXULStoreAttribute("folderPaneFolderSize", "visible", show); + this.toggleFolderSizes(!show); + }, + + /** + * Toggle the folder size info on each folder. + */ + toggleFolderSizes(visible) { + const isHidden = !visible; + for (let row of document.querySelectorAll(`li[is="folder-tree-row"]`)) { + row.updateSizeCount(isHidden); + } + }, + + /** + * Toggle the hiding of the local folders and update the XULStore. + */ + toggleLocalFolders(event) { + let isHidden = event.target.hasAttribute("checked"); + this.updateXULStoreAttribute("folderPaneLocalFolders", "hidden", !isHidden); + folderPane.hideLocalFolders = isHidden; + }, + + /** + * Populate the "Get Messages" context menu with all available servers that + * we can fetch data for. + */ + updateGetMessagesContextMenu() { + const menupopup = document.getElementById("folderPaneGetMessagesContext"); + while (menupopup.lastElementChild.classList.contains("server")) { + menupopup.lastElementChild.remove(); + } + + // Get all servers in the proper sorted order. + const servers = FolderUtils.allAccountsSorted(true) + .map(a => a.incomingServer) + .filter(s => s.rootFolder.isServer && s.type != "none"); + for (let server of servers) { + const menuitem = document.createXULElement("menuitem"); + menuitem.classList.add("menuitem-iconic", "server"); + menuitem.dataset.serverType = server.type; + menuitem.dataset.serverSecure = server.isSecure; + menuitem.label = server.prettyName; + menuitem.addEventListener("command", () => + top.MsgGetMessagesForAccount(server.rootFolder) + ); + menupopup.appendChild(menuitem); + } + }, +}; + +/** + * Represents a single row in the folder tree. The row can be for a server or + * a folder. Use `folderPane._createServerRow` or `folderPane._createFolderRow` + * to create rows. + */ +class FolderTreeRow extends HTMLLIElement { + /** + * Used for comparing folder names. This matches the collator used in + * `nsMsgDBFolder::createCollationKeyGenerator`. + * @type {Intl.Collator} + */ + static nameCollator = new Intl.Collator(undefined, { sensitivity: "base" }); + + /** + * Creates an identifier unique for the given mode name and folder URI. + * + * @param {string} modeName + * @param {string} uri + * @returns {string} + */ + static makeRowID(modeName, uri) { + return `${modeName}-${btoa(MailStringUtils.stringToByteString(uri))}`; + } + + /** + * The name of the folder tree mode this row belongs to. + * @type {string} + */ + modeName; + /** + * The URI of the folder represented by this row. + * @type {string} + */ + uri; + /** + * How many times this row is nested. 1 or greater. + * @type {integer} + */ + depth; + /** + * The sort order of this row's associated folder. + * @type {integer} + */ + folderSortOrder; + + /** @type {HTMLSpanElement} */ + nameLabel; + /** @type {HTMLImageElement} */ + icon; + /** @type {HTMLSpanElement} */ + unreadCountLabel; + /** @type {HTMLUListElement} */ + totalCountLabel; + /** @type {HTMLSpanElement} */ + folderSizeLabel; + /** @type {HTMLUListElement} */ + childList; + + constructor() { + super(); + this.setAttribute("is", "folder-tree-row"); + this.append(folderPane._folderTemplate.content.cloneNode(true)); + this.nameLabel = this.querySelector(".name"); + this.icon = this.querySelector(".icon"); + this.unreadCountLabel = this.querySelector(".unread-count"); + this.totalCountLabel = this.querySelector(".total-count"); + this.folderSizeLabel = this.querySelector(".folder-size"); + this.childList = this.querySelector("ul"); + } + + connectedCallback() { + // Set the correct CSS `--depth` variable based on where this row was + // inserted into the tree. + let parent = this.parentNode.closest(`li[is="folder-tree-row"]`); + this.depth = parent ? parent.depth + 1 : 1; + this.childList.style.setProperty("--depth", this.depth); + } + + /** + * The name to display for this folder or server. + * + * @type {string} + */ + get name() { + return this.nameLabel.textContent; + } + + set name(value) { + if (this.name != value) { + this.nameLabel.textContent = value; + this.#updateAriaLabel(); + } + } + + /** + * Format and set the name label of this row. + */ + _setName() { + switch (this._nameStyle) { + case "server": + this.name = this._serverName; + break; + case "folder": + this.name = this._folderName; + break; + case "both": + this.name = `${this._folderName} - ${this._serverName}`; + break; + } + } + + /** + * The number of unread messages for this folder. + * + * @type {integer} + */ + get unreadCount() { + return parseInt(this.unreadCountLabel.textContent, 10) || 0; + } + + set unreadCount(value) { + this.classList.toggle("unread", value > 0); + // Avoid setting `textContent` if possible, each change notifies the + // MutationObserver on `folderTree`, and there could be *many* changes. + let textNode = this.unreadCountLabel.firstChild; + if (textNode) { + textNode.nodeValue = value; + } else { + this.unreadCountLabel.textContent = value; + } + this.#updateAriaLabel(); + } + + /** + * The total number of messages for this folder. + * + * @type {integer} + */ + get totalCount() { + return parseInt(this.totalCountLabel.textContent, 10) || 0; + } + + set totalCount(value) { + this.classList.toggle("total", value > 0); + this.totalCountLabel.textContent = value; + this.#updateAriaLabel(); + } + + /** + * The folder size for this folder. + * + * @type {integer} + */ + get folderSize() { + return this.folderSizeLabel.textContent; + } + + set folderSize(value) { + this.folderSizeLabel.textContent = value; + this.#updateAriaLabel(); + } + + #updateAriaLabel() { + // Collect the various strings and fluent IDs to build the full string for + // the folder aria-label. + let ariaLabelPromises = []; + ariaLabelPromises.push(this.name); + + // If unread messages. + const count = this.unreadCount; + if (count > 0) { + ariaLabelPromises.push( + document.l10n.formatValue("folder-pane-unread-aria-label", { count }) + ); + } + + // If total messages is visible. + if (folderPane.isTotalMsgCountVisible()) { + ariaLabelPromises.push( + document.l10n.formatValue("folder-pane-total-aria-label", { + count: this.totalCount, + }) + ); + } + + if (folderPane.isItemVisible("folderPaneFolderSize")) { + ariaLabelPromises.push(this.folderSize); + } + + Promise.allSettled(ariaLabelPromises).then(results => { + const folderLabel = results + .map(settledPromise => settledPromise.value ?? "") + .filter(value => value.trim() != "") + .join(", "); + this.setAttribute("aria-label", folderLabel); + this.title = folderLabel; + }); + } + + /** + * Set some common properties based on the URI for this row. + * `this.modeName` must be set before calling this function. + * + * @param {string} uri + */ + _setURI(uri) { + this.id = FolderTreeRow.makeRowID(this.modeName, uri); + this.uri = uri; + if (!FolderTreeProperties.getIsExpanded(uri, this.modeName)) { + this.classList.add("collapsed"); + } + this.setIconColor(); + } + + /** + * Set the icon color to the given color, or if none is given the value from + * FolderTreeProperties, or the default. + * + * @param {string?} iconColor + */ + setIconColor(iconColor) { + if (!iconColor) { + iconColor = FolderTreeProperties.getColor(this.uri); + } + this.icon.style.setProperty("--icon-color", iconColor ?? ""); + } + + /** + * Set some properties based on the server for this row. + * + * @param {nsIMsgIncomingServer} server + */ + setServer(server) { + this._setURI(server.rootFolder.URI); + this.dataset.serverKey = server.key; + this.dataset.serverType = server.type; + this.dataset.serverSecure = server.isSecure; + this._nameStyle = "server"; + this._serverName = server.prettyName; + this._setName(); + const isCollapsed = this.classList.contains("collapsed"); + if (isCollapsed) { + this.unreadCount = server.rootFolder.getNumUnread(isCollapsed); + this.totalCount = server.rootFolder.getTotalMessages(isCollapsed); + } + this.setFolderPropertiesFromFolder(server.rootFolder); + } + + /** + * Set some properties based on the folder for this row. + * + * @param {nsIMsgFolder} folder + * @param {"folder"|"server"|"both"} nameStyle + */ + setFolder(folder, nameStyle = "folder") { + this._setURI(folder.URI); + this.dataset.serverKey = folder.server.key; + this.setFolderTypeFromFolder(folder); + this.setFolderPropertiesFromFolder(folder); + this._nameStyle = nameStyle; + this._serverName = folder.server.prettyName; + this._folderName = folder.abbreviatedName; + this._setName(); + const isCollapsed = this.classList.contains("collapsed"); + this.unreadCount = folder.getNumUnread(isCollapsed); + this.totalCount = folder.getTotalMessages(isCollapsed); + if (folderPane.isItemVisible("folderPaneFolderSize")) { + this.folderSize = this.formatFolderSize(folder.sizeOnDisk); + } + this.folderSortOrder = folder.sortOrder; + if (folder.noSelect) { + this.classList.add("noselect-folder"); + } else { + this.setAttribute("draggable", "true"); + } + } + + /** + * Update new message state of the row. + * + * @param {boolean} [notifiedOfNewMessages=false] - When true there are new + * messages on the server, but they may not yet be downloaded locally. + */ + updateNewMessages(notifiedOfNewMessages = false) { + const folder = MailServices.folderLookup.getFolderForURL(this.uri); + const foldersHaveNewMessages = this.classList.contains("collapsed") + ? folder.hasFolderOrSubfolderNewMessages + : folder.hasNewMessages; + this.classList.toggle( + "new-messages", + notifiedOfNewMessages || foldersHaveNewMessages + ); + } + + updateUnreadMessageCount() { + this.unreadCount = MailServices.folderLookup + .getFolderForURL(this.uri) + .getNumUnread(this.classList.contains("collapsed")); + } + + updateTotalMessageCount() { + const folder = MailServices.folderLookup.getFolderForURL(this.uri); + this.totalCount = folder.getTotalMessages( + this.classList.contains("collapsed") + ); + if (folderPane.isItemVisible("folderPaneFolderSize")) { + this.updateSizeCount(false, folder); + } + } + + updateSizeCount(isHidden, folder = null) { + this.folderSizeLabel.hidden = isHidden; + if (!isHidden) { + folder = folder ?? MailServices.folderLookup.getFolderForURL(this.uri); + this.folderSize = this.formatFolderSize(folder.sizeOnDisk); + } + } + + /** + * Format the folder file size to display in the folder pane. + * + * @param {integer} size - The folder size on disk. + * @returns {string} - The formatted folder size. + */ + formatFolderSize(size) { + return size / 1024 < 1 ? "" : top.messenger.formatFileSize(size, true); + } + + /** + * Update the visibility of the total count badge. + * + * @param {boolean} isHidden + */ + toggleTotalCountBadgeVisibility(isHidden) { + this.totalCountLabel.hidden = isHidden; + this.#updateAriaLabel(); + } + + /** + * Sets the folder type property based on the folder for the row. + * + * @param {nsIMsgFolder} folder + */ + setFolderTypeFromFolder(folder) { + let folderType = FolderUtils.getSpecialFolderString(folder); + if (folderType != "none") { + this.dataset.folderType = folderType.toLowerCase(); + } + } + + /** + * Sets folder properties based on the folder for the row. + * + * @param {nsIMsgFolder} folder + */ + setFolderPropertiesFromFolder(folder) { + if (folder.server.type != "rss") { + return; + } + let urls = !folder.isServer ? FeedUtils.getFeedUrlsInFolder(folder) : null; + if (urls?.length == 1) { + let url = urls[0]; + this.icon.style = `content: url("page-icon:${url}"); background-image: none;`; + } + let props = FeedUtils.getFolderProperties(folder); + for (let name of ["hasError", "isBusy", "isPaused"]) { + if (props.includes(name)) { + this.dataset[name] = "true"; + } else { + delete this.dataset[name]; + } + } + } + + /** + * Update this row's name label to match the new `prettyName` of the server. + * + * @param {string} name + */ + setServerName(name) { + this._serverName = name; + if (this._nameStyle != "folder") { + this._setName(); + } + } + + /** + * Add a child row in the correct sort order. + * + * @param {FolderTreeRow} newChild + * @returns {FolderTreeRow} + */ + insertChildInOrder(newChild) { + let { folderSortOrder, name } = newChild; + for (let child of this.childList.children) { + if (folderSortOrder < child.folderSortOrder) { + return this.childList.insertBefore(newChild, child); + } + if ( + folderSortOrder == child.folderSortOrder && + FolderTreeRow.nameCollator.compare(name, child.name) < 0 + ) { + return this.childList.insertBefore(newChild, child); + } + } + return this.childList.appendChild(newChild); + } +} +customElements.define("folder-tree-row", FolderTreeRow, { extends: "li" }); + +/** + * Header area of the message list pane. + */ +var threadPaneHeader = { + /** + * The header bar element. + * @type {?HTMLElement} + */ + bar: null, + /** + * The h2 element receiving the folder name. + * @type {?HTMLHeadElement} + */ + folderName: null, + /** + * The span element receiving the message count. + * @type {?HTMLSpanElement} + */ + folderCount: null, + /** + * The quick filter toolbar toggle button. + * @type {?HTMLButtonElement} + */ + filterButton: null, + /** + * The display options button opening the popup. + * @type {?HTMLButtonElement} + */ + displayButton: null, + /** + * If the header area is hidden. + * @type {boolean} + */ + isHidden: false, + + init() { + this.isHidden = + Services.xulStore.getValue(XULSTORE_URL, "threadPaneHeader", "hidden") === + "true"; + this.bar = document.getElementById("threadPaneHeaderBar"); + this.bar.hidden = this.isHidden; + + this.folderName = document.getElementById("threadPaneFolderName"); + this.folderCount = document.getElementById("threadPaneFolderCount"); + this.selectedCount = document.getElementById("threadPaneSelectedCount"); + this.filterButton = document.getElementById("threadPaneQuickFilterButton"); + this.filterButton.addEventListener("click", () => + goDoCommand("cmd_toggleQuickFilterBar") + ); + window.addEventListener("qfbtoggle", this); + this.onQuickFilterToggle(); + + this.displayButton = document.getElementById("threadPaneDisplayButton"); + this.displayContext = document.getElementById("threadPaneDisplayContext"); + this.displayButton.addEventListener("click", event => { + this.displayContext.openPopup(event.target, { triggerEvent: event }); + }); + }, + + uninit() { + window.removeEventListener("qfbtoggle", this); + }, + + handleEvent(event) { + switch (event.type) { + case "qfbtoggle": + this.onQuickFilterToggle(); + break; + } + }, + + /** + * Update the context menu to reflect the currently selected display options. + * + * @param {Event} event - The popupshowing DOMEvent. + */ + updateDisplayContextMenu(event) { + if (event.target.id != "threadPaneDisplayContext") { + return; + } + const isTableLayout = document.body.classList.contains("layout-table"); + document + .getElementById( + isTableLayout ? "threadPaneTableView" : "threadPaneCardsView" + ) + .setAttribute("checked", "true"); + }, + + /** + * Update the menuitems inside the thread pane sort menupopup. + * + * @param {Event} event - The popupshowing DOMEvent. + */ + updateThreadPaneSortMenu(event) { + if (event.target.id != "menu_threadPaneSortPopup") { + return; + } + + const hiddenColumns = threadPane.columns + .filter(c => c.hidden) + .map(c => c.sortKey); + + // Update menuitem to reflect sort key. + for (const menuitem of event.target.querySelectorAll(`[name="sortby"]`)) { + const sortKey = menuitem.getAttribute("value"); + menuitem.setAttribute( + "checked", + gViewWrapper.primarySortType == Ci.nsMsgViewSortType[sortKey] + ); + if (hiddenColumns.includes(sortKey)) { + menuitem.setAttribute("disabled", "true"); + } else { + menuitem.removeAttribute("disabled"); + } + } + + // Update sort direction menu items. + event.target + .querySelector(`[value="ascending"]`) + .setAttribute("checked", gViewWrapper.isSortedAscending); + event.target + .querySelector(`[value="descending"]`) + .setAttribute("checked", !gViewWrapper.isSortedAscending); + + // Update the threaded and groupedBy menu items. + event.target + .querySelector(`[value="threaded"]`) + .setAttribute("checked", gViewWrapper.showThreaded); + event.target + .querySelector(`[value="unthreaded"]`) + .setAttribute("checked", gViewWrapper.showUnthreaded); + event.target + .querySelector(`[value="group"]`) + .setAttribute("checked", gViewWrapper.showGroupedBySort); + }, + + /** + * Change the display view of the message list pane. + * + * @param {DOMEvent} event - The click event. + */ + changePaneView(event) { + const view = event.target.value; + Services.xulStore.setValue(XULSTORE_URL, "threadPane", "view", view); + threadPane.updateThreadView(view); + }, + + /** + * Update the quick filter button based on the quick filter bar state. + */ + onQuickFilterToggle() { + const active = quickFilterBar.filterer.visible; + this.filterButton.setAttribute("aria-pressed", active.toString()); + }, + + /** + * Toggle the visibility of the message list pane header. + */ + toggleThreadPaneHeader() { + this.isHidden = !this.isHidden; + this.bar.hidden = this.isHidden; + + Services.xulStore.setValue( + XULSTORE_URL, + "threadPaneHeader", + "hidden", + this.isHidden + ); + // Trigger a data refresh if we're revealing the header. + if (!this.isHidden) { + this.onFolderSelected(); + } + }, + + /** + * Update the header data when the selected folder changes. + */ + onFolderSelected() { + // Bail out if the pane is hidden as we don't need to update anything. + if (this.isHidden) { + return; + } + + // Hide any potential stale data if we don't have a folder. + if (!gFolder && !gDBView && !gViewWrapper?.isSynthetic) { + this.folderName.hidden = true; + this.folderCount.hidden = true; + this.selectedCount.hidden = true; + return; + } + + const folderName = gFolder?.abbreviatedName ?? document.title; + this.folderName.textContent = folderName; + this.folderName.title = folderName; + document.l10n.setAttributes( + this.folderCount, + "thread-pane-folder-message-count", + { count: gFolder?.getTotalMessages(false) || gDBView?.rowCount || 0 } + ); + + this.folderName.hidden = false; + this.folderCount.hidden = false; + }, + + /** + * Update the total message count in the header if the value changed for the + * currently selected folder. + * + * @param {nsIMsgFolder} folder - The folder updating the count. + * @param {integer} newValue + */ + updateFolderCount(folder, newValue) { + if (!gFolder || !folder || this.isHidden || folder.URI != gFolder.URI) { + return; + } + + document.l10n.setAttributes( + this.folderCount, + "thread-pane-folder-message-count", + { count: newValue } + ); + }, + + /** + * Count the number of currently selected messages and update the selected + * message count indicator. + */ + updateSelectedCount() { + // Bail out if the pane is hidden as we don't need to update anything. + if (this.isHidden) { + return; + } + + let count = gDBView?.getSelectedMsgHdrs().length; + if (count < 2) { + this.selectedCount.hidden = true; + return; + } + document.l10n.setAttributes( + this.selectedCount, + "thread-pane-folder-selected-count", + { count } + ); + this.selectedCount.hidden = false; + }, +}; + +var threadPane = { + /** + * Non-persistent storage of the last-selected items in each folder. + * Keys in this map are folder URIs. Values are objects containing an array + * of the selected messages and the current message. Messages are referenced + * by message key to account for possible changes in the folder. + * + * @type {Map} + */ + _savedSelections: new Map(), + + /** + * This is set to true in folderPane._onSelect before opening the folder, if + * new messages have been received and the corresponding preference is set. + * + * @type {boolean} + */ + scrollToNewMessage: false, + + /** + * Set to true when a scrolling event (presumably by the user) is detected + * while messages are still loading in a newly created view. + * + * @type {boolean} + */ + scrollDetected: false, + + /** + * The first detected scrolling event is triggered by creating the view + * itself. This property is then set to false. + * + * @type {boolean} + */ + isFirstScroll: true, + + columns: getDefaultColumns(gFolder), + + cardColumns: getDefaultColumnsForCardsView(gFolder), + + async init() { + quickFilterBar.init(); + + this.setUpTagStyles(); + Services.prefs.addObserver("mailnews.tags.", this); + + Services.obs.addObserver(this, "addrbook-displayname-changed"); + + // Ensure TreeView and its classes are properly defined. + await customElements.whenDefined("tree-view-table-row"); + + threadTree = document.getElementById("threadTree"); + this.treeTable = threadTree.table; + this.treeTable.editable = true; + this.treeTable.setPopupMenuTemplates([ + "threadPaneApplyColumnMenu", + "threadPaneApplyViewMenu", + ]); + threadTree.setAttribute( + "rows", + !Services.xulStore.hasValue(XULSTORE_URL, "threadPane", "view") || + Services.xulStore.getValue(XULSTORE_URL, "threadPane", "view") == + "cards" + ? "thread-card" + : "thread-row" + ); + + XPCOMUtils.defineLazyPreferenceGetter( + this, + "selectDelay", + "mailnews.threadpane_select_delay", + null, + (name, oldValue, newValue) => (threadTree.dataset.selectDelay = newValue) + ); + threadTree.dataset.selectDelay = this.selectDelay; + + window.addEventListener("uidensitychange", () => { + this.densityChange(); + threadTree.reset(); + }); + this.densityChange(); + + XPCOMUtils.defineLazyGetter(this, "notificationBox", () => { + let container = document.getElementById("threadPaneNotificationBox"); + return new MozElements.NotificationBox(element => + container.append(element) + ); + }); + + this.treeTable.addEventListener("shift-column", event => { + this.onColumnShifted(event.detail); + }); + this.treeTable.addEventListener("reorder-columns", event => { + this.onColumnsReordered(event.detail); + }); + this.treeTable.addEventListener("column-resized", event => { + this.treeTable.setColumnsWidths(XULSTORE_URL, event); + }); + this.treeTable.addEventListener("columns-changed", event => { + this.onColumnsVisibilityChanged(event.detail); + }); + this.treeTable.addEventListener("sort-changed", event => { + this.onSortChanged(event.detail); + }); + this.treeTable.addEventListener("restore-columns", () => { + this.restoreDefaultColumns(); + }); + this.treeTable.addEventListener("toggle-flag", event => { + gDBView.applyCommandToIndices( + event.detail.isFlagged + ? Ci.nsMsgViewCommandType.unflagMessages + : Ci.nsMsgViewCommandType.flagMessages, + [event.detail.index] + ); + }); + this.treeTable.addEventListener("toggle-unread", event => { + gDBView.applyCommandToIndices( + event.detail.isUnread + ? Ci.nsMsgViewCommandType.markMessagesRead + : Ci.nsMsgViewCommandType.markMessagesUnread, + [event.detail.index] + ); + }); + this.treeTable.addEventListener("toggle-spam", event => { + gDBView.applyCommandToIndices( + event.detail.isJunk + ? Ci.nsMsgViewCommandType.unjunk + : Ci.nsMsgViewCommandType.junk, + [event.detail.index] + ); + }); + this.treeTable.addEventListener("thread-changed", () => { + sortController.toggleThreaded(); + }); + this.treeTable.addEventListener("request-delete", event => { + gDBView.applyCommandToIndices(Ci.nsMsgViewCommandType.deleteMsg, [ + event.detail.index, + ]); + }); + + this.updateClassList(); + + threadTree.addEventListener("contextmenu", this); + threadTree.addEventListener("dblclick", this); + threadTree.addEventListener("auxclick", this); + threadTree.addEventListener("keypress", this); + threadTree.addEventListener("select", this); + threadTree.table.body.addEventListener("dragstart", this); + threadTree.addEventListener("dragover", this); + threadTree.addEventListener("drop", this); + threadTree.addEventListener("expanded", this); + threadTree.addEventListener("collapsed", this); + threadTree.addEventListener("scroll", this); + }, + + uninit() { + Services.prefs.removeObserver("mailnews.tags.", this); + Services.obs.removeObserver(this, "addrbook-displayname-changed"); + }, + + handleEvent(event) { + const notOnEmptySpace = event.target !== threadTree; + switch (event.type) { + case "contextmenu": + if (notOnEmptySpace) { + this._onContextMenu(event); + } + break; + case "dblclick": + if (notOnEmptySpace) { + this._onDoubleClick(event); + } + break; + case "auxclick": + if (event.button == 1 && notOnEmptySpace) { + this._onMiddleClick(event); + } + break; + case "keypress": + this._onKeyPress(event); + break; + case "select": + this._onSelect(event); + break; + case "dragstart": + this._onDragStart(event); + break; + case "dragover": + this._onDragOver(event); + break; + case "drop": + this._onDrop(event); + break; + case "expanded": + case "collapsed": + if (event.detail == threadTree.selectedIndex) { + // The selected index hasn't changed, but a collapsed row represents + // multiple messages, so for our purposes the selection has changed. + threadTree.dispatchEvent(new CustomEvent("select")); + } + break; + case "scroll": + if (this.isFirstScroll) { + this.isFirstScroll = false; + break; + } + this.scrollDetected = true; + break; + } + }, + observe(subject, topic, data) { + if (topic == "nsPref:changed") { + this.setUpTagStyles(); + } else if (topic == "addrbook-displayname-changed") { + // This runs the when mail.displayname.version preference observer is + // notified/the mail.displayname.version number has been updated. + threadTree.invalidate(); + } + }, + + /** + * Update the CSS classes of the thread tree based on the current folder. + */ + updateClassList() { + if (!gFolder) { + threadTree.classList.remove("is-outgoing"); + return; + } + + threadTree.classList.toggle("is-outgoing", isOutgoing(gFolder)); + }, + + /** + * Temporarily select a different index from the actual selection, without + * visually changing or losing the current selection. + * + * @param {integer} index - The index of the clicked row. + */ + suppressSelect(index) { + this.saveSelection(); + threadTree._selection.selectEventsSuppressed = true; + threadTree._selection.select(index); + }, + + /** + * Clear the selection suppression and restore the previous selection. + */ + releaseSelection() { + threadTree._selection.selectEventsSuppressed = true; + this.restoreSelection({ notify: false }); + threadTree._selection.selectEventsSuppressed = false; + }, + + _onDoubleClick(event) { + if (event.target.closest("button") || event.target.closest("menupopup")) { + // Prevent item activation if double click happens on a button inside the + // row. E.g.: Thread toggle, spam, favorite, etc. or in a menupopup like + // the column picker. + return; + } + this._onItemActivate(event); + }, + + _onKeyPress(event) { + if (event.target.closest("thead")) { + // Bail out if the keypress happens in the table header. + return; + } + + if (event.key == "Enter") { + this._onItemActivate(event); + } + }, + + _onMiddleClick(event) { + const row = + event.target.closest(`tr[is^="thread-"]`) || + threadTree.getRowAtIndex(threadTree.currentIndex); + + const isSelected = gDBView.selection.isSelected(row.index); + if (!isSelected) { + // The middle-clicked row is not selected. Tell the activate item to use + // this instead. + this.suppressSelect(row.index); + } + this._onItemActivate(event); + if (!isSelected) { + this.releaseSelection(); + } + }, + + _onItemActivate(event) { + if ( + threadTree.selectedIndex < 0 || + gDBView.getFlagsAt(threadTree.selectedIndex) & MSG_VIEW_FLAG_DUMMY + ) { + return; + } + + let folder = gFolder || gDBView.hdrForFirstSelectedMessage.folder; + if (folder?.isSpecialFolder(Ci.nsMsgFolderFlags.Drafts, true)) { + commandController.doCommand("cmd_editDraftMsg", event); + } else if (folder?.isSpecialFolder(Ci.nsMsgFolderFlags.Templates, true)) { + commandController.doCommand("cmd_newMsgFromTemplate", event); + } else { + commandController.doCommand("cmd_openMessage", event); + } + }, + + /** + * Handle threadPane select events. + */ + _onSelect(event) { + if (!paneLayout.messagePaneVisible.isCollapsed && gDBView) { + messagePane.clearWebPage(); + switch (gDBView.numSelected) { + case 0: + messagePane.clearMessage(); + messagePane.clearMessages(); + threadPaneHeader.selectedCount.hidden = true; + break; + case 1: + if ( + gDBView.getFlagsAt(threadTree.selectedIndex) & MSG_VIEW_FLAG_DUMMY + ) { + messagePane.clearMessage(); + messagePane.clearMessages(); + threadPaneHeader.selectedCount.hidden = true; + } else { + let uri = gDBView.getURIForViewIndex(threadTree.selectedIndex); + messagePane.displayMessage(uri); + threadPaneHeader.updateSelectedCount(); + } + break; + default: + messagePane.displayMessages(gDBView.getSelectedMsgHdrs()); + threadPaneHeader.updateSelectedCount(); + break; + } + } + + // Update the state of the zoom commands, since the view has changed. + const commandsToUpdate = [ + "cmd_fullZoomReduce", + "cmd_fullZoomEnlarge", + "cmd_fullZoomReset", + "cmd_fullZoomToggle", + ]; + for (const command of commandsToUpdate) { + top.goUpdateCommand(command); + } + }, + + /** + * Handle threadPane drag events. + */ + _onDragStart(event) { + let row = event.target.closest(`tr[is^="thread-"]`); + if (!row) { + event.preventDefault(); + return; + } + + let messageURIs = gDBView.getURIsForSelection(); + if (!threadTree.selectedIndices.includes(row.index)) { + messageURIs = [gDBView.getURIForViewIndex(row.index)]; + } + + let noSubjectString = messengerBundle.GetStringFromName( + "defaultSaveMessageAsFileName" + ); + if (noSubjectString.endsWith(".eml")) { + noSubjectString = noSubjectString.slice(0, -4); + } + let longSubjectTruncator = messengerBundle.GetStringFromName( + "longMsgSubjectTruncator" + ); + // Clip the subject string to 124 chars to avoid problems on Windows, + // see NS_MAX_FILEDESCRIPTOR in m-c/widget/windows/nsDataObj.cpp . + const maxUncutNameLength = 124; + let maxCutNameLength = maxUncutNameLength - longSubjectTruncator.length; + let messages = new Map(); + + for (let [index, uri] of Object.entries(messageURIs)) { + let msgService = MailServices.messageServiceFromURI(uri); + let msgHdr = msgService.messageURIToMsgHdr(uri); + let subject = msgHdr.mime2DecodedSubject || ""; + if (msgHdr.flags & Ci.nsMsgMessageFlags.HasRe) { + subject = "Re: " + subject; + } + + let uniqueFileName; + // If there is no subject, use a default name. + // If subject needs to be truncated, add a truncation character to indicate it. + if (!subject) { + uniqueFileName = noSubjectString; + } else { + uniqueFileName = + subject.length <= maxUncutNameLength + ? subject + : subject.substr(0, maxCutNameLength) + longSubjectTruncator; + } + let msgFileName = validateFileName(uniqueFileName); + let msgFileNameLowerCase = msgFileName.toLocaleLowerCase(); + + while (true) { + if (!messages.has(msgFileNameLowerCase)) { + messages.set(msgFileNameLowerCase, 1); + break; + } else { + let number = messages.get(msgFileNameLowerCase); + messages.set(msgFileNameLowerCase, number + 1); + let postfix = "-" + number; + msgFileName = msgFileName + postfix; + msgFileNameLowerCase = msgFileNameLowerCase + postfix; + } + } + + msgFileName = msgFileName + ".eml"; + + // This type should be unnecessary, but getFlavorData can't get at + // text/x-moz-message for some reason. + event.dataTransfer.mozSetDataAt("text/plain", uri, index); + event.dataTransfer.mozSetDataAt("text/x-moz-message", uri, index); + event.dataTransfer.mozSetDataAt( + "text/x-moz-url", + msgService.getUrlForUri(uri).spec, + index + ); + // When dragging messages to the filesystem: + // - Windows fetches this value and writes it to a file. + // - Linux does the same if there are multiple files, but for a single + // file it uses the flavor data provider below. + // - MacOS always uses the flavor data provider. + event.dataTransfer.mozSetDataAt( + "application/x-moz-file-promise-url", + msgService.getUrlForUri(uri).spec, + index + ); + event.dataTransfer.mozSetDataAt( + "application/x-moz-file-promise", + this._flavorDataProvider, + index + ); + event.dataTransfer.mozSetDataAt( + "application/x-moz-file-promise-dest-filename", + msgFileName.replace(/(.{74}).*(.{10})$/u, "$1...$2"), + index + ); + } + + event.dataTransfer.effectAllowed = "copyMove"; + let bcr = row.getBoundingClientRect(); + event.dataTransfer.setDragImage( + row, + event.clientX - bcr.x, + event.clientY - bcr.y + ); + }, + + /** + * Handle threadPane dragover events. + */ + _onDragOver(event) { + if (event.target.closest("thead")) { + return; // Only allow dropping in the body. + } + // Must prevent default. Otherwise dropEffect gets cleared. + event.preventDefault(); + event.dataTransfer.dropEffect = "none"; + let types = Array.from(event.dataTransfer.mozTypesAt(0)); + let targetFolder = gFolder; + if (types.includes("application/x-moz-file")) { + if (targetFolder.isServer || !targetFolder.canFileMessages) { + return; + } + for (let i = 0; i < event.dataTransfer.mozItemCount; i++) { + let extFile = event.dataTransfer + .mozGetDataAt("application/x-moz-file", i) + .QueryInterface(Ci.nsIFile); + if (!extFile.isFile() || !/\.eml$/i.test(extFile.leafName)) { + return; + } + } + event.dataTransfer.dropEffect = "copy"; + } + }, + + /** + * Handle threadPane drop events. + */ + _onDrop(event) { + if (event.target.closest("thead")) { + return; // Only allow dropping in the body. + } + event.preventDefault(); + for (let i = 0; i < event.dataTransfer.mozItemCount; i++) { + let extFile = event.dataTransfer + .mozGetDataAt("application/x-moz-file", i) + .QueryInterface(Ci.nsIFile); + if (extFile.isFile() && /\.eml$/i.test(extFile.leafName)) { + MailServices.copy.copyFileMessage( + extFile, + gFolder, + null, + false, + 1, + "", + null, + top.msgWindow + ); + } + } + }, + + _onContextMenu(event, retry = false) { + let row = + event.target.closest(`tr[is^="thread-"]`) || + threadTree.getRowAtIndex(threadTree.currentIndex); + const isMouse = event.button == 2; + if (!isMouse) { + if (threadTree.selectedIndex < 0) { + return; + } + // Scroll selected row we're triggering the context menu for into view. + threadTree.scrollToIndex(threadTree.currentIndex, true); + if (!row) { + row = threadTree.getRowAtIndex(threadTree.currentIndex); + // Try again once in the next frame. + if (!row && !retry) { + window.requestAnimationFrame(() => this._onContextMenu(event, true)); + return; + } + } + } + if (!row || gDBView.getFlagsAt(row.index) & MSG_VIEW_FLAG_DUMMY) { + return; + } + + mailContextMenu.setAsThreadPaneContextMenu(); + let popup = document.getElementById("mailContext"); + + if (isMouse) { + if (!gDBView.selection.isSelected(row.index)) { + // The right-clicked-on row is not selected. Tell the context menu to + // use it instead. This override lasts until the context menu fires + // a "popuphidden" event. + mailContextMenu.setOverrideSelection(row.index); + row.classList.add("context-menu-target"); + } + popup.openPopupAtScreen(event.screenX, event.screenY, true); + } else { + popup.openPopup(row, "after_end", 0, 0, true); + } + + event.preventDefault(); + }, + + _flavorDataProvider: { + QueryInterface: ChromeUtils.generateQI(["nsIFlavorDataProvider"]), + + getFlavorData(transferable, flavor, data) { + if (flavor !== "application/x-moz-file-promise") { + return; + } + + let fileName = {}; + transferable.getTransferData( + "application/x-moz-file-promise-dest-filename", + fileName + ); + fileName.value.QueryInterface(Ci.nsISupportsString); + + let destDir = {}; + transferable.getTransferData( + "application/x-moz-file-promise-dir", + destDir + ); + destDir.value.QueryInterface(Ci.nsIFile); + + let file = destDir.value.clone(); + file.append(fileName.value.data); + + let messageURI = {}; + transferable.getTransferData("text/plain", messageURI); + messageURI.value.QueryInterface(Ci.nsISupportsString); + + top.messenger.saveAs(messageURI.value.data, true, null, file.path, true); + }, + }, + + _jsTree: { + QueryInterface: ChromeUtils.generateQI(["nsIMsgJSTree"]), + _inBatch: false, + beginUpdateBatch() { + this._inBatch = true; + }, + endUpdateBatch() { + this._inBatch = false; + }, + ensureRowIsVisible(index) { + if (!this._inBatch) { + threadTree.scrollToIndex(index, true); + } + }, + invalidate() { + if (!this._inBatch) { + threadTree.reset(); + if (threadPane) { + threadPane.isFirstScroll = true; + threadPane.scrollDetected = false; + threadPane.scrollToLatestRowIfNoSelection(); + } + } + }, + invalidateRange(startIndex, endIndex) { + if (!this._inBatch) { + threadTree.invalidateRange(startIndex, endIndex); + } + }, + rowCountChanged(index, count) { + if (!this._inBatch) { + threadTree.rowCountChanged(index, count); + } + }, + get currentIndex() { + return threadTree.currentIndex; + }, + set currentIndex(index) { + threadTree.currentIndex = index; + }, + }, + + /** + * Tell the tree and the view about each other. `nsITreeView.setTree` can't + * be used because it needs a XULTreeElement and threadTree isn't one. + * (Strictly speaking the shim passed here isn't a tree either but it does + * implement the required methods.) + * + * @param {nsIMsgDBView} view + */ + setTreeView(view) { + threadTree.view = gDBView = view; + // Clear the batch flag. Don't call `endUpdateBatch` as that may change in + // future leading to unintended consequences. + this._jsTree._inBatch = false; + view.setJSTree(this._jsTree); + }, + + setUpTagStyles() { + if (this.tagStyle) { + this.tagStyle.remove(); + } + this.tagStyle = document.head.appendChild(document.createElement("style")); + + for (let { color, key } of MailServices.tags.getAllTags()) { + if (!color) { + continue; + } + let selector = MailServices.tags.getSelectorForKey(key); + let contrast = TagUtils.isColorContrastEnough(color) ? "black" : "white"; + this.tagStyle.sheet.insertRule( + `tr[data-properties~="${selector}"] { + --tag-color: ${color}; + --tag-contrast-color: ${contrast}; + }` + ); + } + }, + + /** + * Make the list rows density aware. + */ + densityChange() { + // The class ThreadRow can't be referenced because it's declared in a + // different scope. But we can get it from customElements. + let rowClass = customElements.get("thread-row"); + let cardClass = customElements.get("thread-card"); + switch (UIDensity.prefValue) { + case UIDensity.MODE_COMPACT: + rowClass.ROW_HEIGHT = 18; + cardClass.ROW_HEIGHT = 40; + break; + case UIDensity.MODE_TOUCH: + rowClass.ROW_HEIGHT = 32; + cardClass.ROW_HEIGHT = 52; + break; + default: + rowClass.ROW_HEIGHT = 26; + cardClass.ROW_HEIGHT = 46; + break; + } + }, + + /** + * Store the current thread tree selection. + */ + saveSelection() { + // Identifying messages by key doesn't reliably work on on cross-folder views since + // the msgKey may not be unique. + if (gFolder && gDBView && !gViewWrapper?.isMultiFolder) { + this._savedSelections.set(gFolder.URI, { + currentKey: gDBView.getKeyAt(threadTree.currentIndex), + // In views which are "grouped by sort", getting the key for collapsed dummy rows + // returns the key of the first group member, so we would restore something that + // wasn't selected. So filter them out. + selectedKeys: threadTree.selectedIndices + .filter(i => !gViewWrapper.isGroupedByHeaderAtIndex(i)) + .map(gDBView.getKeyAt), + }); + } + }, + + /** + * Forget any saved selection of the given folder. This is useful if you're + * going to set the selection after switching to the folder. + * + * @param {string} folderURI + */ + forgetSelection(folderURI) { + this._savedSelections.delete(folderURI); + }, + + /** + * Restore the previously saved thread tree selection. + * + * @param {boolean} [discard=true] - If false, the selection data is kept for + * another call of this function, unless all selections could already be + * restored in this run. + * @param {boolean} [notify=true] - Whether a change in "select" event + * should be fired. + * @param {boolean} [expand=true] - Try to expand threads containing selected + * messages. + */ + restoreSelection({ discard = true, notify = true, expand = true } = {}) { + if (!this._savedSelections.has(gFolder?.URI) || !threadTree.view) { + return; + } + + let { currentKey, selectedKeys } = this._savedSelections.get(gFolder.URI); + let currentIndex = nsMsgViewIndex_None; + let indices = new Set(); + for (let key of selectedKeys) { + let index = gDBView.findIndexFromKey(key, expand); + // While the first message in a collapsed group returns the index of the + // dummy row, other messages return none. To be consistent, we don't + // select the dummy row in any case. + if ( + index != nsMsgViewIndex_None && + !gViewWrapper.isGroupedByHeaderAtIndex(index) + ) { + indices.add(index); + if (key == currentKey) { + currentIndex = index; + } + continue; + } + // Since it does not seem to be possible to reliably find the dummy row + // for a message in a group, we continue. + if (gViewWrapper.showGroupedBySort) { + continue; + } + // The message for this key can't be found. Perhaps the thread it's in + // has been collapsed? Select the root message in that case. + try { + const folder = + gViewWrapper.isVirtual && gViewWrapper.isSingleFolder + ? gViewWrapper._underlyingFolders[0] + : gFolder; + const msgHdr = folder.GetMessageHeader(key); + const thread = gDBView.getThreadContainingMsgHdr(msgHdr); + const rootMsgHdr = thread.getRootHdr(); + index = gDBView.findIndexOfMsgHdr(rootMsgHdr, false); + if (index != nsMsgViewIndex_None) { + indices.add(index); + if (key == currentKey) { + currentIndex = index; + } + } + } catch (ex) { + console.error(ex); + } + } + threadTree.setSelectedIndices(indices.values(), !notify); + + if (currentIndex != nsMsgViewIndex_None) { + threadTree.style.scrollBehavior = "auto"; // Avoid smooth scroll. + threadTree.currentIndex = currentIndex; + threadTree.style.scrollBehavior = null; + } + + // If all selections have already been restored, discard them as well. + if (discard || gDBView.selection.count == selectedKeys.length) { + this._savedSelections.delete(gFolder.URI); + } + }, + + /** + * Scroll to the most relevant end of the tree, but only if no rows are + * selected. + */ + scrollToLatestRowIfNoSelection() { + if (!gDBView || gDBView.selection.count > 0 || gDBView.rowCount <= 0) { + return; + } + if ( + gViewWrapper.sortImpliesTemporalOrdering && + gViewWrapper.isSortedAscending + ) { + threadTree.scrollToIndex(gDBView.rowCount - 1, true); + } else { + threadTree.scrollToIndex(0, true); + } + }, + + /** + * Re-collapse threads expanded by nsMsgQuickSearchDBView if necessary. + */ + ensureThreadStateForQuickSearchView() { + // nsMsgQuickSearchDBView::SortThreads leaves all threads expanded in any + // case. + if ( + gViewWrapper.isSingleFolder && + gViewWrapper.search.hasSearchTerms && + gViewWrapper.showThreaded && + !gViewWrapper._threadExpandAll + ) { + window.threadPane.saveSelection(); + gViewWrapper.dbView.doCommand(Ci.nsMsgViewCommandType.collapseAll); + window.threadPane.restoreSelection(); + } + }, + + /** + * Restore the collapsed or expanded state of threads. + */ + restoreThreadState() { + if ( + gViewWrapper._threadExpandAll && + !(gViewWrapper.dbView.viewFlags & Ci.nsMsgViewFlagsType.kExpandAll) + ) { + gViewWrapper.dbView.doCommand(Ci.nsMsgViewCommandType.expandAll); + } + if ( + !gViewWrapper._threadExpandAll && + gViewWrapper.dbView.viewFlags & Ci.nsMsgViewFlagsType.kExpandAll + ) { + gViewWrapper.dbView.doCommand(Ci.nsMsgViewCommandType.collapseAll); + } + }, + + /** + * Restore the chevron icon indicating the current sort order. + */ + restoreSortIndicator() { + if (!gDBView) { + return; + } + this.updateSortIndicator( + sortController.convertSortTypeToColumnID(gViewWrapper.primarySortType) + ); + }, + + /** + * Update the columns object and force the refresh of the thread pane to apply + * the updated state. This is usually called when changing folders. + */ + restoreColumns() { + this.restoreColumnsState(); + this.updateColumns(); + }, + + /** + * Restore the visibility and order of the columns for the current folder. + */ + restoreColumnsState() { + // Always fetch a fresh array of columns for the cards view even if we don't + // have a folder defined. + this.cardColumns = getDefaultColumnsForCardsView(gFolder); + this.updateClassList(); + + // Avoid doing anything if no folder has been loaded yet. + if (!gFolder) { + return; + } + + // A missing folder database will throw an error so we need to handle that. + let msgDatabase; + try { + msgDatabase = gFolder.msgDatabase; + } catch { + return; + } + + const stringState = + msgDatabase.dBFolderInfo.getCharProperty("columnStates"); + if (!stringState) { + // If we don't have a previously saved state, make sure to enforce the + // default columns for the currently visible folder, otherwise the table + // layout will maintain whatever state is currently set from the previous + // folder, which it doesn't reflect reality. + this.columns = getDefaultColumns(gFolder); + return; + } + + this.applyPersistedColumnsState(JSON.parse(stringState)); + }, + + /** + * Update the current columns to match a previously saved state. + * + * @param {JSON} columnStates - The parsed JSON of a previously saved state. + */ + applyPersistedColumnsState(columnStates) { + this.columns.forEach(c => { + c.hidden = !columnStates[c.id]?.visible; + c.ordinal = columnStates[c.id]?.ordinal ?? 0; + }); + // Sort columns by ordinal. + this.columns.sort(function (a, b) { + return a.ordinal - b.ordinal; + }); + }, + + /** + * Force an update of the thread tree to reflect the columns change. + * + * @param {boolean} isSimple - If the columns structure only requires a simple + * update and not a full reset of the entire table header. + */ + updateColumns(isSimple = false) { + if (!this.rowTemplate) { + this.rowTemplate = document.getElementById("threadPaneRowTemplate"); + } + + // Update the row template to match the column properties. + for (let column of this.columns) { + let cell = this.rowTemplate.content.querySelector( + `.${column.id.toLowerCase()}-column` + ); + cell.hidden = column.hidden; + this.rowTemplate.content.appendChild(cell); + } + + if (isSimple) { + this.treeTable.updateColumns(this.columns); + } else { + // The order of the columns have changed, which warrants a rebuild of the + // full table header. + this.treeTable.setColumns(this.columns); + } + this.treeTable.restoreColumnsWidths(XULSTORE_URL); + }, + + /** + * Restore the default columns visibility and order and save the change. + */ + restoreDefaultColumns() { + this.columns = getDefaultColumns(gFolder, gViewWrapper?.isSynthetic); + this.cardColumns = getDefaultColumnsForCardsView(gFolder); + this.updateClassList(); + this.updateColumns(); + threadTree.reset(); + this.persistColumnStates(); + }, + + /** + * Shift the ordinal of a column by one based on the visible columns. + * + * @param {object} data - The detail object of the bubbled event. + */ + onColumnShifted(data) { + const column = data.column; + const forward = data.forward; + + const columnToShift = this.columns.find(c => c.id == column); + const currentPosition = this.columns.indexOf(columnToShift); + + let delta = forward ? 1 : -1; + let newPosition = currentPosition + delta; + // Account for hidden columns to find the correct new position. + while (this.columns.at(newPosition).hidden) { + newPosition += delta; + } + + // Get the column in the current new position before shuffling the array. + const destinationTH = document.getElementById( + this.columns.at(newPosition).id + ); + + this.columns.splice( + newPosition, + 0, + this.columns.splice(currentPosition, 1)[0] + ); + + // Update the ordinal of the columns to reflect the new positions. + this.columns.forEach((column, index) => { + column.ordinal = index; + }); + + this.persistColumnStates(); + this.updateColumns(true); + threadTree.reset(); + + // Swap the DOM elements. + const originalTH = document.getElementById(column); + if (forward) { + destinationTH.after(originalTH); + } else { + destinationTH.before(originalTH); + } + // Restore the focus so we can continue shifting if needed. + document.getElementById(`${column}Button`).focus(); + }, + + onColumnsReordered(data) { + this.columns = data.columns; + + this.persistColumnStates(); + this.updateColumns(true); + threadTree.reset(); + }, + + /** + * Update the list of visible columns based on the users' selection. + * + * @param {object} data - The detail object of the bubbled event. + */ + onColumnsVisibilityChanged(data) { + let column = data.value; + let checked = data.target.hasAttribute("checked"); + + let changedColumn = this.columns.find(c => c.id == column); + changedColumn.hidden = !checked; + + this.persistColumnStates(); + this.updateColumns(true); + threadTree.reset(); + }, + + /** + * Save the current visibility of the columns in the folder database. + */ + persistColumnStates() { + let newState = {}; + for (const column of this.columns) { + newState[column.id] = { + visible: !column.hidden, + ordinal: column.ordinal, + }; + } + + if (gViewWrapper.isSynthetic) { + let syntheticView = gViewWrapper._syntheticView; + if ("setPersistedSetting" in syntheticView) { + syntheticView.setPersistedSetting("columns", newState); + } + return; + } + + if (!gFolder) { + return; + } + + // A missing folder database will throw an error so we need to handle that. + let msgDatabase; + try { + msgDatabase = gFolder.msgDatabase; + } catch { + return; + } + + msgDatabase.dBFolderInfo.setCharProperty( + "columnStates", + JSON.stringify(newState) + ); + msgDatabase.commit(Ci.nsMsgDBCommitType.kLargeCommit); + }, + + /** + * Trigger a sort change when the user clicks on the table header. + * + * @param {object} data - The detail of the custom event. + */ + onSortChanged(data) { + const sortColumn = sortController.convertSortTypeToColumnID( + gViewWrapper.primarySortType + ); + const column = data.column; + + // A click happened on the column that is already used to sort the list. + if (sortColumn == column) { + if (gViewWrapper.isSortedAscending) { + sortController.sortDescending(); + } else { + sortController.sortAscending(); + } + this.updateSortIndicator(column); + return; + } + + const sortName = this.columns.find(c => c.id == data.column).sortKey; + sortController.sortThreadPane(sortName); + this.updateSortIndicator(column); + }, + + /** + * Update the classes on the table header to reflect the sorting order. + * + * @param {string} column - The ID of column affecting the sorting order. + */ + updateSortIndicator(column) { + this.treeTable + .querySelector(".sorting") + ?.classList.remove("sorting", "ascending", "descending"); + this.treeTable + .querySelector(`#${column} button`) + ?.classList.add( + "sorting", + gViewWrapper.isSortedAscending ? "ascending" : "descending" + ); + }, + + /** + * Prompt the user to confirm applying the current columns state to the chosen + * folder and its children. + * + * @param {nsIMsgFolder} folder - The chosen message folder. + * @param {boolean} [useChildren=false] - If the requested action should be + * propagated to the child folders. + */ + async confirmApplyColumns(folder, useChildren = false) { + const msgFluentID = useChildren + ? "apply-current-columns-to-folder-with-children-message" + : "apply-current-columns-to-folder-message"; + let [title, message] = await document.l10n.formatValues([ + "apply-changes-to-folder-title", + { id: msgFluentID, args: { name: folder.name } }, + ]); + if (Services.prompt.confirm(null, title, message)) { + this._applyColumns(folder, useChildren); + } + }, + + /** + * Apply the current columns state to the chosen folder and its children, + * if specified. + * + * @param {nsIMsgFolder} destFolder - The chosen folder. + * @param {boolean} useChildren - True if the changes should affect the child + * folders of the chosen folder. + */ + _applyColumns(destFolder, useChildren) { + // Avoid doing anything if no folder has been loaded yet. + if (!gFolder || !destFolder) { + return; + } + + // Get the current state from the columns array, not the saved state in the + // database in order to make sure we're getting the currently visible state. + let columnState = {}; + for (const column of this.columns) { + columnState[column.id] = { + visible: !column.hidden, + ordinal: column.ordinal, + }; + } + + // Swaps "From" and "Recipient" if only one is shown. This is useful for + // copying an incoming folder's columns to and from an outgoing folder. + let columStateString = JSON.stringify(columnState); + let swappedColumnStateString; + if (columnState.senderCol.visible != columnState.recipientCol.visible) { + const backedSenderColumn = columnState.senderCol; + columnState.senderCol = columnState.recipientCol; + columnState.recipientCol = backedSenderColumn; + swappedColumnStateString = JSON.stringify(columnState); + } else { + swappedColumnStateString = columStateString; + } + + const currentFolderIsOutgoing = isOutgoing(gFolder); + + /** + * Update the columnStates property of the folder database and forget the + * reference to prevent memory bloat. + * + * @param {nsIMsgFolder} folder - The message folder. + */ + const commitColumnsState = folder => { + if (folder.isServer) { + return; + } + // Check if the destination folder we're trying to update matches the same + // special state of the folder we're getting the column state from. + const colStateString = + isOutgoing(folder) == currentFolderIsOutgoing + ? columStateString + : swappedColumnStateString; + + folder.msgDatabase.dBFolderInfo.setCharProperty( + "columnStates", + colStateString + ); + folder.msgDatabase.commit(Ci.nsMsgDBCommitType.kLargeCommit); + // Force the reference to be forgotten. + folder.msgDatabase = null; + }; + + if (!useChildren) { + commitColumnsState(destFolder); + return; + } + + // Loop through all the child folders and apply the same column state. + MailUtils.takeActionOnFolderAndDescendents( + destFolder, + commitColumnsState + ).then(() => { + Services.obs.notifyObservers( + gViewWrapper.displayedFolder, + "msg-folder-columns-propagated" + ); + }); + }, + + /** + * Prompt the user to confirm applying the current view sate to the chosen + * folder and its children. + * + * @param {nsIMsgFolder} folder - The chosen message folder. + * @param {boolean} [useChildren=false] - If the requested action should be + * propagated to the child folders. + */ + async confirmApplyView(folder, useChildren = false) { + const msgFluentID = useChildren + ? "apply-current-view-to-folder-with-children-message" + : "apply-current-view-to-folder-message"; + let [title, message] = await document.l10n.formatValues([ + { id: "apply-changes-to-folder-title" }, + { id: msgFluentID, args: { name: folder.name } }, + ]); + if (Services.prompt.confirm(null, title, message)) { + this._applyView(folder, useChildren); + } + }, + + /** + * Apply the current view flags, sorting key, and sorting order to another + * folder and its children, if specified. + * + * @param {nsIMsgFolder} destFolder - The chosen folder. + * @param {boolean} useChildren - True if the changes should affect the child + * folders of the chosen folder. + */ + _applyView(destFolder, useChildren) { + const viewFlags = gViewWrapper.dbView.viewFlags; + const sortType = gViewWrapper.dbView.sortType; + const sortOrder = gViewWrapper.dbView.sortOrder; + + /** + * Update the view state flags of the folder database and forget the + * reference to prevent memory bloat. + * + * @param {nsIMsgFolder} folder - The message folder. + */ + const commitViewState = folder => { + if (folder.isServer) { + return; + } + folder.msgDatabase.dBFolderInfo.viewFlags = viewFlags; + folder.msgDatabase.dBFolderInfo.sortType = sortType; + folder.msgDatabase.dBFolderInfo.sortOrder = sortOrder; + // Null out to avoid memory bloat. + folder.msgDatabase = null; + }; + + if (!useChildren) { + commitViewState(destFolder); + return; + } + + MailUtils.takeActionOnFolderAndDescendents( + destFolder, + commitViewState + ).then(() => { + Services.obs.notifyObservers( + gViewWrapper.displayedFolder, + "msg-folder-views-propagated" + ); + }); + }, + + /** + * Hide any notifications about ignored threads. + */ + hideIgnoredMessageNotification() { + this.notificationBox.removeTransientNotifications(); + }, + + /** + * Show a notification in the thread pane footer, allowing the user to learn + * more about the ignore thread feature, and also allowing undo ignore thread. + * + * @param {nsIMsgDBHdr[]} messages - The messages being ignored. + * @param {boolean} subthreadOnly - If true, ignoring only `messages` and + * their subthreads, otherwise ignoring the whole thread. + */ + showIgnoredMessageNotification(messages, subthreadOnly) { + let threadIds = new Set(); + messages.forEach(function (msg) { + if (!threadIds.has(msg.threadId)) { + threadIds.add(msg.threadId); + } + }); + + let buttons = [ + { + label: messengerBundle.GetStringFromName("learnMoreAboutIgnoreThread"), + accessKey: messengerBundle.GetStringFromName( + "learnMoreAboutIgnoreThreadAccessKey" + ), + popup: null, + callback(aNotificationBar, aButton) { + let url = Services.prefs.getCharPref( + "mail.ignore_thread.learn_more_url" + ); + top.openContentTab(url); + return true; // Keep notification open. + }, + }, + { + label: messengerBundle.GetStringFromName( + !subthreadOnly ? "undoIgnoreThread" : "undoIgnoreSubthread" + ), + accessKey: messengerBundle.GetStringFromName( + !subthreadOnly + ? "undoIgnoreThreadAccessKey" + : "undoIgnoreSubthreadAccessKey" + ), + isDefault: true, + popup: null, + callback(aNotificationBar, aButton) { + messages.forEach(function (msg) { + let msgDb = msg.folder.msgDatabase; + if (subthreadOnly) { + msgDb.markHeaderKilled(msg, false, null); + } else if (threadIds.has(msg.threadId)) { + let thread = msgDb.getThreadContainingMsgHdr(msg); + msgDb.markThreadIgnored( + thread, + thread.getChildKeyAt(0), + false, + null + ); + threadIds.delete(msg.threadId); + } + }); + // Invalidation should be unnecessary but the back end doesn't + // notify us properly and resists attempts to fix this. + threadTree.reset(); + threadTree.table.body.focus(); + return false; // Close notification. + }, + }, + ]; + + if (threadIds.size == 1) { + let ignoredThreadText = messengerBundle.GetStringFromName( + !subthreadOnly ? "ignoredThreadFeedback" : "ignoredSubthreadFeedback" + ); + let subj = messages[0].mime2DecodedSubject || ""; + if (subj.length > 45) { + subj = subj.substring(0, 45) + "…"; + } + let text = ignoredThreadText.replace("#1", subj); + + this.notificationBox.appendNotification( + "ignoreThreadInfo", + { + label: text, + priority: this.notificationBox.PRIORITY_INFO_MEDIUM, + }, + buttons + ); + } else { + let ignoredThreadText = messengerBundle.GetStringFromName( + !subthreadOnly ? "ignoredThreadsFeedback" : "ignoredSubthreadsFeedback" + ); + + const { PluralForm } = ChromeUtils.importESModule( + "resource://gre/modules/PluralForm.sys.mjs" + ); + let text = PluralForm.get(threadIds.size, ignoredThreadText).replace( + "#1", + threadIds.size + ); + this.notificationBox.appendNotification( + "ignoreThreadsInfo", + { + label: text, + priority: this.notificationBox.PRIORITY_INFO_MEDIUM, + }, + buttons + ); + } + }, + + /** + * Update the display view of the message list. Current supported options are + * table and cards. + * + * @param {string} view - The view type. + */ + updateThreadView(view) { + switch (view) { + case "table": + document.body.classList.add("layout-table"); + threadTree?.setAttribute("rows", "thread-row"); + break; + case "cards": + default: + document.body.classList.remove("layout-table"); + threadTree?.setAttribute("rows", "thread-card"); + break; + } + }, + + /** + * Update the ARIA Role of the tree view table body to properly communicate + * to assistive techonology the type of list we're rendering and toggles the + * threaded class on the tree table header. + * + * @param {boolean} isListbox - If the list should have a listbox role. + */ + updateListRole(isListbox) { + threadTree.table.body.setAttribute("role", isListbox ? "listbox" : "tree"); + if (isListbox) { + threadTree.table.header.classList.remove("threaded"); + } else { + threadTree.table.header.classList.add("threaded"); + } + }, +}; + +var messagePane = { + async init() { + webBrowser = document.getElementById("webBrowser"); + // Attach the progress listener for the webBrowser. For the messageBrowser this + // happens in the "aboutMessageLoaded" event from aboutMessage.js. + top.contentProgress.addProgressListenerToBrowser(webBrowser); + + messageBrowser = document.getElementById("messageBrowser"); + messageBrowser.docShell.allowDNSPrefetch = false; + + multiMessageBrowser = document.getElementById("multiMessageBrowser"); + multiMessageBrowser.docShell.allowDNSPrefetch = false; + + if (messageBrowser.contentDocument.readyState != "complete") { + await new Promise(resolve => { + messageBrowser.addEventListener("load", () => resolve(), { + capture: true, + once: true, + }); + }); + } + + if (multiMessageBrowser.contentDocument.readyState != "complete") { + await new Promise(resolve => { + multiMessageBrowser.addEventListener("load", () => resolve(), { + capture: true, + once: true, + }); + }); + } + }, + + /** + * Ensure all message pane browsers are blank. + */ + clearAll() { + this.clearWebPage(); + this.clearMessage(); + this.clearMessages(); + }, + + /** + * Ensure the web page browser is blank, unless the start page is shown. + */ + clearWebPage() { + if (!this._keepStartPageOpen) { + webBrowser.hidden = true; + MailE10SUtils.loadAboutBlank(webBrowser); + } + }, + + /** + * Display a web page in the web page browser. If `url` is not given, or is + * "about:blank", the web page browser is cleared and hidden. + * + * @param {string} url - The URL to load. + * @param {object} [params] - Any params to pass to MailE10SUtils.loadURI. + */ + displayWebPage(url, params) { + if (!paneLayout.messagePaneVisible) { + return; + } + if (!url || url == "about:blank") { + this._keepStartPageOpen = false; + this.clearWebPage(); + return; + } + + this.clearMessage(); + this.clearMessages(); + + MailE10SUtils.loadURI(webBrowser, url, params); + webBrowser.hidden = false; + }, + + /** + * Ensure the message browser is not displaying a message. + */ + clearMessage() { + messageBrowser.hidden = true; + messageBrowser.contentWindow.displayMessage(); + }, + + /** + * Display a single message in the message browser. If `messageURI` is not + * given, the message browser is cleared and hidden. + * + * @param {string} messageURI + */ + displayMessage(messageURI) { + if (!paneLayout.messagePaneVisible) { + return; + } + if (!messageURI) { + this.clearMessage(); + return; + } + + this._keepStartPageOpen = false; + messagePane.clearWebPage(); + messagePane.clearMessages(); + + messageBrowser.contentWindow.displayMessage(messageURI, gViewWrapper); + messageBrowser.hidden = false; + }, + + /** + * Ensure the multi-message browser is not displaying messages. + */ + clearMessages() { + multiMessageBrowser.hidden = true; + multiMessageBrowser.contentWindow.gMessageSummary.clear(); + }, + + /** + * Display messages in the multi-message browser. For a single message, use + * `displayMessage` instead. If `messages` is not given, or an empty array, + * the multi-message browser is cleared and hidden. + * + * @param {nsIMsgDBHdr[]} messages + */ + displayMessages(messages = []) { + if (!paneLayout.messagePaneVisible) { + return; + } + if (messages.length == 0) { + this.clearMessages(); + return; + } + + this._keepStartPageOpen = false; + messagePane.clearWebPage(); + messagePane.clearMessage(); + + let getThreadId = function (message) { + return gDBView.getThreadContainingMsgHdr(message).getRootHdr().messageKey; + }; + + let oneThread = true; + let firstThreadId = getThreadId(messages[0]); + for (let i = 1; i < messages.length; i++) { + if (getThreadId(messages[i]) != firstThreadId) { + oneThread = false; + break; + } + } + + multiMessageBrowser.contentWindow.gMessageSummary.summarize( + oneThread ? "thread" : "multipleselection", + messages, + gDBView, + function (messages) { + threadTree.selectedIndices = messages + .map(m => gDBView.findIndexOfMsgHdr(m, true)) + .filter(i => i != nsMsgViewIndex_None); + } + ); + + multiMessageBrowser.hidden = false; + window.dispatchEvent(new CustomEvent("MsgsLoaded", { bubbles: true })); + }, + + /** + * Show the start page in the web page browser. The start page will remain + * shown until a message is displayed. + */ + showStartPage() { + this._keepStartPageOpen = true; + let url = Services.urlFormatter.formatURLPref("mailnews.start_page.url"); + if (/^mailbox:|^imap:|^pop:|^s?news:|^nntp:/i.test(url)) { + console.warn(`Can't use ${url} as mailnews.start_page.url`); + Services.prefs.clearUserPref("mailnews.start_page.url"); + url = Services.urlFormatter.formatURLPref("mailnews.start_page.url"); + } + messagePane.displayWebPage(url); + }, +}; + +function restoreState({ + folderPaneVisible, + messagePaneVisible, + folderURI, + syntheticView, + first = false, + title = null, +} = {}) { + if (folderPaneVisible === undefined) { + folderPaneVisible = folderURI || !syntheticView; + } + paneLayout.folderPaneSplitter.isCollapsed = !folderPaneVisible; + paneLayout.folderPaneSplitter.isDisabled = syntheticView; + + if (messagePaneVisible === undefined) { + messagePaneVisible = + Services.xulStore.getValue( + XULSTORE_URL, + "messagepaneboxwrapper", + "collapsed" + ) !== "true"; + } + paneLayout.messagePaneSplitter.isCollapsed = !messagePaneVisible; + + if (folderURI) { + displayFolder(folderURI); + } else if (syntheticView) { + // In a synthetic view check if we have a previously edited column layout to + // restore. + if ("getPersistedSetting" in syntheticView) { + let columnsState = syntheticView.getPersistedSetting("columns"); + if (!columnsState) { + threadPane.restoreDefaultColumns(); + return; + } + + threadPane.applyPersistedColumnsState(columnsState); + threadPane.updateColumns(); + } else { + // Otherwise restore the default synthetic columns. + threadPane.restoreDefaultColumns(); + } + + gViewWrapper = new DBViewWrapper(dbViewWrapperListener); + gViewWrapper.openSynthetic(syntheticView); + gDBView = gViewWrapper.dbView; + + if ("selectedMessage" in syntheticView) { + threadTree.selectedIndex = gDBView.findIndexOfMsgHdr( + syntheticView.selectedMessage, + true + ); + } else { + // So that nsMsgSearchDBView::GetHdrForFirstSelectedMessage works from + // the beginning. + threadTree.currentIndex = 0; + } + + document.title = title; + document.body.classList.remove("account-central"); + accountCentralBrowser.hidden = true; + threadPaneHeader.onFolderSelected(); + } + + if ( + first && + messagePaneVisible && + Services.prefs.getBoolPref("mailnews.start_page.enabled") + ) { + messagePane.showStartPage(); + } +} + +/** + * Set up the given folder to be selected in the folder pane. + * @param {nsIMsgFolder|string} folder - The folder to display, or its URI. + */ +function displayFolder(folder) { + let folderURI = folder instanceof Ci.nsIMsgFolder ? folder.URI : folder; + if (folderTree.selectedRow?.uri == folderURI) { + // Already set to display the right folder. Make sure not not to change + // to the same folder in a different folder mode. + return; + } + + let row = folderPane.getRowForFolder(folderURI); + if (!row) { + return; + } + + let collapsedAncestor = row.parentNode.closest("#folderTree li.collapsed"); + while (collapsedAncestor) { + folderTree.expandRow(collapsedAncestor); + collapsedAncestor = collapsedAncestor.parentNode.closest( + "#folderTree li.collapsed" + ); + } + folderTree.selectedRow = row; +} + +/** + * Update the thread pane selection if it doesn't already match `msgHdr`. + * The selected folder will be changed if necessary. If the selection + * changes, the message pane will also be updated (via a "select" event). + * + * @param {nsIMsgDBHdr} msgHdr + */ +function selectMessage(msgHdr) { + if ( + gDBView?.numSelected == 1 && + gDBView.hdrForFirstSelectedMessage == msgHdr + ) { + return; + } + + let index = threadTree.view?.findIndexOfMsgHdr(msgHdr, true); + // Change to correct folder if needed. We might not be in a folder, or the + // message might not be found in the current folder. + if (index === undefined || index === nsMsgViewIndex_None) { + threadPane.forgetSelection(msgHdr.folder.URI); + displayFolder(msgHdr.folder.URI); + index = threadTree.view.findIndexOfMsgHdr(msgHdr, true); + threadTree.scrollToIndex(index, true); + } + threadTree.selectedIndex = index; +} + +var folderListener = { + QueryInterface: ChromeUtils.generateQI(["nsIFolderListener"]), + onFolderAdded(parentFolder, childFolder) { + folderPane.addFolder(parentFolder, childFolder); + folderPane.updateFolderRowUIElements(); + }, + onMessageAdded(parentFolder, msg) {}, + onFolderRemoved(parentFolder, childFolder) { + folderPane.removeFolder(parentFolder, childFolder); + if (childFolder == gFolder) { + gFolder = null; + gViewWrapper?.close(true); + } + }, + onMessageRemoved(parentFolder, msg) {}, + onFolderPropertyChanged(folder, property, oldValue, newValue) {}, + onFolderIntPropertyChanged(folder, property, oldValue, newValue) { + switch (property) { + case "BiffState": + folderPane.changeNewMessages( + folder, + newValue === Ci.nsIMsgFolder.nsMsgBiffState_NewMail + ); + break; + case "FolderFlag": + folderPane.changeFolderFlag(folder, oldValue, newValue); + break; + case "FolderSize": + folderPane.changeFolderSize(folder); + break; + case "TotalUnreadMessages": + if (oldValue == newValue) { + break; + } + folderPane.changeUnreadCount(folder, newValue); + break; + case "TotalMessages": + if (oldValue == newValue) { + break; + } + folderPane.changeTotalCount(folder, newValue); + threadPaneHeader.updateFolderCount(folder, newValue); + break; + } + }, + onFolderBoolPropertyChanged(folder, property, oldValue, newValue) { + switch (property) { + case "isDeferred": + if (newValue) { + folderPane.removeFolder(null, folder); + } else { + folderPane.addFolder(null, folder); + for (let f of folder.descendants) { + folderPane.addFolder(f.parent, f); + } + } + break; + case "NewMessages": + folderPane.changeNewMessages(folder, newValue); + break; + } + }, + onFolderUnicharPropertyChanged(folder, property, oldValue, newValue) { + switch (property) { + case "Name": + if (folder.isServer) { + folderPane.changeServerName(folder, newValue); + } + break; + } + }, + onFolderPropertyFlagChanged(folder, property, oldFlag, newFlag) {}, + onFolderEvent(folder, event) { + if (event == "RenameCompleted") { + // If a folder is renamed, we get an `onFolderAdded` notification for + // the folder but we are not notified about the descendants. + for (let f of folder.descendants) { + folderPane.addFolder(f.parent, f); + } + } + }, +}; + +/** + * Custom element for rows in the thread tree. + */ +customElements.whenDefined("tree-view-table-row").then(() => { + class ThreadRow extends customElements.get("tree-view-table-row") { + static ROW_HEIGHT = 22; + + connectedCallback() { + if (this.hasConnected) { + return; + } + + super.connectedCallback(); + + this.setAttribute("draggable", "true"); + this.appendChild(threadPane.rowTemplate.content.cloneNode(true)); + } + + get index() { + return super.index; + } + + set index(index) { + super.index = index; + + let textColumns = []; + for (let column of threadPane.columns) { + // No need to update the text of this cell if it's hidden, the selection + // column, or an icon column that doesn't match a specific flag. + if (column.hidden || column.icon || column.select) { + continue; + } + textColumns.push(column.id); + } + + // XPCOM calls here must be keep to a minimum. Collect all of the + // required data in one go. + let properties = {}; + let threadLevel = {}; + let cellTexts = this.view.cellDataForColumns( + index, + textColumns, + properties, + threadLevel + ); + + // Collect the various strings and fluent IDs to build the full string for + // the message row aria-label. + let ariaLabelPromises = []; + + const propertiesSet = new Set(properties.value.split(" ")); + const isDummyRow = propertiesSet.has("dummy"); + + this.dataset.properties = properties.value.trim(); + + for (let column of threadPane.columns) { + // Skip this column if it's hidden or it's the "select" column, since + // the selection state is communicated via the aria-activedescendant. + if (column.hidden || column.select) { + continue; + } + let cell = this.querySelector(`.${column.id.toLowerCase()}-column`); + let textIndex = textColumns.indexOf(column.id); + + // Special case for the subject column. + if (column.id == "subjectCol") { + const div = cell.querySelector(".subject-line"); + + // Indent child message of this thread. + div.style.setProperty( + "--thread-level", + gViewWrapper.showGroupedBySort ? 0 : threadLevel.value + ); + + let imageFluentID = this.#getMessageIndicatorString(propertiesSet); + const image = div.querySelector("img"); + if (imageFluentID && !isDummyRow) { + document.l10n.setAttributes(image, imageFluentID); + } else { + image.removeAttribute("data-l10n-id"); + image.alt = ""; + } + + const span = div.querySelector("span"); + cell.title = span.textContent = cellTexts[textIndex]; + ariaLabelPromises.push(cellTexts[textIndex]); + continue; + } + + if (column.id == "threadCol") { + let buttonL10nId, labelString; + if (propertiesSet.has("ignore")) { + buttonL10nId = "tree-list-view-row-ignored-thread-button"; + labelString = "tree-list-view-row-ignored-thread"; + } else if (propertiesSet.has("ignoreSubthread")) { + buttonL10nId = "tree-list-view-row-ignored-subthread-button"; + labelString = "tree-list-view-row-ignored-subthread"; + } else if (propertiesSet.has("watch")) { + buttonL10nId = "tree-list-view-row-watched-thread-button"; + labelString = "tree-list-view-row-watched-thread"; + } else if (this.classList.contains("children")) { + buttonL10nId = "tree-list-view-row-thread-button"; + } + + let button = cell.querySelector("button"); + if (buttonL10nId) { + document.l10n.setAttributes(button, buttonL10nId); + } + if (labelString) { + ariaLabelPromises.push(document.l10n.formatValue(labelString)); + } + continue; + } + + if (column.id == "flaggedCol") { + let button = cell.querySelector("button"); + if (propertiesSet.has("flagged")) { + document.l10n.setAttributes(button, "tree-list-view-row-flagged"); + ariaLabelPromises.push( + document.l10n.formatValue("threadpane-flagged-cell-label") + ); + } else { + document.l10n.setAttributes(button, "tree-list-view-row-flag"); + } + continue; + } + + if (column.id == "junkStatusCol") { + let button = cell.querySelector("button"); + if (propertiesSet.has("junk")) { + document.l10n.setAttributes(button, "tree-list-view-row-spam"); + ariaLabelPromises.push( + document.l10n.formatValue("threadpane-spam-cell-label") + ); + } else { + document.l10n.setAttributes(button, "tree-list-view-row-not-spam"); + } + continue; + } + + if (column.id == "unreadButtonColHeader") { + let button = cell.querySelector("button"); + if (propertiesSet.has("read")) { + document.l10n.setAttributes(button, "tree-list-view-row-read"); + ariaLabelPromises.push( + document.l10n.formatValue("threadpane-read-cell-label") + ); + } else { + document.l10n.setAttributes(button, "tree-list-view-row-not-read"); + ariaLabelPromises.push( + document.l10n.formatValue("threadpane-unread-cell-label") + ); + } + continue; + } + + if (column.id == "attachmentCol" && propertiesSet.has("attach")) { + ariaLabelPromises.push( + document.l10n.formatValue("threadpane-attachments-cell-label") + ); + continue; + } + + if (textIndex >= 0) { + if (isDummyRow) { + cell.textContent = ""; + continue; + } + cell.textContent = cellTexts[textIndex]; + ariaLabelPromises.push(cellTexts[textIndex]); + } + } + + Promise.allSettled(ariaLabelPromises).then(results => { + this.setAttribute( + "aria-label", + results + .map(settledPromise => settledPromise.value ?? "") + .filter(value => value.trim() != "") + .join(", ") + ); + }); + } + + /** + * Find the fluent ID matching the current message state. + * + * @param {Set} propertiesSet - The Set() of properties for the row. + * @returns {?string} - The fluent ID string if we found one, otherwise null. + */ + #getMessageIndicatorString(propertiesSet) { + // Bail out early if this is a new message since it can't be anything else. + if (propertiesSet.has("new")) { + return "threadpane-message-new"; + } + + const isReplied = propertiesSet.has("replied"); + const isForwarded = propertiesSet.has("forwarded"); + const isRedirected = propertiesSet.has("redirected"); + + if (isReplied && !isForwarded && !isRedirected) { + return "threadpane-message-replied"; + } + + if (isRedirected && !isForwarded && !isReplied) { + return "threadpane-message-redirected"; + } + + if (isForwarded && !isReplied && !isRedirected) { + return "threadpane-message-forwarded"; + } + + if (isReplied && isForwarded && !isRedirected) { + return "threadpane-message-replied-forwarded"; + } + + if (isReplied && isRedirected && !isForwarded) { + return "threadpane-message-replied-redirected"; + } + + if (isForwarded && isRedirected && !isReplied) { + return "threadpane-message-forwarded-redirected"; + } + + if (isReplied && isForwarded && isRedirected) { + return "threadpane-message-replied-forwarded-redirected"; + } + + return null; + } + } + customElements.define("thread-row", ThreadRow, { extends: "tr" }); + + class ThreadCard extends customElements.get("tree-view-table-row") { + static ROW_HEIGHT = 46; + + connectedCallback() { + if (this.hasConnected) { + return; + } + + super.connectedCallback(); + + this.setAttribute("draggable", "true"); + + this.appendChild( + document + .getElementById("threadPaneCardTemplate") + .content.cloneNode(true) + ); + + this.senderLine = this.querySelector(".sender"); + this.subjectLine = this.querySelector(".subject"); + this.dateLine = this.querySelector(".date"); + this.starButton = this.querySelector(".button-star"); + this.tagIcon = this.querySelector(".tag-icon"); + } + + get index() { + return super.index; + } + + set index(index) { + super.index = index; + + // XPCOM calls here must be keep to a minimum. Collect all of the + // required data in one go. + let properties = {}; + let threadLevel = {}; + + let cellTexts = this.view.cellDataForColumns( + index, + threadPane.cardColumns, + properties, + threadLevel + ); + + // Collect the various strings and fluent IDs to build the full string for + // the message row aria-label. + let ariaLabelPromises = []; + + if (threadLevel.value) { + properties.value += " thread-children"; + } + const propertiesSet = new Set(properties.value.split(" ")); + this.dataset.properties = properties.value.trim(); + + this.subjectLine.textContent = cellTexts[0]; + this.subjectLine.title = cellTexts[0]; + this.senderLine.textContent = cellTexts[1]; + this.dateLine.textContent = cellTexts[2]; + this.tagIcon.title = cellTexts[3]; + + // Follow the layout order. + ariaLabelPromises.push(cellTexts[1]); + ariaLabelPromises.push(cellTexts[2]); + ariaLabelPromises.push(cellTexts[0]); + ariaLabelPromises.push(cellTexts[3]); + + if (propertiesSet.has("flagged")) { + document.l10n.setAttributes( + this.starButton, + "tree-list-view-row-flagged" + ); + ariaLabelPromises.push( + document.l10n.formatValue("threadpane-flagged-cell-label") + ); + } else { + document.l10n.setAttributes(this.starButton, "tree-list-view-row-flag"); + } + + if (propertiesSet.has("junk")) { + ariaLabelPromises.push( + document.l10n.formatValue("threadpane-spam-cell-label") + ); + } + + if (propertiesSet.has("read")) { + ariaLabelPromises.push( + document.l10n.formatValue("threadpane-read-cell-label") + ); + } + + if (propertiesSet.has("unread")) { + ariaLabelPromises.push( + document.l10n.formatValue("threadpane-unread-cell-label") + ); + } + + if (propertiesSet.has("attach")) { + ariaLabelPromises.push( + document.l10n.formatValue("threadpane-attachments-cell-label") + ); + } + + Promise.allSettled(ariaLabelPromises).then(results => { + this.setAttribute( + "aria-label", + results + .map(settledPromise => settledPromise.value ?? "") + .filter(value => value.trim() != "") + .join(", ") + ); + }); + } + } + customElements.define("thread-card", ThreadCard, { + extends: "tr", + }); +}); + +commandController.registerCallback( + "cmd_newFolder", + (folder = gFolder) => folderPane.newFolder(folder), + () => folderPaneContextMenu.getCommandState("cmd_newFolder") +); +commandController.registerCallback("cmd_newVirtualFolder", (folder = gFolder) => + folderPane.newVirtualFolder(undefined, undefined, folder) +); +commandController.registerCallback( + "cmd_deleteFolder", + (folder = gFolder) => folderPane.deleteFolder(folder), + () => folderPaneContextMenu.getCommandState("cmd_deleteFolder") +); +commandController.registerCallback( + "cmd_renameFolder", + (folder = gFolder) => folderPane.renameFolder(folder), + () => folderPaneContextMenu.getCommandState("cmd_renameFolder") +); +commandController.registerCallback( + "cmd_compactFolder", + (folder = gFolder) => { + if (folder.isServer) { + folderPane.compactAllFoldersForAccount(folder); + } else { + folderPane.compactFolder(folder); + } + }, + () => folderPaneContextMenu.getCommandState("cmd_compactFolder") +); +commandController.registerCallback( + "cmd_emptyTrash", + (folder = gFolder) => folderPane.emptyTrash(folder), + () => folderPaneContextMenu.getCommandState("cmd_emptyTrash") +); +commandController.registerCallback( + "cmd_properties", + (folder = gFolder) => folderPane.editFolder(folder), + () => folderPaneContextMenu.getCommandState("cmd_properties") +); +commandController.registerCallback( + "cmd_toggleFavoriteFolder", + (folder = gFolder) => folder.toggleFlag(Ci.nsMsgFolderFlags.Favorite), + () => folderPaneContextMenu.getCommandState("cmd_toggleFavoriteFolder") +); + +// Delete commands, which change behaviour based on the active element. +// Note that `document.activeElement` refers to the active element in *this* +// document regardless of whether this document is the active one. +commandController.registerCallback( + "cmd_delete", + () => { + if (document.activeElement == folderTree) { + commandController.doCommand("cmd_deleteFolder"); + } else if (!quickFilterBar.domNode.contains(document.activeElement)) { + commandController.doCommand("cmd_deleteMessage"); + } + }, + () => { + if (document.activeElement == folderTree) { + return commandController.isCommandEnabled("cmd_deleteFolder"); + } + if ( + !quickFilterBar?.domNode || + quickFilterBar.domNode.contains(document.activeElement) + ) { + return false; + } + return commandController.isCommandEnabled("cmd_deleteMessage"); + } +); +commandController.registerCallback( + "cmd_shiftDelete", + () => { + commandController.doCommand("cmd_shiftDeleteMessage"); + }, + () => { + if ( + document.activeElement == folderTree || + !quickFilterBar?.domNode || + quickFilterBar.domNode.contains(document.activeElement) + ) { + return false; + } + return commandController.isCommandEnabled("cmd_shiftDeleteMessage"); + } +); + +commandController.registerCallback("cmd_viewClassicMailLayout", () => + Services.prefs.setIntPref("mail.pane_config.dynamic", 0) +); +commandController.registerCallback("cmd_viewWideMailLayout", () => + Services.prefs.setIntPref("mail.pane_config.dynamic", 1) +); +commandController.registerCallback("cmd_viewVerticalMailLayout", () => + Services.prefs.setIntPref("mail.pane_config.dynamic", 2) +); +commandController.registerCallback( + "cmd_toggleThreadPaneHeader", + () => threadPaneHeader.toggleThreadPaneHeader(), + () => gFolder && !gFolder.isServer +); +commandController.registerCallback( + "cmd_toggleFolderPane", + () => paneLayout.folderPaneSplitter.toggleCollapsed(), + () => !!gFolder +); +commandController.registerCallback("cmd_toggleMessagePane", () => { + paneLayout.messagePaneSplitter.toggleCollapsed(); +}); + +commandController.registerCallback( + "cmd_selectAll", + () => { + threadTree.selectAll(); + threadTree.table.body.focus(); + }, + () => !!gViewWrapper?.dbView +); +commandController.registerCallback( + "cmd_selectThread", + () => gViewWrapper.dbView.doCommand(Ci.nsMsgViewCommandType.selectThread), + () => !!gViewWrapper?.dbView +); +commandController.registerCallback( + "cmd_selectFlagged", + () => gViewWrapper.dbView.doCommand(Ci.nsMsgViewCommandType.selectFlagged), + () => !!gViewWrapper?.dbView +); +commandController.registerCallback( + "cmd_downloadFlagged", + () => + gViewWrapper.dbView.doCommand( + Ci.nsMsgViewCommandType.downloadFlaggedForOffline + ), + () => gFolder && !gFolder.isServer && MailOfflineMgr.isOnline() +); +commandController.registerCallback( + "cmd_downloadSelected", + () => + gViewWrapper.dbView.doCommand( + Ci.nsMsgViewCommandType.downloadSelectedForOffline + ), + () => + gFolder && + !gFolder.isServer && + MailOfflineMgr.isOnline() && + gViewWrapper.dbView.selectedCount > 0 +); + +var sortController = { + handleCommand(event) { + switch (event.target.value) { + case "ascending": + this.sortAscending(); + threadPane.restoreSortIndicator(); + break; + case "descending": + this.sortDescending(); + threadPane.restoreSortIndicator(); + break; + case "threaded": + this.sortThreaded(); + break; + case "unthreaded": + this.sortUnthreaded(); + break; + case "group": + this.groupBySort(); + break; + default: + if (event.target.value in Ci.nsMsgViewSortType) { + this.sortThreadPane(event.target.value); + threadPane.restoreSortIndicator(); + } + break; + } + }, + sortByThread() { + threadPane.updateListRole(false); + gViewWrapper.showThreaded = true; + this.sortThreadPane("byDate"); + }, + sortThreadPane(sortName) { + let sortType = Ci.nsMsgViewSortType[sortName]; + let grouped = gViewWrapper.showGroupedBySort; + gViewWrapper._threadExpandAll = Boolean( + gViewWrapper._viewFlags & Ci.nsMsgViewFlagsType.kExpandAll + ); + + if (!grouped) { + threadTree.style.scrollBehavior = "auto"; // Avoid smooth scroll. + gViewWrapper.sort(sortType, Ci.nsMsgViewSortOrder.ascending); + threadTree.style.scrollBehavior = null; + // Respect user's last expandAll/collapseAll choice, post sort direction change. + threadPane.restoreThreadState(); + return; + } + + // legacy behavior dictates we un-group-by-sort if we were. this probably + // deserves a UX call... + + // For non virtual folders, do not ungroup (which sorts by the going away + // sort) and then sort, as it's a double sort. + // For virtual folders, which are rebuilt in the backend in a grouped + // change, create a new view upfront rather than applying viewFlags. There + // are oddities just applying viewFlags, for example changing out of a + // custom column grouped xfvf view with the threads collapsed works (doesn't) + // differently than other variations. + // So, first set the desired sortType and sortOrder, then set viewFlags in + // batch mode, then apply it all (open a new view) with endViewUpdate(). + gViewWrapper.beginViewUpdate(); + gViewWrapper._sort = [[sortType, Ci.nsMsgViewSortOrder.ascending]]; + gViewWrapper.showGroupedBySort = false; + gViewWrapper.endViewUpdate(); + + // Virtual folders don't persist viewFlags well in the back end, + // due to a virtual folder being either 'real' or synthetic, so make + // sure it's done here. + if (gViewWrapper.isVirtual) { + gViewWrapper.dbView.viewFlags = gViewWrapper.viewFlags; + } + }, + reverseSortThreadPane() { + let grouped = gViewWrapper.showGroupedBySort; + gViewWrapper._threadExpandAll = Boolean( + gViewWrapper._viewFlags & Ci.nsMsgViewFlagsType.kExpandAll + ); + + // Grouped By view is special for column click sort direction changes. + if (grouped) { + if (gDBView.selection.count) { + threadPane.saveSelection(); + } + + if (gViewWrapper.isSingleFolder) { + if (gViewWrapper.isVirtual) { + gViewWrapper.showGroupedBySort = false; + } else { + // Must ensure rows are collapsed and kExpandAll is unset. + gViewWrapper.dbView.doCommand(Ci.nsMsgViewCommandType.collapseAll); + } + } + } + + if (gViewWrapper.isSortedAscending) { + gViewWrapper.sortDescending(); + } else { + gViewWrapper.sortAscending(); + } + + // Restore Grouped By state post sort direction change. + if (grouped) { + if (gViewWrapper.isVirtual && gViewWrapper.isSingleFolder) { + this.groupBySort(); + } + // Restore Grouped By selection post sort direction change. + threadPane.restoreSelection(); + // Refresh dummy rows in case of collapseAll. + threadTree.invalidate(); + } + threadPane.restoreThreadState(); + }, + toggleThreaded() { + if (gViewWrapper.showThreaded) { + threadPane.updateListRole(true); + gViewWrapper.showUnthreaded = true; + } else { + threadPane.updateListRole(false); + gViewWrapper.showThreaded = true; + } + }, + sortThreaded() { + threadPane.updateListRole(false); + gViewWrapper.showThreaded = true; + }, + groupBySort() { + threadPane.updateListRole(false); + gViewWrapper.showGroupedBySort = true; + }, + sortUnthreaded() { + threadPane.updateListRole(true); + gViewWrapper.showUnthreaded = true; + }, + sortAscending() { + if (gViewWrapper.showGroupedBySort && gViewWrapper.isSingleFolder) { + if (gViewWrapper.isSortedDescending) { + this.reverseSortThreadPane(); + } + return; + } + + threadTree.style.scrollBehavior = "auto"; // Avoid smooth scroll. + gViewWrapper.sortAscending(); + threadPane.ensureThreadStateForQuickSearchView(); + threadTree.style.scrollBehavior = null; + }, + sortDescending() { + if (gViewWrapper.showGroupedBySort && gViewWrapper.isSingleFolder) { + if (gViewWrapper.isSortedAscending) { + this.reverseSortThreadPane(); + } + return; + } + + threadTree.style.scrollBehavior = "auto"; // Avoid smooth scroll. + gViewWrapper.sortDescending(); + threadPane.ensureThreadStateForQuickSearchView(); + threadTree.style.scrollBehavior = null; + }, + convertSortTypeToColumnID(sortKey) { + let columnID; + + // Hack to turn this into an integer, if it was a string. + // It would be a string if it came from XULStore.json. + sortKey = sortKey - 0; + + switch (sortKey) { + // In the case of None, we default to the date column. This appears to be + // the case in such instances as Global search, so don't complain about + // it. + case Ci.nsMsgViewSortType.byNone: + case Ci.nsMsgViewSortType.byDate: + columnID = "dateCol"; + break; + case Ci.nsMsgViewSortType.byReceived: + columnID = "receivedCol"; + break; + case Ci.nsMsgViewSortType.byAuthor: + columnID = "senderCol"; + break; + case Ci.nsMsgViewSortType.byRecipient: + columnID = "recipientCol"; + break; + case Ci.nsMsgViewSortType.bySubject: + columnID = "subjectCol"; + break; + case Ci.nsMsgViewSortType.byLocation: + columnID = "locationCol"; + break; + case Ci.nsMsgViewSortType.byAccount: + columnID = "accountCol"; + break; + case Ci.nsMsgViewSortType.byUnread: + columnID = "unreadButtonColHeader"; + break; + case Ci.nsMsgViewSortType.byStatus: + columnID = "statusCol"; + break; + case Ci.nsMsgViewSortType.byTags: + columnID = "tagsCol"; + break; + case Ci.nsMsgViewSortType.bySize: + columnID = "sizeCol"; + break; + case Ci.nsMsgViewSortType.byPriority: + columnID = "priorityCol"; + break; + case Ci.nsMsgViewSortType.byFlagged: + columnID = "flaggedCol"; + break; + case Ci.nsMsgViewSortType.byThread: + columnID = "threadCol"; + break; + case Ci.nsMsgViewSortType.byId: + columnID = "idCol"; + break; + case Ci.nsMsgViewSortType.byJunkStatus: + columnID = "junkStatusCol"; + break; + case Ci.nsMsgViewSortType.byAttachments: + columnID = "attachmentCol"; + break; + case Ci.nsMsgViewSortType.byCustom: + // TODO: either change try() catch to if (property exists) or restore + // the getColumnHandler() check. + try { + // getColumnHandler throws an error when the ID is not handled + columnID = gDBView.curCustomColumn; + } catch (e) { + // error - means no handler + dump( + "ConvertSortTypeToColumnID: custom sort key but no handler for column '" + + columnID + + "'\n" + ); + columnID = "dateCol"; + } + break; + case Ci.nsMsgViewSortType.byCorrespondent: + columnID = "correspondentCol"; + break; + default: + dump("unsupported sort key: " + sortKey + "\n"); + columnID = "dateCol"; + break; + } + return columnID; + }, +}; + +commandController.registerCallback( + "cmd_sort", + event => sortController.handleCommand(event), + () => !!gViewWrapper?.dbView +); + +commandController.registerCallback( + "cmd_expandAllThreads", + () => { + threadPane.saveSelection(); + gViewWrapper.dbView.doCommand(Ci.nsMsgViewCommandType.expandAll); + gViewWrapper._threadExpandAll = true; + threadPane.restoreSelection(); + }, + () => !!gViewWrapper?.dbView +); +commandController.registerCallback( + "cmd_collapseAllThreads", + () => { + threadPane.saveSelection(); + gViewWrapper.dbView.doCommand(Ci.nsMsgViewCommandType.collapseAll); + gViewWrapper._threadExpandAll = false; + threadPane.restoreSelection({ expand: false }); + }, + () => !!gViewWrapper?.dbView +); + +function SwitchView(command) { + // when switching thread views, we might be coming out of quick search + // or a message view. + // first set view picker to all + if (gViewWrapper.mailViewIndex != 0) { + // MailViewConstants.kViewItemAll + gViewWrapper.setMailView(0); + } + + switch (command) { + // "All" threads and "Unread" threads don't change threading state + case "cmd_viewAllMsgs": + gViewWrapper.showUnreadOnly = false; + break; + case "cmd_viewUnreadMsgs": + gViewWrapper.showUnreadOnly = true; + break; + // "Threads with Unread" and "Watched Threads with Unread" force threading + case "cmd_viewWatchedThreadsWithUnread": + gViewWrapper.specialViewWatchedThreadsWithUnread = true; + break; + case "cmd_viewThreadsWithUnread": + gViewWrapper.specialViewThreadsWithUnread = true; + break; + // "Ignored Threads" toggles 'ignored' inclusion -- + // but it also resets 'With Unread' views to 'All' + case "cmd_viewIgnoredThreads": + gViewWrapper.showIgnored = !gViewWrapper.showIgnored; + break; + } +} + +commandController.registerCallback( + "cmd_viewAllMsgs", + () => SwitchView("cmd_viewAllMsgs"), + () => !!gDBView +); +commandController.registerCallback( + "cmd_viewThreadsWithUnread", + () => SwitchView("cmd_viewThreadsWithUnread"), + () => gDBView && gFolder && !(gFolder.flags & Ci.nsMsgFolderFlags.Virtual) +); +commandController.registerCallback( + "cmd_viewWatchedThreadsWithUnread", + () => SwitchView("cmd_viewWatchedThreadsWithUnread"), + () => gDBView && gFolder && !(gFolder.flags & Ci.nsMsgFolderFlags.Virtual) +); +commandController.registerCallback( + "cmd_viewUnreadMsgs", + () => SwitchView("cmd_viewUnreadMsgs"), + () => gDBView && gFolder && !(gFolder.flags & Ci.nsMsgFolderFlags.Virtual) +); +commandController.registerCallback( + "cmd_viewIgnoredThreads", + () => SwitchView("cmd_viewIgnoredThreads"), + () => !!gDBView +); + +commandController.registerCallback("cmd_goStartPage", () => { + // This is a user-triggered command, they must want to see the page, so show + // the message pane if it's hidden. + paneLayout.messagePaneSplitter.expand(); + messagePane.showStartPage(); +}); +commandController.registerCallback( + "cmd_print", + async () => { + let PrintUtils = top.PrintUtils; + if (!webBrowser.hidden) { + PrintUtils.startPrintWindow(webBrowser.browsingContext); + return; + } + let uris = gViewWrapper.dbView.getURIsForSelection(); + if (uris.length == 1) { + if (messageBrowser.hidden) { + // Load the only message in a hidden browser, then use the print preview UI. + let messageService = MailServices.messageServiceFromURI(uris[0]); + await PrintUtils.loadPrintBrowser( + messageService.getUrlForUri(uris[0]).spec + ); + PrintUtils.startPrintWindow( + PrintUtils.printBrowser.browsingContext, + {} + ); + } else { + PrintUtils.startPrintWindow( + messageBrowser.contentWindow.getMessagePaneBrowser().browsingContext, + {} + ); + } + return; + } + + // Multiple messages. Get the printer settings, then load the messages into + // a hidden browser and print them one at a time. + let ps = PrintUtils.getPrintSettings(); + Cc["@mozilla.org/widget/printdialog-service;1"] + .getService(Ci.nsIPrintDialogService) + .showPrintDialog(window, false, ps); + if (ps.isCancelled) { + return; + } + ps.printSilent = true; + + for (let uri of uris) { + let messageService = MailServices.messageServiceFromURI(uri); + await PrintUtils.loadPrintBrowser(messageService.getUrlForUri(uri).spec); + await PrintUtils.printBrowser.browsingContext.print(ps); + } + }, + () => { + if (!accountCentralBrowser?.hidden) { + return false; + } + if (webBrowser && !webBrowser.hidden) { + return true; + } + return gDBView && gDBView.numSelected > 0; + } +); +commandController.registerCallback( + "cmd_recalculateJunkScore", + () => analyzeMessagesForJunk(), + () => { + // We're going to take a conservative position here, because we really + // don't want people running junk controls on folders that are not + // enabled for junk. The junk type picks up possible dummy message headers, + // while the runJunkControls will prevent running on XF virtual folders. + return ( + commandController._getViewCommandStatus(Ci.nsMsgViewCommandType.junk) && + commandController._getViewCommandStatus( + Ci.nsMsgViewCommandType.runJunkControls + ) + ); + } +); +commandController.registerCallback( + "cmd_runJunkControls", + () => filterFolderForJunk(gFolder), + () => + commandController._getViewCommandStatus( + Ci.nsMsgViewCommandType.runJunkControls + ) +); +commandController.registerCallback( + "cmd_deleteJunk", + () => deleteJunkInFolder(gFolder), + () => + commandController._getViewCommandStatus(Ci.nsMsgViewCommandType.deleteJunk) +); + +commandController.registerCallback( + "cmd_killThread", + () => { + threadPane.hideIgnoredMessageNotification(); + if (!gFolder.msgDatabase.isIgnored(gDBView.keyForFirstSelectedMessage)) { + threadPane.showIgnoredMessageNotification( + gDBView.getSelectedMsgHdrs(), + false + ); + } + commandController._navigate(Ci.nsMsgNavigationType.toggleThreadKilled); + // Invalidation should be unnecessary but the back end doesn't notify us + // properly and resists attempts to fix this. + threadTree.reset(); + }, + () => gDBView?.numSelected >= 1 && (gFolder || gViewWrapper.isSynthetic) +); +commandController.registerCallback( + "cmd_killSubthread", + () => { + threadPane.hideIgnoredMessageNotification(); + if (!gDBView.hdrForFirstSelectedMessage.isKilled) { + threadPane.showIgnoredMessageNotification( + gDBView.getSelectedMsgHdrs(), + true + ); + } + commandController._navigate(Ci.nsMsgNavigationType.toggleSubthreadKilled); + // Invalidation should be unnecessary but the back end doesn't notify us + // properly and resists attempts to fix this. + threadTree.reset(); + }, + () => gDBView?.numSelected >= 1 && (gFolder || gViewWrapper.isSynthetic) +); + +// Forward these commands directly to about:message. +commandController.registerCallback( + "cmd_find", + () => + this.messageBrowser.contentWindow.commandController.doCommand("cmd_find"), + () => this.messageBrowser && !this.messageBrowser.hidden +); +commandController.registerCallback( + "cmd_findAgain", + () => + this.messageBrowser.contentWindow.commandController.doCommand( + "cmd_findAgain" + ), + () => this.messageBrowser && !this.messageBrowser.hidden +); +commandController.registerCallback( + "cmd_findPrevious", + () => + this.messageBrowser.contentWindow.commandController.doCommand( + "cmd_findPrevious" + ), + () => this.messageBrowser && !this.messageBrowser.hidden +); + +/** + * Helper function for the zoom commands, which returns the browser that is + * currently visible in the message pane or null if no browser is visible. + * + * @returns {?XULElement} - A XUL browser or null. + */ +function visibleMessagePaneBrowser() { + if (webBrowser && !webBrowser.hidden) { + return webBrowser; + } + + if (messageBrowser && !messageBrowser.hidden) { + // If the message browser is the one visible, actually return the + // element showing the message's content, since that's the one zoom + // commands should apply to. + return messageBrowser.contentDocument.getElementById("messagepane"); + } + + if (multiMessageBrowser && !multiMessageBrowser.hidden) { + return multiMessageBrowser; + } + + return null; +} + +// Zoom. +commandController.registerCallback( + "cmd_fullZoomReduce", + () => top.ZoomManager.reduce(visibleMessagePaneBrowser()), + () => visibleMessagePaneBrowser() != null +); +commandController.registerCallback( + "cmd_fullZoomEnlarge", + () => top.ZoomManager.enlarge(visibleMessagePaneBrowser()), + () => visibleMessagePaneBrowser() != null +); +commandController.registerCallback( + "cmd_fullZoomReset", + () => top.ZoomManager.reset(visibleMessagePaneBrowser()), + () => visibleMessagePaneBrowser() != null +); +commandController.registerCallback( + "cmd_fullZoomToggle", + () => top.ZoomManager.toggleZoom(visibleMessagePaneBrowser()), + () => visibleMessagePaneBrowser() != null +); + +// Browser commands. +commandController.registerCallback( + "Browser:Back", + () => webBrowser.goBack(), + () => webBrowser?.canGoBack +); +commandController.registerCallback( + "Browser:Forward", + () => webBrowser.goForward(), + () => webBrowser?.canGoForward +); +commandController.registerCallback( + "cmd_reload", + () => webBrowser.reload(), + () => webBrowser && !webBrowser.busy +); +commandController.registerCallback( + "cmd_stop", + () => webBrowser.stop(), + () => webBrowser && webBrowser.busy +); + +// Attachments commands. +for (let command of [ + "cmd_openAllAttachments", + "cmd_saveAllAttachments", + "cmd_detachAllAttachments", + "cmd_deleteAllAttachments", +]) { + commandController.registerCallback( + command, + () => messageBrowser.contentWindow.commandController.doCommand(command), + () => + messageBrowser && + !messageBrowser.hidden && + messageBrowser.contentWindow.commandController.isCommandEnabled(command) + ); +} -- cgit v1.2.3