diff options
Diffstat (limited to 'comm/mail/modules/DBViewWrapper.jsm')
-rw-r--r-- | comm/mail/modules/DBViewWrapper.jsm | 2250 |
1 files changed, 2250 insertions, 0 deletions
diff --git a/comm/mail/modules/DBViewWrapper.jsm b/comm/mail/modules/DBViewWrapper.jsm new file mode 100644 index 0000000000..e88ac3dc05 --- /dev/null +++ b/comm/mail/modules/DBViewWrapper.jsm @@ -0,0 +1,2250 @@ +/* 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/. */ + +const EXPORTED_SYMBOLS = ["DBViewWrapper", "IDBViewWrapperListener"]; + +const { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" +); +const { MailViewConstants, MailViewManager } = ChromeUtils.import( + "resource:///modules/MailViewManager.jsm" +); +const { SearchSpec } = ChromeUtils.import("resource:///modules/SearchSpec.jsm"); +const { VirtualFolderHelper } = ChromeUtils.import( + "resource:///modules/VirtualFolderWrapper.jsm" +); + +var MSG_VIEW_FLAG_DUMMY = 0x20000000; + +var nsMsgViewIndex_None = 0xffffffff; + +/** + * Helper singleton for DBViewWrapper that tells instances when something + * interesting is happening to the folder(s) they care about. The rationale + * for this is to: + * - reduce listener overhead (although arguably the events we listen to are + * fairly rare) + * - make testing / verification easier by centralizing and exposing listeners. + * + */ +var FolderNotificationHelper = { + /** + * Maps URIs of pending folder loads to the DBViewWrapper instances that + * are waiting on the loads. The value is a list of instances in case + * a quick-clicking user is able to do something unexpected. + */ + _pendingFolderUriToViewWrapperLists: {}, + + /** + * Map URIs of folders to view wrappers interested in hearing about their + * deletion. + */ + _interestedWrappers: {}, + + /** + * Array of wrappers that are interested in all folders, used for + * search results wrappers. + */ + _curiousWrappers: [], + + /** + * Initialize our listeners. We currently don't bother cleaning these up + * because we are a singleton and if anyone imports us, they probably want + * us for as long as their application so shall live. + */ + _init() { + // register with the session for our folded loaded notifications + MailServices.mailSession.AddFolderListener( + this, + Ci.nsIFolderListener.event | Ci.nsIFolderListener.intPropertyChanged + ); + + // register with the notification service for deleted folder notifications + MailServices.mfn.addListener( + this, + Ci.nsIMsgFolderNotificationService.folderDeleted | + // we need to track renames because we key off of URIs. frick. + Ci.nsIMsgFolderNotificationService.folderRenamed | + Ci.nsIMsgFolderNotificationService.folderMoveCopyCompleted + ); + }, + + /** + * Call updateFolder, and assuming all goes well, request that the provided + * FolderDisplayWidget be notified when the folder is loaded. This method + * performs the updateFolder call for you so there is less chance of leaking. + * In the event the updateFolder call fails, we will propagate the exception. + */ + updateFolderAndNotifyOnLoad(aFolder, aFolderDisplay, aMsgWindow) { + // set up our datastructure first in case of wacky event sequences + let folderURI = aFolder.URI; + let wrappers = this._pendingFolderUriToViewWrapperLists[folderURI]; + if (wrappers == null) { + wrappers = this._pendingFolderUriToViewWrapperLists[folderURI] = []; + } + wrappers.push(aFolderDisplay); + try { + aFolder.updateFolder(aMsgWindow); + } catch (ex) { + // uh-oh, that didn't work. tear down the data structure... + wrappers.pop(); + if (wrappers.length == 0) { + delete this._pendingFolderUriToViewWrapperLists[folderURI]; + } + throw ex; + } + }, + + /** + * Request notification of every little thing these folders do. + * + * @param aFolders The folders. + * @param aNotherFolder A folder that may or may not be in aFolders. + * @param aViewWrapper The view wrapper that is up to no good. + */ + stalkFolders(aFolders, aNotherFolder, aViewWrapper) { + let folders = aFolders ? aFolders.concat() : []; + if (aNotherFolder && !folders.includes(aNotherFolder)) { + folders.push(aNotherFolder); + } + for (let folder of folders) { + let wrappers = this._interestedWrappers[folder.URI]; + if (wrappers == null) { + wrappers = this._interestedWrappers[folder.URI] = []; + } + wrappers.push(aViewWrapper); + } + }, + + /** + * Request notification of every little thing every folder does. + * + * @param aViewWrapper - the viewWrapper interested in every notification. + * This will be a search results view of some sort. + */ + noteCuriosity(aViewWrapper) { + this._curiousWrappers.push(aViewWrapper); + }, + + /** + * Removal helper for use by removeNotifications. + * + * @param aTable The table mapping URIs to list of view wrappers. + * @param aFolder The folder we care about. + * @param aViewWrapper The view wrapper of interest. + */ + _removeWrapperFromListener(aTable, aFolder, aViewWrapper) { + let wrappers = aTable[aFolder.URI]; + if (wrappers) { + let index = wrappers.indexOf(aViewWrapper); + if (index >= 0) { + wrappers.splice(index, 1); + } + if (wrappers.length == 0) { + delete aTable[aFolder.URI]; + } + } + }, + /** + * Remove notification requests on the provided folders by the given view + * wrapper. + */ + removeNotifications(aFolders, aViewWrapper) { + if (!aFolders) { + this._curiousWrappers.splice( + this._curiousWrappers.indexOf(aViewWrapper), + 1 + ); + return; + } + for (let folder of aFolders) { + this._removeWrapperFromListener( + this._interestedWrappers, + folder, + aViewWrapper + ); + this._removeWrapperFromListener( + this._pendingFolderUriToViewWrapperLists, + folder, + aViewWrapper + ); + } + }, + + /** + * @returns true if there are any listeners still registered. This is intended + * to support debugging code. If you are not debug code, you are a bad + * person/code. + */ + haveListeners() { + if (Object.keys(this._pendingFolderUriToViewWrapperLists).length > 0) { + return true; + } + if (Object.keys(this._interestedWrappers).length > 0) { + return true; + } + return this._curiousWrappers.length != 0; + }, + + /* ***** Notifications ***** */ + _notifyHelper(aFolder, aHandlerName) { + let wrappers = this._interestedWrappers[aFolder.URI]; + if (wrappers) { + // clone the list to avoid confusing mutation by listeners + for (let wrapper of wrappers.concat()) { + wrapper[aHandlerName](aFolder); + } + } + for (let wrapper of this._curiousWrappers) { + wrapper[aHandlerName](aFolder); + } + }, + + onFolderEvent(aFolder, aEvent) { + if (aEvent == "FolderLoaded") { + let folderURI = aFolder.URI; + let widgets = this._pendingFolderUriToViewWrapperLists[folderURI]; + if (widgets) { + for (let widget of widgets) { + // we are friends, this is an explicit relationship. + // (we don't use a generic callback mechanism because the 'this' stuff + // gets ugly and no one else should be hooking in at this level.) + try { + widget._folderLoaded(aFolder); + } catch (ex) { + dump( + "``` EXCEPTION DURING NOTIFY: " + + ex.fileName + + ":" + + ex.lineNumber + + ": " + + ex + + "\n" + ); + if (ex.stack) { + dump("STACK: " + ex.stack + "\n"); + } + console.error(ex); + } + } + delete this._pendingFolderUriToViewWrapperLists[folderURI]; + } + } else if (aEvent == "AboutToCompact") { + this._notifyHelper(aFolder, "_aboutToCompactFolder"); + } else if (aEvent == "CompactCompleted") { + this._notifyHelper(aFolder, "_compactedFolder"); + } else if (aEvent == "DeleteOrMoveMsgCompleted") { + this._notifyHelper(aFolder, "_deleteCompleted"); + } else if (aEvent == "DeleteOrMoveMsgFailed") { + this._notifyHelper(aFolder, "_deleteFailed"); + } else if (aEvent == "RenameCompleted") { + this._notifyHelper(aFolder, "_renameCompleted"); + } + }, + + onFolderIntPropertyChanged(aFolder, aProperty, aOldValue, aNewValue) { + if (aProperty == "TotalMessages" || aProperty == "TotalUnreadMessages") { + this._notifyHelper(aFolder, "_messageCountsChanged"); + } + }, + + _folderMoveHelper(aOldFolder, aNewFolder) { + let oldURI = aOldFolder.URI; + let newURI = aNewFolder.URI; + // fix up our listener tables. + if (oldURI in this._pendingFolderUriToViewWrapperLists) { + this._pendingFolderUriToViewWrapperLists[newURI] = + this._pendingFolderUriToViewWrapperLists[oldURI]; + delete this._pendingFolderUriToViewWrapperLists[oldURI]; + } + if (oldURI in this._interestedWrappers) { + this._interestedWrappers[newURI] = this._interestedWrappers[oldURI]; + delete this._interestedWrappers[oldURI]; + } + + let wrappers = this._interestedWrappers[newURI]; + if (wrappers) { + // clone the list to avoid confusing mutation by listeners + for (let wrapper of wrappers.concat()) { + wrapper._folderMoved(aOldFolder, aNewFolder); + } + } + }, + + /** + * Update our URI mapping tables when renames happen. + */ + folderRenamed(aOrigFolder, aNewFolder) { + this._folderMoveHelper(aOrigFolder, aNewFolder); + }, + + folderMoveCopyCompleted(aMove, aSrcFolder, aDestFolder) { + if (aMove) { + let aNewFolder = aDestFolder.getChildNamed(aSrcFolder.prettyName); + this._folderMoveHelper(aSrcFolder, aNewFolder); + } + }, + + folderDeleted(aFolder) { + let wrappers = this._interestedWrappers[aFolder.URI]; + if (wrappers) { + // clone the list to avoid confusing mutation by listeners + for (let wrapper of wrappers.concat()) { + wrapper._folderDeleted(aFolder); + } + // if the folder is deleted, it's not going to ever do anything again + delete this._interestedWrappers[aFolder.URI]; + } + }, +}; +FolderNotificationHelper._init(); + +/** + * Defines the DBViewWrapper listener interface. This class exists exclusively + * for documentation purposes and should never be instantiated. + */ +function IDBViewWrapperListener() {} +IDBViewWrapperListener.prototype = { + // uh, this is secretly exposed for debug purposes. DO NOT LOOK AT ME! + _FNH: FolderNotificationHelper, + + /* ===== Exposure of UI Globals ===== */ + messenger: null, + msgWindow: null, + threadPaneCommandUpdater: null, + + /* ===== Guidance ===== */ + /** + * Indicate whether mail view settings should be used/honored. A UI oddity + * is that we only have mail views be sticky if its combo box UI is visible. + * (Without the view combobox, it may not be obvious that the mail is + * filtered.) + */ + get shouldUseMailViews() { + return false; + }, + + /** + * Should we defer displaying the messages in this folder until after we have + * talked to the server? This is for our poor man's password protection + * via the "mail.password_protect_local_cache" pref. We add this specific + * check rather than internalizing the logic in the wrapper because the + * password protection is a shoddy UI-only protection. + */ + get shouldDeferMessageDisplayUntilAfterServerConnect() { + return false; + }, + + /** + * Should we mark all messages in a folder as read on exit? + * This is nominally controlled by the "mailnews.mark_message_read.SERVERTYPE" + * preference (on a per-server-type basis). + * For the record, this functionality should not remotely be in the core. + * + * @param aMsgFolder The folder we are leaving and are unsure if we should + * mark all its messages read. I pass the folder instead of the server + * type because having a crazy feature like this will inevitably lead to + * a more full-featured crazy feature (why not on a per-folder basis, eh?) + * @returns true if we should mark all the dudes as read, false if not. + */ + shouldMarkMessagesReadOnLeavingFolder(aMsgFolder) { + return false; + }, + + /* ===== Event Notifications ===== */ + /* === Status Changes === */ + /** + * We tell you when we start and stop loading the folder. This is a good + * time to mess with the hour-glass cursor machinery if you are inclined to + * do so. + */ + onFolderLoading(aIsFolderLoading) {}, + + /** + * We tell you when we start and stop searching. This is a good time to mess + * with progress spinners (meteors) and the like, if you are so inclined. + */ + onSearching(aIsSearching) {}, + + /** + * This event is generated when a new view has been created. It is intended + * to be used to provide the MsgCreateDBView notification so that custom + * columns can add themselves to the view. + * The notification is not generated by the DBViewWrapper itself because this + * is fundamentally a UI issue. Additionally, because the MsgCreateDBView + * notification consumers assume gDBView whose exposure is affected by tabs, + * the tab logic needs to be involved. + */ + onCreatedView() {}, + + /** + * This event is generated just before we close/destroy a message view. + * + * @param aFolderIsComingBack Indicates whether we are planning to create a + * new view to display the same folder after we destroy this view. This + * will be the case unless we are switching to display a new folder or + * closing the view wrapper entirely. + */ + onDestroyingView(aFolderIsComingBack) {}, + + /** + * Generated when we are loading information about the folder from its + * dbFolderInfo. The dbFolderInfo object is passed in. + * The DBViewWrapper has already restored its state when this function is + * called, but has not yet created the dbView. A view update is in process, + * so the view settings can be changed and will take effect when the update + * is closed. + * |onDisplayingFolder| is the next expected notification following this + * notification. + */ + onLoadingFolder(aDbFolderInfo) {}, + + /** + * Generated when the folder is being entered for display. This is the chance + * for the listener to affect any UI-related changes to the folder required. + * Currently, this just means setting the header cache size (which needs to + * be proportional to the number of lines in the tree view, and is thus a + * UI issue.) + * The dbView has already been created and is valid when this function is + * called. + * |onLoadingFolder| is called before this notification. + */ + onDisplayingFolder() {}, + + /** + * Generated when we are leaving a folder. + */ + onLeavingFolder() {}, + + /** + * Things to do once 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 (multi-folder) virtual folder, this happens when the search + * completes. + * You may get onMessagesLoaded called with aAll false immediately after + * the view is opened. You will definitely get onMessagesLoaded(true) + * when we've finished getting the headers for the view. + */ + onMessagesLoaded(aAll) {}, + + /** + * The mail view changed. The mail view widget is likely to care about this. + */ + onMailViewChanged() {}, + + /** + * The active sort changed, and that is all that changed. If the sort is + * changing because the view is being destroyed and re-created, this event + * will not be generated. + */ + onSortChanged() {}, + + /** + * This event is generated when messages in one of the folders backing the + * view have been removed by message moves / deletion. If there is a search + * in effect, it is possible that the removed messages were not visible in + * the view in the first place. + */ + onMessagesRemoved() {}, + + /** + * Like onMessagesRemoved, but something went awry in the move/deletion and + * it failed. Although this is not a very interesting event on its own, + * it is useful in cases where the listener was expecting an + * onMessagesRemoved and might need to clean some state up. + */ + onMessageRemovalFailed() {}, + + /** + * The total message count or total unread message counts changed. + */ + onMessageCountsChanged() {}, +}; + +/** + * Encapsulates everything related to working with our nsIMsgDBView + * implementations. + * + * Things we do not do and why we do not do them: + * - Selection. This depends on having an nsITreeSelection object and we choose + * to avoid entanglement with XUL/layout code. Selection accordingly must be + * handled a layer up in the FolderDisplayWidget. + */ +function DBViewWrapper(aListener) { + this.displayedFolder = null; + this.listener = aListener; + + this._underlyingData = this.kUnderlyingNone; + this._underlyingFolders = null; + this._syntheticView = null; + + this._viewUpdateDepth = 0; + + this._mailViewIndex = MailViewConstants.kViewItemAll; + this._mailViewData = null; + + this._specialView = null; + + this._sort = []; + // see the _viewFlags getter and setter for info on our use of __viewFlags. + this.__viewFlags = null; + + /** + * It's possible to support grouped view thread expand/collapse, and also sort + * by thread despite the back end (see nsMsgQuickSearchDBView::SortThreads). + * Also, nsMsgQuickSearchDBView does not respect the kExpandAll flag, fix that. + */ + this._threadExpandAll = true; + + this.dbView = null; + this.search = null; + + this._folderLoading = false; + this._searching = false; +} +DBViewWrapper.prototype = { + /* = constants explaining the nature of the underlying data = */ + /** + * We currently don't have any underlying data. + */ + kUnderlyingNone: 0, + /** + * The underlying data source is a single folder. + */ + kUnderlyingRealFolder: 1, + /** + * The underlying data source is a virtual folder that is operating over + * multiple underlying folders. + */ + kUnderlyingMultipleFolder: 2, + /** + * Our data source is transient, most likely a gloda search that crammed the + * results into us. This is different from a search view. + */ + kUnderlyingSynthetic: 3, + /** + * We are a search view, which translates into a search that has underlying + * folders, just like kUnderlyingMultipleFolder, but we have no + * displayedFolder. We differ from kUnderlyingSynthetic in that we are + * not just a bunch of message headers randomly crammed in. + */ + kUnderlyingSearchView: 4, + + /** + * @returns true if the folder being displayed is backed by a single 'real' + * folder. This folder can be a saved search on that folder or just + * an outright un-filtered display of that folder. + */ + get isSingleFolder() { + return this._underlyingData == this.kUnderlyingRealFolder; + }, + + /** + * @returns true if the folder being displayed is a virtual folder backed by + * multiple 'real' folders or a search view. This corresponds to a + * cross-folder saved search. + */ + get isMultiFolder() { + return ( + this._underlyingData == this.kUnderlyingMultipleFolder || + this._underlyingData == this.kUnderlyingSearchView + ); + }, + + /** + * @returns true if the folder being displayed is not a real folder at all, + * but rather the result of an un-scoped search, such as a gloda search. + */ + get isSynthetic() { + return this._underlyingData == this.kUnderlyingSynthetic; + }, + + /** + * @returns true if the folder being displayed is not a real folder at all, + * but rather the result of a search. + */ + get isSearch() { + return this._underlyingData == this.kUnderlyingSearchView; + }, + + /** + * Check if the folder in question backs the currently displayed folder. For + * a virtual folder, this is a test of whether the virtual folder includes + * messages from the given folder. For a 'real' single folder, this is + * effectively a test against displayedFolder. + * If you want to see if the displayed folder is a folder, just compare + * against the displayedFolder attribute. + */ + isUnderlyingFolder(aFolder) { + return this._underlyingFolders.some( + underlyingFolder => aFolder == underlyingFolder + ); + }, + + /** + * Refresh the view by re-creating the view. You would do this to get rid of + * messages that no longer match the view but are kept around for view + * stability reasons. (In other words, in an unread-messages view, you would + * go insane if when you clicked on a message it immediately disappeared + * because it no longer matched.) + * This method was adding for testing purposes and does not have a (legacy) UI + * reason for existing. (The 'open' method is intended to behave identically + * to the legacy UI if you click on the currently displayed folder.) + */ + refresh() { + this._applyViewChanges(); + }, + + /** + * Null out the folder's database to avoid memory bloat if we don't have a + * reason to keep the database around. Currently, we keep all Inboxes + * around and null out everyone else. This is a standard stopgap measure + * until we have something more clever going on. + * In general, there is little potential downside to nulling out the message + * database reference when it is in use. As long as someone is holding onto + * a message header from the database, the database will be kept open, and + * therefore the database service will still have a reference to the db. + * When the folder goes to ask for the database again, the service will have + * it, and it will not need to be re-opened. + * + * Another heuristic we could theoretically use is use the mail session's + * isFolderOpenInWindow call, except that uses the outmoded concept that each + * window will have at most one folder open. So nuts to that. + * + * Note: regrettably a unit test cannot verify that we did this; msgDatabase + * is a getter that will always try and load the message database! + */ + _releaseFolderDatabase(aFolder) { + if (!aFolder.isSpecialFolder(Ci.nsMsgFolderFlags.Inbox, false)) { + aFolder.msgDatabase = null; + } + }, + + /** + * Clone this DBViewWrapper and its underlying nsIMsgDBView. + * + * @param aListener {IDBViewWrapperListener} The listener to use on the new view. + */ + clone(aListener) { + let doppel = new DBViewWrapper(aListener); + + // -- copy attributes + doppel.displayedFolder = this.displayedFolder; + doppel._underlyingData = this._underlyingData; + doppel._underlyingFolders = this._underlyingFolders + ? this._underlyingFolders.concat() + : null; + doppel._syntheticView = this._syntheticView; + + // _viewUpdateDepth should stay at its initial value of zero + doppel._mailViewIndex = this._mailViewIndex; + doppel._mailViewData = this._mailViewData; + + doppel._specialView = this._specialView; + // a shallow copy is all that is required for sort; we do not mutate entries + doppel._sort = this._sort.concat(); + + // -- register listeners... + // note: this does not get us a folder loaded notification. Our expected + // use case for cloning is displaying a single message already visible in + // the original view, which implies we don't need to hang about for folder + // loaded notification messages. + FolderNotificationHelper.stalkFolders( + doppel._underlyingFolders, + doppel.displayedFolder, + doppel + ); + + // -- clone the view + if (this.dbView) { + doppel.dbView = this.dbView + .cloneDBView( + aListener.messenger, + aListener.msgWindow, + aListener.threadPaneCommandUpdater + ) + .QueryInterface(Ci.nsITreeView); + } + // -- clone the search + if (this.search) { + doppel.search = this.search.clone(doppel); + } + + if ( + doppel._underlyingData == this.kUnderlyingSearchView || + doppel._underlyingData == this.kUnderlyingSynthetic + ) { + FolderNotificationHelper.noteCuriosity(doppel); + } + + return doppel; + }, + + /** + * Close the current view. You would only do this if you want to clean up all + * the resources associated with this view wrapper. You would not do this + * for UI reasons like the user de-selecting the node in the tree; we should + * always be displaying something when used in a UI context! + * + * @param {boolean} folderIsDead - If true, tells us not to try and tidy up + * on our way out by virtue of the fact that the folder is dead and should + * not be messed with. + */ + close(folderIsDead) { + if (this.displayedFolder != null) { + // onLeavingFolder does all the application-level stuff related to leaving + // the folder (marking as read, etc.) We only do this when the folder + // is not dead (for obvious reasons). + if (!folderIsDead) { + // onLeavingFolder must be called before we potentially null out its + // msgDatabase, which we will do in the upcoming underlyingFolders loop + this.onLeavingFolder(); // application logic + this.listener.onLeavingFolder(); // display logic + } + // (potentially) zero out the display folder if we are dealing with a + // virtual folder and so the next loop won't take care of it. + if (this.isVirtual) { + FolderNotificationHelper.removeNotifications( + [this.displayedFolder], + this + ); + this._releaseFolderDatabase(this.displayedFolder); + } + + this.folderLoading = false; + this.displayedFolder = null; + } + + FolderNotificationHelper.removeNotifications(this._underlyingFolders, this); + if (this.isSearch || this.isSynthetic) { + // Opposite of FolderNotificationHelper.noteCuriosity(this) + FolderNotificationHelper.removeNotifications(null, this); + } + + if (this._underlyingFolders) { + // (potentially) zero out the underlying msgDatabase references + for (let folder of this._underlyingFolders) { + this._releaseFolderDatabase(folder); + } + } + + // kill off the view and its search association + if (this.dbView) { + this.listener.onDestroyingView(false); + this.search.dissociateView(this.dbView); + this.dbView.setTree(null); + this.dbView.setJSTree(null); + this.dbView.selection = null; + this.dbView.close(); + this.dbView = null; + } + + // zero out the view update depth here. We don't do it on open because it's + // theoretically be nice to be able to start a view update before you open + // something so you can defer the open. In practice, that is not yet + // tested. + this._viewUpdateDepth = 0; + + this._underlyingData = this.kUnderlyingNone; + this._underlyingFolders = null; + this._syntheticView = null; + + this._mailViewIndex = MailViewConstants.kViewItemAll; + this._mailViewData = null; + + this._specialView = null; + + this._sort = []; + this.__viewFlags = null; + + this.search = null; + }, + + /** + * Open the passed-in nsIMsgFolder folder. Use openSynthetic for synthetic + * view providers. + */ + open(aFolder) { + if (aFolder == null) { + this.close(); + return; + } + + // If we are in the same folder, there is nothing to do unless we are a + // virtual folder. Virtual folders apparently want to try and get updated. + if (this.displayedFolder == aFolder) { + if (!this.isVirtual) { + return; + } + // note: we intentionally (for consistency with old code, not that the + // code claimed to have a good reason) fall through here and call + // onLeavingFolder via close even though that's debatable in this case. + } + this.close(); + + this.displayedFolder = aFolder; + this._enteredFolder = false; + + this.search = new SearchSpec(this); + this._sort = []; + + if (aFolder.isServer) { + this._showServer(); + return; + } + + let typeForTelemetry = + [ + "Inbox", + "Drafts", + "Trash", + "SentMail", + "Templates", + "Junk", + "Archive", + "Queue", + "Virtual", + ].find(x => aFolder.getFlag(Ci.nsMsgFolderFlags[x])) || "Other"; + Services.telemetry.keyedScalarAdd( + "tb.mails.folder_opened", + typeForTelemetry, + 1 + ); + + this.beginViewUpdate(); + let msgDatabase; + try { + // This will throw an exception if the .msf file is missing, + // out of date (e.g., the local folder has changed), or corrupted. + msgDatabase = this.displayedFolder.msgDatabase; + } catch (e) {} + if (msgDatabase) { + this._prepareToLoadView(msgDatabase, aFolder); + } + + if (!this.isVirtual) { + this.folderLoading = true; + FolderNotificationHelper.updateFolderAndNotifyOnLoad( + this.displayedFolder, + this, + this.listener.msgWindow + ); + } + + // we do this after kicking off the update because this could initiate a + // search which could fight our explicit updateFolder call if the search + // is already outstanding. + if (this.shouldShowMessagesForFolderImmediately()) { + this._enterFolder(); + } + }, + + /** + * Open a synthetic view provider as backing our view. + */ + openSynthetic(aSyntheticView) { + this.close(); + + this._underlyingData = this.kUnderlyingSynthetic; + this._syntheticView = aSyntheticView; + + this.search = new SearchSpec(this); + this._sort = this._syntheticView.defaultSort.concat(); + + this._applyViewChanges(); + FolderNotificationHelper.noteCuriosity(this); + this.listener.onDisplayingFolder(); + }, + + /** + * Makes us irrevocavbly be a search view, for use in search windows. + * Once you call this, you are not allowed to use us for anything + * but a search view! + * We add a 'searchFolders' property that allows you to control what + * folders we are searching over. + */ + openSearchView() { + this.close(); + + this._underlyingData = this.kUnderlyingSearchView; + this._underlyingFolders = []; + + let dis = this; + this.__defineGetter__("searchFolders", function () { + return dis._underlyingFolders; + }); + this.__defineSetter__("searchFolders", function (aSearchFolders) { + dis._underlyingFolders = aSearchFolders; + dis._applyViewChanges(); + }); + + this.search = new SearchSpec(this); + // the search view uses the order in which messages are added as the + // order by default. + this._sort = [ + [Ci.nsMsgViewSortType.byNone, Ci.nsMsgViewSortOrder.ascending], + ]; + this.__viewFlags = Ci.nsMsgViewFlagsType.kNone; + + FolderNotificationHelper.noteCuriosity(this); + this._applyViewChanges(); + }, + + get folderLoading() { + return this._folderLoading; + }, + set folderLoading(aFolderLoading) { + if (this._folderLoading == aFolderLoading) { + return; + } + this._folderLoading = aFolderLoading; + // tell the folder about what is going on so it can remove its db change + // listener and restore it, respectively. + if (aFolderLoading) { + this.displayedFolder.startFolderLoading(); + } else { + this.displayedFolder.endFolderLoading(); + } + this.listener.onFolderLoading(aFolderLoading); + }, + + get searching() { + return this._searching; + }, + set searching(aSearching) { + if (aSearching == this._searching) { + return; + } + this._searching = aSearching; + this.listener.onSearching(aSearching); + // notify that all messages are loaded if searching has concluded + if (!aSearching) { + this.listener.onMessagesLoaded(true); + } + }, + + /** + * Do we want to show the messages immediately, or should we wait for + * updateFolder to complete? The historical heuristic is: + * - Virtual folders get shown immediately (and updateFolder has no + * meaning for them anyways.) + * - If _underlyingFolders == null, we failed to open the database, + * so we need to wait for UpdateFolder to reparse the folder (in the + * local folder case). + * - Wait on updateFolder if our poor man's security via + * "mail.password_protect_local_cache" preference is enabled and the + * server requires a password to login. This is accomplished by asking our + * listener via shouldDeferMessageDisplayUntilAfterServerConnect. Note that + * there is an obvious hole in this logic because of the virtual folder case + * above. + * + * @pre this.folderDisplayed is the folder we are talking about. + * + * @returns true if the folder should be shown immediately, false if we should + * wait for updateFolder to complete. + */ + shouldShowMessagesForFolderImmediately() { + return ( + this.isVirtual || + !( + this._underlyingFolders == null || + this.listener.shouldDeferMessageDisplayUntilAfterServerConnect + ) + ); + }, + /** + * Extract information about the view from the dbFolderInfo (e.g., sort type, + * sort order, current view flags, etc), and save in the view wrapper. + */ + _prepareToLoadView(msgDatabase, aFolder) { + let dbFolderInfo = msgDatabase.dBFolderInfo; + // - retrieve persisted sort information + this._sort = [[dbFolderInfo.sortType, dbFolderInfo.sortOrder]]; + + // - retrieve persisted display settings + this.__viewFlags = dbFolderInfo.viewFlags; + // - retrieve persisted thread last expanded state. + this._threadExpandAll = Boolean( + this.__viewFlags & Ci.nsMsgViewFlagsType.kExpandAll + ); + + // Make sure the threaded bit is set if group-by-sort is set. The views + // encode 3 states in 2-bits, and we want to avoid that odd-man-out + // state. + // The expand flag must be set when opening a single virtual folder + // (quicksearch) in grouped view. The user's last set expand/collapse state + // for grouped/threaded in this use case is restored later. + if (this.__viewFlags & Ci.nsMsgViewFlagsType.kGroupBySort) { + this.__viewFlags |= Ci.nsMsgViewFlagsType.kThreadedDisplay; + this.__viewFlags |= Ci.nsMsgViewFlagsType.kExpandAll; + this._ensureValidSort(); + } + + // See if the last-used view was one of the special views. If so, put us in + // that special view mode. We intentionally do this after restoring the + // view flags because _setSpecialView enforces threading. + // The nsMsgDBView is the one who persists this information for us. In this + // case the nsMsgThreadedDBView superclass of the special views triggers it + // when opened. + let viewType = dbFolderInfo.viewType; + if ( + viewType == Ci.nsMsgViewType.eShowThreadsWithUnread || + viewType == Ci.nsMsgViewType.eShowWatchedThreadsWithUnread + ) { + this._setSpecialView(viewType); + } + + // - retrieve virtual folder configuration + if (aFolder.flags & Ci.nsMsgFolderFlags.Virtual) { + let virtFolder = VirtualFolderHelper.wrapVirtualFolder(aFolder); + + if (virtFolder.searchFolderURIs == "*") { + // This is a special virtual folder that searches all folders in all + // accounts (except the unwanted types listed). Get those folders now. + let unwantedFlags = + Ci.nsMsgFolderFlags.Trash | + Ci.nsMsgFolderFlags.Junk | + Ci.nsMsgFolderFlags.Queue | + Ci.nsMsgFolderFlags.Virtual; + this._underlyingFolders = []; + for (let server of MailServices.accounts.allServers) { + for (let f of server.rootFolder.descendants) { + if (!f.isSpecialFolder(unwantedFlags, true)) { + this._underlyingFolders.push(f); + } + } + } + } else { + // Filter out the server roots; they only exist for UI reasons. + this._underlyingFolders = virtFolder.searchFolders.filter( + folder => !folder.isServer + ); + } + this._underlyingData = + this._underlyingFolders.length > 1 + ? this.kUnderlyingMultipleFolder + : this.kUnderlyingRealFolder; + + // figure out if we are using online IMAP searching + this.search.onlineSearch = virtFolder.onlineSearch; + + // retrieve and chew the search query + this.search.virtualFolderTerms = virtFolder.searchTerms; + } else { + this._underlyingData = this.kUnderlyingRealFolder; + this._underlyingFolders = [this.displayedFolder]; + } + + FolderNotificationHelper.stalkFolders( + this._underlyingFolders, + this.displayedFolder, + this + ); + + // - retrieve mail view configuration + if (this.listener.shouldUseMailViews) { + // if there is a view tag (basically ":tagname"), then it's a + // mailview tag. clearly. + let mailViewTag = dbFolderInfo.getCharProperty( + MailViewConstants.kViewCurrentTag + ); + // "0" and "1" are all and unread views, respectively, from 2.0 + if (mailViewTag && mailViewTag != "0" && mailViewTag != "1") { + // the tag gets stored with a ":" on the front, presumably done + // as a means of name-spacing that was never subsequently leveraged. + if (mailViewTag.startsWith(":")) { + mailViewTag = mailViewTag.substr(1); + } + // (the true is so we don't persist) + this.setMailView(MailViewConstants.kViewItemTags, mailViewTag, true); + } else { + // otherwise it's just an index. we kinda-sorta migrate from old-school + // $label tags, except someone reused one of the indices for + // kViewItemNotDeleted, which means that $label2 can no longer be + // migrated. + let mailViewIndex = dbFolderInfo.getUint32Property( + MailViewConstants.kViewCurrent, + MailViewConstants.kViewItemAll + ); + // label migration per above + if ( + mailViewIndex == MailViewConstants.kViewItemTags || + (MailViewConstants.kViewItemTags + 2 <= mailViewIndex && + mailViewIndex < MailViewConstants.kViewItemVirtual) + ) { + this.setMailView( + MailViewConstants.kViewItemTags, + "$label" + (mailViewIndex - 1) + ); + } else { + this.setMailView(mailViewIndex); + } + } + } + + this.listener.onLoadingFolder(dbFolderInfo); + }, + + /** + * Creates a view appropriate to the current settings of the folder display + * widget, returning it. The caller is responsible to assign the result to + * this.dbView (or whatever it wants to do with it.) + */ + _createView() { + let dbviewContractId = "@mozilla.org/messenger/msgdbview;1?type="; + + // we will have saved these off when closing our view + let viewFlags = + this.__viewFlags ?? + Services.prefs.getIntPref("mailnews.default_view_flags", 1); + + // real folders are subject to the most interest set of possibilities... + if (this._underlyingData == this.kUnderlyingRealFolder) { + // quick-search inherits from threaded which inherits from group, so this + // is right to choose it first. + if (this.search.hasSearchTerms) { + dbviewContractId += "quicksearch"; + } else if (this.showGroupedBySort) { + dbviewContractId += "group"; + } else if (this.specialViewThreadsWithUnread) { + dbviewContractId += "threadswithunread"; + } else if (this.specialViewWatchedThreadsWithUnread) { + dbviewContractId += "watchedthreadswithunread"; + } else { + dbviewContractId += "threaded"; + } + } else if (this._underlyingData == this.kUnderlyingMultipleFolder) { + // if we're dealing with virtual folders, the answer is always an xfvf + dbviewContractId += "xfvf"; + } else { + // kUnderlyingSynthetic or kUnderlyingSearchView + dbviewContractId += "search"; + } + + // and now zero the saved-off flags. + this.__viewFlags = null; + + let dbView = Cc[dbviewContractId].createInstance(Ci.nsIMsgDBView); + dbView.init( + this.listener.messenger, + this.listener.msgWindow, + this.listener.threadPaneCommandUpdater + ); + // Excluding Group By views, use the least-specific sort so we can clock + // them back through to build up the correct sort order, + const index = + viewFlags & Ci.nsMsgViewFlagsType.kGroupBySort + ? 0 + : this._sort.length - 1; + let [sortType, sortOrder, sortCustomCol] = this._getSortDetails(index); + let outCount = {}; + // when the underlying folder is a single real folder (virtual or no), we + // tell the view about the underlying folder. + if (this.isSingleFolder) { + // If the folder is virtual, m_viewFolder needs to be set before the + // folder is opened, otherwise persisted sort info will not be restored + // from the right dbFolderInfo. The use case is for a single folder + // backed saved search. Currently, sort etc. changes in quick filter are + // persisted (gloda list and quick filter in gloda list are not involved). + if (this.isVirtual) { + dbView.viewFolder = this.displayedFolder; + } + + // Open the folder. + dbView.open( + this._underlyingFolders[0], + sortType, + sortOrder, + viewFlags, + outCount + ); + + // If there are any search terms, we need to tell the db view about the + // the display (/virtual) folder so it can store all the view-specific + // data there (things like the active mail view and such that go in + // dbFolderInfo.) This also goes for cases where the quick search is + // active; the C++ code explicitly nulls out the view folder for no + // good/documented reason, so we need to set it again if we want changes + // made with the quick filter applied. (We don't just change the C++ + // code because there could be SeaMonkey fallout.) See bug 502767 for + // info about the quick-search part of the problem. + if (this.search.hasSearchTerms) { + dbView.viewFolder = this.displayedFolder; + } + } else { + // when we're dealing with a multi-folder virtual folder, we just tell the + // db view about the display folder. (It gets its own XFVF view, so it + // knows what to do.) + // and for a synthetic folder, displayedFolder is null anyways + dbView.open( + this.displayedFolder, + sortType, + sortOrder, + viewFlags, + outCount + ); + } + if (sortCustomCol) { + dbView.curCustomColumn = sortCustomCol; + } + + // we all know it's a tree view, make sure the interface is available + // so no one else has to do this. + dbView.QueryInterface(Ci.nsITreeView); + + // If Grouped By, the view has already been opened with the most specific + // sort (groups themselves are always sorted by date). + if (!(viewFlags & Ci.nsMsgViewFlagsType.kGroupBySort)) { + // clock through the rest of the sorts, if there are any + for (let iSort = this._sort.length - 2; iSort >= 0; iSort--) { + [sortType, sortOrder, sortCustomCol] = this._getSortDetails(iSort); + if (sortCustomCol) { + dbView.curCustomColumn = sortCustomCol; + } + dbView.sort(sortType, sortOrder); + } + } + + return dbView; + }, + + /** + * Callback method invoked by FolderNotificationHelper when our folder is + * loaded. Assuming we are still interested in the folder, we enter the + * folder via _enterFolder. + */ + _folderLoaded(aFolder) { + if (aFolder == this.displayedFolder) { + this.folderLoading = false; + // If _underlyingFolders is null, DBViewWrapper_open probably got + // an exception trying to open the db, but after reparsing the local + // folder, we should have a db, so set up the view based on info + // from the db. + if (this._underlyingFolders == null) { + this._prepareToLoadView(aFolder.msgDatabase, aFolder); + } + this._enterFolder(); + } + }, + + /** + * Enter this.displayedFolder if we have not yet entered it. + * + * Things we do on entering a folder: + * - clear the folder's biffState! + * - set the message database's header cache size + */ + _enterFolder() { + if (this._enteredFolder) { + this.listener.onMessagesLoaded(true); + return; + } + + this.displayedFolder.biffState = Ci.nsIMsgFolder.nsMsgBiffState_NoMail; + + // we definitely want a view at this point; force the view. + this._viewUpdateDepth = 0; + this._applyViewChanges(); + + this.listener.onDisplayingFolder(); + + this._enteredFolder = true; + }, + + /** + * Renames, moves to the trash, it's all crazy. We have to update all our + * references when this happens. + */ + _folderMoved(aOldFolder, aNewFolder) { + if (aOldFolder == this.displayedFolder) { + this.displayedFolder = aNewFolder; + } + + if (!this._underlyingFolders) { + // View is closed already. + return; + } + + let i = this._underlyingFolders.findIndex(f => f == aOldFolder); + if (i >= 0) { + this._underlyingFolders[i] = aNewFolder; + } + + // re-populate the view. + this._applyViewChanges(); + }, + + /** + * FolderNotificationHelper tells us when folders we care about are deleted + * (because we asked it to in |open|). If it was the folder we were + * displaying (real or virtual), this closes it. If we are virtual and + * backed by a single folder, this closes us. If we are backed by multiple + * folders, we just update ourselves. (Currently, cross-folder views are + * not clever enough to purge the mooted messages, so we need to do this to + * help them out.) + * We do not update virtual folder definitions as a result of deletion; we are + * a display abstraction. That (hopefully) happens elsewhere. + */ + _folderDeleted(aFolder) { + // XXX When we empty the trash, we're actually sending a folder deleted + // notification around. This check ensures we don't think we've really + // deleted the trash folder in the DBViewWrapper, and that stops nasty + // things happening, like forgetting we've got the trash folder selected. + if (aFolder.isSpecialFolder(Ci.nsMsgFolderFlags.Trash, false)) { + return; + } + + if (aFolder == this.displayedFolder) { + this.close(); + return; + } + + // indexOf doesn't work for this (reliably) + for (let [i, underlyingFolder] of this._underlyingFolders.entries()) { + if (aFolder == underlyingFolder) { + this._underlyingFolders.splice(i, 1); + break; + } + } + + if (this._underlyingFolders.length == 0) { + this.close(); + return; + } + // if we are virtual, this will update the search session which draws its + // search scopes from this._underlyingFolders anyways. + this._applyViewChanges(); + }, + + /** + * Compacting a local folder nukes its message keys, requiring the view to be + * rebuilt. If the folder is IMAP, it doesn't matter because the UIDs are + * the message keys and we can ignore it. In the local case we want to + * notify our listener so they have a chance to save the selected messages. + */ + _aboutToCompactFolder(aFolder) { + // IMAP compaction does not affect us unless we are holding headers + if (aFolder.server.type == "imap") { + return; + } + + // we will have to re-create the view, so nuke the view now. + if (this.dbView) { + this.listener.onDestroyingView(true); + this.search.dissociateView(this.dbView); + this.dbView.close(); + this.dbView = null; + } + }, + + /** + * Compaction is all done, let's re-create the view! (Unless the folder is + * IMAP, in which case we are ignoring this event sequence.) + */ + _compactedFolder(aFolder) { + // IMAP compaction does not affect us unless we are holding headers + if (aFolder.server.type == "imap") { + return; + } + + this.refresh(); + }, + + /** + * DB Views need help to know when their move / deletion operations complete. + * This happens in both single-folder and multiple-folder backed searches. + * In the latter case, there is potential danger that we tell a view that did + * not initiate the move / deletion but has kicked off its own about the + * completion and confuse it. However, that's on the view code. + */ + _deleteCompleted(aFolder) { + if (this.dbView) { + this.dbView.onDeleteCompleted(true); + } + this.listener.onMessagesRemoved(); + }, + + /** + * See _deleteCompleted for an explanation of what is going on. + */ + _deleteFailed(aFolder) { + if (this.dbView) { + this.dbView.onDeleteCompleted(false); + } + this.listener.onMessageRemovalFailed(); + }, + + _forceOpen(aFolder) { + this.displayedFolder = null; + this.open(aFolder); + }, + + _renameCompleted(aFolder) { + if (aFolder == this.displayedFolder) { + this._forceOpen(aFolder); + } + }, + + /** + * If the displayed folder had its total message count or total unread message + * count change, notify the listener. (Note: only for the display folder; + * not the underlying folders!) + */ + _messageCountsChanged(aFolder) { + if (aFolder == this.displayedFolder) { + this.listener.onMessageCountsChanged(); + } + }, + + /** + * @returns the current set of viewFlags. This may be: + * - A modified set of flags that are pending application because a view + * update is in effect and we don't want to modify the view when it's just + * going to get destroyed. + * - The live set of flags from the current dbView. + * - The 'limbo' set of flags because we currently lack a view but will have + * one soon (and then we will apply the flags). + */ + get _viewFlags() { + if (this.__viewFlags != null) { + return this.__viewFlags; + } + if (this.dbView) { + return this.dbView.viewFlags; + } + return 0; + }, + /** + * Update the view flags to use on the view. If we are in a view update or + * currently don't have a view, we save the view flags for later usage when + * the view gets (re)built. If we have a view, depending on what's happening + * we may re-create the view or just set the bits. The rules/reasons are: + * - XFVF views can handle the flag changes, just set the flags. + * - XFVF threaded/unthreaded change must re-sort, the backend forgot. + * - Single-folder virtual folders (quicksearch) can handle viewFlag changes, + * to/from grouped included, so set it. + * - Single-folder threaded/unthreaded can handle a change to/from unthreaded/ + * threaded, so set it. + * - Single-folder can _not_ handle a change between grouped and not-grouped, + * so re-generate the view. Also it can't handle a change involving + * kUnreadOnly or kShowIgnored. + */ + set _viewFlags(aViewFlags) { + if (this._viewUpdateDepth || !this.dbView) { + this.__viewFlags = aViewFlags; + return; + } + + // For viewFlag changes, do not make a random selection if there is not + // actually anything selected; some views do this (looking at xfvf). + if (this.dbView.selection && this.dbView.selection.count == 0) { + this.dbView.selection.currentIndex = -1; + } + + let setViewFlags = true; + let reSort = false; + let oldFlags = this.dbView.viewFlags; + let changedFlags = oldFlags ^ aViewFlags; + + if (this.isVirtual) { + if ( + this.isMultiFolder && + changedFlags & Ci.nsMsgViewFlagsType.kThreadedDisplay && + !(changedFlags & Ci.nsMsgViewFlagsType.kGroupBySort) + ) { + reSort = true; + } + if (this.isSingleFolder) { + // ugh, and the single folder case needs us to re-apply his sort... + reSort = true; + } + } else { + // The regular single folder case. + if ( + changedFlags & + (Ci.nsMsgViewFlagsType.kGroupBySort | + Ci.nsMsgViewFlagsType.kUnreadOnly | + Ci.nsMsgViewFlagsType.kShowIgnored) + ) { + setViewFlags = false; + } + // ugh, and the single folder case needs us to re-apply his sort... + reSort = true; + } + + if (setViewFlags) { + this.dbView.viewFlags = aViewFlags; + if (reSort) { + this.dbView.sort(this.dbView.sortType, this.dbView.sortOrder); + } + this.listener.onSortChanged(); + } else { + this.__viewFlags = aViewFlags; + this._applyViewChanges(); + } + }, + + /** + * Apply accumulated changes to the view. If we are in a batch, we do + * nothing, relying on endDisplayUpdate to call us. + */ + _applyViewChanges() { + // if we are in a batch, wait for endDisplayUpdate to be called to get us + // out to zero. + if (this._viewUpdateDepth) { + return; + } + // make the dbView stop being a search listener if it is one + if (this.dbView) { + // save the view's flags if it has any and we haven't already overridden + // them. + if (this.__viewFlags == null) { + this.__viewFlags = this.dbView.viewFlags; + } + this.listener.onDestroyingView(true); // we will re-create it! + this.search.dissociateView(this.dbView); + this.dbView.close(); + this.dbView = null; + } + + this.dbView = this._createView(); + // if the synthetic view defines columns, add those for it + if (this.isSynthetic) { + for (let customCol of this._syntheticView.customColumns) { + customCol.bindToView(this.dbView); + this.dbView.addColumnHandler(customCol.id, customCol); + } + } + this.listener.onCreatedView(); + + // this ends up being a no-op if there are no search terms + this.search.associateView(this.dbView); + + // If we are searching, then the search will generate the all messages + // loaded notification. Although in some cases the search may have + // completed by now, that is not a guarantee. The search logic is + // time-slicing, which is why this can vary. (If it uses up its time + // slices, it will re-schedule itself, returning to us before completing.) + // Which is why we always defer to the search if one is active. + // If we are loading the folder, the load completion will also notify us, + // so we should not generate all messages loaded right now. + if (!this.searching && !this.folderLoading) { + this.listener.onMessagesLoaded(true); + } else if (this.dbView.numMsgsInView > 0) { + this.listener.onMessagesLoaded(false); + } + }, + + get isMailFolder() { + return Boolean( + this.displayedFolder && + this.displayedFolder.flags & Ci.nsMsgFolderFlags.Mail + ); + }, + + get isNewsFolder() { + return Boolean( + this.displayedFolder && + this.displayedFolder.flags & Ci.nsMsgFolderFlags.Newsgroup + ); + }, + + get isFeedFolder() { + return Boolean( + this.displayedFolder && this.displayedFolder.server.type == "rss" + ); + }, + + OUTGOING_FOLDER_FLAGS: + Ci.nsMsgFolderFlags.SentMail | + Ci.nsMsgFolderFlags.Drafts | + Ci.nsMsgFolderFlags.Queue | + Ci.nsMsgFolderFlags.Templates, + /** + * @returns true if the folder is an outgoing folder by virtue of being a + * sent mail folder, drafts folder, queue folder, or template folder, + * or being a sub-folder of one of those types of folders. + */ + get isOutgoingFolder() { + return ( + this.displayedFolder && + this.displayedFolder.isSpecialFolder(this.OUTGOING_FOLDER_FLAGS, true) + ); + }, + /** + * @returns true if the folder is not known to be a special outgoing folder + * or the descendent of a special outgoing folder. + */ + get isIncomingFolder() { + return !this.isOutgoingFolder; + }, + + get isVirtual() { + return Boolean( + this.displayedFolder && + this.displayedFolder.flags & Ci.nsMsgFolderFlags.Virtual + ); + }, + + /** + * Prevent view updates from running until a paired |endViewUpdate| call is + * made. This is an advisory method intended to aid us in performing + * redundant view re-computations and does not forbid us from building the + * view earlier if we have a good reason. + * Since calling endViewUpdate will compel a view update when the update + * depth reaches 0, you should only call this method if you are sure that + * you will need the view to be re-built. If you are doing things like + * changing to/from threaded mode that do not cause the view to be rebuilt, + * you should just set those attributes directly. + */ + beginViewUpdate() { + this._viewUpdateDepth++; + }, + + /** + * Conclude a paired call to |beginViewUpdate|. Assuming the view depth has + * reached 0 with this call, the view will be re-created with the current + * settings. + */ + endViewUpdate(aForceLevel) { + if (--this._viewUpdateDepth == 0) { + this._applyViewChanges(); + } + // Avoid pathological situations. + if (this._viewUpdateDepth < 0) { + this._viewUpdateDepth = 0; + } + }, + + /** + * @returns the primary sort type (as one of the numeric constants from + * nsMsgViewSortType). + */ + get primarySortType() { + return this._sort[0][0]; + }, + + /** + * @returns the primary sort order (as one of the numeric constants from + * nsMsgViewSortOrder.) + */ + get primarySortOrder() { + return this._sort[0][1]; + }, + + /** + * @returns true if the dominant sort is ascending. + */ + get isSortedAscending() { + return ( + this._sort.length && this._sort[0][1] == Ci.nsMsgViewSortOrder.ascending + ); + }, + /** + * @returns true if the dominant sort is descending. + */ + get isSortedDescending() { + return ( + this._sort.length && this._sort[0][1] == Ci.nsMsgViewSortOrder.descending + ); + }, + /** + * Indicate if we are sorting by time or something correlated with time. + * + * @returns true if the dominant sort is by time. + */ + get sortImpliesTemporalOrdering() { + if (!this._sort.length) { + return false; + } + let sortType = this._sort[0][0]; + return ( + sortType == Ci.nsMsgViewSortType.byDate || + sortType == Ci.nsMsgViewSortType.byReceived || + sortType == Ci.nsMsgViewSortType.byId || + sortType == Ci.nsMsgViewSortType.byThread + ); + }, + + sortAscending() { + if (!this.isSortedAscending) { + this.magicSort(this._sort[0][0], Ci.nsMsgViewSortOrder.ascending); + } + }, + sortDescending() { + if (!this.isSortedDescending) { + this.magicSort(this._sort[0][0], Ci.nsMsgViewSortOrder.descending); + } + }, + + /** + * Explicit sort command. We ignore all previous sort state and only apply + * what you tell us. If you want implied secondary sort, use |magicSort|. + * You must use this sort command, and never directly call the sort commands + * on the underlying db view! If you do not, make sure to fight us every + * step of the way, because we will keep clobbering your manually applied + * sort. + * For secondary and multiple custom column support, a byCustom aSortType and + * aSecondaryType must be the column name string. + */ + sort(aSortType, aSortOrder, aSecondaryType, aSecondaryOrder) { + // For sort changes, do not make a random selection if there is not + // actually anything selected; some views do this (looking at xfvf). + if (this.dbView.selection && this.dbView.selection.count == 0) { + this.dbView.selection.currentIndex = -1; + } + + this._sort = [[aSortType, aSortOrder]]; + if (aSecondaryType != null && aSecondaryOrder != null) { + this._sort.push([aSecondaryType, aSecondaryOrder]); + } + // make sure the sort won't make the view angry... + this._ensureValidSort(); + // if we are not in a view update, invoke the sort. + if (this._viewUpdateDepth == 0 && this.dbView) { + for (let iSort = this._sort.length - 1; iSort >= 0; iSort--) { + // apply them in the reverse order + let [sortType, sortOrder, sortCustomCol] = this._getSortDetails(iSort); + if (sortCustomCol) { + this.dbView.curCustomColumn = sortCustomCol; + } + this.dbView.sort(sortType, sortOrder); + } + // (only generate the event since we're not in a update batch) + this.listener.onSortChanged(); + } + // (if we are in a view update, then a new view will be created when the + // update ends, and it will just use the new sort order anyways.) + }, + + /** + * Logic that compensates for custom column identifiers being provided as + * sort types. + * + * @returns [sort type, sort order, sort custom column name] + */ + _getSortDetails(aIndex) { + let [sortType, sortOrder] = this._sort[aIndex]; + let sortCustomColumn = null; + let sortTypeType = typeof sortType; + if (sortTypeType != "number") { + sortCustomColumn = sortTypeType == "string" ? sortType : sortType.id; + sortType = Ci.nsMsgViewSortType.byCustom; + } + + return [sortType, sortOrder, sortCustomColumn]; + }, + + /** + * Accumulates implied secondary sorts based on multiple calls to this method. + * This is intended to be hooked up to be controlled by the UI. + * Because we are lazy, we actually just poke the view's sort method and save + * the apparent secondary sort. This also allows perfect compliance with the + * way this used to be implemented! + * For secondary and multiple custom column support, a byCustom aSortType must + * be the column name string. + */ + magicSort(aSortType, aSortOrder) { + if (this.dbView) { + // For sort changes, do not make a random selection if there is not + // actually anything selected; some views do this (looking at xfvf). + if (this.dbView.selection && this.dbView.selection.count == 0) { + this.dbView.selection.currentIndex = -1; + } + + // so, the thing we just set obviously will be there + this._sort = [[aSortType, aSortOrder]]; + // (make sure it is valid...) + this._ensureValidSort(); + // get sort details, handle custom column as string sortType + let [sortType, sortOrder, sortCustomCol] = this._getSortDetails(0); + if (sortCustomCol) { + this.dbView.curCustomColumn = sortCustomCol; + } + // apply the sort to see what happens secondary-wise + this.dbView.sort(sortType, sortOrder); + // there is only a secondary sort if it's not none and not the same. + if ( + this.dbView.secondarySortType != Ci.nsMsgViewSortType.byNone && + (this.dbView.secondarySortType != sortType || + (this.dbView.secondarySortType == Ci.nsMsgViewSortType.byCustom && + this.dbView.secondaryCustomColumn != sortCustomCol)) + ) { + this._sort.push([ + this.dbView.secondaryCustomColumn || this.dbView.secondarySortType, + this.dbView.secondarySortOrder, + ]); + } + // only tell our listener if we're not in a view update batch + if (this._viewUpdateDepth == 0) { + this.listener.onSortChanged(); + } + } + }, + + /** + * Make sure the current sort is valid under our other constraints, make it + * safe if it is not. Most specifically, some sorts are illegal when + * grouping by sort, and we reset the sort to date in those cases. + * + * @param aViewFlags Optional set of view flags to consider instead of the + * potentially live view flags. + */ + _ensureValidSort(aViewFlags) { + if ( + (aViewFlags != null ? aViewFlags : this._viewFlags) & + Ci.nsMsgViewFlagsType.kGroupBySort + ) { + // We cannot be sorting by thread, id, none, or size. If we are, switch + // to sorting by date. + for (let sortPair of this._sort) { + let sortType = sortPair[0]; + if ( + sortType == Ci.nsMsgViewSortType.byThread || + sortType == Ci.nsMsgViewSortType.byId || + sortType == Ci.nsMsgViewSortType.byNone || + sortType == Ci.nsMsgViewSortType.bySize + ) { + this._sort = [[Ci.nsMsgViewSortType.byDate, this._sort[0][1]]]; + break; + } + } + } + }, + + /** + * @returns {boolean} true if we are grouped-by-sort, false if not. If we are + * not grouped-by-sort, then we are either threaded or unthreaded; check + * the showThreaded property to find out which of those it is. + */ + get showGroupedBySort() { + return Boolean(this._viewFlags & Ci.nsMsgViewFlagsType.kGroupBySort); + }, + /** + * Enable grouped-by-sort which is mutually exclusive with threaded display + * (as controlled/exposed by showThreaded). Grouped-by-sort is not legal + * for sorts by thread/id/size/none and enabling this will cause us to change + * our sort to by date in those cases. + */ + set showGroupedBySort(aShowGroupBySort) { + if (this.showGroupedBySort != aShowGroupBySort) { + if (aShowGroupBySort) { + // For virtual single folders, the kExpandAll flag must be set. + // Do not apply the flag change until we have made the sort safe. + let viewFlags = + this._viewFlags | + Ci.nsMsgViewFlagsType.kGroupBySort | + Ci.nsMsgViewFlagsType.kExpandAll | + Ci.nsMsgViewFlagsType.kThreadedDisplay; + this._ensureValidSort(viewFlags); + this._viewFlags = viewFlags; + } else { + // maybe we shouldn't do anything in this case? + this._viewFlags &= ~( + Ci.nsMsgViewFlagsType.kGroupBySort | + Ci.nsMsgViewFlagsType.kThreadedDisplay + ); + } + } + }, + + /** + * Are we showing ignored/killed threads? + */ + get showIgnored() { + return Boolean(this._viewFlags & Ci.nsMsgViewFlagsType.kShowIgnored); + }, + /** + * Set whether we are showing ignored/killed threads. + */ + set showIgnored(aShowIgnored) { + if (this.showIgnored == aShowIgnored) { + return; + } + + if (aShowIgnored) { + this._viewFlags |= Ci.nsMsgViewFlagsType.kShowIgnored; + } else { + this._viewFlags &= ~Ci.nsMsgViewFlagsType.kShowIgnored; + } + }, + + /** + * @returns {boolean} true if we are in threaded mode (as opposed to unthreaded + * or grouped-by-sort). + */ + get showThreaded() { + return Boolean( + this._viewFlags & Ci.nsMsgViewFlagsType.kThreadedDisplay && + !(this._viewFlags & Ci.nsMsgViewFlagsType.kGroupBySort) + ); + }, + /** + * Set us to threaded display mode when set to true. If we are already in + * threaded display mode, we do nothing. If you want to set us to unthreaded + * mode, set |showUnthreaded| to true. (Because we have three modes of + * operation: unthreaded, threaded, and grouped-by-sort, we are a tri-state + * and setting us to false is ambiguous. We should probably be using a + * single attribute with three constants...) + */ + set showThreaded(aShowThreaded) { + if (this.showThreaded != aShowThreaded) { + let viewFlags = this._viewFlags; + if (aShowThreaded) { + viewFlags |= Ci.nsMsgViewFlagsType.kThreadedDisplay; + } else { + // Maybe we shouldn't do anything in this case? + viewFlags &= ~Ci.nsMsgViewFlagsType.kThreadedDisplay; + } + // lose the group bit... + viewFlags &= ~Ci.nsMsgViewFlagsType.kGroupBySort; + this._viewFlags = viewFlags; + } + }, + + /** + * @returns {boolean} true if we are in unthreaded mode (which means not + * threaded and not grouped-by-sort). + */ + get showUnthreaded() { + return Boolean( + !( + this._viewFlags & + (Ci.nsMsgViewFlagsType.kGroupBySort | + Ci.nsMsgViewFlagsType.kThreadedDisplay) + ) + ); + }, + /** + * Set to true to put us in unthreaded mode (which means not threaded and + * not grouped-by-sort). + */ + set showUnthreaded(aShowUnthreaded) { + if (this.showUnthreaded != aShowUnthreaded) { + if (aShowUnthreaded) { + this._viewFlags &= ~( + Ci.nsMsgViewFlagsType.kGroupBySort | + Ci.nsMsgViewFlagsType.kThreadedDisplay + ); + } else { + // Maybe we shouldn't do anything in this case? + this._viewFlags = + (this._viewFlags & ~Ci.nsMsgViewFlagsType.kGroupBySort) | + Ci.nsMsgViewFlagsType.kThreadedDisplay; + } + } + }, + + /** + * @returns true if we are showing only unread messages. + */ + get showUnreadOnly() { + return Boolean(this._viewFlags & Ci.nsMsgViewFlagsType.kUnreadOnly); + }, + /** + * Enable/disable showing only unread messages using the view's flag-based + * mechanism. This functionality can also be approximated using a mail + * view (or other search) for unread messages. There also exist special + * views for showing messages with unread threads which is different and + * has serious limitations because of its nature. + * Setting anything to this value clears any active special view because the + * actual UI use case (the "View... Threads..." menu) uses this setter + * intentionally as a mutually exclusive UI choice from the special views. + */ + set showUnreadOnly(aShowUnreadOnly) { + if (this._specialView || this.showUnreadOnly != aShowUnreadOnly) { + let viewRebuildRequired = this._specialView != null; + this._specialView = null; + if (viewRebuildRequired) { + this.beginViewUpdate(); + } + + if (aShowUnreadOnly) { + this._viewFlags |= Ci.nsMsgViewFlagsType.kUnreadOnly; + } else { + this._viewFlags &= ~Ci.nsMsgViewFlagsType.kUnreadOnly; + } + + if (viewRebuildRequired) { + this.endViewUpdate(); + } + } + }, + + /** + * Read-only attribute indicating if a 'special view' is in use. There are + * two special views in existence, both of which are concerned about + * showing you threads that have any unread messages in them. They are views + * rather than search predicates because the search mechanism is not capable + * of expressing such a thing. (Or at least it didn't use to be? We might + * be able to whip something up these days...) + */ + get specialView() { + return this._specialView != null; + }, + /** + * Private helper for use by the specialView* setters that handles the common + * logic. We don't want this method to be public because we want it to be + * feasible for the view hierarchy and its enumerations to go away without + * code outside this class having to care so much. + */ + _setSpecialView(aViewEnum) { + // special views simply cannot work for virtual folders. explode. + if (this.isVirtual) { + throw new Error("Virtual folders cannot use special views!"); + } + this.beginViewUpdate(); + // all special views imply a threaded view + this.showThreaded = true; + this._specialView = aViewEnum; + // We clear the search for paranoia/correctness reasons. However, the UI + // layer is currently responsible for making sure these are already zeroed + // out. + this.search.clear(); + this.endViewUpdate(); + }, + /** + * @returns true if the special view that shows threads with unread messages + * in them is active. + */ + get specialViewThreadsWithUnread() { + return this._specialView == Ci.nsMsgViewType.eShowThreadsWithUnread; + }, + /** + * If true is assigned, attempts to enable the special view that shows threads + * with unread messages in them. This will not work on virtual folders + * because of the inheritance hierarchy. + * Any mechanism that requires search terms (quick search, mailviews) will be + * reset/disabled when enabling this view. + */ + set specialViewThreadsWithUnread(aSpecial) { + this._setSpecialView(Ci.nsMsgViewType.eShowThreadsWithUnread); + }, + /** + * @returns true if the special view that shows watched threads with unread + * messages in them is active. + */ + get specialViewWatchedThreadsWithUnread() { + return this._specialView == Ci.nsMsgViewType.eShowWatchedThreadsWithUnread; + }, + /** + * If true is assigned, attempts to enable the special view that shows watched + * threads with unread messages in them. This will not work on virtual + * folders because of the inheritance hierarchy. + * Any mechanism that requires search terms (quick search, mailviews) will be + * reset/disabled when enabling this view. + */ + set specialViewWatchedThreadsWithUnread(aSpecial) { + this._setSpecialView(Ci.nsMsgViewType.eShowWatchedThreadsWithUnread); + }, + + get mailViewIndex() { + return this._mailViewIndex; + }, + + get mailViewData() { + return this._mailViewData; + }, + + /** + * Set the current mail view to the given mail view index with the provided + * data (normally only used for the 'tag' mail views.) We persist the state + * change + * + * @param aMailViewIndex The view to use, one of the kViewItem* constants from + * msgViewPickerOverlay.js OR the name of a custom view. (It's really up + * to MailViewManager.getMailViewByIndex...) + * @param aData Some piece of data appropriate to the mail view, currently + * this is only used for the tag name for kViewItemTags (sans the ":"). + * @param aDoNotPersist If true, we don't save this change to the db folder + * info. This is intended for internal use only. + */ + setMailView(aMailViewIndex, aData, aDoNotPersist) { + let mailViewDef = MailViewManager.getMailViewByIndex(aMailViewIndex); + + this._mailViewIndex = aMailViewIndex; + this._mailViewData = aData; + + // - update the search terms + // (this triggers a view update if we are not in a batch) + this.search.viewTerms = mailViewDef.makeTerms(this.search.session, aData); + + // - persist the view to the folder. + if (!aDoNotPersist && this.displayedFolder) { + let msgDatabase = this.displayedFolder.msgDatabase; + if (msgDatabase) { + let dbFolderInfo = msgDatabase.dBFolderInfo; + dbFolderInfo.setUint32Property( + MailViewConstants.kViewCurrent, + this._mailViewIndex + ); + // _mailViewData attempts to be sane and be the tag name, as opposed to + // magic-value ":"-prefixed value historically stored on disk. Because + // we want to be forwards and backwards compatible, we put this back on + // when we persist it. It's not like the property is really generic + // anyways. + dbFolderInfo.setCharProperty( + MailViewConstants.kViewCurrentTag, + this._mailViewData ? ":" + this._mailViewData : "" + ); + } + } + + this.listener.onMailViewChanged(); + }, + + /** + * @returns true if the row at the given index contains a collapsed thread, + * false if the row is a collapsed group or anything else. + */ + isCollapsedThreadAtIndex(aViewIndex) { + let flags = this.dbView.getFlagsAt(aViewIndex); + return ( + flags & Ci.nsMsgMessageFlags.Elided && + !(flags & MSG_VIEW_FLAG_DUMMY) && + this.dbView.isContainer(aViewIndex) + ); + }, + + /** + * @returns true if the row at the given index is a grouped view dummy header + * row, false if anything else. + */ + isGroupedByHeaderAtIndex(aViewIndex) { + if ( + !this.dbView || + aViewIndex < 0 || + aViewIndex >= this.dbView.rowCount || + !this.showGroupedBySort + ) { + return false; + } + return Boolean(this.dbView.getFlagsAt(aViewIndex) & MSG_VIEW_FLAG_DUMMY); + }, + + /** + * Perform application-level behaviors related to leaving a folder that have + * nothing to do with our abstraction. + * + * Things we do on leaving a folder: + * - Mark the folder's messages as no longer new + * - Mark all messages read in the folder _if so configured_. + */ + onLeavingFolder() { + // Suppress useless InvalidateRange calls to the tree by the dbView. + if (this.dbView) { + this.dbView.suppressChangeNotifications = true; + } + this.displayedFolder.clearNewMessages(); + this.displayedFolder.hasNewMessages = false; + try { + // For legacy reasons, we support marking all messages as read when we + // leave a folder based on the server type. It's this listener's job + // to do the legwork to figure out if this is desired. + // + // Mark all messages of aFolder as read: + // We can't use the command controller, because it is already tuned in to + // the new folder, so we just mimic its behaviour wrt + // goDoCommand('cmd_markAllRead'). + if ( + this.dbView && + this.listener.shouldMarkMessagesReadOnLeavingFolder( + this.displayedFolder + ) + ) { + this.dbView.doCommand(Ci.nsMsgViewCommandType.markAllRead); + } + } catch (e) {} + }, + + /** + * Returns the view index for this message header in this view. + * + * - If this is a single folder view, we first check whether the folder is the + * right one. If it is, we call the db view's findIndexOfMsgHdr. We do the + * first check because findIndexOfMsgHdr only checks for whether the message + * key matches, which might lead to false positives. + * + * - If this isn't, we trust findIndexOfMsgHdr to do the right thing. + * + * @param aMsgHdr The message header for which the view index should be + * returned. + * @param [aForceFind] 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. + * + * @returns the view index for this header, or nsMsgViewIndex_None if it isn't + * found. + * + * @public + */ + getViewIndexForMsgHdr(aMsgHdr, aForceFind) { + if (this.dbView) { + if (this.isSingleFolder && aMsgHdr.folder != this.dbView.msgFolder) { + return nsMsgViewIndex_None; + } + + let viewIndex = this.dbView.findIndexOfMsgHdr(aMsgHdr, true); + + if (aForceFind && viewIndex == nsMsgViewIndex_None) { + // Consider dropping view filters. + // - If we're not displaying all messages, switch to All + if ( + viewIndex == nsMsgViewIndex_None && + this.mailViewIndex != MailViewConstants.kViewItemAll + ) { + this.setMailView(MailViewConstants.kViewItemAll, null); + viewIndex = this.dbView.findIndexOfMsgHdr(aMsgHdr, true); + } + + // - Don't just show unread only + if (viewIndex == nsMsgViewIndex_None) { + this.showUnreadOnly = false; + viewIndex = this.dbView.findIndexOfMsgHdr(aMsgHdr, true); + } + } + + // We've done all we can. + return viewIndex; + } + + // No db view, so we can't do anything + return nsMsgViewIndex_None; + }, + + /** + * Convenience function to retrieve the first nsIMsgDBHdr in any of the + * folders backing this view with the given message-id header. This + * is for the benefit of FolderDisplayWidget's selection logic. + * When thinking about using this, please keep in mind that, currently, this + * is O(n) for the total number of messages across all the backing folders. + * Since the folder database should already be in memory, this should + * ideally not involve any disk I/O. + * Additionally, duplicate message-ids can and will happen, but since we + * are using the message database's getMsgHdrForMessageID method to be fast, + * our semantics are limited to telling you about only the first one we find. + * + * @param aMessageId The message-id of the message you want. + * @returns The first nsIMsgDBHdr found in any of the underlying folders with + * the given message header, null if none are found. The fact that we + * return something does not guarantee that it is actually visible in the + * view. (The search may be filtering it out.) + */ + getMsgHdrForMessageID(aMessageId) { + if (this._syntheticView) { + return this._syntheticView.getMsgHdrForMessageID(aMessageId); + } + if (!this._underlyingFolders) { + return null; + } + for (let folder of this._underlyingFolders) { + let msgHdr = folder.msgDatabase.getMsgHdrForMessageID(aMessageId); + if (msgHdr) { + return msgHdr; + } + } + return null; + }, +}; |