summaryrefslogtreecommitdiffstats
path: root/comm/mail/base/content/folderDisplay.js
diff options
context:
space:
mode:
Diffstat (limited to 'comm/mail/base/content/folderDisplay.js')
-rw-r--r--comm/mail/base/content/folderDisplay.js2649
1 files changed, 2649 insertions, 0 deletions
diff --git a/comm/mail/base/content/folderDisplay.js b/comm/mail/base/content/folderDisplay.js
new file mode 100644
index 0000000000..dfd5824c6b
--- /dev/null
+++ b/comm/mail/base/content/folderDisplay.js
@@ -0,0 +1,2649 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from SearchDialog.js */
+
+/* globals ViewPickerBinding */ // From msgViewPickerOverlay.js
+
+/* TODO: Now used exclusively in SearchDialog.xhtml. Needs dead code removal. */
+
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ TreeSelection: "chrome://messenger/content/tree-selection.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ DBViewWrapper: "resource:///modules/DBViewWrapper.jsm",
+});
+
+var gDBView;
+var nsMsgKey_None = 0xffffffff;
+var nsMsgViewIndex_None = 0xffffffff;
+
+/**
+ * Maintains a list of listeners for all FolderDisplayWidget instances in this
+ * window. The assumption is that because of our multiplexed tab
+ * implementation all consumers are effectively going to care about all such
+ * tabs.
+ *
+ * We are not just a global list so that we can add brains about efficiently
+ * building lists, provide try-wrapper convenience, etc.
+ */
+var FolderDisplayListenerManager = {
+ _listeners: [],
+
+ /**
+ * Register a listener that implements one or more of the methods defined on
+ * |IDBViewWrapperListener|. Note that a change from those interface
+ * signatures is that the first argument is always a reference to the
+ * FolderDisplayWidget generating the notification.
+ *
+ * We additionally support the following notifications:
+ * - onMakeActive. Invoked when makeActive is called on the
+ * FolderDisplayWidget. The second argument (after the folder display) is
+ * aWasInactive.
+ *
+ * - onActiveCreatedView. onCreatedView deferred to when the tab is actually
+ * made active.
+ *
+ * - onActiveMessagesLoaded. onMessagesLoaded deferred to when the
+ * tab is actually made active. Use this if the actions you need to take
+ * are based on the folder display actually being visible, such as updating
+ * some UI widget, etc. Not all messages may have been loaded, but some.
+ *
+ */
+ registerListener(aListener) {
+ this._listeners.push(aListener);
+ },
+
+ /**
+ * Unregister a previously registered event listener.
+ */
+ unregisterListener(aListener) {
+ let idx = this._listeners.indexOf(aListener);
+ if (idx >= 0) {
+ this._listeners.splice(idx, 1);
+ }
+ },
+
+ /**
+ * For use by FolderDisplayWidget to trigger listener invocation.
+ */
+ _fireListeners(aEventName, aArgs) {
+ for (let listener of this._listeners) {
+ if (aEventName in listener) {
+ try {
+ listener[aEventName].apply(listener, aArgs);
+ } catch (e) {
+ console.error(
+ aEventName + " event listener FAILED; " + e + " at: " + e.stack
+ );
+ }
+ }
+ }
+ },
+};
+
+/**
+ * Abstraction for a widget that (roughly speaking) displays the contents of
+ * folders. The widget belongs to a tab and has a lifetime as long as the tab
+ * that contains it. This class is strictly concerned with the UI aspects of
+ * this; the DBViewWrapper class handles the view details (and is exposed on
+ * the 'view' attribute.)
+ *
+ * The search window subclasses this into the SearchFolderDisplayWidget rather
+ * than us attempting to generalize everything excessively. This is because
+ * we hate the search window and don't want to clutter up this code for it.
+ * The standalone message display window also subclasses us; we do not hate it,
+ * but it's not invited to our birthday party either.
+ * For reasons of simplicity and the original order of implementation, this
+ * class does alter its behavior slightly for the benefit of the standalone
+ * message window. If no tab info is provided, we avoid touching tabmail
+ * (which is good, because it won't exist!) And now we guard against treeBox
+ * manipulations...
+ */
+function FolderDisplayWidget() {
+ // If the folder does not get handled by the DBViewWrapper, stash it here.
+ // ex: when isServer is true.
+ this._nonViewFolder = null;
+
+ this.view = new DBViewWrapper(this);
+
+ /**
+ * The XUL tree node, as retrieved by getDocumentElementById. The caller is
+ * responsible for setting this.
+ */
+ this.tree = null;
+
+ /**
+ * The nsIMsgWindow corresponding to the window that holds us. There is only
+ * one of these per tab. The caller is responsible for setting this.
+ */
+ this.msgWindow = null;
+ /**
+ * The nsIMessenger instance that corresponds to our tab/window. We do not
+ * use this ourselves, but are responsible for using it to update the
+ * global |messenger| object so that our tab maintains its own undo and
+ * navigation history. At some point we might touch it for those reasons.
+ */
+ this.messenger = null;
+ this.threadPaneCommandUpdater = this;
+
+ /**
+ * Flag to expose whether all messages are loaded or not. Set by
+ * onMessagesLoaded() when aAll is true.
+ */
+ this._allMessagesLoaded = false;
+
+ /**
+ * Save the top row displayed when we go inactive, restore when we go active,
+ * nuke it when we destroy the view.
+ */
+ this._savedFirstVisibleRow = null;
+ /** the next view index to select once the delete completes */
+ this._nextViewIndexAfterDelete = null;
+ /**
+ * Track when a mass move is in effect (we get told by hintMassMoveStarting,
+ * and hintMassMoveCompleted) so that we can avoid deletion-triggered
+ * moving to _nextViewIndexAfterDelete until the mass move completes.
+ */
+ this._massMoveActive = false;
+ /**
+ * Track when a message is being deleted so we can respond appropriately.
+ */
+ this._deleteInProgress = false;
+
+ /**
+ * Used by pushNavigation to queue a navigation request for when we enter the
+ * next folder; onMessagesLoaded(true) is the one that processes it.
+ */
+ this._pendingNavigation = null;
+
+ this._active = false;
+ /**
+ * A list of methods to call on 'this' object when we are next made active.
+ * This list is populated by calls to |_notifyWhenActive| when we are
+ * not active at the moment.
+ */
+ this._notificationsPendingActivation = [];
+
+ this._mostRecentSelectionCounts = [];
+ this._mostRecentCurrentIndices = [];
+}
+FolderDisplayWidget.prototype = {
+ /**
+ * @returns the currently displayed folder. This is just proxied from the
+ * view wrapper.
+ * @groupName Displayed
+ */
+ get displayedFolder() {
+ return this._nonViewFolder || this.view.displayedFolder;
+ },
+
+ /**
+ * @returns true if the selection should be summarized for this folder. This
+ * is based on the mail.operate_on_msgs_in_collapsed_threads pref and
+ * if we are in a newsgroup folder. XXX When bug 478167 is fixed, this
+ * should be limited to being disabled for newsgroups that are not stored
+ * offline.
+ */
+ get summarizeSelectionInFolder() {
+ return (
+ Services.prefs.getBoolPref("mail.operate_on_msgs_in_collapsed_threads") &&
+ !(this.displayedFolder instanceof Ci.nsIMsgNewsFolder)
+ );
+ },
+
+ /**
+ * @returns the nsITreeSelection object for our tree view. This exists for
+ * the benefit of message tabs that haven't been switched to yet.
+ * We provide a fake tree selection in those cases.
+ * @protected
+ */
+ get treeSelection() {
+ // If we haven't switched to this tab yet, dbView will exist but
+ // dbView.selection won't, so use the fake tree selection instead.
+ if (this.view.dbView) {
+ return this.view.dbView.selection;
+ }
+ return null;
+ },
+
+ /**
+ * Determine which pane currently has focus (one of the folder pane, thread
+ * pane, or message pane). The message pane node is the common ancestor of
+ * the single- and multi-message content windows. When changing focus to the
+ * message pane, be sure to focus the appropriate content window in addition
+ * to the messagepanebox (doing both is required in order to blur the
+ * previously-focused chrome element).
+ *
+ * @returns the focused pane
+ */
+ get focusedPane() {
+ let panes = ["threadTree", "folderTree", "messagepanebox"].map(id =>
+ document.getElementById(id)
+ );
+
+ let currentNode = top.document.activeElement;
+
+ while (currentNode) {
+ if (panes.includes(currentNode)) {
+ return currentNode;
+ }
+
+ currentNode = currentNode.parentNode;
+ }
+ return null;
+ },
+
+ /**
+ * Number of headers to tell the message database to cache when we enter a
+ * folder. This value is being propagated from legacy code which provided
+ * no explanation for its choice.
+ *
+ * We definitely want the header cache size to be larger than the number of
+ * rows that can be displayed on screen simultaneously.
+ *
+ * @private
+ */
+ PERF_HEADER_CACHE_SIZE: 100,
+
+ /**
+ * @name Selection Persistence
+ * @private
+ */
+ // @{
+
+ /**
+ * An optional object, with the following properties:
+ * - messages: This is a list where each item is an object with the following
+ * attributes sufficient to re-establish the selected items even in the
+ * face of folder renaming.
+ * - messageId: The value of the message's message-id header.
+ *
+ * That's right, we only save the message-id header value. This is arguably
+ * overkill and ambiguous in the face of duplicate messages, but it's the
+ * most persistent/reliable thing we have without gloda.
+ * Using the view index was ruled out because it is hardly stable. Using the
+ * message key alone is insufficient for cross-folder searches. Using a
+ * folder identifier and message key is insufficient for local folders in the
+ * face of compaction, let alone complexities where the folder name may
+ * change due to renaming/moving. Which means we eventually need to fall
+ * back to message-id anyways. Feel free to add in lots of complexity if
+ * you actually write unit tests for all the many possible cases.
+ * Additional justification is that selection saving/restoration should not
+ * happen all that frequently. A nice freebie is that message-id is
+ * definitely persistable.
+ *
+ * - forceSelect: Whether we are allowed to drop all filters in our quest to
+ * select messages.
+ */
+ _savedSelection: null,
+
+ /**
+ * Save the current view selection for when we the view is getting destroyed
+ * or otherwise re-ordered in such a way that the nsITreeSelection will lose
+ * track of things (because it just has a naive view-index 'view' of the
+ * world.) We just save each message's message-id header. This is overkill
+ * and ambiguous in the face of duplicate messages (and expensive to
+ * restore), but is also the most reliable option for this use case.
+ */
+ _saveSelection() {
+ this._savedSelection = {
+ messages: this.selectedMessages.map(msgHdr => ({
+ messageId: msgHdr.messageId,
+ })),
+ forceSelect: false,
+ };
+ },
+
+ /**
+ * Clear the saved selection.
+ */
+ _clearSavedSelection() {
+ this._savedSelection = null;
+ },
+
+ /**
+ * Restore the view selection if we have a saved selection. We must be
+ * active!
+ *
+ * @returns true if we were able to restore the selection and there was
+ * a selection, false if there was no selection (anymore).
+ */
+ _restoreSelection() {
+ if (!this._savedSelection || !this._active) {
+ return false;
+ }
+
+ // translate message IDs back to messages. this is O(s(m+n)) where:
+ // - s is the number of messages saved in the selection
+ // - m is the number of messages in the view (from findIndexOfMsgHdr)
+ // - n is the number of messages in the underlying folders (from
+ // DBViewWrapper.getMsgHdrForMessageID).
+ // which ends up being O(sn)
+ let messages = this._savedSelection.messages
+ .map(savedInfo => this.view.getMsgHdrForMessageID(savedInfo.messageId))
+ .filter(msgHdr => !!msgHdr);
+
+ this.selectMessages(messages, this._savedSelection.forceSelect, true);
+ this._savedSelection = null;
+
+ return this.selectedCount != 0;
+ },
+
+ /**
+ * Restore the last expandAll/collapseAll state, for both grouped and threaded
+ * views. Not all views respect viewFlags, ie single folder non-virtual.
+ */
+ restoreThreadState() {
+ if (!this._active || !this.tree || !this.view.dbView.viewFolder) {
+ return;
+ }
+
+ if (
+ this.view._threadExpandAll &&
+ !(this.view.dbView.viewFlags & Ci.nsMsgViewFlagsType.kExpandAll)
+ ) {
+ this.view.dbView.doCommand(Ci.nsMsgViewCommandType.expandAll);
+ }
+ if (
+ !this.view._threadExpandAll &&
+ this.view.dbView.viewFlags & Ci.nsMsgViewFlagsType.kExpandAll
+ ) {
+ this.view.dbView.doCommand(Ci.nsMsgViewCommandType.collapseAll);
+ }
+ },
+ // @}
+
+ /**
+ * @name Columns
+ * @protected
+ */
+ // @{
+
+ /**
+ * The map of all stock sortable columns and their sortType. The key must
+ * match the column's xul <treecol> id.
+ */
+ COLUMNS_MAP: new Map([
+ ["accountCol", "byAccount"],
+ ["attachmentCol", "byAttachments"],
+ ["senderCol", "byAuthor"],
+ ["correspondentCol", "byCorrespondent"],
+ ["dateCol", "byDate"],
+ ["flaggedCol", "byFlagged"],
+ ["idCol", "byId"],
+ ["junkStatusCol", "byJunkStatus"],
+ ["locationCol", "byLocation"],
+ ["priorityCol", "byPriority"],
+ ["receivedCol", "byReceived"],
+ ["recipientCol", "byRecipient"],
+ ["sizeCol", "bySize"],
+ ["statusCol", "byStatus"],
+ ["subjectCol", "bySubject"],
+ ["tagsCol", "byTags"],
+ ["threadCol", "byThread"],
+ ["unreadButtonColHeader", "byUnread"],
+ ]),
+
+ /**
+ * The map of stock non-sortable columns. The key must match the column's
+ * xul <treecol> id.
+ */
+ COLUMNS_MAP_NOSORT: new Set([
+ "selectCol",
+ "totalCol",
+ "unreadCol",
+ "deleteCol",
+ ]),
+
+ /**
+ * The set of potential default columns in their default display order. Each
+ * column in this list is checked against |COLUMN_DEFAULT_TESTERS| to see if
+ * it is actually an appropriate default for the folder type.
+ */
+ DEFAULT_COLUMNS: [
+ "threadCol",
+ "attachmentCol",
+ "flaggedCol",
+ "subjectCol",
+ "unreadButtonColHeader",
+ "senderCol", // news folders or incoming folders when correspondents not in use
+ "recipientCol", // outgoing folders when correspondents not in use
+ "correspondentCol", // mail folders
+ "junkStatusCol",
+ "dateCol",
+ "locationCol", // multiple-folder backed folders
+ ],
+
+ /**
+ * Maps column ids to functions that test whether the column is a good default
+ * for display for the folder. Each function should expect a DBViewWrapper
+ * instance as its argument. The intent is that the various helper
+ * properties like isMailFolder/isIncomingFolder/isOutgoingFolder allow the
+ * constraint to be expressed concisely. If a helper does not exist, add
+ * one! (If doing so is out of reach, than access viewWrapper.displayedFolder
+ * to get at the nsIMsgFolder.)
+ * If a column does not have a function, it is assumed that it should be
+ * displayed by default.
+ */
+ COLUMN_DEFAULT_TESTERS: {
+ correspondentCol(viewWrapper) {
+ if (Services.prefs.getBoolPref("mail.threadpane.use_correspondents")) {
+ // Don't show the correspondent for news or RSS where it doesn't make sense.
+ return viewWrapper.isMailFolder && !viewWrapper.isFeedFolder;
+ }
+ return false;
+ },
+ senderCol(viewWrapper) {
+ if (Services.prefs.getBoolPref("mail.threadpane.use_correspondents")) {
+ // Show the sender even if correspondent is enabled for news and feeds.
+ return viewWrapper.isNewsFolder || viewWrapper.isFeedFolder;
+ }
+ // senderCol = From. You only care in incoming folders.
+ return viewWrapper.isIncomingFolder;
+ },
+ recipientCol(viewWrapper) {
+ if (Services.prefs.getBoolPref("mail.threadpane.use_correspondents")) {
+ // No recipient column if we use correspondent.
+ return false;
+ }
+ // recipientCol = To. You only care in outgoing folders.
+ return viewWrapper.isOutgoingFolder;
+ },
+ // Only show the location column for non-single-folder results
+ locationCol(viewWrapper) {
+ return !viewWrapper.isSingleFolder;
+ },
+ // core UI does not provide an ability to mark newsgroup messages as spam
+ junkStatusCol(viewWrapper) {
+ return !viewWrapper.isNewsFolder;
+ },
+ },
+
+ /**
+ * The property name we use to store the column states on the
+ * dbFolderInfo.
+ */
+ PERSISTED_COLUMN_PROPERTY_NAME: "columnStates",
+
+ /**
+ * Given a dbFolderInfo, extract the persisted state from it if there is any.
+ *
+ * @returns null if there was no persisted state, the persisted state in object
+ * form otherwise. (Ideally the state conforms to the documentation on
+ * |_savedColumnStates| but we can't stop people from doing bad things.)
+ */
+ _depersistColumnStatesFromDbFolderInfo(aDbFolderInfo) {
+ let columnJsonString = aDbFolderInfo.getCharProperty(
+ this.PERSISTED_COLUMN_PROPERTY_NAME
+ );
+ if (!columnJsonString) {
+ return null;
+ }
+
+ return JSON.parse(columnJsonString);
+ },
+
+ /**
+ * Persist the column state for the currently displayed folder. We are
+ * assuming that the message database is already open when we are called and
+ * therefore that we do not need to worry about cleaning up after the message
+ * database.
+ * The caller should only call this when they have reason to suspect that the
+ * column state has been changed. This could be because there was no
+ * persisted state so we figured out a default one and want to save it.
+ * Otherwise this should be because the user explicitly changed up the column
+ * configurations. You should not call this willy-nilly.
+ *
+ * @param aState State to persist.
+ */
+ _persistColumnStates(aState) {
+ if (this.view.isSynthetic) {
+ let syntheticView = this.view._syntheticView;
+ if ("setPersistedSetting" in syntheticView) {
+ syntheticView.setPersistedSetting("columns", aState);
+ }
+ return;
+ }
+
+ if (!this.view.displayedFolder || !this.view.displayedFolder.msgDatabase) {
+ return;
+ }
+
+ let msgDatabase = this.view.displayedFolder.msgDatabase;
+ let dbFolderInfo = msgDatabase.dBFolderInfo;
+ dbFolderInfo.setCharProperty(
+ this.PERSISTED_COLUMN_PROPERTY_NAME,
+ JSON.stringify(aState)
+ );
+ msgDatabase.commit(Ci.nsMsgDBCommitType.kLargeCommit);
+ },
+
+ /**
+ * Let us know that the state of the columns has changed. This is either due
+ * to a re-ordering or hidden-ness being toggled.
+ *
+ * This method should only be called on (the active) gFolderDisplay.
+ */
+ hintColumnsChanged() {
+ // ignore this if we are the ones doing things
+ if (this._touchingColumns) {
+ return;
+ }
+ this._persistColumnStates(this.getColumnStates());
+ },
+
+ /**
+ * Either inherit the column state of another folder or use heuristics to
+ * figure out the best column state for the current folder.
+ */
+ _getDefaultColumnsForCurrentFolder(aDoNotInherit) {
+ // If the view is synthetic, try asking it for its default columns. If it
+ // fails, just return nothing, since most synthetic views don't care about
+ // columns anyway.
+ if (this.view.isSynthetic) {
+ if ("getDefaultSetting" in this.view._syntheticView) {
+ return this.view._syntheticView.getDefaultSetting("columns");
+ }
+ return {};
+ }
+
+ // do not inherit from the inbox if:
+ // - It's an outgoing folder; these have a different use-case and there
+ // should be a small number of these, so it's okay to have no defaults.
+ // - It's a virtual folder (single or multi-folder backed). Who knows what
+ // the intent of the user is in this case. This should also be bounded
+ // in number and our default heuristics should be pretty good.
+ // - It's a multiple folder; this is either a search view (which has no
+ // displayed folder) or a virtual folder (which we eliminated above).
+ // - News folders. There is no inbox so there's nothing to inherit from.
+ // (Although we could try and see if they have opened any other news
+ // folders in the same account. But it's not all that important to us.)
+ // - It's an inbox!
+ let doNotInherit =
+ aDoNotInherit ||
+ this.view.isOutgoingFolder ||
+ this.view.isVirtual ||
+ this.view.isMultiFolder ||
+ this.view.isNewsFolder ||
+ this.displayedFolder.getFlag(Ci.nsMsgFolderFlags.Inbox);
+
+ // Try and grab the inbox for this account's settings. we may not be able
+ // to, in which case we just won't inherit. (It ends up the same since the
+ // inbox is obviously not customized in this case.)
+ if (!doNotInherit) {
+ let inboxFolder = this.displayedFolder.rootFolder.getFolderWithFlags(
+ Ci.nsMsgFolderFlags.Inbox
+ );
+ if (inboxFolder) {
+ let state = this._depersistColumnStatesFromDbFolderInfo(
+ inboxFolder.msgDatabase.dBFolderInfo
+ );
+ // inbox message databases don't get closed as a matter of policy.
+
+ if (state) {
+ return state;
+ }
+ }
+ }
+
+ // if we are still here, use the defaults and helper functions
+ let state = {};
+ for (let colId of this.DEFAULT_COLUMNS) {
+ let shouldShowColumn = true;
+ if (colId in this.COLUMN_DEFAULT_TESTERS) {
+ // This is potentially going to be used by extensions; avoid them
+ // killing us.
+ try {
+ shouldShowColumn = this.COLUMN_DEFAULT_TESTERS[colId](this.view);
+ } catch (ex) {
+ shouldShowColumn = false;
+ console.error(ex);
+ }
+ }
+ state[colId] = { visible: shouldShowColumn };
+ }
+ return state;
+ },
+
+ /**
+ * Is setColumnStates messing with the columns' DOM? This is used by
+ * hintColumnsChanged to avoid wasteful state persistence.
+ */
+ _touchingColumns: false,
+
+ /**
+ * Set the column states of this FolderDisplay to the provided state.
+ *
+ * @param aColumnStates an object of the form described on
+ * |_savedColumnStates|. If ordinal attributes are omitted then no
+ * re-ordering will be performed. This is intentional, but potentially a
+ * bad idea. (Right now only gloda search underspecifies ordinals.)
+ * @param [aPersistChanges=false] Should we persist the changes to the view?
+ * This only has an effect if we are active.
+ *
+ * @public
+ */
+ setColumnStates(aColumnStates, aPersistChanges) {
+ // If we are not active, just overwrite our current state with the provided
+ // state and bail.
+ if (!this._active) {
+ this._savedColumnStates = aColumnStates;
+ return;
+ }
+
+ this._touchingColumns = true;
+
+ try {
+ let cols = document.getElementById("threadCols");
+ let colChildren = cols.children;
+
+ for (let iKid = 0; iKid < colChildren.length; iKid++) {
+ let colChild = colChildren[iKid];
+ if (colChild == null) {
+ continue;
+ }
+
+ // We only care about treecols. The splitters do not need to be marked
+ // hidden or un-hidden.
+ if (colChild.tagName == "treecol") {
+ // if it doesn't have preserved state it should be hidden
+ let shouldBeHidden = true;
+ // restore state
+ if (colChild.id in aColumnStates) {
+ let colState = aColumnStates[colChild.id];
+ if ("visible" in colState) {
+ shouldBeHidden = !colState.visible;
+ }
+ if ("ordinal" in colState && colChild.ordinal != colState.ordinal) {
+ colChild.ordinal = colState.ordinal;
+ }
+ }
+ let isHidden = colChild.hidden;
+ if (isHidden != shouldBeHidden) {
+ if (shouldBeHidden) {
+ colChild.setAttribute("hidden", "true");
+ } else {
+ colChild.removeAttribute("hidden");
+ }
+ }
+ }
+ }
+ } finally {
+ this._touchingColumns = false;
+ }
+
+ if (aPersistChanges) {
+ this.hintColumnsChanged();
+ }
+ },
+
+ /**
+ * A dictionary that maps column ids to dictionaries where each dictionary
+ * has the following fields:
+ * - visible: Is the column visible.
+ * - ordinal: The 1-based XUL 'ordinal' value assigned to the column. This
+ * corresponds to the position but is not something you want to manipulate.
+ * See the documentation in _saveColumnStates for more information.
+ */
+ _savedColumnStates: null,
+
+ /**
+ * Return a dictionary in the form of |_savedColumnStates| representing the
+ * current column states.
+ *
+ * @public
+ */
+ getColumnStates() {
+ if (!this._active) {
+ return this._savedColumnStates;
+ }
+
+ let columnStates = {};
+
+ let cols = document.getElementById("threadCols");
+ let colChildren = cols.children;
+ for (let iKid = 0; iKid < colChildren.length; iKid++) {
+ let colChild = colChildren[iKid];
+ if (colChild.tagName != "treecol") {
+ continue;
+ }
+ columnStates[colChild.id] = {
+ visible: !colChild.hidden,
+ ordinal: colChild.ordinal,
+ };
+ }
+
+ return columnStates;
+ },
+
+ /**
+ * For now, just save the visible columns into a dictionary for use in a
+ * subsequent call to |setColumnStates|.
+ */
+ _saveColumnStates() {
+ // In the actual TreeColumn, the index property indicates the column
+ // number. This column number is a 0-based index with no gaps; it only
+ // increments the number each time it sees a column.
+ // However, this is subservient to the 'ordinal' property which
+ // defines the _apparent content sequence_ provided by GetNextSibling.
+ // The underlying content ordering is still the same, which is how
+ // _ensureColumnOrder() can reset things to their XUL definition sequence.
+ // The 'ordinal' stuff works because nsBoxFrame::RelayoutChildAtOrdinal
+ // messes with the sibling relationship.
+ // Ordinals are 1-based. _ensureColumnOrder() apparently is dumb and does
+ // not know this, although the ordering is relative so it doesn't actually
+ // matter. The annoying splitters do have ordinals, and live between
+ // tree columns. The splitters adjacent to a tree column do not need to
+ // have any 'ordinal' relationship, although it would appear user activity
+ // tends to move them around in a predictable fashion with oddness involved
+ // at the edges.
+ // Changes to the ordinal attribute should take immediate effect in terms of
+ // sibling relationship, but will merely invalidate the columns rather than
+ // cause a re-computation of column relationships every time.
+ // _ensureColumnOrder() invalidates the tree when it is done re-ordering;
+ // I'm not sure that's entirely necessary...
+ this._savedColumnStates = this.getColumnStates();
+ },
+
+ /**
+ * Restores the visible columns saved by |_saveColumnStates|.
+ */
+ _restoreColumnStates() {
+ if (this._savedColumnStates) {
+ this.setColumnStates(this._savedColumnStates);
+ this._savedColumnStates = null;
+ }
+ },
+ // @}
+
+ /**
+ * @name What To Display
+ * @protected
+ */
+ // @{
+ showFolderUri(aFolderURI) {
+ return this.show(MailUtils.getExistingFolder(aFolderURI));
+ },
+
+ /**
+ * Invoked by showFolder when it turns out the folder is in fact a server.
+ *
+ * @private
+ */
+ _showServer() {
+ // currently nothing to do. makeActive handles everything for us (because
+ // what is displayed needs to be re-asserted each time we are activated
+ // too.)
+ },
+
+ /**
+ * Select a folder for display.
+ *
+ * @param aFolder The nsIMsgDBFolder to display.
+ */
+ show(aFolder) {
+ if (aFolder == null) {
+ this._nonViewFolder = null;
+ this.view.close();
+ } else if (aFolder instanceof Ci.nsIMsgFolder) {
+ if (aFolder.isServer) {
+ this._nonViewFolder = aFolder;
+ this._showServer();
+ this.view.close();
+ // A server is fully loaded immediately, for now. (When we have the
+ // account summary, we might want to change this to wait for the page
+ // load to complete.)
+ this._allMessagesLoaded = true;
+ } else {
+ this._nonViewFolder = null;
+ this.view.open(aFolder);
+ }
+ } else {
+ // it must be a synthetic view
+ this.view.openSynthetic(aFolder);
+ }
+ if (this._active) {
+ this.makeActive();
+ }
+ },
+
+ /**
+ * Clone an existing view wrapper as the basis for our display.
+ */
+ cloneView(aViewWrapper) {
+ this.view = aViewWrapper.clone(this);
+ // generate a view created notification; this will cause us to do the right
+ // thing in terms of associating the view with the tree and such.
+ this.onCreatedView();
+ if (this._active) {
+ this.makeActive();
+ }
+ },
+
+ /**
+ * Close resources associated with the currently displayed folder because you
+ * no longer care about this FolderDisplayWidget.
+ */
+ close() {
+ // Mark ourselves as inactive without doing any of the hard work of becoming
+ // inactive. This saves us from trying to update things as they go away.
+ this._active = false;
+
+ this.view.close();
+ this.messenger.setWindow(null, null);
+ this.messenger = null;
+ },
+ // @}
+
+ /* =============================== */
+ /* ===== IDBViewWrapper Listener ===== */
+ /* =============================== */
+
+ /**
+ * @name IDBViewWrapperListener Interface
+ * @private
+ */
+ // @{
+
+ /**
+ * @returns true if the mail view picker is visible. This affects whether the
+ * DBViewWrapper will actually use the persisted mail view or not.
+ */
+ get shouldUseMailViews() {
+ return ViewPickerBinding.isVisible;
+ },
+
+ /**
+ * Let the viewWrapper know if we should defer message display because we
+ * want the user to connect to the server first so password authentication
+ * can occur.
+ *
+ * @returns true if the folder should be shown immediately, false if we should
+ * wait for updateFolder to complete.
+ */
+ get shouldDeferMessageDisplayUntilAfterServerConnect() {
+ let passwordPromptRequired = false;
+
+ if (Services.prefs.getBoolPref("mail.password_protect_local_cache")) {
+ passwordPromptRequired =
+ this.view.displayedFolder.server.passwordPromptRequired;
+ }
+
+ return passwordPromptRequired;
+ },
+
+ /**
+ * Let the viewWrapper know if it should mark the messages read when leaving
+ * the provided folder.
+ *
+ * @returns true if the preference is set for the folder's server type.
+ */
+ shouldMarkMessagesReadOnLeavingFolder(aMsgFolder) {
+ return Services.prefs.getBoolPref(
+ "mailnews.mark_message_read." + aMsgFolder.server.type
+ );
+ },
+
+ /**
+ * The view wrapper tells us when it starts loading a folder, and we set the
+ * cursor busy. Setting the cursor busy on a per-tab basis is us being
+ * nice to the future. Loading a folder is a blocking operation that is going
+ * to make us unresponsive and accordingly make it very hard for the user to
+ * change tabs.
+ */
+ onFolderLoading(aFolderLoading) {
+ FolderDisplayListenerManager._fireListeners("onFolderLoading", [
+ this,
+ aFolderLoading,
+ ]);
+ },
+
+ /**
+ * The view wrapper tells us when a search is active, and we mark the tab as
+ * thinking so the user knows something is happening. 'Searching' in this
+ * case is more than just a user-initiated search. Virtual folders / saved
+ * searches, mail views, plus the more obvious quick search are all based off
+ * of searches and we will receive a notification for them.
+ */
+ onSearching(aIsSearching) {
+ FolderDisplayListenerManager._fireListeners("onSearching", [
+ this,
+ aIsSearching,
+ ]);
+ },
+
+ /**
+ * Things we do on creating a view:
+ * - notify the observer service so that custom column handler providers can
+ * add their custom columns to our view.
+ */
+ onCreatedView() {
+ // All of our messages are not displayed if the view was just created. We
+ // will get an onMessagesLoaded(true) nearly immediately if this is a local
+ // folder where view creation is synonymous with having all messages.
+ this._allMessagesLoaded = false;
+
+ FolderDisplayListenerManager._fireListeners("onCreatedView", [this]);
+
+ this._notifyWhenActive(this._activeCreatedView);
+ },
+ _activeCreatedView() {
+ gDBView = this.view.dbView; // eslint-disable-line no-global-assign
+
+ // A change in view may result in changes to sorts, the view menu, etc.
+ // Do this before we 'reroot' the dbview.
+ this._updateThreadDisplay();
+
+ // this creates a new selection object for the view.
+ if (this.tree) {
+ this.tree.view = this.view.dbView;
+ }
+
+ FolderDisplayListenerManager._fireListeners("onActiveCreatedView", [this]);
+
+ // The data payload used to be viewType + ":" + viewFlags. We no longer
+ // do this because we already have the implied contract that gDBView is
+ // valid at the time we generate the notification. In such a case, you
+ // can easily get that information from the gDBView. (The documentation
+ // on creating a custom column assumes gDBView.)
+ Services.obs.notifyObservers(this.displayedFolder, "MsgCreateDBView");
+ },
+
+ /**
+ * If our view is being destroyed and it is coming back, we want to save the
+ * current selection so we can restore it when the view comes back.
+ */
+ onDestroyingView(aFolderIsComingBack) {
+ // try and persist the selection's content if we can
+ if (this._active) {
+ // If saving the selection throws an exception, we still want continue
+ // destroying the view. Saving the selection can fail if an underlying
+ // local folder has been compacted, invalidating the message keys.
+ // See bug 536676 for more info.
+ try {
+ // If a new selection is coming up, there's no point in trying to
+ // persist any selections.
+ if (aFolderIsComingBack && !this._aboutToSelectMessage) {
+ this._saveSelection();
+ } else {
+ this._clearSavedSelection();
+ }
+ } catch (ex) {
+ console.error(ex);
+ }
+ gDBView = null; // eslint-disable-line no-global-assign
+ }
+
+ FolderDisplayListenerManager._fireListeners("onDestroyingView", [
+ this,
+ aFolderIsComingBack,
+ ]);
+
+ // if we have no view, no messages could be loaded.
+ this._allMessagesLoaded = false;
+
+ // but the actual tree view selection (based on view indices) is a goner no
+ // matter what, make everyone forget.
+ this.view.dbView.selection = null;
+ this._savedFirstVisibleRow = null;
+ this._nextViewIndexAfterDelete = null;
+ // although the move may still be active, its relation to the view is moot.
+ this._massMoveActive = false;
+
+ // Anything pending needs to get cleared out; the new view and its related
+ // events will re-schedule anything required or simply run it when it
+ // has its initial call to makeActive compelled.
+ this._notificationsPendingActivation = [];
+ },
+
+ /**
+ * Restore persisted information about what columns to display for the folder.
+ * If we have no persisted information, we leave/set _savedColumnStates null.
+ * The column states will be set to default values in onDisplayingFolder in
+ * that case.
+ */
+ onLoadingFolder(aDbFolderInfo) {
+ this._savedColumnStates =
+ this._depersistColumnStatesFromDbFolderInfo(aDbFolderInfo);
+
+ FolderDisplayListenerManager._fireListeners("onLoadingFolder", [
+ this,
+ aDbFolderInfo,
+ ]);
+ },
+
+ /**
+ * We are entering the folder for display:
+ * - set the header cache size.
+ * - Setup the columns if we did not already depersist in |onLoadingFolder|.
+ */
+ onDisplayingFolder() {
+ let displayedFolder = this.view.displayedFolder;
+ let msgDatabase = displayedFolder && displayedFolder.msgDatabase;
+ if (msgDatabase) {
+ msgDatabase.resetHdrCacheSize(this.PERF_HEADER_CACHE_SIZE);
+ }
+
+ // makeActive will restore the folder state
+ if (!this._savedColumnStates) {
+ if (
+ this.view.isSynthetic &&
+ "getPersistedSetting" in this.view._syntheticView
+ ) {
+ let columns = this.view._syntheticView.getPersistedSetting("columns");
+ this._savedColumnStates = columns;
+ } else {
+ // get the default for this folder
+ this._savedColumnStates = this._getDefaultColumnsForCurrentFolder();
+ // and save it so it doesn't wiggle if the inbox/prototype changes
+ this._persistColumnStates(this._savedColumnStates);
+ }
+ }
+
+ FolderDisplayListenerManager._fireListeners("onDisplayingFolder", [this]);
+
+ if (this.active) {
+ this.makeActive();
+ }
+ },
+
+ /**
+ * Notification from DBViewWrapper that it is closing the folder. This can
+ * happen for reasons other than our own 'close' method closing the view.
+ * For example, user deletion of the folder or underlying folder closes it.
+ */
+ onLeavingFolder() {
+ FolderDisplayListenerManager._fireListeners("onLeavingFolder", [this]);
+
+ // Keep the msgWindow's openFolder up-to-date; it powers nsMessenger's
+ // concept of history so that it can bring you back to the actual folder
+ // you were looking at, rather than just the underlying folder.
+ if (this._active) {
+ msgWindow.openFolder = null;
+ }
+ },
+
+ /**
+ * Indicates whether we are done loading the messages that should be in this
+ * folder. This is being surfaced for testing purposes, but could be useful
+ * to other code as well. But don't poll this property; ask for an event
+ * that you can hook.
+ */
+ get allMessagesLoaded() {
+ return this._allMessagesLoaded;
+ },
+
+ /**
+ * Things to do once some or all the messages that should show up in a folder
+ * have shown up. For a real folder, this happens when the folder is
+ * entered. For a virtual folder, this happens when the search completes.
+ *
+ * What we do:
+ * - Any scrolling required!
+ */
+ onMessagesLoaded(aAll) {
+ this._allMessagesLoaded = aAll;
+
+ FolderDisplayListenerManager._fireListeners("onMessagesLoaded", [
+ this,
+ aAll,
+ ]);
+
+ this._notifyWhenActive(this._activeMessagesLoaded);
+ },
+ _activeMessagesLoaded() {
+ FolderDisplayListenerManager._fireListeners("onActiveMessagesLoaded", [
+ this,
+ ]);
+
+ // - if a selectMessage's coming up, get out of here
+ if (this._aboutToSelectMessage) {
+ return;
+ }
+
+ // - restore user's last expand/collapse choice.
+ this.restoreThreadState();
+
+ // - restore selection
+ // Attempt to restore the selection (if we saved it because the view was
+ // being destroyed or otherwise manipulated in a fashion that the normal
+ // nsTreeSelection would be unable to handle.)
+ if (this._restoreSelection()) {
+ this.ensureRowIsVisible(this.view.dbView.viewIndexForFirstSelectedMsg);
+ return;
+ }
+
+ // - pending navigation from pushNavigation (probably spacebar triggered)
+ // Need to have all messages loaded first.
+ if (this._pendingNavigation) {
+ // Move it to a local and clear the state in case something bad happens.
+ // (We don't want to swallow the exception.)
+ let pendingNavigation = this._pendingNavigation;
+ this._pendingNavigation = null;
+ this.navigate.apply(this, pendingNavigation);
+ return;
+ }
+
+ // - if something's already selected (e.g. in a message tab), scroll to the
+ // first selected message and get out
+ if (this.view.dbView.numSelected > 0) {
+ this.ensureRowIsVisible(this.view.dbView.viewIndexForFirstSelectedMsg);
+ return;
+ }
+
+ // - new messages
+ // if configured to scroll to new messages, try that
+ if (
+ Services.prefs.getBoolPref("mailnews.scroll_to_new_message") &&
+ this.navigate(Ci.nsMsgNavigationType.firstNew, /* select */ false)
+ ) {
+ return;
+ }
+
+ // - last selected message
+ // if configured to load the last selected message (this is currently more
+ // persistent than our saveSelection/restoreSelection stuff), and the view
+ // is backed by a single underlying folder (the only way having just a
+ // message key works out), try that
+ if (
+ Services.prefs.getBoolPref("mailnews.remember_selected_message") &&
+ this.view.isSingleFolder &&
+ this.view.displayedFolder
+ ) {
+ // use the displayed folder; nsMsgDBView goes to the effort to save the
+ // state to the viewFolder, so this is the correct course of action.
+ let lastLoadedMessageKey = this.view.displayedFolder.lastMessageLoaded;
+ if (lastLoadedMessageKey != nsMsgKey_None) {
+ this.view.dbView.selectMsgByKey(lastLoadedMessageKey);
+ // The message key may not be present in the view for a variety of
+ // reasons. Beyond message deletion, it simply may not match the
+ // active mail view or quick search, for example.
+ if (this.view.dbView.numSelected > 0) {
+ this.ensureRowIsVisible(
+ this.view.dbView.viewIndexForFirstSelectedMsg
+ );
+ return;
+ }
+ }
+ }
+
+ // - towards the newest messages, but don't select
+ if (
+ this.view.isSortedAscending &&
+ this.view.sortImpliesTemporalOrdering &&
+ this.navigate(Ci.nsMsgNavigationType.lastMessage, /* select */ false)
+ ) {
+ return;
+ }
+
+ // - to the top, the coliseum
+ this.ensureRowIsVisible(0);
+ },
+
+ /**
+ * The DBViewWrapper tells us when someone (possibly the wrapper itself)
+ * changes the active mail view so that we can kick the UI to update.
+ */
+ onMailViewChanged() {
+ // only do this if we're currently active. no need to queue it because we
+ // always update the mail view whenever we are made active.
+ if (this.active) {
+ // you cannot cancel a view change!
+ window.dispatchEvent(
+ new Event("MailViewChanged", { bubbles: false, cancelable: false })
+ );
+ }
+ },
+
+ /**
+ * Just the sort or threading was changed, without changing other things. We
+ * will not get this notification if the view was re-created, for example.
+ */
+ onSortChanged() {
+ if (this.active) {
+ UpdateSortIndicators(
+ this.view.primarySortType,
+ this.view.primarySortOrder
+ );
+ }
+
+ FolderDisplayListenerManager._fireListeners("onSortChanged", [this]);
+ },
+
+ /**
+ * Messages (that may have been displayed) have been removed; this may impact
+ * our message selection. We might know it's coming; if we do then
+ * this._nextViewIndexAfterDelete should know what view index to select next.
+ * For the imap mark-as-deleted we won't know beforehand.
+ */
+ onMessagesRemoved() {
+ FolderDisplayListenerManager._fireListeners("onMessagesRemoved", [this]);
+
+ this._deleteInProgress = false;
+
+ // - we saw this coming
+ let rowCount = this.view.dbView.rowCount;
+ if (!this._massMoveActive && this._nextViewIndexAfterDelete != null) {
+ // adjust the index if it is after the last row...
+ // (this can happen if the "mail.delete_matches_sort_order" pref is not
+ // set and the message is the last message in the view.)
+ if (this._nextViewIndexAfterDelete >= rowCount) {
+ this._nextViewIndexAfterDelete = rowCount - 1;
+ }
+ // just select the index and get on with our lives
+ this.selectViewIndex(this._nextViewIndexAfterDelete);
+ this._nextViewIndexAfterDelete = null;
+ return;
+ }
+
+ // - we didn't see it coming
+
+ // A deletion happened to our folder.
+ let treeSelection = this.treeSelection;
+ // we can't fix the selection if we have no selection
+ if (!treeSelection) {
+ return;
+ }
+
+ // For reasons unknown (but theoretically knowable), sometimes the selection
+ // object will be invalid. At least, I've reliably seen a selection of
+ // [0, 0] with 0 rows. If that happens, we need to fix up the selection
+ // here.
+ if (rowCount == 0 && treeSelection.count) {
+ // nsTreeSelection doesn't generate an event if we use clearRange, so use
+ // that to avoid spurious events, given that we are going to definitely
+ // trigger a change notification below.
+ treeSelection.clearRange(0, 0);
+ }
+
+ // Check if we now no longer have a selection, but we had exactly one
+ // message selected previously. If we did, then try and do some
+ // 'persistence of having a thing selected'.
+ if (
+ treeSelection.count == 0 &&
+ this._mostRecentSelectionCounts.length > 1 &&
+ this._mostRecentSelectionCounts[1] == 1 &&
+ this._mostRecentCurrentIndices[1] != -1
+ ) {
+ let targetIndex = this._mostRecentCurrentIndices[1];
+ if (targetIndex >= rowCount) {
+ targetIndex = rowCount - 1;
+ }
+ this.selectViewIndex(targetIndex);
+ return;
+ }
+
+ // Otherwise, just tell the view that things have changed so it can update
+ // itself to the new state of things.
+ // tell the view that things have changed so it can update itself suitably.
+ if (this.view.dbView) {
+ this.view.dbView.selectionChanged();
+ }
+ },
+
+ /**
+ * Messages were not actually removed, but we were expecting that they would
+ * be. Clean-up what onMessagesRemoved would have cleaned up, namely the
+ * next view index to select.
+ */
+ onMessageRemovalFailed() {
+ this._nextViewIndexAfterDelete = null;
+ FolderDisplayListenerManager._fireListeners("onMessagesRemovalFailed", [
+ this,
+ ]);
+ },
+
+ /**
+ * Update the status bar to reflect our exciting message counts.
+ */
+ onMessageCountsChanged() {},
+ // @}
+ /* ===== End IDBViewWrapperListener ===== */
+
+ /* ================================== */
+ /* ===== nsIMsgDBViewCommandUpdater ===== */
+ /* ================================== */
+
+ /**
+ * @name nsIMsgDBViewCommandUpdater Interface
+ * @private
+ */
+ // @{
+
+ /**
+ * This gets called when the selection changes AND !suppressCommandUpdating
+ * AND (we're not removing a row OR we are now out of rows).
+ * In response, we update the toolbar.
+ */
+ updateCommandStatus() {},
+
+ /**
+ * This gets called by nsMsgDBView::UpdateDisplayMessage following a call
+ * to nsIMessenger.OpenURL to kick off message display OR (UDM gets called)
+ * by nsMsgDBView::SelectionChanged in lieu of loading the message because
+ * mSupressMsgDisplay.
+ * In other words, we get notified immediately after the process of displaying
+ * a message triggered by the nsMsgDBView happens. We get some arguments
+ * that are display optimizations for historical reasons (as usual).
+ *
+ * Things this makes us want to do:
+ * - Set the tab title, perhaps. (If we are a message display.)
+ * - Update message counts, because things might have changed, why not.
+ * - Update some toolbar buttons, why not.
+ *
+ * @param aFolder The display/view folder, as opposed to the backing folder.
+ * @param aSubject The subject with "Re: " if it's got one, which makes it
+ * notably different from just directly accessing the message header's
+ * subject.
+ * @param aKeywords The keywords, which roughly translates to message tags.
+ */
+ displayMessageChanged(aFolder, aSubject, aKeywords) {},
+
+ /**
+ * This gets called as a hint that the currently selected message is junk and
+ * said junked message is going to be moved out of the current folder, or
+ * right before a header is removed from the db view. The legacy behaviour
+ * is to retrieve the msgToSelectAfterDelete attribute off the db view,
+ * stashing it for benefit of the code that gets called when a message
+ * move/deletion is completed so that we can trigger its display.
+ */
+ updateNextMessageAfterDelete() {
+ this.hintAboutToDeleteMessages();
+ },
+
+ /**
+ * The most recent currentIndexes on the selection (from the last time
+ * summarizeSelection got called). We use this in onMessagesRemoved if
+ * we get an unexpected notification.
+ * We keep a maximum of 2 entries in this list.
+ */
+ _mostRecentCurrentIndices: undefined, // initialized in constructor
+ /**
+ * The most recent counts on the selection (from the last time
+ * summarizeSelection got called). We use this in onMessagesRemoved if
+ * we get an unexpected notification.
+ * We keep a maximum of 2 entries in this list.
+ */
+ _mostRecentSelectionCounts: undefined, // initialized in constructor
+
+ /**
+ * Always called by the db view when the selection changes in
+ * SelectionChanged. This event will come after the notification to
+ * displayMessageChanged (if one happens), and before the notification to
+ * updateCommandStatus (if one happens).
+ */
+ summarizeSelection() {
+ // save the current index off in case the selection gets deleted out from
+ // under us and we want to have persistence of actually-having-something
+ // selected.
+ let treeSelection = this.treeSelection;
+ if (treeSelection) {
+ this._mostRecentCurrentIndices.unshift(treeSelection.currentIndex);
+ this._mostRecentCurrentIndices.splice(2);
+ this._mostRecentSelectionCounts.unshift(treeSelection.count);
+ this._mostRecentSelectionCounts.splice(2);
+ }
+ },
+ // @}
+ /* ===== End nsIMsgDBViewCommandUpdater ===== */
+
+ /* ===== Hints from the command infrastructure ===== */
+ /**
+ * @name Command Infrastructure Hints
+ * @protected
+ */
+ // @{
+
+ /**
+ * doCommand helps us out by telling us when it is telling the view to delete
+ * some messages. Ideally it should go through us / the DB View Wrapper to
+ * kick off the delete in the first place, but that's a thread I don't want
+ * to pull on right now.
+ * We use this hint to figure out the next message to display once the
+ * deletion completes. We do this before the deletion happens because the
+ * selection is probably going away (except in the IMAP delete model), and it
+ * might be too late to figure this out after the deletion happens.
+ * Our automated complement (that calls us) is updateNextMessageAfterDelete.
+ */
+ hintAboutToDeleteMessages() {
+ this._deleteInProgress = true;
+ // save the value, even if it is nsMsgViewIndex_None.
+ this._nextViewIndexAfterDelete = this.view.dbView.msgToSelectAfterDelete;
+ },
+
+ /**
+ * The archive code tells us when it is starting to archive messages. This
+ * is different from hinting about deletion because it will also tell us
+ * when it has completed its mass move.
+ * The UI goal is that we do not immediately jump beyond the selected messages
+ * to the next message until all of the selected messages have been
+ * processed (moved). Ideally we would also do this when deleting messages
+ * from a multiple-folder backed message view, but we don't know when the
+ * last job completes in that case (whereas in this case we do because of the
+ * call to hintMassMoveCompleted.)
+ */
+ hintMassMoveStarting() {
+ this.hintAboutToDeleteMessages();
+ this._massMoveActive = true;
+ },
+
+ /**
+ * The archival has completed, we can finally let onMessagseRemoved run to
+ * completion.
+ */
+ hintMassMoveCompleted() {
+ this._massMoveActive = false;
+ this.onMessagesRemoved();
+ },
+
+ /**
+ * When a right-click on the thread pane is going to alter our selection, we
+ * get this notification (currently from |ChangeSelectionWithoutContentLoad|
+ * in threadPane.js), which lets us save our state.
+ * This ends one of two ways: we get made inactive because a new tab popped up
+ * or we get a call to |hintRightClickSelectionPerturbationDone|.
+ *
+ * Ideally, we could just save off our current nsITreeSelection and restore it
+ * when this is all over. This assumption would rely on the underlying view
+ * not having any changes to its rows before we restore the selection. I am
+ * not confident we can rule out background processes making changes, plus
+ * the right-click itself may mutate the view (although we could try and get
+ * it to restore the selection before it gets to the mutation part). Our
+ * only way to resolve this would be to create a 'tee' like fake selection
+ * that would proxy view change notifications to both sets of selections.
+ * That is hard.
+ * So we just use the existing _saveSelection/_restoreSelection mechanism
+ * which is potentially very costly.
+ */
+ hintRightClickPerturbingSelection() {
+ this._saveSelection();
+ },
+
+ /**
+ * When a right-click on the thread pane altered our selection (which we
+ * should have received a call to |hintRightClickPerturbingSelection| for),
+ * we should receive this notification from
+ * |RestoreSelectionWithoutContentLoad| when it wants to put things back.
+ */
+ hintRightClickSelectionPerturbationDone() {
+ this._restoreSelection();
+ },
+ // @}
+ /* ===== End hints from the command infrastructure ==== */
+
+ _updateThreadDisplay() {
+ if (this.active) {
+ if (this.view.dbView) {
+ UpdateSortIndicators(
+ this.view.dbView.sortType,
+ this.view.dbView.sortOrder
+ );
+ SetNewsFolderColumns();
+ UpdateSelectCol();
+ }
+ }
+ },
+
+ /**
+ * Update the UI display apart from the thread tree because the folder being
+ * displayed has changed. This can be the result of changing the folder in
+ * this FolderDisplayWidget, or because this FolderDisplayWidget is being
+ * made active. _updateThreadDisplay handles the parts of the thread tree
+ * that need updating.
+ */
+ _updateContextDisplay() {
+ if (this.active) {
+ UpdateStatusQuota(this.displayedFolder);
+
+ // - mail view combo-box.
+ this.onMailViewChanged();
+ }
+ },
+
+ /**
+ * @name Activation Control
+ * @protected
+ */
+ // @{
+
+ /**
+ * Run the provided notification function right now if we are 'active' (the
+ * currently displayed tab), otherwise queue it to be run when we become
+ * active. We do this because our tabbing model uses multiplexed (reused)
+ * widgets, and extensions likewise depend on these global/singleton things.
+ * If the requested notification function is already queued, it will not be
+ * added a second time, and the original call ordering will be maintained.
+ * If a new call ordering is required, the list of notifications should
+ * probably be reset by the 'big bang' event (new view creation?).
+ */
+ _notifyWhenActive(aNotificationFunc) {
+ if (this._active) {
+ aNotificationFunc.call(this);
+ } else if (
+ !this._notificationsPendingActivation.includes(aNotificationFunc)
+ ) {
+ this._notificationsPendingActivation.push(aNotificationFunc);
+ }
+ },
+
+ /**
+ * Some notifications cannot run while the FolderDisplayWidget is inactive
+ * (presumbly because it is in a background tab). We accumulate those in
+ * _notificationsPendingActivation and then this method runs them when we
+ * become active again.
+ */
+ _runNotificationsPendingActivation() {
+ if (!this._notificationsPendingActivation.length) {
+ return;
+ }
+
+ let pendingNotifications = this._notificationsPendingActivation;
+ this._notificationsPendingActivation = [];
+ for (let notif of pendingNotifications) {
+ notif.call(this);
+ }
+ },
+
+ // This is not guaranteed to be up to date if the folder display is active
+ _folderPaneVisible: null,
+
+ /**
+ * Whether the folder pane is visible. When we're inactive, we stash the value
+ * in |this._folderPaneVisible|.
+ */
+ get folderPaneVisible() {
+ // Early return if the user wants to use Thunderbird without an email
+ // account and no account is configured.
+ if (
+ Services.prefs.getBoolPref("app.use_without_mail_account", false) &&
+ !MailServices.accounts.accounts.length
+ ) {
+ return false;
+ }
+
+ if (this._active) {
+ let folderPaneBox = document.getElementById("folderPaneBox");
+ if (folderPaneBox) {
+ return !folderPaneBox.collapsed;
+ }
+ } else {
+ return this._folderPaneVisible;
+ }
+
+ return null;
+ },
+
+ /**
+ * Sets the visibility of the folder pane. This should reflect reality and
+ * not define it (for active tabs at least).
+ */
+ set folderPaneVisible(aVisible) {
+ this._folderPaneVisible = aVisible;
+ },
+
+ get active() {
+ return this._active;
+ },
+
+ /**
+ * Make this FolderDisplayWidget the 'active' widget by updating globals and
+ * linking us up to the UI widgets. This is intended for use by the tabbing
+ * logic.
+ */
+ makeActive(aWasInactive) {
+ let wasInactive = !this._active;
+
+ // -- globals
+ // update per-tab globals that we own
+ gFolderDisplay = this; // eslint-disable-line no-global-assign
+ gDBView = this.view.dbView; // eslint-disable-line no-global-assign
+ messenger = this.messenger; // eslint-disable-line no-global-assign
+
+ // update singleton globals' state
+ msgWindow.openFolder = this.view.displayedFolder;
+
+ this._active = true;
+ this._runNotificationsPendingActivation();
+
+ FolderDisplayListenerManager._fireListeners("onMakeActive", [
+ this,
+ aWasInactive,
+ ]);
+
+ // -- UI
+
+ // thread pane if we have a db view
+ if (this.view.dbView) {
+ // Make sure said thread pane is visible. If we do this after we re-root
+ // the tree, the thread pane may not actually replace the account central
+ // pane. Concerning...
+ this._showThreadPane();
+
+ // some things only need to happen if we are transitioning from inactive
+ // to active
+ if (wasInactive) {
+ if (this.tree) {
+ // Setting the 'view' attribute on treeBox results in the following
+ // effective calls, noting that in makeInactive we made sure to null
+ // out its view so that it won't try and clean up any views or their
+ // selections. (The actual actions happen in
+ // nsTreeBodyFrame::SetView)
+ // - this.view.dbView.selection.tree = this.tree
+ // - this.view.dbView.setTree(this.tree)
+ // - this.tree.view = this.view.dbView (in
+ // nsTreeBodyObject::SetView)
+ this.tree.view = this.view.dbView;
+
+ if (this._savedFirstVisibleRow != null) {
+ this.tree.scrollToRow(this._savedFirstVisibleRow);
+ }
+ }
+ }
+
+ // Always restore the column state if we have persisted state. We restore
+ // state on folder entry, in which case we were probably not inactive.
+ this._restoreColumnStates();
+
+ // update the columns and such that live inside the thread pane
+ this._updateThreadDisplay();
+ }
+
+ this._updateContextDisplay();
+ },
+
+ /**
+ * Cause the displayBox to display the thread pane.
+ */
+ _showThreadPane() {
+ document.getElementById("accountCentralBox").collapsed = true;
+ document.getElementById("threadPaneBox").collapsed = false;
+ },
+
+ /**
+ * Cause the displayBox to display the (preference configurable) account
+ * central page.
+ */
+ _showAccountCentral() {
+ if (!this.displayedFolder && MailServices.accounts.accounts.length > 0) {
+ // If we have any accounts set up, but no folder is selected yet,
+ // we expect another selection event to come when session restore finishes.
+ // Until then, do nothing.
+ return;
+ }
+ document.getElementById("accountCentralBox").collapsed = false;
+ document.getElementById("threadPaneBox").collapsed = true;
+
+ // Prevent a second load if necessary.
+ let loadURL =
+ "chrome://messenger/content/msgAccountCentral.xhtml" +
+ (this.displayedFolder
+ ? "?folderURI=" + encodeURIComponent(this.displayedFolder.URI)
+ : "");
+ if (window.frames.accountCentralPane.location.href != loadURL) {
+ window.frames.accountCentralPane.location.href = loadURL;
+ }
+ },
+
+ /**
+ * Call this when the tab using us is being hidden.
+ */
+ makeInactive() {
+ // - things to do before we mark ourselves inactive (because they depend on
+ // us being active)
+
+ // getColumnStates returns _savedColumnStates when we are inactive (and is
+ // used by _saveColumnStates) so we must do this before marking inactive.
+ this._saveColumnStates();
+
+ // - mark us inactive
+ this._active = false;
+
+ // - (everything after this point doesn't care that we are marked inactive)
+ // save the folder pane's state always
+ this._folderPaneVisible =
+ !document.getElementById("folderPaneBox").collapsed;
+
+ if (this.view.dbView) {
+ if (this.tree) {
+ this._savedFirstVisibleRow = this.tree.getFirstVisibleRow();
+ }
+
+ // save the message pane's state only when it is potentially visible
+ this.messagePaneCollapsed = document.getElementById(
+ "messagepaneboxwrapper"
+ ).collapsed;
+ }
+ },
+ // @}
+
+ /**
+ * @name Command Support
+ */
+ // @{
+
+ /**
+ * @returns true if there is a db view and the command is enabled on the view.
+ * This function hides some of the XPCOM-odditities of the getCommandStatus
+ * call.
+ */
+ getCommandStatus(aCommandType, aEnabledObj, aCheckStatusObj) {
+ // no view means not enabled
+ if (!this.view.dbView) {
+ return false;
+ }
+ let enabledObj = {},
+ checkStatusObj = {};
+ this.view.dbView.getCommandStatus(aCommandType, enabledObj, checkStatusObj);
+ return enabledObj.value;
+ },
+
+ /**
+ * Make code cleaner by allowing peoples to call doCommand on us rather than
+ * having to do folderDisplayWidget.view.dbView.doCommand.
+ *
+ * @param aCommandName The command name to invoke.
+ */
+ doCommand(aCommandName) {
+ return this.view.dbView && this.view.dbView.doCommand(aCommandName);
+ },
+
+ /**
+ * Make code cleaner by allowing peoples to call doCommandWithFolder on us
+ * rather than having to do:
+ * folderDisplayWidget.view.dbView.doCommandWithFolder.
+ *
+ * @param aCommandName The command name to invoke.
+ * @param aFolder The folder context for the command.
+ */
+ doCommandWithFolder(aCommandName, aFolder) {
+ return (
+ this.view.dbView &&
+ this.view.dbView.doCommandWithFolder(aCommandName, aFolder)
+ );
+ },
+ // @}
+
+ /**
+ * @returns true when account central is being displayed.
+ * @groupName Displayed
+ */
+ get isAccountCentralDisplayed() {
+ return this.view.dbView == null;
+ },
+
+ /**
+ * @name Navigation
+ * @protected
+ */
+ // @{
+
+ /**
+ * Navigate using nsMsgNavigationType rules and ensuring the resulting row is
+ * visible. This is trickier than it used to be because we now support
+ * treating collapsed threads as the set of all the messages in the collapsed
+ * thread rather than just the root message in that thread.
+ *
+ * @param {nsMsgNavigationType} aNavType navigation command.
+ * @param {boolean} [aSelect=true] should we select the message if we find
+ * one?
+ *
+ * @returns true if the navigation constraint matched anything, false if not.
+ * We will have navigated if true, we will have done nothing if false.
+ */
+ navigate(aNavType, aSelect) {
+ if (aSelect === undefined) {
+ aSelect = true;
+ }
+ let resultKeyObj = {},
+ resultIndexObj = {},
+ threadIndexObj = {};
+
+ let summarizeSelection = this.summarizeSelectionInFolder;
+
+ let treeSelection = this.treeSelection; // potentially magic getter
+ let currentIndex = treeSelection ? treeSelection.currentIndex : 0;
+
+ let viewIndex;
+ // if we're doing next unread, and a collapsed thread is selected, and
+ // the top level message is unread, just set the result manually to
+ // the top level message, without using viewNavigate.
+ if (
+ summarizeSelection &&
+ aNavType == Ci.nsMsgNavigationType.nextUnreadMessage &&
+ currentIndex != -1 &&
+ this.view.isCollapsedThreadAtIndex(currentIndex) &&
+ !(this.view.dbView.getFlagsAt(currentIndex) & Ci.nsMsgMessageFlags.Read)
+ ) {
+ viewIndex = currentIndex;
+ } else {
+ // always 'wrap' because the start index is relative to the selection.
+ // (keep in mind that many forms of navigation do not care about the
+ // starting position or 'wrap' at all; for example, firstNew just finds
+ // the first new message.)
+ // allegedly this does tree-expansion for us.
+ this.view.dbView.viewNavigate(
+ aNavType,
+ resultKeyObj,
+ resultIndexObj,
+ threadIndexObj,
+ true
+ );
+ viewIndex = resultIndexObj.value;
+ }
+
+ if (viewIndex == nsMsgViewIndex_None) {
+ return false;
+ }
+
+ // - Expand if required.
+ // (The nsMsgDBView isn't really aware of the varying semantics of
+ // collapsed threads, so viewNavigate might tell us about the root message
+ // and leave it collapsed, not realizing that it needs to be expanded.)
+ if (summarizeSelection && this.view.isCollapsedThreadAtIndex(viewIndex)) {
+ this.view.dbView.toggleOpenState(viewIndex);
+ }
+
+ if (aSelect) {
+ this.selectViewIndex(viewIndex);
+ } else {
+ this.ensureRowIsVisible(viewIndex);
+ }
+ return true;
+ },
+
+ /**
+ * Push a call to |navigate| to be what we do once we successfully open the
+ * next folder. This is intended to be used by cross-folder navigation
+ * code. It should call this method before triggering the folder change.
+ */
+ pushNavigation(aNavType, aSelect) {
+ this._pendingNavigation = [aNavType, aSelect];
+ },
+ // @}
+
+ /**
+ * @name Selection
+ */
+ // @{
+
+ /**
+ * @returns the message header for the first selected message, or null if
+ * there is no selected message.
+ *
+ * If the user has right-clicked on a message, this method will return that
+ * message and not the 'current index' (the dude with the dotted selection
+ * rectangle around him.) If you instead always want the currently
+ * displayed message (which is not impacted by right-clicking), then you
+ * would want to access the displayedMessage property on the
+ * MessageDisplayWidget. You can get to that via the messageDisplay
+ * attribute on this object or (potentially) via the gMessageDisplay object.
+ */
+ get selectedMessage() {
+ // there are inconsistencies in hdrForFirstSelectedMessage between
+ // nsMsgDBView and nsMsgSearchDBView in whether they use currentIndex,
+ // do it ourselves. (nsMsgDBView does not use currentIndex, search does.)
+ let treeSelection = this.treeSelection;
+ if (!treeSelection || !treeSelection.count) {
+ return null;
+ }
+ let minObj = {},
+ maxObj = {};
+ treeSelection.getRangeAt(0, minObj, maxObj);
+ return this.view.dbView.getMsgHdrAt(minObj.value);
+ },
+
+ /**
+ * @returns true if there is a selected message and it's an RSS feed message;
+ * a feed message does not have to be in an rss account folder if stored in
+ * Tb15 and later.
+ */
+ get selectedMessageIsFeed() {
+ return FeedUtils.isFeedMessage(this.selectedMessage);
+ },
+
+ /**
+ * @returns true if there is a selected message and it's an IMAP message.
+ */
+ get selectedMessageIsImap() {
+ let message = this.selectedMessage;
+ return Boolean(
+ message &&
+ message.folder &&
+ message.folder.flags & Ci.nsMsgFolderFlags.ImapBox
+ );
+ },
+
+ /**
+ * @returns true if there is a selected message and it's a news message. It
+ * would be great if messages knew this about themselves, but they don't.
+ */
+ get selectedMessageIsNews() {
+ let message = this.selectedMessage;
+ return Boolean(
+ message &&
+ message.folder &&
+ message.folder.flags & Ci.nsMsgFolderFlags.Newsgroup
+ );
+ },
+
+ /**
+ * @returns true if there is a selected message and it's an external message,
+ * meaning it is loaded from an .eml file on disk or is an rfc822 attachment
+ * on a message.
+ */
+ get selectedMessageIsExternal() {
+ let message = this.selectedMessage;
+ // Dummy messages currently lack a folder. This is not a great heuristic.
+ // I have annotated msgHdrView.js which provides the dummy header to
+ // express this implementation dependency.
+ // (Currently, since external mails can only be opened in standalone windows
+ // which subclass us, we could always return false, and have the subclass
+ // return true using its own heuristics. But since we are moving to a tab
+ // model more heavily, at some point the 3-pane will need this.)
+ return Boolean(message && !message.folder);
+ },
+
+ /**
+ * @returns true if there is a selected message and the message belongs to an
+ * ignored thread.
+ */
+ get selectedMessageThreadIgnored() {
+ let message = this.selectedMessage;
+ return Boolean(
+ message &&
+ message.folder &&
+ message.folder.msgDatabase.isIgnored(message.messageKey)
+ );
+ },
+
+ /**
+ * @returns true if there is a selected message and the message is the base
+ * message for an ignored subthread.
+ */
+ get selectedMessageSubthreadIgnored() {
+ let message = this.selectedMessage;
+ return Boolean(
+ message && message.folder && message.flags & Ci.nsMsgMessageFlags.Ignored
+ );
+ },
+
+ /**
+ * @returns true if there is a selected message and the message belongs to a
+ * watched thread.
+ */
+ get selectedMessageThreadWatched() {
+ let message = this.selectedMessage;
+ return Boolean(
+ message &&
+ message.folder &&
+ message.folder.msgDatabase.isWatched(message.messageKey)
+ );
+ },
+
+ /**
+ * @returns the number of selected messages. If summarizeSelectionInFolder is
+ * true, then any collapsed thread roots that are selected will also
+ * conceptually have all of the messages in that thread selected.
+ */
+ get selectedCount() {
+ return this.selectedMessages.length;
+ },
+
+ /**
+ * Provides a list of the view indices that are selected which is *not* the
+ * same as the rows of the selected messages. When
+ * summarizeSelectionInFolder is true, messages may be selected but not
+ * visible (because the thread root is selected.)
+ * You probably want to use the |selectedMessages| attribute instead of this
+ * one. (Or selectedMessageUris in some rare cases.)
+ *
+ * If the user has right-clicked on a message, this will return that message
+ * and not the selection prior to the right-click.
+ *
+ * @returns a list of the view indices that are currently selected
+ */
+ get selectedIndices() {
+ if (!this.view.dbView) {
+ return [];
+ }
+
+ return this.view.dbView.getIndicesForSelection();
+ },
+
+ /**
+ * Provides a list of the message headers for the currently selected messages.
+ * If summarizeSelectionInFolder is true, then any collapsed thread roots
+ * that are selected will also (conceptually) have all of the messages in
+ * that thread selected and they will be included in the returned list.
+ *
+ * If the user has right-clicked on a message, this will return that message
+ * (and any collapsed children if so enabled) and not the selection prior to
+ * the right-click.
+ *
+ * @returns a list of the message headers for the currently selected messages.
+ * If there are no selected messages, the result is an empty list.
+ */
+ get selectedMessages() {
+ if (!this._active && this._savedSelection?.messages) {
+ return this._savedSelection.messages
+ .map(savedInfo => this.view.getMsgHdrForMessageID(savedInfo.messageId))
+ .filter(msgHdr => !!msgHdr);
+ }
+ if (!this.view.dbView) {
+ return [];
+ }
+ return this.view.dbView.getSelectedMsgHdrs();
+ },
+
+ /**
+ * @returns a list of the URIs for the currently selected messages or null
+ * (instead of a list) if there are no selected messages. Do not
+ * pass around URIs unless you have a good reason. Legacy code is an
+ * ok reason.
+ *
+ * If the user has right-clicked on a message, this will return that message's
+ * URI and not the selection prior to the right-click.
+ */
+ get selectedMessageUris() {
+ if (!this.view.dbView) {
+ return null;
+ }
+
+ let messageArray = this.view.dbView.getURIsForSelection();
+ return messageArray.length ? messageArray : null;
+ },
+
+ /**
+ * @returns true if all the selected messages can be archived, false otherwise.
+ */
+ get canArchiveSelectedMessages() {
+ return false;
+ },
+
+ /**
+ * The maximum number of messages canMarkThreadAsRead will look through.
+ * If the number exceeds this limit, as a performance measure, we return
+ * true rather than looking through the messages and possible
+ * submessages.
+ */
+ MAX_COUNT_FOR_MARK_THREAD: 1000,
+
+ /**
+ * Check if the thread for the currently selected message can be marked as
+ * read. A thread can be marked as read if and only if it has at least one
+ * unread message.
+ */
+ get canMarkThreadAsRead() {
+ if (
+ (this.displayedFolder && this.displayedFolder.getNumUnread(false) > 0) ||
+ this.view._underlyingData === this.view.kUnderlyingSynthetic
+ ) {
+ // If the messages limit is exceeded we bail out early and return true.
+ if (this.selectedIndices.length > this.MAX_COUNT_FOR_MARK_THREAD) {
+ return true;
+ }
+
+ for (let i of this.selectedIndices) {
+ if (
+ this.view.dbView.getThreadContainingIndex(i).numUnreadChildren > 0
+ ) {
+ return true;
+ }
+ }
+ }
+ return false;
+ },
+
+ /**
+ * @returns true if all the selected messages can be deleted from their
+ * folders, false otherwise.
+ */
+ get canDeleteSelectedMessages() {
+ if (!this.view.dbView) {
+ return false;
+ }
+
+ let selectedMessages = this.selectedMessages;
+ for (let i = 0; i < selectedMessages.length; ++i) {
+ if (
+ selectedMessages[i].folder &&
+ (!selectedMessages[i].folder.canDeleteMessages ||
+ selectedMessages[i].folder.flags & Ci.nsMsgFolderFlags.Newsgroup)
+ ) {
+ return false;
+ }
+ }
+ return true;
+ },
+
+ /**
+ * Clear the tree selection, making sure the message pane is cleared and
+ * the context display (toolbars, etc.) are updated.
+ */
+ clearSelection() {
+ let treeSelection = this.treeSelection; // potentially magic getter
+ if (!treeSelection) {
+ return;
+ }
+ treeSelection.clearSelection();
+ this._updateContextDisplay();
+ },
+
+ // Whether we're about to select a message
+ _aboutToSelectMessage: false,
+
+ /**
+ * This needs to be called to let us know that a selectMessage or equivalent
+ * is coming up right after a show() call, so that we know that a double
+ * message load won't be happening.
+ *
+ * This can be assumed to be idempotent.
+ */
+ selectMessageComingUp() {
+ this._aboutToSelectMessage = true;
+ },
+
+ /**
+ * Select a message for display by header. Attempt to select the message
+ * right now. If we were unable to find it, update our saved selection
+ * to want to display the message. Threads are expanded to find the header.
+ *
+ * @param aMsgHdr The message header to select for display.
+ * @param [aForceSelect] If the message is not in the view and this is true,
+ * we will drop any applied view filters to look for the
+ * message. The dropping of view filters is persistent,
+ * so use with care. Defaults to false.
+ */
+ selectMessage(aMsgHdr, aForceSelect) {
+ let viewIndex = this.view.getViewIndexForMsgHdr(aMsgHdr, aForceSelect);
+ if (viewIndex != nsMsgViewIndex_None) {
+ this._savedSelection = null;
+ this.selectViewIndex(viewIndex);
+ } else {
+ this._savedSelection = {
+ messages: [{ messageId: aMsgHdr.messageId }],
+ forceSelect: aForceSelect,
+ };
+ // queue the selection to be restored once we become active if we are not
+ // active.
+ if (!this.active) {
+ this._notifyWhenActive(this._restoreSelection);
+ }
+ }
+
+ // Do this here instead of at the beginning to prevent reentrancy issues
+ this._aboutToSelectMessage = false;
+ },
+
+ /**
+ * Select all of the provided nsIMsgDBHdrs in the aMessages array, expanding
+ * threads as required. If we were not able to find all of the messages,
+ * update our saved selection to want to display the messages. The messages
+ * will then be selected when we are made active or all messages in the
+ * folder complete loading. This is to accommodate the use-case where we
+ * are backed by an in-progress search and no
+ *
+ * @param aMessages An array of nsIMsgDBHdr instances.
+ * @param [aForceSelect] If a message is not in the view and this is true,
+ * we will drop any applied view filters to look for the
+ * message. The dropping of view filters is persistent,
+ * so use with care. Defaults to false.
+ * @param aDoNotNeedToFindAll If true (can be omitted and left undefined), we
+ * do not attempt to save the selection for future use. This is intended
+ * for use by the _restoreSelection call which is the end-of-the-line for
+ * restoring the selection. (Once it gets called all of our messages
+ * should have already been loaded.)
+ */
+ selectMessages(aMessages, aForceSelect, aDoNotNeedToFindAll) {
+ let treeSelection = this.treeSelection; // potentially magic getter
+ let foundAll = true;
+ if (treeSelection) {
+ let minRow = null,
+ maxRow = null;
+
+ treeSelection.selectEventsSuppressed = true;
+ treeSelection.clearSelection();
+
+ for (let msgHdr of aMessages) {
+ let viewIndex = this.view.getViewIndexForMsgHdr(msgHdr, aForceSelect);
+
+ if (viewIndex != nsMsgViewIndex_None) {
+ if (minRow == null || viewIndex < minRow) {
+ minRow = viewIndex;
+ }
+ if (maxRow == null || viewIndex > maxRow) {
+ maxRow = viewIndex;
+ }
+ // nsTreeSelection is actually very clever about doing this
+ // efficiently.
+ treeSelection.rangedSelect(viewIndex, viewIndex, true);
+ } else {
+ foundAll = false;
+ }
+
+ // make sure the selection is as visible as possible
+ if (minRow != null) {
+ this.ensureRowRangeIsVisible(minRow, maxRow);
+ }
+ }
+
+ treeSelection.selectEventsSuppressed = false;
+
+ // If we haven't selected every message, we'll set |this._savedSelection|
+ // below, so it's fine to null it out at this point.
+ this._savedSelection = null;
+ }
+
+ // Do this here instead of at the beginning to prevent reentrancy issues
+ this._aboutToSelectMessage = false;
+
+ // Two cases.
+ // 1. The tree selection isn't there at all.
+ // 2. The tree selection is there, and we needed to find all messages, but
+ // we didn't.
+ if (!treeSelection || (!aDoNotNeedToFindAll && !foundAll)) {
+ this._savedSelection = {
+ messages: aMessages.map(msgHdr => ({ messageId: msgHdr.messageId })),
+ forceSelect: aForceSelect,
+ };
+ if (!this.active) {
+ this._notifyWhenActive(this._restoreSelection);
+ }
+ }
+ },
+
+ /**
+ * Select the message at view index.
+ *
+ * @param aViewIndex The view index to select. This will be bounds-checked
+ * and if it is outside the bounds, we will clear the selection and
+ * bail.
+ */
+ selectViewIndex(aViewIndex) {
+ let treeSelection = this.treeSelection;
+ // if we have no selection, we can't select something
+ if (!treeSelection) {
+ return;
+ }
+ let rowCount = this.view.dbView.rowCount;
+ if (
+ aViewIndex == nsMsgViewIndex_None ||
+ aViewIndex < 0 ||
+ aViewIndex >= rowCount
+ ) {
+ this.clearSelection();
+ return;
+ }
+
+ // Check whether the index is already selected/current. This can be the
+ // case when we are here as the result of a deletion. Assuming
+ // nsMsgDBView::NoteChange ran and was not suppressing change
+ // notifications, then it's very possible the selection is already where
+ // we want it to go. However, in that case, nsMsgDBView::SelectionChanged
+ // bailed without doing anything because m_deletingRows...
+ // So we want to generate a change notification if that is the case. (And
+ // we still want to call ensureRowIsVisible, as there may be padding
+ // required.)
+ if (
+ treeSelection.count == 1 &&
+ (treeSelection.currentIndex == aViewIndex ||
+ treeSelection.isSelected(aViewIndex))
+ ) {
+ // Make sure the index we just selected is also the current index.
+ // This can happen when the tree selection adjusts itself as a result of
+ // changes to the tree as a result of deletion. This will not trigger
+ // a notification.
+ treeSelection.select(aViewIndex);
+ this.view.dbView.selectionChanged();
+ } else {
+ // Previous code was concerned about avoiding updating commands on the
+ // assumption that only the selection count mattered. We no longer
+ // make this assumption.
+ // Things that may surprise you about the call to treeSelection.select:
+ // 1) This ends up calling the onselect method defined on the XUL 'tree'
+ // tag. For the 3pane this is the ThreadPaneSelectionChanged method in
+ // threadPane.js. That code checks a global to see if it is dealing
+ // with a right-click, and ignores it if so.
+ treeSelection.select(aViewIndex);
+ }
+
+ if (this._active) {
+ this.ensureRowIsVisible(aViewIndex);
+ }
+
+ // The saved selection is invalidated, since we've got something newer
+ this._savedSelection = null;
+
+ // Do this here instead of at the beginning to prevent reentrancy issues
+ this._aboutToSelectMessage = false;
+ },
+
+ /**
+ * For every selected message in the display that is part of a (displayed)
+ * thread and is not the root message, de-select it and ensure that the
+ * root message of the thread is selected.
+ * This is primarily intended to be used when collapsing visible threads.
+ *
+ * We do nothing if we are not in a threaded display mode.
+ */
+ selectSelectedThreadRoots() {
+ if (!this.view.showThreaded) {
+ return;
+ }
+
+ // There are basically two implementation strategies available to us:
+ // 1) For each selected view index with a level > 0, keep walking 'up'
+ // (numerically smaller) until we find a message with level 0.
+ // The inefficiency here is the potentially large number of JS calls
+ // into XPCOM space that will be required.
+ // 2) Ask for the thread that each view index belongs to, use that to
+ // efficiently retrieve the thread root, then find the root using
+ // the message header. The inefficiency here is that the view
+ // currently does a linear scan, albeit a relatively efficient one.
+ // And the winner is... option 2, because the code is simpler because we
+ // can reuse selectMessages to do most of the work.
+ let selectedIndices = this.selectedIndices;
+ let newSelectedMessages = [];
+ let dbView = this.view.dbView;
+ for (let index of selectedIndices) {
+ let thread = dbView.getThreadContainingIndex(index);
+ newSelectedMessages.push(thread.getRootHdr());
+ }
+ this.selectMessages(newSelectedMessages);
+ },
+
+ // @}
+
+ /**
+ * @name Ensure Visibility
+ */
+ // @{
+
+ /**
+ * Minimum number of lines to display between the 'focused' message and the
+ * top / bottom of the thread pane.
+ */
+ get visibleRowPadding() {
+ let topPadding, bottomPadding;
+
+ // If we can get the height of the folder pane, treat the values as
+ // percentages of that.
+ if (this.tree) {
+ let topPercentPadding = Services.prefs.getIntPref(
+ "mail.threadpane.padding.top_percent"
+ );
+ let bottomPercentPadding = Services.prefs.getIntPref(
+ "mail.threadpane.padding.bottom_percent"
+ );
+
+ // Assume the bottom row is half-visible and should generally be ignored.
+ // (We could actually do the legwork to see if there is a partial one...)
+ let paneHeight = this.tree.getPageLength() - 1;
+
+ // Convert from percentages to absolute row counts.
+ topPadding = Math.ceil((topPercentPadding / 100) * paneHeight);
+ bottomPadding = Math.ceil((bottomPercentPadding / 100) * paneHeight);
+
+ // We need one visible row not counted in either padding, for the actual
+ // target message. Also helps correct for rounding errors.
+ if (topPadding + bottomPadding > paneHeight) {
+ if (topPadding > bottomPadding) {
+ topPadding--;
+ } else {
+ bottomPadding--;
+ }
+ }
+ } else {
+ // Something's gone wrong elsewhere, and we likely have bigger problems.
+ topPadding = 0;
+ bottomPadding = 0;
+ console.error("Unable to get height of folder pane (treeBox is null)");
+ }
+
+ return [topPadding, bottomPadding];
+ },
+
+ /**
+ * Ensure the given view index is visible, optionally with some padding.
+ * By padding, we mean that the index will not be the first or last message
+ * displayed, but rather have messages on either side.
+ * We have the concept of a 'lip' when we are at the end of the message
+ * display. If we are near the end of the display, we want to show an
+ * empty row (at the bottom) so the user knows they are at the end. Also,
+ * if a message shows up that is new and things are sorted ascending, this
+ * turns out to be useful.
+ */
+ ensureRowIsVisible(aViewIndex, aBounced) {
+ // Dealing with the tree view layout is a nightmare, let's just always make
+ // sure we re-schedule ourselves. The most particular rationale here is
+ // that the message pane may be toggling its state and it's much simpler
+ // and reliable if we ensure that all of FolderDisplayWidget's state
+ // change logic gets to run to completion before we run ourselves.
+ if (!aBounced) {
+ let dis = this;
+ window.setTimeout(function () {
+ dis.ensureRowIsVisible(aViewIndex, true);
+ }, 0);
+ }
+
+ let tree = this.tree;
+ if (!tree || !tree.view) {
+ return;
+ }
+
+ // try and trigger a reflow...
+ tree.getBoundingClientRect();
+
+ let maxIndex = tree.view.rowCount - 1;
+
+ let first = tree.getFirstVisibleRow();
+ // Assume the bottom row is half-visible and should generally be ignored.
+ // (We could actually do the legwork to see if there is a partial one...)
+ const halfVisible = 1;
+ let last = tree.getLastVisibleRow() - halfVisible;
+ let span = tree.getPageLength() - halfVisible;
+ let [topPadding, bottomPadding] = this.visibleRowPadding;
+
+ let target;
+ if (aViewIndex >= last - bottomPadding) {
+ // The index is after the last visible guy (with padding),
+ // move down so that the target index is padded in 1 from the bottom.
+ target = Math.min(maxIndex, aViewIndex + bottomPadding) - span;
+ } else if (aViewIndex <= first + topPadding) {
+ // The index is before the first visible guy (with padding), move up.
+ target = Math.max(0, aViewIndex - topPadding);
+ } else {
+ // It is already visible.
+ return;
+ }
+
+ // this sets the first visible row
+ tree.scrollToRow(target);
+ },
+
+ /**
+ * Ensure that the given range of rows is maximally visible in the thread
+ * pane. If the range is larger than the number of rows that can be
+ * displayed in the thread pane, we bias towards showing the min row (with
+ * padding).
+ *
+ * @param aMinRow The numerically smallest row index defining the start of
+ * the inclusive range.
+ * @param aMaxRow The numberically largest row index defining the end of the
+ * inclusive range.
+ */
+ ensureRowRangeIsVisible(aMinRow, aMaxRow, aBounced) {
+ // Dealing with the tree view layout is a nightmare, let's just always make
+ // sure we re-schedule ourselves. The most particular rationale here is
+ // that the message pane may be toggling its state and it's much simpler
+ // and reliable if we ensure that all of FolderDisplayWidget's state
+ // change logic gets to run to completion before we run ourselves.
+ if (!aBounced) {
+ let dis = this;
+ window.setTimeout(function () {
+ dis.ensureRowRangeIsVisible(aMinRow, aMaxRow, true);
+ }, 0);
+ }
+
+ let tree = this.tree;
+ if (!tree) {
+ return;
+ }
+ let first = tree.getFirstVisibleRow();
+ const halfVisible = 1;
+ let last = tree.getLastVisibleRow() - halfVisible;
+ let span = tree.getPageLength() - halfVisible;
+ let [topPadding, bottomPadding] = this.visibleRowPadding;
+
+ // bail if the range is already visible with padding constraints handled
+ if (first + topPadding <= aMinRow && last - bottomPadding >= aMaxRow) {
+ return;
+ }
+
+ let target;
+ // if the range is bigger than we can fit, optimize position for the min row
+ // with padding to make it obvious the range doesn't extend above the row.
+ if (aMaxRow - aMinRow > span) {
+ target = Math.max(0, aMinRow - topPadding);
+ } else {
+ // So the range must fit, and it's a question of how we want to position
+ // it. For now, the answer is we try and center it, why not.
+ let rowSpan = aMaxRow - aMinRow + 1;
+ let halfSpare = Math.floor(
+ (span - rowSpan - topPadding - bottomPadding) / 2
+ );
+ target = aMinRow - halfSpare - topPadding;
+ }
+ tree.scrollToRow(target);
+ },
+
+ /**
+ * Ensure that the selection is visible to the extent possible.
+ */
+ ensureSelectionIsVisible() {
+ let treeSelection = this.treeSelection; // potentially magic getter
+ if (!treeSelection || !treeSelection.count) {
+ return;
+ }
+
+ let minRow = null,
+ maxRow = null;
+
+ let rangeCount = treeSelection.getRangeCount();
+ for (let iRange = 0; iRange < rangeCount; iRange++) {
+ let rangeMinObj = {},
+ rangeMaxObj = {};
+ treeSelection.getRangeAt(iRange, rangeMinObj, rangeMaxObj);
+ let rangeMin = rangeMinObj.value,
+ rangeMax = rangeMaxObj.value;
+ if (minRow == null || rangeMin < minRow) {
+ minRow = rangeMin;
+ }
+ if (maxRow == null || rangeMax > maxRow) {
+ maxRow = rangeMax;
+ }
+ }
+
+ this.ensureRowRangeIsVisible(minRow, maxRow);
+ },
+ // @}
+};
+
+function SetNewsFolderColumns() {
+ var sizeColumn = document.getElementById("sizeCol");
+ var bundle = document.getElementById("bundle_messenger");
+
+ if (gDBView.usingLines) {
+ sizeColumn.setAttribute("label", bundle.getString("linesColumnHeader"));
+ sizeColumn.setAttribute(
+ "tooltiptext",
+ bundle.getString("linesColumnTooltip2")
+ );
+ } else {
+ sizeColumn.setAttribute("label", bundle.getString("sizeColumnHeader"));
+ sizeColumn.setAttribute(
+ "tooltiptext",
+ bundle.getString("sizeColumnTooltip2")
+ );
+ }
+}
+
+function UpdateStatusQuota(folder) {
+ if (!document.getElementById("quotaPanel")) {
+ // No quotaPanel in here, like for the search window.
+ return;
+ }
+
+ if (!(folder && folder instanceof Ci.nsIMsgImapMailFolder)) {
+ document.getElementById("quotaPanel").hidden = true;
+ return;
+ }
+
+ let quotaUsagePercentage = q =>
+ Number((100n * BigInt(q.usage)) / BigInt(q.limit));
+
+ // For display on main window panel only include quota names containing
+ // "STORAGE" or "MESSAGE". This will exclude unusual quota names containing
+ // items like "MAILBOX" and "LEVEL" from the panel bargraph. All quota names
+ // will still appear on the folder properties quota window.
+ // Note: Quota name is typically something like "User Quota / STORAGE".
+ let folderQuota = folder
+ .getQuota()
+ .filter(
+ quota =>
+ quota.name.toUpperCase().includes("STORAGE") ||
+ quota.name.toUpperCase().includes("MESSAGE")
+ );
+ // If folderQuota not empty, find the index of the element with highest
+ // percent usage and determine if it is above the panel display threshold.
+ if (folderQuota.length > 0) {
+ let highest = folderQuota.reduce((acc, current) =>
+ quotaUsagePercentage(acc) > quotaUsagePercentage(current) ? acc : current
+ );
+ let percent = quotaUsagePercentage(highest);
+ if (
+ percent <
+ Services.prefs.getIntPref("mail.quota.mainwindow_threshold.show")
+ ) {
+ document.getElementById("quotaPanel").hidden = true;
+ } else {
+ document.getElementById("quotaPanel").hidden = false;
+ document.getElementById("quotaMeter").setAttribute("value", percent);
+ var bundle = document.getElementById("bundle_messenger");
+ document.getElementById("quotaLabel").value = bundle.getFormattedString(
+ "percent",
+ [percent]
+ );
+ document.getElementById("quotaLabel").tooltipText =
+ bundle.getFormattedString("quotaTooltip2", [
+ highest.usage,
+ highest.limit,
+ ]);
+ let quotaPanel = document.getElementById("quotaPanel");
+ 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");
+ }
+ }
+ } else {
+ document.getElementById("quotaPanel").hidden = true;
+ }
+}