diff options
Diffstat (limited to '')
-rw-r--r-- | comm/mail/base/content/folderDisplay.js | 2649 |
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; + } +} |