/* 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) ); }