mail/base/content/about3Pane.js (5,385 lines of code) (raw):
/* 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 { MailServices } = ChromeUtils.importESModule(
"resource:///modules/MailServices.sys.mjs"
);
var { XPCOMUtils } = ChromeUtils.importESModule(
"resource://gre/modules/XPCOMUtils.sys.mjs"
);
ChromeUtils.defineESModuleGetters(this, {
CalMetronome: "resource:///modules/CalMetronome.sys.mjs",
FeedUtils: "resource:///modules/FeedUtils.sys.mjs",
FolderPaneUtils: "resource:///modules/FolderPaneUtils.sys.mjs",
FolderTreeProperties: "resource:///modules/FolderTreeProperties.sys.mjs",
FolderUtils: "resource:///modules/FolderUtils.sys.mjs",
Gloda: "resource:///modules/gloda/GlodaPublic.sys.mjs",
MailE10SUtils: "resource:///modules/MailE10SUtils.sys.mjs",
MailStringUtils: "resource:///modules/MailStringUtils.sys.mjs",
MailUtils: "resource:///modules/MailUtils.sys.mjs",
repairMbox: "resource:///modules/MboxRepair.sys.mjs",
SmartMailboxUtils: "resource:///modules/SmartMailboxUtils.sys.mjs",
TagUtils: "resource:///modules/TagUtils.sys.mjs",
UIDensity: "resource:///modules/UIDensity.sys.mjs",
UIFontSize: "resource:///modules/UIFontSize.sys.mjs",
XULStoreUtils: "resource:///modules/XULStoreUtils.sys.mjs",
});
const messengerBundle = Services.strings.createBundle(
"chrome://messenger/locale/messenger.properties"
);
const { ThreadPaneColumns } = ChromeUtils.importESModule(
"chrome://messenger/content/ThreadPaneColumns.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;
/**
* HTML body element handling the general layout of the about3pane.
*/
var paneLayout;
/**
* HTML element handling the swap between message, multimessage, and browser
* XUL views.
*/
var messagePane;
/**
* A Promise with resolvers, indicating if DOMContentLoaded has finished.
*/
var hasDOMContentLoaded = Promise.withResolvers();
/**
* This is called at midnight to have messages grouped by their relative date
* (such as today, yesterday, etc.) correctly categorized.
*/
function refreshGroupedBySortView() {
if (gViewWrapper?.showGroupedBySort) {
folderTree.dispatchEvent(new CustomEvent("select"));
}
}
/**
* Update the state of zoom related key bindings, whenever the view changes.
*/
function updateZoomCommands() {
const commandsToUpdate = [
"cmd_fullZoomReduce",
"cmd_fullZoomEnlarge",
"cmd_fullZoomReset",
"cmd_fullZoomToggle",
];
for (const command of commandsToUpdate) {
top.goUpdateCommand(command);
}
}
window.addEventListener("DOMContentLoaded", async event => {
if (event.target != document) {
return;
}
// Ensure all the necessary custom elements have been defined.
await customElements.whenDefined("pane-layout");
await customElements.whenDefined("message-pane");
await customElements.whenDefined("tree-view-table-row");
await customElements.whenDefined("folder-tree-row");
await customElements.whenDefined("thread-row");
await customElements.whenDefined("thread-card");
await customElements.whenDefined("tree-view");
await customElements.whenDefined("tree-listbox");
UIDensity.registerWindow(window);
UIFontSize.registerWindow(window);
messagePane = document.getElementById("messagePane");
messagePane.addEventListener("request-count-update", threadPaneHeader);
messagePane.addEventListener("show-single-message", threadPane);
paneLayout = document.getElementById("paneLayout");
paneLayout.addEventListener("request-message-clear", messagePane);
paneLayout.addEventListener("request-message-selection", threadPane);
folderTree = document.getElementById("folderTree");
accountCentralBrowser = document.getElementById("accountCentralBrowser");
folderPaneContextMenu.init();
await folderPane.init();
await threadPane.init();
threadPaneHeader.init();
await messagePane.isReady();
webBrowser = messagePane.webBrowser;
messageBrowser = messagePane.messageBrowser;
multiMessageBrowser = messagePane.multiMessageBrowser;
// 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);
// 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);
// Delay inital folder selection until after the message list's resize
// observer has had a chance to respond to layout changes. Otherwise we
// might end up scrolling to the wrong part of the list.
await new Promise(resolve => setTimeout(resolve));
folderTree.dispatchEvent(new CustomEvent("select"));
mailContextMenu.init();
CalMetronome.on("day", refreshGroupedBySortView);
updateZoomCommands();
// Update the state of the about:3pane being fully loaded.
hasDOMContentLoaded.resolve();
});
window.addEventListener("unload", () => {
CalMetronome.off("day", refreshGroupedBySortView);
MailServices.mailSession.RemoveFolderListener(folderListener);
gViewWrapper?.close();
folderPane.uninit();
threadPane.uninit();
threadPaneHeader.uninit();
});
var folderPaneContextMenu = {
/**
* @type {XULPopupElement}
*/
_menupopup: null,
/**
* Commands handled by commandController.
*
* @type {object} - An object {Object.<string, string>}
*/
_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} - An object {Object.<string, boolean>|null}
*/
_commandStates: null,
/**
* Keep track of a context clicked folder outside of the current selection
* range.
*
* @type {?nsIMsgFolder}
*/
_overrideFolder: 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, or multiple
* folders are selected in which case we return the currently active folder.
*
* @type {?nsIMsgFolder}
*/
get activeFolder() {
return (
this._overrideFolder ||
gFolder ||
MailServices.folderLookup.getFolderForURL(folderTree.selectedRow?.uri)
);
},
/**
* 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
* @returns {boolean}
*/
getCommandState(command) {
if (
!this.activeFolder ||
FolderUtils.isSmartTagsFolder(this.activeFolder)
) {
return false;
}
if (this._commandStates !== null) {
return this._commandStates[command];
}
let canCompact;
let isCompactEnabled;
let canCreateSubfolders;
let canRename;
let isServer;
let isNNTP;
let isJunk;
let isVirtual;
let isInbox;
let isSpecialUse;
let canRenameDeleteJunkMail;
let isSmartTagsFolder;
let deletable;
let server;
let URI;
let flags;
let online;
const multiSelection =
folderTree.selection.size > 1 && !this._overrideFolder;
if (multiSelection) {
canCreateSubfolders = false;
canRename = false;
isSmartTagsFolder = false;
isSpecialUse = true;
isInbox = false;
// Set some variables to TRUE to help during the folder lookup loop.
online = true;
canCompact = true;
isServer = true;
deletable = true;
isNNTP = true;
isVirtual = true;
isCompactEnabled = true;
isJunk = true;
canRenameDeleteJunkMail = true;
for (const row of folderTree.selection.values()) {
const folder = MailServices.folderLookup.getFolderForURL(row.uri);
online &&= !Services.io.offline && !folder.server.offlineSupportLevel;
// We only care if a folder doesn't support a specific property, so
// let's update a variable only if it's still truthy.
canCompact &&= folder.canCompact;
isServer &&= folder.isServer;
deletable &&= folder.deletable;
isNNTP &&= folder.server.type == "nntp";
isVirtual &&= folder.flags & Ci.nsMsgFolderFlags.Virtual;
isJunk &&= folder.flags & Ci.nsMsgFolderFlags.Junk;
canRenameDeleteJunkMail &&= FolderUtils.canRenameDeleteJunkMail(
folder.URI
);
isCompactEnabled &&= folder.isCommandEnabled("cmd_compactFolder");
// Tiny performance failsafe in case all of the variables are already
// falsy we can break the loop early.
if (
!canCompact &&
!isServer &&
!deletable &&
!isNNTP &&
!isVirtual &&
!isJunk &&
!canRenameDeleteJunkMail &&
!isCompactEnabled
) {
break;
}
}
} else {
({
canCompact,
canCreateSubfolders,
canRename,
deletable,
flags,
isServer,
server,
URI,
} = this.activeFolder);
online =
!Services.io.offline || !this.activeFolder.server.offlineSupportLevel;
isCompactEnabled =
this.activeFolder.isCommandEnabled("cmd_compactFolder");
isNNTP = server.type == "nntp";
isJunk = flags & Ci.nsMsgFolderFlags.Junk;
isVirtual = flags & Ci.nsMsgFolderFlags.Virtual;
isInbox = flags & Ci.nsMsgFolderFlags.Inbox;
isSpecialUse = flags & Ci.nsMsgFolderFlags.SpecialUse;
canRenameDeleteJunkMail = FolderUtils.canRenameDeleteJunkMail(URI);
isSmartTagsFolder = FolderUtils.isSmartTagsFolder(this.activeFolder);
}
if (isNNTP && !isServer) {
// `folderPane.deleteFolder` has a special case for this.
deletable = true;
}
this._commandStates = {
cmd_newFolder: online && ((!isNNTP && canCreateSubfolders) || isInbox),
cmd_deleteFolder:
online && (isJunk ? canRenameDeleteJunkMail : deletable),
cmd_renameFolder:
online &&
((!isServer && canRename && !isSpecialUse) ||
isVirtual ||
(isJunk && canRenameDeleteJunkMail)),
cmd_compactFolder:
!isVirtual && (isServer || canCompact) && isCompactEnabled,
cmd_emptyTrash: online && !isNNTP,
cmd_properties: !multiSelection && !isServer && !isSmartTagsFolder,
cmd_toggleFavoriteFolder:
!multiSelection && !isServer && !isSmartTagsFolder,
};
return this._commandStates[command];
},
/**
* Update the visibility of a menuitem.
*
* @param {string} id - The id of the menuitem.
* @param {boolean} show - If the item should be made visible.
*/
_showMenuItem(id, show) {
const item = document.getElementById(id);
if (item) {
item.hidden = !show;
}
},
/**
* Update the checked state of a menuitem.
*
* @param {string} id - The id of the menuitem.
* @param {boolean} checked - If the item should be checked.
*/
_checkMenuItem(id, checked) {
const item = document.getElementById(id);
if (item) {
// Always convert truthy/falsy to boolean before string.
item.setAttribute("checked", !!checked);
}
},
onPopupShowing(event) {
if (event.target != this._menupopup) {
return;
}
if (!this._overrideFolder && folderTree.selection.size > 1) {
this.updatePopupForMultiselection();
return;
}
this.updatePopupForSingleSelection();
},
/**
* Update the visibility of the folder pane popup menuitems based on the
* state of enabled commands.
*/
updatePopupCommandStates() {
// Ask commandController about the commands it controls.
for (const [id, command] of Object.entries(this._commands)) {
this._showMenuItem(id, commandController.isCommandEnabled(command));
}
},
/**
* Update the fluent strings of the context menu items that can be used for
* both single and multi selection. We pass a fake integer count to get the
* correct string because we might be showing the context menu for the an
* override folder that it's outside the current multiselection range, so
* relying on the actual selection count is not accurate.
*
* @param {integer} count - 1 or 2 depending if single or multiselection.
*/
updateFluentStrings(count) {
document.l10n.setAttributes(
document.getElementById("folderPaneContext-markMailFolderAllRead"),
"folder-pane-context-mark-folder-read",
{ count }
);
},
/**
* Update the folder pane popup to show only the available actions supported
* during a single folder selection state.
*/
updatePopupForSingleSelection() {
this.updatePopupCommandStates();
this.updateFluentStrings(1);
const folder = this.activeFolder;
const { canCreateSubfolders, flags, isServer, isSpecialFolder, server } =
folder;
const isJunk = flags & Ci.nsMsgFolderFlags.Junk;
const isTrash = isSpecialFolder(Ci.nsMsgFolderFlags.Trash, true);
const isVirtual = flags & Ci.nsMsgFolderFlags.Virtual;
const isRealFolder = !isServer && !isVirtual;
const isSmartVirtualFolder = FolderUtils.isSmartVirtualFolder(folder);
const isSmartTagsFolder = FolderUtils.isSmartTagsFolder(folder);
const serverType = server.type;
const hasNoSearchTerms = () => {
if (!isVirtual) {
return true;
}
const wrapper = VirtualFolderHelper.wrapVirtualFolder(folder);
const noSearchTerms = ["", "ALL"].includes(wrapper.searchString);
wrapper.cleanUpMessageDatabase();
return noSearchTerms;
};
this._showMenuItem(
"folderPaneContext-getMessages",
(isServer && serverType != "none") ||
(["nntp", "rss"].includes(serverType) && !isTrash && !isVirtual)
);
const showPauseAll = isServer && FeedUtils.isFeedFolder(folder);
this._showMenuItem("folderPaneContext-pauseAllUpdates", showPauseAll);
if (showPauseAll) {
const optionsAcct = FeedUtils.getOptionsAcct(server);
this._checkMenuItem(
"folderPaneContext-pauseAllUpdates",
!optionsAcct.doBiff
);
}
const showPaused = !isServer && FeedUtils.getFeedUrlsInFolder(folder);
this._showMenuItem("folderPaneContext-pauseUpdates", showPaused);
if (showPaused) {
const properties = FeedUtils.getFolderProperties(folder);
this._checkMenuItem(
"folderPaneContext-pauseUpdates",
properties.includes("isPaused")
);
}
this._showMenuItem("folderPaneContext-searchMessages", !isVirtual);
if (isVirtual) {
this._showMenuItem("folderPaneContext-subscribe", false);
} else if (serverType == "rss" && !isTrash) {
this._showMenuItem("folderPaneContext-subscribe", true);
} else {
this._showMenuItem(
"folderPaneContext-subscribe",
isServer && ["imap", "nntp"].includes(serverType)
);
}
this._showMenuItem(
"folderPaneContext-newsUnsubscribe",
isRealFolder && serverType == "nntp"
);
const 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"
)
);
}
this._showMenuItem(
"folderPaneContext-markMailFolderAllRead",
!isServer &&
!isSmartTagsFolder &&
hasNoSearchTerms() &&
serverType != "nntp"
);
this._showMenuItem(
"folderPaneContext-markNewsgroupAllRead",
isRealFolder && serverType == "nntp"
);
this._showMenuItem(
"folderPaneContext-emptyTrash",
isSpecialFolder(Ci.nsMsgFolderFlags.Trash, true)
);
this._showMenuItem("folderPaneContext-emptyJunk", isJunk);
this._showMenuItem(
"folderPaneContext-sendUnsentMessages",
flags & Ci.nsMsgFolderFlags.Queue
);
this._checkMenuItem(
"folderPaneContext-favoriteFolder",
flags & Ci.nsMsgFolderFlags.Favorite
);
this._showMenuItem("folderPaneContext-markAllFoldersRead", isServer);
this._showMenuItem("folderPaneContext-settings", isServer);
this._showMenuItem("folderPaneContext-filters", isServer);
this._showMenuItem("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".
const movePopup = document.getElementById("folderContext-movePopup");
if (isVirtual) {
this._showMenuItem("folderPaneContext-copyMenu", false);
let showMove = true;
if (isSmartVirtualFolder || isSmartTagsFolder) {
showMove = false;
}
this._showMenuItem("folderPaneContext-moveMenu", showMove);
if (showMove) {
const rootURI = MailUtils.getOrCreateFolder(folder.rootFolder.URI);
movePopup.parentFolder = rootURI;
}
} else {
// Non-virtual. Don't allow move or copy of special use or root folder.
const okToMoveCopy =
!isServer &&
!(flags & Ci.nsMsgFolderFlags.SpecialUse) &&
serverType != "nntp" &&
(!Services.io.offline || !folder.server.offlineSupportLevel);
if (okToMoveCopy) {
// Set the move menu to show all accounts.
movePopup.parentFolder = null;
}
this._showMenuItem("folderPaneContext-moveMenu", okToMoveCopy);
this._showMenuItem("folderPaneContext-copyMenu", okToMoveCopy);
}
this._refreshMenuSeparator();
},
/**
* Update the folder pane popup to show only the available actions supported
* during a multiselection state.
*/
updatePopupForMultiselection() {
// Hide all menuitems to start from a clean state, except the separators.
for (const menuitem of this._menupopup.children) {
if (menuitem.localName == "menuseparator") {
continue;
}
menuitem.hidden = true;
}
// Update the command states after we've hidden all the menuitems so we can
// show only those that are active.
this.updatePopupCommandStates();
this.updateFluentStrings(folderTree.selection.size);
// Hide anything we know for sure we don't need in multiselection.
this._showMenuItem("folderPaneContext-getMessages", false);
this._showMenuItem("folderPaneContext-pauseAllUpdates", false);
this._showMenuItem("folderPaneContext-pauseUpdates", false);
this._showMenuItem("folderPaneContext-searchMessages", false);
this._showMenuItem("folderPaneContext-subscribe", false);
this._showMenuItem("folderPaneContext-newsUnsubscribe", false);
this._showMenuItem("folderPaneContext-markNewsgroupAllRead", false);
this._showMenuItem("folderPaneContext-emptyTrash", false);
this._showMenuItem("folderPaneContext-emptyJunk", false);
this._showMenuItem("folderPaneContext-sendUnsentMessages", false);
this._showMenuItem("folderPaneContext-markAllFoldersRead", false);
this._showMenuItem("folderPaneContext-settings", false);
this._showMenuItem("folderPaneContext-filters", false);
this._showMenuItem("folderPaneContext-manageTags", false);
// Show only the standard commands that don't require special conditions.
this._showMenuItem("folderPaneContext-openNewTab", true);
this._showMenuItem("folderPaneContext-openNewWindow", true);
this._showMenuItem("folderPaneContext-markMailFolderAllRead", true);
const folders = [...folderTree.selection.values()].map(row =>
MailServices.folderLookup.getFolderForURL(row.uri)
);
const hasSpecial = folders.some(folder => {
return (
folder.isServer ||
folder.isVirtual ||
folder.noSelect ||
folder.flags & Ci.nsMsgFolderFlags.Junk ||
folder.flags & Ci.nsMsgFolderFlags.Virtual ||
folder.flags & Ci.nsMsgFolderFlags.SpecialUse ||
folder.isSpecialFolder(Ci.nsMsgFolderFlags.Trash, true) ||
FolderUtils.isSmartVirtualFolder(folder) ||
FolderUtils.isSmartTagsFolder(folder) ||
folder.server.type == "nntp"
);
});
const online =
!Services.io.offline || folders.every(f => !f.server.offlineSupportLevel);
// Show the move and copy items only if we don't have any special folder in
// the selection range.
this._showMenuItem("folderPaneContext-moveMenu", !hasSpecial && online);
this._showMenuItem("folderPaneContext-copyMenu", !hasSpecial && online);
this._refreshMenuSeparator();
},
/**
* Ensure that we don't leave an orphan menuseparator in the folder context
* menu after all the items have been updated.
*/
_refreshMenuSeparator() {
let lastItem;
for (const child of this._menupopup.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
* @param {nsIMsgCopyServiceListener} [listener]
*/
transferFolder(isMove, sourceFolder, targetFolder, listener = null) {
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,
listener,
top.msgWindow
)
);
return isMove;
},
onCommand(event) {
const activeFolder = this.activeFolder;
const selectedRows = [...folderTree.selection.values()];
// If the currently active folder is not part of the current selection,
// trigger the command only for that folder.
if (!selectedRows.some(s => s.uri == activeFolder.URI)) {
this.triggerCommand(event, activeFolder);
return;
}
// Loop through all currently selected folders and trigger the command for
// each one of those.
for (const row of selectedRows) {
this.triggerCommand(
event,
MailServices.folderLookup.getFolderForURL(row.uri)
);
}
},
/**
* Trigger the selected command from the context menu.
*
* @param {DOMEvent} event
* @param {nsIMsgFolder} folder
*/
triggerCommand(event, folder) {
// 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;
}
const 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":
goDoCommand("cmd_sendUnsentMsgs");
break;
case "folderPaneContext-markAllFoldersRead":
topChromeWindow.MsgMarkAllFoldersRead([folder]);
break;
case "folderPaneContext-settings":
folderPane.editFolder(folder);
break;
case "folderPaneContext-filters":
topChromeWindow.MsgFilters(undefined, folder);
break;
case "folderPaneContext-manageTags":
goDoCommand("cmd_manageTags");
break;
default: {
// Handle folder context menu items move to, copy to.
let isMove = !!event.target.closest("#folderPaneContext-moveMenu");
const isCopy = !!event.target.closest("#folderPaneContext-copyMenu");
if (!isMove && !isCopy) {
return;
}
const 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 <TargetFolderName> 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,
_autoExpandedRows: [],
_modes: {
all: {
name: "all",
active: false,
canBeCompact: false,
initServer(server) {
const serverRow = folderPane._createServerRow(this.name, server);
folderPane._insertInServerOrder(this.containerList, serverRow);
folderPane._addSubFolders(server.rootFolder, serverRow, this.name);
},
addFolder(parentFolder, childFolder) {
// Prevent "Empty Trash on Exit" for POP3 accounts from changing the
// collapsed state when the trash folder is replaced by an empty one.
if (MailServices.accounts.shutdownInProgress) {
return;
}
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;
}
const 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);
const 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: SmartMailboxUtils.getFolderTypes(),
init() {
this._smartMailbox = SmartMailboxUtils.getSmartMailbox();
// Add folders to the UI.
for (const folderType of this._folderTypes) {
const folder = this._smartMailbox.getSmartFolder(folderType.name);
if (!folder) {
// SmartMailboxUtils.SmartMailbox() failed to create the child folder
// and printed an error message to the console. No need for additional
// error handling here.
continue;
}
const 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.
const wrappedFolder = VirtualFolderHelper.wrapVirtualFolder(folder);
for (const searchFolder of wrappedFolder.searchFolders) {
if (searchFolder != folder) {
this._addSearchedFolder(
folderType,
folderPane._getNonGmailParent(searchFolder),
searchFolder
);
}
}
}
MailServices.accounts.saveVirtualFolders();
},
regenerateMode() {
if (this._smartMailbox) {
SmartMailboxUtils.removeAll(true);
}
this.init();
},
_addSearchedFolder(folderType, parentFolder, childFolder) {
if (folderType.flag & childFolder.flags) {
// The folder has the flag for this type.
const 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) {
const 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;
}
const wrappedFolder =
VirtualFolderHelper.wrapVirtualFolder(smartFolder);
const smartFolderRow = folderPane.getRowForFolder(
smartFolder,
this.name
);
const searchFolderURIs = wrappedFolder.searchFolders.map(sf => sf.URI);
const serversToCheck = new Set();
// Remove any rows which may belong to folders that aren't searched.
for (const row of [...smartFolderRow.querySelectorAll("li")]) {
if (!searchFolderURIs.includes(row.uri)) {
row.remove();
const folder = MailServices.folderLookup.getFolderForURL(row.uri);
if (folder) {
serversToCheck.add(folder.server);
}
}
}
// Add missing rows for folders that are searched.
const existingRowURIs = Array.from(
smartFolderRow.querySelectorAll("li"),
row => row.uri
);
for (const searchFolder of wrappedFolder.searchFolders) {
if (
searchFolder == smartFolder ||
existingRowURIs.includes(searchFolder.URI)
) {
continue;
}
const 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 (const server of serversToCheck) {
this.initServer(server);
}
},
initServer(server) {
// Find all folders in this server, and display the ones that aren't
// currently displayed.
const descendants = new Map(
server.rootFolder.descendants.map(d => [d.URI, d])
);
if (!descendants.size) {
return;
}
const remainingFolderURIs = Array.from(descendants.keys());
// Get a list of folders that already exist in the folder tree.
const existingRows = this.containerList.getElementsByTagName("li");
let existingURIs = Array.from(existingRows, li => li.uri);
do {
const folderURI = remainingFolderURIs.shift();
if (existingURIs.includes(folderURI)) {
continue;
}
const 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;
}
const folderType = this._folderTypes.find(ft =>
childFolder.isSpecialFolder(ft.flag, true)
);
if (folderType) {
const virtualFolder = VirtualFolderHelper.wrapVirtualFolder(
MailServices.folderLookup.getFolderForURL(folderType.folderURI)
);
const 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);
}
const 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.
const folderRow = folderPane._addFolderAndAncestors(
this.containerList,
childFolder,
this.name
);
folderPane._addSubFolders(childFolder, folderRow, this.name);
},
removeFolder(parentFolder, childFolder) {
const childRow = folderPane.getRowForFolder(childFolder, this.name);
if (!childRow) {
return;
}
const 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 (const smartFolderRow of this.containerList.children) {
if (
smartFolderRow.dataset.serverKey == this._smartMailbox.server.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++) {
const folder = subFolders[i];
if (folderPane._isGmailFolder(folder)) {
subFolders.splice(i, 1, ...folder.subFolders);
}
}
subFolders.sort((a, b) => a.compareSortKeys(b));
for (const 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) {
const 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 (const 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 (const 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 (const 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() {
const folders = FolderUtils.getMostRecentFolders(
MailServices.accounts.allFolders,
Services.prefs.getIntPref("mail.folder_widget.max_recent"),
"MRUTime"
);
for (const folder of folders) {
const 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._smartMailbox = SmartMailboxUtils.getSmartMailbox();
for (const tag of MailServices.tags.getAllTags()) {
const folder = this._smartMailbox.getTagFolder(tag);
if (!folder) {
continue;
}
this.containerList.appendChild(
folderPane._createTagRow(this.name, folder, tag)
);
}
MailServices.accounts.saveVirtualFolders();
},
/**
* 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) {
const [, , key] = prefName.split(".");
if (!MailServices.tags.isValidKey(key)) {
const uri = this._smartMailbox.getTagFolderUriForKey(key);
folderPane.getRowForFolder(uri)?.remove();
return;
}
const tag = MailServices.tags.getAllTags().find(t => t.key == key);
const folder = this._smartMailbox.getTagFolder(tag);
if (!folder) {
return;
}
const 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<void>} 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 = XULStoreUtils.isItemCompact("messenger", "folderTree");
let activeModes = XULStoreUtils.getValue("messenger", "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, "server-color-changed");
Services.obs.addObserver(this, "server-color-preview");
Services.obs.addObserver(this, "search-folders-changed");
Services.obs.addObserver(this, "folder-properties-changed");
Services.obs.addObserver(this, "folder-needs-repair");
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);
folderTree.addEventListener("dragend", this);
document.getElementById("folderPaneHeaderBar").hidden =
XULStoreUtils.isItemHidden("messenger", "folderPaneHeaderBar");
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 = XULStoreUtils.isItemHidden(
"messenger",
"folderPaneGetMessages"
);
document.getElementById("folderPaneWriteMessage").hidden =
XULStoreUtils.isItemHidden("messenger", "folderPaneWriteMessage");
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, "server-color-changed");
Services.obs.removeObserver(this, "server-color-preview");
Services.obs.removeObserver(this, "search-folders-changed");
Services.obs.removeObserver(this, "folder-properties-changed");
Services.obs.removeObserver(this, "folder-needs-repair");
},
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._onDragLeave(event);
break;
case "drop":
this._onDrop(event);
break;
case "dragend":
this._onDragEnd(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._smartMailbox.server) {
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;
case "server-color-changed":
case "server-color-preview":
this._changeServerRow(subject, row => row.setIconColor(data));
break;
case "folder-needs-repair": {
const folder = subject.QueryInterface(Ci.nsIMsgFolder);
console.warn("caught folder-needs-repair for " + folder.URI);
this.rebuildFolderSummary(folder);
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 = XULStoreUtils.isItemHidden(
"messenger",
"folderPaneLocalFolders"
);
return this._hideLocalFolders;
},
/**
* Reload the folder tree when the option changes.
*
* @param {boolean} value - True if local folders should be hidden.
*/
set hideLocalFolders(value) {
if (value == this._hideLocalFolders) {
return;
}
this._hideLocalFolders = value;
for (const 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) {
const currentModes = this.activeModes;
const mode = event.target.getAttribute("value");
const 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() {
const 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 (const 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");
}
const 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) {
const 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) {
const 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 (const name of Object.keys(this._modes)) {
this._toggleMode(name, modes.includes(name));
}
for (const name of modes) {
const { container, containerHeader } = this._modes[name];
containerHeader.hidden = modes.length == 1;
folderTree.appendChild(container);
}
XULStoreUtils.setValue(
"messenger",
"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 (const mode of Object.values(this._modes)) {
if (!mode.active || !mode.canBeCompact) {
continue;
}
mode.containerList.replaceChildren();
this._initMode(mode);
}
XULStoreUtils.setValue("messenger", "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}`);
}
const mode = this._modes[modeName];
if (mode.active == active) {
return;
}
if (!active) {
mode.container.remove();
delete mode.container;
mode.active = false;
return;
}
const 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 (const 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);
}
if (mode.name == "favorite") {
// Add favorite unified folders as well.
const smartServer = MailServices.accounts.findServer(
"nobody",
"smart mailboxes",
"none"
);
if (smartServer) {
mode.initServer(smartServer);
}
}
},
/**
* 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) {
const 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) {
const row = document.createElement("li", { is: "folder-tree-row" });
row.modeName = modeName;
row.setFolder(folder, nameStyle, this._isCompact);
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) {
const 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) {
const serverKeys = MailServices.accounts.accounts.map(
a => a.incomingServer.key
);
const index = serverKeys.indexOf(serverRow.dataset.serverKey);
for (const row of list.children) {
const 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 (FolderPaneUtils.nameCollator.compare(row.name, serverRow.name) > 0) {
return list.insertBefore(serverRow, row);
}
}
return list.appendChild(serverRow);
},
_reapplyServerOrder(list) {
const selected = list.querySelector("li.selected");
const serverKeys = MailServices.accounts.accounts.map(
a => a.incomingServer.key
);
const 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) {
const serverRow = folderPane._createServerRow(modeName, folder.server);
this._insertInServerOrder(containerList, serverRow);
return serverRow;
}
const 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} [childAlreadyGone=false] - Is this function being called
* to remove the parent of a row that's already been removed?
*/
_removeFolderAndAncestors(
folder,
modeName,
filterFunction,
childAlreadyGone = false
) {
// This may be the parent of the folder actually removed. Do not proceed
// if it matches the mode.
if (childAlreadyGone && filterFunction?.(folder)) {
return;
}
const 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.
const parentFolder = folderPane._getNonGmailParent(folder);
if (parentFolder && !filterFunction?.(parentFolder)) {
this._removeFolderAndAncestors(parentFolder, modeName, filterFunction);
}
// Remove the row for this folder.
folderRow.remove();
const parentRow = folderPane.getRowForFolder(parentFolder, modeName);
if (parentRow?.childList.childElementCount == 0) {
folderTree.expandRow(parentRow);
}
},
/**
* 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++) {
const folder = subFolders[i];
if (this._isGmailFolder(folder)) {
subFolders.splice(i, 1, ...folder.subFolders);
}
}
subFolders.sort((a, b) => a.compareSortKeys(b));
for (const folder of subFolders) {
if (typeof filterFunction == "function" && !filterFunction(folder)) {
continue;
}
const 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;
}
const modeNames = modeName ? [modeName] : this.activeModes;
for (const name of modeNames) {
const id = FolderPaneUtils.makeRowID(name, folderOrURI);
// Look in the mode's container. The container may or may not be
// attached to the document at this point.
const row = this._modes[name].containerList.querySelector(
`#${CSS.escape(id)}`
);
if (row) {
return row;
}
}
return null;
},
/**
* Get the first row inside a specifc mode, even if it is hidden.
*
* @param {string} modeName
* @returns {FolderTreeRow}
*/
getFirstRowForMode(modeName) {
// Look in the mode's container. The container may or may not be
// attached to the document at this point.
return this._modes[modeName].containerList.querySelector("li");
},
/**
* 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 (const 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 (const row of folderTree.querySelectorAll("li")) {
if (row.uri == folderOrURI) {
callback(row);
}
}
},
/**
* Perform a function on all rows representing a server.
*
* @param {nsIMsgAccount} account - The account that changed.
* @param {folderRowChangeCallback} callback
*/
_changeServerRow(account, callback) {
for (const row of folderTree.querySelectorAll(
`li[data-server-type][data-server-key="${account.incomingServer.key}"]`
)) {
callback(row);
}
},
/**
* 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
*/
changeTotalCount(folder) {
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 (const 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 (XULStoreUtils.isItemVisible("messenger", "folderPaneFolderSize")) {
this._changeRows(folder, row => row.updateSizeCount(false, folder));
}
},
_onSelect() {
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();
}
const uri = folderTree.selectedRow?.uri;
if (!uri) {
gFolder = null;
return;
}
const pageTitle = document.getElementById("about3PaneTitle");
// Handle multiselection by preventing any message interaction.
if (folderTree.selection.size > 1) {
// Only update the title and icon for multiselection once if the previous
// state was single selection.
if (!pageTitle.hasAttribute("data-l10n-id")) {
document.title = "";
document.l10n.setAttributes(
document.getElementById("about3PaneTitle"),
"message-list-placeholder-multiple-folders"
);
document.head.querySelector(`link[rel="icon"]`).href =
FolderUtils.getFolderIcon();
}
gViewWrapper?.close();
gFolder = gDBView = gViewWrapper = threadTree.view = null;
threadPaneHeader.onFolderSelected();
this._updateStatusQuota();
window.dispatchEvent(
new CustomEvent("folderURIChanged", { bubbles: true })
);
return;
}
pageTitle.removeAttribute("data-l10n-id");
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;
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.forgetSavedSelection(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
);
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;
}
const tabListener = () => {
// Hide the pane if the new tab ain't us.
quotaPanel.hidden =
top.window.document.getElementById("tabmail").currentAbout3Pane ==
this.window;
};
const unloadListener = () => {
top.window.document.removeEventListener("TabSelect", tabListener);
window.removeEventListener("unload", unloadListener);
};
unloadListener();
// 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".
const 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.
const quotaUsagePercentage = q =>
Number((100n * BigInt(q.usage)) / BigInt(q.limit));
const highest = folderQuota.reduce((acc, current) =>
quotaUsagePercentage(acc) > quotaUsagePercentage(current) ? acc : current
);
const 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);
window.addEventListener("unload", unloadListener);
top.window.document
.getElementById("quotaMeter")
.setAttribute("value", percent);
let usage;
let limit;
if (/STORAGE/i.test(highest.name)) {
const 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;
}
const popup = document.getElementById("folderPaneContext");
if (event.button == 2) {
// Mouse
if (event.target.closest(".mode-container")) {
return;
}
const row = event.target.closest("li");
if (!row) {
return;
}
if (![...folderTree.selection.values()].some(s => s.uri == row.uri)) {
// The right-clicked-on folder is not part of the currently selected
// list of folders. 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
popup.openPopup(folderTree.selectedRow, "after_end", 0, 0, true);
}
event.preventDefault();
},
_onCollapsed({ target }) {
if (target.uri) {
const mode = target.closest("[data-mode]").dataset.mode;
FolderTreeProperties.setIsExpanded(target.uri, mode, false);
}
target.updateUnreadMessageCount();
target.updateTotalMessageCount();
target.updateNewMessages();
},
_onExpanded({ target }) {
if (target.uri) {
const 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.
const 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) {
const draggedRow = event.target.closest(`li[is="folder-tree-row"]`);
if (!draggedRow) {
event.preventDefault();
return;
}
// If the currently dragged row is not part of the selection map, use it
// instead of the current selection entries.
const rows = folderTree.selection.has(folderTree.rows.indexOf(draggedRow))
? folderTree.selection.values()
: [draggedRow];
const folders = [...rows].map(row =>
MailServices.folderLookup.getFolderForURL(row.uri)
);
// We don't allow dragging server rows, or mixing folder types.
if (
folders.some(f => f.isServer || f.server.type != folders[0].server.type)
) {
event.preventDefault();
return;
}
// We don't allow dragging non-local folders while offline.
if (
Services.io.offline &&
folders.some(f => f.server.offlineSupportLevel)
) {
event.preventDefault();
return;
}
for (const [index, folder] of folders.entries()) {
event.dataTransfer.mozSetDataAt(
folder.server.type == "nntp"
? "text/x-moz-newsfolder"
: "text/x-moz-folder",
folder,
index
);
}
event.dataTransfer.effectAllowed = folders.some(
f => f.server.type == "nntp"
)
? "move"
: "copyMove";
},
_onDragOver(event) {
const systemDropEffect = event.dataTransfer.dropEffect;
event.dataTransfer.dropEffect = "none";
event.preventDefault();
const row = event.target.closest("li");
this._timedExpand(row);
if (!row) {
return;
}
this._clearCollapseTimer();
const targetFolder = MailServices.folderLookup.getFolderForURL(row.uri);
if (!targetFolder) {
return;
}
const 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++) {
const 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 =
systemDropEffect == "copy" ? "copy" : "move";
} else if (types.includes("text/x-moz-folder")) {
let allowReorderOnly = !targetFolder.canCreateSubfolders;
let moveWithinSameServer = systemDropEffect == "move";
for (let i = 0; i < event.dataTransfer.mozItemCount; i++) {
const sourceFolder = event.dataTransfer
.mozGetDataAt("text/x-moz-folder", i)
.QueryInterface(Ci.nsIMsgFolder);
// Don't allow to drop on itself.
if (targetFolder == sourceFolder) {
return;
}
const sameServer = sourceFolder.server == targetFolder.server;
// Don't copy within same server.
if (sameServer && systemDropEffect == "copy") {
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) && !sameServer) {
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.
const noRenamePossible =
!sourceFolder.canRename &&
(targetFolder.server.type != "none" || sameServer);
// Don't allow to drop on different hierarchy.
if (noRenamePossible && sourceFolder.parent != targetFolder.parent) {
return;
}
// If in the same hierarchy, allow only reordering.
allowReorderOnly ||= noRenamePossible;
moveWithinSameServer &&= sameServer;
}
// Evaluate the ability to reorder folders.
// * Let's keep it simple. Don't allow "insert" when dragging multiple
// folders.
// * Also, only allow it in "all" mode. Otherwise there is ambiguity.
if (
moveWithinSameServer &&
!targetFolder.isServer &&
event.dataTransfer.mozItemCount == 1 &&
row.modeName == "all"
) {
const { center, quarterOfHeight } = this._calculateElementHeight(row);
if (event.clientY < center - quarterOfHeight) {
// Insert before the target.
this._clearDropTarget();
row.classList.add("reorder-target-before");
event.dataTransfer.dropEffect = "move";
return;
}
if (
event.clientY > center + quarterOfHeight &&
(!row.classList.contains("children") ||
row.classList.contains("collapsed"))
) {
// Insert after the target.
this._clearDropTarget();
row.classList.add("reorder-target-after");
event.dataTransfer.dropEffect = "move";
return;
}
}
if (allowReorderOnly) {
return;
}
event.dataTransfer.dropEffect =
systemDropEffect == "copy" ? "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++) {
const 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")) {
for (let i = 0; i < event.dataTransfer.mozItemCount; i++) {
const folder = event.dataTransfer
.mozGetDataAt("text/x-moz-newsfolder", i)
.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");
},
_onDragLeave(event) {
this._timedExpand();
this._setCollapseTimer();
this._clearDropTarget(event);
},
/**
* Set a timer to expand `row` in 1000ms. 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);
delete this._expandRow;
delete this._expandTimer;
}
if (!row?.classList.contains("collapsed")) {
return;
}
this._expandRow = row;
this._expandTimer = setTimeout(() => {
this._autoExpandedRows.push(this._expandRow);
folderTree.expandRow(this._expandRow);
delete this._expandRow;
delete this._expandTimer;
}, 1000);
},
/**
* Set a timer to collapse all auto-expanded rows in 1000ms.
*/
_setCollapseTimer() {
this._collapseTimer = setTimeout(() => {
this._collapseAutoExpandedRows();
delete this._collapseTimer;
}, 1000);
},
/**
* Clear the timer to collapse all auto-expanded rows..
*/
_clearCollapseTimer() {
if (this._collapseTimer) {
clearTimeout(this._collapseTimer);
delete this._collapseTimer;
}
},
_clearDropTarget() {
folderTree.querySelector(".drop-target")?.classList.remove("drop-target");
folderTree
.querySelector(".reorder-target-before")
?.classList.remove("reorder-target-before");
folderTree
.querySelector(".reorder-target-after")
?.classList.remove("reorder-target-after");
},
_collapseAutoExpandedRows() {
while (this._autoExpandedRows.length) {
for (const row of this._autoExpandedRows) {
folderTree.collapseRow(row);
}
this._autoExpandedRows.length = 0;
this._clearCollapseTimer();
}
},
/**
* Calculate the center point of a row element related to the client height
* and returns it alongside a quarter of its height.
*
* @param {FolderTreeRow} row
* @returns {object}
*/
_calculateElementHeight(row) {
const targetElement = row.querySelector(".container") ?? row;
const targetRect = targetElement.getBoundingClientRect();
const center =
targetRect.top + targetElement.clientTop + targetElement.clientHeight / 2;
const quarterOfHeight = targetElement.clientHeight / 4;
return { center, quarterOfHeight };
},
_onDrop(event) {
this._timedExpand();
this._clearDropTarget();
this._autoExpandedRows.length = 0;
if (event.dataTransfer.dropEffect == "none") {
// Somehow this is possible. It should not be possible.
return;
}
const row = event.target.closest("li");
if (!row) {
return;
}
const targetFolder = MailServices.folderLookup.getFolderForURL(row.uri);
const types = Array.from(event.dataTransfer.mozTypesAt(0));
if (types.includes("text/x-moz-message")) {
const array = [];
let sourceFolder;
for (let i = 0; i < event.dataTransfer.mozItemCount; i++) {
const 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";
const 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")) {
const rows = [];
let isMove = event.dataTransfer.dropEffect == "move";
if (event.dataTransfer.mozItemCount == 1) {
// Only one folder was dragged and dropped.
// If the dropped Y-coordinate is near the center of the targetFolder,
// simply move it into the targetFolder. Otherwise, reorder the dropped
// folder above or below the targetFolder.
const sourceFolder = event.dataTransfer
.mozGetDataAt("text/x-moz-folder", 0)
.QueryInterface(Ci.nsIMsgFolder);
let destinationFolder = targetFolder;
let isReordering = false;
let insertAfter = false;
// Only allow moving a folder in "all" mode, otherwise it would be
// impossible to reorder folders unambiguously.
if (
isMove &&
targetFolder.parent &&
sourceFolder.server == targetFolder.server &&
!targetFolder.isServer &&
row.modeName == "all"
) {
const { center, quarterOfHeight } = this._calculateElementHeight(row);
const upperElementEnd = event.clientY < center - quarterOfHeight;
const lowerElementEndWithoutChildren =
event.clientY > center + quarterOfHeight &&
(!row.classList.contains("children") ||
row.classList.contains("collapsed"));
isReordering = upperElementEnd || lowerElementEndWithoutChildren;
insertAfter = lowerElementEndWithoutChildren;
if (isReordering) {
// To insert the sourceFolder before or after the targetFolder,
// we have to transfer sourceFolder to the parent of targetFolder
// as a sibling of targetFolder. If it is the same as the current
// parent, there is no need to perform the transferFolder, so let
// destinationFolder be null.
destinationFolder =
targetFolder.parent != sourceFolder.parent
? targetFolder.parent
: null;
}
}
if (destinationFolder) {
// Move sourceFolder to a different parent.
// Reset the sort order of sourceFolder before moving it.
sourceFolder.userSortOrder = Ci.nsIMsgFolder.NO_SORT_VALUE;
// Start the move. This is done in an asynchronous process, so order
// them in the listener that will be called when the move is complete.
isMove = folderPaneContextMenu.transferFolder(
isMove,
sourceFolder,
destinationFolder,
isReordering
? new ReorderFolderListener(
sourceFolder,
targetFolder,
insertAfter
)
: null
);
// Save in prefs the destination 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 <DestinationFolderName> Again".
Services.prefs.setStringPref(
"mail.last_msg_movecopy_target_uri",
destinationFolder.URI
);
} else if (isReordering) {
// Reorder within current siblings.
this.insertFolder(sourceFolder, targetFolder, insertAfter);
if (folderTree.selection.has(folderTree.rows.indexOf(row))) {
rows.push(this.getRowForFolder(sourceFolder.URI, row.modeName));
}
}
Services.prefs.setBoolPref("mail.last_msg_movecopy_was_move", isMove);
} else {
// FIXME! Bug 1896531.
console.warn(
"Bug 1896531. Copy and move for multiselection is only partially supported and it might fail."
);
for (let i = 0; i < event.dataTransfer.mozItemCount; i++) {
const sourceFolder = event.dataTransfer
.mozGetDataAt("text/x-moz-folder", i)
.QueryInterface(Ci.nsIMsgFolder);
isMove = folderPaneContextMenu.transferFolder(
isMove,
sourceFolder,
targetFolder
);
rows.push(this.getRowForFolder(sourceFolder.URI, row.modeName));
}
// 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 <TargetFolderName> Again".
Services.prefs.setStringPref(
"mail.last_msg_movecopy_target_uri",
targetFolder.URI
);
Services.prefs.setBoolPref("mail.last_msg_movecopy_was_move", isMove);
}
this.swapFolderSelection(rows);
} else if (types.includes("application/x-moz-file")) {
for (let i = 0; i < event.dataTransfer.mozItemCount; i++) {
const 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")) {
const rows = [];
for (let i = 0; i < event.dataTransfer.mozItemCount; i++) {
const folder = event.dataTransfer
.mozGetDataAt("text/x-moz-newsfolder", i)
.QueryInterface(Ci.nsIMsgFolder);
const newsRoot = targetFolder.rootFolder.QueryInterface(
Ci.nsIMsgNewsFolder
);
newsRoot.reorderGroup(folder, targetFolder);
rows.push(this.getRowForFolder(folder, row.modeName));
}
this.swapFolderSelection(rows);
} 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.
const feedURI = FeedUtils.getFeedUriFromDataTransfer(event.dataTransfer);
FeedUtils.subscribeToFeed(feedURI.spec, targetFolder);
}
event.preventDefault();
},
_onDragEnd(event) {
if (event.dataTransfer.dropEffect != "none") {
return;
}
folderPane._timedExpand();
folderPane._collapseAutoExpandedRows();
},
/**
* Opens the dialog to create a new sub-folder, and creates it if the user
* accepts.
*
* @param {nsIMsgFolder} folder - The parent for the new subfolder.
*/
newFolder(folder) {
// 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.
const rootMsgFolder = folder.server.rootMsgFolder;
folder = rootMsgFolder.canCreateSubfolders
? rootMsgFolder
: top.GetDefaultAccountRootFolder();
}
let dualUseFolders = true;
if (folder.server instanceof Ci.nsIImapIncomingServer) {
dualUseFolders = folder.server.dualUseFolders;
}
/**
* Callback executed when the user selects OK in the create folder dialog.
*
* @param {string} subfolderName
* @param {nsIMsgFolder} parentFolder
*/
const newFolderOkCallback = async (subfolderName, parentFolder) => {
// TODO: Rewrite this logic and also move the opening of alert dialogs from
// nsMsgLocalMailFolder::CreateSubfolderInternal to here (bug 831190#c16).
if (!subfolderName) {
return;
}
const promiseNewFolder = new Promise(resolve => {
const listener = {
folderAdded: addedFolder => {
if (addedFolder.name == subfolderName) {
MailServices.mfn.removeListener(listener);
resolve(addedFolder);
}
},
};
MailServices.mfn.addListener(
listener,
Ci.nsIMsgFolderNotificationService.folderAdded
);
});
parentFolder.createSubfolder(subfolderName, top.msgWindow);
if (!parentFolder.isServer) {
// Inherit view/sort/columns from parent folder.
const newFolder = await promiseNewFolder;
const parentInfo = parentFolder.msgDatabase.dBFolderInfo;
const newInfo = newFolder.msgDatabase.dBFolderInfo;
newInfo.viewFlags = parentInfo.viewFlags;
newInfo.sortType = parentInfo.sortType;
newInfo.sortOrder = parentInfo.sortOrder;
newInfo.setCharProperty(
"columnStates",
parentInfo.getCharProperty("columnStates")
);
}
};
window.openDialog(
"chrome://messenger/content/newFolderDialog.xhtml",
"",
"chrome,modal,resizable=no,centerscreen",
{ folder, dualUseFolders, okCallback: newFolderOkCallback }
);
},
async rebuildFolderSummary(folder) {
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
);
} else if (
Services.prefs.getCharPref(
`mail.server.${folder.server.key}.storeContractID`
) == "@mozilla.org/msgstore/berkeleystore;1"
) {
// For local mbox, fix classic MacOS line endings.
try {
folder.acquireSemaphore(folder, "folderPane.rebuildFolderSummary");
await repairMbox(folder.filePath.path);
} catch (e) {
console.warn(`Repair mbox FAILED; ${e.message}`);
} finally {
folder.releaseSemaphore(folder, "folderPane.rebuildFolderSummary");
}
}
// The following notification causes all DBViewWrappers that include
// this folder to rebuild their views.
MailServices.mfn.notifyFolderReindexTriggered(folder);
folder.msgDatabase.summaryValid = false;
try {
const isIMAP = folder.server.type == "imap";
let transferInfo = null;
if (isIMAP) {
transferInfo = folder.dBTransferInfo.QueryInterface(
Ci.nsIWritablePropertyBag2
);
transferInfo.setPropertyAsACString("numMsgs", "0");
transferInfo.setPropertyAsACString("numNewMsgs", "0");
// Reset UID validity so that nsImapMailFolder::UpdateImapMailboxInfo
// will recognize that a folder repair is in progress.
transferInfo.setPropertyAsACString("UIDValidity", "-1"); // == kUidUnknown
}
folder.closeAndBackupFolderDB("");
if (isIMAP && transferInfo) {
folder.dBTransferInfo = transferInfo;
}
} catch (e) {
// In a failure, proceed anyway since we're dealing with problems
folder.ForceDBClosed();
}
folder.updateFolder(top.msgWindow);
},
/**
* 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;
}
const title = messengerBundle.GetStringFromName("folderProperties");
function editFolderCallback(newName, oldName) {
if (newName != oldName) {
folder.rename(newName, 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: this.rebuildFolderSummary,
}
);
},
/**
* Opens the dialog to rename a particular folder, and does the renaming if
* the user clicks OK in that dialog
*
* @param {nsIMsgFolder} [aFolder] - The folder to rename, if different than
* the currently selected one.
*/
renameFolder(aFolder) {
const 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 {nsIMsgFolder} folder - The folder to delete.
*/
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)) {
const confirmation = messengerBundle.GetStringFromName(
"confirmSavedSearchDeleteMessage"
);
const 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) {
if (ex.result == Cr.NS_ERROR_FILE_NO_DEVICE_SPACE) {
// folder could not be deleted due to low space
// outOfDiskSpace message is too restricted to downloading
// operation so we created a new generic message, outOfDiskSpaceGeneric
folder.throwAlertMsg("outOfDiskSpaceGeneric", top.msgWindow);
} else {
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 {nsIMsgFolder} [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 (const server of MailServices.accounts.allServers) {
for (const 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.
const wrappedFolder = VirtualFolderHelper.wrapVirtualFolder(folder);
for (const searchFolder of wrappedFolder.searchFolders) {
this.emptyJunk(searchFolder, false);
}
return;
}
// Delete any subfolders this folder might have
for (const subFolder of folder.subFolders) {
folder.propagateDelete(subFolder, true);
}
const 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 {string} aName - The default name for the new folder.
* @param {nsIMsgSearchTerm[]} aSearchTerms - The search terms associated
* with the folder.
* @param {nsIMsgFolder} aParent - The folder to run the search terms on.
*/
newVirtualFolder(aName, aSearchTerms, aParent) {
const 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,
}
);
},
/**
* @param {nsIMsgFolder} aFolder
*/
editVirtualFolder(aFolder) {
const 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 {string} aCommand - The command to prompt for.
* @param {nsIMsgFolder} aFolder - The folder for which the confirmation is requested.
*/
_checkConfirmationPrompt(aCommand, aFolder) {
// If no folder was specified, reject the operation.
if (!aFolder) {
return false;
}
const showPrompt = !Services.prefs.getBoolPref(
"mailnews." + aCommand + ".dontAskAgain",
false
);
if (showPrompt) {
const checkbox = { value: false };
const title = messengerBundle.formatStringFromName(
aCommand + "FolderTitle",
[aFolder.prettyName]
);
const msg = messengerBundle.GetStringFromName(aCommand + "FolderMessage");
const 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;
},
/**
* Ensure the pane header context menu items are correctly checked.
*/
updateContextMenuCheckedItems() {
for (const item of document.querySelectorAll(".folder-pane-option")) {
switch (item.id) {
case "folderPaneHeaderToggleGetMessages":
XULStoreUtils.isItemHidden("messenger", "folderPaneGetMessages")
? item.removeAttribute("checked")
: item.setAttribute("checked", true);
break;
case "folderPaneHeaderToggleNewMessage":
XULStoreUtils.isItemHidden("messenger", "folderPaneWriteMessage")
? item.removeAttribute("checked")
: item.setAttribute("checked", true);
break;
case "folderPaneHeaderToggleTotalCount":
XULStoreUtils.isItemVisible("messenger", "totalMsgCount")
? item.setAttribute("checked", true)
: item.removeAttribute("checked");
break;
case "folderPaneMoreContextCompactToggle":
this.isCompact
? item.setAttribute("checked", true)
: item.removeAttribute("checked");
this.toggleCompactViewMenuItem();
break;
case "folderPaneHeaderToggleFolderSize":
XULStoreUtils.isItemVisible("messenger", "folderPaneFolderSize")
? item.setAttribute("checked", true)
: item.removeAttribute("checked");
break;
case "folderPaneHeaderToggleLocalFolders":
XULStoreUtils.isItemHidden("messenger", "folderPaneLocalFolders")
? item.setAttribute("checked", true)
: item.removeAttribute("checked");
break;
default:
item.removeAttribute("checked");
break;
}
}
},
toggleHeaderButton(event, id) {
const isHidden = !event.target.hasAttribute("checked");
document.getElementById(id).hidden = isHidden;
XULStoreUtils.setValue("messenger", id, "hidden", isHidden);
},
toggleHeader(hide) {
document.getElementById("folderPaneHeaderBar").hidden = hide;
XULStoreUtils.setValue("messenger", "folderPaneHeaderBar", "hidden", hide);
},
/**
* Ensure the folder rows UI elements reflect the state set by the user.
*/
updateFolderRowUIElements() {
this.toggleTotalCountBadge();
this.toggleFolderSizes();
},
/**
* Toggle the total message count badges and update the XULStore.
*/
toggleTotal(event) {
const show = event.target.hasAttribute("checked");
XULStoreUtils.setValue("messenger", "totalMsgCount", "visible", show);
this.toggleTotalCountBadge();
},
toggleTotalCountBadge() {
const isHidden = !XULStoreUtils.isItemVisible("messenger", "totalMsgCount");
for (const row of document.querySelectorAll(`li[is="folder-tree-row"]`)) {
row.toggleTotalCountBadgeVisibility(isHidden);
}
},
/**
* Toggle the folder size option and update the XULStore.
*/
toggleFolderSize(event) {
const show = event.target.hasAttribute("checked");
XULStoreUtils.setValue(
"messenger",
"folderPaneFolderSize",
"visible",
show
);
this.toggleFolderSizes();
},
/**
* Toggle the folder size info on each folder.
*/
toggleFolderSizes() {
const isHidden = !XULStoreUtils.isItemVisible(
"messenger",
"folderPaneFolderSize"
);
for (const 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) {
const isHidden = event.target.hasAttribute("checked");
XULStoreUtils.setValue(
"messenger",
"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 (const server of servers) {
const menuitem = document.createXULElement("menuitem");
menuitem.classList.add("menuitem-iconic", "server");
menuitem.dataset.serverKey = server.key;
menuitem.dataset.serverType = server.type;
menuitem.dataset.serverSecure = server.isSecure;
menuitem.label = server.prettyName;
menuitem.addEventListener("command", () =>
top.MsgGetMessagesForAccount(server.rootFolder)
);
menupopup.appendChild(menuitem);
}
},
/**
* Set folder sort order to rows for the folder.
*
* @param {nsIMsgFolder} folder
* @param {integer} order
*/
setOrderToRowInAllModes(folder, order) {
for (const name of this.activeModes) {
const row = folderPane.getRowForFolder(folder, name);
if (row) {
row.folderSortOrder = order;
}
}
},
/**
* Sorting comparator for two folders.
*
* @param {nsIMsgFolder} folderA
* @param {nsIMSgFolder} folderB
* @returns {number} Sorting value when comparing the two folders.
*/
_sortFolders: (folderA, folderB) =>
folderA.sortOrder - folderB.sortOrder ||
FolderPaneUtils.nameCollator.compare(folderA.name, folderB.name),
/**
* Set the sort order for the new folder added to the folder group.
*
* @param {nsIMsgFolder} parentFolder
* @param {nsIMsgFolder} newFolder
*/
setSortOrderOnNewFolder(parentFolder, newFolder) {
if (newFolder.userSortOrder != Ci.nsIMsgFolder.NO_SORT_VALUE) {
return;
}
const subFolders = parentFolder?.subFolders ?? [];
const maxOrderValue = Math.max(
-1,
...subFolders
.filter(folder => folder.userSortOrder != Ci.nsIMsgFolder.NO_SORT_VALUE)
.map(folder => folder.userSortOrder)
);
if (maxOrderValue == -1) {
// None of the sibling folders have a sort order value (i.e. this group of
// folders has never been manually sorted). In this case, the natural
// order should still be used.
return;
}
// The group has already been ordered. In this case, insert the new folder
// before the first folder that is further ahead of it in the natural order.
const sibling = subFolders
// Skip special folders so new folders don't get created before them.
.filter(folder => folder.flags & Ci.nsMsgFolderFlags.SpecialUse)
.sort(this._sortFolders)
.find(
folder =>
FolderPaneUtils.nameCollator.compare(folder.name, newFolder.name) > 0
);
if (sibling) {
folderPane.insertFolder(newFolder, sibling, false);
return;
}
// Place the new folder at the bottom.
const newOrder = maxOrderValue + 1;
newFolder.userSortOrder = newOrder; // Update DB
this.setOrderToRowInAllModes(newFolder, newOrder); // Update row info.
},
/**
* Insert a folder before/after the target and reorder siblings.
* Note: Valid only in "all" mode.
*
* @param {nsIMsgFolder} folder
* @param {nsIMsgFolder} target
* @param {boolean} insertAfter
*/
insertFolder(folder, target, insertAfter) {
let subFolders = [];
try {
subFolders = target.parent.subFolders;
} catch (ex) {
console.error(
`Unable to access the subfolders of ${target.parent.URI}`,
ex
);
}
// Considering the case of a folder inserted between folders with the same
// order value X, the order of the inserted folder must be (X+1), even if
// it is inserted before the target. And the order of subsequent folders
// must be increased by 2.
const targetOrder = target.sortOrder;
const folderOrder = targetOrder + 1;
// Start at the end, so we can stop once we've reached the insertion point.
const folders = subFolders
.filter(sf => sf != folder)
.sort((a, b) => this._sortFolders(b, a));
for (const sibling of folders) {
// If we've reached the target and we're inserting after it, we've done
// all the necessary moving.
if (insertAfter && sibling == target) {
break;
}
const order = sibling.sortOrder + 2;
sibling.userSortOrder = order; // Update DB.
folderPane.setOrderToRowInAllModes(sibling, order); // Update row info.
// If we're inserting before the target and we've just updated the target
// we can now insert the folder itself.
if (!insertAfter && sibling == target) {
break;
}
}
folder.userSortOrder = folderOrder; // Update DB.
folderPane.setOrderToRowInAllModes(folder, folderOrder); // Update row info.
// Update folder pane UI.
const movedFolderURI = folder.URI;
const modeNames = folderPane.activeModes;
for (const name of modeNames) {
// Find a parent UI element of folder in this mode.
// Note that the parent folder on the DB may not be the parent UI element
// (as is the case with Gmail). So we find the parent UI element by
// querying the CSS selector.
const rowToMove = folderPane.getRowForFolder(folder, name);
const id = FolderPaneUtils.makeRowID(name, movedFolderURI);
const listRow = folderPane._modes[name].containerList.querySelector(
`li[is="folder-tree-row"]:has(>ul>li#${CSS.escape(id)})`
);
if (listRow) {
listRow.insertChildInOrder(rowToMove);
}
}
},
get isMultiSelection() {
return folderTree.selection.size > 1;
},
/**
* Wrap the swap selection around a timeout to make sure we run this after any
* other operation like folder move.
*
* @param {HTMLLIElement[]} rows - The array of rows to select.
*/
swapFolderSelection(rows) {
setTimeout(() => {
folderTree.swapSelection(rows);
});
},
};
/**
* Class responsible for the the UI reorder of the folders after the backend
* operation has been completed.
*/
class ReorderFolderListener {
constructor(sourceFolder, targetFolder, insertAfter) {
this.sourceFolder = sourceFolder;
this.targetFolder = targetFolder;
this.insertAfter = insertAfter;
}
onStopCopy() {
// Do reorder within new siblings (all children of new parent).
const movedFolder = MailServices.copy.getArrivedFolder(this.sourceFolder);
if (!movedFolder) {
return;
}
folderPane.insertFolder(movedFolder, this.targetFolder, this.insertAfter);
}
}
/**
* 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 = XULStoreUtils.isItemHidden("messenger", "threadPaneHeader");
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, {
position: "after_end",
triggerEvent: event,
});
});
},
uninit() {
window.removeEventListener("qfbtoggle", this);
},
handleEvent(event) {
switch (event.type) {
case "qfbtoggle":
this.onQuickFilterToggle();
break;
case "request-count-update":
this.updateSelectedCount();
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;
}
document
.getElementById(
threadTree.getAttribute("rows") == "thread-row"
? "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;
}
// Update menuitem to reflect sort key.
for (const menuitem of event.target.querySelectorAll(`[name="sortby"]`)) {
const sortKey = menuitem.getAttribute("value");
menuitem.setAttribute(
"checked",
gViewWrapper.primarySortColumnId == sortKey
);
}
// 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);
},
/**
* 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;
XULStoreUtils.setValue(
"messenger",
"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, or when a
* synthetic view is created.
*/
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;
}
this.folderName.textContent = gFolder?.abbreviatedName ?? document.title;
this.folderName.title = gFolder?.prettyName ?? document.title;
this.updateMessageCount(
gFolder?.getTotalMessages(false) || gDBView?.numMsgsInView || 0
);
this.updateSelectedCount();
this.folderName.hidden = false;
this.folderCount.hidden = false;
},
/**
* Update the total message count in the header.
*
* @param {integer} newValue
*/
updateMessageCount(newValue) {
if (this.isHidden) {
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;
}
const count = gDBView?.getSelectedMsgHdrs().length;
if (count === undefined || 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<string, object>}
*/
_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: ThreadPaneColumns.getDefaultColumns(gFolder),
cardColumns: ThreadPaneColumns.getDefaultColumnsForCardsView(gFolder),
async init() {
await quickFilterBar.init();
this.setUpTagStyles();
Services.prefs.addObserver("mailnews.tags.", this);
Services.prefs.addObserver("mail.threadpane.table.horizontal_scroll", this);
Services.prefs.addObserver("mail.threadpane.listview", this);
Services.obs.addObserver(this, "addrbook-displayname-changed");
Services.obs.addObserver(this, "custom-column-added");
Services.obs.addObserver(this, "custom-column-removed");
Services.obs.addObserver(this, "custom-column-refreshed");
Services.obs.addObserver(this, "global-view-flags-changed");
threadTree = document.getElementById("threadTree");
if (!threadTree.table) {
// It's possible we're here after tree-view is defined but before
// connectedCallback has fired on threadTree. Wait for that to happen.
await new Promise(resolve => {
new MutationObserver((mutations, observer) => {
if (threadTree.table) {
observer.disconnect();
resolve();
}
}).observe(threadTree, { childList: true });
});
}
this.treeTable = threadTree.table;
this.treeTable.editable = true;
this.treeTable.isHorizontalScroll = Services.prefs.getBoolPref(
"mail.threadpane.table.horizontal_scroll",
false
);
this.treeTable.setPopupMenuTemplates([
"threadPaneApplyColumnMenu",
"threadPaneApplyViewMenu",
]);
threadPane.updateThreadView();
XPCOMUtils.defineLazyPreferenceGetter(
this,
"selectDelay",
"mailnews.threadpane_select_delay",
null,
(name, oldValue, newValue) => (threadTree.dataset.selectDelay = newValue)
);
threadTree.dataset.selectDelay = this.selectDelay;
XPCOMUtils.defineLazyPreferenceGetter(
this,
"rowCount",
"mail.threadpane.cardsview.rowcount",
3,
() => this.updateThreadItemSize(),
prefVal => Math.min(Math.max(2, prefVal), 3)
);
window.addEventListener("uidensitychange", () => {
this.updateThreadItemSize();
});
window.addEventListener("uifontsizechange", () => {
this.updateThreadItemSize();
});
this.updateThreadItemSize();
ChromeUtils.defineLazyGetter(this, "notificationBox", () => {
const 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("messenger", 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("click", 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("dragend", this);
threadTree.addEventListener("expanded", this);
threadTree.addEventListener("collapsed", this);
threadTree.addEventListener("scroll", this);
threadTree.addEventListener("showplaceholder", this);
},
uninit() {
Services.prefs.removeObserver("mailnews.tags.", this);
Services.prefs.removeObserver(
"mail.threadpane.table.horizontal_scroll",
this
);
Services.prefs.removeObserver("mail.threadpane.listview", this);
Services.obs.removeObserver(this, "addrbook-displayname-changed");
Services.obs.removeObserver(this, "custom-column-added");
Services.obs.removeObserver(this, "custom-column-removed");
Services.obs.removeObserver(this, "custom-column-refreshed");
Services.obs.removeObserver(this, "global-view-flags-changed");
},
handleEvent(event) {
const notOnEmptySpace = event.target !== threadTree;
switch (event.type) {
case "show-single-message":
threadTree.selectedIndices = event.detail.messages;
break;
case "request-message-selection":
threadTree.dispatchEvent(new CustomEvent("select"));
break;
case "click":
if (notOnEmptySpace && event.target.closest(".tree-button-more")) {
this._onContextMenu(event);
}
break;
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 "dragend":
this._onDragEnd(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;
case "showplaceholder":
threadTree.updatePlaceholders([
folderTree.selection.size > 1
? "placeholderMultipleFolders"
: "placeholderNoMessages",
]);
break;
}
},
observe(subject, topic, data) {
switch (topic) {
case "nsPref:changed":
if (data == "mail.threadpane.table.horizontal_scroll") {
this.treeTable.isHorizontalScroll = Services.prefs.getBoolPref(
"mail.threadpane.table.horizontal_scroll",
false
);
// Only call a columns refresh if a folder is selected. We can skip
// this since we already set the isHorizontalScroll variable and it
// will be used next time the user selects a folder.
if (gFolder) {
this.treeTable.updateColumns(this.columns);
}
break;
}
if (data.startsWith("mailnews.tags.")) {
this.setUpTagStyles();
break;
}
if (data == "mail.threadpane.listview") {
this.updateThreadView();
this.updateThreadItemSize();
}
break;
case "addrbook-displayname-changed":
case "custom-column-refreshed":
// addrbook-displayname-changed: This runs when mail.displayname.version
// preference observer is notified or the number of the
// mail.displayname.version preference has been updated.
// custom-column-refreshed: This used to refresh just the column,
// but now that filling the cells happens asynchronously, that's too
// complicated, so it's better to invalidate the whole thing. Kept for
// add-on compatibility.
threadTree.invalidate();
break;
case "custom-column-added":
this.addCustomColumn(data);
break;
case "custom-column-removed":
this.onCustomColumnRemoved(data);
break;
case "global-view-flags-changed":
// Global view flags have changed. Reload the currently selected message
// list to avoid showing a stale configuration. We could be smart here
// and check if the currently selected folder is part of the modified
// folders but forcing a selection is inexpensive and straightforward.
folderTree.dispatchEvent(new CustomEvent("select"));
break;
}
},
/**
* 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",
ThreadPaneColumns.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 == "Backspace" || event.key == "Delete") && event.repeat) {
// Bail on delete event if there is a repeat event to prevent deleting
// multiple messages by mistake from a longer key press.
event.preventDefault();
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;
}
const 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() {
if (
!dbViewWrapperListener.allMessagesLoaded &&
!this._selectionIsBeingRestored
) {
// The user selected something, stop restoring a saved selection.
this.forgetSavedSelection();
}
if (paneLayout.messagePaneVisible.isCollapsed) {
updateZoomCommands();
return;
}
const numSelected = gDBView?.numSelected || 0;
switch (numSelected) {
case 0:
messagePane.displayMessage();
break;
case 1: {
if (
gDBView.getFlagsAt(threadTree.selectedIndex) & MSG_VIEW_FLAG_DUMMY
) {
messagePane.displayMessage();
break;
}
const uri = gDBView.getURIForViewIndex(threadTree.selectedIndex);
messagePane.displayMessage(uri);
break;
}
default:
if (gViewWrapper.showGroupedBySort) {
const savedIndex = threadTree.currentIndex;
threadTree.selectedIndices
.filter(i => gViewWrapper.isExpandedGroupedByHeaderAtIndex(i))
.forEach(i => threadTree.toggleSelectionAtIndex(i, false, false));
threadTree.currentIndex = savedIndex;
}
messagePane.displayMessages(gDBView.getSelectedMsgHdrs());
break;
}
updateZoomCommands();
},
/**
* Handle threadPane drag events.
*/
_onDragStart(event) {
const row = event.target.closest(`tr[is^="thread-"]`);
const alreadySelected =
row && threadTree.selectedIndices.includes(row.index);
if (
!row ||
gViewWrapper.isExpandedGroupedByHeaderAtIndex(row.index) ||
(!alreadySelected && (event.ctrlKey || event.shiftKey))
) {
event.preventDefault();
threadTree.ensureCorrectFocus();
return;
}
if (!alreadySelected) {
threadTree.selectedIndex = row.index;
}
const messageURIs = gDBView.getURIsForSelection();
let noSubjectString = messengerBundle.GetStringFromName(
"defaultSaveMessageAsFileName"
);
if (noSubjectString.endsWith(".eml")) {
noSubjectString = noSubjectString.slice(0, -4);
}
const 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;
const maxCutNameLength = maxUncutNameLength - longSubjectTruncator.length;
const messages = new Map();
for (const [index, uri] of Object.entries(messageURIs)) {
const msgService = MailServices.messageServiceFromURI(uri);
const 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();
// @see https://github.com/eslint/eslint/issues/17807
// eslint-disable-next-line no-constant-condition
while (true) {
if (!messages.has(msgFileNameLowerCase)) {
messages.set(msgFileNameLowerCase, 1);
break;
} else {
const number = messages.get(msgFileNameLowerCase);
messages.set(msgFileNameLowerCase, number + 1);
const postfix = "-" + number;
msgFileName = msgFileName + postfix;
msgFileNameLowerCase = msgFileNameLowerCase + postfix;
}
}
msgFileName = msgFileName + ".eml";
// When dragging messages to the filesystem:
// - Windows fetches application/x-moz-file-promise-url and writes it to
// a file.
// - Linux uses the flavor data provider, if a single message is dragged.
// If multiple messages are dragged AND text/x-moz-url exists, it
// fetches application/x-moz-file-promise-url and writes it to a file.
// - MacOS always uses the flavor data provider.
// text/plain 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);
const msgUrlSpec = msgService.getUrlForUri(uri).spec;
event.dataTransfer.mozSetDataAt("text/x-moz-url", msgUrlSpec, index);
event.dataTransfer.mozSetDataAt(
"application/x-moz-file-promise-url",
msgUrlSpec,
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";
const 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";
const types = Array.from(event.dataTransfer.mozTypesAt(0));
const targetFolder = gFolder;
if (types.includes("application/x-moz-file")) {
if (targetFolder.isServer || !targetFolder.canFileMessages) {
return;
}
for (let i = 0; i < event.dataTransfer.mozItemCount; i++) {
const 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++) {
const 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
);
}
}
},
/**
* Handle threadPane drag end events.
*/
_onDragEnd(event) {
if (event.dataTransfer.dropEffect != "none") {
return;
}
folderPane._timedExpand();
folderPane._collapseAutoExpandedRows();
},
_onContextMenu(event, retry = false) {
let row =
event.target.closest(`tr[is^="thread-"]`) ||
threadTree.getRowAtIndex(threadTree.currentIndex);
const isRightClick = event.button == 2;
if (!isRightClick) {
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 after the scroll happens.
if (!row && !retry) {
threadTree.addEventListener(
"scroll",
() => this._onContextMenu(event, true),
{ once: true }
);
return;
}
}
}
if (!row || gDBView.getFlagsAt(row.index) & MSG_VIEW_FLAG_DUMMY) {
return;
}
mailContextMenu.setAsThreadPaneContextMenu();
const popup = document.getElementById("mailContext");
if (isRightClick) {
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 if (event.target.closest(".tree-button-more")) {
const moreBtn = event.target.closest(".tree-button-more");
popup.openPopup(moreBtn, "after_end", 0, 0, true);
} else {
popup.openPopup(row, "after_end", 0, 0, true);
}
event.preventDefault();
},
_flavorDataProvider: {
QueryInterface: ChromeUtils.generateQI(["nsIFlavorDataProvider"]),
getFlavorData(transferable, flavor) {
if (flavor !== "application/x-moz-file-promise") {
return;
}
const fileName = {};
transferable.getTransferData(
"application/x-moz-file-promise-dest-filename",
fileName
);
fileName.value.QueryInterface(Ci.nsISupportsString);
const destDir = {};
transferable.getTransferData(
"application/x-moz-file-promise-dir",
destDir
);
destDir.value.QueryInterface(Ci.nsIFile);
const file = destDir.value.clone();
file.append(fileName.value.data);
const 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: 0,
beginUpdateBatch() {
this._inBatch++;
},
endUpdateBatch() {
this._inBatch--;
if (this._inBatch < 0) {
this._inBatch = 0;
console.warn("Mismatch in batch processing detected.");
}
},
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 (const { color, key } of MailServices.tags.getAllTags()) {
if (!color) {
continue;
}
const selector = MailServices.tags.getSelectorForKey(key);
const contrast = TagUtils.isColorContrastEnough(color)
? "black"
: "white";
this.tagStyle.sheet.insertRule(
`tr[data-properties~="${selector}"] {
--tag-color: ${color};
--tag-contrast-color: ${contrast};
}`
);
document.body.style.setProperty(`--tag-${key}-backcolor`, color);
document.body.style.setProperty(`--tag-${key}-forecolor`, contrast);
}
},
/**
* Make the list rows density aware.
*/
async densityChange() {
// The class ThreadRow can't be referenced because it's declared in a
// different scope. But we can get it from customElements.
const rowClass = customElements.get("thread-row");
const cardClass = customElements.get("thread-card");
const currentFontSize = UIFontSize.size;
// subject line-height * this.rowCount * current font-size.
const cardRowConstant = Math.round(1.5 * this.rowCount * currentFontSize);
let rowHeight = Math.ceil(currentFontSize * 1.4);
let lineGap;
let densityPaddingConstant;
let cardRowHeight;
switch (UIDensity.prefValue) {
case UIDensity.MODE_COMPACT:
// Calculation based on card components:
lineGap = 1;
densityPaddingConstant = 3; // card padding-block + 2 * row padding-block
cardRowHeight =
cardRowConstant + lineGap * this.rowCount + densityPaddingConstant;
break;
case UIDensity.MODE_TOUCH:
rowHeight = rowHeight + 13;
lineGap = 6;
densityPaddingConstant = 12; // card padding-block + 2 * row padding-block
cardRowHeight =
cardRowConstant + lineGap * this.rowCount + densityPaddingConstant;
break;
default:
rowHeight = rowHeight + 7;
lineGap = 3;
densityPaddingConstant = 7; // card padding-block + 2 * row padding-block
cardRowHeight =
cardRowConstant + lineGap * this.rowCount + densityPaddingConstant;
break;
}
cardClass.ROW_HEIGHT = Math.max(cardRowHeight, 40);
rowClass.ROW_HEIGHT = Math.max(rowHeight, 18);
},
/**
* Update thread item size in DOM (thread cards and rows).
*/
async updateThreadItemSize() {
threadTree.classList.toggle("cards-row-compact", this.rowCount === 2);
await this.densityChange();
threadTree.reset();
},
/**
* Gets the key to use for storing the selection in `_savedSelections` or for
* retrieving it.
*
* @returns {string?} - A string to use as a key, or null. If null, the
* selection should not be saved.
*/
_getSavedSelectionKey() {
// Synthetic views never share an about:3pane with other views, so it's
// safe to use any key here.
if (gViewWrapper?.isSynthetic) {
return "synthetic";
}
if (gFolder && gDBView) {
return gFolder.URI;
}
return null;
},
/**
* Store the current thread tree selection.
*/
saveSelection() {
const selectionKey = this._getSavedSelectionKey();
if (!selectionKey) {
return;
}
const currentIndex = threadTree.currentIndex;
let currentUri = null;
if (
currentIndex != -1 &&
currentIndex < gDBView.rowCount &&
!gViewWrapper.isGroupedByHeaderAtIndex(currentIndex)
) {
currentUri = gDBView.getURIForViewIndex(threadTree.currentIndex);
}
this._savedSelections.set(selectionKey, {
currentUri,
// 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.
selectedUris: threadTree.selectedIndices
.filter(i => !gViewWrapper.isGroupedByHeaderAtIndex(i))
.map(gDBView.getURIForViewIndex),
rowCount: gDBView.rowCount,
});
},
/**
* 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} [selectionKey] - A folder's URI if given, or whatever is
* currently being displayed.
*/
forgetSavedSelection(selectionKey = this._getSavedSelectionKey()) {
this._savedSelections.delete(selectionKey);
},
/**
* Restore the previously saved thread tree selection.
*
* @param {object} [options={}] - Options.
* @param {boolean} [options.discard=true] - If false, the selection data is
* kept for another call of this function.
* @param {boolean} [options.notify=true] - Whether a change in "select" event
* should be fired and the current index should be scrolled into view.
* @param {boolean} [options.expand=true] - Try to expand threads containing
* selected messages.
*/
restoreSelection({ discard = true, notify = true, expand = true } = {}) {
const selectionKey = this._getSavedSelectionKey();
if (
!selectionKey ||
!this._savedSelections.has(selectionKey) ||
!threadTree.view
) {
return;
}
// Remember what was selected before restoring the selection.
const indicesBefore = threadTree.selectedIndices;
// Ignore any updates from the gDBView caused by findIndexForMsgURI
// expanding threads.
this._jsTree.beginUpdateBatch();
const selection = this._savedSelections.get(selectionKey);
const currentIndex = selection.currentUri
? gDBView.findIndexForMsgURI(selection.currentUri, expand)
: nsMsgViewIndex_None;
const indices = new Set(
selection.selectedUris
.map(uri => gDBView.findIndexForMsgURI(uri, expand))
.filter(i => i != nsMsgViewIndex_None)
);
// Set the selection and stop ignoring updates.
threadTree.setSelectedIndices(indices.values(), true);
this._jsTree.endUpdateBatch();
// If any of these conditions are true, the selection changed. If not,
// the selection didn't change. Don't tell the tree about it, and
// definitely don't fire a "select" event and cause any selected message
// to be reloaded (again).
const selectionDidChange =
gDBView.rowCount != selection.rowCount ||
indices.size != indicesBefore.length ||
indicesBefore.some(i => !indices.has(i));
this._selectionIsBeingRestored = true;
threadTree.onSelectionChanged(false, !notify || !selectionDidChange);
this._selectionIsBeingRestored = false;
if (currentIndex == nsMsgViewIndex_None) {
threadTree.currentIndex = -1;
} else if (notify) {
threadTree.style.scrollBehavior = "auto"; // Avoid smooth scroll.
threadTree.currentIndex = currentIndex;
threadTree.style.scrollBehavior = null;
} else {
// Don't scroll at all.
threadTree._selection.currentIndex = currentIndex;
threadTree._updateCurrentIndexClasses();
}
// To avoid problems with restoreThreadState, do not discard any selection
// data until explicitly requested.
if (discard) {
this._savedSelections.delete(selectionKey);
} else {
// Update the count for next time restoreSelection is called.
selection.rowCount = gDBView.rowCount;
}
},
/**
* 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();
}
},
/**
* Set the correct style attributes in the threadTree and, if setState
* is true, restore the collapsed or expanded state of threads that is being
* held in gViewWrapper._threadExpandAll.
*
* @param {boolean} [setState=true] - Actually set the collapsed/expanded
* state.
*/
restoreThreadState(setState = true) {
// Early return if the view is not available, eg. in multiselection.
if (!gViewWrapper) {
return;
}
if (setState) {
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);
}
}
threadTree.dataset.showGroupedBySort = gViewWrapper.showGroupedBySort;
},
/**
* Restore the chevron icon indicating the current sort order.
*/
restoreSortIndicator() {
if (!gViewWrapper?.dbView) {
return;
}
this.updateSortIndicator(gViewWrapper.primarySortColumnId);
},
/**
* 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 = ThreadPaneColumns.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 = ThreadPaneColumns.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;
});
},
makeCustomColumnCell(column) {
if (!column?.custom) {
throw new Error(`Not a custom column: ${column?.id}`);
}
const cell = document.createElement("td");
const columnName = column.id.toLowerCase();
cell.classList.add(`${columnName}-column`);
// Default columns have this hardcoded in about3Pane.xhtml.
cell.dataset.columnName = columnName;
if (column.icon && column.iconCellDefinitions) {
cell.classList.add("button-column");
// Add predefined icons for custom icon columns.
for (const { id, url, title, alt } of column.iconCellDefinitions) {
const img = document.createElement("img");
img.dataset.cellIconId = id;
img.src = url;
img.alt = alt || "";
img.title = title || "";
img.hidden = true;
cell.appendChild(img);
}
}
return cell;
},
/**
* 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");
for (const customColumn of ThreadPaneColumns.getCustomColumns()) {
if (this.columns.find(c => c.id == customColumn.id)) {
this.rowTemplate.content.appendChild(
this.makeCustomColumnCell(customColumn)
);
} else {
this.addCustomColumn(customColumn.id, false);
}
}
}
// Update the row template to match the column properties.
for (const column of this.columns) {
const 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.restoreSortIndicator();
}
this.treeTable.restoreColumnsWidths("messenger");
},
/**
* Restore the default columns visibility and order and save the change.
*/
restoreDefaultColumns() {
this.columns = ThreadPaneColumns.getDefaultColumns(
gFolder,
gViewWrapper?.isSynthetic
);
this.cardColumns = ThreadPaneColumns.getDefaultColumnsForCardsView(gFolder);
this.updateClassList();
this.updateColumns();
threadTree.reset();
this.persistColumnStates();
},
/**
* Adds a custom column to the thread pane.
*
* @param {string} columnID - Unique id of the custom column.
* @param {boolean} [update=true] - If the thread tree should be updated
* as a result of this function.
*/
addCustomColumn(columnID, update = true) {
const column = ThreadPaneColumns.getColumn(columnID);
if (this.rowTemplate) {
this.rowTemplate.content.appendChild(this.makeCustomColumnCell(column));
}
this.columns.push(column);
const columnStates =
gFolder?.msgDatabase?.dBFolderInfo?.getCharProperty("columnStates");
if (columnStates) {
this.applyPersistedColumnsState(JSON.parse(columnStates));
}
gViewWrapper?.dbView.addColumnHandler(column.id, column.handler);
if (update && this.rowTemplate) {
// If update is false, we're being called by updateColumns.
// If rowTemplate is falsy, the message list has never loaded and
// updateColumns will be called soon.
this.updateColumns();
this.restoreSortIndicator();
threadTree.reset();
}
},
/**
* Removes a custom column from the thread pane.
*
* @param {string} columnID - uniqe id of the custom column
*/
onCustomColumnRemoved(columnID) {
if (this.rowTemplate) {
this.rowTemplate.content
.querySelector(`td.${columnID.toLowerCase()}-column`)
?.remove();
}
this.columns = this.columns.filter(column => column.id != columnID);
this.updateColumns();
gViewWrapper?.dbView.removeColumnHandler(columnID);
threadTree.reset();
},
/**
* 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);
const 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((col, index) => {
col.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();
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) {
const column = data.value;
const checked = data.target.hasAttribute("checked");
const 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() {
const newState = {};
for (const column of this.columns) {
newState[column.id] = {
visible: !column.hidden,
ordinal: column.ordinal,
};
}
if (gViewWrapper.isSynthetic) {
const 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 curSortColumnId = gViewWrapper.primarySortColumnId;
const newSortColumnId = data.column;
// A click happened on the column that is already used to sort the list.
if (curSortColumnId == newSortColumnId) {
if (gViewWrapper.isSortedAscending) {
sortController.sortDescending();
} else {
sortController.sortAscending();
}
this.updateSortIndicator(newSortColumnId);
return;
}
if (sortController.sortThreadPane(newSortColumnId)) {
this.updateSortIndicator(newSortColumnId);
}
},
/**
* 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");
// The column could be a removed custom column.
if (!column) {
return;
}
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";
const [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.
const 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.
const 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 = ThreadPaneColumns.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 =
ThreadPaneColumns.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 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 confirmApplyView(folder, useChildren = false) {
const msgFluentID = useChildren
? "apply-current-view-to-folder-with-children-message"
: "apply-current-view-to-folder-message";
const [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.
*/
async showIgnoredMessageNotification(messages, subthreadOnly) {
const threadIds = new Set();
messages.forEach(function (msg) {
if (!threadIds.has(msg.threadId)) {
threadIds.add(msg.threadId);
}
});
const buttons = [
{
label: messengerBundle.GetStringFromName("learnMoreAboutIgnoreThread"),
accessKey: messengerBundle.GetStringFromName(
"learnMoreAboutIgnoreThreadAccessKey"
),
popup: null,
callback() {
const 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() {
messages.forEach(function (msg) {
const msgDb = msg.folder.msgDatabase;
if (subthreadOnly) {
msgDb.markKilled(msg.messageKey, false, null);
} else if (threadIds.has(msg.threadId)) {
const 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) {
const ignoredThreadText = messengerBundle.GetStringFromName(
!subthreadOnly ? "ignoredThreadFeedback" : "ignoredSubthreadFeedback"
);
let subj = messages[0].mime2DecodedSubject || "";
if (subj.length > 45) {
subj = subj.substring(0, 45) + "…";
}
const text = ignoredThreadText.replace("#1", subj);
await this.notificationBox.appendNotification(
"ignoreThreadInfo",
{
label: text,
priority: this.notificationBox.PRIORITY_INFO_MEDIUM,
},
buttons
);
} else {
const ignoredThreadText = messengerBundle.GetStringFromName(
!subthreadOnly ? "ignoredThreadsFeedback" : "ignoredSubthreadsFeedback"
);
const { PluralForm } = ChromeUtils.importESModule(
"resource:///modules/PluralForm.sys.mjs"
);
const text = PluralForm.get(threadIds.size, ignoredThreadText).replace(
"#1",
threadIds.size
);
await 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.
*/
updateThreadView() {
switch (Services.prefs.getIntPref("mail.threadpane.listview", 0)) {
case 1:
// Table view.
threadTree.setAttribute("rows", "thread-row");
threadTree.headerHidden = false;
break;
case 0:
default:
// Cards view.
threadTree.setAttribute("rows", "thread-card");
threadTree.headerHidden = true;
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" : "treegrid"
);
if (isListbox) {
threadTree.table.header.classList.remove("threaded");
} else {
threadTree.table.header.classList.add("threaded");
}
},
};
/**
* Restore the UI to the given state.
*
* @param {object} [options={}] - Options.
* @param {boolean} options.folderPaneVisible - Whether to show the folder pane.
* If undefined, the folder pane is shown if a folder URI is provided or we're
* not restoring to a synthetic view.
* @param {boolean} options.messagePaneVisible - Whether to show the message
* pane. If undefined, the message pane is shown as long as its wrapper is
* not collapsed.
* @param {?nsIMsgFolder|string} options.folderURI - The folder to display,
* or its URI, if any.
* @param {?GlodaSyntheticView} options.syntheticView - The synthetic view to
* restore to, if any.
* @param {boolean} options.first - Whether this is the first call to this
* function (i.e. we're setting the state at the start of the application),
* in which case we want to greet the user with the start page.
* @param {?string} options.title - If any, the title to set.
*/
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 = !XULStoreUtils.isItemCollapsed(
"messenger",
"messagepaneboxwrapper"
);
}
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) {
const 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;
threadPane.restoreSortIndicator();
threadPaneHeader.onFolderSelected();
window.dispatchEvent(
new CustomEvent("folderURIChanged", { bubbles: true })
);
}
if (
first &&
messagePaneVisible &&
Services.prefs.getBoolPref("mailnews.start_page.enabled")
) {
messagePane.showStartPage();
}
}
/**
* Ensures the given row is visible and all its parent folders are expanded.
*
* @param {FolderTreeRow} row
*/
function ensureFolderTreeRowIsVisible(row) {
let collapsedAncestor = row.parentNode.closest("#folderTree li.collapsed");
while (collapsedAncestor) {
folderTree.expandRow(collapsedAncestor);
collapsedAncestor = collapsedAncestor.parentNode.closest(
"#folderTree li.collapsed"
);
}
}
/**
* 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) {
const 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;
}
const row = folderPane.getRowForFolder(folderURI);
if (!row) {
return;
}
ensureFolderTreeRowIsVisible(row);
folderTree.updateSelection(row);
}
/**
* Update the thread pane selection if it doesn't already match `msgHdr`.
* If necessary, the selected folder will be changed and/or the Quick Filter
* will be cleared. 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;
const foundIndexOfMsgHdrInView = () => {
index = gDBView?.findIndexOfMsgHdr(msgHdr, true);
return index != undefined && index != nsMsgViewIndex_None;
};
if (!foundIndexOfMsgHdrInView()) {
if (gFolder && gFolder.URI == msgHdr.folder.URI) {
// The message might not match the current Quick Filter term.
goDoCommand("cmd_resetQuickFilterBar");
if (!foundIndexOfMsgHdrInView()) {
return;
}
} else {
threadPane.forgetSavedSelection(msgHdr.folder.URI);
displayFolder(msgHdr.folder.URI);
if (!foundIndexOfMsgHdrInView()) {
// Quick Filter might be in sticky mode and still active.
goDoCommand("cmd_resetQuickFilterBar");
if (!foundIndexOfMsgHdrInView()) {
return;
}
}
}
threadTree.scrollToIndex(index, true);
}
threadTree.selectedIndex = index;
}
var folderListener = {
QueryInterface: ChromeUtils.generateQI(["nsIFolderListener"]),
onFolderAdded(parentFolder, childFolder) {
folderPane.setSortOrderOnNewFolder(parentFolder, childFolder);
folderPane.addFolder(parentFolder, childFolder);
folderPane.updateFolderRowUIElements();
},
onMessageAdded() {},
onFolderRemoved(parentFolder, childFolder) {
// Check if the folder is in the selection range before we remove it.
const row = folderPane.getRowForFolder(childFolder.URI);
const notInRange = !folderTree.selection.has(folderTree.rows.indexOf(row));
folderPane.removeFolder(parentFolder, childFolder);
if (childFolder == gFolder) {
// Clean up the display if the deleted folder was being displayed. At this
// point, `DBViewWrapper._folderDeleted` has already cleaned up `gDBView`.
gFolder = null;
gViewWrapper?.close(true);
threadPaneHeader.onFolderSelected();
threadPane._onSelect(); // Ensure no message is displayed.
}
// We need to rebuild the selection map if a folder was removed while we had
// multiple folders selected and it wasn't part of the selection range, to
// ensure the indices match the rows.
if (folderTree.selection.size > 1 && notInRange) {
// Wrap this in a timeout to ensure we don't get stale values from a
// selection that still carries deleted rows.
setTimeout(() => {
folderTree.swapSelection([...folderTree.selection.values()]);
});
}
},
onMessageRemoved() {
if (gViewWrapper?.isSynthetic) {
window.threadPaneHeader.updateMessageCount(gDBView.numMsgsInView);
}
},
onFolderPropertyChanged(folder, property, oldValue, newValue) {
switch (property) {
case "Name":
if (folder.isServer) {
folderPane.changeServerName(folder, newValue);
}
break;
}
},
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);
if (gFolder && folder?.URI == gFolder.URI) {
threadPaneHeader.updateMessageCount(newValue);
}
break;
}
},
onFolderBoolPropertyChanged(folder, property, oldValue, newValue) {
switch (property) {
case "isDeferred":
if (newValue) {
folderPane.removeFolder(null, folder);
} else {
folderPane.addFolder(null, folder);
for (const f of folder.descendants) {
folderPane.addFolder(f.parent, f);
}
}
break;
case "NewMessages":
folderPane.changeNewMessages(folder, newValue);
break;
}
},
onFolderPropertyFlagChanged() {},
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 (const f of folder.descendants) {
folderPane.addFolder(f.parent, f);
}
}
},
};
/* Commands Controller */
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) => {
if (folder) {
folderPane.deleteFolder(folder);
return;
}
// gFolder is not defined and the folder is null, which means a DELETE
// keyboard shortcut was triggered for a multiselection. Loop through
// all currently selected folders and delete them.
for (const row of folderTree.selection.values()) {
folder = MailServices.folderLookup.getFolderForURL(row.uri);
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);
return;
}
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_threadPaneViewCards", () => {
Services.prefs.setIntPref("mail.threadpane.listview", 0);
});
commandController.registerCallback("cmd_threadPaneViewTable", () => {
Services.prefs.setIntPref("mail.threadpane.listview", 1);
});
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 && !gViewWrapper.showGroupedBySort
);
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.numSelected > 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:
{
const column = threadPane.columns.find(
c => c.id == event.target.value
);
if (column && this.sortThreadPane(column.id)) {
threadPane.restoreSortIndicator();
}
}
break;
}
},
sortByThread() {
threadPane.updateListRole(false);
gViewWrapper.showThreaded = true;
this.sortThreadPane("dateCol");
},
/**
* Sorts the thread pane by the provided columnId.
*
* @param {string} newSortColumnId
* @returns {boolean} if sorting was successful
*/
sortThreadPane(newSortColumnId) {
const newSortColumn = threadPane.columns.find(
c => c.sortKey && c.id == newSortColumnId
);
if (!newSortColumn) {
return false;
}
const newSortType = Ci.nsMsgViewSortType[newSortColumn.sortKey];
const grouped = gViewWrapper.showGroupedBySort;
gViewWrapper._threadExpandAll = Boolean(
gViewWrapper._viewFlags & Ci.nsMsgViewFlagsType.kExpandAll
);
if (!grouped) {
threadTree.style.scrollBehavior = "auto"; // Avoid smooth scroll.
gViewWrapper.sort(newSortColumnId, Ci.nsMsgViewSortOrder.ascending);
threadTree.style.scrollBehavior = null;
// Respect user's last expandAll/collapseAll choice, post sort direction change.
threadPane.restoreThreadState();
return true;
}
// 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 = [
[newSortType, Ci.nsMsgViewSortOrder.ascending, newSortColumnId],
];
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;
}
return true;
},
reverseSortThreadPane() {
const 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.search.hasSearchTerms) {
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.isSingleFolder &&
(gViewWrapper.isVirtual || gViewWrapper.search.hasSearchTerms)
) {
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;
threadPane.restoreThreadState(!gViewWrapper.isSingleFolder);
},
groupBySort() {
threadPane.updateListRole(false);
// Similar to reverting grouped-by-sort in this.sortThreadPane(), rebuild
// the view even for multi-folder search views. These views could
// technically handle this themselves by just having their view flags set,
// but they are currently unable to cope with sort types that are invalid
// in grouped-by-sort (such as bySize).
gViewWrapper.beginViewUpdate();
gViewWrapper.showGroupedBySort = true;
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;
}
threadPane.restoreThreadState(!gViewWrapper.isSingleFolder);
},
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;
},
};
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;
}
if (gViewWrapper.specialView) {
// Switching to a special view resets all search terms, so we need to
// reflect this in the quick filter bar.
goDoCommand("cmd_resetQuickFilterBar");
}
}
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 () => {
const PrintUtils = top.PrintUtils;
if (messagePane.isWebBrowserVisible()) {
PrintUtils.startPrintWindow(webBrowser.browsingContext);
return;
}
const uris = gViewWrapper.dbView.getURIsForSelection();
if (uris.length == 1) {
if (!messagePane.isMessageBrowserVisible()) {
// Load the only message in a hidden browser, then use the print preview UI.
const messageService = MailServices.messageServiceFromURI(uris[0]);
await PrintUtils.loadPrintBrowser(
messageService.getUrlForUri(uris[0]).spec
);
PrintUtils.startPrintWindow(
PrintUtils.printBrowser.browsingContext,
{}
);
return;
}
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.
const 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 (const uri of uris) {
const messageService = MailServices.messageServiceFromURI(uri);
await PrintUtils.loadPrintBrowser(messageService.getUrlForUri(uri).spec);
await PrintUtils.printBrowser.browsingContext.print(ps);
}
},
() => {
if (!accountCentralBrowser?.hidden) {
return false;
}
if (messagePane.isWebBrowserVisible()) {
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();
const folder =
gViewWrapper.isVirtual && gViewWrapper.isSingleFolder
? gViewWrapper._underlyingFolders[0]
: gFolder;
if (
!folder.msgDatabase.isIgnored(
gDBView.hdrForFirstSelectedMessage?.messageKey
)
) {
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.isMultiFolder &&
!gViewWrapper.showGroupedBySort
);
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.isMultiFolder &&
!gViewWrapper?.showGroupedBySort
);
/* Forward find commands to about:message if message view is open, otherwise
* create (if not already created) findbars for web and multi message view
* and call the attached find commands. We create the findbars inline here
* because adding them to the HTML initializes and additional Finder, which
* the findbar then uses, but doesn't attach any event listeners to. This
* causes the findbar to not update with a result status properly. */
commandController.registerCallback(
"cmd_find",
() => messagePane.onFindCommand(),
() => messagePane.browserPaneVisible()
);
commandController.registerCallback(
"cmd_findAgain",
() => messagePane.onFindAgainCommand(),
() => messagePane.browserPaneVisible()
);
commandController.registerCallback(
"cmd_findPrevious",
() => messagePane.onFindPreviousCommand(),
() => messagePane.browserPaneVisible()
);
// Zoom.
commandController.registerCallback(
"cmd_fullZoomReduce",
() => top.ZoomManager.reduce(messagePane.visibleMessagePaneBrowser()),
() => !!messagePane.visibleMessagePaneBrowser()
);
commandController.registerCallback(
"cmd_fullZoomEnlarge",
() => top.ZoomManager.enlarge(messagePane.visibleMessagePaneBrowser()),
() => !!messagePane.visibleMessagePaneBrowser()
);
commandController.registerCallback(
"cmd_fullZoomReset",
() => top.ZoomManager.reset(messagePane.visibleMessagePaneBrowser()),
() => !!messagePane.visibleMessagePaneBrowser()
);
commandController.registerCallback(
"cmd_fullZoomToggle",
() => top.ZoomManager.toggleZoom(messagePane.visibleMessagePaneBrowser()),
() => !!messagePane.visibleMessagePaneBrowser()
);
// 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 (const command of [
"cmd_openAllAttachments",
"cmd_saveAllAttachments",
"cmd_detachAllAttachments",
"cmd_deleteAllAttachments",
]) {
commandController.registerCallback(
command,
() => messagePane.doMessageBrowserCommand(command),
() =>
messagePane.isMessageBrowserVisible() &&
messagePane.isMessageBrowserCommandEnabled(command)
);
}