summaryrefslogtreecommitdiffstats
path: root/comm/mail/base
diff options
context:
space:
mode:
Diffstat (limited to 'comm/mail/base')
-rw-r--r--comm/mail/base/content/FilterListDialog.js1162
-rw-r--r--comm/mail/base/content/FilterListDialog.xhtml168
-rw-r--r--comm/mail/base/content/SearchDialog.js650
-rw-r--r--comm/mail/base/content/SearchDialog.xhtml151
-rw-r--r--comm/mail/base/content/about3Pane.js7260
-rw-r--r--comm/mail/base/content/about3Pane.xhtml762
-rw-r--r--comm/mail/base/content/aboutAddonsExtra.js211
-rw-r--r--comm/mail/base/content/aboutDialog-appUpdater.js320
-rw-r--r--comm/mail/base/content/aboutDialog.css187
-rw-r--r--comm/mail/base/content/aboutDialog.js155
-rw-r--r--comm/mail/base/content/aboutDialog.xhtml184
-rw-r--r--comm/mail/base/content/aboutMessage.js617
-rw-r--r--comm/mail/base/content/aboutMessage.xhtml158
-rw-r--r--comm/mail/base/content/aboutRights.xhtml110
-rw-r--r--comm/mail/base/content/browserRequest.js145
-rw-r--r--comm/mail/base/content/browserRequest.xhtml58
-rw-r--r--comm/mail/base/content/buildconfig.html106
-rw-r--r--comm/mail/base/content/commonDialog.xhtml110
-rw-r--r--comm/mail/base/content/compactFoldersDialog.js54
-rw-r--r--comm/mail/base/content/compactFoldersDialog.xhtml54
-rw-r--r--comm/mail/base/content/contentAreaClick.js206
-rw-r--r--comm/mail/base/content/customElements.js35
-rw-r--r--comm/mail/base/content/customizeToolbar.js836
-rw-r--r--comm/mail/base/content/customizeToolbar.xhtml105
-rw-r--r--comm/mail/base/content/dialogShadowDom.js14
-rw-r--r--comm/mail/base/content/editContactPanel.inc.xhtml75
-rw-r--r--comm/mail/base/content/editContactPanel.js248
-rw-r--r--comm/mail/base/content/folderDisplay.js2649
-rw-r--r--comm/mail/base/content/globalOverlay.js122
-rw-r--r--comm/mail/base/content/glodaFacetTab.js111
-rw-r--r--comm/mail/base/content/glodaFacetView.js1114
-rw-r--r--comm/mail/base/content/glodaFacetView.xhtml123
-rw-r--r--comm/mail/base/content/glodaFacetViewWrapper.xhtml54
-rw-r--r--comm/mail/base/content/glodaFacetVis.js428
-rw-r--r--comm/mail/base/content/helpMenu.inc.xhtml43
-rw-r--r--comm/mail/base/content/hiddenWindowMac.js124
-rw-r--r--comm/mail/base/content/hiddenWindowMac.xhtml101
-rw-r--r--comm/mail/base/content/macMessengerMenu.js99
-rw-r--r--comm/mail/base/content/macWindowMenu.inc.xhtml22
-rw-r--r--comm/mail/base/content/mail-offline.js276
-rw-r--r--comm/mail/base/content/mail3PaneWindowCommands.js456
-rw-r--r--comm/mail/base/content/mailCommands.js667
-rw-r--r--comm/mail/base/content/mailCommon.js1126
-rw-r--r--comm/mail/base/content/mailContext.inc.xhtml324
-rw-r--r--comm/mail/base/content/mailContext.js822
-rw-r--r--comm/mail/base/content/mailCore.js1063
-rw-r--r--comm/mail/base/content/mailTabs.js390
-rw-r--r--comm/mail/base/content/mailWindow.js1153
-rw-r--r--comm/mail/base/content/mailWindowOverlay.js2177
-rw-r--r--comm/mail/base/content/mainCommandSet.inc.xhtml247
-rw-r--r--comm/mail/base/content/mainKeySet.inc.xhtml264
-rw-r--r--comm/mail/base/content/mainStatusbar.inc.xhtml19
-rw-r--r--comm/mail/base/content/messageWindow.js742
-rw-r--r--comm/mail/base/content/messageWindow.xhtml484
-rw-r--r--comm/mail/base/content/messenger-customization.js185
-rw-r--r--comm/mail/base/content/messenger-doctype.inc.dtd42
-rw-r--r--comm/mail/base/content/messenger-menubar.inc.xhtml1271
-rw-r--r--comm/mail/base/content/messenger-titlebar-items.inc.xhtml24
-rw-r--r--comm/mail/base/content/messenger.js1289
-rw-r--r--comm/mail/base/content/messenger.xhtml671
-rw-r--r--comm/mail/base/content/migrationProgress.js64
-rw-r--r--comm/mail/base/content/migrationProgress.xhtml39
-rw-r--r--comm/mail/base/content/minimizeToTray.js19
-rw-r--r--comm/mail/base/content/modules/thread-pane-columns.mjs385
-rw-r--r--comm/mail/base/content/msgAttachmentView.inc.xhtml102
-rw-r--r--comm/mail/base/content/msgHdrPopup.inc.xhtml224
-rw-r--r--comm/mail/base/content/msgHdrView.inc.xhtml559
-rw-r--r--comm/mail/base/content/msgHdrView.js4501
-rw-r--r--comm/mail/base/content/msgSecurityPane.inc.xhtml131
-rw-r--r--comm/mail/base/content/msgSecurityPane.js111
-rw-r--r--comm/mail/base/content/msgViewNavigation.js207
-rw-r--r--comm/mail/base/content/multimessageview.js844
-rw-r--r--comm/mail/base/content/multimessageview.xhtml79
-rw-r--r--comm/mail/base/content/newTagDialog.js108
-rw-r--r--comm/mail/base/content/newTagDialog.xhtml30
-rw-r--r--comm/mail/base/content/overrides/app-license-body.html1274
-rw-r--r--comm/mail/base/content/overrides/app-license-list.html33
-rw-r--r--comm/mail/base/content/overrides/app-license-name.html1
-rw-r--r--comm/mail/base/content/overrides/app-license.html9
-rw-r--r--comm/mail/base/content/printUtils.js428
-rw-r--r--comm/mail/base/content/profileDowngrade.js53
-rw-r--r--comm/mail/base/content/profileDowngrade.xhtml48
-rw-r--r--comm/mail/base/content/protovis-r2.6-modded.js5349
-rw-r--r--comm/mail/base/content/quickFilterBar.inc.xhtml118
-rw-r--r--comm/mail/base/content/quickFilterBar.js603
-rw-r--r--comm/mail/base/content/sanitize.js241
-rw-r--r--comm/mail/base/content/sanitize.xhtml92
-rw-r--r--comm/mail/base/content/sanitizeDialog.js206
-rw-r--r--comm/mail/base/content/searchBar.js45
-rw-r--r--comm/mail/base/content/selectionsummaries.js103
-rw-r--r--comm/mail/base/content/shortcutsOverlay.js123
-rw-r--r--comm/mail/base/content/spacesToolbar.inc.xhtml166
-rw-r--r--comm/mail/base/content/spacesToolbar.js1325
-rw-r--r--comm/mail/base/content/spacesToolbarPin.inc.xhtml46
-rw-r--r--comm/mail/base/content/specialTabs.js1320
-rw-r--r--comm/mail/base/content/sync.js157
-rw-r--r--comm/mail/base/content/systemIntegrationDialog.js188
-rw-r--r--comm/mail/base/content/systemIntegrationDialog.xhtml53
-rw-r--r--comm/mail/base/content/tabDialogs.inc.xhtml23
-rw-r--r--comm/mail/base/content/tabmail.js2048
-rw-r--r--comm/mail/base/content/tagDialog.inc.xhtml27
-rw-r--r--comm/mail/base/content/threadPane.js825
-rw-r--r--comm/mail/base/content/threadTree.inc.xhtml230
-rw-r--r--comm/mail/base/content/toolbarIconColor.js166
-rw-r--r--comm/mail/base/content/troubleshootMode.js74
-rw-r--r--comm/mail/base/content/troubleshootMode.xhtml54
-rw-r--r--comm/mail/base/content/utilityOverlay.js514
-rw-r--r--comm/mail/base/content/viewSource.js168
-rw-r--r--comm/mail/base/content/viewSource.xhtml245
-rw-r--r--comm/mail/base/content/viewZoomOverlay.js153
-rw-r--r--comm/mail/base/content/webextensions.css106
-rw-r--r--comm/mail/base/content/widgets/browserPopups.inc.xhtml192
-rw-r--r--comm/mail/base/content/widgets/browserPopups.js991
-rw-r--r--comm/mail/base/content/widgets/customizable-toolbar.js319
-rw-r--r--comm/mail/base/content/widgets/foldersummary.js295
-rw-r--r--comm/mail/base/content/widgets/gloda-autocomplete-input.js243
-rw-r--r--comm/mail/base/content/widgets/glodaFacet.js1823
-rw-r--r--comm/mail/base/content/widgets/header-fields.js973
-rw-r--r--comm/mail/base/content/widgets/mailWidgets.js2477
-rw-r--r--comm/mail/base/content/widgets/pane-splitter.js562
-rw-r--r--comm/mail/base/content/widgets/statuspanel.js78
-rw-r--r--comm/mail/base/content/widgets/tabmail-tab.js179
-rw-r--r--comm/mail/base/content/widgets/tabmail-tabs.js723
-rw-r--r--comm/mail/base/content/widgets/toolbarContext.inc.xhtml19
-rw-r--r--comm/mail/base/content/widgets/toolbarbutton-menu-button.js80
-rw-r--r--comm/mail/base/content/widgets/tree-listbox.js914
-rw-r--r--comm/mail/base/content/widgets/tree-selection.mjs744
-rw-r--r--comm/mail/base/content/widgets/tree-view.mjs2633
-rw-r--r--comm/mail/base/jar.mn144
-rw-r--r--comm/mail/base/moz.build54
-rw-r--r--comm/mail/base/test/browser/browser-detachedWindows.ini15
-rw-r--r--comm/mail/base/test/browser/browser-drawBelowTitlebar.ini17
-rw-r--r--comm/mail/base/test/browser/browser-drawInTitlebar.ini17
-rw-r--r--comm/mail/base/test/browser/browser.ini66
-rw-r--r--comm/mail/base/test/browser/browser_3paneTelemetry.js163
-rw-r--r--comm/mail/base/test/browser/browser_archive.js98
-rw-r--r--comm/mail/base/test/browser/browser_browserContext.js398
-rw-r--r--comm/mail/base/test/browser/browser_browserRequestWindow.js74
-rw-r--r--comm/mail/base/test/browser/browser_cardsView.js248
-rw-r--r--comm/mail/base/test/browser/browser_detachedWindows.js223
-rw-r--r--comm/mail/base/test/browser/browser_editMenu.js511
-rw-r--r--comm/mail/base/test/browser/browser_fileMenu.js137
-rw-r--r--comm/mail/base/test/browser/browser_folderPaneContext.js198
-rw-r--r--comm/mail/base/test/browser/browser_folderTreeProperties.js236
-rw-r--r--comm/mail/base/test/browser/browser_folderTreeQuirks.js1450
-rw-r--r--comm/mail/base/test/browser/browser_formPickers.js352
-rw-r--r--comm/mail/base/test/browser/browser_goMenu.js35
-rw-r--r--comm/mail/base/test/browser/browser_interactionTelemetry.js67
-rw-r--r--comm/mail/base/test/browser/browser_linkHandler.js294
-rw-r--r--comm/mail/base/test/browser/browser_mailContext.js950
-rw-r--r--comm/mail/base/test/browser/browser_mailTabsAndWindows.js355
-rw-r--r--comm/mail/base/test/browser/browser_markAsRead.js204
-rw-r--r--comm/mail/base/test/browser/browser_menulist.js183
-rw-r--r--comm/mail/base/test/browser/browser_messageMenu.js355
-rw-r--r--comm/mail/base/test/browser/browser_navigation.js1035
-rw-r--r--comm/mail/base/test/browser/browser_orderableTreeListbox.js481
-rw-r--r--comm/mail/base/test/browser/browser_paneFocus.js375
-rw-r--r--comm/mail/base/test/browser/browser_paneSplitter.js572
-rw-r--r--comm/mail/base/test/browser/browser_preferDisplayName.js456
-rw-r--r--comm/mail/base/test/browser/browser_searchMessages.js460
-rw-r--r--comm/mail/base/test/browser/browser_selectionWidgetController.js6196
-rw-r--r--comm/mail/base/test/browser/browser_smartFolderDelete.js75
-rw-r--r--comm/mail/base/test/browser/browser_spacesToolbar.js1173
-rw-r--r--comm/mail/base/test/browser/browser_spacesToolbarCustomize.js119
-rw-r--r--comm/mail/base/test/browser/browser_spacesToolbar_drawBelowTitlebar.js24
-rw-r--r--comm/mail/base/test/browser/browser_spacesToolbar_drawInTitlebar.js24
-rw-r--r--comm/mail/base/test/browser/browser_statusFeedback.js71
-rw-r--r--comm/mail/base/test/browser/browser_tabIcon.js99
-rw-r--r--comm/mail/base/test/browser/browser_tagsMode.js214
-rw-r--r--comm/mail/base/test/browser/browser_threadTreeDeleting.js572
-rw-r--r--comm/mail/base/test/browser/browser_threadTreeQuirks.js669
-rw-r--r--comm/mail/base/test/browser/browser_threadTreeSorting.js344
-rw-r--r--comm/mail/base/test/browser/browser_threads.js385
-rw-r--r--comm/mail/base/test/browser/browser_toolsMenu.js109
-rw-r--r--comm/mail/base/test/browser/browser_treeListbox.js1313
-rw-r--r--comm/mail/base/test/browser/browser_treeView.js1941
-rw-r--r--comm/mail/base/test/browser/browser_viewMenu.js218
-rw-r--r--comm/mail/base/test/browser/browser_webSearchTelemetry.js50
-rw-r--r--comm/mail/base/test/browser/browser_zoom.js110
-rw-r--r--comm/mail/base/test/browser/files/formContent.html36
-rw-r--r--comm/mail/base/test/browser/files/links.html38
-rw-r--r--comm/mail/base/test/browser/files/menulist.xhtml30
-rw-r--r--comm/mail/base/test/browser/files/orderableTreeListbox.xhtml171
-rw-r--r--comm/mail/base/test/browser/files/paneSplitter.xhtml122
-rw-r--r--comm/mail/base/test/browser/files/rss.xml16
-rw-r--r--comm/mail/base/test/browser/files/sampleContent.eml160
-rw-r--r--comm/mail/base/test/browser/files/sampleContent.html16
-rw-r--r--comm/mail/base/test/browser/files/selectionWidget.js225
-rw-r--r--comm/mail/base/test/browser/files/selectionWidget.xhtml57
-rw-r--r--comm/mail/base/test/browser/files/tb-logo.pngbin0 -> 6462 bytes
-rw-r--r--comm/mail/base/test/browser/files/tree-element-test-common.js73
-rw-r--r--comm/mail/base/test/browser/files/tree-element-test-header.js64
-rw-r--r--comm/mail/base/test/browser/files/tree-element-test-header.xhtml61
-rw-r--r--comm/mail/base/test/browser/files/tree-element-test-levels.js118
-rw-r--r--comm/mail/base/test/browser/files/tree-element-test-levels.xhtml65
-rw-r--r--comm/mail/base/test/browser/files/tree-element-test-no-header.js58
-rw-r--r--comm/mail/base/test/browser/files/tree-element-test-no-header.xhtml54
-rw-r--r--comm/mail/base/test/browser/files/treeListbox.xhtml390
-rw-r--r--comm/mail/base/test/browser/head.js371
-rw-r--r--comm/mail/base/test/browser/head_spacesToolbar.js34
-rw-r--r--comm/mail/base/test/moz.build22
-rw-r--r--comm/mail/base/test/performance/browser.ini23
-rw-r--r--comm/mail/base/test/performance/browser_preferences_usage.js177
-rw-r--r--comm/mail/base/test/performance/browser_startup.js277
-rw-r--r--comm/mail/base/test/unit/distribution.ini56
-rw-r--r--comm/mail/base/test/unit/head_mailbase.js20
-rw-r--r--comm/mail/base/test/unit/head_mailbase_maildir.js9
-rw-r--r--comm/mail/base/test/unit/resources/viewWrapperTestUtils.js534
-rw-r--r--comm/mail/base/test/unit/test_alertHook.js119
-rw-r--r--comm/mail/base/test/unit/test_attachmentChecker.js121
-rw-r--r--comm/mail/base/test/unit/test_devtools_url.js22
-rw-r--r--comm/mail/base/test/unit/test_emptyTrash_dbViewWrapper.js43
-rw-r--r--comm/mail/base/test/unit/test_mailGlue_distribution.js120
-rw-r--r--comm/mail/base/test/unit/test_oauth_migration.js319
-rw-r--r--comm/mail/base/test/unit/test_treeSelection.js581
-rw-r--r--comm/mail/base/test/unit/test_viewWrapper_imapFolder.js55
-rw-r--r--comm/mail/base/test/unit/test_viewWrapper_logic.js359
-rw-r--r--comm/mail/base/test/unit/test_viewWrapper_realFolder.js666
-rw-r--r--comm/mail/base/test/unit/test_viewWrapper_virtualFolder.js552
-rw-r--r--comm/mail/base/test/unit/test_viewWrapper_virtualFolderCustomTerm.js65
-rw-r--r--comm/mail/base/test/unit/xpcshell.ini25
-rw-r--r--comm/mail/base/test/unit/xpcshell_maildir.ini6
-rw-r--r--comm/mail/base/test/webextensions/.eslintrc.js13
-rw-r--r--comm/mail/base/test/webextensions/browser.ini41
-rw-r--r--comm/mail/base/test/webextensions/browser_extension_install_experiment.js82
-rw-r--r--comm/mail/base/test/webextensions/browser_extension_sideloading.js352
-rw-r--r--comm/mail/base/test/webextensions/browser_extension_update_background.js263
-rw-r--r--comm/mail/base/test/webextensions/browser_extension_update_background_noprompt.js116
-rw-r--r--comm/mail/base/test/webextensions/browser_permissions_installTrigger.js27
-rw-r--r--comm/mail/base/test/webextensions/browser_permissions_local_file.js46
-rw-r--r--comm/mail/base/test/webextensions/browser_permissions_mozAddonManager.js19
-rw-r--r--comm/mail/base/test/webextensions/browser_permissions_optional.js53
-rw-r--r--comm/mail/base/test/webextensions/browser_permissions_pointerevent.js65
-rw-r--r--comm/mail/base/test/webextensions/browser_permissions_unsigned.js49
-rw-r--r--comm/mail/base/test/webextensions/browser_update_checkForUpdates.js17
-rw-r--r--comm/mail/base/test/webextensions/browser_update_interactive_noprompt.js82
-rw-r--r--comm/mail/base/test/webextensions/browser_webext_experiment.xpibin0 -> 2492 bytes
-rw-r--r--comm/mail/base/test/webextensions/browser_webext_experiment_permissions.xpibin0 -> 2510 bytes
-rw-r--r--comm/mail/base/test/webextensions/browser_webext_experiment_update1.xpibin0 -> 331 bytes
-rw-r--r--comm/mail/base/test/webextensions/browser_webext_experiment_update2.xpibin0 -> 2547 bytes
-rw-r--r--comm/mail/base/test/webextensions/browser_webext_nopermissions.xpibin0 -> 4273 bytes
-rw-r--r--comm/mail/base/test/webextensions/browser_webext_permissions.xpibin0 -> 16638 bytes
-rw-r--r--comm/mail/base/test/webextensions/browser_webext_unsigned.xpibin0 -> 12606 bytes
-rw-r--r--comm/mail/base/test/webextensions/browser_webext_update.json82
-rw-r--r--comm/mail/base/test/webextensions/browser_webext_update1.xpibin0 -> 4311 bytes
-rw-r--r--comm/mail/base/test/webextensions/browser_webext_update2.xpibin0 -> 4331 bytes
-rw-r--r--comm/mail/base/test/webextensions/browser_webext_update_icon1.xpibin0 -> 16585 bytes
-rw-r--r--comm/mail/base/test/webextensions/browser_webext_update_icon2.xpibin0 -> 16604 bytes
-rw-r--r--comm/mail/base/test/webextensions/browser_webext_update_origins1.xpibin0 -> 268 bytes
-rw-r--r--comm/mail/base/test/webextensions/browser_webext_update_origins2.xpibin0 -> 275 bytes
-rw-r--r--comm/mail/base/test/webextensions/browser_webext_update_perms1.xpibin0 -> 4273 bytes
-rw-r--r--comm/mail/base/test/webextensions/browser_webext_update_perms2.xpibin0 -> 4282 bytes
-rw-r--r--comm/mail/base/test/webextensions/file_install_extensions.html19
-rw-r--r--comm/mail/base/test/webextensions/head.js632
254 files changed, 102302 insertions, 0 deletions
diff --git a/comm/mail/base/content/FilterListDialog.js b/comm/mail/base/content/FilterListDialog.js
new file mode 100644
index 0000000000..a802da6d78
--- /dev/null
+++ b/comm/mail/base/content/FilterListDialog.js
@@ -0,0 +1,1162 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*-
+ * 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/. */
+
+var { PluralForm } = ChromeUtils.importESModule(
+ "resource://gre/modules/PluralForm.sys.mjs"
+);
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+window.addEventListener("load", onLoad);
+window.addEventListener("unload", onFilterUnload);
+window.addEventListener("close", event => {
+ if (!onFilterClose()) {
+ event.preventDefault();
+ }
+});
+
+var gFilterListMsgWindow = null;
+var gCurrentFilterList;
+var gServerMenu = null;
+var gFilterListbox = null;
+var gEditButton = null;
+var gDeleteButton = null;
+var gCopyToNewButton = null;
+var gTopButton = null;
+var gUpButton = null;
+var gDownButton = null;
+var gBottomButton = null;
+var gSearchBox = null;
+var gRunFiltersFolder = null;
+var gRunFiltersButton = null;
+
+var gFilterBundle = null;
+
+var msgMoveMotion = {
+ Up: 0,
+ Down: 1,
+ Top: 2,
+ Bottom: 3,
+};
+
+var gStatusFeedback = {
+ progressMeterVisible: false,
+
+ showStatusString(status) {
+ document.getElementById("statusText").setAttribute("value", status);
+ },
+ startMeteors() {
+ // change run button to be a stop button
+ gRunFiltersButton.setAttribute(
+ "label",
+ gRunFiltersButton.getAttribute("stoplabel")
+ );
+ gRunFiltersButton.setAttribute(
+ "accesskey",
+ gRunFiltersButton.getAttribute("stopaccesskey")
+ );
+
+ if (!this.progressMeterVisible) {
+ document
+ .getElementById("statusbar-progresspanel")
+ .removeAttribute("collapsed");
+ this.progressMeterVisible = true;
+ }
+
+ document.getElementById("statusbar-icon").removeAttribute("value");
+ },
+ stopMeteors() {
+ try {
+ // change run button to be a stop button
+ gRunFiltersButton.setAttribute(
+ "label",
+ gRunFiltersButton.getAttribute("runlabel")
+ );
+ gRunFiltersButton.setAttribute(
+ "accesskey",
+ gRunFiltersButton.getAttribute("runaccesskey")
+ );
+
+ if (this.progressMeterVisible) {
+ document.getElementById("statusbar-progresspanel").collapsed = true;
+ this.progressMeterVisible = true;
+ }
+ } catch (ex) {
+ // can get here if closing window when running filters
+ }
+ },
+ showProgress(percentage) {},
+ closeWindow() {},
+};
+
+var filterEditorQuitObserver = {
+ observe(aSubject, aTopic, aData) {
+ // Check whether or not we want to veto the quit request (unless another
+ // observer already did.
+ if (
+ aTopic == "quit-application-requested" &&
+ aSubject instanceof Ci.nsISupportsPRBool &&
+ !aSubject.data
+ ) {
+ aSubject.data = !onFilterClose();
+ }
+ },
+};
+
+function onLoad() {
+ gFilterListMsgWindow = Cc[
+ "@mozilla.org/messenger/msgwindow;1"
+ ].createInstance(Ci.nsIMsgWindow);
+ gFilterListMsgWindow.domWindow = window;
+ gFilterListMsgWindow.rootDocShell.appType = Ci.nsIDocShell.APP_TYPE_MAIL;
+ gFilterListMsgWindow.statusFeedback = gStatusFeedback;
+
+ gServerMenu = document.getElementById("serverMenu");
+ gFilterListbox = document.getElementById("filterList");
+ gEditButton = document.getElementById("editButton");
+ gDeleteButton = document.getElementById("deleteButton");
+ gCopyToNewButton = document.getElementById("copyToNewButton");
+ gTopButton = document.getElementById("reorderTopButton");
+ gUpButton = document.getElementById("reorderUpButton");
+ gDownButton = document.getElementById("reorderDownButton");
+ gBottomButton = document.getElementById("reorderBottomButton");
+ gSearchBox = document.getElementById("searchBox");
+ gRunFiltersFolder = document.getElementById("runFiltersFolder");
+ gRunFiltersButton = document.getElementById("runFiltersButton");
+ gFilterBundle = document.getElementById("bundle_filter");
+
+ updateButtons();
+
+ initNewToolbarButtons(document.querySelector("#newButton toolbarbutton"));
+ initNewToolbarButtons(document.querySelector("#newButton dropmarker"));
+ document
+ .getElementById("filterActionButtons")
+ .addEventListener("keypress", event => onFilterActionButtonKeyPress(event));
+
+ processWindowArguments(window.arguments[0]);
+
+ // Don't change width after initial layout, so buttons stay within the dialog.
+ gRunFiltersFolder.style.maxWidth =
+ gRunFiltersFolder.getBoundingClientRect().width + "px";
+
+ Services.obs.addObserver(
+ filterEditorQuitObserver,
+ "quit-application-requested"
+ );
+}
+/**
+ * Set up the toolbarbutton to have an index and an EvenListener for proper
+ * keyboard navigation.
+ *
+ * @param {XULElement} newToolbarbutton - The toolbarbutton that needs to be
+ * initialized.
+ */
+function initNewToolbarButtons(newToolbarbutton) {
+ newToolbarbutton.setAttribute("tabindex", "0");
+ newToolbarbutton.setAttribute(
+ "id",
+ newToolbarbutton.parentNode.id + newToolbarbutton.tagName
+ );
+}
+
+/**
+ * Processes arguments sent to this dialog when opened or refreshed.
+ *
+ * @param aArguments An object having members representing the arguments.
+ * { arg1: value1, arg2: value2, ... }
+ */
+function processWindowArguments(aArguments) {
+ // If a specific folder was requested, try to select it
+ // if we don't already show its server.
+ if (
+ !gServerMenu._folder ||
+ ("folder" in aArguments &&
+ aArguments.folder != gServerMenu._folder &&
+ aArguments.folder.rootFolder != gServerMenu._folder)
+ ) {
+ let wantedFolder;
+ if ("folder" in aArguments) {
+ wantedFolder = aArguments.folder;
+ }
+
+ // Get the folder where filters should be defined, if that server
+ // can accept filters.
+ let firstItem = getFilterFolderForSelection(wantedFolder);
+
+ // If the selected server cannot have filters, get the default server
+ // If the default server cannot have filters, check all accounts
+ // and get a server that can have filters.
+ if (!firstItem) {
+ firstItem = getServerThatCanHaveFilters().rootFolder;
+ }
+
+ if (firstItem) {
+ setFilterFolder(firstItem);
+ }
+
+ if (wantedFolder) {
+ setRunFolder(wantedFolder);
+ }
+ } else {
+ // If we didn't change folder still redraw the list
+ // to show potential new filters if we were called for refresh.
+ rebuildFilterList();
+ }
+
+ // If a specific filter was requested, try to select it.
+ if ("filter" in aArguments) {
+ selectFilter(aArguments.filter);
+ }
+}
+
+/**
+ * This is called from OpenOrFocusWindow() if the dialog is already open.
+ * New filters could have been created by operations outside the dialog.
+ *
+ * @param aArguments An object of arguments having the same format
+ * as window.arguments[0].
+ */
+function refresh(aArguments) {
+ // As we really don't know what has changed, clear the search box
+ // undonditionally so that the changed/added filters are surely visible.
+ resetSearchBox();
+
+ processWindowArguments(aArguments);
+}
+
+function CanRunFiltersAfterTheFact(aServer) {
+ // filter after the fact is implement using search
+ // so if you can't search, you can't filter after the fact
+ return aServer.canSearchMessages;
+}
+
+/**
+ * Change the root server for which we are managing filters.
+ *
+ * @param msgFolder The nsIMsgFolder server containing filters
+ * (or a folder for NNTP server).
+ */
+function setFilterFolder(msgFolder) {
+ if (!msgFolder || msgFolder == gServerMenu._folder) {
+ return;
+ }
+
+ // Save the current filters to disk before switching because
+ // the dialog may be closed and we'll lose current filters.
+ if (gCurrentFilterList) {
+ gCurrentFilterList.saveToDefaultFile();
+ }
+
+ // Setting this attribute should go away in bug 473009.
+ gServerMenu._folder = msgFolder;
+ // Calling this should go away in bug 802609.
+ gServerMenu.menupopup.selectFolder(msgFolder);
+
+ // Calling getEditableFilterList will detect any errors in msgFilterRules.dat,
+ // backup the file, and alert the user.
+ gCurrentFilterList = msgFolder.getEditableFilterList(gFilterListMsgWindow);
+ rebuildFilterList();
+
+ // Select the first item in the list, if there is one.
+ if (gFilterListbox.itemCount > 0) {
+ gFilterListbox.selectItem(gFilterListbox.getItemAtIndex(0));
+ }
+
+ // This will get the deferred to account root folder, if server is deferred.
+ // We intentionally do this after setting the current server, as we want
+ // that to refer to the rootFolder for the actual server, not the
+ // deferred-to server, as current server is really a proxy for the
+ // server whose filters we are editing. But below here we are managing
+ // where the filters will get applied, which is on the deferred-to server.
+ msgFolder = msgFolder.server.rootMsgFolder;
+
+ // root the folder picker to this server
+ let runMenu = gRunFiltersFolder.menupopup;
+ runMenu._teardown();
+ runMenu._parentFolder = msgFolder;
+ runMenu._ensureInitialized();
+
+ let canFilterAfterTheFact = CanRunFiltersAfterTheFact(msgFolder.server);
+ gRunFiltersFolder.disabled = !canFilterAfterTheFact;
+ gRunFiltersButton.disabled = !canFilterAfterTheFact;
+ document.getElementById("folderPickerPrefix").disabled =
+ !canFilterAfterTheFact;
+
+ if (canFilterAfterTheFact) {
+ let wantedFolder = null;
+ // For a given server folder, get the default run target folder or show
+ // "Choose Folder".
+ if (!msgFolder.isServer) {
+ wantedFolder = msgFolder;
+ } else {
+ try {
+ switch (msgFolder.server.type) {
+ case "nntp":
+ // For NNTP select the subscribed newsgroup.
+ wantedFolder = gServerMenu._folder;
+ break;
+ case "rss":
+ // Show "Choose Folder" for feeds.
+ wantedFolder = null;
+ break;
+ case "imap":
+ case "pop3":
+ case "none":
+ // Find Inbox for IMAP and POP or Local Folders,
+ // show "Choose Folder" if not found.
+ wantedFolder = msgFolder.rootFolder.getFolderWithFlags(
+ Ci.nsMsgFolderFlags.Inbox
+ );
+ break;
+ default:
+ // For other account types we don't know what's good to select,
+ // so show "Choose Folder".
+ wantedFolder = null;
+ }
+ } catch (e) {
+ console.error(
+ "Failed to select a suitable folder to run filters on: " + e
+ );
+ wantedFolder = null;
+ }
+ }
+
+ // Select a useful first folder for the server.
+ setRunFolder(wantedFolder);
+ }
+}
+
+/**
+ * Select a folder on which filters are to be run.
+ *
+ * @param aFolder nsIMsgFolder folder to select.
+ */
+function setRunFolder(aFolder) {
+ // Setting this attribute should go away in bug 473009.
+ gRunFiltersFolder._folder = aFolder;
+ // Calling this should go away in bug 802609.
+ gRunFiltersFolder.menupopup.selectFolder(gRunFiltersFolder._folder);
+ updateButtons();
+}
+
+/**
+ * Toggle enabled state of a filter, in both the filter properties and the UI.
+ *
+ * @param aFilterItem an item (row) of the filter list to be toggled
+ */
+function toggleFilter(aFilterItem, aSetForEvent) {
+ let filter = aFilterItem._filter;
+ if (filter.unparseable && !filter.enabled) {
+ Services.prompt.alert(
+ window,
+ null,
+ gFilterBundle.getFormattedString("cannotEnableIncompatFilter", [
+ document.getElementById("bundle_brand").getString("brandShortName"),
+ ])
+ );
+ return;
+ }
+ filter.enabled = aSetForEvent === undefined ? !filter.enabled : aSetForEvent;
+
+ // Now update the checkbox
+ if (aSetForEvent === undefined) {
+ aFilterItem.firstElementChild.nextElementSibling.checked = filter.enabled;
+ }
+ // For accessibility set the checked state on listitem
+ aFilterItem.setAttribute("aria-checked", filter.enabled);
+}
+
+/**
+ * Selects a specific filter in the filter list.
+ * The listbox view is scrolled to the corresponding item.
+ *
+ * @param aFilter The nsIMsgFilter to select.
+ *
+ * @returns true/false indicating whether the filter was found and selected.
+ */
+function selectFilter(aFilter) {
+ if (currentFilter() == aFilter) {
+ return true;
+ }
+
+ resetSearchBox(aFilter);
+
+ let filterCount = gCurrentFilterList.filterCount;
+ for (let i = 0; i < filterCount; i++) {
+ if (gCurrentFilterList.getFilterAt(i) == aFilter) {
+ gFilterListbox.ensureIndexIsVisible(i);
+ gFilterListbox.selectedIndex = i;
+ return true;
+ }
+ }
+ return false;
+}
+
+/**
+ * Returns the currently selected filter. If multiple filters are selected,
+ * returns the first one. If none are selected, returns null.
+ */
+function currentFilter() {
+ let currentItem = gFilterListbox.selectedItem;
+ return currentItem ? currentItem._filter : null;
+}
+
+function onEditFilter() {
+ if (gEditButton.disabled) {
+ return;
+ }
+
+ let selectedFilter = currentFilter();
+ if (!selectedFilter) {
+ return;
+ }
+
+ let args = { filter: selectedFilter, filterList: gCurrentFilterList };
+
+ window.openDialog(
+ "chrome://messenger/content/FilterEditor.xhtml",
+ "FilterEditor",
+ "chrome,modal,titlebar,resizable,centerscreen",
+ args
+ );
+
+ if ("refresh" in args && args.refresh) {
+ // reset search if edit was okay (name change might lead to hidden entry!)
+ resetSearchBox(selectedFilter);
+ rebuildFilterList();
+ }
+}
+
+/**
+ * Handler function for the 'New...' buttons.
+ * Opens the filter dialog for creating a new filter.
+ */
+function onNewFilter() {
+ calculatePositionAndShowCreateFilterDialog({});
+}
+
+/**
+ * Handler function for the 'Copy...' button.
+ * Opens the filter dialog for copying the selected filter.
+ */
+function onCopyToNewFilter() {
+ if (gCopyToNewButton.disabled) {
+ return;
+ }
+
+ let selectedFilter = currentFilter();
+ if (!selectedFilter) {
+ return;
+ }
+
+ let args = { copiedFilter: selectedFilter };
+
+ calculatePositionAndShowCreateFilterDialog(args);
+}
+
+/**
+ * Calculates the position for inserting the new filter,
+ * and then displays the create dialog.
+ *
+ * @param args The object containing the arguments for the dialog,
+ * passed to the filterEditorOnLoad() function.
+ * It will be augmented with the insertion position
+ * and global filters list properties by this function.
+ */
+function calculatePositionAndShowCreateFilterDialog(args) {
+ let selectedFilter = currentFilter();
+ // If no filter is selected use the first position.
+ let position = 0;
+ if (selectedFilter) {
+ // Get the position in the unfiltered list.
+ // - this is where the new filter should be inserted!
+ let filterCount = gCurrentFilterList.filterCount;
+ for (let i = 0; i < filterCount; i++) {
+ if (gCurrentFilterList.getFilterAt(i) == selectedFilter) {
+ position = i;
+ break;
+ }
+ }
+ }
+ args.filterPosition = position;
+
+ args.filterList = gCurrentFilterList;
+
+ window.openDialog(
+ "chrome://messenger/content/FilterEditor.xhtml",
+ "FilterEditor",
+ "chrome,modal,titlebar,resizable,centerscreen",
+ args
+ );
+
+ if ("refresh" in args && args.refresh) {
+ // On success: reset the search box if necessary!
+ resetSearchBox(args.newFilter);
+ rebuildFilterList();
+
+ // Select the new filter, it is at the position of previous selection.
+ gFilterListbox.selectItem(gFilterListbox.getItemAtIndex(position));
+ if (currentFilter() != args.newFilter) {
+ console.error("Filter created at an unexpected position!");
+ }
+ }
+}
+
+/**
+ * Delete selected filters.
+ * 'Selected' is not to be confused with active (checkbox checked)
+ */
+function onDeleteFilter() {
+ if (gDeleteButton.disabled) {
+ return;
+ }
+
+ let items = gFilterListbox.selectedItems;
+ if (!items.length) {
+ return;
+ }
+
+ let checkValue = { value: false };
+ if (
+ Services.prefs.getBoolPref("mailnews.filters.confirm_delete") &&
+ Services.prompt.confirmEx(
+ window,
+ null,
+ gFilterBundle.getString("deleteFilterConfirmation"),
+ Services.prompt.STD_YES_NO_BUTTONS,
+ "",
+ "",
+ "",
+ gFilterBundle.getString("dontWarnAboutDeleteCheckbox"),
+ checkValue
+ )
+ ) {
+ return;
+ }
+
+ if (checkValue.value) {
+ Services.prefs.setBoolPref("mailnews.filters.confirm_delete", false);
+ }
+
+ // Save filter position before the first selected one.
+ let newSelectionIndex = gFilterListbox.selectedIndex - 1;
+
+ // Must reverse the loop, as the items list shrinks when we delete.
+ for (let index = items.length - 1; index >= 0; --index) {
+ let item = items[index];
+ gCurrentFilterList.removeFilter(item._filter);
+ item.remove();
+ }
+ updateCountBox();
+
+ // Select filter above previously selected if one existed, otherwise the first one.
+ if (newSelectionIndex == -1 && gFilterListbox.itemCount > 0) {
+ newSelectionIndex = 0;
+ }
+ if (newSelectionIndex > -1) {
+ gFilterListbox.selectedIndex = newSelectionIndex;
+ updateViewPosition(-1);
+ }
+}
+
+/**
+ * Move filter one step up in visible list.
+ */
+function onUp(event) {
+ moveFilter(msgMoveMotion.Up);
+}
+
+/**
+ * Move filter one step down in visible list.
+ */
+function onDown(event) {
+ moveFilter(msgMoveMotion.Down);
+}
+
+/**
+ * Move filter to bottom for long filter lists.
+ */
+function onTop(evt) {
+ moveFilter(msgMoveMotion.Top);
+}
+
+/**
+ * Move filter to top for long filter lists.
+ */
+function onBottom(evt) {
+ moveFilter(msgMoveMotion.Bottom);
+}
+
+/**
+ * Moves a singular selected filter up or down either 1 increment or to the
+ * top/bottom. This acts on the visible filter list only which means that:
+ *
+ * - when moving up or down "1" the filter may skip one or more other
+ * filters (which are currently not visible) - this will also lead
+ * to the "related" filters (e.g search filters containing 'moz')
+ * being grouped more closely together
+ * - moveTop / moveBottom
+ * this is currently moving to the top/bottom of the absolute list
+ * but it would be better if it moved "just as far as necessary"
+ * which would further "compact" related filters
+ *
+ * @param motion
+ * msgMoveMotion.Up, msgMoveMotion.Down, msgMoveMotion.Top, msgMoveMotion.Bottom
+ */
+function moveFilter(motion) {
+ // At the moment, do not allow moving groups of filters.
+ let selectedFilter = currentFilter();
+ if (!selectedFilter) {
+ return;
+ }
+
+ var relativeStep = 0;
+ var moveFilterNative = null;
+
+ switch (motion) {
+ case msgMoveMotion.Top:
+ if (selectedFilter) {
+ gCurrentFilterList.removeFilter(selectedFilter);
+ gCurrentFilterList.insertFilterAt(0, selectedFilter);
+ rebuildFilterList();
+ }
+ return;
+ case msgMoveMotion.Bottom:
+ if (selectedFilter) {
+ gCurrentFilterList.removeFilter(selectedFilter);
+ gCurrentFilterList.insertFilterAt(
+ gCurrentFilterList.filterCount,
+ selectedFilter
+ );
+ rebuildFilterList();
+ }
+ return;
+ case msgMoveMotion.Up:
+ relativeStep = -1;
+ moveFilterNative = Ci.nsMsgFilterMotion.up;
+ break;
+ case msgMoveMotion.Down:
+ relativeStep = +1;
+ moveFilterNative = Ci.nsMsgFilterMotion.down;
+ break;
+ }
+
+ if (!gSearchBox.value) {
+ // use legacy move filter code: up, down; only if searchBox is empty
+ moveCurrentFilter(moveFilterNative);
+ return;
+ }
+
+ let nextIndex = gFilterListbox.selectedIndex + relativeStep;
+ let nextFilter = gFilterListbox.getItemAtIndex(nextIndex)._filter;
+
+ gCurrentFilterList.removeFilter(selectedFilter);
+
+ // Find the index of the filter we want to insert at.
+ let newIndex = -1;
+ let filterCount = gCurrentFilterList.filterCount;
+ for (let i = 0; i < filterCount; i++) {
+ if (gCurrentFilterList.getFilterAt(i) == nextFilter) {
+ newIndex = i;
+ break;
+ }
+ }
+
+ if (motion == msgMoveMotion.Down) {
+ newIndex += relativeStep;
+ }
+
+ gCurrentFilterList.insertFilterAt(newIndex, selectedFilter);
+
+ rebuildFilterList();
+}
+
+function viewLog() {
+ var args = { filterList: gCurrentFilterList };
+
+ window.openDialog(
+ "chrome://messenger/content/viewLog.xhtml",
+ "FilterLog",
+ "chrome,modal,titlebar,resizable,centerscreen",
+ args
+ );
+}
+
+function onFilterUnload() {
+ gCurrentFilterList.saveToDefaultFile();
+ Services.obs.removeObserver(
+ filterEditorQuitObserver,
+ "quit-application-requested"
+ );
+
+ gFilterListMsgWindow.closeWindow();
+}
+
+function onFilterClose() {
+ if (
+ gRunFiltersButton.getAttribute("label") ==
+ gRunFiltersButton.getAttribute("stoplabel")
+ ) {
+ let promptTitle = gFilterBundle.getString("promptTitle");
+ let promptMsg = gFilterBundle.getString("promptMsg");
+ let stopButtonLabel = gFilterBundle.getString("stopButtonLabel");
+ let continueButtonLabel = gFilterBundle.getString("continueButtonLabel");
+
+ let result = Services.prompt.confirmEx(
+ window,
+ promptTitle,
+ promptMsg,
+ Services.prompt.BUTTON_TITLE_IS_STRING * Services.prompt.BUTTON_POS_0 +
+ Services.prompt.BUTTON_TITLE_IS_STRING * Services.prompt.BUTTON_POS_1,
+ continueButtonLabel,
+ stopButtonLabel,
+ null,
+ null,
+ { value: 0 }
+ );
+
+ if (result) {
+ gFilterListMsgWindow.StopUrls();
+ } else {
+ return false;
+ }
+ }
+
+ return true;
+}
+
+function runSelectedFilters() {
+ // if run button has "stop" label, do stop.
+ if (
+ gRunFiltersButton.getAttribute("label") ==
+ gRunFiltersButton.getAttribute("stoplabel")
+ ) {
+ gFilterListMsgWindow.StopUrls();
+ return;
+ }
+
+ let folder =
+ gRunFiltersFolder._folder || gRunFiltersFolder.selectedItem._folder;
+ if (!folder) {
+ return;
+ }
+
+ let filterList = MailServices.filters.getTempFilterList(folder);
+
+ // make sure the tmp filter list uses the real filter list log stream
+ filterList.loggingEnabled = gCurrentFilterList.loggingEnabled;
+ filterList.logStream = gCurrentFilterList.logStream;
+
+ let index = 0;
+ for (let item of gFilterListbox.selectedItems) {
+ filterList.insertFilterAt(index++, item._filter);
+ }
+
+ MailServices.filters.applyFiltersToFolders(
+ filterList,
+ [folder],
+ gFilterListMsgWindow
+ );
+}
+
+function moveCurrentFilter(motion) {
+ let filter = currentFilter();
+ if (!filter) {
+ return;
+ }
+
+ gCurrentFilterList.moveFilter(filter, motion);
+ rebuildFilterList();
+}
+
+/**
+ * Redraws the list of filters. Takes the search box value into account.
+ *
+ * This function should perform very fast even in case of high number of filters.
+ * Therefore there are some optimizations (e.g. listelement.itemChildren[] instead of
+ * list.getItemAtIndex()), that favour speed vs. semantical perfection.
+ */
+function rebuildFilterList() {
+ // Get filters that match the search box.
+ let aTempFilterList = onFindFilter();
+
+ let searchBoxFocus = false;
+ let activeElement = document.activeElement;
+
+ // Find if the currently focused element is a child inside the search box
+ // (probably html:input). Traverse up the parents until the first element
+ // with an ID is found. If it is not searchBox, return false.
+ while (activeElement != null) {
+ if (activeElement == gSearchBox) {
+ searchBoxFocus = true;
+ break;
+ } else if (activeElement.id) {
+ searchBoxFocus = false;
+ break;
+ }
+ activeElement = activeElement.parentNode;
+ }
+
+ // Make a note of which filters were previously selected
+ let selectedNames = [];
+ for (let i = 0; i < gFilterListbox.selectedItems.length; i++) {
+ selectedNames.push(gFilterListbox.selectedItems[i]._filter.filterName);
+ }
+
+ // Save scroll position so we can try to restore it later.
+ // Doesn't work when the list is rebuilt after search box condition changed.
+ let firstVisibleRowIndex = gFilterListbox.getIndexOfFirstVisibleRow();
+
+ // listbox.xml seems to cache the value of the first selected item in a
+ // range at _selectionStart. The old value though is now obsolete,
+ // since we will recreate all of the elements. We need to clear this,
+ // and one way to do this is with a call to clearSelection. This might be
+ // ugly from an accessibility perspective, since it fires an onSelect event.
+ gFilterListbox.clearSelection();
+
+ let listitem, nameCell, enabledCell, filter;
+ let filterCount = gCurrentFilterList.filterCount;
+ let listitemCount = gFilterListbox.itemCount;
+ let listitemIndex = 0;
+ let tempFilterListLength = aTempFilterList ? aTempFilterList.length - 1 : 0;
+ for (let i = 0; i < filterCount; i++) {
+ if (aTempFilterList && listitemIndex > tempFilterListLength) {
+ break;
+ }
+
+ filter = gCurrentFilterList.getFilterAt(i);
+ if (aTempFilterList && aTempFilterList[listitemIndex] != i) {
+ continue;
+ }
+
+ if (listitemCount > listitemIndex) {
+ // If there is a free existing listitem, reuse it.
+ // Use .itemChildren[] instead of .getItemAtIndex() as it is much faster.
+ listitem = gFilterListbox.itemChildren[listitemIndex];
+ nameCell = listitem.firstElementChild;
+ enabledCell = nameCell.nextElementSibling;
+ } else {
+ // If there are not enough listitems in the list, create a new one.
+ listitem = document.createXULElement("richlistitem");
+ listitem.setAttribute("align", "center");
+ listitem.setAttribute("role", "checkbox");
+ nameCell = document.createXULElement("label");
+ nameCell.setAttribute("flex", "1");
+ nameCell.setAttribute("crop", "end");
+ enabledCell = document.createXULElement("checkbox");
+ enabledCell.setAttribute("style", "padding-inline-start: 25px;");
+ enabledCell.addEventListener("CheckboxStateChange", onFilterClick, true);
+ listitem.appendChild(nameCell);
+ listitem.appendChild(enabledCell);
+ gFilterListbox.appendChild(listitem);
+ // We have to attach this listener to the listitem, even though we only care
+ // about clicks on the enabledCell. However, attaching to that item doesn't
+ // result in any events actually getting received.
+ listitem.addEventListener("dblclick", onFilterDoubleClick, true);
+ }
+ // For accessibility set the label on listitem.
+ listitem.setAttribute("label", filter.filterName);
+ // Set the listitem values to represent the current filter.
+ nameCell.setAttribute("value", filter.filterName);
+ if (filter.enabled) {
+ enabledCell.setAttribute("checked", "true");
+ } else {
+ enabledCell.removeAttribute("checked");
+ }
+ listitem.setAttribute("aria-checked", filter.enabled);
+ listitem._filter = filter;
+
+ if (selectedNames.includes(filter.filterName)) {
+ gFilterListbox.addItemToSelection(listitem);
+ }
+
+ listitemIndex++;
+ }
+ // Remove any superfluous listitems, if the number of filters shrunk.
+ for (let i = listitemCount - 1; i >= listitemIndex; i--) {
+ gFilterListbox.lastChild.remove();
+ }
+
+ updateViewPosition(firstVisibleRowIndex);
+ updateCountBox();
+
+ // If before rebuilding the list the searchbox was focused, focus it again.
+ // In any other case, focus the list.
+ if (searchBoxFocus) {
+ gSearchBox.focus();
+ } else {
+ gFilterListbox.focus();
+ }
+}
+
+function updateViewPosition(firstVisibleRowIndex) {
+ if (firstVisibleRowIndex == -1) {
+ firstVisibleRowIndex = gFilterListbox.getIndexOfFirstVisibleRow();
+ }
+
+ // Restore to the extent possible the scroll position.
+ if (firstVisibleRowIndex && gFilterListbox.itemCount) {
+ gFilterListbox.ensureElementIsVisible(
+ gFilterListbox.getItemAtIndex(
+ Math.min(firstVisibleRowIndex, gFilterListbox.itemCount - 1)
+ ),
+ true
+ );
+ }
+
+ if (gFilterListbox.selectedCount) {
+ // Make sure that at least the first selected item is visible.
+ gFilterListbox.ensureElementIsVisible(gFilterListbox.selectedItems[0]);
+
+ // The current item should be the first selected item, so that keyboard
+ // selection extension can work.
+ gFilterListbox.currentItem = gFilterListbox.selectedItems[0];
+ }
+
+ updateButtons();
+}
+
+/**
+ * Try to only enable buttons that make sense
+ * - moving filters is currently only enabled for single selection
+ * also movement is restricted by searchBox and current selection position
+ * - edit only for single filters
+ * - delete / run only for one or more selected filters
+ */
+function updateButtons() {
+ var numFiltersSelected = gFilterListbox.selectedItems.length;
+ var oneFilterSelected = numFiltersSelected == 1;
+
+ // "edit" is disabled when not exactly one filter is selected
+ // or if we couldn't parse that filter
+ let disabled = !oneFilterSelected || currentFilter().unparseable;
+ gEditButton.disabled = disabled;
+
+ // "copy" is the same as "edit"
+ gCopyToNewButton.disabled = disabled;
+
+ // "delete" only disabled when no filters are selected
+ gDeleteButton.disabled = !numFiltersSelected;
+
+ // we can run multiple filters on a folder
+ // so only disable this UI if no filters are selected
+ document.getElementById("folderPickerPrefix").disabled = !numFiltersSelected;
+ gRunFiltersFolder.disabled = !numFiltersSelected;
+ gRunFiltersButton.disabled =
+ !numFiltersSelected || !gRunFiltersFolder._folder;
+ // "up" and "top" enabled only if one filter is selected, and it's not the first
+ // don't use gFilterListbox.currentIndex here, it's buggy when we've just changed the
+ // children in the list (via rebuildFilterList)
+ disabled = !(
+ oneFilterSelected &&
+ gFilterListbox.getSelectedItem(0) != gFilterListbox.getItemAtIndex(0)
+ );
+ gUpButton.disabled = disabled;
+ gTopButton.disabled = disabled;
+
+ // "down" and "bottom" enabled only if one filter is selected,
+ // and it's not the last one
+ disabled = !(
+ oneFilterSelected &&
+ gFilterListbox.selectedIndex < gFilterListbox.itemCount - 1
+ );
+ gDownButton.disabled = disabled;
+ gBottomButton.disabled = disabled;
+}
+
+/**
+ * Given a selected folder, returns the folder where filters should
+ * be defined (the root folder except for news) if the server can
+ * accept filters.
+ *
+ * @param nsIMsgFolder aFolder - selected folder, from window args
+ * @returns an nsIMsgFolder where the filter is defined
+ */
+function getFilterFolderForSelection(aFolder) {
+ let rootFolder = aFolder && aFolder.server ? aFolder.server.rootFolder : null;
+ if (rootFolder && rootFolder.isServer && rootFolder.server.canHaveFilters) {
+ return aFolder.server.type == "nntp" ? aFolder : rootFolder;
+ }
+
+ return null;
+}
+
+/**
+ * If the selected server cannot have filters, get the default server.
+ * If the default server cannot have filters, check all accounts
+ * and get a server that can have filters.
+ *
+ * @returns an nsIMsgIncomingServer
+ */
+function getServerThatCanHaveFilters() {
+ let defaultAccount = MailServices.accounts.defaultAccount;
+ if (defaultAccount) {
+ let defaultIncomingServer = defaultAccount.incomingServer;
+ // Check to see if default server can have filters.
+ if (defaultIncomingServer.canHaveFilters) {
+ return defaultIncomingServer;
+ }
+ }
+
+ // If it cannot, check all accounts to find a server
+ // that can have filters.
+ return MailServices.accounts.allServers.find(server => server.canHaveFilters);
+}
+
+function onFilterClick(event) {
+ // This is called after the clicked checkbox changed state
+ // so this.checked is the right state we want to toggle to.
+ toggleFilter(this.parentNode, this.checked);
+}
+
+function onFilterDoubleClick(event) {
+ // we only care about button 0 (left click) events
+ if (event.button != 0) {
+ return;
+ }
+
+ onEditFilter();
+}
+
+/**
+ * Handles the keypress event on the filter list dialog.
+ *
+ * @param {Event} event - The keypress DOMEvent.
+ */
+function onFilterActionButtonKeyPress(event) {
+ if (
+ event.key == "Enter" ||
+ (event.key == " " && event.target.hasAttribute("type"))
+ ) {
+ event.preventDefault();
+
+ if (
+ event.target.classList.contains("toolbarbutton-menubutton-dropmarker")
+ ) {
+ document
+ .getElementById("newFilterMenupopup")
+ .openPopup(event.target.parentNode, "after_end", {
+ triggerEvent: event,
+ });
+ return;
+ }
+ event.target.click();
+ }
+}
+
+function onFilterListKeyPress(aEvent) {
+ if (aEvent.keyCode) {
+ switch (aEvent.keyCode) {
+ case KeyEvent.DOM_VK_INSERT:
+ if (!document.getElementById("newButton").disabled) {
+ onNewFilter();
+ }
+ break;
+ case KeyEvent.DOM_VK_DELETE:
+ if (!document.getElementById("deleteButton").disabled) {
+ onDeleteFilter();
+ }
+ break;
+ case KeyEvent.DOM_VK_RETURN:
+ if (!document.getElementById("editButton").disabled) {
+ onEditFilter();
+ }
+ break;
+ }
+ } else if (!aEvent.ctrlKey && !aEvent.altKey && !aEvent.metaKey) {
+ switch (aEvent.charCode) {
+ case KeyEvent.DOM_VK_SPACE:
+ for (let item of gFilterListbox.selectedItems) {
+ toggleFilter(item);
+ }
+ break;
+ default:
+ gSearchBox.focus();
+ gSearchBox.value = String.fromCharCode(aEvent.charCode);
+ }
+ }
+}
+
+/**
+ * Decides if the given filter matches the given keyword.
+ *
+ * @param aFilter nsIMsgFilter to check
+ * @param aKeyword the string to find in the filter name
+ *
+ * @returns True if the filter name contains the searched keyword.
+ Otherwise false. In the future this may be extended to match
+ other filter attributes.
+ */
+function filterSearchMatch(aFilter, aKeyword) {
+ return aFilter.filterName.toLocaleLowerCase().includes(aKeyword);
+}
+
+/**
+ * Called from rebuildFilterList when the list needs to be redrawn.
+ *
+ * @returns Uses the search term in search box, to produce an array of
+ * row (filter) numbers (indexes) that match the search term.
+ */
+function onFindFilter() {
+ let keyWord = gSearchBox.value.toLocaleLowerCase();
+
+ // If searchbox is empty, just return and let rebuildFilterList
+ // create an unfiltered list.
+ if (!keyWord) {
+ return null;
+ }
+
+ // Rematch everything in the list, remove what doesn't match the search box.
+ let rows = gCurrentFilterList.filterCount;
+ let matchingFilterList = [];
+ // Use the full gCurrentFilterList, not the filterList listbox,
+ // which may already be filtered.
+ for (let i = 0; i < rows; i++) {
+ if (filterSearchMatch(gCurrentFilterList.getFilterAt(i), keyWord)) {
+ matchingFilterList.push(i);
+ }
+ }
+
+ return matchingFilterList;
+}
+
+/**
+ * Clear the search term in the search box if needed.
+ *
+ * @param aFilter If this nsIMsgFilter matches the search term,
+ * do not reset the box. If this is null,
+ * reset unconditionally.
+ */
+function resetSearchBox(aFilter) {
+ let keyword = gSearchBox.value.toLocaleLowerCase();
+ if (keyword && (!aFilter || !filterSearchMatch(aFilter, keyword))) {
+ gSearchBox.reset();
+ }
+}
+
+/**
+ * Display "1 item", "11 items" or "4 of 10" if list is filtered via search box.
+ */
+function updateCountBox() {
+ let countBox = document.getElementById("countBox");
+ let sum = gCurrentFilterList.filterCount;
+ let len = gFilterListbox.itemCount;
+
+ if (len == sum) {
+ // "N items"
+ countBox.value = PluralForm.get(
+ len,
+ gFilterBundle.getString("filterCountItems")
+ ).replace("#1", len);
+ return;
+ }
+
+ // "N of M"
+ countBox.value = gFilterBundle.getFormattedString(
+ "filterCountVisibleOfTotal",
+ [len, sum]
+ );
+}
diff --git a/comm/mail/base/content/FilterListDialog.xhtml b/comm/mail/base/content/FilterListDialog.xhtml
new file mode 100644
index 0000000000..397bdea0d8
--- /dev/null
+++ b/comm/mail/base/content/FilterListDialog.xhtml
@@ -0,0 +1,168 @@
+<?xml version="1.0"?>
+<!-- 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/. -->
+
+<?xml-stylesheet href="chrome://messenger/skin/messenger.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/icons.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/folderMenus.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/contextMenu.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/searchBox.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/filterDialog.css" type="text/css"?>
+
+<!DOCTYPE html [
+ <!ENTITY % filtersDTD SYSTEM "chrome://messenger/locale/FilterListDialog.dtd">%filtersDTD;
+]>
+<html id="filterListDialog" xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ windowtype="mailnews:filterlist"
+ lightweightthemes="true"
+ persist="width height screenX screenY"
+ scrolling="false"
+ style="min-width: 800px; min-height: 500px;">
+<head>
+ <title>&window.title;</title>
+ <script defer="defer" src="chrome://messenger/content/globalOverlay.js"></script>
+ <script defer="defer" src="chrome://global/content/editMenuOverlay.js"></script>
+ <script defer="defer" src="chrome://messenger/content/searchWidgets.js"></script>
+ <script defer="defer" src="chrome://messenger/content/FilterListDialog.js"></script>
+</head>
+<html:body xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+ <stringbundle id="bundle_filter" src="chrome://messenger/locale/filter.properties"/>
+ <stringbundle id="bundle_brand" src="chrome://branding/locale/brand.properties"/>
+
+ <keyset>
+ <key key="&closeCmd.key;" modifiers="accel" oncommand="if (onFilterClose()) { window.close(); }"/>
+ <key keycode="VK_ESCAPE" oncommand="if (onFilterClose()) { window.close(); }"/>
+ </keyset>
+
+ <hbox id="filterHeader" align="center">
+ <label value="&filtersForPrefix.label;"
+ accesskey="&filtersForPrefix.accesskey;" control="serverMenu"/>
+
+ <menulist id="serverMenu"
+ class="folderMenuItem" flex="1">
+ <menupopup is="folder-menupopup" id="serverMenuPopup"
+ mode="filters"
+ class="menulist-menupopup"
+ expandFolders="nntp"
+ showFileHereLabel="true"
+ showAccountsFileHere="true"
+ oncommand="setFilterFolder(event.target._folder);"/>
+ </menulist>
+ <search-textbox id="searchBox"
+ class="themeableSearchBox"
+ oncommand="rebuildFilterList();"
+ placeholder="&searchBox.emptyText;"
+ isempty="true"/>
+ </hbox>
+ <separator class="thin"/>
+ <hbox id="filterListGrid" flex="1">
+ <vbox id="filterListBox" flex="1">
+ <hbox>
+ <label id="filterListLabel" control="filterList" flex="1">
+ &filterHeader.label;
+ </label>
+ <label id="countBox"/>
+ </hbox>
+ <richlistbox id="filterList" flex="1" onselect="updateButtons();"
+ seltype="multiple"
+ onkeypress="onFilterListKeyPress(event);">
+ <treecols>
+ <treecol id="nameColumn" label="&nameColumn.label;" flex="1"/>
+ <treecol id="activeColumn" label="&activeColumn.label;" style="width: 100px;"/>
+ </treecols>
+ </richlistbox>
+ <vbox>
+ <separator class="thin"/>
+ <hbox align="center">
+ <label id="folderPickerPrefix" value="&folderPickerPrefix.label;"
+ accesskey="&folderPickerPrefix.accesskey;"
+ disabled="true" control="runFiltersFolder"/>
+ <menulist id="runFiltersFolder" disabled="true" flex="1"
+ class="folderMenuItem"
+ displayformat="verbose">
+ <menupopup is="folder-menupopup" id="runFiltersPopup"
+ class="menulist-menupopup"
+ showFileHereLabel="true"
+ showAccountsFileHere="false"
+ oncommand="setRunFolder(event.target._folder);"/>
+ </menulist>
+ <button id="runFiltersButton"
+ label="&runFilters.label;"
+ accesskey="&runFilters.accesskey;"
+ runlabel="&runFilters.label;"
+ runaccesskey="&runFilters.accesskey;"
+ stoplabel="&stopFilters.label;"
+ stopaccesskey="&stopFilters.accesskey;"
+ oncommand="runSelectedFilters();" disabled="true"/>
+ </hbox>
+ </vbox>
+ </vbox>
+ <vbox id="filterActionButtons">
+ <label value=""/>
+ <toolbarbutton is="toolbarbutton-menu-button" id="newButton"
+ type="menu"
+ label="&newButton.label;"
+ accesskey="&newButton.accesskey;"
+ oncommand="onNewFilter();">
+ <menupopup id="newFilterMenupopup">
+ <menuitem label="&newButton.label;"
+ accesskey="&newButton.accesskey;"/>
+ <menuitem id="copyToNewButton"
+ label="&newButton.popupCopy.label;"
+ accesskey="&newButton.popupCopy.accesskey;"
+ oncommand="onCopyToNewFilter(); event.stopPropagation();"/>
+ </menupopup>
+ </toolbarbutton>
+ <button id="editButton" label="&editButton.label;"
+ accesskey="&editButton.accesskey;"
+ oncommand="onEditFilter();"/>
+ <button id="deleteButton"
+ label="&deleteButton.label;"
+ accesskey="&deleteButton.accesskey;"
+ oncommand="onDeleteFilter();"/>
+ <separator class="thin"/>
+ <button id="reorderTopButton"
+ label="&reorderTopButton;"
+ accesskey="&reorderTopButton.accessKey;"
+ tooltiptext="&reorderTopButton.toolTip;"
+ oncommand="onTop(event);"/>
+ <button id="reorderUpButton"
+ label="&reorderUpButton.label;"
+ accesskey="&reorderUpButton.accesskey;"
+ class="up"
+ oncommand="onUp(event);"/>
+ <button id="reorderDownButton"
+ label="&reorderDownButton.label;"
+ accesskey="&reorderDownButton.accesskey;"
+ class="down"
+ oncommand="onDown(event);"/>
+ <button id="reorderBottomButton"
+ label="&reorderBottomButton;"
+ accesskey="&reorderBottomButton.accessKey;"
+ tooltiptext="&reorderBottomButton.toolTip;"
+ oncommand="onBottom(event);"/>
+ <vbox flex="1" pack="end">
+ <button id="filterLogButton"
+ label="&viewLogButton.label;"
+ accesskey="&viewLogButton.accesskey;"
+ oncommand="viewLog();"/>
+ </vbox>
+ </vbox>
+ </hbox>
+
+ <separator class="thin"/>
+
+ <hbox id="statusbar" role="status">
+ <label id="statusText" flex="1" crop="end"/>
+ <hbox id="statusbar-progresspanel" class="statusbarpanel-progress" collapsed="true">
+ <html:progress class="progressmeter-statusbar" id="statusbar-icon" value="0" max="100"/>
+ </hbox>
+ </hbox>
+
+</html:body>
+</html>
diff --git a/comm/mail/base/content/SearchDialog.js b/comm/mail/base/content/SearchDialog.js
new file mode 100644
index 0000000000..127370d7f2
--- /dev/null
+++ b/comm/mail/base/content/SearchDialog.js
@@ -0,0 +1,650 @@
+/* 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 ../../../mailnews/extensions/newsblog/newsblogOverlay.js */
+/* import-globals-from ../../../mailnews/search/content/searchTerm.js */
+/* import-globals-from folderDisplay.js */
+/* import-globals-from globalOverlay.js */
+/* import-globals-from threadPane.js */
+
+/* globals nsMsgStatusFeedback */ // From mailWindow.js
+
+"use strict";
+
+var { MailUtils } = ChromeUtils.import("resource:///modules/MailUtils.jsm");
+var { PluralForm } = ChromeUtils.importESModule(
+ "resource://gre/modules/PluralForm.sys.mjs"
+);
+var { TagUtils } = ChromeUtils.import("resource:///modules/TagUtils.jsm");
+
+var messenger;
+var msgWindow;
+
+var gCurrentFolder;
+
+var gFolderDisplay;
+
+var gFolderPicker;
+var gStatusFeedback;
+var gSearchBundle;
+
+// Datasource search listener -- made global as it has to be registered
+// and unregistered in different functions.
+var gDataSourceSearchListener;
+var gViewSearchListener;
+
+var gSearchStopButton;
+
+// Should we try to search online?
+var gSearchOnline = false;
+
+window.addEventListener("load", searchOnLoad);
+window.addEventListener("unload", event => {
+ onSearchStop();
+ searchOnUnload();
+});
+
+// Controller object for search results thread pane
+var nsSearchResultsController = {
+ supportsCommand(command) {
+ switch (command) {
+ case "cmd_delete":
+ case "cmd_shiftDelete":
+ case "button_delete":
+ case "cmd_open":
+ case "file_message_button":
+ case "open_in_folder_button":
+ case "saveas_vf_button":
+ case "cmd_selectAll":
+ return true;
+ default:
+ return false;
+ }
+ },
+
+ // this controller only handles commands
+ // that rely on items being selected in
+ // the search results pane.
+ isCommandEnabled(command) {
+ var enabled = true;
+
+ switch (command) {
+ case "open_in_folder_button":
+ if (gFolderDisplay.selectedCount != 1) {
+ enabled = false;
+ }
+ break;
+ case "cmd_delete":
+ case "cmd_shiftDelete":
+ case "button_delete":
+ // this assumes that advanced searches don't cross accounts
+ if (gFolderDisplay.selectedCount <= 0) {
+ enabled = false;
+ }
+ break;
+ case "saveas_vf_button":
+ // need someway to see if there are any search criteria...
+ return true;
+ case "cmd_selectAll":
+ return true;
+ default:
+ if (gFolderDisplay.selectedCount <= 0) {
+ enabled = false;
+ }
+ break;
+ }
+
+ return enabled;
+ },
+
+ doCommand(command) {
+ switch (command) {
+ case "cmd_open":
+ MsgOpenSelectedMessages();
+ return true;
+
+ case "cmd_delete":
+ case "button_delete":
+ MsgDeleteSelectedMessages(Ci.nsMsgViewCommandType.deleteMsg);
+ return true;
+
+ case "cmd_shiftDelete":
+ MsgDeleteSelectedMessages(Ci.nsMsgViewCommandType.deleteNoTrash);
+ return true;
+
+ case "open_in_folder_button":
+ OpenInFolder();
+ return true;
+
+ case "saveas_vf_button":
+ saveAsVirtualFolder();
+ return true;
+
+ case "cmd_selectAll":
+ // move the focus to the search results pane
+ GetThreadTree().focus();
+ gFolderDisplay.doCommand(Ci.nsMsgViewCommandType.selectAll);
+ return true;
+
+ default:
+ return false;
+ }
+ },
+
+ onEvent(event) {},
+};
+
+function UpdateMailSearch(caller) {
+ document.commandDispatcher.updateCommands("mail-search");
+}
+
+function SetAdvancedSearchStatusText(aNumHits) {}
+
+/**
+ * Subclass the FolderDisplayWidget to deal with UI specific to the search
+ * window.
+ */
+function SearchFolderDisplayWidget() {
+ FolderDisplayWidget.call(this);
+}
+
+SearchFolderDisplayWidget.prototype = {
+ __proto__: FolderDisplayWidget.prototype,
+
+ // folder display will want to show the thread pane; we need do nothing
+ _showThreadPane() {},
+
+ onSearching(aIsSearching) {
+ if (aIsSearching) {
+ // Search button becomes the "stop" button
+ gSearchStopButton.setAttribute(
+ "label",
+ gSearchBundle.GetStringFromName("labelForStopButton")
+ );
+ gSearchStopButton.setAttribute(
+ "accesskey",
+ gSearchBundle.GetStringFromName("labelForStopButton.accesskey")
+ );
+
+ // update our toolbar equivalent
+ UpdateMailSearch("new-search");
+ // spin the meteors
+ gStatusFeedback._startMeteors();
+ // tell the user that we're searching
+ gStatusFeedback.showStatusString(
+ gSearchBundle.GetStringFromName("searchingMessage")
+ );
+ } else {
+ // Stop button resumes being the "search" button
+ gSearchStopButton.setAttribute(
+ "label",
+ gSearchBundle.GetStringFromName("labelForSearchButton")
+ );
+ gSearchStopButton.setAttribute(
+ "accesskey",
+ gSearchBundle.GetStringFromName("labelForSearchButton.accesskey")
+ );
+
+ // update our toolbar equivalent
+ UpdateMailSearch("done-search");
+ // stop spining the meteors
+ gStatusFeedback._stopMeteors();
+ // set the result test
+ this.updateStatusResultText();
+ }
+ },
+
+ /**
+ * If messages were removed, we might have lost some search results and so
+ * should update our search result text. Also, defer to our super-class.
+ */
+ onMessagesRemoved() {
+ // result text is only for when we are not searching
+ if (!this.view.searching) {
+ this.updateStatusResultText();
+ }
+ this.__proto__.__proto__.onMessagesRemoved.call(this);
+ },
+
+ updateStatusResultText() {
+ let rowCount = this.view.dbView.rowCount;
+ let statusMsg;
+
+ if (rowCount == 0) {
+ statusMsg = gSearchBundle.GetStringFromName("noMatchesFound");
+ } else {
+ statusMsg = PluralForm.get(
+ rowCount,
+ gSearchBundle.GetStringFromName("matchesFound")
+ );
+ statusMsg = statusMsg.replace("#1", rowCount);
+ }
+
+ gStatusFeedback.showStatusString(statusMsg);
+ },
+};
+
+function searchOnLoad() {
+ TagUtils.loadTagsIntoCSS(document);
+ initializeSearchWidgets();
+ initializeSearchWindowWidgets();
+ // eslint-disable-next-line no-global-assign
+ messenger = Cc["@mozilla.org/messenger;1"].createInstance(Ci.nsIMessenger);
+
+ gSearchBundle = Services.strings.createBundle(
+ "chrome://messenger/locale/search.properties"
+ );
+ gSearchStopButton.setAttribute(
+ "label",
+ gSearchBundle.GetStringFromName("labelForSearchButton")
+ );
+ gSearchStopButton.setAttribute(
+ "accesskey",
+ gSearchBundle.GetStringFromName("labelForSearchButton.accesskey")
+ );
+
+ // eslint-disable-next-line no-global-assign
+ gFolderDisplay = new SearchFolderDisplayWidget();
+ gFolderDisplay.messenger = messenger;
+ gFolderDisplay.msgWindow = msgWindow;
+ gFolderDisplay.tree = document.getElementById("threadTree");
+
+ // The view is initially unsorted; get the persisted sortDirection column
+ // and set up the user's desired sort. This synthetic view is not backed by
+ // a db, so secondary sorts and custom columns are not supported here.
+ let sortCol = gFolderDisplay.tree.querySelector("[sortDirection]");
+ let sortType, sortOrder;
+ if (sortCol) {
+ sortType = Ci.nsMsgViewSortType[gFolderDisplay.COLUMNS_MAP.get(sortCol.id)];
+ sortOrder =
+ sortCol.getAttribute("sortDirection") == "descending"
+ ? Ci.nsMsgViewSortOrder.descending
+ : Ci.nsMsgViewSortOrder.ascending;
+ }
+
+ gFolderDisplay.view.openSearchView();
+ gFolderDisplay.makeActive();
+
+ if (sortType) {
+ gFolderDisplay.view.sort(sortType, sortOrder);
+ }
+
+ if (window.arguments && window.arguments[0]) {
+ updateSearchFolderPicker(window.arguments[0].folder);
+ }
+
+ // Trigger searchTerm.js to create the first criterion.
+ onMore(null);
+ // Make sure all the buttons are configured.
+ UpdateMailSearch("onload");
+}
+
+function searchOnUnload() {
+ gFolderDisplay.close();
+ top.controllers.removeController(nsSearchResultsController);
+
+ msgWindow.closeWindow();
+}
+
+function initializeSearchWindowWidgets() {
+ gFolderPicker = document.getElementById("searchableFolders");
+ gSearchStopButton = document.getElementById("search-button");
+ hideMatchAllItem();
+
+ // eslint-disable-next-line no-global-assign
+ msgWindow = Cc["@mozilla.org/messenger/msgwindow;1"].createInstance(
+ Ci.nsIMsgWindow
+ );
+ msgWindow.domWindow = window;
+ msgWindow.rootDocShell.appType = Ci.nsIDocShell.APP_TYPE_MAIL;
+
+ gStatusFeedback = new nsMsgStatusFeedback();
+ msgWindow.statusFeedback = gStatusFeedback;
+
+ // functionality to enable/disable buttons using nsSearchResultsController
+ // depending of whether items are selected in the search results thread pane.
+ top.controllers.insertControllerAt(0, nsSearchResultsController);
+}
+
+function onSearchStop() {
+ gFolderDisplay.view.search.session.interruptSearch();
+}
+
+function onResetSearch(event) {
+ onReset(event);
+ gFolderDisplay.view.search.clear();
+
+ gStatusFeedback.showStatusString("");
+}
+
+function updateSearchFolderPicker(folder) {
+ gCurrentFolder = folder;
+ gFolderPicker.menupopup.selectFolder(folder);
+
+ var searchOnline = document.getElementById("checkSearchOnline");
+ // We will hide and disable the search online checkbox if we are offline, or
+ // if the folder does not support online search.
+
+ // Any offlineSupportLevel > 0 is an online server like IMAP or news.
+ if (gCurrentFolder?.server.offlineSupportLevel && !Services.io.offline) {
+ searchOnline.hidden = false;
+ searchOnline.disabled = false;
+ } else {
+ searchOnline.hidden = true;
+ searchOnline.disabled = true;
+ }
+ if (gCurrentFolder) {
+ setSearchScope(GetScopeForFolder(gCurrentFolder));
+ }
+}
+
+function updateSearchLocalSystem() {
+ setSearchScope(GetScopeForFolder(gCurrentFolder));
+}
+
+function UpdateAfterCustomHeaderChange() {
+ updateSearchAttributes();
+}
+
+function onEnterInSearchTerm() {
+ // on enter
+ // if not searching, start the search
+ // if searching, stop and then start again
+ if (
+ gSearchStopButton.getAttribute("label") ==
+ gSearchBundle.GetStringFromName("labelForSearchButton")
+ ) {
+ onSearch();
+ } else {
+ onSearchStop();
+ onSearch();
+ }
+}
+
+function onSearch() {
+ let viewWrapper = gFolderDisplay.view;
+ let searchTerms = getSearchTerms();
+
+ viewWrapper.beginViewUpdate();
+ viewWrapper.search.userTerms = searchTerms.length ? searchTerms : null;
+ viewWrapper.search.onlineSearch = gSearchOnline;
+ viewWrapper.searchFolders = getSearchFolders();
+ viewWrapper.endViewUpdate();
+}
+
+/**
+ * Get the current set of search terms, returning them as a list. We filter out
+ * dangerous and insane predicates.
+ */
+function getSearchTerms() {
+ let termCreator = gFolderDisplay.view.search.session;
+
+ let searchTerms = [];
+ // searchTerm.js stores wrapper objects in its gSearchTerms array. Pluck
+ // them.
+ for (let iTerm = 0; iTerm < gSearchTerms.length; iTerm++) {
+ let termWrapper = gSearchTerms[iTerm].obj;
+ let realTerm = termCreator.createTerm();
+ termWrapper.saveTo(realTerm);
+ // A header search of "" is illegal for IMAP and will cause us to
+ // explode. You don't want that and I don't want that. So let's check
+ // if the bloody term is a subject search on a blank string, and if it
+ // is, let's secretly not add the term. Everyone wins!
+ if (
+ realTerm.attrib != Ci.nsMsgSearchAttrib.Subject ||
+ realTerm.value.str != ""
+ ) {
+ searchTerms.push(realTerm);
+ }
+ }
+
+ return searchTerms;
+}
+
+/**
+ * @returns the list of folders the search should cover.
+ */
+function getSearchFolders() {
+ let searchFolders = [];
+
+ if (!gCurrentFolder.isServer && !gCurrentFolder.noSelect) {
+ searchFolders.push(gCurrentFolder);
+ }
+
+ var searchSubfolders = document.getElementById(
+ "checkSearchSubFolders"
+ ).checked;
+ if (
+ gCurrentFolder &&
+ (searchSubfolders || gCurrentFolder.isServer || gCurrentFolder.noSelect)
+ ) {
+ AddSubFolders(gCurrentFolder, searchFolders);
+ }
+
+ return searchFolders;
+}
+
+function AddSubFolders(folder, outFolders) {
+ for (let nextFolder of folder.subFolders) {
+ if (!(nextFolder.flags & Ci.nsMsgFolderFlags.Virtual)) {
+ if (!nextFolder.noSelect) {
+ outFolders.push(nextFolder);
+ }
+
+ AddSubFolders(nextFolder, outFolders);
+ }
+ }
+}
+
+function AddSubFoldersToURI(folder) {
+ var returnString = "";
+
+ for (let nextFolder of folder.subFolders) {
+ if (!(nextFolder.flags & Ci.nsMsgFolderFlags.Virtual)) {
+ if (!nextFolder.noSelect && !nextFolder.isServer) {
+ if (returnString.length > 0) {
+ returnString += "|";
+ }
+ returnString += nextFolder.URI;
+ }
+ var subFoldersString = AddSubFoldersToURI(nextFolder);
+ if (subFoldersString.length > 0) {
+ if (returnString.length > 0) {
+ returnString += "|";
+ }
+ returnString += subFoldersString;
+ }
+ }
+ }
+ return returnString;
+}
+
+/**
+ * Determine the proper search scope to use for a folder, so that the user is
+ * presented with a correct list of search capabilities. The user may manually
+ * request on online search for certain server types. To determine if the
+ * folder body may be searched, we ignore whether autosync is enabled,
+ * figuring that after the user manually syncs, they would still expect that
+ * body searches would work.
+ *
+ * The available search capabilities also depend on whether the user is
+ * currently online or offline. Although that is also checked by the server,
+ * we do it ourselves because we have a more complex response to offline
+ * than the server's searchScope attribute provides.
+ *
+ * This method only works for real folders.
+ */
+function GetScopeForFolder(folder) {
+ let searchOnline = document.getElementById("checkSearchOnline");
+ if (!searchOnline.disabled && searchOnline.checked) {
+ gSearchOnline = true;
+ return folder.server.searchScope;
+ }
+ gSearchOnline = false;
+
+ // We are going to search offline. The proper search scope may depend on
+ // whether we have the body and/or junk available or not.
+ let localType;
+ try {
+ localType = folder.server.localStoreType;
+ } catch (e) {} // On error, we'll just assume the default mailbox type
+
+ let hasBody = folder.getFlag(Ci.nsMsgFolderFlags.Offline);
+ let nsMsgSearchScope = Ci.nsMsgSearchScope;
+ switch (localType) {
+ case "news":
+ // News has four offline scopes, depending on whether junk and body
+ // are available.
+ let hasJunk =
+ folder.getInheritedStringProperty(
+ "dobayes.mailnews@mozilla.org#junk"
+ ) == "true";
+ if (hasJunk && hasBody) {
+ return nsMsgSearchScope.localNewsJunkBody;
+ }
+ if (hasJunk) {
+ // and no body
+ return nsMsgSearchScope.localNewsJunk;
+ }
+ if (hasBody) {
+ // and no junk
+ return nsMsgSearchScope.localNewsBody;
+ }
+ // We don't have offline message bodies or junk processing.
+ return nsMsgSearchScope.localNews;
+
+ case "imap":
+ // Junk is always enabled for imap, so the offline scope only depends on
+ // whether the body is available.
+
+ // If we are the root folder, use the server property for body rather
+ // than the folder property.
+ if (folder.isServer) {
+ let imapServer = folder.server.QueryInterface(Ci.nsIImapIncomingServer);
+ if (imapServer && imapServer.offlineDownload) {
+ hasBody = true;
+ }
+ }
+
+ if (!hasBody) {
+ return nsMsgSearchScope.onlineManual;
+ }
+ // fall through to default
+ default:
+ return nsMsgSearchScope.offlineMail;
+ }
+}
+
+function goUpdateSearchItems(commandset) {
+ for (var i = 0; i < commandset.children.length; i++) {
+ var commandID = commandset.children[i].getAttribute("id");
+ if (commandID) {
+ goUpdateCommand(commandID);
+ }
+ }
+}
+
+// used to toggle functionality for Search/Stop button.
+function onSearchButton(event) {
+ if (
+ event.target.label ==
+ gSearchBundle.GetStringFromName("labelForSearchButton")
+ ) {
+ onSearch();
+ } else {
+ onSearchStop();
+ }
+}
+
+function MsgDeleteSelectedMessages(aCommandType) {
+ gFolderDisplay.hintAboutToDeleteMessages();
+ gFolderDisplay.doCommand(aCommandType);
+}
+
+/**
+ * Move selected messages to the destination folder
+ *
+ * @param destFolder {nsIMsgFolder} - destination folder
+ */
+function MoveMessageInSearch(destFolder) {
+ gFolderDisplay.hintAboutToDeleteMessages();
+ gFolderDisplay.doCommandWithFolder(
+ Ci.nsMsgViewCommandType.moveMessages,
+ destFolder
+ );
+}
+
+function OpenInFolder() {
+ MailUtils.displayMessageInFolderTab(gFolderDisplay.selectedMessage);
+}
+
+function saveAsVirtualFolder() {
+ var searchFolderURIs = gCurrentFolder.URI;
+
+ var searchSubfolders = document.getElementById(
+ "checkSearchSubFolders"
+ ).checked;
+ if (
+ gCurrentFolder &&
+ (searchSubfolders || gCurrentFolder.isServer || gCurrentFolder.noSelect)
+ ) {
+ var subFolderURIs = AddSubFoldersToURI(gCurrentFolder);
+ if (subFolderURIs.length > 0) {
+ searchFolderURIs += "|" + subFolderURIs;
+ }
+ }
+
+ var searchOnline = document.getElementById("checkSearchOnline");
+ var doOnlineSearch = searchOnline.checked && !searchOnline.disabled;
+
+ window.openDialog(
+ "chrome://messenger/content/virtualFolderProperties.xhtml",
+ "",
+ "chrome,titlebar,modal,centerscreen,resizable=yes",
+ {
+ folder: window.arguments[0].folder,
+ searchTerms: getSearchTerms(),
+ searchFolderURIs,
+ searchOnline: doOnlineSearch,
+ }
+ );
+}
+
+function MsgOpenSelectedMessages() {
+ // Toggle message body (feed summary) and content-base url in message pane or
+ // load in browser, per pref, otherwise open summary or web page in new window
+ // or tab, per that pref.
+ if (
+ gFolderDisplay.treeSelection &&
+ gFolderDisplay.treeSelection.count == 1 &&
+ gFolderDisplay.selectedMessageIsFeed
+ ) {
+ let msgHdr = gFolderDisplay.selectedMessage;
+ if (
+ document.documentElement.getAttribute("windowtype") == "mail:3pane" &&
+ FeedMessageHandler.onOpenPref ==
+ FeedMessageHandler.kOpenToggleInMessagePane
+ ) {
+ let showSummary = FeedMessageHandler.shouldShowSummary(msgHdr, true);
+ FeedMessageHandler.setContent(msgHdr, showSummary);
+ return;
+ }
+ if (
+ FeedMessageHandler.onOpenPref == FeedMessageHandler.kOpenLoadInBrowser
+ ) {
+ setTimeout(FeedMessageHandler.loadWebPage, 20, msgHdr, { browser: true });
+ return;
+ }
+ }
+
+ // This is somewhat evil. If we're in a 3pane window, we'd have a tabmail
+ // element and would pass it in here, ensuring that if we open tabs, we use
+ // this tabmail to open them. If we aren't, then we wouldn't, so
+ // displayMessages would look for a 3pane window and open tabs there.
+ MailUtils.displayMessages(
+ gFolderDisplay.selectedMessages,
+ gFolderDisplay.view,
+ document.getElementById("tabmail")
+ );
+}
diff --git a/comm/mail/base/content/SearchDialog.xhtml b/comm/mail/base/content/SearchDialog.xhtml
new file mode 100644
index 0000000000..6d6ff82713
--- /dev/null
+++ b/comm/mail/base/content/SearchDialog.xhtml
@@ -0,0 +1,151 @@
+<?xml version="1.0"?>
+# 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/.
+
+#filter substitution
+#define SEARCH_WINDOW
+<?xml-stylesheet href="chrome://messenger/skin/searchDialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/folderPane.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/tagColors.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/folderMenus.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/contextMenu.css" type="text/css"?>
+
+<!DOCTYPE html [
+ <!ENTITY % messengerDTD SYSTEM "chrome://messenger/locale/messenger.dtd" >
+ %messengerDTD;
+ <!ENTITY % SearchDialogDTD SYSTEM "chrome://messenger/locale/SearchDialog.dtd">
+ %SearchDialogDTD;
+ <!ENTITY % searchTermDTD SYSTEM "chrome://messenger/locale/searchTermOverlay.dtd">
+ %searchTermDTD;
+]>
+<html id="searchMailWindow" xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ windowtype="mailnews:search"
+ scrolling="false"
+ style="min-width:52em; min-height:34em;"
+ lightweightthemes="true"
+ persist="screenX screenY width height sizemode">
+<head>
+ <title>&searchDialogTitle.label;</title>
+ <script defer="defer" src="chrome://messenger/content/globalOverlay.js"></script>
+ <script defer="defer" src="chrome://global/content/editMenuOverlay.js"></script>
+ <script defer="defer" src="chrome://messenger/content/searchWidgets.js"></script>
+ <script defer="defer" src="chrome://messenger/content/mailWindow.js"></script>
+ <script defer="defer" src="chrome://messenger/content/folderDisplay.js"></script>
+ <script defer="defer" src="chrome://messenger/content/threadPane.js"></script>
+ <script defer="defer" src="chrome://communicator/content/utilityOverlay.js"></script>
+ <script defer="defer" src="chrome://messenger-newsblog/content/newsblogOverlay.js"></script>
+ <script defer="defer" src="chrome://messenger/content/searchTerm.js"></script>
+ <script defer="defer" src="chrome://messenger/content/dateFormat.js"></script>
+ <script defer="defer" src="chrome://messenger/content/messenger.js"></script>
+ <script defer="defer" src="chrome://messenger/content/SearchDialog.js"></script>
+</head>
+<html:body xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+ <stringbundle id="bundle_messenger" src="chrome://messenger/locale/messenger.properties"/>
+ <stringbundle id="bundle_brand" src="chrome://branding/locale/brand.properties"/>
+
+ <commands id="commands">
+ <commandset id="mailSearchItems"
+ commandupdater="true"
+ events="mail-search"
+ oncommandupdate="goUpdateSearchItems(this)">
+ <command id="cmd_open" oncommand="goDoCommand('cmd_open')" disabled="true"/>
+ <command id="button_delete" oncommand="goDoCommand('button_delete')" disabled="true"/>
+ <command id="open_in_folder_button" oncommand="goDoCommand('open_in_folder_button')" disabled="true"/>
+ <command id="saveas_vf_button" oncommand="goDoCommand('saveas_vf_button')" disabled="false"/>
+ <command id="file_message_button" oncommand="MoveMessageInSearch(event.target._folder);" disabled="true"/>
+ <command id="cmd_delete" oncommand="goDoCommand('cmd_delete')" disabled="true"/>
+ <command id="cmd_shiftDelete" oncommand="goDoCommand('cmd_shiftDelete');"/>
+ </commandset>
+ </commands>
+
+ <keyset id="mailKeys">
+ <key key="&closeCmd.key;" modifiers="accel" oncommand="window.close();"/>
+ <key keycode="VK_ESCAPE" oncommand="window.close();"/>
+#ifdef XP_MACOSX
+ <key id="key_delete" keycode="VK_BACK" command="cmd_delete"/>
+ <key id="key_delete2" keycode="VK_DELETE" command="cmd_delete"/>
+ <key id="cmd_shiftDelete" keycode="VK_BACK"
+ oncommand="goDoCommand('cmd_shiftDelete');" modifiers="shift"/>
+ <key id="cmd_shiftDelete2" keycode="VK_DELETE"
+ oncommand="goDoCommand('cmd_shiftDelete');" modifiers="shift"/>
+#else
+ <key id="key_delete" keycode="VK_DELETE" command="cmd_delete"/>
+ <key id="cmd_shiftDelete" keycode="VK_DELETE"
+ oncommand="goDoCommand('cmd_shiftDelete');" modifiers="shift"/>
+#endif
+ </keyset>
+
+ <vbox id="searchTerms" class="themeable-brighttext" persist="height">
+ <vbox>
+ <hbox align="center">
+ <label value="&searchHeading.label;" accesskey="&searchHeading.accesskey;"
+ control="searchableFolders"/>
+ <menulist id="searchableFolders" class="folderMenuItem"
+ displayformat="verbose">
+ <menupopup is="folder-menupopup" class="menulist-menupopup"
+ mode="search" showAccountsFileHere="true" showFileHereLabel="true"
+ oncommand="updateSearchFolderPicker(event.target._folder);"/>
+ </menulist>
+ <spacer style="flex: 10 10;"/>
+ <button id="search-button" oncommand="onSearchButton(event);" default="true"/>
+ </hbox>
+
+ <hbox align="center">
+ <checkbox id="checkSearchSubFolders"
+ label="&searchSubfolders.label;"
+ accesskey="&searchSubfolders.accesskey;"
+ checked="true"
+ persist="checked"/>
+ <spacer style="flex: 10 10;"/>
+ <button label="&resetButton.label;" oncommand="onResetSearch(event);" accesskey="&resetButton.accesskey;"/>
+ </hbox>
+ <hbox align="center">
+ <checkbox id="checkSearchOnline"
+ label="&searchOnServer.label;"
+ accesskey="&searchOnServer.accesskey;"
+ oncommand="updateSearchLocalSystem();"
+ persist="checked"/>
+ </hbox>
+ </vbox>
+
+ <hbox style="flex: 1 1 0; min-height: 0;">
+ <vbox id="searchTermListBox" flex="1">
+#include ../../../mailnews/search/content/searchTerm.inc.xhtml
+ </hbox>
+ </vbox>
+
+ <splitter id="gray_horizontal_splitter" persist="state" orient="vertical"/>
+
+ <vbox id="searchResults" persist="height">
+ <vbox id="searchResultListBox" flex="1">
+#include threadTree.inc.xhtml
+ </vbox>
+ <hbox align="start">
+ <button label="&openButton.label;" id="openButton" command="cmd_open" accesskey="&openButton.accesskey;"/>
+ <button id="fileMessageButton" type="menu" label="&moveButton.label;"
+ accesskey="&moveButton.accesskey;"
+ command="file_message_button">
+ <menupopup is="folder-menupopup" showFileHereLabel="true" mode="filing"/>
+ </button>
+
+ <button label="&deleteButton.label;" id="deleteButton" command="button_delete" accesskey="&deleteButton.accesskey;"/>
+ <button label="&openInFolder.label;" id="openInFolderButton" command="open_in_folder_button" accesskey="&openInFolder.accesskey;" />
+ <button label="&saveAsVFButton.label;" id="saveAsVFButton" command="saveas_vf_button" accesskey="&saveAsVFButton.accesskey;" />
+ <spacer flex="1" />
+ </hbox>
+ </vbox>
+
+ <hbox id="status-bar" class="statusbar chromeclass-status" role="status">
+ <label id="statusText" class="statusbarpanel" crop="end" flex="1"/>
+ <hbox id="statusbar-progresspanel" class="statusbarpanel statusbarpanel-progress" collapsed="true">
+ <html:progress class="progressmeter-statusbar" id="statusbar-icon" value="0" max="100"/>
+ </hbox>
+ </hbox>
+</html:body>
+</html>
diff --git a/comm/mail/base/content/about3Pane.js b/comm/mail/base/content/about3Pane.js
new file mode 100644
index 0000000000..43e09a0acc
--- /dev/null
+++ b/comm/mail/base/content/about3Pane.js
@@ -0,0 +1,7260 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals MozElements */
+
+// mailCommon.js
+/* globals commandController, DBViewWrapper, dbViewWrapperListener,
+ nsMsgViewIndex_None, VirtualFolderHelper */
+/* globals gDBView: true, gFolder: true, gViewWrapper: true */
+
+// mailContext.js
+/* globals mailContextMenu */
+
+// globalOverlay.js
+/* globals goDoCommand, goUpdateCommand */
+
+// mail-offline.js
+/* globals MailOfflineMgr */
+
+// junkCommands.js
+/* globals analyzeMessagesForJunk deleteJunkInFolder filterFolderForJunk */
+
+// quickFilterBar.js
+/* globals quickFilterBar */
+
+// utilityOverlay.js
+/* globals validateFileName */
+
+var { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+var { FolderTreeProperties } = ChromeUtils.import(
+ "resource:///modules/FolderTreeProperties.jsm"
+);
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+var { UIDensity } = ChromeUtils.import("resource:///modules/UIDensity.jsm");
+var { UIFontSize } = ChromeUtils.import("resource:///modules/UIFontSize.jsm");
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ FeedUtils: "resource:///modules/FeedUtils.jsm",
+ FolderUtils: "resource:///modules/FolderUtils.jsm",
+ MailUtils: "resource:///modules/MailUtils.jsm",
+ MailE10SUtils: "resource:///modules/MailE10SUtils.jsm",
+ MailStringUtils: "resource:///modules/MailStringUtils.jsm",
+ TagUtils: "resource:///modules/TagUtils.jsm",
+});
+
+const XULSTORE_URL = "chrome://messenger/content/messenger.xhtml";
+
+const messengerBundle = Services.strings.createBundle(
+ "chrome://messenger/locale/messenger.properties"
+);
+
+const { getDefaultColumns, getDefaultColumnsForCardsView, isOutgoing } =
+ ChromeUtils.importESModule(
+ "chrome://messenger/content/thread-pane-columns.mjs"
+ );
+
+// As defined in nsMsgDBView.h.
+const MSG_VIEW_FLAG_DUMMY = 0x20000000;
+
+/**
+ * The TreeListbox widget that displays folders.
+ */
+var folderTree;
+/**
+ * The TreeView widget that displays the message list.
+ */
+var threadTree;
+/**
+ * A XUL browser that displays web pages when required.
+ */
+var webBrowser;
+/**
+ * A XUL browser that displays single messages. This browser always has
+ * about:message loaded.
+ */
+var messageBrowser;
+/**
+ * A XUL browser that displays summaries of multiple messages or threads.
+ * This browser always has multimessageview.xhtml loaded.
+ */
+var multiMessageBrowser;
+/**
+ * A XUL browser that displays Account Central when an account's root folder
+ * is selected.
+ */
+var accountCentralBrowser;
+
+window.addEventListener("DOMContentLoaded", async event => {
+ if (event.target != document) {
+ return;
+ }
+
+ UIDensity.registerWindow(window);
+ UIFontSize.registerWindow(window);
+
+ folderTree = document.getElementById("folderTree");
+ accountCentralBrowser = document.getElementById("accountCentralBrowser");
+
+ paneLayout.init();
+ folderPaneContextMenu.init();
+ await folderPane.init();
+ await threadPane.init();
+ threadPaneHeader.init();
+ await messagePane.init();
+
+ // Set up the initial state using information which may have been provided
+ // by mailTabs.js, or the saved state from the XUL store, or the defaults.
+ try {
+ // Do this in a try so that errors (e.g. bad data) don't prevent doing the
+ // rest of the important 3pane initialization below.
+ restoreState(window.openingState);
+ } catch (e) {
+ console.warn(`Couldn't restore state: ${e.message}`, e);
+ }
+ delete window.openingState;
+
+ // Finally, add the folderTree listener and trigger it. Earlier events
+ // (triggered by `folderPane.init` and possibly `restoreState`) are ignored
+ // to avoid unnecessarily loading the thread tree or Account Central.
+ folderTree.addEventListener("select", folderPane);
+ folderTree.dispatchEvent(new CustomEvent("select"));
+
+ // Attach the progress listener for the webBrowser. For the messageBrowser this
+ // happens in the "aboutMessageLoaded" event from aboutMessage.js.
+ // For the webBrowser, we can do it here directly.
+ top.contentProgress.addProgressListenerToBrowser(webBrowser);
+
+ mailContextMenu.init();
+});
+
+window.addEventListener("unload", () => {
+ MailServices.mailSession.RemoveFolderListener(folderListener);
+ gViewWrapper?.close();
+ folderPane.uninit();
+ threadPane.uninit();
+ threadPaneHeader.uninit();
+});
+
+var paneLayout = {
+ init() {
+ this.folderPaneSplitter = document.getElementById("folderPaneSplitter");
+ this.messagePaneSplitter = document.getElementById("messagePaneSplitter");
+
+ for (let [splitter, properties, storeID] of [
+ [this.folderPaneSplitter, ["width"], "folderPaneBox"],
+ [this.messagePaneSplitter, ["height", "width"], "messagepaneboxwrapper"],
+ ]) {
+ for (let property of properties) {
+ let value = Services.xulStore.getValue(XULSTORE_URL, storeID, property);
+ if (value) {
+ splitter[property] = value;
+ }
+ }
+
+ splitter.storeAttr = function (attrName, attrValue) {
+ Services.xulStore.setValue(XULSTORE_URL, storeID, attrName, attrValue);
+ };
+
+ splitter.addEventListener("splitter-resized", () => {
+ if (splitter.resizeDirection == "vertical") {
+ splitter.storeAttr("height", splitter.height);
+ } else {
+ splitter.storeAttr("width", splitter.width);
+ }
+ });
+ }
+
+ this.messagePaneSplitter.addEventListener("splitter-collapsed", () => {
+ // Clear any loaded page or messages.
+ messagePane.clearAll();
+ this.messagePaneSplitter.storeAttr("collapsed", true);
+ });
+
+ this.messagePaneSplitter.addEventListener("splitter-expanded", () => {
+ // Load the selected messages.
+ threadTree.dispatchEvent(new CustomEvent("select"));
+ this.messagePaneSplitter.storeAttr("collapsed", false);
+ });
+
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "layoutPreference",
+ "mail.pane_config.dynamic",
+ null,
+ (name, oldValue, newValue) => this.setLayout(newValue)
+ );
+ this.setLayout(this.layoutPreference);
+ threadPane.updateThreadView(
+ Services.xulStore.getValue(XULSTORE_URL, "threadPane", "view")
+ );
+ },
+
+ setLayout(preference) {
+ document.body.classList.remove(
+ "layout-classic",
+ "layout-vertical",
+ "layout-wide"
+ );
+ switch (preference) {
+ case 1:
+ document.body.classList.add("layout-wide");
+ this.messagePaneSplitter.resizeDirection = "vertical";
+ break;
+ case 2:
+ document.body.classList.add("layout-vertical");
+ this.messagePaneSplitter.resizeDirection = "horizontal";
+ break;
+ default:
+ document.body.classList.add("layout-classic");
+ this.messagePaneSplitter.resizeDirection = "vertical";
+ break;
+ }
+ },
+
+ get accountCentralVisible() {
+ return document.body.classList.contains("account-central");
+ },
+ get folderPaneVisible() {
+ return !this.folderPaneSplitter.isCollapsed;
+ },
+ set folderPaneVisible(visible) {
+ this.folderPaneSplitter.isCollapsed = !visible;
+ },
+ get messagePaneVisible() {
+ return !this.messagePaneSplitter?.isCollapsed;
+ },
+ set messagePaneVisible(visible) {
+ this.messagePaneSplitter.isCollapsed = !visible;
+ },
+};
+
+var folderPaneContextMenu = {
+ /**
+ * @type {XULPopupElement}
+ */
+ _menupopup: null,
+
+ /**
+ * Commands handled by commandController.
+ *
+ * @type {Object.<string, string>}
+ */
+ _commands: {
+ "folderPaneContext-new": "cmd_newFolder",
+ "folderPaneContext-remove": "cmd_deleteFolder",
+ "folderPaneContext-rename": "cmd_renameFolder",
+ "folderPaneContext-compact": "cmd_compactFolder",
+ "folderPaneContext-properties": "cmd_properties",
+ "folderPaneContext-favoriteFolder": "cmd_toggleFavoriteFolder",
+ },
+
+ /**
+ * Current state of commandController commands. Set to null to invalidate
+ * the states.
+ *
+ * @type {Object.<string, boolean>|null}
+ */
+ _commandStates: null,
+
+ init() {
+ this._menupopup = document.getElementById("folderPaneContext");
+ this._menupopup.addEventListener("popupshowing", this);
+ this._menupopup.addEventListener("popuphidden", this);
+ this._menupopup.addEventListener("command", this);
+ folderTree.addEventListener("select", this);
+ },
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "popupshowing":
+ this.onPopupShowing(event);
+ break;
+ case "popuphidden":
+ this.onPopupHidden(event);
+ break;
+ case "command":
+ this.onCommand(event);
+ break;
+ case "select":
+ this._commandStates = null;
+ break;
+ }
+ },
+
+ /**
+ * The folder that this context menu is operating on. This will be `gFolder`
+ * unless the menu was opened by right-clicking on another folder.
+ *
+ * @type {nsIMsgFolder}
+ */
+ get activeFolder() {
+ return this._overrideFolder || gFolder;
+ },
+
+ /**
+ * Override the folder that this context menu should operate on. The effect
+ * lasts until `clearOverrideFolder` is called by `onPopupHidden`.
+ *
+ * @param {nsIMsgFolder} folder
+ */
+ setOverrideFolder(folder) {
+ this._overrideFolder = folder;
+ this._commandStates = null;
+ },
+
+ /**
+ * Clear the overriding folder, and go back to using `gFolder`.
+ */
+ clearOverrideFolder() {
+ this._overrideFolder = null;
+ this._commandStates = null;
+ },
+
+ /**
+ * Gets the enabled state of a command. If the state is unknown (because the
+ * selected folder has changed) the states of all the commands are worked
+ * out together to save unnecessary work.
+ *
+ * @param {string} command
+ */
+ getCommandState(command) {
+ let folder = this.activeFolder;
+ if (!folder || FolderUtils.isSmartTagsFolder(folder)) {
+ return false;
+ }
+ if (this._commandStates === null) {
+ let {
+ canCompact,
+ canCreateSubfolders,
+ canRename,
+ deletable,
+ flags,
+ isServer,
+ server,
+ URI,
+ } = folder;
+ let isJunk = flags & Ci.nsMsgFolderFlags.Junk;
+ let isVirtual = flags & Ci.nsMsgFolderFlags.Virtual;
+ let isNNTP = server.type == "nntp";
+ if (isNNTP && !isServer) {
+ // `folderPane.deleteFolder` has a special case for this.
+ deletable = true;
+ }
+ let isSmartTagsFolder = FolderUtils.isSmartTagsFolder(folder);
+ let showNewFolderItem =
+ (!isNNTP && canCreateSubfolders) || flags & Ci.nsMsgFolderFlags.Inbox;
+
+ this._commandStates = {
+ cmd_newFolder: showNewFolderItem,
+ cmd_deleteFolder: isJunk
+ ? FolderUtils.canRenameDeleteJunkMail(URI)
+ : deletable,
+ cmd_renameFolder:
+ (!isServer &&
+ canRename &&
+ !(flags & Ci.nsMsgFolderFlags.SpecialUse)) ||
+ isVirtual ||
+ (isJunk && FolderUtils.canRenameDeleteJunkMail(URI)),
+ cmd_compactFolder:
+ !isVirtual &&
+ (isServer || canCompact) &&
+ folder.isCommandEnabled("cmd_compactFolder"),
+ cmd_emptyTrash: !isNNTP,
+ cmd_properties: !isServer && !isSmartTagsFolder,
+ cmd_toggleFavoriteFolder: !isServer && !isSmartTagsFolder,
+ };
+ }
+ return this._commandStates[command];
+ },
+
+ onPopupShowing(event) {
+ if (event.target != this._menupopup) {
+ return;
+ }
+
+ function showItem(id, show) {
+ let item = document.getElementById(id);
+ if (item) {
+ item.hidden = !show;
+ }
+ }
+
+ function checkItem(id, checked) {
+ let item = document.getElementById(id);
+ if (item) {
+ // Always convert truthy/falsy to boolean before string.
+ item.setAttribute("checked", !!checked);
+ }
+ }
+
+ // Ask commandController about the commands it controls.
+ for (let [id, command] of Object.entries(this._commands)) {
+ showItem(id, commandController.isCommandEnabled(command));
+ }
+
+ let folder = this.activeFolder;
+ let { canCreateSubfolders, flags, isServer, isSpecialFolder, server } =
+ folder;
+ let isJunk = flags & Ci.nsMsgFolderFlags.Junk;
+ let isTrash = isSpecialFolder(Ci.nsMsgFolderFlags.Trash, true);
+ let isVirtual = flags & Ci.nsMsgFolderFlags.Virtual;
+ let isRealFolder = !isServer && !isVirtual;
+ let isSmartVirtualFolder = FolderUtils.isSmartVirtualFolder(folder);
+ let isSmartTagsFolder = FolderUtils.isSmartTagsFolder(folder);
+ let serverType = server.type;
+
+ showItem(
+ "folderPaneContext-getMessages",
+ (isServer && serverType != "none") ||
+ (["nntp", "rss"].includes(serverType) && !isTrash && !isVirtual)
+ );
+ let showPauseAll = isServer && FeedUtils.isFeedFolder(folder);
+ showItem("folderPaneContext-pauseAllUpdates", showPauseAll);
+ if (showPauseAll) {
+ let optionsAcct = FeedUtils.getOptionsAcct(server);
+ checkItem("folderPaneContext-pauseAllUpdates", !optionsAcct.doBiff);
+ }
+ let showPaused = !isServer && FeedUtils.getFeedUrlsInFolder(folder);
+ showItem("folderPaneContext-pauseUpdates", showPaused);
+ if (showPaused) {
+ let properties = FeedUtils.getFolderProperties(folder);
+ checkItem(
+ "folderPaneContext-pauseUpdates",
+ properties.includes("isPaused")
+ );
+ }
+
+ showItem("folderPaneContext-searchMessages", !isVirtual);
+ if (isVirtual) {
+ showItem("folderPaneContext-subscribe", false);
+ } else if (serverType == "rss" && !isTrash) {
+ showItem("folderPaneContext-subscribe", true);
+ } else {
+ showItem(
+ "folderPaneContext-subscribe",
+ isServer && ["imap", "nntp"].includes(serverType)
+ );
+ }
+ showItem(
+ "folderPaneContext-newsUnsubscribe",
+ isRealFolder && serverType == "nntp"
+ );
+
+ let showNewFolderItem =
+ (serverType != "nntp" && canCreateSubfolders) ||
+ flags & Ci.nsMsgFolderFlags.Inbox;
+ if (showNewFolderItem) {
+ document
+ .getElementById("folderPaneContext-new")
+ .setAttribute(
+ "label",
+ messengerBundle.GetStringFromName(
+ isServer || flags & Ci.nsMsgFolderFlags.Inbox
+ ? "newFolder"
+ : "newSubfolder"
+ )
+ );
+ }
+
+ showItem(
+ "folderPaneContext-markMailFolderAllRead",
+ !isServer && !isSmartTagsFolder && serverType != "nntp"
+ );
+ showItem(
+ "folderPaneContext-markNewsgroupAllRead",
+ isRealFolder && serverType == "nntp"
+ );
+ showItem(
+ "folderPaneContext-emptyTrash",
+ isSpecialFolder(Ci.nsMsgFolderFlags.Trash, true)
+ );
+ showItem("folderPaneContext-emptyJunk", isJunk);
+ showItem(
+ "folderPaneContext-sendUnsentMessages",
+ flags & Ci.nsMsgFolderFlags.Queue
+ );
+
+ checkItem(
+ "folderPaneContext-favoriteFolder",
+ flags & Ci.nsMsgFolderFlags.Favorite
+ );
+ showItem("folderPaneContext-markAllFoldersRead", isServer);
+
+ showItem("folderPaneContext-settings", isServer);
+
+ showItem("folderPaneContext-manageTags", isSmartTagsFolder);
+
+ // If source folder is virtual, allow only "move" within its own server.
+ // Don't show "copy" and "again" and don't show "recent" and "favorite".
+ // Also, check if this is a top-level smart folder, e.g., virtual "Inbox"
+ // in unified folder view or a Tags folder. If so, don't show "move".
+ let movePopup = document.getElementById("folderContext-movePopup");
+ if (isVirtual) {
+ showItem("folderPaneContext-copyMenu", false);
+ let showMove = true;
+ if (isSmartVirtualFolder || isSmartTagsFolder) {
+ showMove = false;
+ }
+ showItem("folderPaneContext-moveMenu", showMove);
+ if (showMove) {
+ let rootURI = MailUtils.getOrCreateFolder(
+ this.activeFolder.rootFolder.URI
+ );
+ movePopup.parentFolder = rootURI;
+ }
+ } else {
+ // Non-virtual. Don't allow move or copy of special use or root folder.
+ let okToMoveCopy = !(isServer || flags & Ci.nsMsgFolderFlags.SpecialUse);
+ if (okToMoveCopy) {
+ // Set the move menu to show all accounts.
+ movePopup.parentFolder = null;
+ }
+ showItem("folderPaneContext-moveMenu", okToMoveCopy);
+ showItem("folderPaneContext-copyMenu", okToMoveCopy);
+ }
+
+ let lastItem;
+ for (let child of document.getElementById("folderPaneContext").children) {
+ if (child.localName == "menuseparator") {
+ child.hidden = !lastItem || lastItem.localName == "menuseparator";
+ }
+ if (!child.hidden) {
+ lastItem = child;
+ }
+ }
+ if (lastItem.localName == "menuseparator") {
+ lastItem.hidden = true;
+ }
+ },
+
+ onPopupHidden(event) {
+ if (event.target != this._menupopup) {
+ return;
+ }
+
+ folderTree
+ .querySelector(".context-menu-target")
+ ?.classList.remove("context-menu-target");
+ this.clearOverrideFolder();
+ },
+
+ /**
+ * Check if the transfer mode selected from folder context menu is "copy".
+ * If "copy" (!isMove) is selected and the copy is within the same server,
+ * silently change to mode "move".
+ * Do the transfer and return true if moved, false if copied.
+ *
+ * @param {boolean} isMove
+ * @param {nsIMsgFolder} sourceFolder
+ * @param {nsIMsgFolder} targetFolder
+ */
+ transferFolder(isMove, sourceFolder, targetFolder) {
+ if (!isMove && sourceFolder.server == targetFolder.server) {
+ // Don't allow folder copy within the same server; only move allowed.
+ // Can't copy folder intra-server, change to move.
+ isMove = true;
+ }
+ // Do the transfer. A slight delay in calling copyFolder() helps the
+ // folder-menupopup chain of items get properly closed so the next folder
+ // context popup can occur.
+ setTimeout(() =>
+ MailServices.copy.copyFolder(
+ sourceFolder,
+ targetFolder,
+ isMove,
+ null,
+ top.msgWindow
+ )
+ );
+ return isMove;
+ },
+
+ onCommand(event) {
+ let folder = this.activeFolder;
+ // If commandController handles this command, ask it to do so.
+ if (event.target.id in this._commands) {
+ commandController.doCommand(this._commands[event.target.id], folder);
+ return;
+ }
+
+ let topChromeWindow = window.browsingContext.topChromeWindow;
+ switch (event.target.id) {
+ case "folderPaneContext-getMessages":
+ topChromeWindow.MsgGetMessage([folder]);
+ break;
+ case "folderPaneContext-pauseAllUpdates":
+ topChromeWindow.MsgPauseUpdates(
+ [folder],
+ event.target.getAttribute("checked") == "true"
+ );
+ break;
+ case "folderPaneContext-pauseUpdates":
+ topChromeWindow.MsgPauseUpdates(
+ [folder],
+ event.target.getAttribute("checked") == "true"
+ );
+ break;
+ case "folderPaneContext-openNewTab":
+ topChromeWindow.MsgOpenNewTabForFolders([folder], {
+ event,
+ folderPaneVisible: !paneLayout.folderPaneSplitter.isCollapsed,
+ messagePaneVisible: !paneLayout.messagePaneSplitter.isCollapsed,
+ });
+ break;
+ case "folderPaneContext-openNewWindow":
+ topChromeWindow.MsgOpenNewWindowForFolder(folder.URI, -1);
+ break;
+ case "folderPaneContext-searchMessages":
+ commandController.doCommand("cmd_searchMessages", folder);
+ break;
+ case "folderPaneContext-subscribe":
+ topChromeWindow.MsgSubscribe(folder);
+ break;
+ case "folderPaneContext-newsUnsubscribe":
+ topChromeWindow.MsgUnsubscribe([folder]);
+ break;
+ case "folderPaneContext-markMailFolderAllRead":
+ case "folderPaneContext-markNewsgroupAllRead":
+ if (folder.flags & Ci.nsMsgFolderFlags.Virtual) {
+ topChromeWindow.MsgMarkAllRead(
+ VirtualFolderHelper.wrapVirtualFolder(folder).searchFolders
+ );
+ } else {
+ topChromeWindow.MsgMarkAllRead([folder]);
+ }
+ break;
+ case "folderPaneContext-emptyTrash":
+ folderPane.emptyTrash(folder);
+ break;
+ case "folderPaneContext-emptyJunk":
+ folderPane.emptyJunk(folder);
+ break;
+ case "folderPaneContext-sendUnsentMessages":
+ topChromeWindow.SendUnsentMessages();
+ break;
+ case "folderPaneContext-properties":
+ folderPane.editFolder(folder);
+ break;
+ case "folderPaneContext-markAllFoldersRead":
+ topChromeWindow.MsgMarkAllFoldersRead([folder]);
+ break;
+ case "folderPaneContext-settings":
+ folderPane.editFolder(folder);
+ break;
+ case "folderPaneContext-manageTags":
+ goDoCommand("cmd_manageTags");
+ break;
+ default: {
+ // Handle folder context menu items move to, copy to.
+ let isMove = false;
+ let isCopy = false;
+ let targetFolder;
+ if (
+ document
+ .getElementById("folderPaneContext-moveMenu")
+ .contains(event.target)
+ ) {
+ // A move is requested via foldermenu-popup.
+ isMove = true;
+ } else if (
+ document
+ .getElementById("folderPaneContext-copyMenu")
+ .contains(event.target)
+ ) {
+ // A copy is requested via foldermenu-popup.
+ isCopy = true;
+ }
+ if (isMove || isCopy) {
+ if (!targetFolder) {
+ targetFolder = event.target._folder;
+ }
+ isMove = this.transferFolder(isMove, folder, targetFolder);
+ // Save in prefs the target folder URI and if this was a move or
+ // copy. This is to fill in the next folder or message context
+ // menu item "Move|Copy to <TargetFolderName> Again".
+ Services.prefs.setStringPref(
+ "mail.last_msg_movecopy_target_uri",
+ targetFolder.URI
+ );
+ Services.prefs.setBoolPref("mail.last_msg_movecopy_was_move", isMove);
+ }
+ break;
+ }
+ }
+ },
+};
+
+var folderPane = {
+ _initialized: false,
+
+ /**
+ * If the local folders should be hidden.
+ * @type {boolean}
+ */
+ _hideLocalFolders: false,
+
+ _modes: {
+ all: {
+ name: "all",
+ active: false,
+ canBeCompact: false,
+
+ initServer(server) {
+ let serverRow = folderPane._createServerRow(this.name, server);
+ folderPane._insertInServerOrder(this.containerList, serverRow);
+ folderPane._addSubFolders(server.rootFolder, serverRow, this.name);
+ },
+
+ addFolder(parentFolder, childFolder) {
+ FolderTreeProperties.setIsExpanded(childFolder.URI, this.name, true);
+ if (
+ childFolder.server.hidden ||
+ folderPane.getRowForFolder(childFolder, this.name)
+ ) {
+ // We're not displaying this server, or the folder already exists in
+ // the folder tree. Was `addFolder` called twice?
+ return;
+ }
+ if (!parentFolder) {
+ folderPane._insertInServerOrder(
+ this.containerList,
+ folderPane._createServerRow(this.name, childFolder.server)
+ );
+ return;
+ }
+
+ let parentRow = folderPane.getRowForFolder(parentFolder, this.name);
+ if (!parentRow) {
+ console.error("no parentRow for ", parentFolder.URI, childFolder.URI);
+ }
+ // To auto-expand non-root imap folders, imap URL "discoverchildren" is
+ // triggered -- but actually only occurs if server settings configured
+ // to ignore subscriptions. (This also occurs in _onExpanded() for
+ // manual folder expansion.)
+ if (parentFolder.server.type == "imap" && !parentFolder.isServer) {
+ parentFolder.QueryInterface(Ci.nsIMsgImapMailFolder);
+ parentFolder.performExpand(top.msgWindow);
+ }
+ folderTree.expandRow(parentRow);
+ let childRow = folderPane._createFolderRow(this.name, childFolder);
+ folderPane._addSubFolders(childFolder, childRow, "all");
+ parentRow.insertChildInOrder(childRow);
+ },
+
+ removeFolder(parentFolder, childFolder) {
+ folderPane.getRowForFolder(childFolder, this.name)?.remove();
+ },
+
+ changeAccountOrder() {
+ folderPane._reapplyServerOrder(this.containerList);
+ },
+ },
+ smart: {
+ name: "smart",
+ active: false,
+ canBeCompact: false,
+
+ _folderTypes: [
+ { flag: Ci.nsMsgFolderFlags.Inbox, name: "Inbox" },
+ { flag: Ci.nsMsgFolderFlags.Drafts, name: "Drafts" },
+ { flag: Ci.nsMsgFolderFlags.Templates, name: "Templates" },
+ { flag: Ci.nsMsgFolderFlags.SentMail, name: "Sent" },
+ { flag: Ci.nsMsgFolderFlags.Archive, name: "Archives" },
+ { flag: Ci.nsMsgFolderFlags.Junk, name: "Junk" },
+ { flag: Ci.nsMsgFolderFlags.Trash, name: "Trash" },
+ // { flag: Ci.nsMsgFolderFlags.Queue, name: "Outbox" },
+ ],
+
+ init() {
+ this._smartServer = MailServices.accounts.findServer(
+ "nobody",
+ "smart mailboxes",
+ "none"
+ );
+ if (!this._smartServer) {
+ this._smartServer = MailServices.accounts.createIncomingServer(
+ "nobody",
+ "smart mailboxes",
+ "none"
+ );
+ // We don't want the "smart" server/account leaking out into the ui in
+ // other places, so set it as hidden.
+ this._smartServer.hidden = true;
+ let account = MailServices.accounts.createAccount();
+ account.incomingServer = this._smartServer;
+ }
+ this._smartServer.prettyName =
+ messengerBundle.GetStringFromName("unifiedAccountName");
+ let smartRoot = this._smartServer.rootFolder.QueryInterface(
+ Ci.nsIMsgLocalMailFolder
+ );
+
+ let allFlags = 0;
+ this._folderTypes.forEach(folderType => (allFlags |= folderType.flag));
+
+ for (let folderType of this._folderTypes) {
+ let folder = smartRoot.getChildWithURI(
+ `${smartRoot.URI}/${folderType.name}`,
+ false,
+ true
+ );
+ if (!folder) {
+ try {
+ let searchFolders = [];
+
+ function recurse(folder) {
+ let subFolders;
+ try {
+ subFolders = folder.subFolders;
+ } catch (ex) {
+ console.error(
+ new Error(
+ `Unable to access the subfolders of ${folder.URI}`,
+ { cause: ex }
+ )
+ );
+ }
+ if (!subFolders?.length) {
+ return;
+ }
+
+ for (let sf of subFolders) {
+ // Add all of the subfolders except the ones that belong to
+ // a different folder type.
+ if (!(sf.flags & allFlags)) {
+ searchFolders.push(sf);
+ recurse(sf);
+ }
+ }
+ }
+
+ for (let server of MailServices.accounts.allServers) {
+ for (let f of server.rootFolder.getFoldersWithFlags(
+ folderType.flag
+ )) {
+ searchFolders.push(f);
+ recurse(f);
+ }
+ }
+
+ folder = smartRoot.createLocalSubfolder(folderType.name);
+ folder.flags |= Ci.nsMsgFolderFlags.Virtual | folderType.flag;
+
+ let msgDatabase = folder.msgDatabase;
+ let folderInfo = msgDatabase.dBFolderInfo;
+
+ folderInfo.setCharProperty("searchStr", "ALL");
+ folderInfo.setCharProperty(
+ "searchFolderUri",
+ searchFolders.map(f => f.URI).join("|")
+ );
+ folderInfo.setUint32Property("searchFolderFlag", folderType.flag);
+ folderInfo.setBooleanProperty("searchOnline", true);
+ msgDatabase.summaryValid = true;
+ msgDatabase.close(true);
+
+ smartRoot.notifyFolderAdded(folder);
+ } catch (ex) {
+ console.error(ex);
+ continue;
+ }
+ }
+ let row = folderPane._createFolderRow(this.name, folder);
+ this.containerList.appendChild(row);
+ folderType.folderURI = folder.URI;
+ folderType.list = row.childList;
+
+ // Display the searched folders for this type.
+ let wrappedFolder = VirtualFolderHelper.wrapVirtualFolder(folder);
+ for (let searchFolder of wrappedFolder.searchFolders) {
+ if (searchFolder != folder) {
+ this._addSearchedFolder(
+ folderType,
+ folderPane._getNonGmailParent(searchFolder),
+ searchFolder
+ );
+ }
+ }
+ }
+ MailServices.accounts.saveVirtualFolders();
+ },
+
+ regenerateMode() {
+ if (this._smartServer) {
+ MailServices.accounts.removeIncomingServer(this._smartServer, true);
+ }
+ this.init();
+ },
+
+ _addSearchedFolder(folderType, parentFolder, childFolder) {
+ if (folderType.flag & childFolder.flags) {
+ // The folder has the flag for this type.
+ let folderRow = folderPane._createFolderRow(
+ this.name,
+ childFolder,
+ "server"
+ );
+ folderPane._insertInServerOrder(folderType.list, folderRow);
+ return;
+ }
+
+ if (!childFolder.isSpecialFolder(folderType.flag, true)) {
+ // This folder is searched by the virtual folder but it hasn't got
+ // the flag of this type and no ancestor has the flag of this type.
+ // We don't have a good way of displaying it.
+ return;
+ }
+
+ // The folder is a descendant of one which has the flag.
+ let parentRow = folderPane.getRowForFolder(parentFolder, this.name);
+ if (!parentRow) {
+ // This is awkward: `childFolder` is searched but `parentFolder` is
+ // not. Displaying the unsearched folder is probably the least
+ // confusing way to handle this situation.
+ this._addSearchedFolder(
+ folderType,
+ folderPane._getNonGmailParent(parentFolder),
+ parentFolder
+ );
+ parentRow = folderPane.getRowForFolder(parentFolder, this.name);
+ }
+ parentRow.insertChildInOrder(
+ folderPane._createFolderRow(this.name, childFolder)
+ );
+ },
+
+ changeSearchedFolders(smartFolder) {
+ let folderType = this._folderTypes.find(
+ ft => ft.folderURI == smartFolder.URI
+ );
+ if (!folderType) {
+ // This virtual folder isn't one of the smart folders. It's probably
+ // one of the tags virtual folders.
+ return;
+ }
+
+ let wrappedFolder = VirtualFolderHelper.wrapVirtualFolder(smartFolder);
+ let smartFolderRow = folderPane.getRowForFolder(smartFolder, this.name);
+ let searchFolderURIs = wrappedFolder.searchFolders.map(sf => sf.URI);
+ let serversToCheck = new Set();
+
+ // Remove any rows which may belong to folders that aren't searched.
+ for (let row of [...smartFolderRow.querySelectorAll("li")]) {
+ if (!searchFolderURIs.includes(row.uri)) {
+ row.remove();
+ let folder = MailServices.folderLookup.getFolderForURL(row.uri);
+ if (folder) {
+ serversToCheck.add(folder.server);
+ }
+ }
+ }
+
+ // Add missing rows for folders that are searched.
+ let existingRowURIs = Array.from(
+ smartFolderRow.querySelectorAll("li"),
+ row => row.uri
+ );
+ for (let searchFolder of wrappedFolder.searchFolders) {
+ if (
+ searchFolder == smartFolder ||
+ existingRowURIs.includes(searchFolder.URI)
+ ) {
+ continue;
+ }
+ let existingRow = folderPane.getRowForFolder(searchFolder, this.name);
+ if (existingRow) {
+ // A row for this folder exists, but not under the smart folder.
+ // Remove it and display under the smart folder.
+ folderPane._removeFolderAndAncestors(searchFolder, this.name, f =>
+ searchFolderURIs.includes(f.URI)
+ );
+ }
+ this._addSearchedFolder(
+ folderType,
+ folderPane._getNonGmailParent(searchFolder),
+ searchFolder
+ );
+ }
+
+ // For any rows we removed, check they are added back to the tree.
+ for (let server of serversToCheck) {
+ this.initServer(server);
+ }
+ },
+
+ initServer(server) {
+ // Find all folders in this server, and display the ones that aren't
+ // currently displayed.
+ let descendants = new Map(
+ server.rootFolder.descendants.map(d => [d.URI, d])
+ );
+ if (!descendants.size) {
+ return;
+ }
+ let remainingFolderURIs = Array.from(descendants.keys());
+
+ // Get a list of folders that already exist in the folder tree.
+ let existingRows = this.containerList.getElementsByTagName("li");
+ let existingURIs = Array.from(existingRows, li => li.uri);
+ do {
+ let folderURI = remainingFolderURIs.shift();
+ if (existingURIs.includes(folderURI)) {
+ continue;
+ }
+ let folder = descendants.get(folderURI);
+ if (folderPane._isGmailFolder(folder)) {
+ continue;
+ }
+ this.addFolder(folderPane._getNonGmailParent(folder), folder);
+ // Update the list of existing folders. `existingRows` is a live
+ // list, so we don't need to call `getElementsByTagName` again.
+ existingURIs = Array.from(existingRows, li => li.uri);
+ } while (remainingFolderURIs.length);
+ },
+
+ addFolder(parentFolder, childFolder) {
+ if (folderPane.getRowForFolder(childFolder, this.name)) {
+ // If a row for this folder exists, do nothing.
+ return;
+ }
+ if (!parentFolder) {
+ // If this folder is the root folder for a server, do nothing.
+ return;
+ }
+ if (childFolder.server.hidden) {
+ // If this folder is from a hidden server, do nothing.
+ return;
+ }
+
+ let folderType = this._folderTypes.find(ft =>
+ childFolder.isSpecialFolder(ft.flag, true)
+ );
+ if (folderType) {
+ let virtualFolder = VirtualFolderHelper.wrapVirtualFolder(
+ MailServices.folderLookup.getFolderForURL(folderType.folderURI)
+ );
+ let searchFolders = virtualFolder.searchFolders;
+ if (searchFolders.includes(childFolder)) {
+ // This folder is included in the virtual folder, do nothing.
+ return;
+ }
+
+ if (searchFolders.includes(parentFolder)) {
+ // This folder's parent is included in the virtual folder, but the
+ // folder itself isn't. Add it to the list of non-special folders.
+ // Note that `_addFolderAndAncestors` can't be used here, as that
+ // would add the row in the wrong place.
+ let serverRow = folderPane.getRowForFolder(
+ childFolder.rootFolder,
+ this.name
+ );
+ if (!serverRow) {
+ serverRow = folderPane._createServerRow(
+ this.name,
+ childFolder.server
+ );
+ folderPane._insertInServerOrder(this.containerList, serverRow);
+ }
+ let folderRow = folderPane._createFolderRow(this.name, childFolder);
+ serverRow.insertChildInOrder(folderRow);
+ folderPane._addSubFolders(childFolder, folderRow, this.name);
+ return;
+ }
+ }
+
+ // Nothing special about this folder. Add it to the end of the list.
+ let folderRow = folderPane._addFolderAndAncestors(
+ this.containerList,
+ childFolder,
+ this.name
+ );
+ folderPane._addSubFolders(childFolder, folderRow, this.name);
+ },
+
+ removeFolder(parentFolder, childFolder) {
+ let childRow = folderPane.getRowForFolder(childFolder, this.name);
+ if (!childRow) {
+ return;
+ }
+ let parentRow = childRow.parentNode.closest("li");
+ childRow.remove();
+ if (
+ parentRow.parentNode == this.containerList &&
+ parentRow.dataset.serverType &&
+ !parentRow.querySelector("li")
+ ) {
+ parentRow.remove();
+ }
+ },
+
+ changeAccountOrder() {
+ folderPane._reapplyServerOrder(this.containerList);
+
+ for (let smartFolderRow of this.containerList.children) {
+ if (smartFolderRow.dataset.serverKey == this._smartServer.key) {
+ folderPane._reapplyServerOrder(smartFolderRow.childList);
+ }
+ }
+ },
+ },
+ unread: {
+ name: "unread",
+ active: false,
+ canBeCompact: true,
+
+ _unreadFilter(folder, includeSubFolders = true) {
+ return folder.getNumUnread(includeSubFolders) > 0;
+ },
+
+ initServer(server) {
+ this.addFolder(null, server.rootFolder);
+ },
+
+ _recurseSubFolders(parentFolder) {
+ let subFolders;
+ try {
+ subFolders = parentFolder.subFolders;
+ } catch (ex) {
+ console.error(
+ new Error(
+ `Unable to access the subfolders of ${parentFolder.URI}`,
+ { cause: ex }
+ )
+ );
+ }
+ if (!subFolders?.length) {
+ return;
+ }
+
+ for (let i = 0; i < subFolders.length; i++) {
+ let folder = subFolders[i];
+ if (folderPane._isGmailFolder(folder)) {
+ subFolders.splice(i, 1, ...folder.subFolders);
+ }
+ }
+
+ subFolders.sort((a, b) => a.compareSortKeys(b));
+
+ for (let folder of subFolders) {
+ if (!this._unreadFilter(folder)) {
+ continue;
+ }
+ if (this._unreadFilter(folder, false)) {
+ this._addFolder(folder);
+ }
+ this._recurseSubFolders(folder);
+ }
+ },
+
+ addFolder(unused, folder) {
+ if (!this._unreadFilter(folder)) {
+ return;
+ }
+ this._addFolder(folder);
+ this._recurseSubFolders(folder);
+ },
+
+ _addFolder(folder) {
+ if (folderPane.getRowForFolder(folder, this.name)) {
+ // Don't do anything. `folderPane.changeUnreadCount` already did it.
+ return;
+ }
+
+ if (!this._unreadFilter(folder, !folderPane._isCompact)) {
+ return;
+ }
+
+ if (folderPane._isCompact) {
+ let folderRow = folderPane._createFolderRow(
+ this.name,
+ folder,
+ "both"
+ );
+ folderPane._insertInServerOrder(this.containerList, folderRow);
+ return;
+ }
+
+ folderPane._addFolderAndAncestors(
+ this.containerList,
+ folder,
+ this.name
+ );
+ },
+
+ removeFolder(parentFolder, childFolder) {
+ folderPane._removeFolderAndAncestors(
+ childFolder,
+ this.name,
+ this._unreadFilter
+ );
+
+ // If the folder is being moved, `childFolder.parent` is null so the
+ // above code won't remove ancestors. Do this now.
+ if (!childFolder.parent && parentFolder) {
+ folderPane._removeFolderAndAncestors(
+ parentFolder,
+ this.name,
+ this._unreadFilter,
+ true
+ );
+ }
+
+ // Remove any stray rows that might be descendants of `childFolder`.
+ for (let row of [...this.containerList.querySelectorAll("li")]) {
+ if (row.uri.startsWith(childFolder.URI + "/")) {
+ row.remove();
+ }
+ }
+ },
+
+ changeUnreadCount(folder, newValue) {
+ if (newValue > 0) {
+ this._addFolder(folder);
+ }
+ },
+
+ changeAccountOrder() {
+ folderPane._reapplyServerOrder(this.containerList);
+ },
+ },
+ favorite: {
+ name: "favorite",
+ active: false,
+ canBeCompact: true,
+
+ _favoriteFilter(folder) {
+ return folder.flags & Ci.nsMsgFolderFlags.Favorite;
+ },
+
+ initServer(server) {
+ this.addFolder(null, server.rootFolder);
+ },
+
+ addFolder(unused, folder) {
+ this._addFolder(folder);
+ for (let subFolder of folder.getFoldersWithFlags(
+ Ci.nsMsgFolderFlags.Favorite
+ )) {
+ this._addFolder(subFolder);
+ }
+ },
+
+ _addFolder(folder) {
+ if (
+ !this._favoriteFilter(folder) ||
+ folderPane.getRowForFolder(folder, this.name)
+ ) {
+ return;
+ }
+
+ if (folderPane._isCompact) {
+ folderPane._insertInServerOrder(
+ this.containerList,
+ folderPane._createFolderRow(this.name, folder, "both")
+ );
+ return;
+ }
+
+ folderPane._addFolderAndAncestors(
+ this.containerList,
+ folder,
+ this.name
+ );
+ },
+
+ removeFolder(parentFolder, childFolder) {
+ folderPane._removeFolderAndAncestors(
+ childFolder,
+ this.name,
+ this._favoriteFilter
+ );
+
+ // If the folder is being moved, `childFolder.parent` is null so the
+ // above code won't remove ancestors. Do this now.
+ if (!childFolder.parent && parentFolder) {
+ folderPane._removeFolderAndAncestors(
+ parentFolder,
+ this.name,
+ this._favoriteFilter,
+ true
+ );
+ }
+
+ // Remove any stray rows that might be descendants of `childFolder`.
+ for (let row of [...this.containerList.querySelectorAll("li")]) {
+ if (row.uri.startsWith(childFolder.URI + "/")) {
+ row.remove();
+ }
+ }
+ },
+
+ changeFolderFlag(folder, oldValue, newValue) {
+ oldValue &= Ci.nsMsgFolderFlags.Favorite;
+ newValue &= Ci.nsMsgFolderFlags.Favorite;
+
+ if (oldValue == newValue) {
+ return;
+ }
+
+ if (oldValue) {
+ if (
+ folderPane._isCompact ||
+ !folder.getFolderWithFlags(Ci.nsMsgFolderFlags.Favorite)
+ ) {
+ folderPane._removeFolderAndAncestors(
+ folder,
+ this.name,
+ this._favoriteFilter
+ );
+ }
+ } else {
+ this._addFolder(folder);
+ }
+ },
+
+ changeAccountOrder() {
+ folderPane._reapplyServerOrder(this.containerList);
+ },
+ },
+ recent: {
+ name: "recent",
+ active: false,
+ canBeCompact: false,
+
+ init() {
+ let folders = FolderUtils.getMostRecentFolders(
+ MailServices.accounts.allFolders,
+ Services.prefs.getIntPref("mail.folder_widget.max_recent"),
+ "MRUTime"
+ );
+ for (let folder of folders) {
+ let folderRow = folderPane._createFolderRow(
+ this.name,
+ folder,
+ "both"
+ );
+ this.containerList.appendChild(folderRow);
+ }
+ },
+
+ removeFolder(parentFolder, childFolder) {
+ folderPane.getRowForFolder(childFolder)?.remove();
+ },
+ },
+ tags: {
+ name: "tags",
+ active: false,
+ canBeCompact: false,
+
+ init() {
+ this._smartServer = MailServices.accounts.findServer(
+ "nobody",
+ "smart mailboxes",
+ "none"
+ );
+ if (!this._smartServer) {
+ this._smartServer = MailServices.accounts.createIncomingServer(
+ "nobody",
+ "smart mailboxes",
+ "none"
+ );
+ // We don't want the "smart" server/account leaking out into the ui in
+ // other places, so set it as hidden.
+ this._smartServer.hidden = true;
+ let account = MailServices.accounts.createAccount();
+ account.incomingServer = this._smartServer;
+ }
+ this._smartServer.prettyName =
+ messengerBundle.GetStringFromName("unifiedAccountName");
+ let smartRoot = this._smartServer.rootFolder.QueryInterface(
+ Ci.nsIMsgLocalMailFolder
+ );
+ this._tagsFolder =
+ smartRoot.getChildWithURI(`${smartRoot.URI}/tags`, false, false) ??
+ smartRoot.createLocalSubfolder("tags");
+ this._tagsFolder.QueryInterface(Ci.nsIMsgLocalMailFolder);
+
+ for (let tag of MailServices.tags.getAllTags()) {
+ try {
+ let folder = this._getVirtualFolder(tag);
+ this.containerList.appendChild(
+ folderPane._createTagRow(this.name, folder, tag)
+ );
+ } catch (ex) {
+ console.error(ex);
+ }
+ }
+ MailServices.accounts.saveVirtualFolders();
+ },
+
+ /**
+ * Get or create a virtual folder searching messages for `tag`.
+ *
+ * @param {nsIMsgTag} tag
+ * @returns {nsIMsgFolder}
+ */
+ _getVirtualFolder(tag) {
+ let folder = this._tagsFolder.getChildWithURI(
+ `${this._tagsFolder.URI}/${encodeURIComponent(tag.key)}`,
+ false,
+ false
+ );
+ if (folder) {
+ return folder;
+ }
+
+ folder = this._tagsFolder.createLocalSubfolder(tag.key);
+ folder.flags |= Ci.nsMsgFolderFlags.Virtual;
+ folder.prettyName = tag.tag;
+
+ let msgDatabase = folder.msgDatabase;
+ let folderInfo = msgDatabase.dBFolderInfo;
+
+ folderInfo.setCharProperty(
+ "searchStr",
+ `AND (tag,contains,${tag.key})`
+ );
+ folderInfo.setCharProperty("searchFolderUri", "*");
+ folderInfo.setUint32Property(
+ "searchFolderFlag",
+ Ci.nsMsgFolderFlags.Inbox
+ );
+ folderInfo.setBooleanProperty("searchOnline", false);
+ msgDatabase.summaryValid = true;
+ msgDatabase.close(true);
+
+ this._tagsFolder.notifyFolderAdded(folder);
+ return folder;
+ },
+
+ /**
+ * Update the UI to match changes in a tag. If the tag is no longer
+ * valid (i.e. it's been deleted) the row representing it will be
+ * removed. If the tag is new, a row for it will be created.
+ *
+ * @param {string} prefName - The full name of the preference that
+ * changed causing this code to run.
+ */
+ changeTagFromPrefChange(prefName) {
+ let [, , key] = prefName.split(".");
+ if (!MailServices.tags.isValidKey(key)) {
+ let uri = `${this._tagsFolder.URI}/${encodeURIComponent(key)}`;
+ folderPane.getRowForFolder(uri)?.remove();
+ return;
+ }
+
+ let tag = MailServices.tags.getAllTags().find(t => t.key == key);
+ let folder = this._getVirtualFolder(tag);
+ let row = folderPane.getRowForFolder(folder);
+ folder.prettyName = tag.tag;
+ if (row) {
+ row.name = tag.tag;
+ row.icon.style.setProperty("--icon-color", tag.color);
+ } else {
+ this.containerList.appendChild(
+ folderPane._createTagRow(this.name, folder, tag)
+ );
+ }
+ },
+ },
+ },
+
+ /**
+ * Initialize the folder pane if needed.
+ * @returns {Promise<void>} when the folder pane is initialized.
+ */
+ async init() {
+ if (this._initialized) {
+ return;
+ }
+ if (window.openingState?.syntheticView) {
+ // Just avoid initialising the pane. We won't be using it. The folder
+ // listener is still required, because it does other things too.
+ MailServices.mailSession.AddFolderListener(
+ folderListener,
+ Ci.nsIFolderListener.all
+ );
+ return;
+ }
+
+ try {
+ // We could be here before `loadPostAccountWizard` loads the virtual
+ // folders, and we need them, so do it now.
+ MailServices.accounts.loadVirtualFolders();
+ } catch (e) {
+ console.error(e);
+ }
+
+ await FolderTreeProperties.ready;
+
+ this._modeTemplate = document.getElementById("modeTemplate");
+ this._folderTemplate = document.getElementById("folderTemplate");
+
+ this._isCompact =
+ Services.xulStore.getValue(XULSTORE_URL, "folderTree", "compact") ===
+ "true";
+ let activeModes = Services.xulStore.getValue(
+ XULSTORE_URL,
+ "folderTree",
+ "mode"
+ );
+ activeModes = activeModes.split(",");
+ this.activeModes = activeModes;
+
+ // Don't await anything between the active modes being initialised (the
+ // line above) and the listener being added. Otherwise folders may appear
+ // while we're not listening.
+ MailServices.mailSession.AddFolderListener(
+ folderListener,
+ Ci.nsIFolderListener.all
+ );
+
+ Services.prefs.addObserver("mail.accountmanager.accounts", this);
+ Services.prefs.addObserver("mailnews.tags.", this);
+
+ Services.obs.addObserver(this, "folder-color-changed");
+ Services.obs.addObserver(this, "folder-color-preview");
+ Services.obs.addObserver(this, "search-folders-changed");
+ Services.obs.addObserver(this, "folder-properties-changed");
+
+ folderTree.addEventListener("auxclick", this);
+ folderTree.addEventListener("contextmenu", this);
+ folderTree.addEventListener("collapsed", this);
+ folderTree.addEventListener("expanded", this);
+ folderTree.addEventListener("dragstart", this);
+ folderTree.addEventListener("dragover", this);
+ folderTree.addEventListener("dragleave", this);
+ folderTree.addEventListener("drop", this);
+
+ document.getElementById("folderPaneHeaderBar").hidden =
+ this.isFolderPaneHeaderHidden();
+ const folderPaneGetMessages = document.getElementById(
+ "folderPaneGetMessages"
+ );
+ folderPaneGetMessages.addEventListener("click", () => {
+ top.MsgGetMessagesForAccount();
+ });
+ folderPaneGetMessages.addEventListener("contextmenu", event => {
+ document
+ .getElementById("folderPaneGetMessagesContext")
+ .openPopup(event.target, { triggerEvent: event });
+ });
+ document
+ .getElementById("folderPaneWriteMessage")
+ .addEventListener("click", event => {
+ top.MsgNewMessage(event);
+ });
+ folderPaneGetMessages.hidden = this.isFolderPaneGetMsgsBtnHidden();
+ document.getElementById("folderPaneWriteMessage").hidden =
+ this.isFolderPaneNewMsgBtnHidden();
+ this.moreContext = document.getElementById("folderPaneMoreContext");
+ this.folderPaneModeContext = document.getElementById(
+ "folderPaneModeContext"
+ );
+
+ document
+ .getElementById("folderPaneMoreButton")
+ .addEventListener("click", event => {
+ this.moreContext.openPopup(event.target, { triggerEvent: event });
+ });
+ this.subFolderContext = document.getElementById(
+ "folderModesContextMenuPopup"
+ );
+ document
+ .getElementById("folderModesContextMenuPopup")
+ .addEventListener("click", event => {
+ this.subFolderContext.openPopup(event.target, { triggerEvent: event });
+ });
+ this.updateFolderRowUIElements();
+ this.updateWidgets();
+
+ this._initialized = true;
+ },
+
+ uninit() {
+ if (!this._initialized) {
+ return;
+ }
+ Services.prefs.removeObserver("mail.accountmanager.accounts", this);
+ Services.prefs.removeObserver("mailnews.tags.", this);
+ Services.obs.removeObserver(this, "folder-color-changed");
+ Services.obs.removeObserver(this, "folder-color-preview");
+ Services.obs.removeObserver(this, "search-folders-changed");
+ Services.obs.removeObserver(this, "folder-properties-changed");
+ },
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "select":
+ this._onSelect(event);
+ break;
+ case "auxclick":
+ if (event.button == 1) {
+ this._onMiddleClick(event);
+ }
+ break;
+ case "contextmenu":
+ this._onContextMenu(event);
+ break;
+ case "collapsed":
+ this._onCollapsed(event);
+ break;
+ case "expanded":
+ this._onExpanded(event);
+ break;
+ case "dragstart":
+ this._onDragStart(event);
+ break;
+ case "dragover":
+ this._onDragOver(event);
+ break;
+ case "dragleave":
+ this._clearDropTarget(event);
+ break;
+ case "drop":
+ this._onDrop(event);
+ break;
+ }
+ },
+
+ observe(subject, topic, data) {
+ switch (topic) {
+ case "nsPref:changed":
+ if (data == "mail.accountmanager.accounts") {
+ this._forAllActiveModes("changeAccountOrder");
+ } else if (
+ data.startsWith("mailnews.tags.") &&
+ this._modes.tags.active
+ ) {
+ // The tags service isn't updated until immediately after the
+ // preferences change, so go to the back of the event queue before
+ // updating the UI.
+ setTimeout(() => this._modes.tags.changeTagFromPrefChange(data));
+ }
+ break;
+ case "search-folders-changed":
+ if (this._modes.smart.active) {
+ subject.QueryInterface(Ci.nsIMsgFolder);
+ if (subject.server == this._modes.smart._smartServer) {
+ this._modes.smart.changeSearchedFolders(subject);
+ }
+ }
+ break;
+ case "folder-properties-changed":
+ this.updateFolderProperties(subject.QueryInterface(Ci.nsIMsgFolder));
+ break;
+ case "folder-color-changed":
+ case "folder-color-preview":
+ this._changeRows(subject, row => row.setIconColor(data));
+ break;
+ }
+ },
+
+ /**
+ * Whether the folder pane has been initialized.
+ *
+ * @type {boolean}
+ */
+ get isInitialized() {
+ return this._initialized;
+ },
+
+ /**
+ * If the local folders are currently hidden.
+ *
+ * @returns {boolean}
+ */
+ get hideLocalFolders() {
+ this._hideLocalFolders = this.isItemHidden("folderPaneLocalFolders");
+ return this._hideLocalFolders;
+ },
+
+ /**
+ * Reload the folder tree when the option changes.
+ *
+ * @param {boolean} - True if local folders should be hidden.
+ */
+ set hideLocalFolders(value) {
+ if (value == this._hideLocalFolders) {
+ return;
+ }
+
+ this._hideLocalFolders = value;
+ for (let mode of Object.values(this._modes)) {
+ if (!mode.active) {
+ continue;
+ }
+ mode.containerList.replaceChildren();
+ this._initMode(mode);
+ }
+ this.updateFolderRowUIElements();
+ },
+
+ /**
+ * Toggle the folder modes requested by the user.
+ *
+ * @param {Event} event - The DOMEvent.
+ */
+ toggleFolderMode(event) {
+ let currentModes = this.activeModes;
+ let mode = event.target.getAttribute("value");
+ let index = this.activeModes.indexOf(mode);
+
+ if (event.target.hasAttribute("checked")) {
+ if (index == -1) {
+ currentModes.push(mode);
+ }
+ } else if (index >= 0) {
+ currentModes.splice(index, 1);
+ }
+ this.activeModes = currentModes;
+ this.toggleCompactViewMenuItem();
+
+ if (this.activeModes.length == 1 && this.activeModes.at(0) == "all") {
+ this.updateContextCheckedFolderMode();
+ }
+ },
+
+ toggleCompactViewMenuItem() {
+ let subMenuCompactBtn = document.querySelector(
+ "#folderPaneMoreContextCompactToggle"
+ );
+ if (this.canBeCompact) {
+ subMenuCompactBtn.removeAttribute("disabled");
+ return;
+ }
+ subMenuCompactBtn.setAttribute("disabled", "true");
+ },
+
+ /**
+ * Ensure all the folder modes menuitems in the pane header context menu are
+ * checked to reflect the currently active modes.
+ */
+ updateContextCheckedFolderMode() {
+ for (let item of document.querySelectorAll(".folder-pane-mode")) {
+ if (this.activeModes.includes(item.value)) {
+ item.setAttribute("checked", true);
+ continue;
+ }
+ item.removeAttribute("checked");
+ }
+ },
+
+ /**
+ * Ensures all the folder pane mode context menuitems in the folder
+ * pane mode context menu are checked to reflect the current compact mode.
+ * @param {Event} event - The DOMEvent.
+ */
+ onFolderPaneModeContextOpening(event) {
+ this.mode = event.target.closest("[data-mode]")?.dataset.mode;
+
+ // If folder mode is at the top or the only one,
+ // it can't be moved up, so disable "Move Up".
+ const moveUpMenuItem = this.folderPaneModeContext.querySelector(
+ "#folderPaneModeMoveUp"
+ );
+ moveUpMenuItem.removeAttribute("disabled");
+ // Apply attribute mode to context menu option to allow
+ // for sorting later
+ if (this.activeModes.at(0) == this.mode) {
+ moveUpMenuItem.setAttribute("disabled", "true");
+ }
+
+ // If folder mode is at the bottom or the only one,
+ // it can't be moved down, so disable "Move Down".
+ const moveDownMenuItem = this.folderPaneModeContext.querySelector(
+ "#folderPaneModeMoveDown"
+ );
+ moveDownMenuItem.removeAttribute("disabled");
+ // Apply attribute mode to context menu option to allow
+ // for sorting later
+ if (this.activeModes.at(-1) == this.mode) {
+ moveDownMenuItem.setAttribute("disabled", "true");
+ }
+
+ let compactMenuItem = this.folderPaneModeContext.querySelector(
+ "#compactFolderButton"
+ );
+ compactMenuItem.removeAttribute("checked");
+ compactMenuItem.removeAttribute("disabled");
+ if (!this.canModeBeCompact(this.mode)) {
+ compactMenuItem.setAttribute("disabled", "true");
+ return;
+ }
+ if (this.isCompact) {
+ compactMenuItem.setAttribute("checked", true);
+ }
+ },
+
+ /**
+ * Toggles the compact mode of the active modes that allow it.
+ *
+ * @param {Event} event - The DOMEvent.
+ */
+ compactFolderToggle(event) {
+ this.isCompact = event.target.hasAttribute("checked");
+ },
+
+ /**
+ * Moves active folder mode up
+ *
+ * @param {Event} event - The DOMEvent.
+ */
+ moveFolderModeUp(event) {
+ let currentModes = this.activeModes;
+ const mode = this.mode;
+ const index = currentModes.indexOf(mode);
+
+ if (index > 0) {
+ const prev = currentModes[index - 1];
+ currentModes[index - 1] = currentModes[index];
+ currentModes[index] = prev;
+ }
+ this.activeModes = currentModes;
+ },
+
+ /**
+ * Moves active folder mode down
+ *
+ * @param {Event} event - The DOMEvent.
+ */
+ moveFolderModeDown(event) {
+ let currentModes = this.activeModes;
+ const mode = this.mode;
+ const index = currentModes.indexOf(mode);
+
+ if (index < currentModes.length - 1) {
+ const next = currentModes[index + 1];
+ currentModes[index + 1] = currentModes[index];
+ currentModes[index] = next;
+ }
+ this.activeModes = currentModes;
+ },
+
+ /**
+ * The names of all active modes.
+ *
+ * @type {string[]}
+ */
+ get activeModes() {
+ return Array.from(folderTree.children, li => li.dataset.mode);
+ },
+
+ set activeModes(modes) {
+ modes = modes.filter(m => m in this._modes);
+ if (modes.length == 0) {
+ modes = ["all"];
+ }
+ for (let name of Object.keys(this._modes)) {
+ this._toggleMode(name, modes.includes(name));
+ }
+ for (let name of modes) {
+ let { container, containerHeader } = this._modes[name];
+ containerHeader.hidden = modes.length == 1;
+ folderTree.appendChild(container);
+ }
+ Services.xulStore.setValue(
+ XULSTORE_URL,
+ "folderTree",
+ "mode",
+ this.activeModes.join(",")
+ );
+ this.updateFolderRowUIElements();
+ },
+
+ /**
+ * Do any of the active modes have a compact variant?
+ *
+ * @type {boolean}
+ */
+ get canBeCompact() {
+ return Object.values(this._modes).some(
+ mode => mode.active && mode.canBeCompact
+ );
+ },
+
+ /**
+ * Do any of the active modes have a compact variant?
+ *
+ * @param {string} mode
+ * @type {boolean}
+ */
+ canModeBeCompact(mode) {
+ return Object.values(this._modes).some(
+ m => m.name == mode && m.active && m.canBeCompact
+ );
+ },
+
+ /**
+ * Are compact variants enabled?
+ *
+ * @type {boolean}
+ */
+ get isCompact() {
+ return this._isCompact;
+ },
+
+ set isCompact(value) {
+ if (this._isCompact == value) {
+ return;
+ }
+ this._isCompact = value;
+ for (let mode of Object.values(this._modes)) {
+ if (!mode.active || !mode.canBeCompact) {
+ continue;
+ }
+
+ mode.containerList.replaceChildren();
+ this._initMode(mode);
+ }
+ Services.xulStore.setValue(XULSTORE_URL, "folderTree", "compact", value);
+ },
+
+ /**
+ * Show or hide a folder tree mode.
+ *
+ * @param {string} modeName
+ * @param {boolean} active
+ */
+ _toggleMode(modeName, active) {
+ if (!(modeName in this._modes)) {
+ throw new Error(`Unknown folder tree mode: ${modeName}`);
+ }
+ let mode = this._modes[modeName];
+ if (mode.active == active) {
+ return;
+ }
+
+ if (!active) {
+ mode.container.remove();
+ delete mode.container;
+ mode.active = false;
+ return;
+ }
+
+ let container =
+ this._modeTemplate.content.firstElementChild.cloneNode(true);
+ container.dataset.mode = modeName;
+
+ mode.container = container;
+ mode.containerHeader = container.querySelector(".mode-container");
+ mode.containerHeader.querySelector(".mode-name").textContent =
+ messengerBundle.GetStringFromName(
+ modeName == "tags" ? "tag" : `folderPaneModeHeader_${modeName}`
+ );
+ mode.containerList = container.querySelector("ul");
+ this._initMode(mode);
+ mode.active = true;
+ container.querySelector(".mode-button").addEventListener("click", event => {
+ this.onFolderPaneModeContextOpening(event);
+ this.folderPaneModeContext.openPopup(event.target, {
+ triggerEvent: event,
+ });
+ });
+ },
+
+ /**
+ * Initialize a folder mode with all visible accounts.
+ *
+ * @param {object} mode - One of the folder modes from `folderPane._modes`.
+ */
+ _initMode(mode) {
+ if (typeof mode.init == "function") {
+ try {
+ mode.init();
+ } catch (e) {
+ console.warn(`Error intiating ${mode.name} mode.`, e);
+ if (typeof mode.regenerateMode != "function") {
+ return;
+ }
+ mode.containerList.replaceChildren();
+ mode.regenerateMode();
+ }
+ }
+ if (typeof mode.initServer != "function") {
+ return;
+ }
+
+ // `.accounts` is used here because it is ordered, `.allServers` isn't.
+ for (let account of MailServices.accounts.accounts) {
+ // Skip local folders if they're hidden.
+ if (
+ account.incomingServer.type == "none" &&
+ folderPane.hideLocalFolders
+ ) {
+ continue;
+ }
+ // Skip IM accounts.
+ if (account.incomingServer.type == "im") {
+ continue;
+ }
+ // Skip POP3 accounts that are deferred to another account.
+ if (
+ account.incomingServer instanceof Ci.nsIPop3IncomingServer &&
+ account.incomingServer.deferredToAccount
+ ) {
+ continue;
+ }
+ mode.initServer(account.incomingServer);
+ }
+ },
+
+ /**
+ * Create a FolderTreeRow representing a server.
+ *
+ * @param {string} modeName - The name of the mode this row belongs to.
+ * @param {nsIMsgIncomingServer} server - The server the row represents.
+ * @returns {FolderTreeRow}
+ */
+ _createServerRow(modeName, server) {
+ let row = document.createElement("li", { is: "folder-tree-row" });
+ row.modeName = modeName;
+ row.setServer(server);
+ return row;
+ },
+
+ /**
+ * Create a FolderTreeRow representing a folder.
+ *
+ * @param {string} modeName - The name of the mode this row belongs to.
+ * @param {nsIMsgFolder} folder - The folder the row represents.
+ * @param {"folder"|"server"|"both"} nameStyle
+ * @returns {FolderTreeRow}
+ */
+ _createFolderRow(modeName, folder, nameStyle) {
+ let row = document.createElement("li", { is: "folder-tree-row" });
+ row.modeName = modeName;
+ row.setFolder(folder, nameStyle);
+ return row;
+ },
+
+ /**
+ * Create a FolderTreeRow representing a virtual folder for a tag.
+ *
+ * @param {string} modeName - The name of the mode this row belongs to.
+ * @param {nsIMsgFolder} folder - The virtual folder the row represents.
+ * @param {nsIMsgTag} tag - The tag the virtual folder searches for.
+ * @returns {FolderTreeRow}
+ */
+ _createTagRow(modeName, folder, tag) {
+ let row = document.createElement("li", { is: "folder-tree-row" });
+ row.modeName = modeName;
+ row.setFolder(folder);
+ row.dataset.tagKey = tag.key;
+ row.icon.style.setProperty("--icon-color", tag.color);
+ return row;
+ },
+
+ /**
+ * Add a server row to the given list in the correct sort order.
+ *
+ * @param {HTMLUListElement} list
+ * @param {FolderTreeRow} serverRow
+ * @returns {FolderTreeRow}
+ */
+ _insertInServerOrder(list, serverRow) {
+ let serverKeys = MailServices.accounts.accounts.map(
+ a => a.incomingServer.key
+ );
+ let index = serverKeys.indexOf(serverRow.dataset.serverKey);
+ for (let row of list.children) {
+ let i = serverKeys.indexOf(row.dataset.serverKey);
+
+ if (i > index) {
+ return list.insertBefore(serverRow, row);
+ }
+ if (i < index) {
+ continue;
+ }
+
+ if (row.folderSortOrder > serverRow.folderSortOrder) {
+ return list.insertBefore(serverRow, row);
+ }
+ if (row.folderSortOrder < serverRow.folderSortOrder) {
+ continue;
+ }
+
+ if (FolderTreeRow.nameCollator.compare(row.name, serverRow.name) > 0) {
+ return list.insertBefore(serverRow, row);
+ }
+ }
+ return list.appendChild(serverRow);
+ },
+
+ _reapplyServerOrder(list) {
+ let selected = list.querySelector("li.selected");
+ let serverKeys = MailServices.accounts.accounts.map(
+ a => a.incomingServer.key
+ );
+ let serverRows = [...list.children];
+ serverRows.sort(
+ (a, b) =>
+ serverKeys.indexOf(a.dataset.serverKey) -
+ serverKeys.indexOf(b.dataset.serverKey)
+ );
+ list.replaceChildren(...serverRows);
+ if (selected) {
+ setTimeout(() => selected.classList.add("selected"));
+ }
+ },
+
+ /**
+ * Adds a row representing a folder and any missing rows for ancestors of
+ * the folder.
+ *
+ * @param {HTMLUListElement} containerList - The list to add folders to.
+ * @param {nsIMsgFolder} folder
+ * @param {string} modeName - The name of the mode this row belongs to.
+ * @returns {FolderTreeRow}
+ */
+ _addFolderAndAncestors(containerList, folder, modeName) {
+ let folderRow = folderPane.getRowForFolder(folder, modeName);
+ if (folderRow) {
+ return folderRow;
+ }
+
+ if (folder.isServer) {
+ let serverRow = folderPane._createServerRow(modeName, folder.server);
+ this._insertInServerOrder(containerList, serverRow);
+ return serverRow;
+ }
+
+ let parentRow = this._addFolderAndAncestors(
+ containerList,
+ folderPane._getNonGmailParent(folder),
+ modeName
+ );
+ folderRow = folderPane._createFolderRow(modeName, folder);
+ parentRow.insertChildInOrder(folderRow);
+ return folderRow;
+ },
+
+ /**
+ * @callback folderFilterCallback
+ * @param {FolderTreeRow} row
+ * @returns {boolean} - True if the folder should have a row in the tree.
+ */
+ /**
+ * Removes the row representing a folder and the rows for any ancestors of
+ * the folder, as long as they don't have other descendants or match
+ * `filterFunction`.
+ *
+ * @param {nsIMsgFolder} folder
+ * @param {string} modeName - The name of the mode this row belongs to.
+ * @param {folderFilterCallback} [filterFunction] - Optional callback to stop
+ * ascending.
+ * @param {boolean=false} childAlreadyGone - Is this function being called
+ * to remove the parent of a row that's already been removed?
+ */
+ _removeFolderAndAncestors(
+ folder,
+ modeName,
+ filterFunction,
+ childAlreadyGone = false
+ ) {
+ let folderRow = folderPane.getRowForFolder(folder, modeName);
+ if (folderPane._isCompact) {
+ folderRow?.remove();
+ return;
+ }
+
+ // If we get to a row for a folder that doesn't exist, or has children
+ // other than the one being removed, don't go any further.
+ if (
+ !folderRow ||
+ folderRow.childList.childElementCount > (childAlreadyGone ? 0 : 1)
+ ) {
+ return;
+ }
+
+ // Otherwise, move up the folder tree.
+ let parentFolder = folderPane._getNonGmailParent(folder);
+ if (
+ parentFolder &&
+ (typeof filterFunction != "function" || !filterFunction(parentFolder))
+ ) {
+ this._removeFolderAndAncestors(parentFolder, modeName, filterFunction);
+ }
+
+ // Remove the row for this folder.
+ folderRow.remove();
+ },
+
+ /**
+ * Add all subfolders to a row representing a folder. Called recursively,
+ * so all descendants are ultimately added.
+ *
+ * @param {nsIMsgFolder} parentFolder
+ * @param {FolderTreeRow} parentRow - The row representing `parentFolder`.
+ * @param {string} modeName - The name of the mode this row belongs to.
+ * @param {folderFilterCallback} [filterFunction] - Optional callback to add
+ * only some subfolders to the row.
+ */
+ _addSubFolders(parentFolder, parentRow, modeName, filterFunction) {
+ let subFolders;
+ try {
+ subFolders = parentFolder.subFolders;
+ } catch (ex) {
+ console.error(
+ new Error(`Unable to access the subfolders of ${parentFolder.URI}`, {
+ cause: ex,
+ })
+ );
+ }
+ if (!subFolders?.length) {
+ return;
+ }
+
+ for (let i = 0; i < subFolders.length; i++) {
+ let folder = subFolders[i];
+ if (this._isGmailFolder(folder)) {
+ subFolders.splice(i, 1, ...folder.subFolders);
+ }
+ }
+
+ subFolders.sort((a, b) => a.compareSortKeys(b));
+
+ for (let folder of subFolders) {
+ if (typeof filterFunction == "function" && !filterFunction(folder)) {
+ continue;
+ }
+ let folderRow = folderPane._createFolderRow(modeName, folder);
+ this._addSubFolders(folder, folderRow, modeName, filterFunction);
+ parentRow.childList.appendChild(folderRow);
+ }
+ },
+
+ /**
+ * Get the first row representing a folder, even if it is hidden.
+ *
+ * @param {nsIMsgFolder|string} folderOrURI - The folder to find, or its URI.
+ * @param {string?} modeName - If given, only look in the folders for this
+ * mode, otherwise look in the whole tree.
+ * @returns {FolderTreeRow}
+ */
+ getRowForFolder(folderOrURI, modeName) {
+ if (folderOrURI instanceof Ci.nsIMsgFolder) {
+ folderOrURI = folderOrURI.URI;
+ }
+
+ let modeNames = modeName ? [modeName] : this.activeModes;
+ for (let name of modeNames) {
+ let id = FolderTreeRow.makeRowID(name, folderOrURI);
+ // Look in the mode's container. The container may or may not be
+ // attached to the document at this point.
+ let row = this._modes[name].containerList.querySelector(
+ `#${CSS.escape(id)}`
+ );
+ if (row) {
+ return row;
+ }
+ }
+
+ return null;
+ },
+
+ /**
+ * Loop through all currently active modes and call the required function if
+ * it exists.
+ *
+ * @param {string} functionName - The name of the function to call.
+ * @param {...any} args - The list of arguments to pass to the function.
+ */
+ _forAllActiveModes(functionName, ...args) {
+ for (let mode of Object.values(this._modes)) {
+ if (!mode.active || typeof mode[functionName] != "function") {
+ continue;
+ }
+ try {
+ mode[functionName](...args);
+ } catch (ex) {
+ console.error(ex);
+ }
+ }
+ },
+
+ /**
+ * We deliberately hide the [Gmail] (or [Google Mail] in some cases) folder
+ * from the folder tree. This function determines if a folder is that folder.
+ *
+ * @param {nsIMsgFolder} folder
+ * @returns {boolean}
+ */
+ _isGmailFolder(folder) {
+ return (
+ folder?.parent?.isServer &&
+ folder.server instanceof Ci.nsIImapIncomingServer &&
+ folder.server.isGMailServer &&
+ folder.noSelect
+ );
+ },
+
+ /**
+ * If a folder is the [Gmail] folder, returns the parent folder, otherwise
+ * returns the given folder.
+ *
+ * @param {nsIMsgFolder} folder
+ * @returns {nsIMsgFolder}
+ */
+ _getNonGmailFolder(folder) {
+ return this._isGmailFolder(folder) ? folder.parent : folder;
+ },
+
+ /**
+ * Returns the parent folder of a given folder, or if that is the [Gmail]
+ * folder returns the grandparent of the given folder.
+ *
+ * @param {nsIMsgFolder} folder
+ * @returns {nsIMsgFolder}
+ */
+ _getNonGmailParent(folder) {
+ return this._getNonGmailFolder(folder.parent);
+ },
+
+ /**
+ * Update the folder pane UI and add rows for all newly created folders.
+ *
+ * @param {?nsIMsgFolder} parentFolder - The parent of the newly created
+ * folder.
+ * @param {nsIMsgFolder} childFolder - The newly created folder.
+ */
+ addFolder(parentFolder, childFolder) {
+ if (!parentFolder) {
+ // A server folder was added, so check if we need to update actions.
+ this.updateWidgets();
+ }
+
+ if (this._isGmailFolder(childFolder)) {
+ return;
+ }
+
+ parentFolder = this._getNonGmailFolder(parentFolder);
+ this._forAllActiveModes("addFolder", parentFolder, childFolder);
+ },
+
+ /**
+ * Update the folder pane UI and remove rows for all removed folders.
+ *
+ * @param {?nsIMsgFolder} parentFolder - The parent of the removed folder.
+ * @param {nsIMsgFolder} childFolder - The removed folder.
+ */
+ removeFolder(parentFolder, childFolder) {
+ if (!parentFolder) {
+ // A server folder was removed, so check if we need to update actions.
+ this.updateWidgets();
+ }
+
+ parentFolder = this._getNonGmailFolder(parentFolder);
+ this._forAllActiveModes("removeFolder", parentFolder, childFolder);
+ },
+
+ /**
+ * Update the list of folders if the current mode rely on specific flags.
+ *
+ * @param {nsIMsgFolder} item - The target folder.
+ * @param {nsMsgFolderFlags} oldValue - The old flag value.
+ * @param {nsMsgFolderFlags} newValue - The updated flag value.
+ */
+ changeFolderFlag(item, oldValue, newValue) {
+ this._forAllActiveModes("changeFolderFlag", item, oldValue, newValue);
+ this._changeRows(item, row => row.setFolderTypeFromFolder(item));
+ },
+
+ /**
+ * Update the list of folders to reflect current properties.
+ *
+ * @param {nsIMsgFolder} item - The folder whose data to use.
+ */
+ updateFolderProperties(item) {
+ this._forAllActiveModes("updateFolderProperties", item);
+ this._changeRows(item, row => row.setFolderPropertiesFromFolder(item));
+ },
+
+ /**
+ * @callback folderRowChangeCallback
+ * @param {FolderTreeRow} row
+ */
+ /**
+ * Perform a function on all rows representing a folder.
+ *
+ * @param {nsIMsgFolder|string} folderOrURI - The folder to change, or its URI.
+ * @param {folderRowChangeCallback} callback
+ */
+ _changeRows(folderOrURI, callback) {
+ if (folderOrURI instanceof Ci.nsIMsgFolder) {
+ folderOrURI = folderOrURI.URI;
+ }
+ for (let row of folderTree.querySelectorAll("li")) {
+ if (row.uri == folderOrURI) {
+ callback(row);
+ }
+ }
+ },
+
+ /**
+ * Get the folder from the URI by looping through the list of folders and
+ * finding a matching URI.
+ *
+ * @param {string} uri
+ * @returns {?FolderTreeRow}
+ */
+ getFolderFromUri(uri) {
+ for (let folder of folderTree.querySelectorAll("li")) {
+ if (folder.uri == uri) {
+ return folder;
+ }
+ }
+ return [...folderTree.querySelectorAll("li")]?.find(f => f.uri == uri);
+ },
+
+ /**
+ * Called when a folder's new messages state changes.
+ *
+ * @param {nsIMsgFolder} folder
+ * @param {boolean} hasNewMessages
+ */
+ changeNewMessages(folder, hasNewMessages) {
+ this._changeRows(folder, row => {
+ // Find the nearest visible ancestor and update it.
+ let collapsedAncestor = row.parentElement?.closest("li.collapsed");
+ while (collapsedAncestor) {
+ const next = collapsedAncestor.parentElement?.closest("li.collapsed");
+ if (!next) {
+ collapsedAncestor.updateNewMessages(hasNewMessages);
+ break;
+ }
+ collapsedAncestor = next;
+ }
+
+ // Update the row itself.
+ row.updateNewMessages(hasNewMessages);
+ });
+ },
+
+ /**
+ * Called when a folder's unread count changes, to update the UI.
+ *
+ * @param {nsIMsgFolder} folder
+ * @param {integer} newValue
+ */
+ changeUnreadCount(folder, newValue) {
+ this._changeRows(folder, row => {
+ // Find the nearest visible ancestor and update it.
+ let collapsedAncestor = row.parentElement?.closest("li.collapsed");
+ while (collapsedAncestor) {
+ const next = collapsedAncestor.parentElement?.closest("li.collapsed");
+ if (!next) {
+ collapsedAncestor.updateUnreadMessageCount();
+ break;
+ }
+ collapsedAncestor = next;
+ }
+
+ // Update the row itself.
+ row.updateUnreadMessageCount();
+ });
+
+ if (this._modes.unread.active && !folder.server.hidden) {
+ this._modes.unread.changeUnreadCount(folder, newValue);
+ }
+ },
+
+ /**
+ * Called when a folder's total count changes, to update the UI.
+ *
+ * @param {nsIMsgFolder} folder
+ * @param {integer} newValue
+ */
+ changeTotalCount(folder, newValue) {
+ this._changeRows(folder, row => {
+ // Find the nearest visible ancestor and update it.
+ let collapsedAncestor = row.parentElement?.closest("li.collapsed");
+ while (collapsedAncestor) {
+ const next = collapsedAncestor.parentElement?.closest("li.collapsed");
+ if (!next) {
+ collapsedAncestor.updateTotalMessageCount();
+ break;
+ }
+ collapsedAncestor = next;
+ }
+
+ // Update the row itself.
+ row.updateTotalMessageCount();
+ });
+ },
+
+ /**
+ * Called when a server's `prettyName` changes, to update the UI.
+ *
+ * @param {nsIMsgFolder} folder
+ * @param {string} name
+ */
+ changeServerName(folder, name) {
+ for (let row of folderTree.querySelectorAll(
+ `li[data-server-key="${folder.server.key}"]`
+ )) {
+ row.setServerName(name);
+ }
+ },
+
+ /**
+ * Update the UI widget to reflect the real folder size when the "FolderSize"
+ * property changes.
+ *
+ * @param {nsIMsgFolder} folder
+ */
+ changeFolderSize(folder) {
+ if (folderPane.isItemVisible("folderPaneFolderSize")) {
+ this._changeRows(folder, row => row.updateSizeCount(false, folder));
+ }
+ },
+
+ _onSelect(event) {
+ const isSynthetic = gViewWrapper?.isSynthetic;
+ threadPane.saveSelection();
+ threadPane.hideIgnoredMessageNotification();
+ if (!isSynthetic) {
+ // Don't clear the message pane for synthetic views, as a message may have
+ // already been selected in restoreState().
+ messagePane.clearAll();
+ }
+
+ let uri = folderTree.rows[folderTree.selectedIndex]?.uri;
+ if (!uri) {
+ gFolder = null;
+ return;
+ }
+ gFolder = MailServices.folderLookup.getFolderForURL(uri);
+
+ // Bail out if this is synthetic view, such as a gloda search.
+ if (isSynthetic) {
+ return;
+ }
+
+ document.head.querySelector(`link[rel="icon"]`).href =
+ FolderUtils.getFolderIcon(gFolder);
+
+ // Clean up any existing view wrapper. This will invalidate the thread tree.
+ gViewWrapper?.close();
+
+ if (gFolder.isServer) {
+ document.title = gFolder.server.prettyName;
+ gViewWrapper = gDBView = threadTree.view = null;
+
+ MailE10SUtils.loadURI(
+ accountCentralBrowser,
+ `chrome://messenger/content/msgAccountCentral.xhtml?folderURI=${encodeURIComponent(
+ gFolder.URI
+ )}`
+ );
+ document.body.classList.add("account-central");
+ accountCentralBrowser.hidden = false;
+ } else {
+ document.title = `${gFolder.name} - ${gFolder.server.prettyName}`;
+ document.body.classList.remove("account-central");
+ accountCentralBrowser.hidden = true;
+
+ quickFilterBar.activeElement = null;
+ threadPane.restoreColumns();
+
+ gViewWrapper = new DBViewWrapper(dbViewWrapperListener);
+
+ threadPane.scrollToNewMessage =
+ !(gFolder.flags & Ci.nsMsgFolderFlags.Virtual) &&
+ gFolder.hasNewMessages &&
+ Services.prefs.getBoolPref("mailnews.scroll_to_new_message");
+ if (threadPane.scrollToNewMessage) {
+ threadPane.forgetSelection(uri);
+ }
+
+ gViewWrapper.open(gFolder);
+
+ // At this point `dbViewWrapperListener.onCreatedView` gets called,
+ // setting up gDBView and scrolling threadTree to the right end.
+
+ threadPane.updateListRole(
+ !gViewWrapper?.showThreaded && !gViewWrapper?.showGroupedBySort
+ );
+ threadPane.restoreSortIndicator();
+ threadPaneHeader.onFolderSelected();
+ }
+
+ this._updateStatusQuota();
+
+ window.dispatchEvent(
+ new CustomEvent("folderURIChanged", { bubbles: true, detail: uri })
+ );
+ },
+
+ /**
+ * Update the quotaPanel to reflect current folder quota status.
+ */
+ _updateStatusQuota() {
+ if (top.window.document.getElementById("status-bar").hidden) {
+ return;
+ }
+ const quotaPanel = top.window.document.getElementById("quotaPanel");
+ if (!(gFolder && gFolder instanceof Ci.nsIMsgImapMailFolder)) {
+ quotaPanel.hidden = true;
+ return;
+ }
+
+ let tabListener = event => {
+ // Hide the pane if the new tab ain't us.
+ quotaPanel.hidden =
+ top.window.document.getElementById("tabmail").currentAbout3Pane ==
+ this.window;
+ };
+ top.window.document.removeEventListener("TabSelect", tabListener);
+
+ // For display on main window panel only include quota names containing
+ // "STORAGE" or "MESSAGE". This will exclude unusual quota names containing
+ // items like "MAILBOX" and "LEVEL" from the panel bargraph. All quota names
+ // will still appear on the folder properties quota window.
+ // Note: Quota name is typically something like "User Quota / STORAGE".
+ let folderQuota = gFolder
+ .getQuota()
+ .filter(
+ quota =>
+ quota.name.toUpperCase().includes("STORAGE") ||
+ quota.name.toUpperCase().includes("MESSAGE")
+ );
+ if (!folderQuota.length) {
+ quotaPanel.hidden = true;
+ return;
+ }
+ // If folderQuota not empty, find the index of the element with highest
+ // percent usage and determine if it is above the panel display threshold.
+ let quotaUsagePercentage = q =>
+ Number((100n * BigInt(q.usage)) / BigInt(q.limit));
+ let highest = folderQuota.reduce((acc, current) =>
+ quotaUsagePercentage(acc) > quotaUsagePercentage(current) ? acc : current
+ );
+ let percent = quotaUsagePercentage(highest);
+ if (
+ percent <
+ Services.prefs.getIntPref("mail.quota.mainwindow_threshold.show")
+ ) {
+ quotaPanel.hidden = true;
+ } else {
+ quotaPanel.hidden = false;
+ top.window.document.addEventListener("TabSelect", tabListener);
+
+ top.window.document
+ .getElementById("quotaMeter")
+ .setAttribute("value", percent);
+
+ let usage;
+ let limit;
+ if (/STORAGE/i.test(highest.name)) {
+ let messenger = Cc["@mozilla.org/messenger;1"].createInstance(
+ Ci.nsIMessenger
+ );
+ usage = messenger.formatFileSize(highest.usage * 1024);
+ limit = messenger.formatFileSize(highest.limit * 1024);
+ } else {
+ usage = highest.usage;
+ limit = highest.limit;
+ }
+
+ top.window.document.getElementById("quotaLabel").value = `${percent}%`;
+ top.window.document.l10n.setAttributes(
+ top.window.document.getElementById("quotaLabel"),
+ "quota-panel-percent-used",
+ { percent, usage, limit }
+ );
+ if (
+ percent <
+ Services.prefs.getIntPref("mail.quota.mainwindow_threshold.warning")
+ ) {
+ quotaPanel.classList.remove("alert-warning", "alert-critical");
+ } else if (
+ percent <
+ Services.prefs.getIntPref("mail.quota.mainwindow_threshold.critical")
+ ) {
+ quotaPanel.classList.remove("alert-critical");
+ quotaPanel.classList.add("alert-warning");
+ } else {
+ quotaPanel.classList.remove("alert-warning");
+ quotaPanel.classList.add("alert-critical");
+ }
+ }
+ },
+
+ _onMiddleClick(event) {
+ if (
+ event.target.closest(".mode-container") ||
+ folderTree.selectedIndex == -1
+ ) {
+ return;
+ }
+ const row = event.target.closest("li");
+ if (!row) {
+ return;
+ }
+
+ top.MsgOpenNewTabForFolders(
+ [MailServices.folderLookup.getFolderForURL(row.uri)],
+ {
+ event,
+ folderPaneVisible: !paneLayout.folderPaneSplitter.isCollapsed,
+ messagePaneVisible: !paneLayout.messagePaneSplitter.isCollapsed,
+ }
+ );
+ },
+
+ _onContextMenu(event) {
+ if (folderTree.selectedIndex == -1) {
+ return;
+ }
+
+ let popup = document.getElementById("folderPaneContext");
+
+ if (event.button == 2) {
+ // Mouse
+ if (event.target.closest(".mode-container")) {
+ return;
+ }
+ let row = event.target.closest("li");
+ if (!row) {
+ return;
+ }
+ if (row.uri != gFolder.URI) {
+ // The right-clicked-on folder is not `gFolder`. Tell the context menu
+ // to use it instead. This override lasts until the context menu fires
+ // a "popuphidden" event.
+ folderPaneContextMenu.setOverrideFolder(
+ MailServices.folderLookup.getFolderForURL(row.uri)
+ );
+ row.classList.add("context-menu-target");
+ }
+ popup.openPopupAtScreen(event.screenX, event.screenY, true);
+ } else {
+ // Keyboard
+ let row = folderTree.getRowAtIndex(folderTree.selectedIndex);
+ popup.openPopup(row, "after_end", 0, 0, true);
+ }
+
+ event.preventDefault();
+ },
+
+ _onCollapsed({ target }) {
+ if (target.uri) {
+ let mode = target.closest("[data-mode]").dataset.mode;
+ FolderTreeProperties.setIsExpanded(target.uri, mode, false);
+ }
+ target.updateUnreadMessageCount();
+ target.updateTotalMessageCount();
+ target.updateNewMessages();
+ },
+
+ _onExpanded({ target }) {
+ if (target.uri) {
+ let mode = target.closest("[data-mode]").dataset.mode;
+ FolderTreeProperties.setIsExpanded(target.uri, mode, true);
+ }
+
+ const updateRecursively = row => {
+ row.updateUnreadMessageCount();
+ row.updateTotalMessageCount();
+ row.updateNewMessages();
+ if (row.classList.contains("collapsed")) {
+ return;
+ }
+ for (const child of row.childList.children) {
+ updateRecursively(child);
+ }
+ };
+
+ updateRecursively(target);
+
+ // Get server type. IMAP is the only server type that does folder discovery.
+ let folder = MailServices.folderLookup.getFolderForURL(target.uri);
+ if (folder.server.type == "imap") {
+ if (folder.isServer) {
+ folder.server.performExpand(top.msgWindow);
+ } else {
+ folder.QueryInterface(Ci.nsIMsgImapMailFolder);
+ folder.performExpand(top.msgWindow);
+ }
+ }
+ },
+
+ _onDragStart(event) {
+ let row = event.target.closest(`li[is="folder-tree-row"]`);
+ if (!row) {
+ event.preventDefault();
+ return;
+ }
+
+ let folder = MailServices.folderLookup.getFolderForURL(row.uri);
+ if (!folder || folder.isServer) {
+ event.preventDefault();
+ return;
+ }
+ if (folder.server.type == "nntp") {
+ event.dataTransfer.mozSetDataAt("text/x-moz-newsfolder", folder, 0);
+ event.dataTransfer.effectAllowed = "move";
+ return;
+ }
+
+ event.dataTransfer.mozSetDataAt("text/x-moz-folder", folder, 0);
+ event.dataTransfer.effectAllowed = "copyMove";
+ },
+
+ _onDragOver(event) {
+ const copyKey =
+ AppConstants.platform == "macosx" ? event.altKey : event.ctrlKey;
+
+ event.dataTransfer.dropEffect = "none";
+ event.preventDefault();
+
+ let row = event.target.closest("li");
+ this._timedExpand(row);
+ if (!row) {
+ return;
+ }
+
+ let targetFolder = MailServices.folderLookup.getFolderForURL(row.uri);
+ if (!targetFolder) {
+ return;
+ }
+
+ let types = Array.from(event.dataTransfer.mozTypesAt(0));
+ if (types.includes("text/x-moz-message")) {
+ if (targetFolder.isServer || !targetFolder.canFileMessages) {
+ return;
+ }
+ for (let i = 0; i < event.dataTransfer.mozItemCount; i++) {
+ let msgHdr = top.messenger.msgHdrFromURI(
+ event.dataTransfer.mozGetDataAt("text/x-moz-message", i)
+ );
+ // Don't allow drop onto original folder.
+ if (msgHdr.folder == targetFolder) {
+ return;
+ }
+ }
+ event.dataTransfer.dropEffect = copyKey ? "copy" : "move";
+ } else if (types.includes("text/x-moz-folder")) {
+ // If cannot create subfolders then don't allow drop here.
+ if (!targetFolder.canCreateSubfolders) {
+ return;
+ }
+
+ let sourceFolder = event.dataTransfer
+ .mozGetDataAt("text/x-moz-folder", 0)
+ .QueryInterface(Ci.nsIMsgFolder);
+
+ // Don't allow to drop on itself.
+ if (targetFolder == sourceFolder) {
+ return;
+ }
+ // Don't copy within same server.
+ if (sourceFolder.server == targetFolder.server && copyKey) {
+ return;
+ }
+ // Don't allow immediate child to be dropped onto its parent.
+ if (targetFolder == sourceFolder.parent) {
+ return;
+ }
+ // Don't allow dragging of virtual folders across accounts.
+ if (
+ sourceFolder.getFlag(Ci.nsMsgFolderFlags.Virtual) &&
+ sourceFolder.server != targetFolder.server
+ ) {
+ return;
+ }
+ // Don't allow parent to be dropped on its ancestors.
+ if (sourceFolder.isAncestorOf(targetFolder)) {
+ return;
+ }
+ // If there is a folder that can't be renamed, don't allow it to be
+ // dropped if it is not to "Local Folders" or is to the same account.
+ if (
+ !sourceFolder.canRename &&
+ (targetFolder.server.type != "none" ||
+ sourceFolder.server == targetFolder.server)
+ ) {
+ return;
+ }
+ event.dataTransfer.dropEffect = copyKey ? "copy" : "move";
+ } else if (types.includes("application/x-moz-file")) {
+ if (targetFolder.isServer || !targetFolder.canFileMessages) {
+ return;
+ }
+ for (let i = 0; i < event.dataTransfer.mozItemCount; i++) {
+ let extFile = event.dataTransfer
+ .mozGetDataAt("application/x-moz-file", i)
+ .QueryInterface(Ci.nsIFile);
+ if (!extFile.isFile() || !/\.eml$/i.test(extFile.leafName)) {
+ return;
+ }
+ }
+ event.dataTransfer.dropEffect = "copy";
+ } else if (types.includes("text/x-moz-newsfolder")) {
+ let folder = event.dataTransfer
+ .mozGetDataAt("text/x-moz-newsfolder", 0)
+ .QueryInterface(Ci.nsIMsgFolder);
+ if (
+ targetFolder.isServer ||
+ targetFolder.server.type != "nntp" ||
+ folder == targetFolder ||
+ folder.server != targetFolder.server
+ ) {
+ return;
+ }
+ event.dataTransfer.dropEffect = "move";
+ } else if (
+ types.includes("text/x-moz-url-data") ||
+ types.includes("text/x-moz-url")
+ ) {
+ // Allow subscribing to feeds by dragging an url to a feed account.
+ if (
+ targetFolder.server.type == "rss" &&
+ !targetFolder.isSpecialFolder(Ci.nsMsgFolderFlags.Trash, true) &&
+ event.dataTransfer.items.length == 1 &&
+ FeedUtils.getFeedUriFromDataTransfer(event.dataTransfer)
+ ) {
+ return;
+ }
+ event.dataTransfer.dropEffect = "link";
+ } else {
+ return;
+ }
+
+ this._clearDropTarget();
+ row.classList.add("drop-target");
+ },
+
+ /**
+ * Set a timer to expand `row` in 500ms. If called again before the timer
+ * expires and with a different row, the timer is cleared and a new one
+ * started. If `row` is falsy or isn't collapsed the timer is cleared.
+ *
+ * @param {HTMLLIElement?} row
+ */
+ _timedExpand(row) {
+ if (this._expandRow == row) {
+ return;
+ }
+ if (this._expandTimer) {
+ clearTimeout(this._expandTimer);
+ }
+ if (!row?.classList.contains("collapsed")) {
+ return;
+ }
+ this._expandRow = row;
+ this._expandTimer = setTimeout(() => {
+ folderTree.expandRow(this._expandRow);
+ delete this._expandRow;
+ delete this._expandTimer;
+ }, 1000);
+ },
+
+ _clearDropTarget() {
+ folderTree.querySelector(".drop-target")?.classList.remove("drop-target");
+ },
+
+ _onDrop(event) {
+ this._timedExpand();
+ this._clearDropTarget();
+ if (event.dataTransfer.dropEffect == "none") {
+ // Somehow this is possible. It should not be possible.
+ return;
+ }
+
+ let row = event.target.closest("li");
+ if (!row) {
+ return;
+ }
+
+ let targetFolder = MailServices.folderLookup.getFolderForURL(row.uri);
+
+ let types = Array.from(event.dataTransfer.mozTypesAt(0));
+ if (types.includes("text/x-moz-message")) {
+ let array = [];
+ let sourceFolder;
+ for (let i = 0; i < event.dataTransfer.mozItemCount; i++) {
+ let msgHdr = top.messenger.msgHdrFromURI(
+ event.dataTransfer.mozGetDataAt("text/x-moz-message", i)
+ );
+ if (!i) {
+ sourceFolder = msgHdr.folder;
+ }
+ array.push(msgHdr);
+ }
+ let isMove = event.dataTransfer.dropEffect == "move";
+ let isNews = sourceFolder.flags & Ci.nsMsgFolderFlags.Newsgroup;
+ if (!sourceFolder.canDeleteMessages || isNews) {
+ isMove = false;
+ }
+
+ Services.prefs.setStringPref(
+ "mail.last_msg_movecopy_target_uri",
+ targetFolder.URI
+ );
+ Services.prefs.setBoolPref("mail.last_msg_movecopy_was_move", isMove);
+ // ### ugh, so this won't work with cross-folder views. We would
+ // really need to partition the messages by folder.
+ if (isMove) {
+ dbViewWrapperListener.threadPaneCommandUpdater.updateNextMessageAfterDelete();
+ }
+ MailServices.copy.copyMessages(
+ sourceFolder,
+ array,
+ targetFolder,
+ isMove,
+ null,
+ top.msgWindow,
+ true
+ );
+ } else if (types.includes("text/x-moz-folder")) {
+ let sourceFolder = event.dataTransfer
+ .mozGetDataAt("text/x-moz-folder", 0)
+ .QueryInterface(Ci.nsIMsgFolder);
+ let isMove = event.dataTransfer.dropEffect == "move";
+ isMove = folderPaneContextMenu.transferFolder(
+ isMove,
+ sourceFolder,
+ targetFolder
+ );
+ // Save in prefs the target folder URI and if this was a move or copy.
+ // This is to fill in the next folder or message context menu item
+ // "Move|Copy to <TargetFolderName> Again".
+ Services.prefs.setStringPref(
+ "mail.last_msg_movecopy_target_uri",
+ targetFolder.URI
+ );
+ Services.prefs.setBoolPref("mail.last_msg_movecopy_was_move", isMove);
+ } else if (types.includes("application/x-moz-file")) {
+ for (let i = 0; i < event.dataTransfer.mozItemCount; i++) {
+ let extFile = event.dataTransfer
+ .mozGetDataAt("application/x-moz-file", i)
+ .QueryInterface(Ci.nsIFile);
+ if (extFile.isFile() && /\.eml$/i.test(extFile.leafName)) {
+ MailServices.copy.copyFileMessage(
+ extFile,
+ targetFolder,
+ null,
+ false,
+ 1,
+ "",
+ null,
+ top.msgWindow
+ );
+ }
+ }
+ } else if (types.includes("text/x-moz-newsfolder")) {
+ let folder = event.dataTransfer
+ .mozGetDataAt("text/x-moz-newsfolder", 0)
+ .QueryInterface(Ci.nsIMsgFolder);
+
+ let mode = row.closest("li[data-mode]").dataset.mode;
+ let newsRoot = targetFolder.rootFolder.QueryInterface(
+ Ci.nsIMsgNewsFolder
+ );
+ newsRoot.reorderGroup(folder, targetFolder);
+ setTimeout(
+ () => (folderTree.selectedRow = this.getRowForFolder(folder, mode))
+ );
+ } else if (
+ types.includes("text/x-moz-url-data") ||
+ types.includes("text/x-moz-url")
+ ) {
+ // This is a potential rss feed. A link image as well as link text url
+ // should be handled; try to extract a url from non moz apps as well.
+ let feedURI = FeedUtils.getFeedUriFromDataTransfer(event.dataTransfer);
+ FeedUtils.subscribeToFeed(feedURI.spec, targetFolder);
+ }
+
+ event.preventDefault();
+ },
+
+ /**
+ * Opens the dialog to create a new sub-folder, and creates it if the user
+ * accepts.
+ *
+ * @param {?nsIMsgFolder} aParent - The parent for the new subfolder.
+ */
+ newFolder(aParent) {
+ let folder = aParent;
+
+ // Make sure we actually can create subfolders.
+ if (!folder?.canCreateSubfolders) {
+ // Check if we can create them at the root, otherwise use the default
+ // account as root folder.
+ let rootMsgFolder = folder.server.rootMsgFolder;
+ folder = rootMsgFolder.canCreateSubfolders
+ ? rootMsgFolder
+ : top.GetDefaultAccountRootFolder();
+ }
+
+ if (!folder) {
+ return;
+ }
+
+ let dualUseFolders = true;
+ if (folder.server instanceof Ci.nsIImapIncomingServer) {
+ dualUseFolders = folder.server.dualUseFolders;
+ }
+
+ function newFolderCallback(aName, aFolder) {
+ // createSubfolder can throw an exception, causing the newFolder dialog
+ // to not close and wait for another input.
+ // TODO: Rewrite this logic and also move the opening of alert dialogs from
+ // nsMsgLocalMailFolder::CreateSubfolderInternal to here (bug 831190#c16).
+ if (!aName) {
+ return;
+ }
+ aFolder.createSubfolder(aName, top.msgWindow);
+ // Don't call the rebuildAfterChange() here as we'll need to wait for the
+ // new folder to be properly created before rebuilding the tree.
+ }
+
+ window.openDialog(
+ "chrome://messenger/content/newFolderDialog.xhtml",
+ "",
+ "chrome,modal,resizable=no,centerscreen",
+ { folder, dualUseFolders, okCallback: newFolderCallback }
+ );
+ },
+
+ /**
+ * Opens the dialog to edit the properties for a folder
+ *
+ * @param {nsIMsgFolder} [folder] - Folder to edit, if not the selected one.
+ * @param {string} [tabID] - Id of initial tab to select in the folder
+ * properties dialog.
+ */
+ editFolder(folder = gFolder, tabID) {
+ // If this is actually a server, send it off to that controller
+ if (folder.isServer) {
+ top.MsgAccountManager(null, folder.server);
+ return;
+ }
+
+ if (folder.getFlag(Ci.nsMsgFolderFlags.Virtual)) {
+ this.editVirtualFolder(folder);
+ return;
+ }
+ let title = messengerBundle.GetStringFromName("folderProperties");
+
+ function editFolderCallback(newName, oldName) {
+ if (newName != oldName) {
+ folder.rename(newName, top.msgWindow);
+ }
+ }
+
+ async function rebuildSummary() {
+ if (folder.locked) {
+ folder.throwAlertMsg("operationFailedFolderBusy", top.msgWindow);
+ return;
+ }
+ if (folder.supportsOffline) {
+ // Remove the offline store, if any.
+ await IOUtils.remove(folder.filePath.path, { recursive: true }).catch(
+ console.error
+ );
+ }
+
+ // We may be rebuilding a folder that is not the displayed one.
+ // TODO: Close any open views of this folder.
+
+ // Send a notification that we are triggering a database rebuild.
+ MailServices.mfn.notifyFolderReindexTriggered(folder);
+
+ folder.msgDatabase.summaryValid = false;
+
+ const msgDB = folder.msgDatabase;
+ msgDB.summaryValid = false;
+ try {
+ folder.closeAndBackupFolderDB("");
+ } catch (e) {
+ // In a failure, proceed anyway since we're dealing with problems
+ folder.ForceDBClosed();
+ }
+ if (gFolder == folder) {
+ gViewWrapper?.close();
+ folder.updateFolder(top.msgWindow);
+ folderTree.dispatchEvent(new CustomEvent("select"));
+ } else {
+ folder.updateFolder(top.msgWindow);
+ }
+ }
+
+ window.openDialog(
+ "chrome://messenger/content/folderProps.xhtml",
+ "",
+ "chrome,modal,centerscreen",
+ {
+ folder,
+ serverType: folder.server.type,
+ msgWindow: top.msgWindow,
+ title,
+ okCallback: editFolderCallback,
+ tabID,
+ name: folder.prettyName,
+ rebuildSummaryCallback: rebuildSummary,
+ }
+ );
+ },
+
+ /**
+ * Opens the dialog to rename a particular folder, and does the renaming if
+ * the user clicks OK in that dialog
+ *
+ * @param [aFolder] - The folder to rename, if different than the currently
+ * selected one.
+ */
+ renameFolder(aFolder) {
+ let folder = aFolder;
+
+ function renameCallback(aName, aUri) {
+ if (aUri != folder.URI) {
+ console.error("got back a different folder to rename!");
+ }
+
+ // Actually do the rename.
+ folder.rename(aName, top.msgWindow);
+ }
+ window.openDialog(
+ "chrome://messenger/content/renameFolderDialog.xhtml",
+ "",
+ "chrome,modal,centerscreen",
+ {
+ preselectedURI: folder.URI,
+ okCallback: renameCallback,
+ name: folder.prettyName,
+ }
+ );
+ },
+
+ /**
+ * Deletes a folder from its parent. Also handles unsubscribe from newsgroups
+ * if the selected folder/s happen to be nntp.
+ *
+ * @param [folder] - The folder to delete, if not the selected one.
+ */
+ deleteFolder(folder) {
+ // For newsgroups, "delete" means "unsubscribe".
+ if (
+ folder.server.type == "nntp" &&
+ !folder.getFlag(Ci.nsMsgFolderFlags.Virtual)
+ ) {
+ top.MsgUnsubscribe([folder]);
+ return;
+ }
+
+ const canDelete = folder.isSpecialFolder(Ci.nsMsgFolderFlags.Junk, false)
+ ? FolderUtils.canRenameDeleteJunkMail(folder.URI)
+ : folder.deletable;
+
+ if (!canDelete) {
+ throw new Error("Can't delete folder: " + folder.name);
+ }
+
+ if (folder.getFlag(Ci.nsMsgFolderFlags.Virtual)) {
+ let confirmation = messengerBundle.GetStringFromName(
+ "confirmSavedSearchDeleteMessage"
+ );
+ let title = messengerBundle.GetStringFromName("confirmSavedSearchTitle");
+ if (
+ Services.prompt.confirmEx(
+ window,
+ title,
+ confirmation,
+ Services.prompt.STD_YES_NO_BUTTONS +
+ Services.prompt.BUTTON_POS_1_DEFAULT,
+ "",
+ "",
+ "",
+ "",
+ {}
+ ) != 0
+ ) {
+ /* the yes button is in position 0 */
+ return;
+ }
+ }
+
+ try {
+ folder.deleteSelf(top.msgWindow);
+ } catch (ex) {
+ // Ignore known errors from canceled warning dialogs.
+ const NS_MSG_ERROR_COPY_FOLDER_ABORTED = 0x8055001a;
+ if (ex.result != NS_MSG_ERROR_COPY_FOLDER_ABORTED) {
+ throw ex;
+ }
+ }
+ },
+
+ /**
+ * Prompts the user to confirm and empties the trash for the selected folder.
+ * The folder and its children are only emptied if it has the proper Trash flag.
+ *
+ * @param [aFolder] - The trash folder to empty. If unspecified or not a trash
+ * folder, the currently selected server's trash folder is used.
+ */
+ emptyTrash(aFolder) {
+ let folder = aFolder;
+ if (!folder.getFlag(Ci.nsMsgFolderFlags.Trash)) {
+ folder = folder.rootFolder.getFolderWithFlags(Ci.nsMsgFolderFlags.Trash);
+ }
+ if (!folder) {
+ return;
+ }
+
+ if (!this._checkConfirmationPrompt("emptyTrash", folder)) {
+ return;
+ }
+
+ // Check if this is a top-level smart folder. If so, we're going
+ // to empty all the trash folders.
+ if (FolderUtils.isSmartVirtualFolder(folder)) {
+ for (let server of MailServices.accounts.allServers) {
+ for (let trash of server.rootFolder.getFoldersWithFlags(
+ Ci.nsMsgFolderFlags.Trash
+ )) {
+ trash.emptyTrash(null);
+ }
+ }
+ } else {
+ folder.emptyTrash(null);
+ }
+ },
+
+ /**
+ * Deletes everything (folders and messages) in the selected folder.
+ * The folder is only emptied if it has the proper Junk flag.
+ *
+ * @param {nsIMsgFolder} folder - The folder to empty.
+ * @param {boolean} [prompt=true] - If the user should be prompted.
+ */
+ emptyJunk(folder, prompt = true) {
+ if (!folder || !folder.getFlag(Ci.nsMsgFolderFlags.Junk)) {
+ return;
+ }
+
+ if (prompt && !this._checkConfirmationPrompt("emptyJunk", folder)) {
+ return;
+ }
+
+ if (FolderUtils.isSmartVirtualFolder(folder)) {
+ // This is the unified junk folder.
+ let wrappedFolder = VirtualFolderHelper.wrapVirtualFolder(folder);
+ for (let searchFolder of wrappedFolder.searchFolders) {
+ this.emptyJunk(searchFolder, false);
+ }
+ return;
+ }
+
+ // Delete any subfolders this folder might have
+ for (let subFolder of folder.subFolders) {
+ folder.propagateDelete(subFolder, true);
+ }
+
+ let messages = [...folder.messages];
+ if (!messages.length) {
+ return;
+ }
+
+ // Now delete the messages
+ folder.deleteMessages(messages, top.msgWindow, true, false, null, false);
+ },
+
+ /**
+ * Compacts the given folder.
+ *
+ * @param {nsIMsgFolder} folder
+ */
+ compactFolder(folder) {
+ // Can't compact folders that have just been compacted.
+ if (folder.server.type != "imap" && !folder.expungedBytes) {
+ return;
+ }
+
+ folder.compact(null, top.msgWindow);
+ },
+
+ /**
+ * Compacts all folders for the account that the given folder belongs to.
+ *
+ * @param {nsIMsgFolder} folder
+ */
+ compactAllFoldersForAccount(folder) {
+ folder.rootFolder.compactAll(null, top.msgWindow);
+ },
+
+ /**
+ * Opens the dialog to create a new virtual folder
+ *
+ * @param aName - The default name for the new folder.
+ * @param aSearchTerms - The search terms associated with the folder.
+ * @param aParent - The folder to run the search terms on.
+ */
+ newVirtualFolder(aName, aSearchTerms, aParent) {
+ let folder = aParent || top.GetDefaultAccountRootFolder();
+ if (!folder) {
+ return;
+ }
+
+ let name = folder.prettyName;
+ if (aName) {
+ name += "-" + aName;
+ }
+
+ window.openDialog(
+ "chrome://messenger/content/virtualFolderProperties.xhtml",
+ "",
+ "chrome,modal,centerscreen,resizable=yes",
+ {
+ folder,
+ searchTerms: aSearchTerms,
+ newFolderName: name,
+ }
+ );
+ },
+
+ editVirtualFolder(aFolder) {
+ let folder = aFolder;
+
+ function editVirtualCallback() {
+ if (gFolder == folder) {
+ folderTree.dispatchEvent(new CustomEvent("select"));
+ }
+ }
+ window.openDialog(
+ "chrome://messenger/content/virtualFolderProperties.xhtml",
+ "",
+ "chrome,modal,centerscreen,resizable=yes",
+ {
+ folder,
+ editExistingFolder: true,
+ onOKCallback: editVirtualCallback,
+ msgWindow: top.msgWindow,
+ }
+ );
+ },
+
+ /**
+ * Prompts for confirmation, if the user hasn't already chosen the "don't ask
+ * again" option.
+ *
+ * @param aCommand - The command to prompt for.
+ * @param aFolder - The folder for which the confirmation is requested.
+ */
+ _checkConfirmationPrompt(aCommand, aFolder) {
+ // If no folder was specified, reject the operation.
+ if (!aFolder) {
+ return false;
+ }
+
+ let showPrompt = !Services.prefs.getBoolPref(
+ "mailnews." + aCommand + ".dontAskAgain",
+ false
+ );
+
+ if (showPrompt) {
+ let checkbox = { value: false };
+ let title = messengerBundle.formatStringFromName(
+ aCommand + "FolderTitle",
+ [aFolder.prettyName]
+ );
+ let msg = messengerBundle.GetStringFromName(aCommand + "FolderMessage");
+ let ok =
+ Services.prompt.confirmEx(
+ window,
+ title,
+ msg,
+ Services.prompt.STD_YES_NO_BUTTONS,
+ null,
+ null,
+ null,
+ messengerBundle.GetStringFromName(aCommand + "DontAsk"),
+ checkbox
+ ) == 0;
+ if (checkbox.value) {
+ Services.prefs.setBoolPref(
+ "mailnews." + aCommand + ".dontAskAgain",
+ true
+ );
+ }
+ if (!ok) {
+ return false;
+ }
+ }
+ return true;
+ },
+
+ /**
+ * Update those UI elements that rely on the presence of a server to function.
+ */
+ updateWidgets() {
+ this._updateGetMessagesWidgets();
+ this._updateWriteMessageWidgets();
+ },
+
+ _updateGetMessagesWidgets() {
+ const canGetMessages = MailServices.accounts.allServers.some(
+ s => s.type != "none"
+ );
+ document.getElementById("folderPaneGetMessages").disabled = !canGetMessages;
+ },
+
+ _updateWriteMessageWidgets() {
+ const canWriteMessages = MailServices.accounts.allIdentities.length;
+ document.getElementById("folderPaneWriteMessage").disabled =
+ !canWriteMessages;
+ },
+
+ isFolderPaneGetMsgsBtnHidden() {
+ return this.isItemHidden("folderPaneGetMessages");
+ },
+
+ isFolderPaneNewMsgBtnHidden() {
+ return this.isItemHidden("folderPaneWriteMessage");
+ },
+
+ isFolderPaneHeaderHidden() {
+ return this.isItemHidden("folderPaneHeaderBar");
+ },
+
+ isItemHidden(item) {
+ return Services.xulStore.getValue(XULSTORE_URL, item, "hidden") == "true";
+ },
+
+ isItemVisible(item) {
+ return Services.xulStore.getValue(XULSTORE_URL, item, "visible") == "true";
+ },
+
+ /**
+ * Ensure the pane header context menu items are correctly checked.
+ */
+ updateContextMenuCheckedItems() {
+ for (let item of document.querySelectorAll(".folder-pane-option")) {
+ switch (item.id) {
+ case "folderPaneHeaderToggleGetMessages":
+ this.isFolderPaneGetMsgsBtnHidden()
+ ? item.removeAttribute("checked")
+ : item.setAttribute("checked", true);
+ break;
+ case "folderPaneHeaderToggleNewMessage":
+ this.isFolderPaneNewMsgBtnHidden()
+ ? item.removeAttribute("checked")
+ : item.setAttribute("checked", true);
+ break;
+ case "folderPaneHeaderToggleTotalCount":
+ this.isTotalMsgCountVisible()
+ ? item.setAttribute("checked", true)
+ : item.removeAttribute("checked");
+ break;
+ case "folderPaneMoreContextCompactToggle":
+ this.isCompact
+ ? item.setAttribute("checked", true)
+ : item.removeAttribute("checked");
+ this.toggleCompactViewMenuItem();
+ break;
+ case "folderPaneHeaderToggleFolderSize":
+ this.isItemVisible("folderPaneFolderSize")
+ ? item.setAttribute("checked", true)
+ : item.removeAttribute("checked");
+ break;
+ case "folderPaneHeaderToggleLocalFolders":
+ this.isItemHidden("folderPaneLocalFolders")
+ ? item.setAttribute("checked", true)
+ : item.removeAttribute("checked");
+ break;
+ default:
+ item.removeAttribute("checked");
+ break;
+ }
+ }
+ },
+
+ toggleGetMsgsBtn(event) {
+ let show = event.target.hasAttribute("checked");
+ document.getElementById("folderPaneGetMessages").hidden = !show;
+
+ this.updateXULStoreAttribute("folderPaneGetMessages", "hidden", show);
+ },
+
+ toggleNewMsgBtn(event) {
+ let show = event.target.hasAttribute("checked");
+ document.getElementById("folderPaneWriteMessage").hidden = !show;
+
+ this.updateXULStoreAttribute("folderPaneWriteMessage", "hidden", show);
+ },
+
+ toggleHeader(show) {
+ document.getElementById("folderPaneHeaderBar").hidden = !show;
+ this.updateXULStoreAttribute("folderPaneHeaderBar", "hidden", show);
+ },
+
+ updateXULStoreAttribute(element, attribute, value) {
+ Services.xulStore.setValue(
+ XULSTORE_URL,
+ element,
+ attribute,
+ value ? "false" : "true"
+ );
+ },
+
+ /**
+ * Ensure the folder rows UI elements reflect the state set by the user.
+ */
+ updateFolderRowUIElements() {
+ this.toggleTotalCountBadge();
+ this.toggleFolderSizes(this.isItemVisible("folderPaneFolderSize"));
+ },
+
+ /**
+ * Check XULStore to see if the total message count badges should be hidden.
+ */
+ isTotalMsgCountVisible() {
+ return this.isItemVisible("totalMsgCount");
+ },
+
+ /**
+ * Toggle the total message count badges and update the XULStore.
+ */
+ toggleTotal(event) {
+ let show = !event.target.hasAttribute("checked");
+ this.updateXULStoreAttribute("totalMsgCount", "visible", show);
+ this.toggleTotalCountBadge();
+ },
+
+ toggleTotalCountBadge() {
+ const isHidden = !this.isTotalMsgCountVisible();
+ for (let row of document.querySelectorAll(`li[is="folder-tree-row"]`)) {
+ row.toggleTotalCountBadgeVisibility(isHidden);
+ }
+ },
+
+ /**
+ * Toggle the folder size option and update the XULStore.
+ */
+ toggleFolderSize(event) {
+ let show = !event.target.hasAttribute("checked");
+ this.updateXULStoreAttribute("folderPaneFolderSize", "visible", show);
+ this.toggleFolderSizes(!show);
+ },
+
+ /**
+ * Toggle the folder size info on each folder.
+ */
+ toggleFolderSizes(visible) {
+ const isHidden = !visible;
+ for (let row of document.querySelectorAll(`li[is="folder-tree-row"]`)) {
+ row.updateSizeCount(isHidden);
+ }
+ },
+
+ /**
+ * Toggle the hiding of the local folders and update the XULStore.
+ */
+ toggleLocalFolders(event) {
+ let isHidden = event.target.hasAttribute("checked");
+ this.updateXULStoreAttribute("folderPaneLocalFolders", "hidden", !isHidden);
+ folderPane.hideLocalFolders = isHidden;
+ },
+
+ /**
+ * Populate the "Get Messages" context menu with all available servers that
+ * we can fetch data for.
+ */
+ updateGetMessagesContextMenu() {
+ const menupopup = document.getElementById("folderPaneGetMessagesContext");
+ while (menupopup.lastElementChild.classList.contains("server")) {
+ menupopup.lastElementChild.remove();
+ }
+
+ // Get all servers in the proper sorted order.
+ const servers = FolderUtils.allAccountsSorted(true)
+ .map(a => a.incomingServer)
+ .filter(s => s.rootFolder.isServer && s.type != "none");
+ for (let server of servers) {
+ const menuitem = document.createXULElement("menuitem");
+ menuitem.classList.add("menuitem-iconic", "server");
+ menuitem.dataset.serverType = server.type;
+ menuitem.dataset.serverSecure = server.isSecure;
+ menuitem.label = server.prettyName;
+ menuitem.addEventListener("command", () =>
+ top.MsgGetMessagesForAccount(server.rootFolder)
+ );
+ menupopup.appendChild(menuitem);
+ }
+ },
+};
+
+/**
+ * Represents a single row in the folder tree. The row can be for a server or
+ * a folder. Use `folderPane._createServerRow` or `folderPane._createFolderRow`
+ * to create rows.
+ */
+class FolderTreeRow extends HTMLLIElement {
+ /**
+ * Used for comparing folder names. This matches the collator used in
+ * `nsMsgDBFolder::createCollationKeyGenerator`.
+ * @type {Intl.Collator}
+ */
+ static nameCollator = new Intl.Collator(undefined, { sensitivity: "base" });
+
+ /**
+ * Creates an identifier unique for the given mode name and folder URI.
+ *
+ * @param {string} modeName
+ * @param {string} uri
+ * @returns {string}
+ */
+ static makeRowID(modeName, uri) {
+ return `${modeName}-${btoa(MailStringUtils.stringToByteString(uri))}`;
+ }
+
+ /**
+ * The name of the folder tree mode this row belongs to.
+ * @type {string}
+ */
+ modeName;
+ /**
+ * The URI of the folder represented by this row.
+ * @type {string}
+ */
+ uri;
+ /**
+ * How many times this row is nested. 1 or greater.
+ * @type {integer}
+ */
+ depth;
+ /**
+ * The sort order of this row's associated folder.
+ * @type {integer}
+ */
+ folderSortOrder;
+
+ /** @type {HTMLSpanElement} */
+ nameLabel;
+ /** @type {HTMLImageElement} */
+ icon;
+ /** @type {HTMLSpanElement} */
+ unreadCountLabel;
+ /** @type {HTMLUListElement} */
+ totalCountLabel;
+ /** @type {HTMLSpanElement} */
+ folderSizeLabel;
+ /** @type {HTMLUListElement} */
+ childList;
+
+ constructor() {
+ super();
+ this.setAttribute("is", "folder-tree-row");
+ this.append(folderPane._folderTemplate.content.cloneNode(true));
+ this.nameLabel = this.querySelector(".name");
+ this.icon = this.querySelector(".icon");
+ this.unreadCountLabel = this.querySelector(".unread-count");
+ this.totalCountLabel = this.querySelector(".total-count");
+ this.folderSizeLabel = this.querySelector(".folder-size");
+ this.childList = this.querySelector("ul");
+ }
+
+ connectedCallback() {
+ // Set the correct CSS `--depth` variable based on where this row was
+ // inserted into the tree.
+ let parent = this.parentNode.closest(`li[is="folder-tree-row"]`);
+ this.depth = parent ? parent.depth + 1 : 1;
+ this.childList.style.setProperty("--depth", this.depth);
+ }
+
+ /**
+ * The name to display for this folder or server.
+ *
+ * @type {string}
+ */
+ get name() {
+ return this.nameLabel.textContent;
+ }
+
+ set name(value) {
+ if (this.name != value) {
+ this.nameLabel.textContent = value;
+ this.#updateAriaLabel();
+ }
+ }
+
+ /**
+ * Format and set the name label of this row.
+ */
+ _setName() {
+ switch (this._nameStyle) {
+ case "server":
+ this.name = this._serverName;
+ break;
+ case "folder":
+ this.name = this._folderName;
+ break;
+ case "both":
+ this.name = `${this._folderName} - ${this._serverName}`;
+ break;
+ }
+ }
+
+ /**
+ * The number of unread messages for this folder.
+ *
+ * @type {integer}
+ */
+ get unreadCount() {
+ return parseInt(this.unreadCountLabel.textContent, 10) || 0;
+ }
+
+ set unreadCount(value) {
+ this.classList.toggle("unread", value > 0);
+ // Avoid setting `textContent` if possible, each change notifies the
+ // MutationObserver on `folderTree`, and there could be *many* changes.
+ let textNode = this.unreadCountLabel.firstChild;
+ if (textNode) {
+ textNode.nodeValue = value;
+ } else {
+ this.unreadCountLabel.textContent = value;
+ }
+ this.#updateAriaLabel();
+ }
+
+ /**
+ * The total number of messages for this folder.
+ *
+ * @type {integer}
+ */
+ get totalCount() {
+ return parseInt(this.totalCountLabel.textContent, 10) || 0;
+ }
+
+ set totalCount(value) {
+ this.classList.toggle("total", value > 0);
+ this.totalCountLabel.textContent = value;
+ this.#updateAriaLabel();
+ }
+
+ /**
+ * The folder size for this folder.
+ *
+ * @type {integer}
+ */
+ get folderSize() {
+ return this.folderSizeLabel.textContent;
+ }
+
+ set folderSize(value) {
+ this.folderSizeLabel.textContent = value;
+ this.#updateAriaLabel();
+ }
+
+ #updateAriaLabel() {
+ // Collect the various strings and fluent IDs to build the full string for
+ // the folder aria-label.
+ let ariaLabelPromises = [];
+ ariaLabelPromises.push(this.name);
+
+ // If unread messages.
+ const count = this.unreadCount;
+ if (count > 0) {
+ ariaLabelPromises.push(
+ document.l10n.formatValue("folder-pane-unread-aria-label", { count })
+ );
+ }
+
+ // If total messages is visible.
+ if (folderPane.isTotalMsgCountVisible()) {
+ ariaLabelPromises.push(
+ document.l10n.formatValue("folder-pane-total-aria-label", {
+ count: this.totalCount,
+ })
+ );
+ }
+
+ if (folderPane.isItemVisible("folderPaneFolderSize")) {
+ ariaLabelPromises.push(this.folderSize);
+ }
+
+ Promise.allSettled(ariaLabelPromises).then(results => {
+ const folderLabel = results
+ .map(settledPromise => settledPromise.value ?? "")
+ .filter(value => value.trim() != "")
+ .join(", ");
+ this.setAttribute("aria-label", folderLabel);
+ this.title = folderLabel;
+ });
+ }
+
+ /**
+ * Set some common properties based on the URI for this row.
+ * `this.modeName` must be set before calling this function.
+ *
+ * @param {string} uri
+ */
+ _setURI(uri) {
+ this.id = FolderTreeRow.makeRowID(this.modeName, uri);
+ this.uri = uri;
+ if (!FolderTreeProperties.getIsExpanded(uri, this.modeName)) {
+ this.classList.add("collapsed");
+ }
+ this.setIconColor();
+ }
+
+ /**
+ * Set the icon color to the given color, or if none is given the value from
+ * FolderTreeProperties, or the default.
+ *
+ * @param {string?} iconColor
+ */
+ setIconColor(iconColor) {
+ if (!iconColor) {
+ iconColor = FolderTreeProperties.getColor(this.uri);
+ }
+ this.icon.style.setProperty("--icon-color", iconColor ?? "");
+ }
+
+ /**
+ * Set some properties based on the server for this row.
+ *
+ * @param {nsIMsgIncomingServer} server
+ */
+ setServer(server) {
+ this._setURI(server.rootFolder.URI);
+ this.dataset.serverKey = server.key;
+ this.dataset.serverType = server.type;
+ this.dataset.serverSecure = server.isSecure;
+ this._nameStyle = "server";
+ this._serverName = server.prettyName;
+ this._setName();
+ const isCollapsed = this.classList.contains("collapsed");
+ if (isCollapsed) {
+ this.unreadCount = server.rootFolder.getNumUnread(isCollapsed);
+ this.totalCount = server.rootFolder.getTotalMessages(isCollapsed);
+ }
+ this.setFolderPropertiesFromFolder(server.rootFolder);
+ }
+
+ /**
+ * Set some properties based on the folder for this row.
+ *
+ * @param {nsIMsgFolder} folder
+ * @param {"folder"|"server"|"both"} nameStyle
+ */
+ setFolder(folder, nameStyle = "folder") {
+ this._setURI(folder.URI);
+ this.dataset.serverKey = folder.server.key;
+ this.setFolderTypeFromFolder(folder);
+ this.setFolderPropertiesFromFolder(folder);
+ this._nameStyle = nameStyle;
+ this._serverName = folder.server.prettyName;
+ this._folderName = folder.abbreviatedName;
+ this._setName();
+ const isCollapsed = this.classList.contains("collapsed");
+ this.unreadCount = folder.getNumUnread(isCollapsed);
+ this.totalCount = folder.getTotalMessages(isCollapsed);
+ if (folderPane.isItemVisible("folderPaneFolderSize")) {
+ this.folderSize = this.formatFolderSize(folder.sizeOnDisk);
+ }
+ this.folderSortOrder = folder.sortOrder;
+ if (folder.noSelect) {
+ this.classList.add("noselect-folder");
+ } else {
+ this.setAttribute("draggable", "true");
+ }
+ }
+
+ /**
+ * Update new message state of the row.
+ *
+ * @param {boolean} [notifiedOfNewMessages=false] - When true there are new
+ * messages on the server, but they may not yet be downloaded locally.
+ */
+ updateNewMessages(notifiedOfNewMessages = false) {
+ const folder = MailServices.folderLookup.getFolderForURL(this.uri);
+ const foldersHaveNewMessages = this.classList.contains("collapsed")
+ ? folder.hasFolderOrSubfolderNewMessages
+ : folder.hasNewMessages;
+ this.classList.toggle(
+ "new-messages",
+ notifiedOfNewMessages || foldersHaveNewMessages
+ );
+ }
+
+ updateUnreadMessageCount() {
+ this.unreadCount = MailServices.folderLookup
+ .getFolderForURL(this.uri)
+ .getNumUnread(this.classList.contains("collapsed"));
+ }
+
+ updateTotalMessageCount() {
+ const folder = MailServices.folderLookup.getFolderForURL(this.uri);
+ this.totalCount = folder.getTotalMessages(
+ this.classList.contains("collapsed")
+ );
+ if (folderPane.isItemVisible("folderPaneFolderSize")) {
+ this.updateSizeCount(false, folder);
+ }
+ }
+
+ updateSizeCount(isHidden, folder = null) {
+ this.folderSizeLabel.hidden = isHidden;
+ if (!isHidden) {
+ folder = folder ?? MailServices.folderLookup.getFolderForURL(this.uri);
+ this.folderSize = this.formatFolderSize(folder.sizeOnDisk);
+ }
+ }
+
+ /**
+ * Format the folder file size to display in the folder pane.
+ *
+ * @param {integer} size - The folder size on disk.
+ * @returns {string} - The formatted folder size.
+ */
+ formatFolderSize(size) {
+ return size / 1024 < 1 ? "" : top.messenger.formatFileSize(size, true);
+ }
+
+ /**
+ * Update the visibility of the total count badge.
+ *
+ * @param {boolean} isHidden
+ */
+ toggleTotalCountBadgeVisibility(isHidden) {
+ this.totalCountLabel.hidden = isHidden;
+ this.#updateAriaLabel();
+ }
+
+ /**
+ * Sets the folder type property based on the folder for the row.
+ *
+ * @param {nsIMsgFolder} folder
+ */
+ setFolderTypeFromFolder(folder) {
+ let folderType = FolderUtils.getSpecialFolderString(folder);
+ if (folderType != "none") {
+ this.dataset.folderType = folderType.toLowerCase();
+ }
+ }
+
+ /**
+ * Sets folder properties based on the folder for the row.
+ *
+ * @param {nsIMsgFolder} folder
+ */
+ setFolderPropertiesFromFolder(folder) {
+ if (folder.server.type != "rss") {
+ return;
+ }
+ let urls = !folder.isServer ? FeedUtils.getFeedUrlsInFolder(folder) : null;
+ if (urls?.length == 1) {
+ let url = urls[0];
+ this.icon.style = `content: url("page-icon:${url}"); background-image: none;`;
+ }
+ let props = FeedUtils.getFolderProperties(folder);
+ for (let name of ["hasError", "isBusy", "isPaused"]) {
+ if (props.includes(name)) {
+ this.dataset[name] = "true";
+ } else {
+ delete this.dataset[name];
+ }
+ }
+ }
+
+ /**
+ * Update this row's name label to match the new `prettyName` of the server.
+ *
+ * @param {string} name
+ */
+ setServerName(name) {
+ this._serverName = name;
+ if (this._nameStyle != "folder") {
+ this._setName();
+ }
+ }
+
+ /**
+ * Add a child row in the correct sort order.
+ *
+ * @param {FolderTreeRow} newChild
+ * @returns {FolderTreeRow}
+ */
+ insertChildInOrder(newChild) {
+ let { folderSortOrder, name } = newChild;
+ for (let child of this.childList.children) {
+ if (folderSortOrder < child.folderSortOrder) {
+ return this.childList.insertBefore(newChild, child);
+ }
+ if (
+ folderSortOrder == child.folderSortOrder &&
+ FolderTreeRow.nameCollator.compare(name, child.name) < 0
+ ) {
+ return this.childList.insertBefore(newChild, child);
+ }
+ }
+ return this.childList.appendChild(newChild);
+ }
+}
+customElements.define("folder-tree-row", FolderTreeRow, { extends: "li" });
+
+/**
+ * Header area of the message list pane.
+ */
+var threadPaneHeader = {
+ /**
+ * The header bar element.
+ * @type {?HTMLElement}
+ */
+ bar: null,
+ /**
+ * The h2 element receiving the folder name.
+ * @type {?HTMLHeadElement}
+ */
+ folderName: null,
+ /**
+ * The span element receiving the message count.
+ * @type {?HTMLSpanElement}
+ */
+ folderCount: null,
+ /**
+ * The quick filter toolbar toggle button.
+ * @type {?HTMLButtonElement}
+ */
+ filterButton: null,
+ /**
+ * The display options button opening the popup.
+ * @type {?HTMLButtonElement}
+ */
+ displayButton: null,
+ /**
+ * If the header area is hidden.
+ * @type {boolean}
+ */
+ isHidden: false,
+
+ init() {
+ this.isHidden =
+ Services.xulStore.getValue(XULSTORE_URL, "threadPaneHeader", "hidden") ===
+ "true";
+ this.bar = document.getElementById("threadPaneHeaderBar");
+ this.bar.hidden = this.isHidden;
+
+ this.folderName = document.getElementById("threadPaneFolderName");
+ this.folderCount = document.getElementById("threadPaneFolderCount");
+ this.selectedCount = document.getElementById("threadPaneSelectedCount");
+ this.filterButton = document.getElementById("threadPaneQuickFilterButton");
+ this.filterButton.addEventListener("click", () =>
+ goDoCommand("cmd_toggleQuickFilterBar")
+ );
+ window.addEventListener("qfbtoggle", this);
+ this.onQuickFilterToggle();
+
+ this.displayButton = document.getElementById("threadPaneDisplayButton");
+ this.displayContext = document.getElementById("threadPaneDisplayContext");
+ this.displayButton.addEventListener("click", event => {
+ this.displayContext.openPopup(event.target, { triggerEvent: event });
+ });
+ },
+
+ uninit() {
+ window.removeEventListener("qfbtoggle", this);
+ },
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "qfbtoggle":
+ this.onQuickFilterToggle();
+ break;
+ }
+ },
+
+ /**
+ * Update the context menu to reflect the currently selected display options.
+ *
+ * @param {Event} event - The popupshowing DOMEvent.
+ */
+ updateDisplayContextMenu(event) {
+ if (event.target.id != "threadPaneDisplayContext") {
+ return;
+ }
+ const isTableLayout = document.body.classList.contains("layout-table");
+ document
+ .getElementById(
+ isTableLayout ? "threadPaneTableView" : "threadPaneCardsView"
+ )
+ .setAttribute("checked", "true");
+ },
+
+ /**
+ * Update the menuitems inside the thread pane sort menupopup.
+ *
+ * @param {Event} event - The popupshowing DOMEvent.
+ */
+ updateThreadPaneSortMenu(event) {
+ if (event.target.id != "menu_threadPaneSortPopup") {
+ return;
+ }
+
+ const hiddenColumns = threadPane.columns
+ .filter(c => c.hidden)
+ .map(c => c.sortKey);
+
+ // Update menuitem to reflect sort key.
+ for (const menuitem of event.target.querySelectorAll(`[name="sortby"]`)) {
+ const sortKey = menuitem.getAttribute("value");
+ menuitem.setAttribute(
+ "checked",
+ gViewWrapper.primarySortType == Ci.nsMsgViewSortType[sortKey]
+ );
+ if (hiddenColumns.includes(sortKey)) {
+ menuitem.setAttribute("disabled", "true");
+ } else {
+ menuitem.removeAttribute("disabled");
+ }
+ }
+
+ // Update sort direction menu items.
+ event.target
+ .querySelector(`[value="ascending"]`)
+ .setAttribute("checked", gViewWrapper.isSortedAscending);
+ event.target
+ .querySelector(`[value="descending"]`)
+ .setAttribute("checked", !gViewWrapper.isSortedAscending);
+
+ // Update the threaded and groupedBy menu items.
+ event.target
+ .querySelector(`[value="threaded"]`)
+ .setAttribute("checked", gViewWrapper.showThreaded);
+ event.target
+ .querySelector(`[value="unthreaded"]`)
+ .setAttribute("checked", gViewWrapper.showUnthreaded);
+ event.target
+ .querySelector(`[value="group"]`)
+ .setAttribute("checked", gViewWrapper.showGroupedBySort);
+ },
+
+ /**
+ * Change the display view of the message list pane.
+ *
+ * @param {DOMEvent} event - The click event.
+ */
+ changePaneView(event) {
+ const view = event.target.value;
+ Services.xulStore.setValue(XULSTORE_URL, "threadPane", "view", view);
+ threadPane.updateThreadView(view);
+ },
+
+ /**
+ * Update the quick filter button based on the quick filter bar state.
+ */
+ onQuickFilterToggle() {
+ const active = quickFilterBar.filterer.visible;
+ this.filterButton.setAttribute("aria-pressed", active.toString());
+ },
+
+ /**
+ * Toggle the visibility of the message list pane header.
+ */
+ toggleThreadPaneHeader() {
+ this.isHidden = !this.isHidden;
+ this.bar.hidden = this.isHidden;
+
+ Services.xulStore.setValue(
+ XULSTORE_URL,
+ "threadPaneHeader",
+ "hidden",
+ this.isHidden
+ );
+ // Trigger a data refresh if we're revealing the header.
+ if (!this.isHidden) {
+ this.onFolderSelected();
+ }
+ },
+
+ /**
+ * Update the header data when the selected folder changes.
+ */
+ onFolderSelected() {
+ // Bail out if the pane is hidden as we don't need to update anything.
+ if (this.isHidden) {
+ return;
+ }
+
+ // Hide any potential stale data if we don't have a folder.
+ if (!gFolder && !gDBView && !gViewWrapper?.isSynthetic) {
+ this.folderName.hidden = true;
+ this.folderCount.hidden = true;
+ this.selectedCount.hidden = true;
+ return;
+ }
+
+ const folderName = gFolder?.abbreviatedName ?? document.title;
+ this.folderName.textContent = folderName;
+ this.folderName.title = folderName;
+ document.l10n.setAttributes(
+ this.folderCount,
+ "thread-pane-folder-message-count",
+ { count: gFolder?.getTotalMessages(false) || gDBView?.rowCount || 0 }
+ );
+
+ this.folderName.hidden = false;
+ this.folderCount.hidden = false;
+ },
+
+ /**
+ * Update the total message count in the header if the value changed for the
+ * currently selected folder.
+ *
+ * @param {nsIMsgFolder} folder - The folder updating the count.
+ * @param {integer} newValue
+ */
+ updateFolderCount(folder, newValue) {
+ if (!gFolder || !folder || this.isHidden || folder.URI != gFolder.URI) {
+ return;
+ }
+
+ document.l10n.setAttributes(
+ this.folderCount,
+ "thread-pane-folder-message-count",
+ { count: newValue }
+ );
+ },
+
+ /**
+ * Count the number of currently selected messages and update the selected
+ * message count indicator.
+ */
+ updateSelectedCount() {
+ // Bail out if the pane is hidden as we don't need to update anything.
+ if (this.isHidden) {
+ return;
+ }
+
+ let count = gDBView?.getSelectedMsgHdrs().length;
+ if (count < 2) {
+ this.selectedCount.hidden = true;
+ return;
+ }
+ document.l10n.setAttributes(
+ this.selectedCount,
+ "thread-pane-folder-selected-count",
+ { count }
+ );
+ this.selectedCount.hidden = false;
+ },
+};
+
+var threadPane = {
+ /**
+ * Non-persistent storage of the last-selected items in each folder.
+ * Keys in this map are folder URIs. Values are objects containing an array
+ * of the selected messages and the current message. Messages are referenced
+ * by message key to account for possible changes in the folder.
+ *
+ * @type {Map<string, object>}
+ */
+ _savedSelections: new Map(),
+
+ /**
+ * This is set to true in folderPane._onSelect before opening the folder, if
+ * new messages have been received and the corresponding preference is set.
+ *
+ * @type {boolean}
+ */
+ scrollToNewMessage: false,
+
+ /**
+ * Set to true when a scrolling event (presumably by the user) is detected
+ * while messages are still loading in a newly created view.
+ *
+ * @type {boolean}
+ */
+ scrollDetected: false,
+
+ /**
+ * The first detected scrolling event is triggered by creating the view
+ * itself. This property is then set to false.
+ *
+ * @type {boolean}
+ */
+ isFirstScroll: true,
+
+ columns: getDefaultColumns(gFolder),
+
+ cardColumns: getDefaultColumnsForCardsView(gFolder),
+
+ async init() {
+ quickFilterBar.init();
+
+ this.setUpTagStyles();
+ Services.prefs.addObserver("mailnews.tags.", this);
+
+ Services.obs.addObserver(this, "addrbook-displayname-changed");
+
+ // Ensure TreeView and its classes are properly defined.
+ await customElements.whenDefined("tree-view-table-row");
+
+ threadTree = document.getElementById("threadTree");
+ this.treeTable = threadTree.table;
+ this.treeTable.editable = true;
+ this.treeTable.setPopupMenuTemplates([
+ "threadPaneApplyColumnMenu",
+ "threadPaneApplyViewMenu",
+ ]);
+ threadTree.setAttribute(
+ "rows",
+ !Services.xulStore.hasValue(XULSTORE_URL, "threadPane", "view") ||
+ Services.xulStore.getValue(XULSTORE_URL, "threadPane", "view") ==
+ "cards"
+ ? "thread-card"
+ : "thread-row"
+ );
+
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "selectDelay",
+ "mailnews.threadpane_select_delay",
+ null,
+ (name, oldValue, newValue) => (threadTree.dataset.selectDelay = newValue)
+ );
+ threadTree.dataset.selectDelay = this.selectDelay;
+
+ window.addEventListener("uidensitychange", () => {
+ this.densityChange();
+ threadTree.reset();
+ });
+ this.densityChange();
+
+ XPCOMUtils.defineLazyGetter(this, "notificationBox", () => {
+ let container = document.getElementById("threadPaneNotificationBox");
+ return new MozElements.NotificationBox(element =>
+ container.append(element)
+ );
+ });
+
+ this.treeTable.addEventListener("shift-column", event => {
+ this.onColumnShifted(event.detail);
+ });
+ this.treeTable.addEventListener("reorder-columns", event => {
+ this.onColumnsReordered(event.detail);
+ });
+ this.treeTable.addEventListener("column-resized", event => {
+ this.treeTable.setColumnsWidths(XULSTORE_URL, event);
+ });
+ this.treeTable.addEventListener("columns-changed", event => {
+ this.onColumnsVisibilityChanged(event.detail);
+ });
+ this.treeTable.addEventListener("sort-changed", event => {
+ this.onSortChanged(event.detail);
+ });
+ this.treeTable.addEventListener("restore-columns", () => {
+ this.restoreDefaultColumns();
+ });
+ this.treeTable.addEventListener("toggle-flag", event => {
+ gDBView.applyCommandToIndices(
+ event.detail.isFlagged
+ ? Ci.nsMsgViewCommandType.unflagMessages
+ : Ci.nsMsgViewCommandType.flagMessages,
+ [event.detail.index]
+ );
+ });
+ this.treeTable.addEventListener("toggle-unread", event => {
+ gDBView.applyCommandToIndices(
+ event.detail.isUnread
+ ? Ci.nsMsgViewCommandType.markMessagesRead
+ : Ci.nsMsgViewCommandType.markMessagesUnread,
+ [event.detail.index]
+ );
+ });
+ this.treeTable.addEventListener("toggle-spam", event => {
+ gDBView.applyCommandToIndices(
+ event.detail.isJunk
+ ? Ci.nsMsgViewCommandType.unjunk
+ : Ci.nsMsgViewCommandType.junk,
+ [event.detail.index]
+ );
+ });
+ this.treeTable.addEventListener("thread-changed", () => {
+ sortController.toggleThreaded();
+ });
+ this.treeTable.addEventListener("request-delete", event => {
+ gDBView.applyCommandToIndices(Ci.nsMsgViewCommandType.deleteMsg, [
+ event.detail.index,
+ ]);
+ });
+
+ this.updateClassList();
+
+ threadTree.addEventListener("contextmenu", this);
+ threadTree.addEventListener("dblclick", this);
+ threadTree.addEventListener("auxclick", this);
+ threadTree.addEventListener("keypress", this);
+ threadTree.addEventListener("select", this);
+ threadTree.table.body.addEventListener("dragstart", this);
+ threadTree.addEventListener("dragover", this);
+ threadTree.addEventListener("drop", this);
+ threadTree.addEventListener("expanded", this);
+ threadTree.addEventListener("collapsed", this);
+ threadTree.addEventListener("scroll", this);
+ },
+
+ uninit() {
+ Services.prefs.removeObserver("mailnews.tags.", this);
+ Services.obs.removeObserver(this, "addrbook-displayname-changed");
+ },
+
+ handleEvent(event) {
+ const notOnEmptySpace = event.target !== threadTree;
+ switch (event.type) {
+ case "contextmenu":
+ if (notOnEmptySpace) {
+ this._onContextMenu(event);
+ }
+ break;
+ case "dblclick":
+ if (notOnEmptySpace) {
+ this._onDoubleClick(event);
+ }
+ break;
+ case "auxclick":
+ if (event.button == 1 && notOnEmptySpace) {
+ this._onMiddleClick(event);
+ }
+ break;
+ case "keypress":
+ this._onKeyPress(event);
+ break;
+ case "select":
+ this._onSelect(event);
+ break;
+ case "dragstart":
+ this._onDragStart(event);
+ break;
+ case "dragover":
+ this._onDragOver(event);
+ break;
+ case "drop":
+ this._onDrop(event);
+ break;
+ case "expanded":
+ case "collapsed":
+ if (event.detail == threadTree.selectedIndex) {
+ // The selected index hasn't changed, but a collapsed row represents
+ // multiple messages, so for our purposes the selection has changed.
+ threadTree.dispatchEvent(new CustomEvent("select"));
+ }
+ break;
+ case "scroll":
+ if (this.isFirstScroll) {
+ this.isFirstScroll = false;
+ break;
+ }
+ this.scrollDetected = true;
+ break;
+ }
+ },
+ observe(subject, topic, data) {
+ if (topic == "nsPref:changed") {
+ this.setUpTagStyles();
+ } else if (topic == "addrbook-displayname-changed") {
+ // This runs the when mail.displayname.version preference observer is
+ // notified/the mail.displayname.version number has been updated.
+ threadTree.invalidate();
+ }
+ },
+
+ /**
+ * Update the CSS classes of the thread tree based on the current folder.
+ */
+ updateClassList() {
+ if (!gFolder) {
+ threadTree.classList.remove("is-outgoing");
+ return;
+ }
+
+ threadTree.classList.toggle("is-outgoing", isOutgoing(gFolder));
+ },
+
+ /**
+ * Temporarily select a different index from the actual selection, without
+ * visually changing or losing the current selection.
+ *
+ * @param {integer} index - The index of the clicked row.
+ */
+ suppressSelect(index) {
+ this.saveSelection();
+ threadTree._selection.selectEventsSuppressed = true;
+ threadTree._selection.select(index);
+ },
+
+ /**
+ * Clear the selection suppression and restore the previous selection.
+ */
+ releaseSelection() {
+ threadTree._selection.selectEventsSuppressed = true;
+ this.restoreSelection({ notify: false });
+ threadTree._selection.selectEventsSuppressed = false;
+ },
+
+ _onDoubleClick(event) {
+ if (event.target.closest("button") || event.target.closest("menupopup")) {
+ // Prevent item activation if double click happens on a button inside the
+ // row. E.g.: Thread toggle, spam, favorite, etc. or in a menupopup like
+ // the column picker.
+ return;
+ }
+ this._onItemActivate(event);
+ },
+
+ _onKeyPress(event) {
+ if (event.target.closest("thead")) {
+ // Bail out if the keypress happens in the table header.
+ return;
+ }
+
+ if (event.key == "Enter") {
+ this._onItemActivate(event);
+ }
+ },
+
+ _onMiddleClick(event) {
+ const row =
+ event.target.closest(`tr[is^="thread-"]`) ||
+ threadTree.getRowAtIndex(threadTree.currentIndex);
+
+ const isSelected = gDBView.selection.isSelected(row.index);
+ if (!isSelected) {
+ // The middle-clicked row is not selected. Tell the activate item to use
+ // this instead.
+ this.suppressSelect(row.index);
+ }
+ this._onItemActivate(event);
+ if (!isSelected) {
+ this.releaseSelection();
+ }
+ },
+
+ _onItemActivate(event) {
+ if (
+ threadTree.selectedIndex < 0 ||
+ gDBView.getFlagsAt(threadTree.selectedIndex) & MSG_VIEW_FLAG_DUMMY
+ ) {
+ return;
+ }
+
+ let folder = gFolder || gDBView.hdrForFirstSelectedMessage.folder;
+ if (folder?.isSpecialFolder(Ci.nsMsgFolderFlags.Drafts, true)) {
+ commandController.doCommand("cmd_editDraftMsg", event);
+ } else if (folder?.isSpecialFolder(Ci.nsMsgFolderFlags.Templates, true)) {
+ commandController.doCommand("cmd_newMsgFromTemplate", event);
+ } else {
+ commandController.doCommand("cmd_openMessage", event);
+ }
+ },
+
+ /**
+ * Handle threadPane select events.
+ */
+ _onSelect(event) {
+ if (!paneLayout.messagePaneVisible.isCollapsed && gDBView) {
+ messagePane.clearWebPage();
+ switch (gDBView.numSelected) {
+ case 0:
+ messagePane.clearMessage();
+ messagePane.clearMessages();
+ threadPaneHeader.selectedCount.hidden = true;
+ break;
+ case 1:
+ if (
+ gDBView.getFlagsAt(threadTree.selectedIndex) & MSG_VIEW_FLAG_DUMMY
+ ) {
+ messagePane.clearMessage();
+ messagePane.clearMessages();
+ threadPaneHeader.selectedCount.hidden = true;
+ } else {
+ let uri = gDBView.getURIForViewIndex(threadTree.selectedIndex);
+ messagePane.displayMessage(uri);
+ threadPaneHeader.updateSelectedCount();
+ }
+ break;
+ default:
+ messagePane.displayMessages(gDBView.getSelectedMsgHdrs());
+ threadPaneHeader.updateSelectedCount();
+ break;
+ }
+ }
+
+ // Update the state of the zoom commands, since the view has changed.
+ const commandsToUpdate = [
+ "cmd_fullZoomReduce",
+ "cmd_fullZoomEnlarge",
+ "cmd_fullZoomReset",
+ "cmd_fullZoomToggle",
+ ];
+ for (const command of commandsToUpdate) {
+ top.goUpdateCommand(command);
+ }
+ },
+
+ /**
+ * Handle threadPane drag events.
+ */
+ _onDragStart(event) {
+ let row = event.target.closest(`tr[is^="thread-"]`);
+ if (!row) {
+ event.preventDefault();
+ return;
+ }
+
+ let messageURIs = gDBView.getURIsForSelection();
+ if (!threadTree.selectedIndices.includes(row.index)) {
+ messageURIs = [gDBView.getURIForViewIndex(row.index)];
+ }
+
+ let noSubjectString = messengerBundle.GetStringFromName(
+ "defaultSaveMessageAsFileName"
+ );
+ if (noSubjectString.endsWith(".eml")) {
+ noSubjectString = noSubjectString.slice(0, -4);
+ }
+ let longSubjectTruncator = messengerBundle.GetStringFromName(
+ "longMsgSubjectTruncator"
+ );
+ // Clip the subject string to 124 chars to avoid problems on Windows,
+ // see NS_MAX_FILEDESCRIPTOR in m-c/widget/windows/nsDataObj.cpp .
+ const maxUncutNameLength = 124;
+ let maxCutNameLength = maxUncutNameLength - longSubjectTruncator.length;
+ let messages = new Map();
+
+ for (let [index, uri] of Object.entries(messageURIs)) {
+ let msgService = MailServices.messageServiceFromURI(uri);
+ let msgHdr = msgService.messageURIToMsgHdr(uri);
+ let subject = msgHdr.mime2DecodedSubject || "";
+ if (msgHdr.flags & Ci.nsMsgMessageFlags.HasRe) {
+ subject = "Re: " + subject;
+ }
+
+ let uniqueFileName;
+ // If there is no subject, use a default name.
+ // If subject needs to be truncated, add a truncation character to indicate it.
+ if (!subject) {
+ uniqueFileName = noSubjectString;
+ } else {
+ uniqueFileName =
+ subject.length <= maxUncutNameLength
+ ? subject
+ : subject.substr(0, maxCutNameLength) + longSubjectTruncator;
+ }
+ let msgFileName = validateFileName(uniqueFileName);
+ let msgFileNameLowerCase = msgFileName.toLocaleLowerCase();
+
+ while (true) {
+ if (!messages.has(msgFileNameLowerCase)) {
+ messages.set(msgFileNameLowerCase, 1);
+ break;
+ } else {
+ let number = messages.get(msgFileNameLowerCase);
+ messages.set(msgFileNameLowerCase, number + 1);
+ let postfix = "-" + number;
+ msgFileName = msgFileName + postfix;
+ msgFileNameLowerCase = msgFileNameLowerCase + postfix;
+ }
+ }
+
+ msgFileName = msgFileName + ".eml";
+
+ // This type should be unnecessary, but getFlavorData can't get at
+ // text/x-moz-message for some reason.
+ event.dataTransfer.mozSetDataAt("text/plain", uri, index);
+ event.dataTransfer.mozSetDataAt("text/x-moz-message", uri, index);
+ event.dataTransfer.mozSetDataAt(
+ "text/x-moz-url",
+ msgService.getUrlForUri(uri).spec,
+ index
+ );
+ // When dragging messages to the filesystem:
+ // - Windows fetches this value and writes it to a file.
+ // - Linux does the same if there are multiple files, but for a single
+ // file it uses the flavor data provider below.
+ // - MacOS always uses the flavor data provider.
+ event.dataTransfer.mozSetDataAt(
+ "application/x-moz-file-promise-url",
+ msgService.getUrlForUri(uri).spec,
+ index
+ );
+ event.dataTransfer.mozSetDataAt(
+ "application/x-moz-file-promise",
+ this._flavorDataProvider,
+ index
+ );
+ event.dataTransfer.mozSetDataAt(
+ "application/x-moz-file-promise-dest-filename",
+ msgFileName.replace(/(.{74}).*(.{10})$/u, "$1...$2"),
+ index
+ );
+ }
+
+ event.dataTransfer.effectAllowed = "copyMove";
+ let bcr = row.getBoundingClientRect();
+ event.dataTransfer.setDragImage(
+ row,
+ event.clientX - bcr.x,
+ event.clientY - bcr.y
+ );
+ },
+
+ /**
+ * Handle threadPane dragover events.
+ */
+ _onDragOver(event) {
+ if (event.target.closest("thead")) {
+ return; // Only allow dropping in the body.
+ }
+ // Must prevent default. Otherwise dropEffect gets cleared.
+ event.preventDefault();
+ event.dataTransfer.dropEffect = "none";
+ let types = Array.from(event.dataTransfer.mozTypesAt(0));
+ let targetFolder = gFolder;
+ if (types.includes("application/x-moz-file")) {
+ if (targetFolder.isServer || !targetFolder.canFileMessages) {
+ return;
+ }
+ for (let i = 0; i < event.dataTransfer.mozItemCount; i++) {
+ let extFile = event.dataTransfer
+ .mozGetDataAt("application/x-moz-file", i)
+ .QueryInterface(Ci.nsIFile);
+ if (!extFile.isFile() || !/\.eml$/i.test(extFile.leafName)) {
+ return;
+ }
+ }
+ event.dataTransfer.dropEffect = "copy";
+ }
+ },
+
+ /**
+ * Handle threadPane drop events.
+ */
+ _onDrop(event) {
+ if (event.target.closest("thead")) {
+ return; // Only allow dropping in the body.
+ }
+ event.preventDefault();
+ for (let i = 0; i < event.dataTransfer.mozItemCount; i++) {
+ let extFile = event.dataTransfer
+ .mozGetDataAt("application/x-moz-file", i)
+ .QueryInterface(Ci.nsIFile);
+ if (extFile.isFile() && /\.eml$/i.test(extFile.leafName)) {
+ MailServices.copy.copyFileMessage(
+ extFile,
+ gFolder,
+ null,
+ false,
+ 1,
+ "",
+ null,
+ top.msgWindow
+ );
+ }
+ }
+ },
+
+ _onContextMenu(event, retry = false) {
+ let row =
+ event.target.closest(`tr[is^="thread-"]`) ||
+ threadTree.getRowAtIndex(threadTree.currentIndex);
+ const isMouse = event.button == 2;
+ if (!isMouse) {
+ if (threadTree.selectedIndex < 0) {
+ return;
+ }
+ // Scroll selected row we're triggering the context menu for into view.
+ threadTree.scrollToIndex(threadTree.currentIndex, true);
+ if (!row) {
+ row = threadTree.getRowAtIndex(threadTree.currentIndex);
+ // Try again once in the next frame.
+ if (!row && !retry) {
+ window.requestAnimationFrame(() => this._onContextMenu(event, true));
+ return;
+ }
+ }
+ }
+ if (!row || gDBView.getFlagsAt(row.index) & MSG_VIEW_FLAG_DUMMY) {
+ return;
+ }
+
+ mailContextMenu.setAsThreadPaneContextMenu();
+ let popup = document.getElementById("mailContext");
+
+ if (isMouse) {
+ if (!gDBView.selection.isSelected(row.index)) {
+ // The right-clicked-on row is not selected. Tell the context menu to
+ // use it instead. This override lasts until the context menu fires
+ // a "popuphidden" event.
+ mailContextMenu.setOverrideSelection(row.index);
+ row.classList.add("context-menu-target");
+ }
+ popup.openPopupAtScreen(event.screenX, event.screenY, true);
+ } else {
+ popup.openPopup(row, "after_end", 0, 0, true);
+ }
+
+ event.preventDefault();
+ },
+
+ _flavorDataProvider: {
+ QueryInterface: ChromeUtils.generateQI(["nsIFlavorDataProvider"]),
+
+ getFlavorData(transferable, flavor, data) {
+ if (flavor !== "application/x-moz-file-promise") {
+ return;
+ }
+
+ let fileName = {};
+ transferable.getTransferData(
+ "application/x-moz-file-promise-dest-filename",
+ fileName
+ );
+ fileName.value.QueryInterface(Ci.nsISupportsString);
+
+ let destDir = {};
+ transferable.getTransferData(
+ "application/x-moz-file-promise-dir",
+ destDir
+ );
+ destDir.value.QueryInterface(Ci.nsIFile);
+
+ let file = destDir.value.clone();
+ file.append(fileName.value.data);
+
+ let messageURI = {};
+ transferable.getTransferData("text/plain", messageURI);
+ messageURI.value.QueryInterface(Ci.nsISupportsString);
+
+ top.messenger.saveAs(messageURI.value.data, true, null, file.path, true);
+ },
+ },
+
+ _jsTree: {
+ QueryInterface: ChromeUtils.generateQI(["nsIMsgJSTree"]),
+ _inBatch: false,
+ beginUpdateBatch() {
+ this._inBatch = true;
+ },
+ endUpdateBatch() {
+ this._inBatch = false;
+ },
+ ensureRowIsVisible(index) {
+ if (!this._inBatch) {
+ threadTree.scrollToIndex(index, true);
+ }
+ },
+ invalidate() {
+ if (!this._inBatch) {
+ threadTree.reset();
+ if (threadPane) {
+ threadPane.isFirstScroll = true;
+ threadPane.scrollDetected = false;
+ threadPane.scrollToLatestRowIfNoSelection();
+ }
+ }
+ },
+ invalidateRange(startIndex, endIndex) {
+ if (!this._inBatch) {
+ threadTree.invalidateRange(startIndex, endIndex);
+ }
+ },
+ rowCountChanged(index, count) {
+ if (!this._inBatch) {
+ threadTree.rowCountChanged(index, count);
+ }
+ },
+ get currentIndex() {
+ return threadTree.currentIndex;
+ },
+ set currentIndex(index) {
+ threadTree.currentIndex = index;
+ },
+ },
+
+ /**
+ * Tell the tree and the view about each other. `nsITreeView.setTree` can't
+ * be used because it needs a XULTreeElement and threadTree isn't one.
+ * (Strictly speaking the shim passed here isn't a tree either but it does
+ * implement the required methods.)
+ *
+ * @param {nsIMsgDBView} view
+ */
+ setTreeView(view) {
+ threadTree.view = gDBView = view;
+ // Clear the batch flag. Don't call `endUpdateBatch` as that may change in
+ // future leading to unintended consequences.
+ this._jsTree._inBatch = false;
+ view.setJSTree(this._jsTree);
+ },
+
+ setUpTagStyles() {
+ if (this.tagStyle) {
+ this.tagStyle.remove();
+ }
+ this.tagStyle = document.head.appendChild(document.createElement("style"));
+
+ for (let { color, key } of MailServices.tags.getAllTags()) {
+ if (!color) {
+ continue;
+ }
+ let selector = MailServices.tags.getSelectorForKey(key);
+ let contrast = TagUtils.isColorContrastEnough(color) ? "black" : "white";
+ this.tagStyle.sheet.insertRule(
+ `tr[data-properties~="${selector}"] {
+ --tag-color: ${color};
+ --tag-contrast-color: ${contrast};
+ }`
+ );
+ }
+ },
+
+ /**
+ * Make the list rows density aware.
+ */
+ densityChange() {
+ // The class ThreadRow can't be referenced because it's declared in a
+ // different scope. But we can get it from customElements.
+ let rowClass = customElements.get("thread-row");
+ let cardClass = customElements.get("thread-card");
+ switch (UIDensity.prefValue) {
+ case UIDensity.MODE_COMPACT:
+ rowClass.ROW_HEIGHT = 18;
+ cardClass.ROW_HEIGHT = 40;
+ break;
+ case UIDensity.MODE_TOUCH:
+ rowClass.ROW_HEIGHT = 32;
+ cardClass.ROW_HEIGHT = 52;
+ break;
+ default:
+ rowClass.ROW_HEIGHT = 26;
+ cardClass.ROW_HEIGHT = 46;
+ break;
+ }
+ },
+
+ /**
+ * Store the current thread tree selection.
+ */
+ saveSelection() {
+ // Identifying messages by key doesn't reliably work on on cross-folder views since
+ // the msgKey may not be unique.
+ if (gFolder && gDBView && !gViewWrapper?.isMultiFolder) {
+ this._savedSelections.set(gFolder.URI, {
+ currentKey: gDBView.getKeyAt(threadTree.currentIndex),
+ // In views which are "grouped by sort", getting the key for collapsed dummy rows
+ // returns the key of the first group member, so we would restore something that
+ // wasn't selected. So filter them out.
+ selectedKeys: threadTree.selectedIndices
+ .filter(i => !gViewWrapper.isGroupedByHeaderAtIndex(i))
+ .map(gDBView.getKeyAt),
+ });
+ }
+ },
+
+ /**
+ * Forget any saved selection of the given folder. This is useful if you're
+ * going to set the selection after switching to the folder.
+ *
+ * @param {string} folderURI
+ */
+ forgetSelection(folderURI) {
+ this._savedSelections.delete(folderURI);
+ },
+
+ /**
+ * Restore the previously saved thread tree selection.
+ *
+ * @param {boolean} [discard=true] - If false, the selection data is kept for
+ * another call of this function, unless all selections could already be
+ * restored in this run.
+ * @param {boolean} [notify=true] - Whether a change in "select" event
+ * should be fired.
+ * @param {boolean} [expand=true] - Try to expand threads containing selected
+ * messages.
+ */
+ restoreSelection({ discard = true, notify = true, expand = true } = {}) {
+ if (!this._savedSelections.has(gFolder?.URI) || !threadTree.view) {
+ return;
+ }
+
+ let { currentKey, selectedKeys } = this._savedSelections.get(gFolder.URI);
+ let currentIndex = nsMsgViewIndex_None;
+ let indices = new Set();
+ for (let key of selectedKeys) {
+ let index = gDBView.findIndexFromKey(key, expand);
+ // While the first message in a collapsed group returns the index of the
+ // dummy row, other messages return none. To be consistent, we don't
+ // select the dummy row in any case.
+ if (
+ index != nsMsgViewIndex_None &&
+ !gViewWrapper.isGroupedByHeaderAtIndex(index)
+ ) {
+ indices.add(index);
+ if (key == currentKey) {
+ currentIndex = index;
+ }
+ continue;
+ }
+ // Since it does not seem to be possible to reliably find the dummy row
+ // for a message in a group, we continue.
+ if (gViewWrapper.showGroupedBySort) {
+ continue;
+ }
+ // The message for this key can't be found. Perhaps the thread it's in
+ // has been collapsed? Select the root message in that case.
+ try {
+ const folder =
+ gViewWrapper.isVirtual && gViewWrapper.isSingleFolder
+ ? gViewWrapper._underlyingFolders[0]
+ : gFolder;
+ const msgHdr = folder.GetMessageHeader(key);
+ const thread = gDBView.getThreadContainingMsgHdr(msgHdr);
+ const rootMsgHdr = thread.getRootHdr();
+ index = gDBView.findIndexOfMsgHdr(rootMsgHdr, false);
+ if (index != nsMsgViewIndex_None) {
+ indices.add(index);
+ if (key == currentKey) {
+ currentIndex = index;
+ }
+ }
+ } catch (ex) {
+ console.error(ex);
+ }
+ }
+ threadTree.setSelectedIndices(indices.values(), !notify);
+
+ if (currentIndex != nsMsgViewIndex_None) {
+ threadTree.style.scrollBehavior = "auto"; // Avoid smooth scroll.
+ threadTree.currentIndex = currentIndex;
+ threadTree.style.scrollBehavior = null;
+ }
+
+ // If all selections have already been restored, discard them as well.
+ if (discard || gDBView.selection.count == selectedKeys.length) {
+ this._savedSelections.delete(gFolder.URI);
+ }
+ },
+
+ /**
+ * Scroll to the most relevant end of the tree, but only if no rows are
+ * selected.
+ */
+ scrollToLatestRowIfNoSelection() {
+ if (!gDBView || gDBView.selection.count > 0 || gDBView.rowCount <= 0) {
+ return;
+ }
+ if (
+ gViewWrapper.sortImpliesTemporalOrdering &&
+ gViewWrapper.isSortedAscending
+ ) {
+ threadTree.scrollToIndex(gDBView.rowCount - 1, true);
+ } else {
+ threadTree.scrollToIndex(0, true);
+ }
+ },
+
+ /**
+ * Re-collapse threads expanded by nsMsgQuickSearchDBView if necessary.
+ */
+ ensureThreadStateForQuickSearchView() {
+ // nsMsgQuickSearchDBView::SortThreads leaves all threads expanded in any
+ // case.
+ if (
+ gViewWrapper.isSingleFolder &&
+ gViewWrapper.search.hasSearchTerms &&
+ gViewWrapper.showThreaded &&
+ !gViewWrapper._threadExpandAll
+ ) {
+ window.threadPane.saveSelection();
+ gViewWrapper.dbView.doCommand(Ci.nsMsgViewCommandType.collapseAll);
+ window.threadPane.restoreSelection();
+ }
+ },
+
+ /**
+ * Restore the collapsed or expanded state of threads.
+ */
+ restoreThreadState() {
+ if (
+ gViewWrapper._threadExpandAll &&
+ !(gViewWrapper.dbView.viewFlags & Ci.nsMsgViewFlagsType.kExpandAll)
+ ) {
+ gViewWrapper.dbView.doCommand(Ci.nsMsgViewCommandType.expandAll);
+ }
+ if (
+ !gViewWrapper._threadExpandAll &&
+ gViewWrapper.dbView.viewFlags & Ci.nsMsgViewFlagsType.kExpandAll
+ ) {
+ gViewWrapper.dbView.doCommand(Ci.nsMsgViewCommandType.collapseAll);
+ }
+ },
+
+ /**
+ * Restore the chevron icon indicating the current sort order.
+ */
+ restoreSortIndicator() {
+ if (!gDBView) {
+ return;
+ }
+ this.updateSortIndicator(
+ sortController.convertSortTypeToColumnID(gViewWrapper.primarySortType)
+ );
+ },
+
+ /**
+ * Update the columns object and force the refresh of the thread pane to apply
+ * the updated state. This is usually called when changing folders.
+ */
+ restoreColumns() {
+ this.restoreColumnsState();
+ this.updateColumns();
+ },
+
+ /**
+ * Restore the visibility and order of the columns for the current folder.
+ */
+ restoreColumnsState() {
+ // Always fetch a fresh array of columns for the cards view even if we don't
+ // have a folder defined.
+ this.cardColumns = getDefaultColumnsForCardsView(gFolder);
+ this.updateClassList();
+
+ // Avoid doing anything if no folder has been loaded yet.
+ if (!gFolder) {
+ return;
+ }
+
+ // A missing folder database will throw an error so we need to handle that.
+ let msgDatabase;
+ try {
+ msgDatabase = gFolder.msgDatabase;
+ } catch {
+ return;
+ }
+
+ const stringState =
+ msgDatabase.dBFolderInfo.getCharProperty("columnStates");
+ if (!stringState) {
+ // If we don't have a previously saved state, make sure to enforce the
+ // default columns for the currently visible folder, otherwise the table
+ // layout will maintain whatever state is currently set from the previous
+ // folder, which it doesn't reflect reality.
+ this.columns = getDefaultColumns(gFolder);
+ return;
+ }
+
+ this.applyPersistedColumnsState(JSON.parse(stringState));
+ },
+
+ /**
+ * Update the current columns to match a previously saved state.
+ *
+ * @param {JSON} columnStates - The parsed JSON of a previously saved state.
+ */
+ applyPersistedColumnsState(columnStates) {
+ this.columns.forEach(c => {
+ c.hidden = !columnStates[c.id]?.visible;
+ c.ordinal = columnStates[c.id]?.ordinal ?? 0;
+ });
+ // Sort columns by ordinal.
+ this.columns.sort(function (a, b) {
+ return a.ordinal - b.ordinal;
+ });
+ },
+
+ /**
+ * Force an update of the thread tree to reflect the columns change.
+ *
+ * @param {boolean} isSimple - If the columns structure only requires a simple
+ * update and not a full reset of the entire table header.
+ */
+ updateColumns(isSimple = false) {
+ if (!this.rowTemplate) {
+ this.rowTemplate = document.getElementById("threadPaneRowTemplate");
+ }
+
+ // Update the row template to match the column properties.
+ for (let column of this.columns) {
+ let cell = this.rowTemplate.content.querySelector(
+ `.${column.id.toLowerCase()}-column`
+ );
+ cell.hidden = column.hidden;
+ this.rowTemplate.content.appendChild(cell);
+ }
+
+ if (isSimple) {
+ this.treeTable.updateColumns(this.columns);
+ } else {
+ // The order of the columns have changed, which warrants a rebuild of the
+ // full table header.
+ this.treeTable.setColumns(this.columns);
+ }
+ this.treeTable.restoreColumnsWidths(XULSTORE_URL);
+ },
+
+ /**
+ * Restore the default columns visibility and order and save the change.
+ */
+ restoreDefaultColumns() {
+ this.columns = getDefaultColumns(gFolder, gViewWrapper?.isSynthetic);
+ this.cardColumns = getDefaultColumnsForCardsView(gFolder);
+ this.updateClassList();
+ this.updateColumns();
+ threadTree.reset();
+ this.persistColumnStates();
+ },
+
+ /**
+ * Shift the ordinal of a column by one based on the visible columns.
+ *
+ * @param {object} data - The detail object of the bubbled event.
+ */
+ onColumnShifted(data) {
+ const column = data.column;
+ const forward = data.forward;
+
+ const columnToShift = this.columns.find(c => c.id == column);
+ const currentPosition = this.columns.indexOf(columnToShift);
+
+ let delta = forward ? 1 : -1;
+ let newPosition = currentPosition + delta;
+ // Account for hidden columns to find the correct new position.
+ while (this.columns.at(newPosition).hidden) {
+ newPosition += delta;
+ }
+
+ // Get the column in the current new position before shuffling the array.
+ const destinationTH = document.getElementById(
+ this.columns.at(newPosition).id
+ );
+
+ this.columns.splice(
+ newPosition,
+ 0,
+ this.columns.splice(currentPosition, 1)[0]
+ );
+
+ // Update the ordinal of the columns to reflect the new positions.
+ this.columns.forEach((column, index) => {
+ column.ordinal = index;
+ });
+
+ this.persistColumnStates();
+ this.updateColumns(true);
+ threadTree.reset();
+
+ // Swap the DOM elements.
+ const originalTH = document.getElementById(column);
+ if (forward) {
+ destinationTH.after(originalTH);
+ } else {
+ destinationTH.before(originalTH);
+ }
+ // Restore the focus so we can continue shifting if needed.
+ document.getElementById(`${column}Button`).focus();
+ },
+
+ onColumnsReordered(data) {
+ this.columns = data.columns;
+
+ this.persistColumnStates();
+ this.updateColumns(true);
+ threadTree.reset();
+ },
+
+ /**
+ * Update the list of visible columns based on the users' selection.
+ *
+ * @param {object} data - The detail object of the bubbled event.
+ */
+ onColumnsVisibilityChanged(data) {
+ let column = data.value;
+ let checked = data.target.hasAttribute("checked");
+
+ let changedColumn = this.columns.find(c => c.id == column);
+ changedColumn.hidden = !checked;
+
+ this.persistColumnStates();
+ this.updateColumns(true);
+ threadTree.reset();
+ },
+
+ /**
+ * Save the current visibility of the columns in the folder database.
+ */
+ persistColumnStates() {
+ let newState = {};
+ for (const column of this.columns) {
+ newState[column.id] = {
+ visible: !column.hidden,
+ ordinal: column.ordinal,
+ };
+ }
+
+ if (gViewWrapper.isSynthetic) {
+ let syntheticView = gViewWrapper._syntheticView;
+ if ("setPersistedSetting" in syntheticView) {
+ syntheticView.setPersistedSetting("columns", newState);
+ }
+ return;
+ }
+
+ if (!gFolder) {
+ return;
+ }
+
+ // A missing folder database will throw an error so we need to handle that.
+ let msgDatabase;
+ try {
+ msgDatabase = gFolder.msgDatabase;
+ } catch {
+ return;
+ }
+
+ msgDatabase.dBFolderInfo.setCharProperty(
+ "columnStates",
+ JSON.stringify(newState)
+ );
+ msgDatabase.commit(Ci.nsMsgDBCommitType.kLargeCommit);
+ },
+
+ /**
+ * Trigger a sort change when the user clicks on the table header.
+ *
+ * @param {object} data - The detail of the custom event.
+ */
+ onSortChanged(data) {
+ const sortColumn = sortController.convertSortTypeToColumnID(
+ gViewWrapper.primarySortType
+ );
+ const column = data.column;
+
+ // A click happened on the column that is already used to sort the list.
+ if (sortColumn == column) {
+ if (gViewWrapper.isSortedAscending) {
+ sortController.sortDescending();
+ } else {
+ sortController.sortAscending();
+ }
+ this.updateSortIndicator(column);
+ return;
+ }
+
+ const sortName = this.columns.find(c => c.id == data.column).sortKey;
+ sortController.sortThreadPane(sortName);
+ this.updateSortIndicator(column);
+ },
+
+ /**
+ * Update the classes on the table header to reflect the sorting order.
+ *
+ * @param {string} column - The ID of column affecting the sorting order.
+ */
+ updateSortIndicator(column) {
+ this.treeTable
+ .querySelector(".sorting")
+ ?.classList.remove("sorting", "ascending", "descending");
+ this.treeTable
+ .querySelector(`#${column} button`)
+ ?.classList.add(
+ "sorting",
+ gViewWrapper.isSortedAscending ? "ascending" : "descending"
+ );
+ },
+
+ /**
+ * Prompt the user to confirm applying the current columns state to the chosen
+ * folder and its children.
+ *
+ * @param {nsIMsgFolder} folder - The chosen message folder.
+ * @param {boolean} [useChildren=false] - If the requested action should be
+ * propagated to the child folders.
+ */
+ async confirmApplyColumns(folder, useChildren = false) {
+ const msgFluentID = useChildren
+ ? "apply-current-columns-to-folder-with-children-message"
+ : "apply-current-columns-to-folder-message";
+ let [title, message] = await document.l10n.formatValues([
+ "apply-changes-to-folder-title",
+ { id: msgFluentID, args: { name: folder.name } },
+ ]);
+ if (Services.prompt.confirm(null, title, message)) {
+ this._applyColumns(folder, useChildren);
+ }
+ },
+
+ /**
+ * Apply the current columns state to the chosen folder and its children,
+ * if specified.
+ *
+ * @param {nsIMsgFolder} destFolder - The chosen folder.
+ * @param {boolean} useChildren - True if the changes should affect the child
+ * folders of the chosen folder.
+ */
+ _applyColumns(destFolder, useChildren) {
+ // Avoid doing anything if no folder has been loaded yet.
+ if (!gFolder || !destFolder) {
+ return;
+ }
+
+ // Get the current state from the columns array, not the saved state in the
+ // database in order to make sure we're getting the currently visible state.
+ let columnState = {};
+ for (const column of this.columns) {
+ columnState[column.id] = {
+ visible: !column.hidden,
+ ordinal: column.ordinal,
+ };
+ }
+
+ // Swaps "From" and "Recipient" if only one is shown. This is useful for
+ // copying an incoming folder's columns to and from an outgoing folder.
+ let columStateString = JSON.stringify(columnState);
+ let swappedColumnStateString;
+ if (columnState.senderCol.visible != columnState.recipientCol.visible) {
+ const backedSenderColumn = columnState.senderCol;
+ columnState.senderCol = columnState.recipientCol;
+ columnState.recipientCol = backedSenderColumn;
+ swappedColumnStateString = JSON.stringify(columnState);
+ } else {
+ swappedColumnStateString = columStateString;
+ }
+
+ const currentFolderIsOutgoing = isOutgoing(gFolder);
+
+ /**
+ * Update the columnStates property of the folder database and forget the
+ * reference to prevent memory bloat.
+ *
+ * @param {nsIMsgFolder} folder - The message folder.
+ */
+ const commitColumnsState = folder => {
+ if (folder.isServer) {
+ return;
+ }
+ // Check if the destination folder we're trying to update matches the same
+ // special state of the folder we're getting the column state from.
+ const colStateString =
+ isOutgoing(folder) == currentFolderIsOutgoing
+ ? columStateString
+ : swappedColumnStateString;
+
+ folder.msgDatabase.dBFolderInfo.setCharProperty(
+ "columnStates",
+ colStateString
+ );
+ folder.msgDatabase.commit(Ci.nsMsgDBCommitType.kLargeCommit);
+ // Force the reference to be forgotten.
+ folder.msgDatabase = null;
+ };
+
+ if (!useChildren) {
+ commitColumnsState(destFolder);
+ return;
+ }
+
+ // Loop through all the child folders and apply the same column state.
+ MailUtils.takeActionOnFolderAndDescendents(
+ destFolder,
+ commitColumnsState
+ ).then(() => {
+ Services.obs.notifyObservers(
+ gViewWrapper.displayedFolder,
+ "msg-folder-columns-propagated"
+ );
+ });
+ },
+
+ /**
+ * Prompt the user to confirm applying the current view sate to the chosen
+ * folder and its children.
+ *
+ * @param {nsIMsgFolder} folder - The chosen message folder.
+ * @param {boolean} [useChildren=false] - If the requested action should be
+ * propagated to the child folders.
+ */
+ async confirmApplyView(folder, useChildren = false) {
+ const msgFluentID = useChildren
+ ? "apply-current-view-to-folder-with-children-message"
+ : "apply-current-view-to-folder-message";
+ let [title, message] = await document.l10n.formatValues([
+ { id: "apply-changes-to-folder-title" },
+ { id: msgFluentID, args: { name: folder.name } },
+ ]);
+ if (Services.prompt.confirm(null, title, message)) {
+ this._applyView(folder, useChildren);
+ }
+ },
+
+ /**
+ * Apply the current view flags, sorting key, and sorting order to another
+ * folder and its children, if specified.
+ *
+ * @param {nsIMsgFolder} destFolder - The chosen folder.
+ * @param {boolean} useChildren - True if the changes should affect the child
+ * folders of the chosen folder.
+ */
+ _applyView(destFolder, useChildren) {
+ const viewFlags = gViewWrapper.dbView.viewFlags;
+ const sortType = gViewWrapper.dbView.sortType;
+ const sortOrder = gViewWrapper.dbView.sortOrder;
+
+ /**
+ * Update the view state flags of the folder database and forget the
+ * reference to prevent memory bloat.
+ *
+ * @param {nsIMsgFolder} folder - The message folder.
+ */
+ const commitViewState = folder => {
+ if (folder.isServer) {
+ return;
+ }
+ folder.msgDatabase.dBFolderInfo.viewFlags = viewFlags;
+ folder.msgDatabase.dBFolderInfo.sortType = sortType;
+ folder.msgDatabase.dBFolderInfo.sortOrder = sortOrder;
+ // Null out to avoid memory bloat.
+ folder.msgDatabase = null;
+ };
+
+ if (!useChildren) {
+ commitViewState(destFolder);
+ return;
+ }
+
+ MailUtils.takeActionOnFolderAndDescendents(
+ destFolder,
+ commitViewState
+ ).then(() => {
+ Services.obs.notifyObservers(
+ gViewWrapper.displayedFolder,
+ "msg-folder-views-propagated"
+ );
+ });
+ },
+
+ /**
+ * Hide any notifications about ignored threads.
+ */
+ hideIgnoredMessageNotification() {
+ this.notificationBox.removeTransientNotifications();
+ },
+
+ /**
+ * Show a notification in the thread pane footer, allowing the user to learn
+ * more about the ignore thread feature, and also allowing undo ignore thread.
+ *
+ * @param {nsIMsgDBHdr[]} messages - The messages being ignored.
+ * @param {boolean} subthreadOnly - If true, ignoring only `messages` and
+ * their subthreads, otherwise ignoring the whole thread.
+ */
+ showIgnoredMessageNotification(messages, subthreadOnly) {
+ let threadIds = new Set();
+ messages.forEach(function (msg) {
+ if (!threadIds.has(msg.threadId)) {
+ threadIds.add(msg.threadId);
+ }
+ });
+
+ let buttons = [
+ {
+ label: messengerBundle.GetStringFromName("learnMoreAboutIgnoreThread"),
+ accessKey: messengerBundle.GetStringFromName(
+ "learnMoreAboutIgnoreThreadAccessKey"
+ ),
+ popup: null,
+ callback(aNotificationBar, aButton) {
+ let url = Services.prefs.getCharPref(
+ "mail.ignore_thread.learn_more_url"
+ );
+ top.openContentTab(url);
+ return true; // Keep notification open.
+ },
+ },
+ {
+ label: messengerBundle.GetStringFromName(
+ !subthreadOnly ? "undoIgnoreThread" : "undoIgnoreSubthread"
+ ),
+ accessKey: messengerBundle.GetStringFromName(
+ !subthreadOnly
+ ? "undoIgnoreThreadAccessKey"
+ : "undoIgnoreSubthreadAccessKey"
+ ),
+ isDefault: true,
+ popup: null,
+ callback(aNotificationBar, aButton) {
+ messages.forEach(function (msg) {
+ let msgDb = msg.folder.msgDatabase;
+ if (subthreadOnly) {
+ msgDb.markHeaderKilled(msg, false, null);
+ } else if (threadIds.has(msg.threadId)) {
+ let thread = msgDb.getThreadContainingMsgHdr(msg);
+ msgDb.markThreadIgnored(
+ thread,
+ thread.getChildKeyAt(0),
+ false,
+ null
+ );
+ threadIds.delete(msg.threadId);
+ }
+ });
+ // Invalidation should be unnecessary but the back end doesn't
+ // notify us properly and resists attempts to fix this.
+ threadTree.reset();
+ threadTree.table.body.focus();
+ return false; // Close notification.
+ },
+ },
+ ];
+
+ if (threadIds.size == 1) {
+ let ignoredThreadText = messengerBundle.GetStringFromName(
+ !subthreadOnly ? "ignoredThreadFeedback" : "ignoredSubthreadFeedback"
+ );
+ let subj = messages[0].mime2DecodedSubject || "";
+ if (subj.length > 45) {
+ subj = subj.substring(0, 45) + "…";
+ }
+ let text = ignoredThreadText.replace("#1", subj);
+
+ this.notificationBox.appendNotification(
+ "ignoreThreadInfo",
+ {
+ label: text,
+ priority: this.notificationBox.PRIORITY_INFO_MEDIUM,
+ },
+ buttons
+ );
+ } else {
+ let ignoredThreadText = messengerBundle.GetStringFromName(
+ !subthreadOnly ? "ignoredThreadsFeedback" : "ignoredSubthreadsFeedback"
+ );
+
+ const { PluralForm } = ChromeUtils.importESModule(
+ "resource://gre/modules/PluralForm.sys.mjs"
+ );
+ let text = PluralForm.get(threadIds.size, ignoredThreadText).replace(
+ "#1",
+ threadIds.size
+ );
+ this.notificationBox.appendNotification(
+ "ignoreThreadsInfo",
+ {
+ label: text,
+ priority: this.notificationBox.PRIORITY_INFO_MEDIUM,
+ },
+ buttons
+ );
+ }
+ },
+
+ /**
+ * Update the display view of the message list. Current supported options are
+ * table and cards.
+ *
+ * @param {string} view - The view type.
+ */
+ updateThreadView(view) {
+ switch (view) {
+ case "table":
+ document.body.classList.add("layout-table");
+ threadTree?.setAttribute("rows", "thread-row");
+ break;
+ case "cards":
+ default:
+ document.body.classList.remove("layout-table");
+ threadTree?.setAttribute("rows", "thread-card");
+ break;
+ }
+ },
+
+ /**
+ * Update the ARIA Role of the tree view table body to properly communicate
+ * to assistive techonology the type of list we're rendering and toggles the
+ * threaded class on the tree table header.
+ *
+ * @param {boolean} isListbox - If the list should have a listbox role.
+ */
+ updateListRole(isListbox) {
+ threadTree.table.body.setAttribute("role", isListbox ? "listbox" : "tree");
+ if (isListbox) {
+ threadTree.table.header.classList.remove("threaded");
+ } else {
+ threadTree.table.header.classList.add("threaded");
+ }
+ },
+};
+
+var messagePane = {
+ async init() {
+ webBrowser = document.getElementById("webBrowser");
+ // Attach the progress listener for the webBrowser. For the messageBrowser this
+ // happens in the "aboutMessageLoaded" event from aboutMessage.js.
+ top.contentProgress.addProgressListenerToBrowser(webBrowser);
+
+ messageBrowser = document.getElementById("messageBrowser");
+ messageBrowser.docShell.allowDNSPrefetch = false;
+
+ multiMessageBrowser = document.getElementById("multiMessageBrowser");
+ multiMessageBrowser.docShell.allowDNSPrefetch = false;
+
+ if (messageBrowser.contentDocument.readyState != "complete") {
+ await new Promise(resolve => {
+ messageBrowser.addEventListener("load", () => resolve(), {
+ capture: true,
+ once: true,
+ });
+ });
+ }
+
+ if (multiMessageBrowser.contentDocument.readyState != "complete") {
+ await new Promise(resolve => {
+ multiMessageBrowser.addEventListener("load", () => resolve(), {
+ capture: true,
+ once: true,
+ });
+ });
+ }
+ },
+
+ /**
+ * Ensure all message pane browsers are blank.
+ */
+ clearAll() {
+ this.clearWebPage();
+ this.clearMessage();
+ this.clearMessages();
+ },
+
+ /**
+ * Ensure the web page browser is blank, unless the start page is shown.
+ */
+ clearWebPage() {
+ if (!this._keepStartPageOpen) {
+ webBrowser.hidden = true;
+ MailE10SUtils.loadAboutBlank(webBrowser);
+ }
+ },
+
+ /**
+ * Display a web page in the web page browser. If `url` is not given, or is
+ * "about:blank", the web page browser is cleared and hidden.
+ *
+ * @param {string} url - The URL to load.
+ * @param {object} [params] - Any params to pass to MailE10SUtils.loadURI.
+ */
+ displayWebPage(url, params) {
+ if (!paneLayout.messagePaneVisible) {
+ return;
+ }
+ if (!url || url == "about:blank") {
+ this._keepStartPageOpen = false;
+ this.clearWebPage();
+ return;
+ }
+
+ this.clearMessage();
+ this.clearMessages();
+
+ MailE10SUtils.loadURI(webBrowser, url, params);
+ webBrowser.hidden = false;
+ },
+
+ /**
+ * Ensure the message browser is not displaying a message.
+ */
+ clearMessage() {
+ messageBrowser.hidden = true;
+ messageBrowser.contentWindow.displayMessage();
+ },
+
+ /**
+ * Display a single message in the message browser. If `messageURI` is not
+ * given, the message browser is cleared and hidden.
+ *
+ * @param {string} messageURI
+ */
+ displayMessage(messageURI) {
+ if (!paneLayout.messagePaneVisible) {
+ return;
+ }
+ if (!messageURI) {
+ this.clearMessage();
+ return;
+ }
+
+ this._keepStartPageOpen = false;
+ messagePane.clearWebPage();
+ messagePane.clearMessages();
+
+ messageBrowser.contentWindow.displayMessage(messageURI, gViewWrapper);
+ messageBrowser.hidden = false;
+ },
+
+ /**
+ * Ensure the multi-message browser is not displaying messages.
+ */
+ clearMessages() {
+ multiMessageBrowser.hidden = true;
+ multiMessageBrowser.contentWindow.gMessageSummary.clear();
+ },
+
+ /**
+ * Display messages in the multi-message browser. For a single message, use
+ * `displayMessage` instead. If `messages` is not given, or an empty array,
+ * the multi-message browser is cleared and hidden.
+ *
+ * @param {nsIMsgDBHdr[]} messages
+ */
+ displayMessages(messages = []) {
+ if (!paneLayout.messagePaneVisible) {
+ return;
+ }
+ if (messages.length == 0) {
+ this.clearMessages();
+ return;
+ }
+
+ this._keepStartPageOpen = false;
+ messagePane.clearWebPage();
+ messagePane.clearMessage();
+
+ let getThreadId = function (message) {
+ return gDBView.getThreadContainingMsgHdr(message).getRootHdr().messageKey;
+ };
+
+ let oneThread = true;
+ let firstThreadId = getThreadId(messages[0]);
+ for (let i = 1; i < messages.length; i++) {
+ if (getThreadId(messages[i]) != firstThreadId) {
+ oneThread = false;
+ break;
+ }
+ }
+
+ multiMessageBrowser.contentWindow.gMessageSummary.summarize(
+ oneThread ? "thread" : "multipleselection",
+ messages,
+ gDBView,
+ function (messages) {
+ threadTree.selectedIndices = messages
+ .map(m => gDBView.findIndexOfMsgHdr(m, true))
+ .filter(i => i != nsMsgViewIndex_None);
+ }
+ );
+
+ multiMessageBrowser.hidden = false;
+ window.dispatchEvent(new CustomEvent("MsgsLoaded", { bubbles: true }));
+ },
+
+ /**
+ * Show the start page in the web page browser. The start page will remain
+ * shown until a message is displayed.
+ */
+ showStartPage() {
+ this._keepStartPageOpen = true;
+ let url = Services.urlFormatter.formatURLPref("mailnews.start_page.url");
+ if (/^mailbox:|^imap:|^pop:|^s?news:|^nntp:/i.test(url)) {
+ console.warn(`Can't use ${url} as mailnews.start_page.url`);
+ Services.prefs.clearUserPref("mailnews.start_page.url");
+ url = Services.urlFormatter.formatURLPref("mailnews.start_page.url");
+ }
+ messagePane.displayWebPage(url);
+ },
+};
+
+function restoreState({
+ folderPaneVisible,
+ messagePaneVisible,
+ folderURI,
+ syntheticView,
+ first = false,
+ title = null,
+} = {}) {
+ if (folderPaneVisible === undefined) {
+ folderPaneVisible = folderURI || !syntheticView;
+ }
+ paneLayout.folderPaneSplitter.isCollapsed = !folderPaneVisible;
+ paneLayout.folderPaneSplitter.isDisabled = syntheticView;
+
+ if (messagePaneVisible === undefined) {
+ messagePaneVisible =
+ Services.xulStore.getValue(
+ XULSTORE_URL,
+ "messagepaneboxwrapper",
+ "collapsed"
+ ) !== "true";
+ }
+ paneLayout.messagePaneSplitter.isCollapsed = !messagePaneVisible;
+
+ if (folderURI) {
+ displayFolder(folderURI);
+ } else if (syntheticView) {
+ // In a synthetic view check if we have a previously edited column layout to
+ // restore.
+ if ("getPersistedSetting" in syntheticView) {
+ let columnsState = syntheticView.getPersistedSetting("columns");
+ if (!columnsState) {
+ threadPane.restoreDefaultColumns();
+ return;
+ }
+
+ threadPane.applyPersistedColumnsState(columnsState);
+ threadPane.updateColumns();
+ } else {
+ // Otherwise restore the default synthetic columns.
+ threadPane.restoreDefaultColumns();
+ }
+
+ gViewWrapper = new DBViewWrapper(dbViewWrapperListener);
+ gViewWrapper.openSynthetic(syntheticView);
+ gDBView = gViewWrapper.dbView;
+
+ if ("selectedMessage" in syntheticView) {
+ threadTree.selectedIndex = gDBView.findIndexOfMsgHdr(
+ syntheticView.selectedMessage,
+ true
+ );
+ } else {
+ // So that nsMsgSearchDBView::GetHdrForFirstSelectedMessage works from
+ // the beginning.
+ threadTree.currentIndex = 0;
+ }
+
+ document.title = title;
+ document.body.classList.remove("account-central");
+ accountCentralBrowser.hidden = true;
+ threadPaneHeader.onFolderSelected();
+ }
+
+ if (
+ first &&
+ messagePaneVisible &&
+ Services.prefs.getBoolPref("mailnews.start_page.enabled")
+ ) {
+ messagePane.showStartPage();
+ }
+}
+
+/**
+ * Set up the given folder to be selected in the folder pane.
+ * @param {nsIMsgFolder|string} folder - The folder to display, or its URI.
+ */
+function displayFolder(folder) {
+ let folderURI = folder instanceof Ci.nsIMsgFolder ? folder.URI : folder;
+ if (folderTree.selectedRow?.uri == folderURI) {
+ // Already set to display the right folder. Make sure not not to change
+ // to the same folder in a different folder mode.
+ return;
+ }
+
+ let row = folderPane.getRowForFolder(folderURI);
+ if (!row) {
+ return;
+ }
+
+ let collapsedAncestor = row.parentNode.closest("#folderTree li.collapsed");
+ while (collapsedAncestor) {
+ folderTree.expandRow(collapsedAncestor);
+ collapsedAncestor = collapsedAncestor.parentNode.closest(
+ "#folderTree li.collapsed"
+ );
+ }
+ folderTree.selectedRow = row;
+}
+
+/**
+ * Update the thread pane selection if it doesn't already match `msgHdr`.
+ * The selected folder will be changed if necessary. If the selection
+ * changes, the message pane will also be updated (via a "select" event).
+ *
+ * @param {nsIMsgDBHdr} msgHdr
+ */
+function selectMessage(msgHdr) {
+ if (
+ gDBView?.numSelected == 1 &&
+ gDBView.hdrForFirstSelectedMessage == msgHdr
+ ) {
+ return;
+ }
+
+ let index = threadTree.view?.findIndexOfMsgHdr(msgHdr, true);
+ // Change to correct folder if needed. We might not be in a folder, or the
+ // message might not be found in the current folder.
+ if (index === undefined || index === nsMsgViewIndex_None) {
+ threadPane.forgetSelection(msgHdr.folder.URI);
+ displayFolder(msgHdr.folder.URI);
+ index = threadTree.view.findIndexOfMsgHdr(msgHdr, true);
+ threadTree.scrollToIndex(index, true);
+ }
+ threadTree.selectedIndex = index;
+}
+
+var folderListener = {
+ QueryInterface: ChromeUtils.generateQI(["nsIFolderListener"]),
+ onFolderAdded(parentFolder, childFolder) {
+ folderPane.addFolder(parentFolder, childFolder);
+ folderPane.updateFolderRowUIElements();
+ },
+ onMessageAdded(parentFolder, msg) {},
+ onFolderRemoved(parentFolder, childFolder) {
+ folderPane.removeFolder(parentFolder, childFolder);
+ if (childFolder == gFolder) {
+ gFolder = null;
+ gViewWrapper?.close(true);
+ }
+ },
+ onMessageRemoved(parentFolder, msg) {},
+ onFolderPropertyChanged(folder, property, oldValue, newValue) {},
+ onFolderIntPropertyChanged(folder, property, oldValue, newValue) {
+ switch (property) {
+ case "BiffState":
+ folderPane.changeNewMessages(
+ folder,
+ newValue === Ci.nsIMsgFolder.nsMsgBiffState_NewMail
+ );
+ break;
+ case "FolderFlag":
+ folderPane.changeFolderFlag(folder, oldValue, newValue);
+ break;
+ case "FolderSize":
+ folderPane.changeFolderSize(folder);
+ break;
+ case "TotalUnreadMessages":
+ if (oldValue == newValue) {
+ break;
+ }
+ folderPane.changeUnreadCount(folder, newValue);
+ break;
+ case "TotalMessages":
+ if (oldValue == newValue) {
+ break;
+ }
+ folderPane.changeTotalCount(folder, newValue);
+ threadPaneHeader.updateFolderCount(folder, newValue);
+ break;
+ }
+ },
+ onFolderBoolPropertyChanged(folder, property, oldValue, newValue) {
+ switch (property) {
+ case "isDeferred":
+ if (newValue) {
+ folderPane.removeFolder(null, folder);
+ } else {
+ folderPane.addFolder(null, folder);
+ for (let f of folder.descendants) {
+ folderPane.addFolder(f.parent, f);
+ }
+ }
+ break;
+ case "NewMessages":
+ folderPane.changeNewMessages(folder, newValue);
+ break;
+ }
+ },
+ onFolderUnicharPropertyChanged(folder, property, oldValue, newValue) {
+ switch (property) {
+ case "Name":
+ if (folder.isServer) {
+ folderPane.changeServerName(folder, newValue);
+ }
+ break;
+ }
+ },
+ onFolderPropertyFlagChanged(folder, property, oldFlag, newFlag) {},
+ onFolderEvent(folder, event) {
+ if (event == "RenameCompleted") {
+ // If a folder is renamed, we get an `onFolderAdded` notification for
+ // the folder but we are not notified about the descendants.
+ for (let f of folder.descendants) {
+ folderPane.addFolder(f.parent, f);
+ }
+ }
+ },
+};
+
+/**
+ * Custom element for rows in the thread tree.
+ */
+customElements.whenDefined("tree-view-table-row").then(() => {
+ class ThreadRow extends customElements.get("tree-view-table-row") {
+ static ROW_HEIGHT = 22;
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+
+ super.connectedCallback();
+
+ this.setAttribute("draggable", "true");
+ this.appendChild(threadPane.rowTemplate.content.cloneNode(true));
+ }
+
+ get index() {
+ return super.index;
+ }
+
+ set index(index) {
+ super.index = index;
+
+ let textColumns = [];
+ for (let column of threadPane.columns) {
+ // No need to update the text of this cell if it's hidden, the selection
+ // column, or an icon column that doesn't match a specific flag.
+ if (column.hidden || column.icon || column.select) {
+ continue;
+ }
+ textColumns.push(column.id);
+ }
+
+ // XPCOM calls here must be keep to a minimum. Collect all of the
+ // required data in one go.
+ let properties = {};
+ let threadLevel = {};
+ let cellTexts = this.view.cellDataForColumns(
+ index,
+ textColumns,
+ properties,
+ threadLevel
+ );
+
+ // Collect the various strings and fluent IDs to build the full string for
+ // the message row aria-label.
+ let ariaLabelPromises = [];
+
+ const propertiesSet = new Set(properties.value.split(" "));
+ const isDummyRow = propertiesSet.has("dummy");
+
+ this.dataset.properties = properties.value.trim();
+
+ for (let column of threadPane.columns) {
+ // Skip this column if it's hidden or it's the "select" column, since
+ // the selection state is communicated via the aria-activedescendant.
+ if (column.hidden || column.select) {
+ continue;
+ }
+ let cell = this.querySelector(`.${column.id.toLowerCase()}-column`);
+ let textIndex = textColumns.indexOf(column.id);
+
+ // Special case for the subject column.
+ if (column.id == "subjectCol") {
+ const div = cell.querySelector(".subject-line");
+
+ // Indent child message of this thread.
+ div.style.setProperty(
+ "--thread-level",
+ gViewWrapper.showGroupedBySort ? 0 : threadLevel.value
+ );
+
+ let imageFluentID = this.#getMessageIndicatorString(propertiesSet);
+ const image = div.querySelector("img");
+ if (imageFluentID && !isDummyRow) {
+ document.l10n.setAttributes(image, imageFluentID);
+ } else {
+ image.removeAttribute("data-l10n-id");
+ image.alt = "";
+ }
+
+ const span = div.querySelector("span");
+ cell.title = span.textContent = cellTexts[textIndex];
+ ariaLabelPromises.push(cellTexts[textIndex]);
+ continue;
+ }
+
+ if (column.id == "threadCol") {
+ let buttonL10nId, labelString;
+ if (propertiesSet.has("ignore")) {
+ buttonL10nId = "tree-list-view-row-ignored-thread-button";
+ labelString = "tree-list-view-row-ignored-thread";
+ } else if (propertiesSet.has("ignoreSubthread")) {
+ buttonL10nId = "tree-list-view-row-ignored-subthread-button";
+ labelString = "tree-list-view-row-ignored-subthread";
+ } else if (propertiesSet.has("watch")) {
+ buttonL10nId = "tree-list-view-row-watched-thread-button";
+ labelString = "tree-list-view-row-watched-thread";
+ } else if (this.classList.contains("children")) {
+ buttonL10nId = "tree-list-view-row-thread-button";
+ }
+
+ let button = cell.querySelector("button");
+ if (buttonL10nId) {
+ document.l10n.setAttributes(button, buttonL10nId);
+ }
+ if (labelString) {
+ ariaLabelPromises.push(document.l10n.formatValue(labelString));
+ }
+ continue;
+ }
+
+ if (column.id == "flaggedCol") {
+ let button = cell.querySelector("button");
+ if (propertiesSet.has("flagged")) {
+ document.l10n.setAttributes(button, "tree-list-view-row-flagged");
+ ariaLabelPromises.push(
+ document.l10n.formatValue("threadpane-flagged-cell-label")
+ );
+ } else {
+ document.l10n.setAttributes(button, "tree-list-view-row-flag");
+ }
+ continue;
+ }
+
+ if (column.id == "junkStatusCol") {
+ let button = cell.querySelector("button");
+ if (propertiesSet.has("junk")) {
+ document.l10n.setAttributes(button, "tree-list-view-row-spam");
+ ariaLabelPromises.push(
+ document.l10n.formatValue("threadpane-spam-cell-label")
+ );
+ } else {
+ document.l10n.setAttributes(button, "tree-list-view-row-not-spam");
+ }
+ continue;
+ }
+
+ if (column.id == "unreadButtonColHeader") {
+ let button = cell.querySelector("button");
+ if (propertiesSet.has("read")) {
+ document.l10n.setAttributes(button, "tree-list-view-row-read");
+ ariaLabelPromises.push(
+ document.l10n.formatValue("threadpane-read-cell-label")
+ );
+ } else {
+ document.l10n.setAttributes(button, "tree-list-view-row-not-read");
+ ariaLabelPromises.push(
+ document.l10n.formatValue("threadpane-unread-cell-label")
+ );
+ }
+ continue;
+ }
+
+ if (column.id == "attachmentCol" && propertiesSet.has("attach")) {
+ ariaLabelPromises.push(
+ document.l10n.formatValue("threadpane-attachments-cell-label")
+ );
+ continue;
+ }
+
+ if (textIndex >= 0) {
+ if (isDummyRow) {
+ cell.textContent = "";
+ continue;
+ }
+ cell.textContent = cellTexts[textIndex];
+ ariaLabelPromises.push(cellTexts[textIndex]);
+ }
+ }
+
+ Promise.allSettled(ariaLabelPromises).then(results => {
+ this.setAttribute(
+ "aria-label",
+ results
+ .map(settledPromise => settledPromise.value ?? "")
+ .filter(value => value.trim() != "")
+ .join(", ")
+ );
+ });
+ }
+
+ /**
+ * Find the fluent ID matching the current message state.
+ *
+ * @param {Set} propertiesSet - The Set() of properties for the row.
+ * @returns {?string} - The fluent ID string if we found one, otherwise null.
+ */
+ #getMessageIndicatorString(propertiesSet) {
+ // Bail out early if this is a new message since it can't be anything else.
+ if (propertiesSet.has("new")) {
+ return "threadpane-message-new";
+ }
+
+ const isReplied = propertiesSet.has("replied");
+ const isForwarded = propertiesSet.has("forwarded");
+ const isRedirected = propertiesSet.has("redirected");
+
+ if (isReplied && !isForwarded && !isRedirected) {
+ return "threadpane-message-replied";
+ }
+
+ if (isRedirected && !isForwarded && !isReplied) {
+ return "threadpane-message-redirected";
+ }
+
+ if (isForwarded && !isReplied && !isRedirected) {
+ return "threadpane-message-forwarded";
+ }
+
+ if (isReplied && isForwarded && !isRedirected) {
+ return "threadpane-message-replied-forwarded";
+ }
+
+ if (isReplied && isRedirected && !isForwarded) {
+ return "threadpane-message-replied-redirected";
+ }
+
+ if (isForwarded && isRedirected && !isReplied) {
+ return "threadpane-message-forwarded-redirected";
+ }
+
+ if (isReplied && isForwarded && isRedirected) {
+ return "threadpane-message-replied-forwarded-redirected";
+ }
+
+ return null;
+ }
+ }
+ customElements.define("thread-row", ThreadRow, { extends: "tr" });
+
+ class ThreadCard extends customElements.get("tree-view-table-row") {
+ static ROW_HEIGHT = 46;
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+
+ super.connectedCallback();
+
+ this.setAttribute("draggable", "true");
+
+ this.appendChild(
+ document
+ .getElementById("threadPaneCardTemplate")
+ .content.cloneNode(true)
+ );
+
+ this.senderLine = this.querySelector(".sender");
+ this.subjectLine = this.querySelector(".subject");
+ this.dateLine = this.querySelector(".date");
+ this.starButton = this.querySelector(".button-star");
+ this.tagIcon = this.querySelector(".tag-icon");
+ }
+
+ get index() {
+ return super.index;
+ }
+
+ set index(index) {
+ super.index = index;
+
+ // XPCOM calls here must be keep to a minimum. Collect all of the
+ // required data in one go.
+ let properties = {};
+ let threadLevel = {};
+
+ let cellTexts = this.view.cellDataForColumns(
+ index,
+ threadPane.cardColumns,
+ properties,
+ threadLevel
+ );
+
+ // Collect the various strings and fluent IDs to build the full string for
+ // the message row aria-label.
+ let ariaLabelPromises = [];
+
+ if (threadLevel.value) {
+ properties.value += " thread-children";
+ }
+ const propertiesSet = new Set(properties.value.split(" "));
+ this.dataset.properties = properties.value.trim();
+
+ this.subjectLine.textContent = cellTexts[0];
+ this.subjectLine.title = cellTexts[0];
+ this.senderLine.textContent = cellTexts[1];
+ this.dateLine.textContent = cellTexts[2];
+ this.tagIcon.title = cellTexts[3];
+
+ // Follow the layout order.
+ ariaLabelPromises.push(cellTexts[1]);
+ ariaLabelPromises.push(cellTexts[2]);
+ ariaLabelPromises.push(cellTexts[0]);
+ ariaLabelPromises.push(cellTexts[3]);
+
+ if (propertiesSet.has("flagged")) {
+ document.l10n.setAttributes(
+ this.starButton,
+ "tree-list-view-row-flagged"
+ );
+ ariaLabelPromises.push(
+ document.l10n.formatValue("threadpane-flagged-cell-label")
+ );
+ } else {
+ document.l10n.setAttributes(this.starButton, "tree-list-view-row-flag");
+ }
+
+ if (propertiesSet.has("junk")) {
+ ariaLabelPromises.push(
+ document.l10n.formatValue("threadpane-spam-cell-label")
+ );
+ }
+
+ if (propertiesSet.has("read")) {
+ ariaLabelPromises.push(
+ document.l10n.formatValue("threadpane-read-cell-label")
+ );
+ }
+
+ if (propertiesSet.has("unread")) {
+ ariaLabelPromises.push(
+ document.l10n.formatValue("threadpane-unread-cell-label")
+ );
+ }
+
+ if (propertiesSet.has("attach")) {
+ ariaLabelPromises.push(
+ document.l10n.formatValue("threadpane-attachments-cell-label")
+ );
+ }
+
+ Promise.allSettled(ariaLabelPromises).then(results => {
+ this.setAttribute(
+ "aria-label",
+ results
+ .map(settledPromise => settledPromise.value ?? "")
+ .filter(value => value.trim() != "")
+ .join(", ")
+ );
+ });
+ }
+ }
+ customElements.define("thread-card", ThreadCard, {
+ extends: "tr",
+ });
+});
+
+commandController.registerCallback(
+ "cmd_newFolder",
+ (folder = gFolder) => folderPane.newFolder(folder),
+ () => folderPaneContextMenu.getCommandState("cmd_newFolder")
+);
+commandController.registerCallback("cmd_newVirtualFolder", (folder = gFolder) =>
+ folderPane.newVirtualFolder(undefined, undefined, folder)
+);
+commandController.registerCallback(
+ "cmd_deleteFolder",
+ (folder = gFolder) => folderPane.deleteFolder(folder),
+ () => folderPaneContextMenu.getCommandState("cmd_deleteFolder")
+);
+commandController.registerCallback(
+ "cmd_renameFolder",
+ (folder = gFolder) => folderPane.renameFolder(folder),
+ () => folderPaneContextMenu.getCommandState("cmd_renameFolder")
+);
+commandController.registerCallback(
+ "cmd_compactFolder",
+ (folder = gFolder) => {
+ if (folder.isServer) {
+ folderPane.compactAllFoldersForAccount(folder);
+ } else {
+ folderPane.compactFolder(folder);
+ }
+ },
+ () => folderPaneContextMenu.getCommandState("cmd_compactFolder")
+);
+commandController.registerCallback(
+ "cmd_emptyTrash",
+ (folder = gFolder) => folderPane.emptyTrash(folder),
+ () => folderPaneContextMenu.getCommandState("cmd_emptyTrash")
+);
+commandController.registerCallback(
+ "cmd_properties",
+ (folder = gFolder) => folderPane.editFolder(folder),
+ () => folderPaneContextMenu.getCommandState("cmd_properties")
+);
+commandController.registerCallback(
+ "cmd_toggleFavoriteFolder",
+ (folder = gFolder) => folder.toggleFlag(Ci.nsMsgFolderFlags.Favorite),
+ () => folderPaneContextMenu.getCommandState("cmd_toggleFavoriteFolder")
+);
+
+// Delete commands, which change behaviour based on the active element.
+// Note that `document.activeElement` refers to the active element in *this*
+// document regardless of whether this document is the active one.
+commandController.registerCallback(
+ "cmd_delete",
+ () => {
+ if (document.activeElement == folderTree) {
+ commandController.doCommand("cmd_deleteFolder");
+ } else if (!quickFilterBar.domNode.contains(document.activeElement)) {
+ commandController.doCommand("cmd_deleteMessage");
+ }
+ },
+ () => {
+ if (document.activeElement == folderTree) {
+ return commandController.isCommandEnabled("cmd_deleteFolder");
+ }
+ if (
+ !quickFilterBar?.domNode ||
+ quickFilterBar.domNode.contains(document.activeElement)
+ ) {
+ return false;
+ }
+ return commandController.isCommandEnabled("cmd_deleteMessage");
+ }
+);
+commandController.registerCallback(
+ "cmd_shiftDelete",
+ () => {
+ commandController.doCommand("cmd_shiftDeleteMessage");
+ },
+ () => {
+ if (
+ document.activeElement == folderTree ||
+ !quickFilterBar?.domNode ||
+ quickFilterBar.domNode.contains(document.activeElement)
+ ) {
+ return false;
+ }
+ return commandController.isCommandEnabled("cmd_shiftDeleteMessage");
+ }
+);
+
+commandController.registerCallback("cmd_viewClassicMailLayout", () =>
+ Services.prefs.setIntPref("mail.pane_config.dynamic", 0)
+);
+commandController.registerCallback("cmd_viewWideMailLayout", () =>
+ Services.prefs.setIntPref("mail.pane_config.dynamic", 1)
+);
+commandController.registerCallback("cmd_viewVerticalMailLayout", () =>
+ Services.prefs.setIntPref("mail.pane_config.dynamic", 2)
+);
+commandController.registerCallback(
+ "cmd_toggleThreadPaneHeader",
+ () => threadPaneHeader.toggleThreadPaneHeader(),
+ () => gFolder && !gFolder.isServer
+);
+commandController.registerCallback(
+ "cmd_toggleFolderPane",
+ () => paneLayout.folderPaneSplitter.toggleCollapsed(),
+ () => !!gFolder
+);
+commandController.registerCallback("cmd_toggleMessagePane", () => {
+ paneLayout.messagePaneSplitter.toggleCollapsed();
+});
+
+commandController.registerCallback(
+ "cmd_selectAll",
+ () => {
+ threadTree.selectAll();
+ threadTree.table.body.focus();
+ },
+ () => !!gViewWrapper?.dbView
+);
+commandController.registerCallback(
+ "cmd_selectThread",
+ () => gViewWrapper.dbView.doCommand(Ci.nsMsgViewCommandType.selectThread),
+ () => !!gViewWrapper?.dbView
+);
+commandController.registerCallback(
+ "cmd_selectFlagged",
+ () => gViewWrapper.dbView.doCommand(Ci.nsMsgViewCommandType.selectFlagged),
+ () => !!gViewWrapper?.dbView
+);
+commandController.registerCallback(
+ "cmd_downloadFlagged",
+ () =>
+ gViewWrapper.dbView.doCommand(
+ Ci.nsMsgViewCommandType.downloadFlaggedForOffline
+ ),
+ () => gFolder && !gFolder.isServer && MailOfflineMgr.isOnline()
+);
+commandController.registerCallback(
+ "cmd_downloadSelected",
+ () =>
+ gViewWrapper.dbView.doCommand(
+ Ci.nsMsgViewCommandType.downloadSelectedForOffline
+ ),
+ () =>
+ gFolder &&
+ !gFolder.isServer &&
+ MailOfflineMgr.isOnline() &&
+ gViewWrapper.dbView.selectedCount > 0
+);
+
+var sortController = {
+ handleCommand(event) {
+ switch (event.target.value) {
+ case "ascending":
+ this.sortAscending();
+ threadPane.restoreSortIndicator();
+ break;
+ case "descending":
+ this.sortDescending();
+ threadPane.restoreSortIndicator();
+ break;
+ case "threaded":
+ this.sortThreaded();
+ break;
+ case "unthreaded":
+ this.sortUnthreaded();
+ break;
+ case "group":
+ this.groupBySort();
+ break;
+ default:
+ if (event.target.value in Ci.nsMsgViewSortType) {
+ this.sortThreadPane(event.target.value);
+ threadPane.restoreSortIndicator();
+ }
+ break;
+ }
+ },
+ sortByThread() {
+ threadPane.updateListRole(false);
+ gViewWrapper.showThreaded = true;
+ this.sortThreadPane("byDate");
+ },
+ sortThreadPane(sortName) {
+ let sortType = Ci.nsMsgViewSortType[sortName];
+ let grouped = gViewWrapper.showGroupedBySort;
+ gViewWrapper._threadExpandAll = Boolean(
+ gViewWrapper._viewFlags & Ci.nsMsgViewFlagsType.kExpandAll
+ );
+
+ if (!grouped) {
+ threadTree.style.scrollBehavior = "auto"; // Avoid smooth scroll.
+ gViewWrapper.sort(sortType, Ci.nsMsgViewSortOrder.ascending);
+ threadTree.style.scrollBehavior = null;
+ // Respect user's last expandAll/collapseAll choice, post sort direction change.
+ threadPane.restoreThreadState();
+ return;
+ }
+
+ // legacy behavior dictates we un-group-by-sort if we were. this probably
+ // deserves a UX call...
+
+ // For non virtual folders, do not ungroup (which sorts by the going away
+ // sort) and then sort, as it's a double sort.
+ // For virtual folders, which are rebuilt in the backend in a grouped
+ // change, create a new view upfront rather than applying viewFlags. There
+ // are oddities just applying viewFlags, for example changing out of a
+ // custom column grouped xfvf view with the threads collapsed works (doesn't)
+ // differently than other variations.
+ // So, first set the desired sortType and sortOrder, then set viewFlags in
+ // batch mode, then apply it all (open a new view) with endViewUpdate().
+ gViewWrapper.beginViewUpdate();
+ gViewWrapper._sort = [[sortType, Ci.nsMsgViewSortOrder.ascending]];
+ gViewWrapper.showGroupedBySort = false;
+ gViewWrapper.endViewUpdate();
+
+ // Virtual folders don't persist viewFlags well in the back end,
+ // due to a virtual folder being either 'real' or synthetic, so make
+ // sure it's done here.
+ if (gViewWrapper.isVirtual) {
+ gViewWrapper.dbView.viewFlags = gViewWrapper.viewFlags;
+ }
+ },
+ reverseSortThreadPane() {
+ let grouped = gViewWrapper.showGroupedBySort;
+ gViewWrapper._threadExpandAll = Boolean(
+ gViewWrapper._viewFlags & Ci.nsMsgViewFlagsType.kExpandAll
+ );
+
+ // Grouped By view is special for column click sort direction changes.
+ if (grouped) {
+ if (gDBView.selection.count) {
+ threadPane.saveSelection();
+ }
+
+ if (gViewWrapper.isSingleFolder) {
+ if (gViewWrapper.isVirtual) {
+ gViewWrapper.showGroupedBySort = false;
+ } else {
+ // Must ensure rows are collapsed and kExpandAll is unset.
+ gViewWrapper.dbView.doCommand(Ci.nsMsgViewCommandType.collapseAll);
+ }
+ }
+ }
+
+ if (gViewWrapper.isSortedAscending) {
+ gViewWrapper.sortDescending();
+ } else {
+ gViewWrapper.sortAscending();
+ }
+
+ // Restore Grouped By state post sort direction change.
+ if (grouped) {
+ if (gViewWrapper.isVirtual && gViewWrapper.isSingleFolder) {
+ this.groupBySort();
+ }
+ // Restore Grouped By selection post sort direction change.
+ threadPane.restoreSelection();
+ // Refresh dummy rows in case of collapseAll.
+ threadTree.invalidate();
+ }
+ threadPane.restoreThreadState();
+ },
+ toggleThreaded() {
+ if (gViewWrapper.showThreaded) {
+ threadPane.updateListRole(true);
+ gViewWrapper.showUnthreaded = true;
+ } else {
+ threadPane.updateListRole(false);
+ gViewWrapper.showThreaded = true;
+ }
+ },
+ sortThreaded() {
+ threadPane.updateListRole(false);
+ gViewWrapper.showThreaded = true;
+ },
+ groupBySort() {
+ threadPane.updateListRole(false);
+ gViewWrapper.showGroupedBySort = true;
+ },
+ sortUnthreaded() {
+ threadPane.updateListRole(true);
+ gViewWrapper.showUnthreaded = true;
+ },
+ sortAscending() {
+ if (gViewWrapper.showGroupedBySort && gViewWrapper.isSingleFolder) {
+ if (gViewWrapper.isSortedDescending) {
+ this.reverseSortThreadPane();
+ }
+ return;
+ }
+
+ threadTree.style.scrollBehavior = "auto"; // Avoid smooth scroll.
+ gViewWrapper.sortAscending();
+ threadPane.ensureThreadStateForQuickSearchView();
+ threadTree.style.scrollBehavior = null;
+ },
+ sortDescending() {
+ if (gViewWrapper.showGroupedBySort && gViewWrapper.isSingleFolder) {
+ if (gViewWrapper.isSortedAscending) {
+ this.reverseSortThreadPane();
+ }
+ return;
+ }
+
+ threadTree.style.scrollBehavior = "auto"; // Avoid smooth scroll.
+ gViewWrapper.sortDescending();
+ threadPane.ensureThreadStateForQuickSearchView();
+ threadTree.style.scrollBehavior = null;
+ },
+ convertSortTypeToColumnID(sortKey) {
+ let columnID;
+
+ // Hack to turn this into an integer, if it was a string.
+ // It would be a string if it came from XULStore.json.
+ sortKey = sortKey - 0;
+
+ switch (sortKey) {
+ // In the case of None, we default to the date column. This appears to be
+ // the case in such instances as Global search, so don't complain about
+ // it.
+ case Ci.nsMsgViewSortType.byNone:
+ case Ci.nsMsgViewSortType.byDate:
+ columnID = "dateCol";
+ break;
+ case Ci.nsMsgViewSortType.byReceived:
+ columnID = "receivedCol";
+ break;
+ case Ci.nsMsgViewSortType.byAuthor:
+ columnID = "senderCol";
+ break;
+ case Ci.nsMsgViewSortType.byRecipient:
+ columnID = "recipientCol";
+ break;
+ case Ci.nsMsgViewSortType.bySubject:
+ columnID = "subjectCol";
+ break;
+ case Ci.nsMsgViewSortType.byLocation:
+ columnID = "locationCol";
+ break;
+ case Ci.nsMsgViewSortType.byAccount:
+ columnID = "accountCol";
+ break;
+ case Ci.nsMsgViewSortType.byUnread:
+ columnID = "unreadButtonColHeader";
+ break;
+ case Ci.nsMsgViewSortType.byStatus:
+ columnID = "statusCol";
+ break;
+ case Ci.nsMsgViewSortType.byTags:
+ columnID = "tagsCol";
+ break;
+ case Ci.nsMsgViewSortType.bySize:
+ columnID = "sizeCol";
+ break;
+ case Ci.nsMsgViewSortType.byPriority:
+ columnID = "priorityCol";
+ break;
+ case Ci.nsMsgViewSortType.byFlagged:
+ columnID = "flaggedCol";
+ break;
+ case Ci.nsMsgViewSortType.byThread:
+ columnID = "threadCol";
+ break;
+ case Ci.nsMsgViewSortType.byId:
+ columnID = "idCol";
+ break;
+ case Ci.nsMsgViewSortType.byJunkStatus:
+ columnID = "junkStatusCol";
+ break;
+ case Ci.nsMsgViewSortType.byAttachments:
+ columnID = "attachmentCol";
+ break;
+ case Ci.nsMsgViewSortType.byCustom:
+ // TODO: either change try() catch to if (property exists) or restore
+ // the getColumnHandler() check.
+ try {
+ // getColumnHandler throws an error when the ID is not handled
+ columnID = gDBView.curCustomColumn;
+ } catch (e) {
+ // error - means no handler
+ dump(
+ "ConvertSortTypeToColumnID: custom sort key but no handler for column '" +
+ columnID +
+ "'\n"
+ );
+ columnID = "dateCol";
+ }
+ break;
+ case Ci.nsMsgViewSortType.byCorrespondent:
+ columnID = "correspondentCol";
+ break;
+ default:
+ dump("unsupported sort key: " + sortKey + "\n");
+ columnID = "dateCol";
+ break;
+ }
+ return columnID;
+ },
+};
+
+commandController.registerCallback(
+ "cmd_sort",
+ event => sortController.handleCommand(event),
+ () => !!gViewWrapper?.dbView
+);
+
+commandController.registerCallback(
+ "cmd_expandAllThreads",
+ () => {
+ threadPane.saveSelection();
+ gViewWrapper.dbView.doCommand(Ci.nsMsgViewCommandType.expandAll);
+ gViewWrapper._threadExpandAll = true;
+ threadPane.restoreSelection();
+ },
+ () => !!gViewWrapper?.dbView
+);
+commandController.registerCallback(
+ "cmd_collapseAllThreads",
+ () => {
+ threadPane.saveSelection();
+ gViewWrapper.dbView.doCommand(Ci.nsMsgViewCommandType.collapseAll);
+ gViewWrapper._threadExpandAll = false;
+ threadPane.restoreSelection({ expand: false });
+ },
+ () => !!gViewWrapper?.dbView
+);
+
+function SwitchView(command) {
+ // when switching thread views, we might be coming out of quick search
+ // or a message view.
+ // first set view picker to all
+ if (gViewWrapper.mailViewIndex != 0) {
+ // MailViewConstants.kViewItemAll
+ gViewWrapper.setMailView(0);
+ }
+
+ switch (command) {
+ // "All" threads and "Unread" threads don't change threading state
+ case "cmd_viewAllMsgs":
+ gViewWrapper.showUnreadOnly = false;
+ break;
+ case "cmd_viewUnreadMsgs":
+ gViewWrapper.showUnreadOnly = true;
+ break;
+ // "Threads with Unread" and "Watched Threads with Unread" force threading
+ case "cmd_viewWatchedThreadsWithUnread":
+ gViewWrapper.specialViewWatchedThreadsWithUnread = true;
+ break;
+ case "cmd_viewThreadsWithUnread":
+ gViewWrapper.specialViewThreadsWithUnread = true;
+ break;
+ // "Ignored Threads" toggles 'ignored' inclusion --
+ // but it also resets 'With Unread' views to 'All'
+ case "cmd_viewIgnoredThreads":
+ gViewWrapper.showIgnored = !gViewWrapper.showIgnored;
+ break;
+ }
+}
+
+commandController.registerCallback(
+ "cmd_viewAllMsgs",
+ () => SwitchView("cmd_viewAllMsgs"),
+ () => !!gDBView
+);
+commandController.registerCallback(
+ "cmd_viewThreadsWithUnread",
+ () => SwitchView("cmd_viewThreadsWithUnread"),
+ () => gDBView && gFolder && !(gFolder.flags & Ci.nsMsgFolderFlags.Virtual)
+);
+commandController.registerCallback(
+ "cmd_viewWatchedThreadsWithUnread",
+ () => SwitchView("cmd_viewWatchedThreadsWithUnread"),
+ () => gDBView && gFolder && !(gFolder.flags & Ci.nsMsgFolderFlags.Virtual)
+);
+commandController.registerCallback(
+ "cmd_viewUnreadMsgs",
+ () => SwitchView("cmd_viewUnreadMsgs"),
+ () => gDBView && gFolder && !(gFolder.flags & Ci.nsMsgFolderFlags.Virtual)
+);
+commandController.registerCallback(
+ "cmd_viewIgnoredThreads",
+ () => SwitchView("cmd_viewIgnoredThreads"),
+ () => !!gDBView
+);
+
+commandController.registerCallback("cmd_goStartPage", () => {
+ // This is a user-triggered command, they must want to see the page, so show
+ // the message pane if it's hidden.
+ paneLayout.messagePaneSplitter.expand();
+ messagePane.showStartPage();
+});
+commandController.registerCallback(
+ "cmd_print",
+ async () => {
+ let PrintUtils = top.PrintUtils;
+ if (!webBrowser.hidden) {
+ PrintUtils.startPrintWindow(webBrowser.browsingContext);
+ return;
+ }
+ let uris = gViewWrapper.dbView.getURIsForSelection();
+ if (uris.length == 1) {
+ if (messageBrowser.hidden) {
+ // Load the only message in a hidden browser, then use the print preview UI.
+ let messageService = MailServices.messageServiceFromURI(uris[0]);
+ await PrintUtils.loadPrintBrowser(
+ messageService.getUrlForUri(uris[0]).spec
+ );
+ PrintUtils.startPrintWindow(
+ PrintUtils.printBrowser.browsingContext,
+ {}
+ );
+ } else {
+ PrintUtils.startPrintWindow(
+ messageBrowser.contentWindow.getMessagePaneBrowser().browsingContext,
+ {}
+ );
+ }
+ return;
+ }
+
+ // Multiple messages. Get the printer settings, then load the messages into
+ // a hidden browser and print them one at a time.
+ let ps = PrintUtils.getPrintSettings();
+ Cc["@mozilla.org/widget/printdialog-service;1"]
+ .getService(Ci.nsIPrintDialogService)
+ .showPrintDialog(window, false, ps);
+ if (ps.isCancelled) {
+ return;
+ }
+ ps.printSilent = true;
+
+ for (let uri of uris) {
+ let messageService = MailServices.messageServiceFromURI(uri);
+ await PrintUtils.loadPrintBrowser(messageService.getUrlForUri(uri).spec);
+ await PrintUtils.printBrowser.browsingContext.print(ps);
+ }
+ },
+ () => {
+ if (!accountCentralBrowser?.hidden) {
+ return false;
+ }
+ if (webBrowser && !webBrowser.hidden) {
+ return true;
+ }
+ return gDBView && gDBView.numSelected > 0;
+ }
+);
+commandController.registerCallback(
+ "cmd_recalculateJunkScore",
+ () => analyzeMessagesForJunk(),
+ () => {
+ // We're going to take a conservative position here, because we really
+ // don't want people running junk controls on folders that are not
+ // enabled for junk. The junk type picks up possible dummy message headers,
+ // while the runJunkControls will prevent running on XF virtual folders.
+ return (
+ commandController._getViewCommandStatus(Ci.nsMsgViewCommandType.junk) &&
+ commandController._getViewCommandStatus(
+ Ci.nsMsgViewCommandType.runJunkControls
+ )
+ );
+ }
+);
+commandController.registerCallback(
+ "cmd_runJunkControls",
+ () => filterFolderForJunk(gFolder),
+ () =>
+ commandController._getViewCommandStatus(
+ Ci.nsMsgViewCommandType.runJunkControls
+ )
+);
+commandController.registerCallback(
+ "cmd_deleteJunk",
+ () => deleteJunkInFolder(gFolder),
+ () =>
+ commandController._getViewCommandStatus(Ci.nsMsgViewCommandType.deleteJunk)
+);
+
+commandController.registerCallback(
+ "cmd_killThread",
+ () => {
+ threadPane.hideIgnoredMessageNotification();
+ if (!gFolder.msgDatabase.isIgnored(gDBView.keyForFirstSelectedMessage)) {
+ threadPane.showIgnoredMessageNotification(
+ gDBView.getSelectedMsgHdrs(),
+ false
+ );
+ }
+ commandController._navigate(Ci.nsMsgNavigationType.toggleThreadKilled);
+ // Invalidation should be unnecessary but the back end doesn't notify us
+ // properly and resists attempts to fix this.
+ threadTree.reset();
+ },
+ () => gDBView?.numSelected >= 1 && (gFolder || gViewWrapper.isSynthetic)
+);
+commandController.registerCallback(
+ "cmd_killSubthread",
+ () => {
+ threadPane.hideIgnoredMessageNotification();
+ if (!gDBView.hdrForFirstSelectedMessage.isKilled) {
+ threadPane.showIgnoredMessageNotification(
+ gDBView.getSelectedMsgHdrs(),
+ true
+ );
+ }
+ commandController._navigate(Ci.nsMsgNavigationType.toggleSubthreadKilled);
+ // Invalidation should be unnecessary but the back end doesn't notify us
+ // properly and resists attempts to fix this.
+ threadTree.reset();
+ },
+ () => gDBView?.numSelected >= 1 && (gFolder || gViewWrapper.isSynthetic)
+);
+
+// Forward these commands directly to about:message.
+commandController.registerCallback(
+ "cmd_find",
+ () =>
+ this.messageBrowser.contentWindow.commandController.doCommand("cmd_find"),
+ () => this.messageBrowser && !this.messageBrowser.hidden
+);
+commandController.registerCallback(
+ "cmd_findAgain",
+ () =>
+ this.messageBrowser.contentWindow.commandController.doCommand(
+ "cmd_findAgain"
+ ),
+ () => this.messageBrowser && !this.messageBrowser.hidden
+);
+commandController.registerCallback(
+ "cmd_findPrevious",
+ () =>
+ this.messageBrowser.contentWindow.commandController.doCommand(
+ "cmd_findPrevious"
+ ),
+ () => this.messageBrowser && !this.messageBrowser.hidden
+);
+
+/**
+ * Helper function for the zoom commands, which returns the browser that is
+ * currently visible in the message pane or null if no browser is visible.
+ *
+ * @returns {?XULElement} - A XUL browser or null.
+ */
+function visibleMessagePaneBrowser() {
+ if (webBrowser && !webBrowser.hidden) {
+ return webBrowser;
+ }
+
+ if (messageBrowser && !messageBrowser.hidden) {
+ // If the message browser is the one visible, actually return the
+ // element showing the message's content, since that's the one zoom
+ // commands should apply to.
+ return messageBrowser.contentDocument.getElementById("messagepane");
+ }
+
+ if (multiMessageBrowser && !multiMessageBrowser.hidden) {
+ return multiMessageBrowser;
+ }
+
+ return null;
+}
+
+// Zoom.
+commandController.registerCallback(
+ "cmd_fullZoomReduce",
+ () => top.ZoomManager.reduce(visibleMessagePaneBrowser()),
+ () => visibleMessagePaneBrowser() != null
+);
+commandController.registerCallback(
+ "cmd_fullZoomEnlarge",
+ () => top.ZoomManager.enlarge(visibleMessagePaneBrowser()),
+ () => visibleMessagePaneBrowser() != null
+);
+commandController.registerCallback(
+ "cmd_fullZoomReset",
+ () => top.ZoomManager.reset(visibleMessagePaneBrowser()),
+ () => visibleMessagePaneBrowser() != null
+);
+commandController.registerCallback(
+ "cmd_fullZoomToggle",
+ () => top.ZoomManager.toggleZoom(visibleMessagePaneBrowser()),
+ () => visibleMessagePaneBrowser() != null
+);
+
+// Browser commands.
+commandController.registerCallback(
+ "Browser:Back",
+ () => webBrowser.goBack(),
+ () => webBrowser?.canGoBack
+);
+commandController.registerCallback(
+ "Browser:Forward",
+ () => webBrowser.goForward(),
+ () => webBrowser?.canGoForward
+);
+commandController.registerCallback(
+ "cmd_reload",
+ () => webBrowser.reload(),
+ () => webBrowser && !webBrowser.busy
+);
+commandController.registerCallback(
+ "cmd_stop",
+ () => webBrowser.stop(),
+ () => webBrowser && webBrowser.busy
+);
+
+// Attachments commands.
+for (let command of [
+ "cmd_openAllAttachments",
+ "cmd_saveAllAttachments",
+ "cmd_detachAllAttachments",
+ "cmd_deleteAllAttachments",
+]) {
+ commandController.registerCallback(
+ command,
+ () => messageBrowser.contentWindow.commandController.doCommand(command),
+ () =>
+ messageBrowser &&
+ !messageBrowser.hidden &&
+ messageBrowser.contentWindow.commandController.isCommandEnabled(command)
+ );
+}
diff --git a/comm/mail/base/content/about3Pane.xhtml b/comm/mail/base/content/about3Pane.xhtml
new file mode 100644
index 0000000000..084a5ac7ad
--- /dev/null
+++ b/comm/mail/base/content/about3Pane.xhtml
@@ -0,0 +1,762 @@
+<?xml version="1.0"?>
+<!-- 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/. -->
+
+#filter substitution
+
+<!DOCTYPE html [
+<!ENTITY % messengerDTD SYSTEM "chrome://messenger/locale/messenger.dtd" >
+%messengerDTD;
+<!ENTITY % calendarDTD SYSTEM "chrome://calendar/locale/calendar.dtd">
+%calendarDTD;
+]>
+<html xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ lightweightthemes="true">
+<head>
+ <meta charset="utf-8" />
+ <title></title>
+
+ <link rel="icon" href="chrome://messenger/skin/icons/new/compact/folder.svg" />
+
+ <link rel="stylesheet" href="chrome://messenger/skin/messenger.css" />
+ <link rel="stylesheet" href="chrome://messenger/skin/contextMenu.css" />
+ <link rel="stylesheet" href="chrome://messenger/skin/icons.css" />
+ <link rel="stylesheet" href="chrome://messenger/skin/colors.css" />
+ <link rel="stylesheet" href="chrome://messenger/skin/folderColors.css" />
+ <link rel="stylesheet" href="chrome://messenger/skin/folderMenus.css" />
+ <link rel="stylesheet" href="chrome://messenger/skin/shared/quickFilterBar.css" />
+ <link rel="stylesheet" href="chrome://messenger/skin/shared/tree-listbox.css" />
+ <link rel="stylesheet" href="chrome://messenger/skin/searchBox.css" />
+ <link rel="stylesheet" href="chrome://messenger/skin/about3Pane.css" />
+
+ <link rel="localization" href="messenger/about3Pane.ftl" />
+ <link rel="localization" href="messenger/treeView.ftl" />
+ <link rel="localization" href="messenger/messenger.ftl" />
+ <link rel="localization" href="toolkit/global/textActions.ftl" />
+
+ <script defer="defer" src="chrome://messenger/content/globalOverlay.js"></script>
+ <script defer="defer" src="chrome://global/content/editMenuOverlay.js"></script>
+ <script defer="defer" src="chrome://communicator/content/utilityOverlay.js"></script>
+ <script defer="defer" src="chrome://messenger/content/junkCommands.js"></script>
+ <script defer="defer" src="chrome://messenger/content/mail-offline.js"></script>
+ <script defer="defer" src="chrome://messenger/content/msgViewNavigation.js"></script>
+ <script defer="defer" src="chrome://messenger/content/quickFilterBar.js"></script>
+ <script defer="defer" src="chrome://messenger/content/pane-splitter.js"></script>
+ <script defer="defer" src="chrome://messenger/content/tree-listbox.js"></script>
+ <script defer="defer" type="module" src="chrome://messenger/content/tree-view.mjs"></script>
+ <script defer="defer" src="chrome://messenger/content/jsTreeView.js"></script>
+ <script defer="defer" src="chrome://messenger/content/mailContext.js"></script>
+ <script defer="defer" src="chrome://messenger/content/mailCommon.js"></script>
+ <script defer="defer" src="chrome://messenger/content/about3Pane.js"></script>
+</head>
+<body class="layout-classic">
+ <div id="folderPane" class="collapsed-by-splitter no-overscroll" tabindex="-1">
+ <div id="folderPaneHeaderBar" hidden="hidden">
+# Force a reverse tabindex to work alongside the `flex-direction: row-reverse`
+# in order to guarantee a consistent end alignment of the `#folderPaneMoreButton`.
+ <button id="folderPaneMoreButton"
+ class="button button-flat icon-button icon-only"
+ data-l10n-id="folder-pane-more-menu-button"
+ type="button"
+ tabindex="3"></button>
+ <button id="folderPaneWriteMessage"
+ class="button button-primary icon-button"
+ data-l10n-id="folder-pane-write-message-button"
+ type="button"
+ tabindex="2"
+ disabled="disabled"></button>
+ <button id="folderPaneGetMessages"
+ class="button button-flat icon-button icon-only"
+ data-l10n-id="folder-pane-get-messages-button"
+ type="button"
+ tabindex="1"
+ disabled="disabled"></button>
+ </div>
+ <ul id="folderTree" is="tree-listbox" role="tree"></ul>
+ <template id="modeTemplate">
+ <li class="unselectable">
+ <div class="mode-container">
+ <div class="mode-name"></div>
+ <button class="mode-button button button-flat icon-button icon-only"
+ type="button"
+ data-l10n-id="folder-pane-mode-context-button"
+ tabindex="-1"></button>
+ </div>
+ <ul></ul>
+ </li>
+ </template>
+ <template id="folderTemplate">
+ <div class="container">
+ <div class="twisty">
+ <img class="twisty-icon" src="chrome://global/skin/icons/arrow-down-12.svg" alt="" />
+ </div>
+ <div class="icon"></div>
+ <span class="name" tabindex="-1"></span>
+ <span class="folder-count-badge unread-count"></span>
+ <span class="folder-count-badge total-count" hidden="hidden"></span>
+ <span class="folder-size" hidden="hidden"></span>
+ </div>
+ <ul></ul>
+ </template>
+ </div>
+ <hr is="pane-splitter" id="folderPaneSplitter"
+ resize-direction="horizontal"
+ resize-id="folderPane"
+ collapse-width="100" />
+ <div id="threadPane">
+ <div id="threadPaneHeaderBar" class="list-header-bar">
+ <div class="list-header-bar-container-start"
+ role="region"
+ aria-live="off">
+ <h2 id="threadPaneFolderName" class="list-header-title"></h2>
+ <div id="threadPaneFolderCountContainer">
+ <span id="threadPaneFolderCount"
+ class="thread-pane-count-info"
+ hidden="hidden"></span>
+ <span id="threadPaneSelectedCount"
+ class="thread-pane-count-info"
+ hidden="hidden"></span>
+ </div>
+ </div>
+ <div class="list-header-bar-container-end">
+ <button id="threadPaneQuickFilterButton"
+ class="button icon-button check-button unified-toolbar-button"
+ data-l10n-id="quick-filter-button"
+ oncommand="cmd_toggleQuickFilterBar">
+ <span data-l10n-id="quick-filter-button-label"></span>
+ </button>
+ <button id="threadPaneDisplayButton"
+ class="button button-flat icon-button icon-only"
+ data-l10n-id="thread-pane-header-display-button"
+ type="button">
+ </button>
+ </div>
+ </div>
+#include quickFilterBar.inc.xhtml
+ <tree-view id="threadTree" data-label-id="threadPaneFolderName"/>
+ <!-- Thread pane templates -->
+ <template id="threadPaneApplyColumnMenu">
+ <xul:menu class="applyTo-menu"
+ data-l10n-id="apply-columns-to-menu"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+ <menupopup>
+ <menu class="applyToFolder-menu"
+ data-l10n-id="apply-current-view-to-folder"
+ oncommand="threadPane.confirmApplyColumns(event.target._folder);">
+ <menupopup is="folder-menupopup"
+ class="applyToFolder"
+ showFileHereLabel="false"
+ position="start_before"></menupopup>
+ </menu>
+ <menu class="applyToFolderAndChildren-menu"
+ data-l10n-id="apply-current-view-to-folder-children"
+ oncommand="threadPane.confirmApplyColumns(event.target._folder, true);">
+ <menupopup is="folder-menupopup"
+ class="applyToFolderAndChildren"
+ showFileHereLabel="true"
+ showAccountsFileHere="true"
+ position="start_before"></menupopup>
+ </menu>
+ </menupopup>
+ </xul:menu>
+ </template>
+ <template id="threadPaneApplyViewMenu">
+ <xul:menu class="applyViewTo-menu"
+ data-l10n-id="apply-current-view-to-menu"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+ <menupopup>
+ <menu class="applyViewToFolder-menu"
+ data-l10n-id="apply-current-view-to-folder"
+ oncommand="threadPane.confirmApplyView(event.target._folder);">
+ <menupopup is="folder-menupopup"
+ class="applyViewToFolder"
+ showFileHereLabel="true"
+ position="start_before"></menupopup>
+ </menu>
+ <menu class="applyViewToFolderAndChildren-menu"
+ data-l10n-id="apply-current-view-to-folder-children"
+ oncommand="threadPane.confirmApplyView(event.target._folder, true);">
+ <menupopup is="folder-menupopup"
+ class="applyViewToFolderAndChildren"
+ showFileHereLabel="true"
+ showAccountsFileHere="true"
+ position="start_before"></menupopup>
+ </menu>
+ </menupopup>
+ </xul:menu>
+ </template>
+ <template id="threadPaneRowTemplate">
+ <!-- This template must be kept in sync with thread-pane-columns.mjs. -->
+ <td class="selectcol-column" data-l10n-id="threadpane-cell-select"></td>
+ <td class="tree-view-row-thread threadcol-column button-column" data-l10n-id="threadpane-cell-thread">
+ <button type="button"
+ class="button-flat tree-button-thread"
+ aria-hidden="true"
+ tabindex="-1">
+ <img src="" alt="" />
+ </button>
+ </td>
+ <td class="tree-view-row-flag flaggedcol-column button-column" data-l10n-id="threadpane-cell-flagged">
+ <button type="button"
+ class="button-flat tree-button-flag"
+ aria-hidden="true"
+ tabindex="-1">
+ <img src="" alt="" />
+ </button>
+ </td>
+ <td class="attachmentcol-column button-column" data-l10n-id="threadpane-cell-attachments">
+ <img src="" data-l10n-id="tree-list-view-row-attach" />
+ </td>
+ <td class="subjectcol-column" data-l10n-id="threadpane-cell-subject">
+ <div class="thread-container">
+ <button type="button"
+ class="button button-flat button-reset twisty"
+ aria-hidden="true"
+ tabindex="-1">
+ <img src="" alt="" class="twisty-icon" />
+ </button>
+ <div class="subject-line" tabindex="-1">
+ <img src="" alt="" /><span></span>
+ </div>
+ </div>
+ </td>
+ <td class="tree-view-row-unread unreadbuttoncolheader-column button-column" data-l10n-id="threadpane-cell-read-status">
+ <button type="button"
+ class="button-flat tree-button-unread"
+ aria-hidden="true"
+ tabindex="-1">
+ <img src="" alt="" />
+ </button>
+ </td>
+ <td class="sendercol-column" data-l10n-id="threadpane-cell-sender"></td>
+ <td class="recipientcol-column" data-l10n-id="threadpane-cell-recipient"></td>
+ <td class="correspondentcol-column" data-l10n-id="threadpane-cell-correspondents"></td>
+ <td class="tree-view-row-spam junkstatuscol-column button-column" data-l10n-id="threadpane-cell-spam">
+ <button type="button"
+ class="button-flat tree-button-spam"
+ aria-hidden="true"
+ tabindex="-1">
+ <img src="" alt="" />
+ </button>
+ </td>
+ <td class="datecol-column" data-l10n-id="threadpane-cell-date"></td>
+ <td class="receivedcol-column" data-l10n-id="threadpane-cell-received"></td>
+ <td class="statuscol-column" data-l10n-id="threadpane-cell-status"></td>
+ <td class="sizecol-column" data-l10n-id="threadpane-cell-size"></td>
+ <td class="tagscol-column" data-l10n-id="threadpane-cell-tags"></td>
+ <td class="accountcol-column" data-l10n-id="threadpane-cell-account"></td>
+ <td class="prioritycol-column" data-l10n-id="threadpane-cell-priority"></td>
+ <td class="unreadcol-column" data-l10n-id="threadpane-cell-unread"></td>
+ <td class="totalcol-column" data-l10n-id="threadpane-cell-total"></td>
+ <td class="locationcol-column" data-l10n-id="threadpane-cell-location"></td>
+ <td class="idcol-column" data-l10n-id="threadpane-cell-id"></td>
+ <td class="tree-view-row-delete deletecol-column button-column" data-l10n-id="threadpane-cell-delete">
+ <button type="button"
+ class="button-flat tree-button-delete tree-button-request-delete"
+ tabindex="-1"
+ aria-hidden="true"
+ data-l10n-id="tree-list-view-row-delete">
+ <img src="" alt="" />
+ </button>
+ <button type="button"
+ class="button-flat tree-button-restore tree-button-request-delete"
+ tabindex="-1"
+ aria-hidden="true"
+ data-l10n-id="tree-list-view-row-restore">
+ <img src="" alt="" />
+ </button>
+ </td>
+ </template>
+ <template id="threadPaneCardTemplate">
+ <td>
+ <div class="thread-card-container">
+ <div class="thread-card-row">
+ <span class="sender"></span>
+ <img class="state replied" src="" data-l10n-id="threadpane-message-replied" />
+ <img class="state forwarded" src="" data-l10n-id="threadpane-message-forwarded" />
+ <img class="state redirected" src="" data-l10n-id="threadpane-message-redirected" />
+ <button class="button-spam tree-button-spam"
+ data-l10n-id="tree-list-view-row-spam"
+ aria-hidden="true"
+ tabindex="-1">
+ </button>
+ <span class="date"></span>
+ </div>
+ <div class="thread-card-row">
+ <div class="thread-card-subject-container">
+ <button type="button"
+ class="button button-flat button-reset twisty"
+ aria-hidden="true"
+ tabindex="-1">
+ <img src="" alt="" class="twisty-icon" />
+ </button>
+ <span class="subject"></span>
+ </div>
+ <img class="attachment-icon" src="" data-l10n-id="tree-list-view-row-attach" />
+ <img class="tag-icon" src="" alt="" hidden="hidden" />
+ <button class="button-star tree-button-flag"
+ aria-hidden="true"
+ tabindex="-1">
+ </button>
+ </div>
+ </div>
+ </td>
+ </template>
+ <div id="threadPaneNotificationBox">
+ <!-- notificationbox will be added here lazily. -->
+ </div>
+ </div>
+ <hr is="pane-splitter" id="messagePaneSplitter"
+ resize-id="messagePane"
+ collapse-width="300"
+ collapse-height="100" />
+ <div id="messagePane" class="collapsed-by-splitter">
+ <xul:browser id="webBrowser"
+ type="content"
+ hidden="true"
+ nodefaultsrc="true"
+ context="browserContext"
+ autocompletepopup="PopupAutoComplete"
+ forcemessagemanager="true"
+ messagemanagergroup="single-page"
+ maychangeremoteness="true" />
+ <xul:browser id="messageBrowser"
+ hidden="true"
+ src="about:message" />
+ <xul:browser id="multiMessageBrowser"
+ type="content"
+ hidden="true"
+ context="aboutPagesContext"
+ src="chrome://messenger/content/multimessageview.xhtml" />
+ </div>
+ <xul:browser id="accountCentralBrowser" hidden="true"/>
+</body>
+<popupset xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+ <menupopup id="folderPaneContext">
+ <menuitem id="folderPaneContext-getMessages"
+ class="menuitem-iconic"
+ label="&folderContextGetMessages.label;"
+ accesskey="&folderContextGetMessages.accesskey;"/>
+ <menuitem id="folderPaneContext-pauseAllUpdates"
+ type="checkbox"
+ label="&folderContextPauseAllUpdates.label;"
+ accesskey="&folderContextPauseUpdates.accesskey;"/>
+ <menuitem id="folderPaneContext-pauseUpdates"
+ type="checkbox"
+ label="&folderContextPauseUpdates.label;"
+ accesskey="&folderContextPauseUpdates.accesskey;"/>
+ <menuseparator/>
+ <menuitem id="folderPaneContext-openNewTab"
+ class="menuitem-iconic"
+ label="&folderContextOpenNewTab.label;"
+ accesskey="&folderContextOpenNewTab.accesskey;"/>
+ <menuitem id="folderPaneContext-openNewWindow"
+ class="menuitem-iconic"
+ label="&folderContextOpenInNewWindow.label;"
+ accesskey="&folderContextOpenInNewWindow.accesskey;"/>
+ <menuitem id="folderPaneContext-searchMessages"
+ class="menuitem-iconic"
+ label="&folderContextSearchForMessages.label;"
+ accesskey="&folderContextSearchForMessages.accesskey;"/>
+ <menuitem id="folderPaneContext-subscribe"
+ class="menuitem-iconic"
+ label="&folderContextSubscribe.label;"
+ accesskey="&folderContextSubscribe.accesskey;"/>
+ <menuitem id="folderPaneContext-newsUnsubscribe"
+ class="menuitem-iconic"
+ label="&folderContextUnsubscribe.label;"
+ accesskey="&folderContextUnsubscribe.accesskey;"/>
+ <menuseparator/>
+ <menuitem id="folderPaneContext-new"
+ class="menuitem-iconic"
+ label="&folderContextNew.label;"
+ accesskey="&folderContextNew.accesskey;"/>
+ <menuitem id="folderPaneContext-remove"
+ class="menuitem-iconic"
+ label="&folderContextRemove.label;"
+ accesskey="&folderContextRemove.accesskey;"/>
+ <menuitem id="folderPaneContext-rename"
+ class="menuitem-iconic"
+ label="&folderContextRename.label;"
+ accesskey="&folderContextRename.accesskey;"/>
+ <menuseparator/>
+ <menu id="folderPaneContext-moveMenu"
+ class="menu-iconic"
+ label="&moveMsgToMenu.label;"
+ accesskey="&moveMsgToMenu.accesskey;">
+ <menupopup is="folder-menupopup" id="folderContext-movePopup"
+ mode="filing"
+ showAccountsFileHere="true"
+ showFileHereLabel="true"
+ showRecent="true"
+ recentLabel="&contextMoveCopyMsgRecentMenu.label;"
+ recentAccessKey="&contextMoveCopyMsgRecentMenu.accesskey;"
+ showFavorites="true"
+ favoritesLabel="&contextMoveCopyMsgFavoritesMenu.label;"
+ favoritesAccessKey="&contextMoveCopyMsgFavoritesMenu.accesskey;"
+ showLast="true"/>
+ </menu>
+ <menu id="folderPaneContext-copyMenu"
+ class="menu-iconic"
+ label="&copyMsgToMenu.label;"
+ accesskey="&copyMsgToMenu.accesskey;">
+ <menupopup is="folder-menupopup" id="folderContext-copyPopup"
+ mode="filing"
+ showAccountsFileHere="true"
+ showFileHereLabel="true"
+ showRecent="true"
+ recentLabel="&contextMoveCopyMsgRecentMenu.label;"
+ recentAccessKey="&contextMoveCopyMsgRecentMenu.accesskey;"
+ showFavorites="true"
+ favoritesLabel="&contextMoveCopyMsgFavoritesMenu.label;"
+ favoritesAccessKey="&contextMoveCopyMsgFavoritesMenu.accesskey;"
+ showLast="true"/>
+ </menu>
+ <menuseparator/>
+ <menuitem id="folderPaneContext-compact"
+ class="menuitem-iconic"
+ label="&folderContextCompact.label;"
+ accesskey="&folderContextCompact.accesskey;"/>
+ <menuitem id="folderPaneContext-markMailFolderAllRead"
+ class="menuitem-iconic"
+ label="&folderContextMarkMailFolderRead.label;"
+ accesskey="&folderContextMarkMailFolderRead.accesskey;"/>
+ <menuitem id="folderPaneContext-markNewsgroupAllRead"
+ class="menuitem-iconic"
+ label="&folderContextMarkNewsgroupRead.label;"
+ accesskey="&folderContextMarkNewsgroupRead.accesskey;"/>
+ <menuitem id="folderPaneContext-emptyTrash"
+ class="menuitem-iconic"
+ label="&folderContextEmptyTrash.label;"
+ accesskey="&folderContextEmptyTrash.accesskey;"/>
+ <menuitem id="folderPaneContext-emptyJunk"
+ class="menuitem-iconic"
+ label="&folderContextEmptyJunk.label;"
+ accesskey="&folderContextEmptyJunk.accesskey;"/>
+ <menuitem id="folderPaneContext-sendUnsentMessages"
+ class="menuitem-iconic"
+ label="&folderContextSendUnsentMessages.label;"
+ accesskey="&folderContextSendUnsentMessages.accesskey;"/>
+ <menuseparator/>
+ <menuitem id="folderPaneContext-favoriteFolder"
+ type="checkbox"
+ label="&folderContextFavoriteFolder.label;"
+ accesskey="&folderContextFavoriteFolder.accesskey;"/>
+ <menuitem id="folderPaneContext-properties"
+ class="menuitem-iconic"
+ label="&folderContextProperties2.label;"
+ accesskey="&folderContextProperties2.accesskey;"/>
+ <menuitem id="folderPaneContext-markAllFoldersRead"
+ class="menuitem-iconic"
+ label="&folderContextMarkAllFoldersRead.label;"/>
+ <menuseparator/>
+ <menuitem id="folderPaneContext-settings"
+ class="menuitem-iconic"
+ label="&folderContextSettings2.label;"
+ accesskey="&folderContextSettings2.accesskey;"/>
+ <menuitem id="folderPaneContext-manageTags"
+ class="menuitem-iconic"
+ label="&manageTags.label;"
+ accesskey="&manageTags.accesskey;"/>
+ <menuseparator/>
+ </menupopup>
+ <tooltip id="qfb-text-search-upsell">
+ <div id="qfb-upsell-line-one"
+ data-l10n-id="quick-filter-bar-gloda-upsell-line1"></div>
+ <div id="qfb-upsell-line-two"></div>
+ </tooltip>
+ <menupopup id="folderPaneMoreContext"
+ class="no-accel-menupopup"
+ position="bottomleft topleft"
+ onpopupshowing="folderPane.updateContextMenuCheckedItems();">
+ <menu id="folderModesContextMenu"
+ data-l10n-id="folder-pane-header-folder-modes"
+ position="bottomleft topleft">
+ <menupopup id="folderModesContextMenuPopup"
+ onpopupshowing="folderPane.updateContextCheckedFolderMode();">
+ <menuitem id="folderPaneMoreContextAllFolders"
+ class="folder-pane-mode"
+ value="all"
+ type="checkbox"
+ closemenu="none"
+ data-l10n-id="show-all-folders-label"
+ oncommand="folderPane.toggleFolderMode(event);"/>
+ <menuitem id="folderPaneMoreContextUnifiedFolders"
+ class="folder-pane-mode"
+ value="smart"
+ type="checkbox"
+ closemenu="none"
+ data-l10n-id="show-smart-folders-label"
+ oncommand="folderPane.toggleFolderMode(event);"/>
+ <menuitem id="folderPaneMoreContextUnreadFolders"
+ class="folder-pane-mode"
+ value="unread"
+ type="checkbox" name="viewmessages"
+ closemenu="none"
+ data-l10n-id="show-unread-folders-label"
+ oncommand="folderPane.toggleFolderMode(event);"/>
+ <menuitem id="folderPaneMoreContextFavoriteFolders"
+ class="folder-pane-mode"
+ value="favorite"
+ type="checkbox"
+ closemenu="none"
+ data-l10n-id="show-favorite-folders-label"
+ oncommand="folderPane.toggleFolderMode(event);"/>
+ <menuitem id="folderPaneMoreContextRecentFolders"
+ class="folder-pane-mode"
+ value="recent"
+ type="checkbox"
+ closemenu="none"
+ data-l10n-id="show-recent-folders-label"
+ oncommand="folderPane.toggleFolderMode(event);"/>
+ <menuseparator/>
+ <menuitem id="folderPaneMoreContextTags"
+ class="folder-pane-mode"
+ value="tags"
+ type="checkbox"
+ closemenu="none"
+ data-l10n-id="show-tags-folders-label"
+ oncommand="folderPane.toggleFolderMode(event);"/>
+ <menuseparator id="separatorAfterFolderModes"/>
+ <menuitem id="folderPaneMoreContextCompactToggle"
+ class="compact-folder-button folder-pane-option"
+ value="compact"
+ type="checkbox"
+ closemenu="none"
+ data-l10n-id="folder-pane-mode-context-toggle-compact-mode"
+ oncommand="folderPane.compactFolderToggle(event);"/>
+ </menupopup>
+ </menu>
+ <menuseparator id="separatorAfterFolderViewOptions"/>
+ <menuitem id="folderPaneHeaderToggleGetMessages"
+ class="folder-pane-option"
+ type="checkbox"
+ closemenu="none"
+ data-l10n-id="folder-pane-header-context-toggle-get-messages"
+ oncommand="folderPane.toggleGetMsgsBtn(event);"/>
+ <menuitem id="folderPaneHeaderToggleNewMessage"
+ class="folder-pane-option"
+ type="checkbox"
+ closemenu="none"
+ data-l10n-id="folder-pane-header-context-toggle-new-message"
+ oncommand="folderPane.toggleNewMsgBtn(event);"/>
+ <menuseparator id="separatorAfterToggleButtons"/>
+ <menuitem id="folderPaneHeaderToggleTotalCount"
+ class="folder-pane-option"
+ value="total"
+ type="checkbox"
+ closemenu="none"
+ data-l10n-id="folder-pane-show-total-toggle"
+ oncommand="folderPane.toggleTotal(event);"/>
+ <menuitem id="folderPaneHeaderToggleFolderSize"
+ class="folder-pane-option"
+ type="checkbox"
+ closemenu="none"
+ data-l10n-id="folder-pane-header-toggle-folder-size"
+ oncommand="folderPane.toggleFolderSize(event);"/>
+ <menuitem id="folderPaneHeaderToggleLocalFolders"
+ class="folder-pane-option"
+ type="checkbox"
+ closemenu="none"
+ data-l10n-id="folder-pane-header-hide-local-folders"
+ oncommand="folderPane.toggleLocalFolders(event);"/>
+ <menuseparator id="separatorBeforeHideFolderPaneHeaderOption"/>
+ <menuitem id="folderPaneHeaderHideMenuItem"
+ data-l10n-id="folder-pane-header-context-hide"
+ oncommand="folderPane.toggleHeader(false);"/>
+ </menupopup>
+ <menupopup id="folderPaneModeContext"
+ class="no-accel-menupopup"
+ position="bottomleft topleft">
+ <menuitem id="folderPaneModeMoveUp"
+ class="folder-pane-mode"
+ value="moveup"
+ data-l10n-id="folder-pane-mode-move-up"
+ oncommand="folderPane.moveFolderModeUp(event);"/>
+ <menuitem id="folderPaneModeMoveDown"
+ class="folder-pane-mode"
+ value="movedown"
+ data-l10n-id="folder-pane-mode-move-down"
+ oncommand="folderPane.moveFolderModeDown(event);"/>
+ <menuseparator id="separatorBeforeCompactFolderOption"/>
+ <menuitem id="compactFolderButton"
+ class="compact-folder-button folder-pane-mode"
+ value="compact"
+ type="checkbox"
+ data-l10n-id="folder-pane-mode-context-toggle-compact-mode"
+ oncommand="folderPane.compactFolderToggle(event);"/>
+ </menupopup>
+ <menupopup id="threadPaneDisplayContext"
+ class="no-accel-menupopup"
+ position="bottomleft topleft"
+ onpopupshowing="threadPaneHeader.updateDisplayContextMenu(event);">
+ <menuitem id="threadPaneTableView"
+ class="thread-view-option"
+ type="radio"
+ name="threadview"
+ value="table"
+ closemenu="none"
+ data-l10n-id="thread-pane-header-context-table-view"
+ oncommand="threadPaneHeader.changePaneView(event);"/>
+ <menuitem id="threadPaneCardsView"
+ class="thread-view-option"
+ type="radio"
+ name="threadview"
+ value="cards"
+ closemenu="none"
+ data-l10n-id="thread-pane-header-context-cards-view"
+ oncommand="threadPaneHeader.changePaneView(event);"/>
+ <menuseparator id="separatorBeforeHideThreadHeaderOption"/>
+ <menu id="threadPaneSortMenu"
+ accesskey="&sortMenu.accesskey;"
+ label="&sortMenu.label;">
+ <menupopup id="menu_threadPaneSortPopup"
+ oncommand="goDoCommand('cmd_sort', event);"
+ onpopupshowing="threadPaneHeader.updateThreadPaneSortMenu(event);">
+ <menuitem id="threadPaneSortByDateMenuitem"
+ type="radio"
+ name="sortby"
+ value="byDate"
+ label="&sortByDateCmd.label;"
+ accesskey="&sortByDateCmd.accesskey;"/>
+ <menuitem id="threadPaneSortByReceivedMenuitem"
+ type="radio"
+ name="sortby"
+ value="byReceived"
+ label="&sortByReceivedCmd.label;"
+ accesskey="&sortByReceivedCmd.accesskey;"/>
+ <menuitem id="threadPaneSortByFlagMenuitem"
+ type="radio"
+ name="sortby"
+ value="byFlagged"
+ label="&sortByStarCmd.label;"
+ accesskey="&sortByStarCmd.accesskey;"/>
+ <menuitem id="threadPaneSortByOrderReceivedMenuitem"
+ type="radio"
+ name="sortby"
+ value="byId"
+ label="&sortByOrderReceivedCmd.label;"
+ accesskey="&sortByOrderReceivedCmd.accesskey;"/>
+ <menuitem id="threadPaneSortByPriorityMenuitem"
+ type="radio"
+ name="sortby"
+ value="byPriority"
+ label="&sortByPriorityCmd.label;"
+ accesskey="&sortByPriorityCmd.accesskey;"/>
+ <menuitem id="threadPaneSortByFromMenuitem"
+ type="radio"
+ name="sortby"
+ value="byAuthor"
+ label="&sortByFromCmd.label;"
+ accesskey="&sortByFromCmd.accesskey;"/>
+ <menuitem id="threadPaneSortByRecipientMenuitem"
+ type="radio"
+ name="sortby"
+ value="byRecipient"
+ label="&sortByRecipientCmd.label;"
+ accesskey="&sortByRecipientCmd.accesskey;"/>
+ <menuitem id="threadPaneSortByCorrespondentMenuitem"
+ type="radio"
+ name="sortby"
+ value="byCorrespondent"
+ label="&sortByCorrespondentCmd.label;"
+ accesskey="&sortByCorrespondentCmd.accesskey;"/>
+ <menuitem id="threadPaneSortBySizeMenuitem"
+ type="radio"
+ name="sortby"
+ value="bySize"
+ label="&sortBySizeCmd.label;"
+ accesskey="&sortBySizeCmd.accesskey;"/>
+ <menuitem id="threadPaneSortByStatusMenuitem"
+ type="radio"
+ name="sortby"
+ value="byStatus"
+ label="&sortByStatusCmd.label;"
+ accesskey="&sortByStatusCmd.accesskey;"/>
+ <menuitem id="threadPaneSortBySubjectMenuitem"
+ type="radio"
+ name="sortby"
+ value="bySubject"
+ label="&sortBySubjectCmd.label;"
+ accesskey="&sortBySubjectCmd.accesskey;"/>
+ <menuitem id="threadPaneSortByUnreadMenuitem"
+ type="radio"
+ name="sortby"
+ value="byUnread"
+ label="&sortByUnreadCmd.label;"
+ accesskey="&sortByUnreadCmd.accesskey;"/>
+ <menuitem id="threadPaneSortByTagsMenuitem"
+ type="radio"
+ name="sortby"
+ value="byTags"
+ label="&sortByTagsCmd.label;"
+ accesskey="&sortByTagsCmd.accesskey;"/>
+ <menuitem id="threadPaneSortByJunkStatusMenuitem"
+ type="radio"
+ name="sortby"
+ value="byJunkStatus"
+ label="&sortByJunkStatusCmd.label;"
+ accesskey="&sortByJunkStatusCmd.accesskey;"/>
+ <menuitem id="threadPaneSortByAttachmentsMenuitem"
+ type="radio"
+ name="sortby"
+ value="byAttachments"
+ label="&sortByAttachmentsCmd.label;"
+ accesskey="&sortByAttachmentsCmd.accesskey;"/>
+ <menuseparator id="threadPaneSortAfterAttachmentSeparator"/>
+ <menuitem id="threadPaneSortAscending"
+ type="radio"
+ name="sortdirection"
+ value="ascending"
+ label="&sortAscending.label;"
+ accesskey="&sortAscending.accesskey;"/>
+ <menuitem id="threadPaneSortDescending"
+ type="radio"
+ name="sortdirection"
+ value="descending"
+ label="&sortDescending.label;"
+ accesskey="&sortDescending.accesskey;"/>
+ <menuseparator id="threadPaneSortAfterDescendingSeparator"/>
+ <menuitem id="threadPaneSortThreaded"
+ type="radio"
+ name="threaded"
+ value="threaded"
+ label="&sortThreaded.label;"
+ accesskey="&sortThreaded.accesskey;"/>
+ <menuitem id="threadPaneSortUnthreaded"
+ type="radio"
+ name="threaded"
+ value="unthreaded"
+ label="&sortUnthreaded.label;"
+ accesskey="&sortUnthreaded.accesskey;"/>
+ <menuitem id="threadPaneGroupBySort"
+ type="checkbox"
+ name="group"
+ value="group"
+ label="&groupBySort.label;"
+ accesskey="&groupBySort.accesskey;"/>
+ </menupopup>
+ </menu>
+ <menuseparator id="separatorAfterSortOptions"/>
+ <menuitem data-l10n-id="thread-pane-header-context-hide"
+ oncommand="threadPaneHeader.toggleThreadPaneHeader();"/>
+ </menupopup>
+ <menupopup id="folderPaneGetMessagesContext"
+ class="no-accel-menupopup"
+ position="bottomleft topleft"
+ onpopupshowing="folderPane.updateGetMessagesContextMenu();">
+ <menuitem id="itemGetAllNewMessages"
+ class="menuitem-iconic"
+ data-l10n-id="folder-pane-get-all-messages-menuitem"
+ oncommand="top.MsgGetMessagesForAllAuthenticatedAccounts();"/>
+ <menuseparator id="separatorAfterItemGetAllNewMessages"/>
+ </menupopup>
+#include mailContext.inc.xhtml
+ <panel is="autocomplete-richlistbox-popup" id="PopupAutoComplete"
+ type="autocomplete"
+ role="group"
+ noautofocus="true"/>
+</popupset>
+</html>
diff --git a/comm/mail/base/content/aboutAddonsExtra.js b/comm/mail/base/content/aboutAddonsExtra.js
new file mode 100644
index 0000000000..1499d3927b
--- /dev/null
+++ b/comm/mail/base/content/aboutAddonsExtra.js
@@ -0,0 +1,211 @@
+/* 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 ../../../../toolkit/mozapps/extensions/content/aboutaddons.js */
+
+const THUNDERBIRD_THEME_PREVIEWS = new Map([
+ [
+ "thunderbird-compact-light@mozilla.org",
+ "resource://builtin-themes/light/preview.svg",
+ ],
+ [
+ "thunderbird-compact-dark@mozilla.org",
+ "resource://builtin-themes/dark/preview.svg",
+ ],
+]);
+
+var { UIFontSize } = ChromeUtils.import("resource:///modules/UIFontSize.jsm");
+ChromeUtils.defineESModuleGetters(this, {
+ ExtensionData: "resource://gre/modules/Extension.sys.mjs",
+});
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "alternativeAddonSearchUrl",
+ "extensions.alternativeAddonSearch.url"
+);
+
+(async function () {
+ window.MozXULElement.insertFTLIfNeeded("messenger/aboutAddonsExtra.ftl");
+ // Needed for webext-perms-description-experiment.
+ window.MozXULElement.insertFTLIfNeeded("messenger/extensionPermissions.ftl");
+ UIFontSize.registerWindow(window);
+
+ // Consume clicks on a-tags and let openTrustedLinkIn() decide how to open them.
+ window.addEventListener("click", event => {
+ if (event.target.matches("a[href]") && event.target.href) {
+ let uri = Services.io.newURI(event.target.href);
+ if (uri.scheme == "http" || uri.scheme == "https") {
+ event.preventDefault();
+ event.stopPropagation();
+ windowRoot.ownerGlobal.openTrustedLinkIn(event.target.href, "tab");
+ }
+ }
+ });
+
+ // Fix the "Search on addons.mozilla.org" placeholder text in the searchbox.
+ let textbox = document.querySelector("search-addons > search-textbox");
+ document.l10n.setAttributes(textbox, "atn-addons-heading-search-input");
+
+ // Add our stylesheet.
+ let contentStylesheet = document.createElement("link");
+ contentStylesheet.rel = "stylesheet";
+ contentStylesheet.href = "chrome://messenger/skin/aboutAddonsExtra.css";
+ document.head.appendChild(contentStylesheet);
+
+ // Override logic for detecting unsigned add-ons.
+ window.isCorrectlySigned = function () {
+ return true;
+ };
+
+ // Load our theme screenshots.
+ let _getScreenshotUrlForAddon = getScreenshotUrlForAddon;
+ getScreenshotUrlForAddon = function (addon) {
+ if (THUNDERBIRD_THEME_PREVIEWS.has(addon.id)) {
+ return THUNDERBIRD_THEME_PREVIEWS.get(addon.id);
+ }
+ return _getScreenshotUrlForAddon(addon);
+ };
+
+ // Add logic to detect add-ons using the unsupported legacy API.
+ let getMozillaAddonMessageInfo = window.getAddonMessageInfo;
+ window.getAddonMessageInfo = async function (addon) {
+ const { name } = addon;
+ const { STATE_SOFTBLOCKED } = Ci.nsIBlocklistService;
+
+ let data = new ExtensionData(addon.getResourceURI());
+ await data.loadManifest();
+ if (
+ addon.type == "extension" &&
+ (data.manifest.legacy ||
+ (!addon.isCompatible &&
+ (AddonManager.checkCompatibility ||
+ addon.blocklistState !== STATE_SOFTBLOCKED)))
+ ) {
+ return {
+ linkText: await document.l10n.formatValue(
+ "add-on-search-alternative-button-label"
+ ),
+ linkUrl: `${alternativeAddonSearchUrl}?id=${encodeURIComponent(
+ addon.id
+ )}&q=${encodeURIComponent(name)}`,
+ messageId: "details-notification-incompatible",
+ messageArgs: { name, version: Services.appinfo.version },
+ type: "warning",
+ };
+ }
+ return getMozillaAddonMessageInfo(addon);
+ };
+ document.querySelectorAll("addon-card").forEach(card => card.updateMessage());
+
+ // Override parts of the addon-card customElement to be able
+ // to add a dedicated button for extension preferences.
+ await customElements.whenDefined("addon-card");
+ AddonCard.prototype.addOptionsButton = async function () {
+ let { addon, optionsButton } = this;
+ if (addon.type != "extension") {
+ return;
+ }
+
+ let addonOptionsButton = this.querySelector(".extension-options-button");
+ if (!addonOptionsButton) {
+ addonOptionsButton = document.createElement("button");
+ addonOptionsButton.classList.add("extension-options-button");
+ addonOptionsButton.setAttribute("action", "preferences");
+ document.l10n.setAttributes(addonOptionsButton, "add-on-options-button");
+ addonOptionsButton.disabled = true;
+ optionsButton.parentNode.insertBefore(addonOptionsButton, optionsButton);
+ }
+
+ // Upon fresh install the manifest has not been parsed and optionsType
+ // is not known, manually trigger parsing.
+ if (addon.isActive && !addon.optionsType) {
+ let data = new ExtensionData(addon.getResourceURI());
+ await data.loadManifest();
+ }
+
+ addonOptionsButton.disabled = !(addon.isActive && addon.optionsType);
+ };
+ AddonCard.prototype._update = AddonCard.prototype.update;
+ AddonCard.prototype.update = function () {
+ this._update();
+ this.addOptionsButton();
+ };
+
+ // Override parts of the addon-permission-list customElement to be able
+ // to show the usage of Experiments in the permission list.
+ await customElements.whenDefined("addon-permissions-list");
+ AddonPermissionsList.prototype.renderExperimentOnly = function () {
+ this.textContent = "";
+ let frag = importTemplate("addon-permissions-list");
+ let section = frag.querySelector(".addon-permissions-required");
+ section.hidden = false;
+ let list = section.querySelector(".addon-permissions-list");
+
+ let item = document.createElement("li");
+ document.l10n.setAttributes(item, "webext-perms-description-experiment");
+ item.classList.add("permission-info", "permission-checked");
+ list.appendChild(item);
+
+ this.appendChild(frag);
+ };
+ // We change this function from sync to async, which does not matter.
+ // It calls this.render() which is async without awaiting it anyway.
+ AddonPermissionsList.prototype.setAddon = async function (addon) {
+ this.addon = addon;
+ let data = new ExtensionData(addon.getResourceURI());
+ await data.loadManifest();
+ if (data.manifest.experiment_apis) {
+ this.renderExperimentOnly();
+ } else {
+ this.render();
+ }
+ };
+
+ await customElements.whenDefined("recommended-addon-card");
+ RecommendedAddonCard.prototype._setCardContent =
+ RecommendedAddonCard.prototype.setCardContent;
+ RecommendedAddonCard.prototype.setCardContent = function (card, addon) {
+ this._setCardContent(card, addon);
+ card.addEventListener("click", event => {
+ if (event.target.matches("a[href]") || event.target.matches("button")) {
+ return;
+ }
+ windowRoot.ownerGlobal.openTrustedLinkIn(
+ card.querySelector(".disco-addon-author a").href,
+ "tab"
+ );
+ });
+ };
+
+ await customElements.whenDefined("search-addons");
+ SearchAddons.prototype.searchAddons = function (query) {
+ if (query.length === 0) {
+ return;
+ }
+
+ let url = new URL(
+ formatUTMParams(
+ "addons-manager-search",
+ AddonRepository.getSearchURL(query)
+ )
+ );
+
+ // Limit search to themes, if the themes section is currently active.
+ if (
+ document.getElementById("page-header").getAttribute("type") == "theme"
+ ) {
+ url.searchParams.set("cat", "themes");
+ }
+
+ let browser = getBrowserElement();
+ let chromewin = browser.ownerGlobal;
+ chromewin.openLinkIn(url.href, "tab", {
+ fromChrome: true,
+ triggeringPrincipal: Services.scriptSecurityManager.createNullPrincipal(
+ {}
+ ),
+ });
+ };
+})();
diff --git a/comm/mail/base/content/aboutDialog-appUpdater.js b/comm/mail/base/content/aboutDialog-appUpdater.js
new file mode 100644
index 0000000000..36bbc6a3e6
--- /dev/null
+++ b/comm/mail/base/content/aboutDialog-appUpdater.js
@@ -0,0 +1,320 @@
+/* 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/. */
+
+// Note: this file is included in aboutDialog.xhtml and preferences/advanced.xhtml
+// if MOZ_UPDATER is defined.
+
+/* import-globals-from aboutDialog.js */
+
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ AppUpdater: "resource://gre/modules/AppUpdater.sys.mjs",
+ DownloadUtils: "resource://gre/modules/DownloadUtils.sys.mjs",
+ UpdateUtils: "resource://gre/modules/UpdateUtils.sys.mjs",
+});
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "AUS",
+ "@mozilla.org/updates/update-service;1",
+ "nsIApplicationUpdateService"
+);
+
+var UPDATING_MIN_DISPLAY_TIME_MS = 1500;
+
+var gAppUpdater;
+
+function onUnload(aEvent) {
+ if (gAppUpdater) {
+ gAppUpdater.destroy();
+ gAppUpdater = null;
+ }
+}
+
+function appUpdater(options = {}) {
+ this._appUpdater = new AppUpdater();
+
+ this._appUpdateListener = (status, ...args) => {
+ this._onAppUpdateStatus(status, ...args);
+ };
+ this._appUpdater.addListener(this._appUpdateListener);
+
+ this.options = options;
+ this.updatingMinDisplayTimerId = null;
+ this.updateDeck = document.getElementById("updateDeck");
+
+ this.bundle = Services.strings.createBundle(
+ "chrome://messenger/locale/messenger.properties"
+ );
+
+ try {
+ let manualURL = new URL(
+ Services.urlFormatter.formatURLPref("app.update.url.manual")
+ );
+
+ for (const manualLink of document.getElementsByClassName("manualLink")) {
+ // Strip hash and search parameters for display text.
+ manualLink.textContent = manualURL.origin + manualURL.pathname;
+ manualLink.href = manualURL.href;
+ }
+
+ document.getElementById("failedLink").href = manualURL.href;
+ } catch (e) {
+ console.error("Invalid manual update url.", e);
+ }
+
+ this._appUpdater.check();
+}
+
+appUpdater.prototype = {
+ destroy() {
+ this.stopCurrentCheck();
+ if (this.updatingMinDisplayTimerId) {
+ clearTimeout(this.updatingMinDisplayTimerId);
+ }
+ },
+
+ stopCurrentCheck() {
+ this._appUpdater.removeListener(this._appUpdateListener);
+ this._appUpdater.stop();
+ },
+
+ get update() {
+ return this._appUpdater.update;
+ },
+
+ get selectedPanel() {
+ return this.updateDeck.selectedPanel;
+ },
+
+ _onAppUpdateStatus(status, ...args) {
+ switch (status) {
+ case AppUpdater.STATUS.UPDATE_DISABLED_BY_POLICY:
+ this.selectPanel("policyDisabled");
+ break;
+ case AppUpdater.STATUS.READY_FOR_RESTART:
+ this.selectPanel("apply");
+ break;
+ case AppUpdater.STATUS.OTHER_INSTANCE_HANDLING_UPDATES:
+ this.selectPanel("otherInstanceHandlingUpdates");
+ break;
+ case AppUpdater.STATUS.DOWNLOADING: {
+ let downloadStatus = document.getElementById("downloadStatus");
+ if (!args.length) {
+ // Very early in the DOWNLOADING state, `selectedPatch` may not be
+ // available yet. But this function will be called again when it is
+ // available. A `maxSize < 0` indicates that the max size is not yet
+ // available.
+ let maxSize = -1;
+ if (this.update.selectedPatch) {
+ maxSize = this.update.selectedPatch.size;
+ }
+ downloadStatus.textContent = DownloadUtils.getTransferTotal(
+ 0,
+ maxSize
+ );
+ this.selectPanel("downloading");
+ } else {
+ let [progress, max] = args;
+ downloadStatus.textContent = DownloadUtils.getTransferTotal(
+ progress,
+ max
+ );
+ }
+ break;
+ }
+ case AppUpdater.STATUS.STAGING:
+ this.selectPanel("applying");
+ break;
+ case AppUpdater.STATUS.CHECKING: {
+ this.checkingForUpdatesDelayPromise = new Promise(resolve => {
+ this.updatingMinDisplayTimerId = setTimeout(
+ resolve,
+ UPDATING_MIN_DISPLAY_TIME_MS
+ );
+ });
+ if (Services.policies.isAllowed("appUpdate")) {
+ this.selectPanel("checkingForUpdates");
+ } else {
+ this.selectPanel("policyDisabled");
+ }
+ break;
+ }
+ case AppUpdater.STATUS.CHECKING_FAILED:
+ this.selectPanel("checkingFailed");
+ break;
+ case AppUpdater.STATUS.NO_UPDATES_FOUND:
+ this.checkingForUpdatesDelayPromise.then(() => {
+ if (Services.policies.isAllowed("appUpdate")) {
+ this.selectPanel("noUpdatesFound");
+ } else {
+ this.selectPanel("policyDisabled");
+ }
+ });
+ break;
+ case AppUpdater.STATUS.UNSUPPORTED_SYSTEM:
+ if (this.update.detailsURL) {
+ let unsupportedLink = document.getElementById("unsupportedLink");
+ unsupportedLink.href = this.update.detailsURL;
+ }
+ this.selectPanel("unsupportedSystem");
+ break;
+ case AppUpdater.STATUS.MANUAL_UPDATE:
+ this.selectPanel("manualUpdate");
+ break;
+ case AppUpdater.STATUS.DOWNLOAD_AND_INSTALL:
+ this.selectPanel("downloadAndInstall");
+ break;
+ case AppUpdater.STATUS.DOWNLOAD_FAILED:
+ this.selectPanel("downloadFailed");
+ break;
+ case AppUpdater.STATUS.INTERNAL_ERROR:
+ this.selectPanel("internalError");
+ break;
+ case AppUpdater.STATUS.NEVER_CHECKED:
+ this.selectPanel("checkForUpdates");
+ break;
+ case AppUpdater.STATUS.NO_UPDATER:
+ default:
+ this.selectPanel("noUpdater");
+ document.getElementById("updateBox").style.maxHeight = "0";
+ break;
+ }
+ },
+
+ /**
+ * Sets the panel of the updateDeck and the visibility of icons
+ * in the #icons element.
+ *
+ * @param aChildID
+ * The id of the deck's child to select, e.g. "apply".
+ */
+ selectPanel(aChildID) {
+ let panel = document.getElementById(aChildID);
+ let icons = document.getElementById("icons");
+ if (icons) {
+ icons.className = aChildID;
+ }
+
+ // Make sure to select the panel before potentially auto-focusing the button.
+ this.updateDeck.selectedPanel = panel;
+
+ let button = panel.querySelector("button");
+ if (button) {
+ if (aChildID == "downloadAndInstall") {
+ let updateVersion = gAppUpdater.update.displayVersion;
+ // Include the build ID if this is an "a#" (nightly or aurora) build
+ if (/a\d+$/.test(updateVersion)) {
+ let buildID = gAppUpdater.update.buildID;
+ let year = buildID.slice(0, 4);
+ let month = buildID.slice(4, 6);
+ let day = buildID.slice(6, 8);
+ updateVersion += ` (${year}-${month}-${day})`;
+ } else {
+ let updateNotesLink = document.getElementById("updateNotes");
+ if (updateNotesLink) {
+ updateNotesLink.href = gAppUpdater.update.detailsURL;
+ updateNotesLink.hidden = false;
+ }
+ }
+ button.textContent = this.bundle.formatStringFromName(
+ "update.downloadAndInstallButton.label",
+ [updateVersion]
+ );
+ button.accessKey = this.bundle.GetStringFromName(
+ "update.downloadAndInstallButton.accesskey"
+ );
+ }
+ if (this.options.buttonAutoFocus) {
+ let promise = Promise.resolve();
+ if (document.readyState != "complete") {
+ promise = new Promise(resolve =>
+ window.addEventListener("load", resolve, { once: true })
+ );
+ }
+ promise.then(() => {
+ if (
+ !document.commandDispatcher.focusedElement || // don't steal the focus
+ // except from the other buttons
+ document.commandDispatcher.focusedElement.localName == "button"
+ ) {
+ button.focus();
+ }
+ });
+ }
+ }
+ },
+
+ /**
+ * Check for updates
+ */
+ checkForUpdates() {
+ this._appUpdater.check();
+ },
+
+ /**
+ * Handles oncommand for the "Restart to Update" button
+ * which is presented after the download has been downloaded.
+ */
+ buttonRestartAfterDownload() {
+ if (AUS.currentState != Ci.nsIApplicationUpdateService.STATE_PENDING) {
+ return;
+ }
+
+ gAppUpdater.selectPanel("restarting");
+
+ // Notify all windows that an application quit has been requested.
+ let cancelQuit = Cc["@mozilla.org/supports-PRBool;1"].createInstance(
+ Ci.nsISupportsPRBool
+ );
+ Services.obs.notifyObservers(
+ cancelQuit,
+ "quit-application-requested",
+ "restart"
+ );
+
+ // Something aborted the quit process.
+ if (cancelQuit.data) {
+ gAppUpdater.selectPanel("apply");
+ return;
+ }
+
+ // If already in safe mode restart in safe mode (bug 327119)
+ if (Services.appinfo.inSafeMode) {
+ Services.startup.restartInSafeMode(Ci.nsIAppStartup.eAttemptQuit);
+ return;
+ }
+
+ if (
+ !Services.startup.quit(
+ Ci.nsIAppStartup.eAttemptQuit | Ci.nsIAppStartup.eRestart
+ )
+ ) {
+ // Either the user or the hidden window aborted the quit process.
+ gAppUpdater.selectPanel("apply");
+ }
+ },
+
+ /**
+ * Starts the download of an update mar.
+ */
+ startDownload() {
+ this._appUpdater.allowUpdateDownload();
+ },
+};
+
+window.addEventListener("load", () => {
+ let protocolSvc = Cc[
+ "@mozilla.org/uriloader/external-protocol-service;1"
+ ].getService(Ci.nsIExternalProtocolService);
+ for (let link of document.querySelectorAll(".download-link")) {
+ link.addEventListener("click", event => {
+ event.preventDefault();
+ protocolSvc.loadURI(Services.io.newURI(event.target.href));
+ });
+ }
+});
diff --git a/comm/mail/base/content/aboutDialog.css b/comm/mail/base/content/aboutDialog.css
new file mode 100644
index 0000000000..36a6332030
--- /dev/null
+++ b/comm/mail/base/content/aboutDialog.css
@@ -0,0 +1,187 @@
+/* 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/. */
+
+:root {
+ --dialog-background: #fefefe;
+ --dialog-box-color: #222;
+ --client-box-background: #f7f7f7;
+ --link-color: -moz-nativehyperlinktext;
+ --link-decoration: inherit;
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --dialog-background: #222;
+ --dialog-box-color: #f7f7f7;
+ --client-box-background: #444;
+ --link-color: #f7f7f7;
+ --link-decoration: underline;
+ }
+}
+
+@media (prefers-contrast) {
+ :root {
+ --dialog-background: -moz-Dialog;
+ --dialog-box-color: -moz-DialogText;
+ --client-box-background: -moz-Dialog;
+ --link-color: -moz-nativehyperlinktext;
+ --link-decoration: inherit;
+ }
+}
+
+body {
+ margin: 0;
+ overflow: hidden;
+}
+
+#aboutDialog {
+ /* Set an explicit line-height to avoid discrepancies in 'auto' spacing
+ across screens with different device DPI, which may cause font metrics
+ to round differently. */
+ line-height: 1.5;
+}
+
+#aboutDialogContainer {
+ width: 670px;
+ color: var(--dialog-box-color);
+ background-color: var(--dialog-background);
+}
+
+#rightBox {
+ background-image: url("chrome://branding/content/about-wordmark.svg");
+ background-repeat: no-repeat;
+ /* padding-top creates room for the wordmark */
+ padding-top: 38px;
+ margin-top: 20px;
+ margin-inline: 30px;
+ -moz-context-properties: fill;
+ fill: currentColor;
+}
+
+#detailsBox {
+ padding-top: 10px;
+}
+
+#updateDeck {
+ align-items: center;
+ min-height: 33px;
+}
+
+.update-throbber {
+ width: 16px;
+ min-height: 16px;
+ margin-inline-end: 3px;
+ content: image-set(url("chrome://global/skin/icons/loading.png"),
+ url("chrome://global/skin/icons/loading@2x.png") 2x);
+ vertical-align: middle;
+}
+
+#rightBox:-moz-locale-dir(rtl) {
+ background-position: 100% 0;
+}
+
+#bottomBox {
+ padding: 15px 10px 0;
+}
+
+#version {
+ font-weight: bold;
+ margin-top: 10px;
+ margin-inline-start: 0;
+ user-select: text;
+ -moz-user-focus: normal;
+ cursor: text;
+}
+
+#releasenotes {
+ margin-inline-start: 0.5em;
+}
+
+#distribution,
+#distributionId {
+ display: none;
+ margin-block: 0;
+}
+
+.text-blurb {
+ margin-bottom: 10px;
+ margin-inline-start: 0;
+ padding-inline-start: 0;
+}
+
+.update-deck-container {
+ display: flex;
+ align-items: center;
+ position: fixed;
+}
+
+.update-deck-container > * {
+ flex: 0 0 fit-content;
+}
+
+.update-deck-container.deck-selected {
+ visibility: visible;
+}
+
+.update-deck-container span {
+ font-style: italic;
+}
+
+.update-deck-container span > a {
+ font-style: normal;
+}
+
+.update-throbber {
+ width: 16px;
+ height: 16px;
+ margin-inline-end: 3px;
+}
+
+.trademark-label,
+.text-link,
+.text-link:focus {
+ margin: 0;
+ padding: 0;
+}
+
+.bottom-link,
+.bottom-link:focus {
+ text-align: center;
+ margin: 0 40px;
+}
+
+.text-link {
+ color: var(--link-color);
+}
+
+.text-link:not(:hover) {
+ text-decoration: var(--link-decoration);
+}
+
+#updateNotes {
+ margin-inline-start: 5px;
+}
+
+#currentChannel {
+ margin: 0;
+ padding: 0;
+ font-weight: bold;
+}
+
+#icons > .icon {
+ -moz-context-properties: fill;
+ margin: 10px 5px;
+ width: 16px;
+ height: 16px;
+}
+
+#icons:not(.checkingForUpdates, .downloading, .applying, .restarting) > .update-throbber,
+#icons:not(.noUpdatesFound) > .noUpdatesFound,
+#icons:not(.apply) > .apply {
+ display: none;
+}
+
+#icons > .noUpdatesFound {
+ fill: #16a34a;
+}
diff --git a/comm/mail/base/content/aboutDialog.js b/comm/mail/base/content/aboutDialog.js
new file mode 100644
index 0000000000..74af77866f
--- /dev/null
+++ b/comm/mail/base/content/aboutDialog.js
@@ -0,0 +1,155 @@
+/* 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 aboutDialog-appUpdater.js */
+
+"use strict";
+
+var { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+if (AppConstants.MOZ_UPDATER) {
+ Services.scriptloader.loadSubScript(
+ "chrome://messenger/content/aboutDialog-appUpdater.js",
+ this
+ );
+}
+
+window.addEventListener("DOMContentLoaded", onLoad);
+if (AppConstants.MOZ_UPDATER) {
+ // This method is in the aboutDialog-appUpdater.js file.
+ window.addEventListener("unload", onUnload);
+}
+
+function onLoad(event) {
+ if (event.target !== document) {
+ return;
+ }
+
+ let defaults = Services.prefs.getDefaultBranch(null);
+ let distroId = defaults.getCharPref("distribution.id", "");
+ if (distroId) {
+ let distroAbout = defaults.getStringPref("distribution.about", "");
+ // If there is about text, we always show it.
+ if (distroAbout) {
+ let distroField = document.getElementById("distribution");
+ distroField.innerText = distroAbout;
+ distroField.style.display = "block";
+ }
+ // If it's not a mozilla distribution, show the rest,
+ // unless about text exists, then we always show.
+ if (!distroId.startsWith("mozilla-") || distroAbout) {
+ let distroVersion = defaults.getCharPref("distribution.version", "");
+ if (distroVersion) {
+ distroId += " - " + distroVersion;
+ }
+
+ let distroIdField = document.getElementById("distributionId");
+ distroIdField.innerText = distroId;
+ distroIdField.style.display = "block";
+ }
+ }
+
+ // Include the build ID and display warning if this is an "a#" (nightly or aurora) build
+ let versionId = "aboutDialog-version";
+ let versionAttributes = {
+ version: AppConstants.MOZ_APP_VERSION_DISPLAY,
+ bits: Services.appinfo.is64Bit ? 64 : 32,
+ };
+
+ let version = Services.appinfo.version;
+ if (/a\d+$/.test(version)) {
+ versionId = "aboutDialog-version-nightly";
+ let buildID = Services.appinfo.appBuildID;
+ let year = buildID.slice(0, 4);
+ let month = buildID.slice(4, 6);
+ let day = buildID.slice(6, 8);
+ versionAttributes.isodate = `${year}-${month}-${day}`;
+
+ document.getElementById("experimental").hidden = false;
+ document.getElementById("communityDesc").hidden = true;
+ }
+
+ // Use Fluent arguments for append version and the architecture of the build
+ let versionField = document.getElementById("version");
+
+ document.l10n.setAttributes(versionField, versionId, versionAttributes);
+
+ if (!AppConstants.NIGHTLY_BUILD) {
+ // Show a release notes link if we have a URL.
+ let relNotesLink = document.getElementById("releasenotes");
+ let relNotesPrefType = Services.prefs.getPrefType("app.releaseNotesURL");
+ if (relNotesPrefType != Services.prefs.PREF_INVALID) {
+ let relNotesURL = Services.urlFormatter.formatURLPref(
+ "app.releaseNotesURL"
+ );
+ if (relNotesURL != "about:blank") {
+ relNotesLink.href = relNotesURL;
+ relNotesLink.hidden = false;
+ }
+ }
+ }
+
+ if (AppConstants.MOZ_UPDATER) {
+ gAppUpdater = new appUpdater({ buttonAutoFocus: true });
+
+ let channelLabel = document.getElementById("currentChannelText");
+ let channelAttrs = document.l10n.getAttributes(channelLabel);
+ let channel = UpdateUtils.UpdateChannel;
+ document.l10n.setAttributes(channelLabel, channelAttrs.id, { channel });
+ if (
+ /^release($|\-)/.test(channel) ||
+ Services.sysinfo.getProperty("isPackagedApp")
+ ) {
+ channelLabel.hidden = true;
+ }
+ }
+
+ // Open external links in browser
+ for (const link of document.getElementsByClassName("browser-link")) {
+ link.onclick = event => {
+ event.preventDefault();
+ openLink(event.target.href);
+ };
+ }
+ // Open internal (about:) links open in Thunderbird tab
+ for (const link of document.getElementsByClassName("tab-link")) {
+ link.onclick = event => {
+ event.preventDefault();
+ openAboutTab(event.target.href);
+ };
+ }
+}
+
+// This function is used to open about: tabs. The caller should ensure the url
+// is only an about: url.
+function openAboutTab(url) {
+ // Check existing windows
+ let mailWindow = Services.wm.getMostRecentWindow("mail:3pane");
+ if (mailWindow) {
+ mailWindow.focus();
+ mailWindow.document
+ .getElementById("tabmail")
+ .openTab("contentTab", { url });
+ return;
+ }
+
+ // No existing windows.
+ window.openDialog(
+ "chrome://messenger/content/messenger.xhtml",
+ "_blank",
+ "chrome,dialog=no,all",
+ null,
+ {
+ tabType: "contentTab",
+ tabParams: { url },
+ }
+ );
+}
+
+function openLink(url) {
+ Cc["@mozilla.org/uriloader/external-protocol-service;1"]
+ .getService(Ci.nsIExternalProtocolService)
+ .loadURI(Services.io.newURI(url));
+}
diff --git a/comm/mail/base/content/aboutDialog.xhtml b/comm/mail/base/content/aboutDialog.xhtml
new file mode 100644
index 0000000000..55c7330f03
--- /dev/null
+++ b/comm/mail/base/content/aboutDialog.xhtml
@@ -0,0 +1,184 @@
+<?xml version="1.0"?>
+<!-- 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/. -->
+
+#filter substitution
+
+<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/content/aboutDialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://branding/content/aboutDialog.css" type="text/css"?>
+
+<!DOCTYPE html>
+<html id="aboutDialog" xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ role="dialog"
+ windowtype="mail:about">
+
+<head>
+ <title data-l10n-id="about-dialog-title"></title>
+ <link rel="localization" href="branding/brand.ftl"/>
+ <link rel="localization" href="messenger/aboutDialog.ftl"/>
+ <script defer="true" src="chrome://messenger/content/aboutDialog.js"></script>
+</head>
+<body aria-describedby="version distribution distributionId currentChannelText communityDesc contributeDesc trademark">
+ <xul:keyset id="mainKeyset">
+ <xul:key keycode="VK_ESCAPE" oncommand="window.close();"/>
+#ifdef XP_MACOSX
+ <xul:key id="key_close" modifiers="accel" data-l10n-id="cmd-close-mac-command-key"
+ oncommand="window.close();"/>
+#endif
+ </xul:keyset>
+ <div id="aboutDialogContainer">
+ <xul:hbox id="clientBox">
+ <xul:vbox id="leftBox" flex="1"/>
+ <xul:vbox id="rightBox">
+ <xul:hbox align="baseline">
+ <span id="version"></span>
+ <a id="releasenotes" class="text-link browser-link" hidden="hidden"
+ data-l10n-id="release-notes-link"></a>
+ </xul:hbox>
+
+ <img src="chrome://messenger/skin/icons/new/supernova-logo.webp" alt="" id="supernova-logo"/>
+
+ <span id="distribution" class="text-blurb"></span>
+ <span id="distributionId" class="text-blurb"></span>
+
+ <xul:vbox id="detailsBox">
+ <xul:hbox id="updateBox">
+#ifdef MOZ_UPDATER
+ <div id="icons">
+ <img class="icon update-throbber" role="presentation"/>
+ <img class="icon noUpdatesFound" src="chrome://global/skin/icons/check.svg" role="presentation"/>
+ <img class="icon apply" src="chrome://global/skin/icons/reload.svg" role="presentation"/>
+ </div>
+ <xul:vbox>
+ <xul:deck id="updateDeck" orient="vertical">
+ <div id="checkForUpdates" class="update-deck-container">
+ <button id="checkForUpdatesButton"
+ data-l10n-id="update-check-for-updates-button"
+ onclick="gAppUpdater.checkForUpdates();">
+ </button>
+ </div>
+ <div id="downloadAndInstall" class="update-deck-container">
+ <button id="downloadAndInstallButton"
+ onclick="gAppUpdater.startDownload();">
+ </button>
+ <a id="updateNotes" class="text-link browser-link" hidden="hidden"
+ data-l10n-id="about-update-whats-new"></a>
+ </div>
+ <div id="apply" class="update-deck-container">
+ <button id="updateButton"
+ data-l10n-id="update-update-button"
+ onclick="gAppUpdater.buttonRestartAfterDownload();">
+ </button>
+ </div>
+ <div id="checkingForUpdates" class="update-deck-container">
+ <span data-l10n-id="update-checking-for-updates"></span>
+ </div>
+ <div id="downloading" class="update-deck-container" data-l10n-id="update-downloading-message">
+ <span id="downloadStatus" data-l10n-name="download-status"></span>
+ </div>
+ <div id="applying" class="update-deck-container">
+ <span data-l10n-id="update-applying"></span>
+ </div>
+ <div id="downloadFailed" class="update-deck-container">
+ <!-- Outer span ensures whitespace between the plain text and
+ - the link. Otherwise, this would be suppressed by the
+ - update-deck-container's display: flex. -->
+ <span data-l10n-id="update-failed">
+ <a id="failedLink" data-l10n-name="failed-link"
+ class="text-link browser-link"></a>
+ </span>
+ </div>
+ <div id="policyDisabled" class="update-deck-container">
+ <span data-l10n-id="update-admin-disabled"></span>
+ </div>
+ <div id="noUpdatesFound" class="update-deck-container">
+ <span data-l10n-id="update-no-updates-found"></span>
+ </div>
+ <div id="checkingFailed" class="update-deck-container">
+ <span data-l10n-id="aboutdialog-update-checking-failed"></span>
+ </div>
+ <div id="otherInstanceHandlingUpdates" class="update-deck-container">
+ <span data-l10n-id="update-other-instance-handling-updates"></span>
+ </div>
+ <div id="manualUpdate" class="update-deck-container">
+ <span data-l10n-id="update-manual">
+ <a id="manualLink" data-l10n-name="manual-link"
+ class="manualLink text-link browser-link"></a>
+ </span>
+ </div>
+ <div id="unsupportedSystem" class="update-deck-container">
+ <span data-l10n-id="update-unsupported">
+ <a id="unsupportedLink" data-l10n-name="unsupported-link"
+ class="manualLink text-link browser-link"></a>
+ </span>
+ </div>
+ <div id="restarting" class="update-deck-container">
+ <span data-l10n-id="update-restarting"></span>
+ </div>
+ <div id="internalError" class="update-deck-container">
+ <span data-l10n-id="update-internal-error">
+ <a id="internalErrorLink" data-l10n-name="manual-link"
+ class="manualLink text-link browser-link"></a>
+ </span>
+ </div>
+ <div id="noUpdater" class="update-deck-container"></div>
+ </xul:deck>
+ <!-- This HBOX is duplicated above without class="update" -->
+ <xul:hbox align="baseline">
+ <span id="version" class="update"></span>
+ <a id="releasenotes" class="text-link browser-link" hidden="hidden"
+ data-l10n-id="release-notes-link"></a>
+ </xul:hbox>
+ </xul:vbox>
+#endif
+ </xul:hbox>
+
+#ifdef MOZ_UPDATER
+ <div class="text-blurb" id="currentChannelText" data-l10n-id="channel-description"
+ data-l10n-args='{"channel": ""}'
+ data-l10n-attrs="{&quot;channel&quot;: &quot;&quot;}">
+ <span id="currentChannel" data-l10n-name="current-channel"></span>
+ </div>
+#endif
+ <xul:vbox id="experimental" hidden="true">
+ <div class="text-blurb" id="warningDesc">
+ <span data-l10n-id="warning-desc-version"></span>
+#ifdef MOZ_TELEMETRY_ON_BY_DEFAULT
+ <span data-l10n-id="warning-desc-telemetry"></span>
+#endif
+ </div>
+ <div class="text-blurb" id="communityExperimentalDesc" data-l10n-id="community-experimental">
+ <a class="text-link browser-link" href="https://www.mozilla.org/"
+ data-l10n-name="community-exp-mozilla-link"></a>
+ <a class="text-link tab-link" href="about:credits"
+ data-l10n-name="community-exp-credits-link"></a>
+ </div>
+ </xul:vbox>
+ <div class="text-blurb" id="communityDesc" data-l10n-id="community-desc">
+ <a class="text-link browser-link" href="https://www.mozilla.org/" data-l10n-name="community-mozilla-link"></a>
+ <a class="text-link tab-link" href="about:credits" data-l10n-name="community-credits-link"></a>
+ </div>
+ <div class="text-blurb" id="contributeDesc" data-l10n-id="about-donation">
+ <a class="text-link browser-link" href="https://give.thunderbird.net/?utm_source=thunderbird-client&amp;utm_medium=referral&amp;utm_content=about-dialog"
+ data-l10n-name="helpus-donate-link"></a>
+ <a class="text-link browser-link" href="https://www.thunderbird.net/get-involved/"
+ data-l10n-name="helpus-get-involved-link"></a>
+ </div>
+ </xul:vbox>
+ </xul:vbox>
+ </xul:hbox>
+ <xul:vbox id="bottomBox">
+ <xul:hbox pack="center">
+ <a class="text-link bottom-link tab-link" href="about:license" data-l10n-id="bottom-links-license"></a>
+ <a class="text-link bottom-link tab-link" href="about:rights" data-l10n-id="bottom-links-rights"></a>
+ <a class="text-link bottom-link browser-link" href="https://www.mozilla.org/privacy/thunderbird/"
+ data-l10n-id="bottom-links-privacy"></a>
+ </xul:hbox>
+ <span id="trademark" data-l10n-id="trademarkInfo"></span>
+ </xul:vbox>
+ </div>
+</body>
+</html>
diff --git a/comm/mail/base/content/aboutMessage.js b/comm/mail/base/content/aboutMessage.js
new file mode 100644
index 0000000000..29ce47ba4d
--- /dev/null
+++ b/comm/mail/base/content/aboutMessage.js
@@ -0,0 +1,617 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals Enigmail, MailE10SUtils */
+
+// mailCommon.js
+/* globals commandController, DBViewWrapper, dbViewWrapperListener,
+ nsMsgViewIndex_None, TreeSelection */
+/* globals gDBView: true, gFolder: true, gViewWrapper: true */
+
+// mailContext.js
+/* globals mailContextMenu */
+
+// msgHdrView.js
+/* globals AdjustHeaderView ClearCurrentHeaders ClearPendingReadTimer
+ HideMessageHeaderPane OnLoadMsgHeaderPane OnTagsChange
+ OnUnloadMsgHeaderPane HandleAllAttachments AttachmentMenuController */
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ UIDensity: "resource:///modules/UIDensity.jsm",
+ UIFontSize: "resource:///modules/UIFontSize.jsm",
+ NetUtil: "resource://gre/modules/NetUtil.jsm",
+});
+
+const messengerBundle = Services.strings.createBundle(
+ "chrome://messenger/locale/messenger.properties"
+);
+
+var gMessage, gMessageURI;
+var autodetectCharset;
+
+function getMessagePaneBrowser() {
+ return document.getElementById("messagepane");
+}
+
+function messagePaneOnResize() {
+ const doc = getMessagePaneBrowser().contentDocument;
+ // Bail out if it's http content or we don't have images.
+ if (doc?.URL.startsWith("http") || !doc?.images) {
+ return;
+ }
+
+ for (let img of doc.images) {
+ img.toggleAttribute(
+ "overflowing",
+ img.clientWidth - doc.body.offsetWidth >= 0 &&
+ (img.clientWidth <= img.naturalWidth || !img.naturalWidth)
+ );
+ }
+}
+
+function ReloadMessage() {
+ if (!gMessageURI) {
+ return;
+ }
+ displayMessage(gMessageURI, gViewWrapper);
+}
+
+function MailSetCharacterSet() {
+ let messageService = MailServices.messageServiceFromURI(gMessageURI);
+ gMessage = messageService.messageURIToMsgHdr(gMessageURI);
+ messageService.loadMessage(
+ gMessageURI,
+ getMessagePaneBrowser().docShell,
+ top.msgWindow,
+ null,
+ true
+ );
+ autodetectCharset = true;
+}
+
+window.addEventListener("DOMContentLoaded", event => {
+ if (event.target != document) {
+ return;
+ }
+
+ UIDensity.registerWindow(window);
+ UIFontSize.registerWindow(window);
+
+ OnLoadMsgHeaderPane();
+
+ Enigmail.msg.messengerStartup();
+ Enigmail.hdrView.hdrViewLoad();
+
+ MailServices.mailSession.AddFolderListener(
+ folderListener,
+ Ci.nsIFolderListener.removed
+ );
+
+ preferenceObserver.init();
+ Services.obs.addObserver(msgObserver, "message-content-updated");
+
+ const browser = getMessagePaneBrowser();
+
+ if (parent == top) {
+ // Standalone message display? Focus the message pane.
+ browser.focus();
+ }
+
+ if (window.parent == window.top) {
+ mailContextMenu.init();
+ }
+
+ // There might not be a msgWindow variable on the top window
+ // if we're e.g. showing a message in a dedicated window.
+ if (top.msgWindow) {
+ // Necessary plumbing to communicate status updates back to
+ // the user.
+ browser.docShell
+ ?.QueryInterface(Ci.nsIWebProgress)
+ .addProgressListener(
+ top.msgWindow.statusFeedback,
+ Ci.nsIWebProgress.NOTIFY_ALL
+ );
+ }
+
+ window.dispatchEvent(
+ new CustomEvent("aboutMessageLoaded", { bubbles: true })
+ );
+});
+
+window.addEventListener("unload", () => {
+ ClearPendingReadTimer();
+ OnUnloadMsgHeaderPane();
+ MailServices.mailSession.RemoveFolderListener(folderListener);
+ preferenceObserver.cleanUp();
+ Services.obs.removeObserver(msgObserver, "message-content-updated");
+ gViewWrapper?.close();
+});
+
+function displayMessage(uri, viewWrapper) {
+ // Clear the state flags, if this window is re-used.
+ window.msgLoaded = false;
+ window.msgLoading = false;
+
+ // Clean up existing objects before starting again.
+ ClearPendingReadTimer();
+ gMessage = null;
+ if (gViewWrapper && viewWrapper != gViewWrapper) {
+ // Don't clean up gViewWrapper if we're going to reuse it. If we're inside
+ // about:3pane, close the view wrapper, but don't call `onLeavingFolder`,
+ // because about:3pane will do that if we're actually leaving the folder.
+ gViewWrapper?.close(parent != top);
+ gViewWrapper = null;
+ }
+ gDBView = null;
+
+ gMessageURI = uri;
+ ClearCurrentHeaders();
+
+ if (!uri) {
+ HideMessageHeaderPane();
+ MailE10SUtils.loadAboutBlank(getMessagePaneBrowser());
+ window.msgLoaded = true;
+ window.dispatchEvent(
+ new CustomEvent("messageURIChanged", { bubbles: true, detail: uri })
+ );
+ return;
+ }
+
+ let messageService = MailServices.messageServiceFromURI(uri);
+ gMessage = messageService.messageURIToMsgHdr(uri);
+ gFolder = gMessage.folder;
+
+ messageHistory.push(uri);
+
+ if (gFolder) {
+ if (viewWrapper) {
+ if (viewWrapper != gViewWrapper) {
+ gViewWrapper = viewWrapper.clone(dbViewWrapperListener);
+ }
+ } else {
+ gViewWrapper = new DBViewWrapper(dbViewWrapperListener);
+ gViewWrapper._viewFlags = Ci.nsMsgViewFlagsType.kThreadedDisplay;
+ gViewWrapper.open(gFolder);
+ }
+ } else {
+ gViewWrapper = new DBViewWrapper(dbViewWrapperListener);
+ gViewWrapper.openSearchView();
+ }
+ gDBView = gViewWrapper.dbView;
+ let selection = (gDBView.selection = new TreeSelection());
+ selection.view = gDBView;
+ let index = gDBView.findIndexOfMsgHdr(gMessage, true);
+ selection.select(index == nsMsgViewIndex_None ? -1 : index);
+ gDBView?.setJSTree({
+ QueryInterface: ChromeUtils.generateQI(["nsIMsgJSTree"]),
+ _inBatch: false,
+ beginUpdateBatch() {
+ this._inBatch = true;
+ },
+ endUpdateBatch() {
+ this._inBatch = false;
+ },
+ ensureRowIsVisible(index) {},
+ invalidate() {},
+ invalidateRange(startIndex, endIndex) {},
+ rowCountChanged(index, count) {
+ let wasSuppressed = gDBView.selection.selectEventsSuppressed;
+ gDBView.selection.selectEventsSuppressed = true;
+ gDBView.selection.adjustSelection(index, count);
+ gDBView.selection.selectEventsSuppressed = wasSuppressed;
+ },
+ currentIndex: null,
+ });
+
+ if (gMessage.flags & Ci.nsMsgMessageFlags.HasRe) {
+ document.title = `Re: ${gMessage.mime2DecodedSubject || ""}`;
+ } else {
+ document.title = gMessage.mime2DecodedSubject;
+ }
+
+ let browser = getMessagePaneBrowser();
+ const browserChanged = MailE10SUtils.changeRemoteness(browser, null);
+ // The message pane browser should inherit `docShellIsActive` from the
+ // about:message browser, but changing remoteness causes that to not happen.
+ browser.docShellIsActive = !document.hidden;
+ browser.docShell.allowAuth = false;
+ browser.docShell.allowDNSPrefetch = false;
+
+ if (browserChanged) {
+ browser.docShell
+ ?.QueryInterface(Ci.nsIWebProgress)
+ .addProgressListener(
+ top.msgWindow.statusFeedback,
+ Ci.nsIWebProgress.NOTIFY_ALL
+ );
+ }
+
+ if (gMessage.flags & Ci.nsMsgMessageFlags.Partial) {
+ document.body.classList.add("partial-message");
+ } else if (document.body.classList.contains("partial-message")) {
+ document.body.classList.remove("partial-message");
+ document.body.classList.add("completed-message");
+ }
+
+ // @implements {nsIUrlListener}
+ let urlListener = {
+ OnStartRunningUrl(url) {},
+ OnStopRunningUrl(url, status) {
+ window.msgLoading = true;
+ window.dispatchEvent(
+ new CustomEvent("messageURIChanged", { bubbles: true, detail: uri })
+ );
+ if (url instanceof Ci.nsIMsgMailNewsUrl && url.seeOtherURI) {
+ // Show error page if needed.
+ HideMessageHeaderPane();
+ MailE10SUtils.loadURI(getMessagePaneBrowser(), url.seeOtherURI);
+ }
+ },
+ };
+ try {
+ messageService.loadMessage(
+ uri,
+ browser.docShell,
+ top.msgWindow,
+ urlListener,
+ false
+ );
+ } catch (ex) {
+ if (ex.result != Cr.NS_ERROR_OFFLINE) {
+ throw ex;
+ }
+
+ // TODO: This should be replaced with a real page, and made not ugly.
+ let title = messengerBundle.GetStringFromName("nocachedbodytitle");
+ // This string includes some HTML! Get rid of it.
+ title = title.replace(/<\/?title>/gi, "");
+ let body = messengerBundle.GetStringFromName("nocachedbodybody2");
+ HideMessageHeaderPane();
+ MailE10SUtils.loadURI(
+ getMessagePaneBrowser(),
+ "data:text/html;base64," +
+ btoa(
+ `<!DOCTYPE html>
+ <html>
+ <head>
+ <meta charset="utf-8" />
+ <title>${title}</title>
+ </head>
+ <body>
+ <h1>${title}</h1>
+ <p>${body}</p>
+ </body>
+ </html>`
+ )
+ );
+ }
+ autodetectCharset = false;
+}
+
+var folderListener = {
+ QueryInterface: ChromeUtils.generateQI(["nsIFolderListener"]),
+
+ onFolderRemoved(parentFolder, childFolder) {},
+ onMessageRemoved(parentFolder, msg) {
+ messageHistory.onMessageRemoved(parentFolder, msg);
+ },
+};
+
+var msgObserver = {
+ QueryInterface: ChromeUtils.generateQI(["nsIObserver"]),
+
+ observe(subject, topic, data) {
+ if (
+ topic == "message-content-updated" &&
+ gMessageURI == subject.QueryInterface(Ci.nsISupportsString).data
+ ) {
+ // This notification is triggered after a partial pop3 message was
+ // fully downloaded. The old message URI is now gone. To reload the
+ // message, we display it with its new URI.
+ displayMessage(data, gViewWrapper);
+ }
+ },
+};
+
+var preferenceObserver = {
+ QueryInterface: ChromeUtils.generateQI(["nsIObserver"]),
+
+ _topics: [
+ "mail.inline_attachments",
+ "mail.show_headers",
+ "mail.showCondensedAddresses",
+ "mailnews.display.disallow_mime_handlers",
+ "mailnews.display.html_as",
+ "mailnews.display.prefer_plaintext",
+ "mailnews.headers.showReferences",
+ "rss.show.summary",
+ ],
+
+ _reloadTimeout: null,
+
+ init() {
+ for (let topic of this._topics) {
+ Services.prefs.addObserver(topic, this);
+ }
+ },
+
+ cleanUp() {
+ for (let topic of this._topics) {
+ Services.prefs.removeObserver(topic, this);
+ }
+ },
+
+ observe(subject, topic, data) {
+ if (data == "mail.show_headers") {
+ AdjustHeaderView(Services.prefs.getIntPref(data));
+ }
+ if (!this._reloadTimeout) {
+ // Clear the event queue before reloading the message. Several prefs may
+ // be changed at once.
+ this._reloadTimeout = setTimeout(() => {
+ this._reloadTimeout = null;
+ ReloadMessage();
+ });
+ }
+ },
+};
+
+var messageHistory = {
+ MAX_HISTORY_SIZE: 20,
+ /**
+ * @typedef {object} MessageHistoryEntry
+ * @property {string} messageURI - URI of the message for this entry.
+ * @property {string} folderURI - URI of the folder for this entry.
+ */
+ /**
+ * @type {MessageHistoryEntry[]}
+ */
+ _history: [],
+ _currentIndex: -1,
+ /**
+ * Remove the message from the history, cleaning up the state as needed in
+ * the process.
+ *
+ * @param {nsIMsgFolder} parentFolder
+ * @param {nsIMsgDBHdr} message
+ */
+ onMessageRemoved(parentFolder, message) {
+ if (!this._history.length) {
+ return;
+ }
+ const messageURI = parentFolder.generateMessageURI(message.messageKey);
+ const folderURI = parentFolder.URI;
+ const oldLength = this._history.length;
+ let removedEntriesBeforeFuture = 0;
+ this._history = this._history.filter((entry, index) => {
+ const keepEntry =
+ entry.messageURI !== messageURI || entry.folderURI !== folderURI;
+ if (!keepEntry && index <= this._currentIndex) {
+ ++removedEntriesBeforeFuture;
+ }
+ return keepEntry;
+ });
+ this._currentIndex -= removedEntriesBeforeFuture;
+ // Correct for first entry getting removed while it's the current entry.
+ if (this._history.length && this._currentIndex == -1) {
+ this._currentIndex = 0;
+ }
+ if (oldLength === this._history.length) {
+ return;
+ }
+ window.top.goUpdateCommand("cmd_goBack");
+ window.top.goUpdateCommand("cmd_goForward");
+ },
+ /**
+ * Get the actual index in the history based on a delta from the current
+ * index.
+ *
+ * @param {number} delta - Relative delta from the current index. Forward is
+ * positive, backward is negative.
+ * @returns {number} Absolute index in the history, bounded to the history
+ * size.
+ */
+ _getAbsoluteIndex(delta) {
+ return Math.min(
+ Math.max(this._currentIndex + delta, 0),
+ this._history.length - 1
+ );
+ },
+ /**
+ * Add a message to the end of the history. Does nothing if the message is
+ * already the current item. Moves the history forward by one step if the next
+ * item already matches the given message. Else removes any "future" history
+ * if the current position isn't the newest entry in the history.
+ *
+ * If the history is growing larger than what we want to keep, it is trimmed.
+ *
+ * Assumes the view is currently in the folder that should be comitted to
+ * history.
+ *
+ * @param {string} messageURI - Message to add to the history.
+ */
+ push(messageURI) {
+ if (!messageURI) {
+ return;
+ }
+ let currentItem = this._history[this._currentIndex];
+ let currentFolder = gFolder?.URI;
+ if (
+ currentItem &&
+ messageURI === currentItem.messageURI &&
+ currentFolder === currentItem.folderURI
+ ) {
+ return;
+ }
+ let nextMessageIndex = this._currentIndex + 1;
+ let erasedFuture = false;
+ if (nextMessageIndex < this._history.length) {
+ let nextMessage = this._history[nextMessageIndex];
+ if (
+ nextMessage &&
+ messageURI === nextMessage.messageURI &&
+ currentFolder === nextMessage.folderURI
+ ) {
+ this._currentIndex = nextMessageIndex;
+ if (this._currentIndex === 1) {
+ window.top.goUpdateCommand("cmd_goBack");
+ }
+ if (this._currentIndex + 1 === this._history.length) {
+ window.top.goUpdateCommand("cmd_goForward");
+ }
+ return;
+ }
+ this._history.splice(nextMessageIndex, Infinity);
+ erasedFuture = true;
+ }
+ this._history.push({ messageURI, folderURI: currentFolder });
+ this._currentIndex = nextMessageIndex;
+ if (this._history.length > this.MAX_HISTORY_SIZE) {
+ let amountOfItemsToRemove = this._history.length - this.MAX_HISTORY_SIZE;
+ this._history.splice(0, amountOfItemsToRemove);
+ this._currentIndex -= amountOfItemsToRemove;
+ }
+ if (!currentItem || this._currentIndex === 0) {
+ window.top.goUpdateCommand("cmd_goBack");
+ }
+ if (erasedFuture) {
+ window.top.goUpdateCommand("cmd_goForward");
+ }
+ },
+ /**
+ * Go forward or back in history relative to the current position.
+ *
+ * @param {number} delta
+ * @returns {?MessageHistoryEntry} The message and folder URI that are now at
+ * the active position in the history. If null is returned, no action was
+ * taken.
+ */
+ pop(delta) {
+ let targetIndex = this._getAbsoluteIndex(delta);
+ if (this._currentIndex == targetIndex && gMessage) {
+ return null;
+ }
+ this._currentIndex = targetIndex;
+ window.top.goUpdateCommand("cmd_goBack");
+ window.top.goUpdateCommand("cmd_goForward");
+ return this._history[targetIndex];
+ },
+ /**
+ * Get the current state of the message history.
+ *
+ * @returns {{entries: MessageHistoryEntry[], currentIndex: number}}
+ * A list of message and folder URIs as strings and the current index in the
+ * entries.
+ */
+ getHistory() {
+ return { entries: this._history.slice(), currentIndex: this._currentIndex };
+ },
+ /**
+ * Get a specific history entry relative to the current positon.
+ *
+ * @param {number} delta - Relative index to get the value of.
+ * @returns {?MessageHistoryEntry} If found, the message and
+ * folder URI at the given position.
+ */
+ getMessageAt(delta) {
+ if (!this._history.length) {
+ return null;
+ }
+ return this._history[this._getAbsoluteIndex(delta)];
+ },
+ /**
+ * Check if going forward or back in the history by the given steps is
+ * possible. A special case is when no message is currently selected, going
+ * back to relative position 0 (so the current index) is possible.
+ *
+ * @param {number} delta - Relative position to go to from the current index.
+ * @returns {boolean} If there is a target available at that position in the
+ * current history.
+ */
+ canPop(delta) {
+ let resultIndex = this._currentIndex + delta;
+ return (
+ resultIndex >= 0 &&
+ resultIndex < this._history.length &&
+ (resultIndex !== this._currentIndex || !gMessage)
+ );
+ },
+ /**
+ * Clear the message history, resetting it to its initial empty state.
+ */
+ clear() {
+ this._history.length = 0;
+ this._currentIndex = -1;
+ window.top.goUpdateCommand("cmd_goBack");
+ window.top.goUpdateCommand("cmd_goForward");
+ },
+};
+
+commandController.registerCallback(
+ "cmd_delete",
+ () => commandController.doCommand("cmd_deleteMessage"),
+ () => commandController.isCommandEnabled("cmd_deleteMessage")
+);
+commandController.registerCallback(
+ "cmd_shiftDelete",
+ () => commandController.doCommand("cmd_shiftDeleteMessage"),
+ () => commandController.isCommandEnabled("cmd_shiftDeleteMessage")
+);
+commandController.registerCallback("cmd_find", () =>
+ document.getElementById("FindToolbar").onFindCommand()
+);
+commandController.registerCallback("cmd_findAgain", () =>
+ document.getElementById("FindToolbar").onFindAgainCommand(false)
+);
+commandController.registerCallback("cmd_findPrevious", () =>
+ document.getElementById("FindToolbar").onFindAgainCommand(true)
+);
+commandController.registerCallback("cmd_print", () => {
+ top.PrintUtils.startPrintWindow(getMessagePaneBrowser().browsingContext, {});
+});
+commandController.registerCallback("cmd_fullZoomReduce", () => {
+ top.ZoomManager.reduce();
+});
+commandController.registerCallback("cmd_fullZoomEnlarge", () => {
+ top.ZoomManager.enlarge();
+});
+commandController.registerCallback("cmd_fullZoomReset", () => {
+ top.ZoomManager.reset();
+});
+commandController.registerCallback("cmd_fullZoomToggle", () => {
+ top.ZoomManager.toggleZoom();
+});
+
+// Attachments commands.
+commandController.registerCallback(
+ "cmd_openAllAttachments",
+ () => HandleAllAttachments("open"),
+ () => AttachmentMenuController.someFilesAvailable()
+);
+
+commandController.registerCallback(
+ "cmd_saveAllAttachments",
+ () => HandleAllAttachments("save"),
+ () => AttachmentMenuController.someFilesAvailable()
+);
+
+commandController.registerCallback(
+ "cmd_detachAllAttachments",
+ () => HandleAllAttachments("detach"),
+ () => AttachmentMenuController.canDetachFiles()
+);
+
+commandController.registerCallback(
+ "cmd_deleteAllAttachments",
+ () => HandleAllAttachments("delete"),
+ () => AttachmentMenuController.canDetachFiles()
+);
diff --git a/comm/mail/base/content/aboutMessage.xhtml b/comm/mail/base/content/aboutMessage.xhtml
new file mode 100644
index 0000000000..36571563db
--- /dev/null
+++ b/comm/mail/base/content/aboutMessage.xhtml
@@ -0,0 +1,158 @@
+<?xml version="1.0"?>
+<!-- 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/. -->
+
+#filter substitution
+
+<!DOCTYPE html [
+<!ENTITY % msgHdrViewOverlayDTD SYSTEM "chrome://messenger/locale/msgHdrViewOverlay.dtd">
+%msgHdrViewOverlayDTD;
+<!ENTITY % messengerDTD SYSTEM "chrome://messenger/locale/messenger.dtd" >
+%messengerDTD;
+<!ENTITY % editContactOverlayDTD SYSTEM "chrome://messenger/locale/editContactOverlay.dtd">
+%editContactOverlayDTD;
+<!ENTITY % lightningDTD SYSTEM "chrome://lightning/locale/lightning.dtd">
+%lightningDTD;
+<!ENTITY % calendarDTD SYSTEM "chrome://calendar/locale/calendar.dtd" >
+%calendarDTD;
+<!ENTITY % smimeDTD SYSTEM "chrome://messenger-smime/locale/msgReadSecurityInfo.dtd">
+%smimeDTD;
+]>
+<html xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml">
+<head>
+ <meta charset="utf-8" />
+ <title></title>
+
+ <link rel="icon" href="chrome://messenger/skin/icons/new/compact/draft.svg" />
+
+ <link rel="stylesheet" href="chrome://calendar/skin/calendar.css" />
+ <link rel="stylesheet" href="chrome://calendar/skin/shared/calendar-invitation-display.css" />
+ <link rel="stylesheet" href="chrome://messenger/skin/messageWindow.css" />
+ <link rel="stylesheet" href="chrome://messenger/skin/popupPanel.css" />
+ <link rel="stylesheet" href="chrome://messenger/skin/messageHeader.css" />
+ <link rel="stylesheet" href="chrome://messenger/skin/icons.css" />
+ <link rel="stylesheet" href="chrome://messenger/skin/colors.css" />
+ <link rel="stylesheet" href="chrome://messenger/skin/folderMenus.css" />
+ <link rel="stylesheet" href="chrome://messenger/skin/attachmentList.css" />
+ <link rel="stylesheet" href="chrome://messenger/skin/searchBox.css" />
+ <link rel="stylesheet" href="chrome://messenger/skin/openpgp/inlineNotification.css" />
+ <link rel="stylesheet" href="chrome://messenger/skin/contextMenu.css" />
+ <link rel="stylesheet" href="chrome://messenger/skin/autocomplete.css" />
+
+ <link rel="localization" href="messenger/messenger.ftl" />
+ <link rel="localization" href="toolkit/main-window/findbar.ftl" />
+ <link rel="localization" href="toolkit/global/textActions.ftl" />
+ <link rel="localization" href="calendar/calendar-invitation-panel.ftl" />
+ <link rel="localization" href="messenger/openpgp/openpgp.ftl" />
+ <link rel="localization" href="messenger/openpgp/openpgp-frontend.ftl" />
+ <link rel="localization" href="messenger/openpgp/msgReadStatus.ftl" />
+ <link rel="localization" href="messenger/messageheader/headerFields.ftl" />
+
+ <script defer="defer" src="chrome://messenger/content/globalOverlay.js"></script>
+ <script defer="defer" src="chrome://global/content/contentAreaUtils.js"></script>
+ <script defer="defer" src="chrome://communicator/content/utilityOverlay.js"></script>
+ <script defer="defer" src="chrome://communicator/content/contentAreaClick.js"></script>
+ <script defer="defer" src="chrome://messenger/content/msgViewNavigation.js"></script>
+ <script defer="defer" src="chrome://messenger/content/editContactPanel.js"></script>
+ <script defer="defer" src="chrome://messenger/content/header-fields.js"></script>
+ <script defer="defer" src="chrome://messenger/content/mail-offline.js"></script>
+ <script defer="defer" src="chrome://messenger-smime/content/msgHdrViewSMIMEOverlay.js"></script>
+ <script defer="defer" src="chrome://messenger-smime/content/msgReadSMIMEOverlay.js"></script>
+ <script defer="defer" src="chrome://openpgp/content/ui/enigmailMessengerOverlay.js"></script>
+ <script defer="defer" src="chrome://openpgp/content/ui/enigmailMsgHdrViewOverlay.js"></script>
+ <script defer="defer" src="chrome://messenger/content/msgSecurityPane.js"></script>
+ <script defer="defer" src="chrome://messenger-newsblog/content/newsblogOverlay.js"></script>
+ <script defer="defer" src="chrome://messenger/content/messenger-customization.js"></script>
+ <script defer="defer" src="chrome://calendar/content/calendar-item-editing.js"></script>
+ <script defer="defer" src="chrome://calendar/content/imip-bar.js"></script>
+ <script defer="defer" src="chrome://calendar/content/calendar-invitation-display.js"></script>
+ <script defer="defer" src="chrome://calendar/content/widgets/calendar-invitation-panel.js"></script>
+ <script defer="defer" src="chrome://messenger/content/mailCore.js"></script>
+ <script defer="defer" src="chrome://messenger/content/mailContext.js"></script>
+ <script defer="defer" src="chrome://messenger/content/mailCommon.js"></script>
+ <script defer="defer" src="chrome://messenger/content/msgHdrView.js"></script>
+ <script defer="defer" src="chrome://messenger/content/aboutMessage.js"></script>
+</head>
+<html:body xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+
+ <stringbundle id="bundle_brand" src="chrome://branding/locale/brand.properties"/>
+ <stringbundle id="bundle_messenger" src="chrome://messenger/locale/messenger.properties"/>
+
+ <commandset id="attachmentCommands">
+ <command id="cmd_openAllAttachments"
+ oncommand="goDoCommand('cmd_openAllAttachments');"/>
+ <command id="cmd_saveAllAttachments"
+ oncommand="goDoCommand('cmd_saveAllAttachments');"/>
+ <command id="cmd_detachAllAttachments"
+ oncommand="goDoCommand('cmd_detachAllAttachments');"/>
+ <command id="cmd_deleteAllAttachments"
+ oncommand="goDoCommand('cmd_deleteAllAttachments');"/>
+ </commandset>
+
+ <popupset id="mainPopupSet">
+#include mailContext.inc.xhtml
+#include msgHdrPopup.inc.xhtml
+#include editContactPanel.inc.xhtml
+ <tooltip id="aHTMLTooltip" page="true"/>
+ </popupset>
+
+ <!-- msg header view -->
+ <!-- a convenience box for ease of extension overlaying -->
+ <hbox id="messagepaneboxwrapper" flex="1">
+ <vbox id="messagepanebox">
+ <vbox id="singleMessage">
+ <hbox id="msgHeaderView" collapsed="true" class="main-header-area">
+#include msgHdrView.inc.xhtml
+ </hbox>
+#include ../../../calendar/base/content/imip-bar-overlay.inc.xhtml
+ </vbox>
+ <!-- The msgNotificationBar appears on top of the message and displays
+ information like: junk, mdn, remote content and phishing warnings -->
+ <vbox id="mail-notification-top">
+ <!-- notificationbox will be added here lazily. -->
+ </vbox>
+
+#include ../../../calendar/base/content/widgets/calendar-invitation-panel.xhtml
+#include ../../../calendar/base/content/widgets/calendar-minidate.xhtml
+
+ <vbox id="calendarInvitationDisplayContainer"
+ flex="1"
+ hidden="true">
+ <html:div id="calendarInvitationDisplay">
+ <!-- The calendar invitation panel is displayed here. -->
+ </html:div>
+ </vbox>
+
+ <!-- message view -->
+ <browser id="messagepane"
+ context="mailContext"
+ tooltip="aHTMLTooltip"
+ style="height: 0px; min-height: 1px; background-color: field;"
+ flex="1"
+ name="messagepane"
+ disablesecurity="true"
+ disablehistory="true"
+ type="content"
+ primary="true"
+ autofind="false"
+ nodefaultsrc="true"
+ forcemessagemanager="true"
+ maychangeremoteness="true"
+ messagemanagergroup="single-page"
+ onclick="return contentAreaClick(event);"
+ onresize="messagePaneOnResize();"/>
+ <splitter id="attachment-splitter" orient="vertical"
+ resizebefore="closest" resizeafter="closest"
+ collapse="after" collapsed="true"/>
+ <vbox id="attachmentView" collapsed="true">
+#include msgAttachmentView.inc.xhtml
+ </vbox>
+ <findbar id="FindToolbar" browserid="messagepane"/>
+ </vbox>
+#include msgSecurityPane.inc.xhtml
+ </hbox>
+</html:body>
+</html>
diff --git a/comm/mail/base/content/aboutRights.xhtml b/comm/mail/base/content/aboutRights.xhtml
new file mode 100644
index 0000000000..51997dc87d
--- /dev/null
+++ b/comm/mail/base/content/aboutRights.xhtml
@@ -0,0 +1,110 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE html [ <!ENTITY % htmlDTD PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "DTD/xhtml1-strict.dtd">
+%htmlDTD; ]>
+
+<!-- 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/. -->
+
+<html xmlns="http://www.w3.org/1999/xhtml">
+ <head>
+ <meta
+ http-equiv="Content-Security-Policy"
+ content="default-src chrome:; object-src 'none'"
+ />
+ <title data-l10n-id="rights-title"></title>
+ <link
+ rel="stylesheet"
+ href="chrome://global/skin/in-content/info-pages.css"
+ type="text/css"
+ />
+ <link
+ rel="stylesheet"
+ href="chrome://global/skin/aboutRights.css"
+ type="text/css"
+ />
+ <link rel="localization" href="branding/brand.ftl" />
+ <link rel="localization" href="messenger/aboutRights.ftl" />
+ </head>
+
+ <body id="your-rights">
+ <div class="container">
+ <div class="rights-header">
+ <div>
+ <h1 data-l10n-id="rights-title"></h1>
+
+ <p data-l10n-id="rights-intro"></p>
+ </div>
+ </div>
+
+ <ul>
+ <li data-l10n-id="rights-intro-point-1">
+ <a
+ href="http://www.mozilla.org/MPL/"
+ data-l10n-name="mozilla-public-license-link"
+ ></a>
+ </li>
+ <!-- Point 2 discusses Mozilla trademarks, and isn't needed when the build is unbranded.
+ - Point 4 discusses privacy policy, unbranded builds get a placeholder (for the vendor to replace)
+ - Point 5 discusses web service terms, unbranded builds gets a placeholder (for the vendor to replace) -->
+ <li data-l10n-id="rights-intro-point-2">
+ <a
+ href="http://www.mozilla.org/foundation/trademarks/policy.html"
+ data-l10n-name="mozilla-trademarks-link"
+ ></a>
+ </li>
+ <li data-l10n-id="rights-intro-point-3"></li>
+ <li data-l10n-id="rights-intro-point-4">
+ <a
+ href="https://www.mozilla.org/legal/privacy/firefox.html"
+ data-l10n-name="mozilla-privacy-policy-link"
+ ></a>
+ </li>
+ <li data-l10n-id="rights-intro-point-5">
+ <a
+ href="about:rights#webservices"
+ id="showWebServices"
+ data-l10n-name="mozilla-service-terms-link"
+ ></a>
+ </li>
+ <li data-l10n-id="rights-intro-point-6"></li>
+ </ul>
+
+ <div id="webservices-container">
+ <a name="webservices" />
+ <h3 data-l10n-id="rights-webservices-header"></h3>
+
+ <p data-l10n-id="rights-webservices2">
+ <a
+ href="about:rights#disabling-webservices"
+ id="showDisablingWebServices"
+ data-l10n-name="mozilla-disable-service-link"
+ ></a>
+ </p>
+
+ <div id="disabling-webservices-container" style="margin-left: 40px">
+ <a name="disabling-webservices" />
+ <p data-l10n-id="rights-locationawarebrowsing"></p>
+ <ul>
+ <li data-l10n-id="rights-locationawarebrowsing-term-1"></li>
+ <li data-l10n-id="rights-locationawarebrowsing-term-2"></li>
+ <li data-l10n-id="rights-locationawarebrowsing-term-3"></li>
+ <li data-l10n-id="rights-locationawarebrowsing-term-4"></li>
+ </ul>
+ </div>
+
+ <ol>
+ <!-- Terms only apply to official builds, unbranded builds get a placeholder. -->
+ <li data-l10n-id="rights-webservices-term-1"></li>
+ <li data-l10n-id="rights-webservices-term-2"></li>
+ <li data-l10n-id="rights-webservices-term-3"></li>
+ <li data-l10n-id="rights-webservices-term-4"></li>
+ <li data-l10n-id="rights-webservices-term-5"></li>
+ <li data-l10n-id="rights-webservices-term-6"></li>
+ <li data-l10n-id="rights-webservices-term-7"></li>
+ </ol>
+ </div>
+ </div>
+ </body>
+ <script src="chrome://global/content/aboutRights.js" />
+</html>
diff --git a/comm/mail/base/content/browserRequest.js b/comm/mail/base/content/browserRequest.js
new file mode 100644
index 0000000000..3cf695f94a
--- /dev/null
+++ b/comm/mail/base/content/browserRequest.js
@@ -0,0 +1,145 @@
+/* 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/. */
+
+var { MailE10SUtils } = ChromeUtils.import(
+ "resource:///modules/MailE10SUtils.jsm"
+);
+
+/* Magic global things the <browser> and its entourage of logic expect. */
+var PopupNotifications = {
+ show(browser, id, message) {
+ console.warn(
+ "Not showing popup notification",
+ id,
+ "with the message",
+ message
+ );
+ },
+};
+
+var gBrowser = {
+ get selectedBrowser() {
+ return document.getElementById("requestFrame");
+ },
+ _getAndMaybeCreateDateTimePickerPanel() {
+ return this.selectedBrowser.dateTimePicker;
+ },
+ get webNavigation() {
+ return this.selectedBrowser.webNavigation;
+ },
+};
+
+function getBrowser() {
+ return gBrowser.selectedBrowser;
+}
+
+/* Logic to actually run the login process and window contents */
+var reporterListener = {
+ _isBusy: false,
+
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIWebProgressListener",
+ "nsISupportsWeakReference",
+ ]),
+
+ onStateChange(
+ /* in nsIWebProgress*/ aWebProgress,
+ /* in nsIRequest*/ aRequest,
+ /* in unsigned long*/ aStateFlags,
+ /* in nsresult*/ aStatus
+ ) {},
+
+ onProgressChange(
+ /* in nsIWebProgress*/ aWebProgress,
+ /* in nsIRequest*/ aRequest,
+ /* in long*/ aCurSelfProgress,
+ /* in long */ aMaxSelfProgress,
+ /* in long */ aCurTotalProgress,
+ /* in long */ aMaxTotalProgress
+ ) {},
+
+ onLocationChange(
+ /* in nsIWebProgress*/ aWebProgress,
+ /* in nsIRequest*/ aRequest,
+ /* in nsIURI*/ aLocation
+ ) {
+ document.getElementById("headerMessage").value = aLocation.spec;
+ },
+
+ onStatusChange(
+ /* in nsIWebProgress*/ aWebProgress,
+ /* in nsIRequest*/ aRequest,
+ /* in nsresult*/ aStatus,
+ /* in wstring*/ aMessage
+ ) {},
+
+ onSecurityChange(
+ /* in nsIWebProgress*/ aWebProgress,
+ /* in nsIRequest*/ aRequest,
+ /* in unsigned long*/ aState
+ ) {
+ const wpl_security_bits =
+ Ci.nsIWebProgressListener.STATE_IS_SECURE |
+ Ci.nsIWebProgressListener.STATE_IS_BROKEN |
+ Ci.nsIWebProgressListener.STATE_IS_INSECURE;
+
+ let icon = document.getElementById("security-icon");
+ switch (aState & wpl_security_bits) {
+ case Ci.nsIWebProgressListener.STATE_IS_SECURE:
+ icon.setAttribute(
+ "src",
+ "chrome://messenger/skin/icons/connection-secure.svg"
+ );
+ // Set alt.
+ document.l10n.setAttributes(icon, "content-tab-security-high-icon");
+ icon.classList.add("secure-connection-icon");
+ break;
+ case Ci.nsIWebProgressListener.STATE_IS_BROKEN:
+ icon.setAttribute(
+ "src",
+ "chrome://messenger/skin/icons/connection-insecure.svg"
+ );
+ document.l10n.setAttributes(icon, "content-tab-security-broken-icon");
+ icon.classList.remove("secure-connection-icon");
+ break;
+ default:
+ icon.removeAttribute("src");
+ icon.removeAttribute("data-l10n-id");
+ icon.removeAttribute("alt");
+ icon.classList.remove("secure-connection-icon");
+ break;
+ }
+ },
+
+ onContentBlockingEvent(
+ /* in nsIWebProgress*/ aWebProgress,
+ /* in nsIRequest*/ aRequest,
+ /* in unsigned long*/ aEvent
+ ) {},
+};
+
+function cancelRequest() {
+ reportUserClosed();
+ window.close();
+}
+
+function reportUserClosed() {
+ let request = window.arguments[0].wrappedJSObject;
+ request.cancelled();
+}
+
+function loadRequestedUrl() {
+ let request = window.arguments[0].wrappedJSObject;
+
+ var browser = document.getElementById("requestFrame");
+ browser.addProgressListener(reporterListener, Ci.nsIWebProgress.NOTIFY_ALL);
+ var url = request.url;
+ if (url == "") {
+ document.getElementById("headerMessage").value = request.promptText;
+ } else {
+ MailE10SUtils.loadURI(browser, url);
+ document.getElementById("headerMessage").value = url;
+ }
+ request.loaded(window, browser.webProgress);
+}
diff --git a/comm/mail/base/content/browserRequest.xhtml b/comm/mail/base/content/browserRequest.xhtml
new file mode 100644
index 0000000000..b9a77766a9
--- /dev/null
+++ b/comm/mail/base/content/browserRequest.xhtml
@@ -0,0 +1,58 @@
+<?xml version="1.0"?>
+<!--# 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/.
+ -->
+
+<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/messenger.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/tabmail.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/searchBox.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/browserRequest.css" type="text/css"?>
+
+<!DOCTYPE window [
+<!ENTITY % brandDTD SYSTEM "chrome://branding/locale/brand.dtd">
+%brandDTD;
+<!ENTITY % messengerDTD SYSTEM "chrome://messenger/locale/messenger.dtd">
+%messengerDTD;
+]>
+<window id="browserRequest"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" xmlns:html="http://www.w3.org/1999/xhtml"
+ buttons=","
+ onload="loadRequestedUrl()"
+ onclose="reportUserClosed()"
+ title=""
+ width="800"
+ height="500"
+ orient="vertical">
+ <popupset id="mainPopupSet">
+#define NO_BROWSERCONTEXT
+#include widgets/browserPopups.inc.xhtml
+ </popupset>
+
+ <script src="chrome://messenger/content/globalOverlay.js"/>
+ <script src="chrome://global/content/editMenuOverlay.js"/>
+ <script src="chrome://messenger/content/viewZoomOverlay.js"/>
+ <script src="chrome://messenger/content/browserRequest.js"/>
+
+ <html:link rel="localization" href="messenger/messenger.ftl"/>
+
+ <keyset id="mainKeyset">
+ <key id="key_close" key="w" modifiers="accel" oncommand="cancelRequest()"/>
+ <key id="key_close2" keycode="VK_ESCAPE" oncommand="cancelRequest()"/>
+ </keyset>
+
+ <!-- Use the same styling and semantics as content tabs. -->
+ <html:div id="header" class="contentTabAddress">
+ <html:img id="security-icon" class="contentTabSecurity" />
+ <html:input id="headerMessage" class="contentTabUrlInput themeableSearchBox"
+ readonly="readonly">
+ </html:input>
+ </html:div>
+ <browser id="requestFrame"
+ type="content"
+ nodefaultsrc="true"
+ maychangeremoteness="true"
+ flex="1"
+ autocompletepopup="PopupAutoComplete"/>
+</window>
diff --git a/comm/mail/base/content/buildconfig.html b/comm/mail/base/content/buildconfig.html
new file mode 100644
index 0000000000..1a699cbfed
--- /dev/null
+++ b/comm/mail/base/content/buildconfig.html
@@ -0,0 +1,106 @@
+<!DOCTYPE html>
+# 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/.
+#
+#filter substitution
+#include @TOPOBJDIR@/source-repo.h
+#include @TOPOBJDIR@/buildid.h
+<html>
+ <head>
+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src chrome:; object-src 'none'" />
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width; user-scalable=false;">
+ <title>Build Configuration</title>
+ <link rel="stylesheet" href="chrome://global/skin/in-content/info-pages.css" type="text/css">
+ <link rel="stylesheet" href="chrome://global/content/buildconfig.css" type="text/css">
+ </head>
+ <body>
+ <div>
+ <h1>Build Configuration</h1>
+ <h2>@MOZ_APP_DISPLAYNAME@ @MOZ_APP_VERSION_DISPLAY@ - @MOZ_BUILDID@</h2>
+ <table>
+ <tbody>
+ #ifdef MOZ_COMM_SOURCE_URL
+ <tr>
+ <th>
+ @MOZ_APP_DISPLAYNAME@ source
+ </th>
+ </tr>
+ <tr>
+ <td>
+ <a href="@MOZ_COMM_SOURCE_URL@">@MOZ_COMM_SOURCE_URL@</a>
+ </td>
+ </tr>
+ #endif
+ #ifdef MOZ_GECKO_SOURCE_URL
+ <tr>
+ <th>
+ Platform source
+ </th>
+ </tr>
+ <tr>
+ <td>
+ <a href="@MOZ_GECKO_SOURCE_URL@">@MOZ_GECKO_SOURCE_URL@</a>
+ </td>
+ </tr>
+ #endif
+ </tbody>
+ </table>
+
+ <p>
+ The latest information on building @MOZ_APP_DISPLAYNAME@ can be found at
+ <a href="@THUNDERBIRD_DEVELOPER_WWW@">@THUNDERBIRD_DEVELOPER_WWW@</a>.
+ </p>
+
+ <h2>Build platform</h2>
+ <table>
+ <tbody>
+ <tr>
+ <th>Target</th>
+ </tr>
+ <tr>
+ <td>@target@</td>
+ </tr>
+ </tbody>
+ </table>
+ #if defined(CC) && defined(CXX) && defined(RUSTC)
+ <h2>Build tools</h2>
+ <table>
+ <tbody>
+ <tr>
+ <th>Compiler</th>
+ <th>Version</th>
+ <th>Compiler flags</th>
+ </tr>
+ <tr>
+ <td><code>@CC@</code></td>
+ <td><code>@CC_VERSION@</code></td>
+ <td><code>@CFLAGS@</code></td>
+ </tr>
+ <tr>
+ <td><code>@CXX@</code></td>
+ <td><code>@CC_VERSION@</code></td>
+ <td><code>@CXXFLAGS@</code></td>
+ </tr>
+ <tr>
+ <td><code>@RUSTC@</code></td>
+ <td><code>@RUSTC_VERSION@</code></td>
+ <td><code>@RUSTFLAGS@</code></td>
+ </tr>
+ </tbody>
+ </table>
+ #endif
+ <h2>Configure options</h2>
+ <table>
+ <tbody>
+ <tr>
+ <td>
+ <code>@MOZ_CONFIGURE_OPTIONS@</code>
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+ </body>
+</html>
diff --git a/comm/mail/base/content/commonDialog.xhtml b/comm/mail/base/content/commonDialog.xhtml
new file mode 100644
index 0000000000..4072ff52f6
--- /dev/null
+++ b/comm/mail/base/content/commonDialog.xhtml
@@ -0,0 +1,110 @@
+<?xml version="1.0"?>
+<!-- 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/. -->
+
+<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?>
+<?xml-stylesheet href="chrome://global/content/commonDialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://global/skin/commonDialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/variables.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?>
+
+<!DOCTYPE window>
+
+<window
+ id="commonDialogWindow"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ aria-describedby="infoBody"
+ headerparent="dialogGrid"
+ onunload="commonDialogOnUnload();"
+>
+ <dialog id="commonDialog" buttonpack="end">
+ <linkset>
+ <html:link rel="localization" href="branding/brand.ftl" />
+ <html:link rel="localization" href="toolkit/global/commonDialog.ftl" />
+ </linkset>
+ <script src="chrome://global/content/adjustableTitle.js" />
+ <script src="chrome://global/content/commonDialog.js" />
+ <script src="chrome://messenger/content/globalOverlay.js" />
+ <script src="chrome://global/content/editMenuOverlay.js" />
+ <script src="chrome://global/content/customElements.js" />
+ <script src="chrome://messenger/content/dialogShadowDom.js" />
+ <script>
+ /* eslint-disable no-undef */
+ document.addEventListener("DOMContentLoaded", function () {
+ commonDialogOnLoad();
+ });
+ </script>
+
+ <commandset id="selectEditMenuItems">
+ <command
+ id="cmd_copy"
+ oncommand="goDoCommand('cmd_copy')"
+ disabled="true"
+ />
+ <command id="cmd_selectAll" oncommand="goDoCommand('cmd_selectAll')" />
+ </commandset>
+
+ <popupset id="contentAreaContextSet">
+ <menupopup
+ id="contentAreaContextMenu"
+ onpopupshowing="goUpdateCommand('cmd_copy')"
+ >
+ <menuitem
+ id="context-copy"
+ data-l10n-id="common-dialog-copy-cmd"
+ command="cmd_copy"
+ disabled="true"
+ />
+ <menuitem
+ id="context-selectall"
+ data-l10n-id="common-dialog-select-all-cmd"
+ command="cmd_selectAll"
+ />
+ </menupopup>
+ </popupset>
+
+ <!-- The <div> was added in bug 1606617 to workaround bug 1614447 -->
+ <div xmlns="http://www.w3.org/1999/xhtml">
+ <div id="dialogGrid">
+ <div class="dialogRow" id="infoRow" hidden="hidden">
+ <div id="iconContainer">
+ <img id="infoIcon" src="" alt="" role="presentation" />
+ </div>
+ <div id="infoContainer">
+ <xul:description id="infoTitle" />
+ <xul:description
+ id="infoBody"
+ context="contentAreaContextMenu"
+ noinitialfocus="true"
+ />
+ </div>
+ </div>
+ <div id="loginContainer" class="dialogRow" hidden="hidden">
+ <xul:label
+ id="loginLabel"
+ data-l10n-id="common-dialog-username"
+ control="loginTextbox"
+ />
+ <input type="text" id="loginTextbox" dir="ltr" />
+ </div>
+ <div id="password1Container" class="dialogRow" hidden="hidden">
+ <xul:label
+ id="password1Label"
+ data-l10n-id="common-dialog-password"
+ control="password1Textbox"
+ />
+ <input type="password" id="password1Textbox" dir="ltr" />
+ </div>
+ <div id="checkboxContainer" class="dialogRow" hidden="hidden">
+ <div />
+ <!-- spacer -->
+ <xul:checkbox id="checkbox" oncommand="Dialog.onCheckbox()" />
+ </div>
+ </div>
+ </div>
+ </dialog>
+</window>
diff --git a/comm/mail/base/content/compactFoldersDialog.js b/comm/mail/base/content/compactFoldersDialog.js
new file mode 100644
index 0000000000..b0ac027266
--- /dev/null
+++ b/comm/mail/base/content/compactFoldersDialog.js
@@ -0,0 +1,54 @@
+/* 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/. */
+
+var propBag, args;
+
+document.addEventListener("DOMContentLoaded", compactDialogOnDOMContentLoaded);
+// Bug 1720540: Call sizeToContent only after the entire window has been loaded,
+// including the shadow DOM and the updated fluent strings.
+window.addEventListener("load", window.sizeToContent);
+
+function compactDialogOnDOMContentLoaded() {
+ propBag = window.arguments[0]
+ .QueryInterface(Ci.nsIWritablePropertyBag2)
+ .QueryInterface(Ci.nsIWritablePropertyBag);
+
+ // Convert to a JS object.
+ args = {};
+ for (let prop of propBag.enumerator) {
+ args[prop.name] = prop.value;
+ }
+
+ // We're deliberately adding the data-l10n-args attribute synchronously to
+ // avoid race issues for window.sizeToContent later on.
+ document
+ .getElementById("compactFoldersText")
+ .setAttribute("data-l10n-args", JSON.stringify({ data: args.compactSize }));
+
+ document.addEventListener("dialogaccept", function () {
+ args.buttonNumClicked = 0;
+ args.checked = document.getElementById("neverAskCheckbox").checked;
+ });
+
+ document.addEventListener("dialogcancel", function () {
+ args.buttonNumClicked = 1;
+ });
+
+ document.addEventListener("dialogextra1", function () {
+ // Open the support article URL and leave the dialog open.
+ let uri = Services.io.newURI(
+ "https://support.mozilla.org/kb/compacting-folders"
+ );
+ Cc["@mozilla.org/uriloader/external-protocol-service;1"]
+ .getService(Ci.nsIExternalProtocolService)
+ .loadURI(uri);
+ });
+}
+
+function compactDialogOnUnload() {
+ // Convert args back into property bag.
+ for (let propName in args) {
+ propBag.setProperty(propName, args[propName]);
+ }
+}
diff --git a/comm/mail/base/content/compactFoldersDialog.xhtml b/comm/mail/base/content/compactFoldersDialog.xhtml
new file mode 100644
index 0000000000..427c70848c
--- /dev/null
+++ b/comm/mail/base/content/compactFoldersDialog.xhtml
@@ -0,0 +1,54 @@
+<?xml version="1.0"?>
+<!-- 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/. -->
+
+<?xml-stylesheet href="chrome://messenger/skin/messenger.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?>
+
+<!DOCTYPE window>
+
+<window
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ data-l10n-id="compact-dialog-window-title"
+ role="alert"
+ lightweightthemes="true"
+ onunload="compactDialogOnUnload();"
+>
+ <dialog
+ id="folderCompactDialog"
+ buttons="accept,cancel,extra1"
+ data-l10n-id="compact-dialog"
+ data-l10n-attrs="buttonlabelaccept, buttonlabelcancel, buttonlabelextra1, buttonaccesskeyaccept, buttonaccesskeycancel, buttonaccesskeyextra1"
+ >
+ <linkset>
+ <html:link rel="localization" href="branding/brand.ftl" />
+ <html:link rel="localization" href="messenger/compactFoldersDialog.ftl" />
+ </linkset>
+
+ <script src="chrome://messenger/content/globalOverlay.js" />
+ <script src="chrome://global/content/editMenuOverlay.js" />
+ <script src="chrome://messenger/content/compactFoldersDialog.js" />
+ <script src="chrome://messenger/content/dialogShadowDom.js" />
+
+ <hbox>
+ <vbox class="image-container">
+ <html:img src="chrome://global/skin/icons/help.svg" alt="" />
+ </vbox>
+
+ <vbox flex="1" class="text-container">
+ <description
+ id="compactFoldersText"
+ data-l10n-id="compact-dialog-message"
+ data-l10n-args='{"data" : ""}'
+ />
+ <checkbox
+ id="neverAskCheckbox"
+ data-l10n-id="compact-dialog-never-ask-checkbox"
+ />
+ </vbox>
+ </hbox>
+ </dialog>
+</window>
diff --git a/comm/mail/base/content/contentAreaClick.js b/comm/mail/base/content/contentAreaClick.js
new file mode 100644
index 0000000000..647c4e7551
--- /dev/null
+++ b/comm/mail/base/content/contentAreaClick.js
@@ -0,0 +1,206 @@
+/**
+ * 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 ../../../../toolkit/content/contentAreaUtils.js */
+/* import-globals-from utilityOverlay.js */
+
+/* globals getMessagePaneBrowser */ // From aboutMessage.js
+
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+XPCOMUtils.defineLazyModuleGetters(this, {
+ PhishingDetector: "resource:///modules/PhishingDetector.jsm",
+});
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "alternativeAddonSearchUrl",
+ "extensions.alternativeAddonSearch.url"
+);
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "canonicalAddonServerUrl",
+ "extensions.canonicalAddonServer.url"
+);
+/**
+ * Extract the href from the link click event.
+ * We look for HTMLAnchorElement, HTMLAreaElement, HTMLLinkElement,
+ * HTMLInputElement.form.action, and nested anchor tags.
+ * If the clicked element was a HTMLInputElement or HTMLButtonElement
+ * we return the form action.
+ *
+ * @returns [href, linkText] the url and the text for the link being clicked.
+ */
+function hRefForClickEvent(aEvent, aDontCheckInputElement) {
+ let target =
+ aEvent.type == "command"
+ ? document.commandDispatcher.focusedElement
+ : aEvent.target;
+
+ if (
+ HTMLImageElement.isInstance(target) &&
+ target.hasAttribute("overflowing")
+ ) {
+ // Click on zoomed image.
+ return [null, null];
+ }
+
+ let href = null;
+ let linkText = null;
+ if (
+ HTMLAnchorElement.isInstance(target) ||
+ HTMLAreaElement.isInstance(target) ||
+ HTMLLinkElement.isInstance(target)
+ ) {
+ if (target.hasAttribute("href")) {
+ href = target.href;
+ linkText = gatherTextUnder(target);
+ }
+ } else if (
+ !aDontCheckInputElement &&
+ (HTMLInputElement.isInstance(target) ||
+ HTMLButtonElement.isInstance(target))
+ ) {
+ if (target.form && target.form.action) {
+ href = target.form.action;
+ }
+ } else {
+ // We may be nested inside of a link node.
+ let linkNode = aEvent.target;
+ while (linkNode && !HTMLAnchorElement.isInstance(linkNode)) {
+ linkNode = linkNode.parentNode;
+ }
+
+ if (linkNode) {
+ href = linkNode.href;
+ linkText = gatherTextUnder(linkNode);
+ }
+ }
+ return [href, linkText];
+}
+
+/**
+ * Check whether the click target's or its ancestor's href
+ * points to an anchor on the page.
+ *
+ * @param HTMLElement aTargetNode - the element node.
+ * @returns - true if link pointing to anchor.
+ */
+function isLinkToAnchorOnPage(aTargetNode) {
+ let url = aTargetNode.ownerDocument.URL;
+ if (!url.startsWith("http")) {
+ return false;
+ }
+
+ let linkNode = aTargetNode;
+ while (linkNode && !HTMLAnchorElement.isInstance(linkNode)) {
+ linkNode = linkNode.parentNode;
+ }
+
+ // It's not a link with an anchor.
+ if (!linkNode || !linkNode.href || !linkNode.hash) {
+ return false;
+ }
+
+ // The link's href must match the document URL.
+ if (makeURI(linkNode.href).specIgnoringRef != makeURI(url).specIgnoringRef) {
+ return false;
+ }
+
+ return true;
+}
+
+// Called whenever the user clicks in the content area,
+// should always return true for click to go through.
+function contentAreaClick(aEvent) {
+ let target = aEvent.target;
+ if (target.localName == "browser") {
+ // This is a remote browser. Nothing useful can happen in this process.
+ return true;
+ }
+
+ // If we've loaded a web page url, and the element's or its ancestor's href
+ // points to an anchor on the page, let the click go through.
+ // Otherwise fall through and open externally.
+ if (isLinkToAnchorOnPage(target)) {
+ return true;
+ }
+
+ let [href, linkText] = hRefForClickEvent(aEvent);
+
+ if (!href && !aEvent.button) {
+ // Is this an image that we might want to scale?
+
+ if (HTMLImageElement.isInstance(target)) {
+ // Make sure it loaded successfully. No action if not or a broken link.
+ var req = target.getRequest(Ci.nsIImageLoadingContent.CURRENT_REQUEST);
+ if (!req || req.imageStatus & Ci.imgIRequest.STATUS_ERROR) {
+ return false;
+ }
+
+ // Is it an image?
+ if (target.localName == "img" && target.hasAttribute("overflowing")) {
+ if (target.hasAttribute("shrinktofit")) {
+ // Currently shrunk to fit, so unshrink it.
+ target.removeAttribute("shrinktofit");
+ } else {
+ // User wants to shrink now.
+ target.setAttribute("shrinktofit", true);
+ }
+
+ return false;
+ }
+ }
+ return true;
+ }
+
+ if (!href || aEvent.button == 2) {
+ return true;
+ }
+
+ // We want all about, http and https links in the message pane to be loaded
+ // externally in a browser, therefore we need to detect that here and redirect
+ // as necessary.
+ let uri = makeURI(href);
+ if (
+ Cc["@mozilla.org/uriloader/external-protocol-service;1"]
+ .getService(Ci.nsIExternalProtocolService)
+ .isExposedProtocol(uri.scheme) &&
+ !uri.schemeIs("http") &&
+ !uri.schemeIs("https")
+ ) {
+ return true;
+ }
+
+ // Add-on names in the Add-On Manager are links, but we don't want to do
+ // anything with them.
+ if (uri.schemeIs("addons")) {
+ return true;
+ }
+
+ // Now we're here, we know this should be loaded in an external browser, so
+ // prevent the default action so we don't try and load it here.
+ aEvent.preventDefault();
+
+ // Let the phishing detector check the link.
+ let urlPhishCheckResult = PhishingDetector.warnOnSuspiciousLinkClick(
+ window,
+ href,
+ linkText
+ );
+ if (urlPhishCheckResult === 1) {
+ return false; // Block request
+ }
+
+ if (urlPhishCheckResult === 0) {
+ // Use linkText instead.
+ openLinkExternally(linkText);
+ return true;
+ }
+
+ openLinkExternally(href);
+ return true;
+}
diff --git a/comm/mail/base/content/customElements.js b/comm/mail/base/content/customElements.js
new file mode 100644
index 0000000000..f5bec376a9
--- /dev/null
+++ b/comm/mail/base/content/customElements.js
@@ -0,0 +1,35 @@
+/* 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/. */
+
+"use strict";
+
+// This is loaded into chrome windows with the subscript loader. Wrap in
+// a block to prevent accidentally leaking globals onto `window`.
+(() => {
+ // If toolkit customElements weren't already loaded, do it now.
+ if (!window.MozXULElement) {
+ Services.scriptloader.loadSubScript(
+ "chrome://global/content/customElements.js",
+ window
+ );
+ }
+
+ const isDummyDocument =
+ document.documentURI == "chrome://extensions/content/dummy.xhtml";
+ if (!isDummyDocument) {
+ for (let script of [
+ "chrome://chat/content/conversation-browser.js",
+ "chrome://messenger/content/gloda-autocomplete-input.js",
+ "chrome://chat/content/chat-tooltip.js",
+ "chrome://messenger/content/mailWidgets.js",
+ "chrome://messenger/content/statuspanel.js",
+ "chrome://messenger/content/foldersummary.js",
+ "chrome://messenger/content/addressbook/menulist-addrbooks.js",
+ "chrome://messenger/content/folder-menupopup.js",
+ "chrome://messenger/content/toolbarbutton-menu-button.js",
+ ]) {
+ Services.scriptloader.loadSubScript(script, window);
+ }
+ }
+})();
diff --git a/comm/mail/base/content/customizeToolbar.js b/comm/mail/base/content/customizeToolbar.js
new file mode 100644
index 0000000000..dd2b28bdab
--- /dev/null
+++ b/comm/mail/base/content/customizeToolbar.js
@@ -0,0 +1,836 @@
+/* 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/. */
+
+var gToolboxDocument = null;
+var gToolbox = null;
+var gCurrentDragOverItem = null;
+var gToolboxChanged = false;
+var gToolboxSheet = false;
+var gPaletteBox = null;
+
+var { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+
+function onLoad() {
+ if ("arguments" in window && window.arguments[0]) {
+ InitWithToolbox(window.arguments[0]);
+ repositionDialog(window);
+ } else if (window.frameElement && "toolbox" in window.frameElement) {
+ gToolboxSheet = true;
+ InitWithToolbox(window.frameElement.toolbox);
+ repositionDialog(window.frameElement.panel);
+ }
+}
+
+function InitWithToolbox(aToolbox) {
+ gToolbox = aToolbox;
+ dispatchCustomizationEvent("beforecustomization");
+ gToolboxDocument = gToolbox.ownerDocument;
+ gToolbox.customizing = true;
+ forEachCustomizableToolbar(function (toolbar) {
+ toolbar.setAttribute("customizing", "true");
+ });
+ gPaletteBox = document.getElementById("palette-box");
+
+ var elts = getRootElements();
+ for (let i = 0; i < elts.length; i++) {
+ elts[i].addEventListener("dragstart", onToolbarDragStart, true);
+ elts[i].addEventListener("dragover", onToolbarDragOver, true);
+ elts[i].addEventListener("dragleave", onToolbarDragLeave, true);
+ elts[i].addEventListener("drop", onToolbarDrop, true);
+ }
+
+ initDialog();
+}
+
+function onClose() {
+ if (!gToolboxSheet) {
+ window.close();
+ } else {
+ finishToolbarCustomization();
+ }
+}
+
+function onUnload() {
+ if (!gToolboxSheet) {
+ finishToolbarCustomization();
+ }
+}
+
+function finishToolbarCustomization() {
+ removeToolboxListeners();
+ unwrapToolbarItems();
+ persistCurrentSets();
+ gToolbox.customizing = false;
+ forEachCustomizableToolbar(function (toolbar) {
+ toolbar.removeAttribute("customizing");
+ });
+
+ notifyParentComplete();
+}
+
+function initDialog() {
+ var mode = gToolbox.getAttribute("mode");
+ document.getElementById("modelist").value = mode;
+ var smallIconsCheckbox = document.getElementById("smallicons");
+ smallIconsCheckbox.checked = gToolbox.getAttribute("iconsize") == "small";
+ if (mode == "text") {
+ smallIconsCheckbox.disabled = true;
+ }
+
+ if (AppConstants.MOZ_APP_NAME == "thunderbird") {
+ document.getElementById("showTitlebar").checked =
+ !Services.prefs.getBoolPref("mail.tabs.drawInTitlebar");
+ if (
+ window.opener &&
+ window.opener.document.documentElement.getAttribute("windowtype") ==
+ "mail:3pane"
+ ) {
+ document.getElementById("titlebarSettings").hidden = false;
+ }
+ }
+
+ // Build up the palette of other items.
+ buildPalette();
+
+ // Wrap all the items on the toolbar in toolbarpaletteitems.
+ wrapToolbarItems();
+}
+
+function repositionDialog(aWindow) {
+ // Position the dialog touching the bottom of the toolbox and centered with
+ // it.
+ if (!aWindow) {
+ return;
+ }
+
+ var width;
+ if (aWindow != window) {
+ width = aWindow.getBoundingClientRect().width;
+ } else if (document.documentElement.hasAttribute("width")) {
+ width = document.documentElement.getAttribute("width");
+ } else {
+ width = parseInt(document.documentElement.style.width);
+ }
+ var boundingRect = gToolbox.getBoundingClientRect();
+ var screenX = gToolbox.screenX + (boundingRect.width - width) / 2;
+ var screenY = gToolbox.screenY + boundingRect.height;
+
+ aWindow.moveTo(screenX, screenY);
+}
+
+function removeToolboxListeners() {
+ var elts = getRootElements();
+ for (let i = 0; i < elts.length; i++) {
+ elts[i].removeEventListener("dragstart", onToolbarDragStart, true);
+ elts[i].removeEventListener("dragover", onToolbarDragOver, true);
+ elts[i].removeEventListener("dragleave", onToolbarDragLeave, true);
+ elts[i].removeEventListener("drop", onToolbarDrop, true);
+ }
+}
+
+/**
+ * Invoke a callback on the toolbox to notify it that the dialog is done
+ * and going away.
+ */
+function notifyParentComplete() {
+ if ("customizeDone" in gToolbox) {
+ gToolbox.customizeDone(gToolboxChanged);
+ }
+ dispatchCustomizationEvent("aftercustomization");
+}
+
+function toolboxChanged(aType) {
+ gToolboxChanged = true;
+ if ("customizeChange" in gToolbox) {
+ gToolbox.customizeChange(aType);
+ }
+ dispatchCustomizationEvent("customizationchange");
+}
+
+function dispatchCustomizationEvent(aEventName) {
+ var evt = document.createEvent("Events");
+ evt.initEvent(aEventName, true, true);
+ gToolbox.dispatchEvent(evt);
+}
+
+/**
+ * Persist the current set of buttons in all customizable toolbars to
+ * localstore.
+ */
+function persistCurrentSets() {
+ if (!gToolboxChanged || gToolboxDocument.defaultView.closed) {
+ return;
+ }
+
+ forEachCustomizableToolbar(function (toolbar) {
+ // Calculate currentset and store it in the attribute.
+ var currentSet = toolbar.currentSet;
+ toolbar.setAttribute("currentset", currentSet);
+ Services.xulStore.persist(toolbar, "currentset");
+ });
+}
+
+/**
+ * Wraps all items in all customizable toolbars in a toolbox.
+ */
+function wrapToolbarItems() {
+ forEachCustomizableToolbar(function (toolbar) {
+ for (let item of toolbar.children) {
+ if (AppConstants.platform == "macosx") {
+ if (
+ item.firstElementChild &&
+ item.firstElementChild.localName == "menubar"
+ ) {
+ return;
+ }
+ }
+ if (isToolbarItem(item)) {
+ let wrapper = wrapToolbarItem(item);
+ cleanupItemForToolbar(item, wrapper);
+ }
+ }
+ });
+}
+
+function getRootElements() {
+ if (window.frameElement && "externalToolbars" in window.frameElement) {
+ return [gToolbox].concat(window.frameElement.externalToolbars);
+ }
+ if ("arguments" in window && window.arguments[1].length > 0) {
+ return [gToolbox].concat(window.arguments[1]);
+ }
+ return [gToolbox];
+}
+
+/**
+ * Unwraps all items in all customizable toolbars in a toolbox.
+ */
+function unwrapToolbarItems() {
+ let elts = getRootElements();
+ for (let i = 0; i < elts.length; i++) {
+ let paletteItems = elts[i].getElementsByTagName("toolbarpaletteitem");
+ let paletteItem;
+ while ((paletteItem = paletteItems.item(0)) != null) {
+ let toolbarItem = paletteItem.firstElementChild;
+ restoreItemForToolbar(toolbarItem, paletteItem);
+ paletteItem.parentNode.replaceChild(toolbarItem, paletteItem);
+ }
+ }
+}
+
+/**
+ * Creates a wrapper that can be used to contain a toolbaritem and prevent
+ * it from receiving UI events.
+ */
+function createWrapper(aId, aDocument) {
+ let wrapper = aDocument.createXULElement("toolbarpaletteitem");
+
+ wrapper.id = "wrapper-" + aId;
+ return wrapper;
+}
+
+/**
+ * Wraps an item that has been cloned from a template and adds
+ * it to the end of the palette.
+ */
+function wrapPaletteItem(aPaletteItem) {
+ var wrapper = createWrapper(aPaletteItem.id, document);
+
+ wrapper.appendChild(aPaletteItem);
+
+ // XXX We need to call this AFTER the palette item has been appended
+ // to the wrapper or else we crash dropping certain buttons on the
+ // palette due to removal of the command and disabled attributes - JRH
+ cleanUpItemForPalette(aPaletteItem, wrapper);
+
+ gPaletteBox.appendChild(wrapper);
+}
+
+/**
+ * Wraps an item that is currently on a toolbar and replaces the item
+ * with the wrapper. This is not used when dropping items from the palette,
+ * only when first starting the dialog and wrapping everything on the toolbars.
+ */
+function wrapToolbarItem(aToolbarItem) {
+ var wrapper = createWrapper(aToolbarItem.id, gToolboxDocument);
+
+ wrapper.flex = aToolbarItem.flex;
+
+ aToolbarItem.parentNode.replaceChild(wrapper, aToolbarItem);
+
+ wrapper.appendChild(aToolbarItem);
+
+ return wrapper;
+}
+
+/**
+ * Get the list of ids for the current set of items on each toolbar.
+ */
+function getCurrentItemIds() {
+ var currentItems = {};
+ forEachCustomizableToolbar(function (toolbar) {
+ var child = toolbar.firstElementChild;
+ while (child) {
+ if (isToolbarItem(child)) {
+ currentItems[child.id] = 1;
+ }
+ child = child.nextElementSibling;
+ }
+ });
+ return currentItems;
+}
+
+/**
+ * Builds the palette of draggable items that are not yet in a toolbar.
+ */
+function buildPalette() {
+ // Empty the palette first.
+ while (gPaletteBox.lastElementChild) {
+ gPaletteBox.lastChild.remove();
+ }
+
+ // Add the toolbar separator item.
+ var templateNode = document.createXULElement("toolbarseparator");
+ templateNode.id = "separator";
+ wrapPaletteItem(templateNode);
+
+ // Add the toolbar spring item.
+ templateNode = document.createXULElement("toolbarspring");
+ templateNode.id = "spring";
+ templateNode.flex = 1;
+ wrapPaletteItem(templateNode);
+
+ // Add the toolbar spacer item.
+ templateNode = document.createXULElement("toolbarspacer");
+ templateNode.id = "spacer";
+ templateNode.flex = 1;
+ wrapPaletteItem(templateNode);
+
+ var currentItems = getCurrentItemIds();
+ templateNode = gToolbox.palette.firstElementChild;
+ while (templateNode) {
+ // Check if the item is already in a toolbar before adding it to the
+ // palette, but do not add back separators, springs and spacers - we do
+ // not want them duplicated.
+ if (!isSpecialItem(templateNode) && !(templateNode.id in currentItems)) {
+ var paletteItem = document.importNode(templateNode, true);
+ wrapPaletteItem(paletteItem);
+ }
+
+ templateNode = templateNode.nextElementSibling;
+ }
+}
+
+/**
+ * Makes sure that an item that has been cloned from a template
+ * is stripped of any attributes that may adversely affect its
+ * appearance in the palette.
+ */
+function cleanUpItemForPalette(aItem, aWrapper) {
+ aWrapper.setAttribute("place", "palette");
+ setWrapperType(aItem, aWrapper);
+
+ if (aItem.hasAttribute("title")) {
+ aWrapper.setAttribute("title", aItem.getAttribute("title"));
+ } else if (aItem.hasAttribute("label")) {
+ aWrapper.setAttribute("title", aItem.getAttribute("label"));
+ } else if (isSpecialItem(aItem)) {
+ var stringBundle = document.getElementById("stringBundle");
+ // Remove the common "toolbar" prefix to generate the string name.
+ var title = stringBundle.getString(aItem.localName.slice(7) + "Title");
+ aWrapper.setAttribute("title", title);
+ }
+ aWrapper.setAttribute("tooltiptext", aWrapper.getAttribute("title"));
+
+ // Remove attributes that screw up our appearance.
+ aItem.removeAttribute("command");
+ aItem.removeAttribute("observes");
+ aItem.removeAttribute("type");
+ aItem.removeAttribute("width");
+ aItem.removeAttribute("checked");
+ aItem.removeAttribute("collapsed");
+
+ aWrapper.querySelectorAll("[disabled]").forEach(function (aNode) {
+ aNode.removeAttribute("disabled");
+ });
+}
+
+/**
+ * Makes sure that an item that has been cloned from a template
+ * is stripped of all properties that may adversely affect its
+ * appearance in the toolbar. Store critical properties on the
+ * wrapper so they can be put back on the item when we're done.
+ */
+function cleanupItemForToolbar(aItem, aWrapper) {
+ setWrapperType(aItem, aWrapper);
+ aWrapper.setAttribute("place", "toolbar");
+
+ if (aItem.hasAttribute("command")) {
+ aWrapper.setAttribute("itemcommand", aItem.getAttribute("command"));
+ aItem.removeAttribute("command");
+ }
+
+ if (aItem.hasAttribute("collapsed")) {
+ aWrapper.setAttribute("itemcollapsed", aItem.getAttribute("collapsed"));
+ aItem.removeAttribute("collapsed");
+ }
+
+ if (aItem.checked) {
+ aWrapper.setAttribute("itemchecked", "true");
+ aItem.checked = false;
+ }
+
+ if (aItem.disabled) {
+ aWrapper.setAttribute("itemdisabled", "true");
+ aItem.disabled = false;
+ }
+}
+
+/**
+ * Restore all the properties that we stripped off above.
+ */
+function restoreItemForToolbar(aItem, aWrapper) {
+ if (aWrapper.hasAttribute("itemdisabled")) {
+ aItem.disabled = true;
+ }
+
+ if (aWrapper.hasAttribute("itemchecked")) {
+ aItem.checked = true;
+ }
+
+ if (aWrapper.hasAttribute("itemcollapsed")) {
+ let collapsed = aWrapper.getAttribute("itemcollapsed");
+ aItem.setAttribute("collapsed", collapsed);
+ }
+
+ if (aWrapper.hasAttribute("itemcommand")) {
+ let commandID = aWrapper.getAttribute("itemcommand");
+ aItem.setAttribute("command", commandID);
+
+ // XXX Bug 309953 - toolbarbuttons aren't in sync with their commands after customizing
+ let command = gToolboxDocument.getElementById(commandID);
+ if (command && command.hasAttribute("disabled")) {
+ aItem.setAttribute("disabled", command.getAttribute("disabled"));
+ }
+ }
+}
+
+function setWrapperType(aItem, aWrapper) {
+ if (aItem.localName == "toolbarseparator") {
+ aWrapper.setAttribute("type", "separator");
+ } else if (aItem.localName == "toolbarspring") {
+ aWrapper.setAttribute("type", "spring");
+ } else if (aItem.localName == "toolbarspacer") {
+ aWrapper.setAttribute("type", "spacer");
+ } else if (aItem.localName == "toolbaritem" && aItem.firstElementChild) {
+ aWrapper.setAttribute("type", aItem.firstElementChild.localName);
+ }
+}
+
+function setDragActive(aItem, aValue) {
+ var node = aItem;
+ var direction = window.getComputedStyle(aItem).direction;
+ var value = direction == "ltr" ? "left" : "right";
+ if (aItem.localName == "toolbar") {
+ node = aItem.lastElementChild;
+ value = direction == "ltr" ? "right" : "left";
+ }
+
+ if (!node) {
+ return;
+ }
+
+ if (aValue) {
+ if (!node.hasAttribute("dragover")) {
+ node.setAttribute("dragover", value);
+ }
+ } else {
+ node.removeAttribute("dragover");
+ }
+}
+
+/**
+ * Restore the default set of buttons to fixed toolbars,
+ * remove all custom toolbars, and rebuild the palette.
+ */
+function restoreDefaultSet() {
+ // Unwrap the items on the toolbar.
+ unwrapToolbarItems();
+
+ // Remove all of the customized toolbars.
+ var child = gToolbox.lastElementChild;
+ while (child) {
+ if (child.hasAttribute("customindex")) {
+ var thisChild = child;
+ child = child.previousElementSibling;
+ thisChild.currentSet = "__empty";
+ gToolbox.removeChild(thisChild);
+ } else {
+ child = child.previousElementSibling;
+ }
+ }
+
+ // Restore the defaultset for fixed toolbars.
+ forEachCustomizableToolbar(function (toolbar) {
+ var defaultSet = toolbar.getAttribute("defaultset");
+ if (defaultSet) {
+ toolbar.currentSet = defaultSet;
+ }
+ });
+
+ // Restore the default icon size and mode.
+ document.getElementById("smallicons").checked = updateIconSize() == "small";
+ document.getElementById("modelist").value = updateToolbarMode();
+
+ // Now rebuild the palette.
+ buildPalette();
+
+ // Now re-wrap the items on the toolbar.
+ wrapToolbarItems();
+
+ toolboxChanged("reset");
+}
+
+function updateIconSize(aSize) {
+ return updateToolboxProperty("iconsize", aSize, "large");
+}
+
+function updateTitlebar() {
+ let titlebarCheckbox = document.getElementById("showTitlebar");
+ Services.prefs.setBoolPref(
+ "mail.tabs.drawInTitlebar",
+ !titlebarCheckbox.checked
+ );
+
+ // Bring the customizeToolbar window to front (on linux it's behind the main
+ // window). Otherwise the customization window gets left in the background.
+ setTimeout(() => window.focus(), 100);
+}
+
+function updateToolbarMode(aModeValue) {
+ var mode = updateToolboxProperty("mode", aModeValue, "icons");
+
+ var iconSizeCheckbox = document.getElementById("smallicons");
+ iconSizeCheckbox.disabled = mode == "text";
+
+ return mode;
+}
+
+function updateToolboxProperty(aProp, aValue, aToolkitDefault) {
+ var toolboxDefault =
+ gToolbox.getAttribute("default" + aProp) || aToolkitDefault;
+
+ gToolbox.setAttribute(aProp, aValue || toolboxDefault);
+ Services.xulStore.persist(gToolbox, aProp);
+
+ forEachCustomizableToolbar(function (toolbar) {
+ var toolbarDefault =
+ toolbar.getAttribute("default" + aProp) || toolboxDefault;
+ if (
+ toolbar.getAttribute("lock" + aProp) == "true" &&
+ toolbar.getAttribute(aProp) == toolbarDefault
+ ) {
+ return;
+ }
+
+ toolbar.setAttribute(aProp, aValue || toolbarDefault);
+ Services.xulStore.persist(toolbar, aProp);
+ });
+
+ toolboxChanged(aProp);
+
+ return aValue || toolboxDefault;
+}
+
+function forEachCustomizableToolbar(callback) {
+ if (window.frameElement && "externalToolbars" in window.frameElement) {
+ Array.from(window.frameElement.externalToolbars)
+ .filter(isCustomizableToolbar)
+ .forEach(callback);
+ } else if ("arguments" in window && window.arguments[1].length > 0) {
+ Array.from(window.arguments[1])
+ .filter(isCustomizableToolbar)
+ .forEach(callback);
+ }
+ Array.from(gToolbox.children).filter(isCustomizableToolbar).forEach(callback);
+}
+
+function isCustomizableToolbar(aElt) {
+ return (
+ aElt.localName == "toolbar" && aElt.getAttribute("customizable") == "true"
+ );
+}
+
+function isSpecialItem(aElt) {
+ return (
+ aElt.localName == "toolbarseparator" ||
+ aElt.localName == "toolbarspring" ||
+ aElt.localName == "toolbarspacer"
+ );
+}
+
+function isToolbarItem(aElt) {
+ return (
+ aElt.localName == "toolbarbutton" ||
+ aElt.localName == "toolbaritem" ||
+ aElt.localName == "toolbarseparator" ||
+ aElt.localName == "toolbarspring" ||
+ aElt.localName == "toolbarspacer"
+ );
+}
+
+// Drag and Drop observers
+
+function onToolbarDragLeave(aEvent) {
+ if (isUnwantedDragEvent(aEvent)) {
+ return;
+ }
+
+ if (gCurrentDragOverItem) {
+ setDragActive(gCurrentDragOverItem, false);
+ }
+}
+
+function onToolbarDragStart(aEvent) {
+ var item = aEvent.target;
+ while (item && item.localName != "toolbarpaletteitem") {
+ if (item.localName == "toolbar") {
+ return;
+ }
+ item = item.parentNode;
+ }
+
+ item.setAttribute("dragactive", "true");
+
+ var dt = aEvent.dataTransfer;
+ var documentId = gToolboxDocument.documentElement.id;
+ dt.setData("text/toolbarwrapper-id/" + documentId, item.firstElementChild.id);
+ dt.effectAllowed = "move";
+}
+
+function onToolbarDragOver(aEvent) {
+ if (isUnwantedDragEvent(aEvent)) {
+ return;
+ }
+
+ var documentId = gToolboxDocument.documentElement.id;
+ if (
+ !aEvent.dataTransfer.types.includes(
+ "text/toolbarwrapper-id/" + documentId.toLowerCase()
+ )
+ ) {
+ return;
+ }
+
+ var toolbar = aEvent.target;
+ var dropTarget = aEvent.target;
+ while (toolbar && toolbar.localName != "toolbar") {
+ dropTarget = toolbar;
+ toolbar = toolbar.parentNode;
+ }
+
+ // Make sure we are dragging over a customizable toolbar.
+ if (!toolbar || !isCustomizableToolbar(toolbar)) {
+ gCurrentDragOverItem = null;
+ return;
+ }
+
+ var previousDragItem = gCurrentDragOverItem;
+
+ if (dropTarget.localName == "toolbar") {
+ gCurrentDragOverItem = dropTarget;
+ } else {
+ gCurrentDragOverItem = null;
+
+ var direction = window.getComputedStyle(dropTarget.parentNode).direction;
+ var boundingRect = dropTarget.getBoundingClientRect();
+ var dropTargetCenter = boundingRect.x + boundingRect.width / 2;
+ var dragAfter;
+ if (direction == "ltr") {
+ dragAfter = aEvent.clientX > dropTargetCenter;
+ } else {
+ dragAfter = aEvent.clientX < dropTargetCenter;
+ }
+
+ if (dragAfter) {
+ gCurrentDragOverItem = dropTarget.nextElementSibling;
+ if (!gCurrentDragOverItem) {
+ gCurrentDragOverItem = toolbar;
+ }
+ } else {
+ gCurrentDragOverItem = dropTarget;
+ }
+ }
+
+ if (previousDragItem && gCurrentDragOverItem != previousDragItem) {
+ setDragActive(previousDragItem, false);
+ }
+
+ setDragActive(gCurrentDragOverItem, true);
+
+ aEvent.preventDefault();
+ aEvent.stopPropagation();
+}
+
+function onToolbarDrop(aEvent) {
+ if (isUnwantedDragEvent(aEvent)) {
+ return;
+ }
+
+ if (!gCurrentDragOverItem) {
+ return;
+ }
+
+ setDragActive(gCurrentDragOverItem, false);
+
+ var documentId = gToolboxDocument.documentElement.id;
+ var draggedItemId = aEvent.dataTransfer.getData(
+ "text/toolbarwrapper-id/" + documentId
+ );
+ if (gCurrentDragOverItem.id == draggedItemId) {
+ return;
+ }
+
+ var toolbar = aEvent.target;
+ while (toolbar.localName != "toolbar") {
+ toolbar = toolbar.parentNode;
+ }
+
+ var draggedPaletteWrapper = document.getElementById(
+ "wrapper-" + draggedItemId
+ );
+ if (!draggedPaletteWrapper) {
+ // The wrapper has been dragged from the toolbar.
+ // Get the wrapper from the toolbar document and make sure that
+ // it isn't being dropped on itself.
+ let wrapper = gToolboxDocument.getElementById("wrapper-" + draggedItemId);
+ if (wrapper == gCurrentDragOverItem) {
+ return;
+ }
+
+ // Don't allow non-removable kids (e.g., the menubar) to move.
+ if (wrapper.firstElementChild.getAttribute("removable") != "true") {
+ return;
+ }
+
+ // Remove the item from its place in the toolbar.
+ wrapper.remove();
+
+ // Determine which toolbar we are dropping on.
+ var dropToolbar = null;
+ if (gCurrentDragOverItem.localName == "toolbar") {
+ dropToolbar = gCurrentDragOverItem;
+ } else {
+ dropToolbar = gCurrentDragOverItem.parentNode;
+ }
+
+ // Insert the item into the toolbar.
+ if (gCurrentDragOverItem != dropToolbar) {
+ dropToolbar.insertBefore(wrapper, gCurrentDragOverItem);
+ } else {
+ dropToolbar.appendChild(wrapper);
+ }
+ } else {
+ // The item has been dragged from the palette
+
+ // Create a new wrapper for the item. We don't know the id yet.
+ let wrapper = createWrapper("", gToolboxDocument);
+
+ // Ask the toolbar to clone the item's template, place it inside the wrapper, and insert it in the toolbar.
+ var newItem = toolbar.insertItem(
+ draggedItemId,
+ gCurrentDragOverItem == toolbar ? null : gCurrentDragOverItem,
+ wrapper
+ );
+
+ // Prepare the item and wrapper to look good on the toolbar.
+ cleanupItemForToolbar(newItem, wrapper);
+ wrapper.id = "wrapper-" + newItem.id;
+ wrapper.flex = newItem.flex;
+
+ // Remove the wrapper from the palette.
+ if (
+ draggedItemId != "separator" &&
+ draggedItemId != "spring" &&
+ draggedItemId != "spacer"
+ ) {
+ gPaletteBox.removeChild(draggedPaletteWrapper);
+ }
+ }
+
+ gCurrentDragOverItem = null;
+
+ toolboxChanged();
+}
+
+function onPaletteDragOver(aEvent) {
+ if (isUnwantedDragEvent(aEvent)) {
+ return;
+ }
+ var documentId = gToolboxDocument.documentElement.id;
+ if (
+ aEvent.dataTransfer.types.includes(
+ "text/toolbarwrapper-id/" + documentId.toLowerCase()
+ )
+ ) {
+ aEvent.preventDefault();
+ }
+}
+
+function onPaletteDrop(aEvent) {
+ if (isUnwantedDragEvent(aEvent)) {
+ return;
+ }
+ var documentId = gToolboxDocument.documentElement.id;
+ var itemId = aEvent.dataTransfer.getData(
+ "text/toolbarwrapper-id/" + documentId
+ );
+
+ var wrapper = gToolboxDocument.getElementById("wrapper-" + itemId);
+ if (wrapper) {
+ // Don't allow non-removable kids (e.g., the menubar) to move.
+ if (wrapper.firstElementChild.getAttribute("removable") != "true") {
+ return;
+ }
+
+ var wrapperType = wrapper.getAttribute("type");
+ if (
+ wrapperType != "separator" &&
+ wrapperType != "spacer" &&
+ wrapperType != "spring"
+ ) {
+ restoreItemForToolbar(wrapper.firstElementChild, wrapper);
+ wrapPaletteItem(document.importNode(wrapper.firstElementChild, true));
+ gToolbox.palette.appendChild(wrapper.firstElementChild);
+ }
+
+ // The item was dragged out of the toolbar.
+ wrapper.remove();
+ }
+
+ toolboxChanged();
+}
+
+function isUnwantedDragEvent(aEvent) {
+ try {
+ if (
+ Services.prefs.getBoolPref("toolkit.customization.unsafe_drag_events")
+ ) {
+ return false;
+ }
+ } catch (ex) {}
+
+ // Discard drag events that originated from a separate window to
+ // prevent content->chrome privilege escalations.
+ let mozSourceNode = aEvent.dataTransfer.mozSourceNode;
+ // mozSourceNode is null in the dragStart event handler or if
+ // the drag event originated in an external application.
+ if (!mozSourceNode) {
+ return true;
+ }
+ let sourceWindow = mozSourceNode.ownerGlobal;
+ return sourceWindow != window && sourceWindow != gToolboxDocument.defaultView;
+}
diff --git a/comm/mail/base/content/customizeToolbar.xhtml b/comm/mail/base/content/customizeToolbar.xhtml
new file mode 100644
index 0000000000..8810a44ee9
--- /dev/null
+++ b/comm/mail/base/content/customizeToolbar.xhtml
@@ -0,0 +1,105 @@
+<?xml version="1.0"?>
+
+<!-- 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/. -->
+
+<!DOCTYPE dialog [ <!ENTITY % customizeToolbarDTD SYSTEM "chrome://messenger/locale/customizeToolbar.dtd">
+%customizeToolbarDTD; ]>
+
+<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/customizeToolbar.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/icons.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/messenger.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/messageHeader.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/primaryToolbar.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/chat.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/messengercompose/messengercompose.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://calendar/skin/calendar-task-view.css" type="text/css"?>
+<?xml-stylesheet href="chrome://calendar/skin/shared/dialogs/calendar-event-dialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://calendar/skin/shared/calendar-toolbar.css" type="text/css"?>
+
+<window
+ id="CustomizeToolbarWindow"
+ title="&dialog.title;"
+ lightweightthemes="true"
+ windowtype="mailnews:customizeToolbar"
+ onload="overlayOnLoad();"
+ onunload="onUnload();"
+ style="max-width: 92ch; min-height: 36em"
+ persist="width height"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+>
+ <script src="chrome://messenger/content/customizeToolbar.js" />
+ <script src="chrome://messenger/content/mailCore.js" />
+ <stringbundle
+ id="stringBundle"
+ src="chrome://messenger/locale/customizeToolbar.properties"
+ />
+
+ <keyset id="CustomizeToolbarKeyset">
+ <key id="cmd_close1" keycode="VK_ESCAPE" oncommand="onClose();" />
+ <key id="cmd_close2" keycode="VK_RETURN" oncommand="onClose();" />
+ </keyset>
+
+ <vbox id="main-box" flex="1">
+ <description id="instructions"> &instructions.description; </description>
+
+ <vbox
+ flex="1"
+ id="palette-box"
+ ondragstart="onToolbarDragStart(event)"
+ ondragover="onPaletteDragOver(event)"
+ ondrop="onPaletteDrop(event)"
+ />
+
+ <hbox id="buttonBox" align="center">
+ <hbox id="titlebarSettings" hidden="true">
+ <checkbox
+ id="showTitlebar"
+ oncommand="updateTitlebar();"
+ label="&showTitlebar2.label;"
+ />
+ </hbox>
+ <label id="modelistLabel" value="&show.label;" control="modelist" />
+ <menulist
+ id="modelist"
+ value="icons"
+ oncommand="overlayUpdateToolbarMode(this.value, 'mail-toolbox');"
+ >
+ <menupopup id="modelistpopup">
+ <menuitem id="modefull" value="full" label="&iconsAndText.label;" />
+ <menuitem id="modeicons" value="icons" label="&icons.label;" />
+ <menuitem id="modetext" value="text" label="&text.label;" />
+ <menuitem
+ id="textbesideiconItem"
+ value="textbesideicon"
+ label="&iconsBesideText.label;"
+ />
+ </menupopup>
+ </menulist>
+ <checkbox
+ id="smallicons"
+ oncommand="updateIconSize(this.checked ? 'small' : 'large');"
+ label="&useSmallIcons.label;"
+ />
+ </hbox>
+ <hbox align="center">
+ <button
+ id="restoreDefault"
+ label="&restoreDefaultSet.label;"
+ oncommand="restoreDefaultSet();"
+ />
+ <spacer flex="1" />
+ <button
+ id="donebutton"
+ label="&saveChanges.label;"
+ oncommand="onClose();"
+ default="true"
+ />
+ </hbox>
+ </vbox>
+</window>
diff --git a/comm/mail/base/content/dialogShadowDom.js b/comm/mail/base/content/dialogShadowDom.js
new file mode 100644
index 0000000000..447aa87603
--- /dev/null
+++ b/comm/mail/base/content/dialogShadowDom.js
@@ -0,0 +1,14 @@
+/* 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/. */
+
+/**
+ * When the dialog window loads, add a stylesheet to the shadow DOM of the
+ * dialog to style the accept and cancel buttons, etc.
+ */
+window.addEventListener("load", () => {
+ let link = document.createElement("link");
+ link.setAttribute("rel", "stylesheet");
+ link.setAttribute("href", "chrome://messenger/skin/themeableDialog.css");
+ document.querySelector("dialog").shadowRoot.appendChild(link);
+});
diff --git a/comm/mail/base/content/editContactPanel.inc.xhtml b/comm/mail/base/content/editContactPanel.inc.xhtml
new file mode 100644
index 0000000000..4580648eca
--- /dev/null
+++ b/comm/mail/base/content/editContactPanel.inc.xhtml
@@ -0,0 +1,75 @@
+# 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/.
+
+<html:template id="editContactPanelTemplate">
+<panel id="editContactPanel"
+ type="arrow"
+ orient="vertical"
+ class="cui-widget-panel popup-panel panel-no-padding"
+ ignorekeys="true"
+ aria-labelledby="editContactPanelTitle"
+ onpopuphidden="editContactInlineUI.onPopupHidden(event);"
+ onpopupshown="editContactInlineUI.onPopupShown(event);"
+ onkeypress="editContactInlineUI.onKeyPress(event, true);">
+ <html:div class="popup-panel-body">
+ <html:div id="editContactHeader">
+ <html:img id="editContactPanelIcon"
+ src="chrome://messenger/skin/icons/new/normal/address-book.svg"
+ alt="" />
+ <html:h3 id="editContactPanelTitle" flex="1"></html:h3>
+ </html:div>
+
+ <box id="editContactContent">
+ <hbox pack="end">
+ <label value="&editContactName.label;"
+ class="editContactPanel_rowLabel"
+ accesskey="&editContactName.accesskey;"
+ control="editContactName"/>
+ </hbox>
+ <html:input id="editContactName" class="editContactTextbox" type="text"
+ onkeypress="editContactInlineUI.onKeyPress(event, true);"/>
+ <hbox pack="end">
+ <label value="&editContactEmail.label;"
+ class="editContactPanel_rowLabel"
+ accesskey="&editContactEmail.accesskey;"
+ control="editContactEmail"/>
+ </hbox>
+ <html:input id="editContactEmail" readonly="readonly"
+ class="editContactTextbox" type="email"
+ onkeypress="editContactInlineUI.onKeyPress(event, true);"/>
+ <hbox pack="end">
+ <label id="editContactAddressBook"
+ class="editContactPanel_rowLabel"
+ value="&editContactAddressBook.label;"
+ accesskey="&editContactAddressBook.accesskey;"
+ control="editContactAddressBookList"/>
+ </hbox>
+ <menulist is="menulist-addrbooks"
+ id="editContactAddressBookList"
+ flex="1"/>
+ <label value="" collapsed="true"/>
+ <description id="contactMoveDisabledText" hidden="true">
+ &contactMoveDisabledWarning.description;
+ </description>
+ </box>
+
+ <html:div class="popup-panel-buttons-container">
+ <button id="editContactPanelEditDetailsButton"
+ oncommand="editContactInlineUI.editDetails();"
+ onkeypress="editContactInlineUI.onKeyPress(event, false);"/>
+ <button id="editContactPanelDeleteContactButton"
+ label="&editContactPanelDeleteContact.label;"
+ accesskey="&editContactPanelDeleteContact.accesskey;"
+ oncommand="editContactInlineUI.deleteContact();"
+ onkeypress="editContactInlineUI.onKeyPress(event, false);"/>
+ <button id="editContactPanelDoneButton"
+ class="primary"
+ label="&editContactPanelDone.label;"
+ accesskey="&editContactPanelDone.accesskey;"
+ oncommand="editContactInlineUI.saveChanges();"
+ onkeypress="editContactInlineUI.onKeyPress(event, false);"/>
+ </html:div>
+ </html:div>
+</panel>
+</html:template>
diff --git a/comm/mail/base/content/editContactPanel.js b/comm/mail/base/content/editContactPanel.js
new file mode 100644
index 0000000000..40e1061167
--- /dev/null
+++ b/comm/mail/base/content/editContactPanel.js
@@ -0,0 +1,248 @@
+/* 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/. */
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+var editContactInlineUI = {
+ _overlayLoaded: false,
+ _overlayLoading: false,
+ _cardDetails: null,
+ _writeable: true,
+ _blockedCommands: ["cmd_close"],
+
+ _blockCommands() {
+ for (var i = 0; i < this._blockedCommands; ++i) {
+ var elt = document.getElementById(this._blockedCommands[i]);
+ // make sure not to permanetly disable this item
+ if (elt.hasAttribute("wasDisabled")) {
+ continue;
+ }
+
+ if (elt.getAttribute("disabled") == "true") {
+ elt.setAttribute("wasDisabled", "true");
+ } else {
+ elt.setAttribute("wasDisabled", "false");
+ elt.setAttribute("disabled", "true");
+ }
+ }
+ },
+
+ _restoreCommandsState() {
+ for (var i = 0; i < this._blockedCommands; ++i) {
+ var elt = document.getElementById(this._blockedCommands[i]);
+ if (elt.getAttribute("wasDisabled") != "true") {
+ elt.removeAttribute("disabled");
+ }
+ elt.removeAttribute("wasDisabled");
+ }
+ document.getElementById("editContactAddressBookList").disabled = false;
+ document.getElementById("contactMoveDisabledText").hidden = true;
+ },
+
+ onPopupHidden(aEvent) {
+ if (aEvent.target == this.panel) {
+ this._restoreCommandsState();
+ }
+ },
+
+ onPopupShown(aEvent) {
+ if (aEvent.target == this.panel) {
+ document.getElementById("editContactName").focus();
+ }
+ },
+
+ onKeyPress(aEvent, aHandleOnlyReadOnly) {
+ // Escape should just close this panel
+ if (aEvent.keyCode == KeyEvent.DOM_VK_ESCAPE) {
+ this.panel.hidePopup();
+ return;
+ }
+
+ // Return does the default button (done)
+ if (aEvent.keyCode == KeyEvent.DOM_VK_RETURN) {
+ if (!aEvent.target.hasAttribute("oncommand")) {
+ this.saveChanges();
+ }
+ return;
+ }
+
+ // Only handle the read-only cases here.
+ if (aHandleOnlyReadOnly && this._writeable && !aEvent.target.readOnly) {
+ return;
+ }
+
+ // Any other character and we prevent the default, this stops us doing
+ // things in the main message window.
+ if (aEvent.charCode) {
+ aEvent.preventDefault();
+ }
+ },
+
+ get panel() {
+ // The panel is initially stored in a template for performance reasons.
+ // Load it into the DOM now.
+ delete this.panel;
+ let template = document.getElementById("editContactPanelTemplate");
+ template.replaceWith(template.content);
+ let element = document.getElementById("editContactPanel");
+ return (this.panel = element);
+ },
+
+ showEditContactPanel(aCardDetails, aAnchorElement) {
+ this._cardDetails = aCardDetails;
+ let position = "after_start";
+ this._doShowEditContactPanel(aAnchorElement, position);
+ },
+
+ _doShowEditContactPanel(aAnchorElement, aPosition) {
+ this._blockCommands(); // un-done in the popuphiding handler.
+ var bundle = Services.strings.createBundle(
+ "chrome://messenger/locale/editContactOverlay.properties"
+ );
+
+ // Is this address book writeable?
+ this._writeable = !this._cardDetails.book.readOnly;
+ var type = this._writeable ? "edit" : "view";
+
+ // Force the panel to be created from the template, if necessary.
+ this.panel;
+
+ // Update the labels accordingly.
+ document.getElementById("editContactPanelTitle").textContent =
+ bundle.GetStringFromName(type + "Title");
+ document.getElementById("editContactPanelEditDetailsButton").label =
+ bundle.GetStringFromName(type + "DetailsLabel");
+ document.getElementById("editContactPanelEditDetailsButton").accessKey =
+ bundle.GetStringFromName(type + "DetailsAccessKey");
+
+ // We don't need a delete button for a read only card.
+ document.getElementById("editContactPanelDeleteContactButton").hidden =
+ !this._writeable;
+
+ var nameElement = document.getElementById("editContactName");
+
+ // Set these to read only if we can't write to the directory.
+ if (this._writeable) {
+ nameElement.removeAttribute("readonly");
+ nameElement.class = "editContactTextbox";
+ } else {
+ nameElement.setAttribute("readonly", "readonly");
+ nameElement.class = "plain";
+ }
+
+ // Fill in the card details
+ nameElement.value = this._cardDetails.card.displayName;
+ document.getElementById("editContactEmail").value =
+ aAnchorElement.getAttribute("emailAddress") ||
+ aAnchorElement.emailAddress;
+
+ document.getElementById("editContactAddressBookList").value =
+ this._cardDetails.book.URI;
+
+ // Is this card contained within mailing lists?
+ let inMailList = false;
+ if (this._cardDetails.book.supportsMailingLists) {
+ // We only have to look in one book here, because cards currently have
+ // to be in the address book they belong to.
+ for (let list of this._cardDetails.book.childNodes) {
+ if (!list.isMailList) {
+ continue;
+ }
+
+ for (let card of list.childCards) {
+ if (card.primaryEmail == this._cardDetails.card.primaryEmail) {
+ inMailList = true;
+ break;
+ }
+ }
+ if (inMailList) {
+ break;
+ }
+ }
+ }
+
+ if (!this._writeable || inMailList) {
+ document.getElementById("editContactAddressBookList").disabled = true;
+ }
+
+ if (inMailList) {
+ document.getElementById("contactMoveDisabledText").hidden = false;
+ }
+
+ this.panel.openPopup(aAnchorElement, aPosition, -1, -1);
+ },
+
+ editDetails() {
+ this.saveChanges();
+ top.toAddressBook({ action: "edit", card: this._cardDetails.card });
+ },
+
+ deleteContact() {
+ if (this._cardDetails.book.readOnly) {
+ // Double check we can delete this.
+ return;
+ }
+
+ // Hide before the dialog or the panel takes the first click.
+ this.panel.hidePopup();
+
+ var bundle = Services.strings.createBundle(
+ "chrome://messenger/locale/editContactOverlay.properties"
+ );
+ if (
+ !Services.prompt.confirm(
+ window,
+ bundle.GetStringFromName("deleteContactTitle"),
+ bundle.GetStringFromName("deleteContactMessage")
+ )
+ ) {
+ // XXX Would be nice to bring the popup back up here.
+ return;
+ }
+
+ MailServices.ab
+ .getDirectory(this._cardDetails.book.URI)
+ .deleteCards([this._cardDetails.card]);
+ },
+
+ saveChanges() {
+ // If we're a popup dialog, just hide the popup and return
+ if (!this._writeable) {
+ this.panel.hidePopup();
+ return;
+ }
+
+ let originalBook = this._cardDetails.book;
+
+ let abURI = document.getElementById("editContactAddressBookList").value;
+ if (abURI != originalBook.URI) {
+ this._cardDetails.book = MailServices.ab.getDirectory(abURI);
+ }
+
+ // We can assume the email address stays the same, so just update the name
+ var newName = document.getElementById("editContactName").value;
+ if (newName != this._cardDetails.card.displayName) {
+ this._cardDetails.card.displayName = newName;
+ this._cardDetails.card.setProperty("PreferDisplayName", true);
+ }
+
+ // Save the card
+ if (this._cardDetails.book.hasCard(this._cardDetails.card)) {
+ // Address book wasn't changed.
+ this._cardDetails.book.modifyCard(this._cardDetails.card);
+ } else {
+ // We changed address books for the card.
+
+ // Add it to the chosen address book...
+ this._cardDetails.book.addCard(this._cardDetails.card);
+
+ // ...and delete it from the old place.
+ originalBook.deleteCards([this._cardDetails.card]);
+ }
+
+ this.panel.hidePopup();
+ },
+};
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;
+ }
+}
diff --git a/comm/mail/base/content/globalOverlay.js b/comm/mail/base/content/globalOverlay.js
new file mode 100644
index 0000000000..b31751a490
--- /dev/null
+++ b/comm/mail/base/content/globalOverlay.js
@@ -0,0 +1,122 @@
+/* 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/. */
+
+/**
+ * Notifies observers that quitting has been requested.
+ *
+ * @returns {boolean} - True if an observer prevented quitting, false otherwise.
+ */
+function canQuitApplication() {
+ try {
+ let cancelQuit = Cc["@mozilla.org/supports-PRBool;1"].createInstance(
+ Ci.nsISupportsPRBool
+ );
+ Services.obs.notifyObservers(cancelQuit, "quit-application-requested");
+
+ // Something aborted the quit process.
+ if (cancelQuit.data) {
+ return false;
+ }
+ } catch (ex) {}
+ return true;
+}
+
+/**
+ * Quit the application if no `quit-application-requested` observer prevents it.
+ */
+function goQuitApplication() {
+ if (!canQuitApplication()) {
+ return false;
+ }
+
+ Services.startup.quit(Ci.nsIAppStartup.eAttemptQuit);
+ return true;
+}
+
+/**
+ * Gets the first registered controller that returns true for both
+ * `supportsCommand` and `isCommandEnabled`, or null if no controllers
+ * return true for both.
+ *
+ * @param {string} command - The command name to pass to controllers.
+ * @returns {nsIController|null}
+ */
+function getEnabledControllerForCommand(command) {
+ // The first controller for which `supportsCommand` returns true.
+ let controllerA =
+ top.document.commandDispatcher.getControllerForCommand(command);
+ if (controllerA?.isCommandEnabled(command)) {
+ return controllerA;
+ }
+
+ // Didn't find a controller, or `isCommandEnabled` returned false?
+ // Try the other controllers. Note this isn't exactly the same set
+ // of controllers as `commandDispatcher` has.
+ for (let i = 0; i < top.controllers.getControllerCount(); i++) {
+ let controllerB = top.controllers.getControllerAt(i);
+ if (
+ controllerB !== controllerA &&
+ controllerB.supportsCommand(command) &&
+ controllerB.isCommandEnabled(command)
+ ) {
+ return controllerB;
+ }
+ }
+
+ return null;
+}
+
+/**
+ * Updates the enabled state of the element with the ID `command`. The command
+ * is considered enabled if at least one controller returns true for both
+ * `supportsCommand` and `isCommandEnabled`.
+ *
+ * @param {string} command - The command name to pass to controllers.
+ */
+function goUpdateCommand(command) {
+ try {
+ goSetCommandEnabled(command, !!getEnabledControllerForCommand(command));
+ } catch (e) {
+ console.error(`An error occurred updating the ${command} command: ${e}`);
+ }
+}
+
+/**
+ * Calls `doCommand` on the first controller that returns true for both
+ * `supportsCommand` and `isCommandEnabled`.
+ *
+ * @param {string} command - The command name to pass to controllers.
+ * @param {any[]} args - Any number of arguments to pass to the chosen
+ * controller. Note that passing arguments is not part of the `nsIController`
+ * interface and only possible for JS controllers.
+ */
+function goDoCommand(command, ...args) {
+ try {
+ let controller = getEnabledControllerForCommand(command);
+ if (controller) {
+ controller = controller.wrappedJSObject ?? controller;
+ controller.doCommand(command, ...args);
+ }
+ } catch (e) {
+ console.error(`An error occurred executing the ${command} command: ${e}`);
+ }
+}
+
+/**
+ * Updates the enabled state of the element with the ID `id`.
+ *
+ * @param {string} id
+ * @param {boolean} enabled
+ */
+function goSetCommandEnabled(id, enabled) {
+ let node = document.getElementById(id);
+
+ if (node) {
+ if (enabled) {
+ node.removeAttribute("disabled");
+ } else {
+ node.setAttribute("disabled", "true");
+ }
+ }
+}
diff --git a/comm/mail/base/content/glodaFacetTab.js b/comm/mail/base/content/glodaFacetTab.js
new file mode 100644
index 0000000000..f194b2c24b
--- /dev/null
+++ b/comm/mail/base/content/glodaFacetTab.js
@@ -0,0 +1,111 @@
+/* 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/. */
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "GlodaMsgSearcher",
+ "resource:///modules/gloda/GlodaMsgSearcher.jsm"
+);
+
+var glodaFacetTabType = {
+ name: "glodaFacet",
+ perTabPanel: "vbox",
+ lastTabId: 0,
+ strings: Services.strings.createBundle(
+ "chrome://messenger/locale/glodaFacetView.properties"
+ ),
+ modes: {
+ glodaFacet: {
+ // this is what get exposed on the tab for icon purposes
+ type: "glodaSearch",
+ },
+ },
+ openTab(aTab, aArgs) {
+ // If aArgs is empty, default to a blank user search.
+ if (!Object.keys(aArgs).length) {
+ aArgs = { searcher: new GlodaMsgSearcher(null, "") };
+ }
+ // we have no browser until our XUL document loads
+ aTab.browser = null;
+
+ aTab.tabNode.setIcon(
+ "chrome://messenger/skin/icons/new/compact/search.svg"
+ );
+
+ // First clone the page and set up the basics.
+ let clone = document
+ .getElementById("glodaTab")
+ .firstElementChild.cloneNode(true);
+
+ aTab.panel.setAttribute("id", "glodaTab" + this.lastTabId);
+ aTab.panel.appendChild(clone);
+ aTab.iframe = aTab.panel.querySelector("iframe");
+
+ if ("query" in aArgs) {
+ aTab.query = aArgs.query;
+ aTab.collection = aTab.query.getCollection();
+
+ aTab.title = this.strings.GetStringFromName(
+ "glodaFacetView.tab.query.label"
+ );
+ aTab.searchString = null;
+ } else if ("searcher" in aArgs) {
+ aTab.searcher = aArgs.searcher;
+ aTab.collection = aTab.searcher.getCollection();
+ aTab.query = aTab.searcher.query;
+ if ("IMSearcher" in aArgs) {
+ aTab.IMSearcher = aArgs.IMSearcher;
+ aTab.IMCollection = aArgs.IMSearcher.getCollection();
+ aTab.IMQuery = aTab.IMSearcher.query;
+ }
+
+ let searchString = aTab.searcher.searchString;
+ aTab.searchInputValue = aTab.searchString = searchString;
+ aTab.title = searchString
+ ? searchString
+ : this.strings.GetStringFromName("glodaFacetView.tab.search.label");
+ } else if ("collection" in aArgs) {
+ aTab.collection = aArgs.collection;
+
+ aTab.title = this.strings.GetStringFromName(
+ "glodaFacetView.tab.query.label"
+ );
+ aTab.searchString = null;
+ }
+
+ function xulLoadHandler() {
+ aTab.iframe.contentWindow.tab = aTab;
+ aTab.browser = aTab.iframe.contentDocument.getElementById("browser");
+ aTab.browser.setAttribute(
+ "src",
+ "chrome://messenger/content/glodaFacetView.xhtml"
+ );
+
+ // Wire up the search input icon click event
+ let searchInput = aTab.panel.querySelector(".remote-gloda-search");
+ searchInput.focus();
+ }
+
+ aTab.iframe.contentWindow.addEventListener("load", xulLoadHandler, {
+ capture: false,
+ once: true,
+ });
+ aTab.iframe.setAttribute(
+ "src",
+ "chrome://messenger/content/glodaFacetViewWrapper.xhtml"
+ );
+
+ this.lastTabId++;
+ },
+ closeTab(aTab) {},
+ saveTabState(aTab) {
+ // nothing to do; we are not multiplexed
+ },
+ showTab(aTab) {
+ // nothing to do; we are not multiplexed
+ },
+ getBrowser(aTab) {
+ return aTab.browser;
+ },
+};
diff --git a/comm/mail/base/content/glodaFacetView.js b/comm/mail/base/content/glodaFacetView.js
new file mode 100644
index 0000000000..db8bce8150
--- /dev/null
+++ b/comm/mail/base/content/glodaFacetView.js
@@ -0,0 +1,1114 @@
+/* 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/. */
+
+/*
+ * This file provides the global context for the faceting environment. In the
+ * Model View Controller (paradigm), we are the view and the XBL widgets are
+ * the the view and controller.
+ *
+ * Because much of the work related to faceting is not UI-specific, we try and
+ * push as much of it into mailnews/db/gloda/Facet.jsm. In some cases we may
+ * get it wrong and it may eventually want to migrate.
+ */
+
+var { PluralForm } = ChromeUtils.importESModule(
+ "resource://gre/modules/PluralForm.sys.mjs"
+);
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+var { TagUtils } = ChromeUtils.import("resource:///modules/TagUtils.jsm");
+var { Gloda } = ChromeUtils.import("resource:///modules/gloda/GlodaPublic.jsm");
+var { GlodaConstants } = ChromeUtils.import(
+ "resource:///modules/gloda/GlodaConstants.jsm"
+);
+var { GlodaSyntheticView } = ChromeUtils.import(
+ "resource:///modules/gloda/GlodaSyntheticView.jsm"
+);
+var { FacetDriver, FacetUtils } = ChromeUtils.import(
+ "resource:///modules/gloda/Facet.jsm"
+);
+
+var glodaFacetStrings = Services.strings.createBundle(
+ "chrome://messenger/locale/glodaFacetView.properties"
+);
+
+/**
+ * Object containing query-explanantion binding methods.
+ */
+const QueryExplanation = {
+ get node() {
+ return document.getElementById("query-explanation");
+ },
+ /**
+ * Indicate that we are based on a fulltext search
+ */
+ setFulltext(aMsgSearcher) {
+ while (this.node.hasChildNodes()) {
+ this.node.lastChild.remove();
+ }
+
+ const spanify = (text, classNames) => {
+ const span = document.createElement("span");
+ span.setAttribute("class", classNames);
+ span.textContent = text;
+ this.node.appendChild(span);
+ return span;
+ };
+
+ const searchLabel = glodaFacetStrings.GetStringFromName(
+ "glodaFacetView.search.label2"
+ );
+ spanify(searchLabel, "explanation-fulltext-label");
+
+ const criteriaText = glodaFacetStrings.GetStringFromName(
+ "glodaFacetView.constraints.query.fulltext." +
+ (aMsgSearcher.andTerms ? "and" : "or") +
+ "JoinWord"
+ );
+ for (let [iTerm, term] of aMsgSearcher.fulltextTerms.entries()) {
+ if (iTerm) {
+ spanify(criteriaText, "explanation-fulltext-criteria");
+ }
+ spanify(term, "explanation-fulltext-term");
+ }
+ },
+ setQuery(msgQuery) {
+ try {
+ while (this.node.hasChildNodes()) {
+ this.node.lastChild.remove();
+ }
+
+ const spanify = (text, classNames) => {
+ const span = document.createElement("span");
+ span.setAttribute("class", classNames);
+ span.textContent = text;
+ this.node.appendChild(span);
+ return span;
+ };
+
+ let label = glodaFacetStrings.GetStringFromName(
+ "glodaFacetView.search.label2"
+ );
+ spanify(label, "explanation-query-label");
+
+ let constraintStrings = [];
+ for (let constraint of msgQuery._constraints) {
+ if (constraint[0] != 1) {
+ // No idea what this is about.
+ return;
+ }
+ if (constraint[1].attributeName == "involves") {
+ let involvesLabel = glodaFacetStrings.GetStringFromName(
+ "glodaFacetView.constraints.query.involves.label"
+ );
+ involvesLabel = involvesLabel.replace("#1", constraint[2].value);
+ spanify(involvesLabel, "explanation-query-involves");
+ } else if (constraint[1].attributeName == "tag") {
+ const tagLabel = glodaFacetStrings.GetStringFromName(
+ "glodaFacetView.constraints.query.tagged.label"
+ );
+ const tag = constraint[2];
+ const tagNode = document.createElement("span");
+ const color = MailServices.tags.getColorForKey(tag.key);
+ tagNode.setAttribute("class", "message-tag");
+ if (color) {
+ let textColor = !TagUtils.isColorContrastEnough(color)
+ ? "white"
+ : "black";
+ tagNode.setAttribute(
+ "style",
+ "color: " + textColor + "; background-color: " + color + ";"
+ );
+ }
+ tagNode.textContent = tag.tag;
+ spanify(tagLabel, "explanation-query-tagged");
+ this.node.appendChild(tagNode);
+ }
+ }
+ label = label + constraintStrings.join(", "); // XXX l10n?
+ } catch (e) {
+ console.error(e);
+ }
+ },
+};
+
+/**
+ * Object containing facets binding methods.
+ */
+const UIFacets = {
+ get node() {
+ return document.getElementById("facets");
+ },
+ clearFacets() {
+ while (this.node.hasChildNodes()) {
+ this.node.lastChild.remove();
+ }
+ },
+ addFacet(type, attrDef, args) {
+ let facet;
+
+ if (type === "boolean") {
+ facet = document.createElement("facet-boolean");
+ } else if (type === "boolean-filtered") {
+ facet = document.createElement("facet-boolean-filtered");
+ } else if (type === "discrete") {
+ facet = document.createElement("facet-discrete");
+ } else {
+ facet = document.createElement("div");
+ facet.setAttribute("class", "facetious");
+ }
+
+ facet.attrDef = attrDef;
+ facet.nounDef = attrDef.objectNounDef;
+ facet.setAttribute("type", type);
+
+ for (let key in args) {
+ facet[key] = args[key];
+ }
+
+ facet.setAttribute("name", attrDef.attributeName);
+ this.node.appendChild(facet);
+
+ return facet;
+ },
+};
+
+/**
+ * Represents the active constraints on a singular facet. Singular facets can
+ * only have an inclusive set or an exclusive set, but not both. Non-singular
+ * facets can have both. Because they are different worlds, non-singular gets
+ * its own class, |ActiveNonSingularConstraint|.
+ */
+function ActiveSingularConstraint(aFaceter, aRanged) {
+ this.faceter = aFaceter;
+ this.attrDef = aFaceter.attrDef;
+ this.facetDef = aFaceter.facetDef;
+ this.ranged = Boolean(aRanged);
+ this.clear();
+}
+ActiveSingularConstraint.prototype = {
+ _makeQuery() {
+ // have the faceter make the query and the invert decision for us if it
+ // implements the makeQuery method.
+ if ("makeQuery" in this.faceter) {
+ [this.query, this.invertQuery] = this.faceter.makeQuery(
+ this.groupValues,
+ this.inclusive
+ );
+ return;
+ }
+
+ let query = (this.query = Gloda.newQuery(GlodaConstants.NOUN_MESSAGE));
+ let constraintFunc;
+ // If the facet definition references a queryHelper defined by the noun
+ // type, use that instead of the standard constraint function.
+ if ("queryHelper" in this.facetDef) {
+ constraintFunc =
+ query[this.attrDef.boundName + this.facetDef.queryHelper];
+ } else {
+ constraintFunc =
+ query[
+ this.ranged
+ ? this.attrDef.boundName + "Range"
+ : this.attrDef.boundName
+ ];
+ }
+ constraintFunc.apply(query, this.groupValues);
+
+ this.invertQuery = !this.inclusive;
+ },
+ /**
+ * Adjust the constraint given the incoming faceting constraint desired.
+ * Mainly, if the inclusive flag is the same as what we already have, we
+ * just append the new values to the existing set of values. If it is not
+ * the same, we replace them.
+ *
+ * @returns true if the caller needs to revalidate their understanding of the
+ * constraint because we have flipped whether we are inclusive or
+ * exclusive and have thrown away some constraints as a result.
+ */
+ constrain(aInclusive, aGroupValues) {
+ if (aInclusive == this.inclusive) {
+ this.groupValues = this.groupValues.concat(aGroupValues);
+ this._makeQuery();
+ return false;
+ }
+
+ let needToRevalidate = this.inclusive != null;
+ this.inclusive = aInclusive;
+ this.groupValues = aGroupValues;
+ this._makeQuery();
+
+ return needToRevalidate;
+ },
+ /**
+ * Relax something we previously constrained. Remove it, some might say. It
+ * is possible after relaxing that we will no longer be an active constraint.
+ *
+ * @returns true if we are no longer constrained at all.
+ */
+ relax(aInclusive, aGroupValues) {
+ if (aInclusive != this.inclusive) {
+ throw new Error("You can't relax a constraint that isn't possible.");
+ }
+
+ for (let groupValue of aGroupValues) {
+ let index = this.groupValues.indexOf(groupValue);
+ if (index == -1) {
+ throw new Error("Tried to relax a constraint that was not in force.");
+ }
+ this.groupValues.splice(index, 1);
+ }
+ if (this.groupValues.length == 0) {
+ this.clear();
+ return true;
+ }
+ this._makeQuery();
+
+ return false;
+ },
+ /**
+ * Indicate whether this constraint is actually doing anything anymore.
+ */
+ get isConstrained() {
+ return this.inclusive != null;
+ },
+ /**
+ * Clear the constraint so that the next call to adjust initializes it.
+ */
+ clear() {
+ this.inclusive = null;
+ this.groupValues = null;
+ this.query = null;
+ this.invertQuery = null;
+ },
+ /**
+ * Filter the items against our constraint.
+ */
+ sieve(aItems) {
+ let query = this.query;
+ let expectedResult = !this.invertQuery;
+ return aItems.filter(item => query.test(item) == expectedResult);
+ },
+ isIncludedGroup(aGroupValue) {
+ if (!this.inclusive) {
+ return false;
+ }
+ return this.groupValues.includes(aGroupValue);
+ },
+ isExcludedGroup(aGroupValue) {
+ if (this.inclusive) {
+ return false;
+ }
+ return this.groupValues.includes(aGroupValue);
+ },
+};
+
+function ActiveNonSingularConstraint(aFaceter, aRanged) {
+ this.faceter = aFaceter;
+ this.attrDef = aFaceter.attrDef;
+ this.facetDef = aFaceter.facetDef;
+ this.ranged = Boolean(aRanged);
+
+ this.clear();
+}
+ActiveNonSingularConstraint.prototype = {
+ _makeQuery(aInclusive, aGroupValues) {
+ // have the faceter make the query and the invert decision for us if it
+ // implements the makeQuery method.
+ if ("makeQuery" in this.faceter) {
+ // returns [query, invertQuery] directly
+ return this.faceter.makeQuery(aGroupValues, aInclusive);
+ }
+
+ let query = Gloda.newQuery(GlodaConstants.NOUN_MESSAGE);
+ let constraintFunc;
+ // If the facet definition references a queryHelper defined by the noun
+ // type, use that instead of the standard constraint function.
+ if ("queryHelper" in this.facetDef) {
+ constraintFunc =
+ query[this.attrDef.boundName + this.facetDef.queryHelper];
+ } else {
+ constraintFunc =
+ query[
+ this.ranged
+ ? this.attrDef.boundName + "Range"
+ : this.attrDef.boundName
+ ];
+ }
+ constraintFunc.apply(query, aGroupValues);
+
+ return [query, false];
+ },
+
+ /**
+ * Adjust the constraint given the incoming faceting constraint desired.
+ * Mainly, if the inclusive flag is the same as what we already have, we
+ * just append the new values to the existing set of values. If it is not
+ * the same, we replace them.
+ */
+ constrain(aInclusive, aGroupValues) {
+ let groupIdAttr = this.attrDef.objectNounDef.isPrimitive
+ ? null
+ : this.facetDef.groupIdAttr;
+ let idMap = aInclusive ? this.includedGroupIds : this.excludedGroupIds;
+ let valList = aInclusive
+ ? this.includedGroupValues
+ : this.excludedGroupValues;
+ for (let groupValue of aGroupValues) {
+ let valId =
+ groupIdAttr !== null && groupValue != null
+ ? groupValue[groupIdAttr]
+ : groupValue;
+ idMap[valId] = true;
+ valList.push(groupValue);
+ }
+
+ let [query, invertQuery] = this._makeQuery(aInclusive, valList);
+ if (aInclusive && !invertQuery) {
+ this.includeQuery = query;
+ } else {
+ this.excludeQuery = query;
+ }
+
+ return false;
+ },
+ /**
+ * Relax something we previously constrained. Remove it, some might say. It
+ * is possible after relaxing that we will no longer be an active constraint.
+ *
+ * @returns true if we are no longer constrained at all.
+ */
+ relax(aInclusive, aGroupValues) {
+ let groupIdAttr = this.attrDef.objectNounDef.isPrimitive
+ ? null
+ : this.facetDef.groupIdAttr;
+ let idMap = aInclusive ? this.includedGroupIds : this.excludedGroupIds;
+ let valList = aInclusive
+ ? this.includedGroupValues
+ : this.excludedGroupValues;
+ for (let groupValue of aGroupValues) {
+ let valId =
+ groupIdAttr !== null && groupValue != null
+ ? groupValue[groupIdAttr]
+ : groupValue;
+ if (!(valId in idMap)) {
+ throw new Error("Tried to relax a constraint that was not in force.");
+ }
+ delete idMap[valId];
+
+ let index = valList.indexOf(groupValue);
+ valList.splice(index, 1);
+ }
+
+ if (valList.length == 0) {
+ if (aInclusive) {
+ this.includeQuery = null;
+ } else {
+ this.excludeQuery = null;
+ }
+ } else {
+ let [query, invertQuery] = this._makeQuery(aInclusive, valList);
+ if (aInclusive && !invertQuery) {
+ this.includeQuery = query;
+ } else {
+ this.excludeQuery = query;
+ }
+ }
+
+ return this.includeQuery == null && this.excludeQuery == null;
+ },
+ /**
+ * Indicate whether this constraint is actually doing anything anymore.
+ */
+ get isConstrained() {
+ return this.includeQuery == null && this.excludeQuery == null;
+ },
+ /**
+ * Clear the constraint so that the next call to adjust initializes it.
+ */
+ clear() {
+ this.includeQuery = null;
+ this.includedGroupIds = {};
+ this.includedGroupValues = [];
+
+ this.excludeQuery = null;
+ this.excludedGroupIds = {};
+ this.excludedGroupValues = [];
+ },
+ /**
+ * Filter the items against our constraint.
+ */
+ sieve(aItems) {
+ let includeQuery = this.includeQuery;
+ let excludeQuery = this.excludeQuery;
+ return aItems.filter(
+ item =>
+ (!includeQuery || includeQuery.test(item)) &&
+ (!excludeQuery || !excludeQuery.test(item))
+ );
+ },
+ isIncludedGroup(aGroupValue) {
+ let valId = aGroupValue[this.facetDef.groupIdAttr];
+ return valId in this.includedGroupIds;
+ },
+ isExcludedGroup(aGroupValue) {
+ let valId = aGroupValue[this.facetDef.groupIdAttr];
+ return valId in this.excludedGroupIds;
+ },
+};
+
+var FacetContext = {
+ facetDriver: new FacetDriver(Gloda.lookupNounDef("message"), window),
+
+ /**
+ * The root collection which our active set is a subset of. We hold onto this
+ * for garbage collection reasons, although the tab that owns us should also
+ * be holding on.
+ */
+ _collection: null,
+ set collection(aCollection) {
+ this._collection = aCollection;
+ },
+ get collection() {
+ return this._collection;
+ },
+
+ _sortBy: null,
+ get sortBy() {
+ return this._sortBy;
+ },
+ set sortBy(val) {
+ try {
+ if (val == this._sortBy) {
+ return;
+ }
+ this._sortBy = val;
+ this.build(this._sieveAll());
+ } catch (e) {
+ console.error(e);
+ }
+ },
+ /**
+ * List of the current working set
+ */
+ _activeSet: null,
+ get activeSet() {
+ return this._activeSet;
+ },
+
+ /**
+ * fullSet is a special attribute which is passed a set of items that we're
+ * displaying, but the order of which is determined by the sortBy property.
+ * On setting the fullSet, we compute both sorted lists, and then on getting,
+ * we return the appropriate one.
+ */
+ get fullSet() {
+ return this._sortBy == "-dascore"
+ ? this._relevantSortedItems
+ : this._dateSortedItems;
+ },
+
+ set fullSet(items) {
+ let scores;
+ if (this.searcher && this.searcher.scores) {
+ scores = this.searcher.scores;
+ } else {
+ scores = Gloda.scoreNounItems(items);
+ }
+ let scoredItems = items.map(function (item, index) {
+ return [scores[index], item];
+ });
+ scoredItems.sort((a, b) => b[0] - a[0]);
+ this._relevantSortedItems = scoredItems.map(scoredItem => scoredItem[1]);
+
+ this._dateSortedItems = this._relevantSortedItems
+ .concat()
+ .sort((a, b) => b.date - a.date);
+ },
+
+ initialBuild() {
+ if (this.searcher) {
+ QueryExplanation.setFulltext(this.searcher);
+ } else {
+ QueryExplanation.setQuery(this.collection.query);
+ }
+ // we like to sort them so should clone the list
+ this.faceters = this.facetDriver.faceters.concat();
+
+ this._timelineShown = !Services.prefs.getBoolPref(
+ "gloda.facetview.hidetimeline"
+ );
+
+ this.everFaceted = false;
+ this._activeConstraints = {};
+ if (this.searcher) {
+ let sortByPref = Services.prefs.getIntPref("gloda.facetview.sortby");
+ this._sortBy = sortByPref == 0 || sortByPref == 2 ? "-dascore" : "-date";
+ } else {
+ this._sortBy = "-date";
+ }
+ this.fullSet = this._removeDupes(this._collection.items.concat());
+ if ("IMCollection" in this) {
+ this.fullSet = this.fullSet.concat(this.IMCollection.items);
+ }
+ this.build(this.fullSet);
+ },
+
+ /**
+ * Remove duplicate messages from search results.
+ *
+ * @param aItems the initial set of messages to deduplicate
+ * @returns the subset of those, with duplicates removed.
+ *
+ * Some IMAP servers (here's looking at you, Gmail) will create message
+ * duplicates unbeknownst to the user. We'd like to deal with them earlier
+ * in the pipeline, but that's a bit hard right now. So as a workaround
+ * we'd rather not show them in the Search Results UI. The simplest way
+ * of doing that is just to cull (from the display) messages with have the
+ * Message-ID of a message already displayed.
+ */
+ _removeDupes(aItems) {
+ let deduped = [];
+ let msgIdsSeen = {};
+ for (let item of aItems) {
+ if (item.headerMessageID in msgIdsSeen) {
+ continue;
+ }
+ deduped.push(item);
+ msgIdsSeen[item.headerMessageID] = true;
+ }
+ return deduped;
+ },
+
+ /**
+ * Kick-off a new faceting pass.
+ *
+ * @param aNewSet the set of items to facet.
+ * @param aCallback the callback to invoke when faceting is completed.
+ */
+ build(aNewSet, aCallback) {
+ this._activeSet = aNewSet;
+ this._callbackOnFacetComplete = aCallback;
+ this.facetDriver.go(this._activeSet, this.facetingCompleted, this);
+ },
+
+ /**
+ * Attempt to figure out a reasonable number of rows to limit each facet to
+ * display. While the number will ordinarily be dominated by the maximum
+ * number of rows we believe the user can easily scan, this may also be
+ * impacted by layout concerns (since we want to avoid scrolling).
+ */
+ planLayout() {
+ // XXX arbitrary!
+ this.maxDisplayRows = 8;
+ this.maxMessagesToShow = 10;
+ },
+
+ /**
+ * Clean up the UI in preparation for a new query to come in.
+ */
+ _resetUI() {
+ for (let faceter of this.faceters) {
+ if (faceter.xblNode && !faceter.xblNode.explicit) {
+ faceter.xblNode.remove();
+ }
+ faceter.xblNode = null;
+ faceter.constraint = null;
+ }
+ },
+
+ _groupCountComparator(a, b) {
+ return b.groupCount - a.groupCount;
+ },
+ /**
+ * Tells the UI about all the facets when notified by the |facetDriver| when
+ * it is done faceting everything.
+ */
+ facetingCompleted() {
+ this.planLayout();
+
+ if (!this.everFaceted) {
+ this.everFaceted = true;
+ this.faceters.sort(this._groupCountComparator);
+ for (let faceter of this.faceters) {
+ let attrName = faceter.attrDef.attributeName;
+ let explicitBinding = document.getElementById("facet-" + attrName);
+
+ if (explicitBinding) {
+ explicitBinding.explicit = true;
+ explicitBinding.faceter = faceter;
+ explicitBinding.attrDef = faceter.attrDef;
+ explicitBinding.facetDef = faceter.facetDef;
+ explicitBinding.nounDef = faceter.attrDef.objectNounDef;
+ explicitBinding.orderedGroups = faceter.orderedGroups;
+ // explicit booleans should always be displayed for consistency
+ if (
+ faceter.groupCount >= 1 ||
+ explicitBinding.getAttribute("type").includes("boolean")
+ ) {
+ try {
+ explicitBinding.build(true);
+ } catch (e) {
+ console.error(e);
+ }
+ explicitBinding.removeAttribute("uninitialized");
+ }
+ faceter.xblNode = explicitBinding;
+ continue;
+ }
+
+ // ignore facets that do not vary!
+ if (faceter.groupCount <= 1) {
+ faceter.xblNode = null;
+ continue;
+ }
+
+ faceter.xblNode = UIFacets.addFacet(faceter.type, faceter.attrDef, {
+ faceter,
+ facetDef: faceter.facetDef,
+ orderedGroups: faceter.orderedGroups,
+ maxDisplayRows: this.maxDisplayRows,
+ explicit: false,
+ });
+ }
+ } else {
+ for (let faceter of this.faceters) {
+ // Do not bother with un-displayed facets, or that are locked by a
+ // constraint. But do bother if the widget can be updated without
+ // losing important data.
+ if (
+ !faceter.xblNode ||
+ (faceter.constraint && !faceter.xblNode.canUpdate)
+ ) {
+ continue;
+ }
+
+ // hide things that have 0/1 groups now and are not constrained and not
+ // explicit
+ if (
+ faceter.groupCount <= 1 &&
+ !faceter.constraint &&
+ (!faceter.xblNode.explicit || faceter.type == "date")
+ ) {
+ faceter.xblNode.style.display = "none";
+ } else {
+ // otherwise, update
+ faceter.xblNode.orderedGroups = faceter.orderedGroups;
+ faceter.xblNode.build(false);
+ faceter.xblNode.removeAttribute("style");
+ }
+ }
+ }
+
+ if (!this._timelineShown) {
+ this._hideTimeline(true);
+ }
+
+ this._showResults();
+
+ if (this._callbackOnFacetComplete) {
+ let callback = this._callbackOnFacetComplete;
+ this._callbackOnFacetComplete = null;
+ callback();
+ }
+ },
+
+ _showResults() {
+ let results = document.getElementById("results");
+ let numMessageToShow = Math.min(
+ this.maxMessagesToShow * this._numPages,
+ this._activeSet.length
+ );
+ results.setMessages(this._activeSet.slice(0, numMessageToShow));
+
+ let showLoading = document.getElementById("showLoading");
+ showLoading.style.display = "none"; // Hide spinner, we're done thinking.
+
+ let showEmpty = document.getElementById("showEmpty");
+ let showAll = document.getElementById("gloda-showall");
+ // Check for no messages at all.
+ if (this._activeSet.length == 0) {
+ showEmpty.style.display = "block";
+ showAll.style.display = "none";
+ } else {
+ showEmpty.style.display = "none";
+ showAll.style.display = "block";
+ }
+
+ let showMore = document.getElementById("showMore");
+ showMore.style.display =
+ this._activeSet.length > numMessageToShow ? "block" : "none";
+ },
+
+ showMore() {
+ this._numPages += 1;
+ this._showResults();
+ },
+
+ zoomOut() {
+ let facetDate = document.getElementById("facet-date");
+ this.removeFacetConstraint(
+ facetDate.faceter,
+ true,
+ facetDate.vis.constraints
+ );
+ facetDate.setAttribute("zoomedout", "true");
+ },
+
+ toggleTimeline() {
+ try {
+ this._timelineShown = !this._timelineShown;
+ if (this._timelineShown) {
+ this._showTimeline();
+ } else {
+ this._hideTimeline(false);
+ }
+ } catch (e) {
+ console.error(e);
+ }
+ },
+
+ _showTimeline() {
+ let facetDate = document.getElementById("facet-date");
+ if (facetDate.style.display == "none") {
+ facetDate.style.display = "inherit";
+ // Force binding attachment so the transition to the
+ // visible state actually happens.
+ facetDate.getBoundingClientRect();
+ }
+ let listener = () => {
+ // Need to set overflow to visible so that the zoom button
+ // is not cut off at the top, and overflow=hidden causes
+ // the transition to not work as intended.
+ facetDate.removeAttribute("style");
+ };
+ facetDate.addEventListener("transitionend", listener, { once: true });
+ facetDate.removeAttribute("hide");
+ document.getElementById("date-toggle").setAttribute("checked", "true");
+ Services.prefs.setBoolPref("gloda.facetview.hidetimeline", false);
+ },
+
+ _hideTimeline(immediate) {
+ let facetDate = document.getElementById("facet-date");
+ if (immediate) {
+ facetDate.style.display = "none";
+ }
+ facetDate.style.overflow = "hidden";
+ facetDate.setAttribute("hide", "true");
+ document.getElementById("date-toggle").removeAttribute("checked");
+ Services.prefs.setBoolPref("gloda.facetview.hidetimeline", true);
+ },
+
+ _timelineShown: true,
+
+ /** For use in hovering specific results. */
+ fakeResultFaceter: {},
+ /** For use in hovering specific results. */
+ fakeResultAttr: {},
+
+ _numPages: 1,
+ _HOVER_STABILITY_DURATION_MS: 100,
+ _brushedFacet: null,
+ _brushedGroup: null,
+ _brushedItems: null,
+ _brushTimeout: null,
+ hoverFacet(aFaceter, aAttrDef, aGroupValue, aGroupItems) {
+ // bail if we are already brushing this item
+ if (this._brushedFacet == aFaceter && this._brushedGroup == aGroupValue) {
+ return;
+ }
+
+ this._brushedFacet = aFaceter;
+ this._brushedGroup = aGroupValue;
+ this._brushedItems = aGroupItems;
+
+ if (this._brushTimeout != null) {
+ clearTimeout(this._brushTimeout);
+ }
+ this._brushTimeout = setTimeout(
+ this._timeoutHoverWrapper,
+ this._HOVER_STABILITY_DURATION_MS,
+ this
+ );
+ },
+ _timeoutHover() {
+ this._brushTimeout = null;
+ for (let faceter of this.faceters) {
+ if (faceter == this._brushedFacet || !faceter.xblNode) {
+ continue;
+ }
+
+ if (this._brushedItems != null) {
+ faceter.xblNode.brushItems(this._brushedItems);
+ } else {
+ faceter.xblNode.clearBrushedItems();
+ }
+ }
+ },
+ _timeoutHoverWrapper(aThis) {
+ aThis._timeoutHover();
+ },
+ unhoverFacet(aFaceter, aAttrDef, aGroupValue, aGroupItems) {
+ // have we already brushed from some other source already? ignore then.
+ if (this._brushedFacet != aFaceter || this._brushedGroup != aGroupValue) {
+ return;
+ }
+
+ // reuse hover facet to null everyone out
+ this.hoverFacet(null, null, null, null);
+ },
+
+ /**
+ * Maps attribute names to their corresponding |ActiveConstraint|, if they
+ * have one.
+ */
+ _activeConstraints: null,
+ /**
+ * Called by facet bindings when the user does some clicking and wants to
+ * impose a new constraint.
+ *
+ * @param aFaceter The faceter that is the source of this constraint. We
+ * need to know this because once a facet has a constraint attached,
+ * the UI stops updating it.
+ * @param {boolean} aInclusive Is this an inclusive (true) or exclusive
+ * (false) constraint? The constraint instance is the one that deals with
+ * the nuances resulting from this.
+ * @param aGroupValues A list of the group values this constraint covers. In
+ * general, we expect that only one group value will be present in the
+ * list since this method should get called each time the user clicks
+ * something. Previously, we provided support for an "other" case which
+ * covered multiple groupValues so a single click needed to be able to
+ * pass in a list. The "other" case is gone now, but semantically it's
+ * okay for us to support a list.
+ * @param [aRanged] Is it a ranged constraint? (Currently only for dates)
+ * @param [aNukeExisting] Do we need to replace the existing constraint and
+ * re-sieve everything? This currently only happens for dates, where
+ * our display allows a click to actually make our range more generic
+ * than it currently is. (But this only matters if we already have
+ * a date constraint applied.)
+ * @param [aCallback] The callback to call once (re-)faceting has completed.
+ *
+ * @returns true if the caller needs to revalidate because the constraint has
+ * changed in a way other than explicitly requested. This can occur if
+ * a singular constraint flips its inclusive state and throws away
+ * constraints.
+ */
+ addFacetConstraint(
+ aFaceter,
+ aInclusive,
+ aGroupValues,
+ aRanged,
+ aNukeExisting,
+ aCallback
+ ) {
+ let attrName = aFaceter.attrDef.attributeName;
+
+ let constraint;
+ let needToSieveAll = false;
+ if (attrName in this._activeConstraints) {
+ constraint = this._activeConstraints[attrName];
+
+ needToSieveAll = true;
+ if (aNukeExisting) {
+ constraint.clear();
+ }
+ } else {
+ let constraintClass = aFaceter.attrDef.singular
+ ? ActiveSingularConstraint
+ : ActiveNonSingularConstraint;
+ constraint = this._activeConstraints[attrName] = new constraintClass(
+ aFaceter,
+ aRanged
+ );
+ aFaceter.constraint = constraint;
+ }
+ let needToRevalidate = constraint.constrain(aInclusive, aGroupValues);
+
+ // Given our current implementation, we can only be further constraining our
+ // active set, so we can just sieve the existing active set with the
+ // (potentially updated) constraint. In some cases, it would be much
+ // cheaper to use the facet's knowledge about the items in the groups, but
+ // for now let's keep a single code-path for how we refine the active set.
+ this.build(
+ needToSieveAll ? this._sieveAll() : constraint.sieve(this.activeSet),
+ aCallback
+ );
+
+ return needToRevalidate;
+ },
+
+ /**
+ * Remove a constraint previously imposed by addFacetConstraint. The
+ * constraint must still be active, which means you need to pay attention
+ * when |addFacetConstraint| returns true indicating that you need to
+ * revalidate.
+ *
+ * @param aFaceter
+ * @param aInclusive Whether the group values were previously included /
+ * excluded. If you want to remove some values that were included and
+ * some that were excluded then you need to call us once for each case.
+ * @param aGroupValues The list of group values to remove.
+ * @param aCallback The callback to call once all facets have been updated.
+ *
+ * @returns true if the constraint has been completely removed. Under the
+ * current regime, this will likely cause the binding that is calling us
+ * to be rebuilt, so be aware if you are trying to do any cool animation
+ * that might no longer make sense.
+ */
+ removeFacetConstraint(aFaceter, aInclusive, aGroupValues, aCallback) {
+ let attrName = aFaceter.attrDef.attributeName;
+ let constraint = this._activeConstraints[attrName];
+
+ let constraintGone = false;
+
+ if (constraint.relax(aInclusive, aGroupValues)) {
+ delete this._activeConstraints[attrName];
+ aFaceter.constraint = null;
+ constraintGone = true;
+ }
+
+ // we definitely need to re-sieve everybody in this case...
+ this.build(this._sieveAll(), aCallback);
+
+ return constraintGone;
+ },
+
+ /**
+ * Sieve the items from the underlying collection against all constraints,
+ * returning the value.
+ */
+ _sieveAll() {
+ let items = this.fullSet;
+
+ for (let elem in this._activeConstraints) {
+ items = this._activeConstraints[elem].sieve(items);
+ }
+
+ return items;
+ },
+
+ toggleFulltextCriteria() {
+ this.tab.searcher.andTerms = !this.tab.searcher.andTerms;
+ this._resetUI();
+ this.collection = this.tab.searcher.getCollection(this);
+ },
+
+ /**
+ * Show the active message set in a 3-pane tab.
+ */
+ showActiveSetInTab() {
+ let tabmail = this.rootWin.document.getElementById("tabmail");
+ tabmail.openTab("mail3PaneTab", {
+ folderPaneVisible: false,
+ syntheticView: new GlodaSyntheticView({
+ collection: Gloda.explicitCollection(
+ GlodaConstants.NOUN_MESSAGE,
+ this.activeSet
+ ),
+ }),
+ title: this.tab.title,
+ });
+ },
+
+ /**
+ * Show the conversation in a new 3-pane tab.
+ *
+ * @param {glodaFacetBindings.xml#result-message} aResultMessage The
+ * result the user wants to see in more details.
+ * @param {boolean} [aBackground] Whether it should be in the background.
+ */
+ showConversationInTab(aResultMessage, aBackground) {
+ let tabmail = this.rootWin.document.getElementById("tabmail");
+ let message = aResultMessage.message;
+ if (
+ "IMCollection" in this &&
+ message instanceof Gloda.lookupNounDef("im-conversation").clazz
+ ) {
+ tabmail.openTab("chat", {
+ convType: "log",
+ conv: message,
+ searchTerm: aResultMessage.firstMatchText,
+ background: aBackground,
+ });
+ return;
+ }
+ tabmail.openTab("mail3PaneTab", {
+ folderPaneVisible: false,
+ syntheticView: new GlodaSyntheticView({
+ conversation: message.conversation,
+ message,
+ }),
+ title: message.conversation.subject,
+ background: aBackground,
+ });
+ },
+
+ onItemsAdded(aItems, aCollection) {},
+ onItemsModified(aItems, aCollection) {},
+ onItemsRemoved(aItems, aCollection) {},
+ onQueryCompleted(aCollection) {
+ if (
+ this.tab.query.completed &&
+ (!("IMQuery" in this.tab) || this.tab.IMQuery.completed)
+ ) {
+ this.initialBuild();
+ }
+ },
+};
+
+/**
+ * addEventListener betrayals compel us to establish our link with the
+ * outside world from inside. NeilAway suggests the problem might have
+ * been the registration of the listener prior to initiating the load. Which
+ * is odd considering it works for the XUL case, but I could see how that might
+ * differ. Anywho, this works for now and is a delightful reference to boot.
+ */
+function reachOutAndTouchFrame() {
+ let us = window
+ .getInterface(Ci.nsIWebNavigation)
+ .QueryInterface(Ci.nsIDocShellTreeItem);
+
+ FacetContext.rootWin = us.rootTreeItem.domWindow;
+
+ let parentWin = us.parent.domWindow;
+ let aTab = (FacetContext.tab = parentWin.tab);
+ parentWin.tab = null;
+ window.addEventListener("resize", function () {
+ document.getElementById("facet-date").build(true);
+ });
+ // we need to hook the context up as a listener in all cases since
+ // removal notifications are required.
+ if ("searcher" in aTab) {
+ FacetContext.searcher = aTab.searcher;
+ aTab.searcher.listener = FacetContext;
+ if ("IMSearcher" in aTab) {
+ FacetContext.IMSearcher = aTab.IMSearcher;
+ aTab.IMSearcher.listener = FacetContext;
+ }
+ } else {
+ FacetContext.searcher = null;
+ aTab.collection.listener = FacetContext;
+ }
+ FacetContext.collection = aTab.collection;
+ if ("IMCollection" in aTab) {
+ FacetContext.IMCollection = aTab.IMCollection;
+ }
+
+ // if it has already completed, we need to prod things
+ if (
+ aTab.query.completed &&
+ (!("IMQuery" in aTab) || aTab.IMQuery.completed)
+ ) {
+ FacetContext.initialBuild();
+ }
+}
+
+function clickOnBody(event) {
+ if (event.bubbles) {
+ document.querySelector("facet-popup-menu").hide();
+ }
+ return 0;
+}
diff --git a/comm/mail/base/content/glodaFacetView.xhtml b/comm/mail/base/content/glodaFacetView.xhtml
new file mode 100644
index 0000000000..2b53355e0e
--- /dev/null
+++ b/comm/mail/base/content/glodaFacetView.xhtml
@@ -0,0 +1,123 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- 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/. -->
+
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd" [ <!ENTITY % brandDTD SYSTEM "chrome://branding/locale/brand.dtd">
+%brandDTD;
+<!ENTITY % facetViewDTD SYSTEM "chrome://messenger/locale/glodaFacetView.dtd">
+%facetViewDTD; ]>
+<html
+ xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+>
+ <head>
+ <!-- Themes -->
+ <link
+ rel="stylesheet"
+ href="chrome://messenger/skin/glodaFacetView.css"
+ type="text/css"
+ />
+ <!-- Custom elements -->
+ <script src="chrome://messenger/content/glodaFacet.js"></script>
+ <!-- Global Context -->
+ <script src="chrome://messenger/content/glodaFacetView.js"></script>
+ <!-- Libs -->
+ <script src="chrome://messenger/content/protovis-r2.6-modded.js"></script>
+ <!-- Facet Binding Stuff that doesn't belong in XBL -->
+ <script src="chrome://messenger/content/glodaFacetVis.js"></script>
+ </head>
+ <body
+ id="body"
+ onload="reachOutAndTouchFrame()"
+ onmouseup="return clickOnBody(event)"
+ >
+ <facet-popup-menu class="popup-menu" variety="invisible" />
+ <div id="gloda-facet-view">
+ <div class="facets facets-sidebar" id="facets">
+ <h1 id="filter-header-label">&glodaFacetView.filters.label;</h1>
+ <div>
+ <facet-boolean
+ id="facet-fromMe"
+ type="boolean"
+ attr="fromMe"
+ uninitialized="true"
+ />
+ <facet-boolean
+ id="facet-toMe"
+ type="boolean"
+ attr="toMe"
+ uninitialized="true"
+ />
+ <facet-boolean
+ id="facet-star"
+ type="boolean"
+ attr="star"
+ uninitialized="true"
+ /><br />
+ <facet-boolean-filtered
+ id="facet-attachmentTypes"
+ type="boolean-filtered"
+ attr="attachmentTypes"
+ groupDisplayProperty="categoryLabel"
+ uninitialized="true"
+ />
+ </div>
+ </div>
+
+ <div id="main-column">
+ <div id="header">
+ <div id="query-explanation" />
+ <a
+ id="gloda-showall"
+ class="results-message-showall-button"
+ title="&glodaFacetView.openEmailAsList.tooltip;"
+ onclick="FacetContext.showActiveSetInTab();"
+ onkeypress="if (event.charCode == KeyEvent.DOM_VK_SPACE) { FacetContext.showActiveSetInTab(); event.preventDefault(); }"
+ tabindex="0"
+ >
+ &glodaFacetView.openEmailAsList.label;
+ </a>
+ </div>
+ <div id="data-column">
+ <!-- facet-results-message is put before facet-date here so that it gets upgraded first.
+ facet-date uses width of facet-results-message for the visualization. Using order property
+ we can show facet-date before facet-results-message -->
+ <facet-results-message id="results" class="results" />
+ <facet-date id="facet-date" class="facetious" type="date" />
+ <div class="loading" id="showLoading">
+ <span class="loading">
+ <img
+ class="loading"
+ src="chrome://global/skin/icons/loading.png"
+ alt=""
+ />
+ &glodaFacetView.loading.label;
+ </span>
+ </div>
+ <div id="showEmpty" class="empty">
+ <span class="empty">
+ <img
+ class="empty"
+ src="chrome://messenger/skin/icons/empty-search-results.svg"
+ alt=""
+ />
+ <br />
+ &glodaFacetView.empty.label;
+ </span>
+ </div>
+ <button
+ id="showMore"
+ class="show-more"
+ tabindex="0"
+ onclick="FacetContext.showMore()"
+ onkeypress="if (event.charCode == KeyEvent.DOM_VK_SPACE) { FacetContext.showMore(); event.preventDefault() }"
+ >
+ &glodaFacetView.pageMore.label;
+ </button>
+ </div>
+ </div>
+ </div>
+ </body>
+</html>
diff --git a/comm/mail/base/content/glodaFacetViewWrapper.xhtml b/comm/mail/base/content/glodaFacetViewWrapper.xhtml
new file mode 100644
index 0000000000..8b2ea1e8bc
--- /dev/null
+++ b/comm/mail/base/content/glodaFacetViewWrapper.xhtml
@@ -0,0 +1,54 @@
+<?xml version="1.0"?>
+<!-- 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/. -->
+
+<?xml-stylesheet href="chrome://global/skin" type="text/css"?>
+<window
+ id="window"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+>
+ <script src="chrome://messenger/content/viewZoomOverlay.js" />
+ <script>
+ <![CDATA[
+ function getBrowser() {
+ return document.getElementById('browser');
+ }
+ ]]>
+ </script>
+ <commandset id="selectEditMenuItems">
+ <command id="cmd_fullZoomReduce" oncommand="ZoomManager.reduce();" />
+ <command id="cmd_fullZoomEnlarge" oncommand="ZoomManager.enlarge();" />
+ <command id="cmd_fullZoomReset" oncommand="ZoomManager.reset();" />
+ </commandset>
+ <keyset>
+ <!--move to locale-->
+ <key
+ id="key_fullZoomEnlarge"
+ key="+"
+ command="cmd_fullZoomEnlarge"
+ modifiers="accel"
+ />
+ <key
+ id="key_fullZoomEnlarge2"
+ key="="
+ command="cmd_fullZoomEnlarge"
+ modifiers="accel"
+ />
+ <key
+ id="key_fullZoomReduce"
+ key="-"
+ command="cmd_fullZoomReduce"
+ modifiers="accel"
+ />
+ <key
+ id="key_fullZoomReset"
+ key="0"
+ command="cmd_fullZoomReset"
+ modifiers="accel"
+ />
+ </keyset>
+ <tooltip id="aHTMLTooltip" page="true" />
+ <browser id="browser" flex="1" disablehistory="true" tooltip="aHTMLTooltip" />
+</window>
diff --git a/comm/mail/base/content/glodaFacetVis.js b/comm/mail/base/content/glodaFacetVis.js
new file mode 100644
index 0000000000..0060f67d92
--- /dev/null
+++ b/comm/mail/base/content/glodaFacetVis.js
@@ -0,0 +1,428 @@
+/* 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/. */
+
+/*
+ * Facet visualizations that would be awkward in XBL. Allegedly because the
+ * interaciton idiom of a protovis-based visualization is entirely different
+ * from XBL, but also a lot because of the lack of good syntax highlighting.
+ */
+
+/* import-globals-from glodaFacetView.js */
+/* import-globals-from protovis-r2.6-modded.js */
+
+/**
+ * A date facet visualization abstraction.
+ */
+function DateFacetVis(aBinding, aCanvasNode) {
+ this.binding = aBinding;
+ this.canvasNode = aCanvasNode;
+
+ this.faceter = aBinding.faceter;
+ this.attrDef = this.faceter.attrDef;
+}
+DateFacetVis.prototype = {
+ build() {
+ let resultsBarRect = document
+ .getElementById("results")
+ .getBoundingClientRect();
+ this.allowedSpace = resultsBarRect.right - resultsBarRect.left;
+ this.render();
+ },
+ rebuild() {
+ this.render();
+ },
+
+ _MIN_BAR_SIZE_PX: 9,
+ _BAR_SPACING_PX: 1,
+
+ _MAX_BAR_SIZE_PX: 44,
+
+ _AXIS_FONT: "10px sans-serif",
+ _AXIS_HEIGHT_NO_LABEL_PX: 6,
+ _AXIS_HEIGHT_WITH_LABEL_PX: 14,
+ _AXIS_VERT_SPACING_PX: 1,
+ _AXIS_HORIZ_MIN_SPACING_PX: 4,
+
+ _MAX_DAY_COUNT_LABEL_DISPLAY: 10,
+
+ /**
+ * Figure out how to chunk things given the linear space in pixels. In an
+ * ideal world we would not use pixels, avoiding tying ourselves to assumed
+ * pixel densities, but we do not live there. Reality wants crisp graphics
+ * and does not have enough pixels that you can ignore the pixel coordinate
+ * space and have things still look sharp (and good).
+ *
+ * Because of our love of sharpness, we will potentially under-use the space
+ * allocated to us.
+ *
+ * @param aPixels The number of linear content pixels we have to work with.
+ * You are in charge of the borders and such, so you subtract that off
+ * before you pass it in.
+ * @returns An object with attributes:
+ */
+ makeIdealScaleGivenSpace(aPixels) {
+ let facet = this.faceter;
+ // build a scale and have it grow the edges based on the span
+ let scale = pv.Scales.dateTime(facet.oldest, facet.newest);
+
+ const Span = pv.Scales.DateTimeScale.Span;
+ const MS_MIN = 60 * 1000,
+ MS_HOUR = 60 * MS_MIN,
+ MS_DAY = 24 * MS_HOUR,
+ MS_WEEK = 7 * MS_DAY,
+ MS_MONTHISH = 31 * MS_DAY,
+ MS_YEARISH = 366 * MS_DAY;
+ const roughMap = {};
+ roughMap[Span.DAYS] = MS_DAY;
+ roughMap[Span.WEEKS] = MS_WEEK;
+ // we overestimate since we want to slightly underestimate pixel usage
+ // in enoughPix's rough estimate
+ roughMap[Span.MONTHS] = MS_MONTHISH;
+ roughMap[Span.YEARS] = MS_YEARISH;
+
+ const minBarPix = this._MIN_BAR_SIZE_PX + this._BAR_SPACING_PX;
+
+ let delta = facet.newest.valueOf() - facet.oldest.valueOf();
+ let span, rules, barPixBudget;
+ // evil side-effect land
+ function enoughPix(aSpan) {
+ span = aSpan;
+ // do a rough guestimate before doing something potentially expensive...
+ barPixBudget = Math.floor(aPixels / (delta / roughMap[span]));
+ if (barPixBudget < minBarPix + 1) {
+ return false;
+ }
+
+ rules = scale.ruleValues(span);
+ // + 0 because we want to over-estimate slightly for niceness rounding
+ // reasons
+ barPixBudget = Math.floor(aPixels / (rules.length + 0));
+ delta = scale.max().valueOf() - scale.min().valueOf();
+ return barPixBudget > minBarPix;
+ }
+
+ // day is our smallest unit
+ const ALLOWED_SPANS = [Span.DAYS, Span.WEEKS, Span.MONTHS, Span.YEARS];
+ for (let trySpan of ALLOWED_SPANS) {
+ if (enoughPix(trySpan)) {
+ // do the equivalent of nice() for our chosen span
+ scale.min(scale.round(scale.min(), trySpan, false));
+ scale.max(scale.round(scale.max(), trySpan, true));
+ // try again for paranoia, but mainly for the side-effect...
+ if (enoughPix(trySpan)) {
+ break;
+ }
+ }
+ }
+
+ // - Figure out our labeling strategy
+ // normalize the symbols into an explicit ordering
+ let spandex = ALLOWED_SPANS.indexOf(span);
+ // from least-specific to most-specific
+ let labelTiers = [];
+ // add year spans in all cases, although whether we draw bars depends on if
+ // we are in year mode or not
+ labelTiers.push({
+ rules: span == Span.YEARS ? rules : scale.ruleValues(Span.YEARS, true),
+ // We should not hit the null member of the array...
+ label: [{ year: "numeric" }, { year: "2-digit" }, null],
+ boost: span == Span.YEARS,
+ noFringe: span == Span.YEARS,
+ });
+ // add month spans if we are days or weeks...
+ if (spandex < 2) {
+ labelTiers.push({
+ rules: scale.ruleValues(Span.MONTHS, true),
+ // try to use the full month, falling back to the short month
+ label: [{ month: "long" }, { month: "short" }, null],
+ boost: false,
+ });
+ }
+ // add week spans if our granularity is days...
+ if (span == Span.DAYS) {
+ let numDays = delta / MS_DAY;
+
+ // find out how many days we are talking about and add days if it's small
+ // enough, display both the date and the day of the week
+ if (numDays <= this._MAX_DAY_COUNT_LABEL_DISPLAY) {
+ labelTiers.push({
+ rules,
+ label: [{ day: "numeric" }, null],
+ boost: true,
+ noFringe: true,
+ });
+ labelTiers.push({
+ rules,
+ label: [{ weekday: "short" }, null],
+ boost: true,
+ noFringe: true,
+ });
+ } else {
+ // show the weeks since we're at greater than a day time-scale
+ labelTiers.push({
+ rules: scale.ruleValues(Span.WEEKS, true),
+ // labeling weeks is nonsensical; no one understands ISO weeks
+ // numbers.
+ label: [null],
+ boost: false,
+ });
+ }
+ }
+
+ return { scale, span, rules, barPixBudget, labelTiers };
+ },
+
+ render() {
+ let { scale, span, rules, barPixBudget, labelTiers } =
+ this.makeIdealScaleGivenSpace(this.allowedSpace);
+
+ barPixBudget = Math.floor(barPixBudget);
+
+ let minBarPix = this._MIN_BAR_SIZE_PX + this._BAR_SPACING_PX;
+ let maxBarPix = this._MAX_BAR_SIZE_PX + this._BAR_SPACING_PX;
+
+ let barPix = Math.max(minBarPix, Math.min(maxBarPix, barPixBudget));
+ let width = barPix * (rules.length - 1);
+
+ let totalAxisLabelHeight = 0;
+ let isRTL = window.getComputedStyle(this.binding).direction == "rtl";
+
+ // we need to do some font-metric calculations, so create a canvas...
+ let fontMetricCanvas = document.createElement("canvas");
+ let ctx = fontMetricCanvas.getContext("2d");
+
+ // do the labeling logic,
+ for (let labelTier of labelTiers) {
+ let labelRules = labelTier.rules;
+ let perLabelBudget = width / (labelRules.length - 1);
+ for (let labelFormat of labelTier.label) {
+ let maxWidth = 0;
+ let displayValues = [];
+ for (let iRule = 0; iRule < labelRules.length - 1; iRule++) {
+ // is this at the either edge of the display? in that case, it might
+ // be partial...
+ let fringe =
+ labelRules.length > 2 &&
+ (iRule == 0 || iRule == labelRules.length - 2);
+ let labelStartDate = labelRules[iRule];
+ let labelEndDate = labelRules[iRule + 1];
+ let labelText = labelFormat
+ ? labelStartDate.toLocaleDateString(undefined, labelFormat)
+ : null;
+ let labelStartNorm = Math.max(0, scale.normalize(labelStartDate));
+ let labelEndNorm = Math.min(1, scale.normalize(labelEndDate));
+ let labelBudget = (labelEndNorm - labelStartNorm) * width;
+ if (labelText) {
+ let labelWidth = ctx.measureText(labelText).width;
+ // discard labels at the fringe who don't fit in our budget
+ if (fringe && !labelTier.noFringe && labelWidth > labelBudget) {
+ labelText = null;
+ } else {
+ maxWidth = Math.max(labelWidth, maxWidth);
+ }
+ }
+
+ displayValues.push([
+ labelStartNorm,
+ labelEndNorm,
+ labelText,
+ labelStartDate,
+ labelEndDate,
+ ]);
+ }
+ // there needs to be space between the labels. (we may be over-padding
+ // here if there is only one label with the maximum width...)
+ maxWidth += this._AXIS_HORIZ_MIN_SPACING_PX;
+
+ if (labelTier.boost && maxWidth > perLabelBudget) {
+ // we only boost labels that are the same span as the bins, so rules
+ // === labelRules at this point. (and barPix === perLabelBudget)
+ barPix = perLabelBudget = maxWidth;
+ width = barPix * (labelRules.length - 1);
+ }
+ if (maxWidth <= perLabelBudget) {
+ labelTier.displayValues = displayValues;
+ labelTier.displayLabel = labelFormat != null;
+ labelTier.vertHeight = labelFormat
+ ? this._AXIS_HEIGHT_WITH_LABEL_PX
+ : this._AXIS_HEIGHT_NO_LABEL_PX;
+ labelTier.vertOffset = totalAxisLabelHeight;
+ totalAxisLabelHeight +=
+ labelTier.vertHeight + this._AXIS_VERT_SPACING_PX;
+
+ break;
+ }
+ }
+ }
+
+ let barWidth = barPix - this._BAR_SPACING_PX;
+
+ width = barPix * (rules.length - 1);
+ // we ideally want this to be the same size as the max rows translates to...
+ let height = 100;
+ let ch = height - totalAxisLabelHeight;
+
+ let [bins, maxBinSize] = this.binBySpan(scale, span, rules);
+
+ // build empty bins for our hot bins
+ this.emptyBins = bins.map(bin => 0);
+
+ let binScale = maxBinSize ? ch / maxBinSize : 1;
+
+ let vis = (this.vis = new pv.Panel()
+ .canvas(this.canvasNode)
+ // dimensions
+ .width(width)
+ .height(ch)
+ // margins
+ .bottom(totalAxisLabelHeight));
+
+ let faceter = this.faceter;
+ let dis = this;
+ // bin bars...
+ vis
+ .add(pv.Bar)
+ .data(bins)
+ .bottom(0)
+ .height(d => Math.floor(d.items.length * binScale))
+ .width(() => barWidth)
+ .left(function () {
+ return isRTL ? null : this.index * barPix;
+ })
+ .right(function () {
+ return isRTL ? this.index * barPix : null;
+ })
+ .fillStyle("var(--barColor)")
+ .event("mouseover", function (d) {
+ return this.fillStyle("var(--barHlColor)");
+ })
+ .event("mouseout", function (d) {
+ return this.fillStyle("var(--barColor)");
+ })
+ .event("click", function (d) {
+ dis.constraints = [[d.startDate, d.endDate]];
+ dis.binding.setAttribute("zoomedout", "false");
+ FacetContext.addFacetConstraint(
+ faceter,
+ true,
+ dis.constraints,
+ true,
+ true
+ );
+ });
+
+ this.hotBars = vis
+ .add(pv.Bar)
+ .data(this.emptyBins)
+ .bottom(0)
+ .height(d => Math.floor(d * binScale))
+ .width(() => barWidth)
+ .left(function () {
+ return this.index * barPix;
+ })
+ .fillStyle("var(--barHlColor)");
+
+ for (let labelTier of labelTiers) {
+ let labelBar = vis
+ .add(pv.Bar)
+ .data(labelTier.displayValues)
+ .bottom(-totalAxisLabelHeight + labelTier.vertOffset)
+ .height(labelTier.vertHeight)
+ .left(d => (isRTL ? null : Math.floor(width * d[0])))
+ .right(d => (isRTL ? Math.floor(width * d[0]) : null))
+ .width(d => Math.floor(width * d[1]) - Math.floor(width * d[0]) - 1)
+ .fillStyle("var(--dateColor)")
+ .event("mouseover", function (d) {
+ return this.fillStyle("var(--dateHLColor)");
+ })
+ .event("mouseout", function (d) {
+ return this.fillStyle("var(--dateColor)");
+ })
+ .event("click", function (d) {
+ dis.constraints = [[d[3], d[4]]];
+ dis.binding.setAttribute("zoomedout", "false");
+ FacetContext.addFacetConstraint(
+ faceter,
+ true,
+ dis.constraints,
+ true,
+ true
+ );
+ });
+
+ if (labelTier.displayLabel) {
+ labelBar
+ .anchor("top")
+ .add(pv.Label)
+ .font(this._AXIS_FONT)
+ .textAlign("center")
+ .textBaseline("top")
+ .textStyle("var(--dateTextColor)")
+ .text(d => d[2]);
+ }
+ }
+
+ vis.render();
+ },
+
+ hoverItems(aItems) {
+ let itemToBin = this.itemToBin;
+ let bins = this.emptyBins.concat();
+ for (let item of aItems) {
+ if (item.id in itemToBin) {
+ bins[itemToBin[item.id]]++;
+ }
+ }
+ this.hotBars.data(bins);
+ this.vis.render();
+ },
+
+ clearHover() {
+ this.hotBars.data(this.emptyBins);
+ this.vis.render();
+ },
+
+ /**
+ * Bin items at the given span granularity with the set of rules generated
+ * for the given span. This could equally as well be done as a pre-built
+ * array of buckets with a linear scan of items and a calculation of what
+ * bucket they should be placed in.
+ */
+ binBySpan(aScale, aSpan, aRules, aItems) {
+ let bins = [];
+ let maxBinSize = 0;
+ let binCount = aRules.length - 1;
+ let itemToBin = (this.itemToBin = {});
+
+ // We used to break this out by case, but that was a lot of code, and it was
+ // somewhat ridiculous. So now we just do the simple, if somewhat more
+ // expensive thing. Reviewer, feel free to thank me.
+ // We do a pass through the rules, mapping each rounded rule to a bin. We
+ // then do a pass through all of the items, rounding them down and using
+ // that to perform a lookup against the map. We could special-case the
+ // rounding, but I doubt it's worth it.
+ let binMap = {};
+ for (let iRule = 0; iRule < binCount; iRule++) {
+ let binStartDate = aRules[iRule],
+ binEndDate = aRules[iRule + 1];
+ binMap[binStartDate.valueOf().toString()] = iRule;
+ bins.push({ items: [], startDate: binStartDate, endDate: binEndDate });
+ }
+ let attrKey = this.attrDef.boundName;
+ for (let item of this.faceter.validItems) {
+ let val = item[attrKey];
+ // round it to the rule...
+ val = aScale.round(val, aSpan, false);
+ // which we can then map...
+ let itemBin = binMap[val.valueOf().toString()];
+ itemToBin[item.id] = itemBin;
+ bins[itemBin].items.push(item);
+ }
+ for (let bin of bins) {
+ maxBinSize = Math.max(bin.items.length, maxBinSize);
+ }
+
+ return [bins, maxBinSize];
+ },
+};
diff --git a/comm/mail/base/content/helpMenu.inc.xhtml b/comm/mail/base/content/helpMenu.inc.xhtml
new file mode 100644
index 0000000000..c69aa7ea6d
--- /dev/null
+++ b/comm/mail/base/content/helpMenu.inc.xhtml
@@ -0,0 +1,43 @@
+# 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/.
+
+<menu id="helpMenu"
+ data-l10n-id="menu-help-help-title"
+ onpopupshowing="buildHelpMenu();">
+ <menupopup id="menu_HelpPopup">
+ <menuitem id="menu_openHelp"
+ data-l10n-id="menu-help-get-help"
+ key="key_openHelp"
+ oncommand="openSupportURL();"/>
+ <menuitem id="menu_openTour"
+ data-l10n-id="menu-help-explore-features"
+ oncommand="openLinkText(event, 'tourURL');"/>
+ <menuitem id="menu_keyboardShortcuts"
+ data-l10n-id="menu-help-shortcuts"
+ oncommand="openLinkText(event, 'keyboardShortcutsURL');"/>
+ <menuseparator/>
+ <menuitem id="getInvolved"
+ data-l10n-id="menu-help-get-involved"
+ oncommand="openLinkText(event, 'getInvolvedURL');"/>
+ <menuitem id="donationsPage"
+ data-l10n-id="menu-help-donation"
+ oncommand="openLinkText(event, 'donateURL');"/>
+ <menuitem id="feedbackPage"
+ data-l10n-id="menu-help-share-feedback"
+ oncommand="openLinkText(event, 'feedbackURL');"/>
+ <menuseparator id="functionsSeparator"/>
+ <menuitem id="helpTroubleshootMode"
+ data-l10n-id="menu-help-enter-troubleshoot-mode"
+ oncommand="safeModeRestart();"/>
+ <menuitem id="aboutsupport_open"
+ data-l10n-id="menu-help-troubleshooting-info"
+ oncommand="openAboutSupport();"/>
+#ifndef XP_MACOSX
+ <menuseparator id="aboutSeparator"/>
+#endif
+ <menuitem id="aboutName"
+ data-l10n-id="menu-help-about-product"
+ oncommand="openAboutDialog();"/>
+ </menupopup>
+ </menu>
diff --git a/comm/mail/base/content/hiddenWindowMac.js b/comm/mail/base/content/hiddenWindowMac.js
new file mode 100644
index 0000000000..f6a8ffccc8
--- /dev/null
+++ b/comm/mail/base/content/hiddenWindowMac.js
@@ -0,0 +1,124 @@
+/* -*- Mode: Javascript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*-
+ * 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/. */
+
+function hiddenWindowStartup() {
+ // Disable menus which are not appropriate
+ let disabledItems = [
+ "menu_newFolder",
+ "newMailAccountMenuItem",
+ "newNewsgroupAccountMenuItem",
+ "menu_close",
+ "menu_saveAs",
+ "menu_saveAsFile",
+ "menu_newVirtualFolder",
+ "menu_find",
+ "menu_findCmd",
+ "menu_findAgainCmd",
+ "menu_sendunsentmsgs",
+ "menu_subscribe",
+ "menu_deleteFolder",
+ "menu_renameFolder",
+ "menu_select",
+ "menu_selectAll",
+ "menu_selectThread",
+ "menu_favoriteFolder",
+ "menu_properties",
+ "menu_Toolbars",
+ "menu_MessagePaneLayout",
+ "menu_showMessage",
+ "menu_toggleThreadPaneHeader",
+ "menu_showFolderPane",
+ "menu_FolderViews",
+ "viewSortMenu",
+ "groupBySort",
+ "viewMessageViewMenu",
+ "viewMessagesMenu",
+ "menu_expandAllThreads",
+ "collapseAllThreads",
+ "viewheadersmenu",
+ "viewBodyMenu",
+ "viewAttachmentsInlineMenuitem",
+ "viewFullZoomMenu",
+ "goNextMenu",
+ "menu_nextMsg",
+ "menu_nextUnreadMsg",
+ "menu_nextUnreadThread",
+ "goPreviousMenu",
+ "menu_prevMsg",
+ "menu_prevUnreadMsg",
+ "menu_goForward",
+ "menu_goBack",
+ "goStartPage",
+ "newMsgCmd",
+ "replyMainMenu",
+ "replySenderMainMenu",
+ "replyNewsgroupMainMenu",
+ "menu_replyToAll",
+ "menu_replyToList",
+ "menu_forwardMsg",
+ "forwardAsMenu",
+ "menu_editMsgAsNew",
+ "openMessageWindowMenuitem",
+ "openConversationMenuitem",
+ "moveMenu",
+ "copyMenu",
+ "moveToFolderAgain",
+ "tagMenu",
+ "markMenu",
+ "markReadMenuItem",
+ "menu_markThreadAsRead",
+ "menu_markReadByDate",
+ "menu_markAllRead",
+ "markFlaggedMenuItem",
+ "menu_markAsJunk",
+ "menu_markAsNotJunk",
+ "createFilter",
+ "killThread",
+ "killSubthread",
+ "watchThread",
+ "applyFilters",
+ "runJunkControls",
+ "deleteJunk",
+ "menu_import",
+ "searchMailCmd",
+ "searchAddressesCmd",
+ "filtersCmd",
+ "cmd_close",
+ "minimizeWindow",
+ "zoomWindow",
+ "appmenu_newFolder",
+ "appmenu_newMailAccountMenuItem",
+ "appmenu_newNewsgroupAccountMenuItem",
+ "appmenu_saveAs",
+ "appmenu_saveAsFile",
+ "appmenu_newVirtualFolder",
+ "appmenu_findAgainCmd",
+ "appmenu_favoriteFolder",
+ "appmenu_properties",
+ "appmenu_MessagePaneLayout",
+ "appmenu_showMessage",
+ "appmenu_toggleThreadPaneHeader",
+ "appmenu_showFolderPane",
+ "appmenu_FolderViews",
+ "appmenu_groupBySort",
+ "appmenu_findCmd",
+ "appmenu_find",
+ "appmenu_openMessageWindowMenuitem",
+ ];
+
+ let element;
+ for (let id of disabledItems) {
+ element = document.getElementById(id);
+ if (element) {
+ element.setAttribute("disabled", "true");
+ }
+ }
+
+ // Also hide the window-list separator if it exists.
+ element = document.getElementById("sep-window-list");
+ if (element) {
+ element.setAttribute("hidden", "true");
+ }
+}
diff --git a/comm/mail/base/content/hiddenWindowMac.xhtml b/comm/mail/base/content/hiddenWindowMac.xhtml
new file mode 100644
index 0000000000..3f0e493e32
--- /dev/null
+++ b/comm/mail/base/content/hiddenWindowMac.xhtml
@@ -0,0 +1,101 @@
+<?xml version="1.0"?>
+
+# 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/.
+
+<!DOCTYPE window [
+#include messenger-doctype.inc.dtd
+]>
+
+<window id="hidden-window"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" xmlns:html="http://www.w3.org/1999/xhtml"
+ onload="hiddenWindowStartup();">
+
+<script src="chrome://messenger/content/globalOverlay.js"/>
+<script src="chrome://messenger/content/mailWindow.js"/>
+<script src="chrome://messenger/content/messenger.js"/>
+<script src="chrome://messenger/content/mail3PaneWindowCommands.js"/>
+<script src="chrome://messenger/content/searchBar.js"/>
+<script src="chrome://messenger/content/hiddenWindowMac.js"/>
+<script src="chrome://messenger/content/mailCommands.js"/>
+<script src="chrome://messenger/content/mailWindowOverlay.js"/>
+<script src="chrome://messenger/content/mailTabs.js"/>
+<script src="chrome://messenger-newsblog/content/newsblogOverlay.js"/>
+<script src="chrome://messenger/content/accountUtils.js"/>
+<script src="chrome://messenger/content/mail-offline.js"/>
+<script src="chrome://messenger/content/msgViewPickerOverlay.js"/>
+<script src="chrome://messenger/content/viewZoomOverlay.js"/>
+<script src="chrome://communicator/content/utilityOverlay.js"/>
+<script src="chrome://messenger/content/mailCore.js"/>
+<script src="chrome://messenger/content/newmailaccount/uriListener.js"/>
+<script src="chrome://global/content/macWindowMenu.js"/>
+
+<stringbundle id="bundle_messenger" src="chrome://messenger/locale/messenger.properties"/>
+<stringbundle id="bundle_brand" src="chrome://branding/locale/brand.properties"/>
+
+<linkset>
+ <html:link rel="localization" href="branding/brand.ftl"/>
+ <html:link rel="localization" href="messenger/messenger.ftl"/>
+ <html:link rel="localization" href="messenger/menubar.ftl"/>
+ <html:link rel="localization" href="messenger/appmenu.ftl"/>
+ <html:link rel="localization" href="toolkit/global/textActions.ftl"/>
+ <html:link rel="localization" href="messenger/openpgp/openpgp-frontend.ftl"/>
+</linkset>
+
+<!-- keys are appended from the overlay -->
+<keyset id="mailKeys">
+#include mainKeySet.inc.xhtml
+ <keyset id="tasksKeys">
+ <key id="key_newMessage" key="&newMessageCmd.key;" command="cmd_newMessage"
+ modifiers="accel,shift"/>
+ <key id="key_newMessage2" key="&newMessageCmd2.key;" command="cmd_newMessage"
+ modifiers="accel"/>
+ </keyset>
+</keyset>
+
+<commandset id="mailCommands">
+#include mainCommandSet.inc.xhtml
+ <commandset id="mailSearchMenuItems"/>
+ <commandset id="globalEditMenuItems"
+ commandupdater="true"
+ events="create-menu-edit"
+ oncommandupdate="goUpdateGlobalEditMenuItems()"/>
+ <commandset id="selectEditMenuItems"
+ commandupdater="true"
+ events="create-menu-edit"
+ oncommandupdate="goUpdateSelectEditMenuItems()"/>
+ <commandset id="clipboardEditMenuItems"
+ commandupdater="true"
+ events="clipboard"
+ oncommandupdate="goUpdatePasteMenuItems()"/>
+ <commandset id="tasksCommands">
+ <command id="cmd_newMessage" oncommand="goOpenNewMessage();"/>
+ <command id="cmd_newCard" oncommand="openNewCardDialog()"/>
+ </commandset>
+</commandset>
+
+ <!-- it's the whole mailWindowOverlay.xhtml menubar! hidden windows need to
+ have a menubar for situations where they're the only window remaining
+ on a platform that wants to leave the app running, like the Mac.
+ -->
+ <box id="navigation-toolbox-background">
+ <toolbox id="navigation-toolbox" flex="1" labelalign="end" defaultlabelalign="end">
+
+ <vbox id="titlebar">
+ <!-- Menu -->
+ <toolbar id="toolbar-menubar"
+ class="chromeclass-menubar themeable-full"
+ type="menubar"
+ context="toolbar-context-menu">
+# The entire main menubar is placed into messenger-menubar.inc.xhtml, so that it
+# can be shared with other top level windows.
+#include messenger-menubar.inc.xhtml
+ </toolbar>
+ </vbox>
+ </toolbox>
+ </box>
+
+<browser id="hiddenBrowser" disablehistory="true"/>
+
+</window>
diff --git a/comm/mail/base/content/macMessengerMenu.js b/comm/mail/base/content/macMessengerMenu.js
new file mode 100644
index 0000000000..3ee6ba4872
--- /dev/null
+++ b/comm/mail/base/content/macMessengerMenu.js
@@ -0,0 +1,99 @@
+/* -*- Mode: Javascript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*-
+ * 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 mailCore.js */
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+// Load and add the menu item to the OS X Dock icon menu.
+addEventListener(
+ "load",
+ function () {
+ let dockMenuElement = document.getElementById("menu_mac_dockmenu");
+ let nativeMenu = Cc[
+ "@mozilla.org/widget/standalonenativemenu;1"
+ ].createInstance(Ci.nsIStandaloneNativeMenu);
+
+ nativeMenu.init(dockMenuElement);
+
+ let dockSupport = Cc["@mozilla.org/widget/macdocksupport;1"].getService(
+ Ci.nsIMacDockSupport
+ );
+ dockSupport.dockMenu = nativeMenu;
+ },
+ false
+);
+
+/**
+ * When the Preferences window is actually loaded, this Listener is called.
+ * Not doing this way could make DOM elements not available.
+ */
+function loadListener(event) {
+ setTimeout(function () {
+ let prefWin = Services.wm.getMostRecentWindow("Mail:Preferences");
+ prefWin.gSubDialog.open(
+ "chrome://messenger/content/preferences/dockoptions.xhtml"
+ );
+ });
+}
+
+/**
+ * When the Preferences window is opened/closed, this observer will be called.
+ * This is done so subdialog opens as a child of it.
+ */
+function PrefWindowObserver() {
+ this.observe = function (aSubject, aTopic, aData) {
+ if (aTopic == "domwindowopened") {
+ aSubject.addEventListener("load", loadListener, {
+ capture: false,
+ once: true,
+ });
+ }
+ Services.ww.unregisterNotification(this);
+ };
+}
+
+/**
+ * Show the Dock Options sub-dialog hanging from the Preferences window.
+ * If Preference window was already opened, this will select General pane before
+ * opening Dock Options sub-dialog.
+ */
+function openDockOptions() {
+ let win = Services.wm.getMostRecentWindow("Mail:Preferences");
+
+ if (win) {
+ openOptionsDialog("paneGeneral");
+ win.gSubDialog("chrome://messenger/content/preferences/dockoptions.xhtml");
+ } else {
+ Services.ww.registerNotification(new PrefWindowObserver());
+ openOptionsDialog("paneGeneral");
+ }
+}
+
+/**
+ * Open a new window for writing a new message
+ */
+function writeNewMessageDock() {
+ // Default identity will be used as sender for the new message.
+ MailServices.compose.OpenComposeWindow(
+ null,
+ null,
+ null,
+ Ci.nsIMsgCompType.New,
+ Ci.nsIMsgCompFormat.Default,
+ null,
+ null,
+ null
+ );
+}
+
+/**
+ * Open the address book window
+ */
+function openAddressBookDock() {
+ toAddressBook();
+}
diff --git a/comm/mail/base/content/macWindowMenu.inc.xhtml b/comm/mail/base/content/macWindowMenu.inc.xhtml
new file mode 100644
index 0000000000..e75a68f51d
--- /dev/null
+++ b/comm/mail/base/content/macWindowMenu.inc.xhtml
@@ -0,0 +1,22 @@
+# 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/.
+
+<!-- Mac window menu -->
+ <menu id="windowMenu"
+ label="&windowMenu.label;">
+ <menupopup id="windowPopup">
+ <menuseparator/>
+ <menuitem id="minimizeWindow"
+ label="&minimizeWindow.label;"
+ oncommand="window.minimize();"
+ key="key_minimizeWindow"/>
+ <menuitem id="zoomWindow"
+ label="&zoomWindow.label;"
+ oncommand="zoomWindow();"/>
+ <!-- decomment when "BringAllToFront" is implemented
+ <menuseparator/>
+ <menuitem label="&bringAllToFront.label;" disabled="true"/> -->
+ <menuseparator id="sep-window-list"/>
+ </menupopup>
+ </menu>
diff --git a/comm/mail/base/content/mail-offline.js b/comm/mail/base/content/mail-offline.js
new file mode 100644
index 0000000000..13024b874a
--- /dev/null
+++ b/comm/mail/base/content/mail-offline.js
@@ -0,0 +1,276 @@
+/* -*- Mode: Javascript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals msgWindow */ // From mailWindow.js
+
+var MailOfflineMgr = {
+ offlineManager: null,
+ offlineBundle: null,
+
+ init() {
+ Services.obs.addObserver(this, "network:offline-status-changed");
+
+ this.offlineManager = Cc[
+ "@mozilla.org/messenger/offline-manager;1"
+ ].getService(Ci.nsIMsgOfflineManager);
+ this.offlineBundle = Services.strings.createBundle(
+ "chrome://messenger/locale/offline.properties"
+ );
+
+ // initialize our offline state UI
+ this.updateOfflineUI(!this.isOnline());
+ },
+
+ uninit() {
+ Services.obs.removeObserver(this, "network:offline-status-changed");
+ },
+
+ /**
+ * @returns true if we are online
+ */
+ isOnline() {
+ return !Services.io.offline;
+ },
+
+ /**
+ * Toggles the online / offline state, initiated by the user. Depending on user settings
+ * we may prompt the user to send unsent messages when going online or to download messages for
+ * offline use when going offline.
+ */
+ toggleOfflineStatus() {
+ // the offline manager(goOnline and synchronizeForOffline) actually does the dirty work of
+ // changing the offline state with the networking service.
+ if (!this.isOnline()) {
+ // We do the go online stuff in our listener for the online state change.
+ Services.io.offline = false;
+ // resume managing offline status now that we are going back online.
+ Services.io.manageOfflineStatus =
+ Services.prefs.getBoolPref("offline.autoDetect");
+ } else {
+ // going offline
+ // Stop automatic management of the offline status since the user has
+ // decided to go offline.
+ Services.io.manageOfflineStatus = false;
+ var prefDownloadMessages = Services.prefs.getIntPref(
+ "offline.download.download_messages"
+ );
+ // 0 == Ask, 1 == Always Download, 2 == Never Download
+ var downloadForOfflineUse =
+ (prefDownloadMessages == 0 &&
+ this.confirmDownloadMessagesForOfflineUse()) ||
+ prefDownloadMessages == 1;
+ this.offlineManager.synchronizeForOffline(
+ downloadForOfflineUse,
+ downloadForOfflineUse,
+ false,
+ true,
+ msgWindow
+ );
+ }
+ },
+
+ observe(aSubject, aTopic, aState) {
+ if (aTopic == "network:offline-status-changed") {
+ this.mailOfflineStateChanged(aState == "offline");
+ }
+ },
+
+ /**
+ * @returns true if there are unsent messages
+ */
+ haveUnsentMessages() {
+ return Cc["@mozilla.org/messengercompose/sendlater;1"]
+ .getService(Ci.nsIMsgSendLater)
+ .hasUnsentMessages();
+ },
+
+ /**
+ * open the offline panel in the account manager for the currently loaded
+ * account.
+ */
+ openOfflineAccountSettings() {
+ window.parent.MsgAccountManager("am-offline.xhtml");
+ },
+
+ /**
+ * Prompt the user about going online to send unsent messages, and then send them
+ * if appropriate. Puts the app back into online mode.
+ *
+ * @param aMsgWindow the msg window to be used when going online
+ */
+ goOnlineToSendMessages(aMsgWindow) {
+ let goOnlineToSendMsgs = Services.prompt.confirm(
+ window,
+ this.offlineBundle.GetStringFromName("sendMessagesOfflineWindowTitle1"),
+ this.offlineBundle.GetStringFromName("sendMessagesOfflineLabel1")
+ );
+
+ if (goOnlineToSendMsgs) {
+ this.offlineManager.goOnline(
+ true /* send unsent messages*/,
+ false,
+ aMsgWindow
+ );
+ }
+ },
+
+ /**
+ * Prompts the user to confirm sending of unsent messages. This is different from
+ * goOnlineToSendMessages which involves going online to send unsent messages.
+ *
+ * @returns true if the user wants to send unsent messages
+ */
+ confirmSendUnsentMessages() {
+ let alwaysAsk = { value: true };
+ let sendUnsentMessages =
+ Services.prompt.confirmEx(
+ window,
+ this.offlineBundle.GetStringFromName("sendMessagesWindowTitle1"),
+ this.offlineBundle.GetStringFromName("sendMessagesLabel2"),
+ Services.prompt.BUTTON_TITLE_IS_STRING * Services.prompt.BUTTON_POS_0 +
+ Services.prompt.BUTTON_TITLE_IS_STRING * Services.prompt.BUTTON_POS_1,
+ this.offlineBundle.GetStringFromName("sendMessagesNow2"),
+ this.offlineBundle.GetStringFromName("processMessagesLater2"),
+ null,
+ this.offlineBundle.GetStringFromName("sendMessagesCheckboxLabel1"),
+ alwaysAsk
+ ) == 0;
+
+ // if the user changed the ask me setting then update the global pref based on their yes / no answer
+ if (!alwaysAsk.value) {
+ Services.prefs.setIntPref(
+ "offline.send.unsent_messages",
+ sendUnsentMessages ? 1 : 2
+ );
+ }
+
+ return sendUnsentMessages;
+ },
+
+ /**
+ * Should we send unsent messages? Based on the value of
+ * offline.send.unsent_messages, this method may prompt the user.
+ *
+ * @returns true if we should send unsent messages
+ */
+ shouldSendUnsentMessages() {
+ var sendUnsentWhenGoingOnlinePref = Services.prefs.getIntPref(
+ "offline.send.unsent_messages"
+ );
+ if (sendUnsentWhenGoingOnlinePref == 2) {
+ // never send
+ return false;
+ } else if (this.haveUnsentMessages()) {
+ // if we we have unsent messages, then honor the offline.send.unsent_messages pref.
+ if (
+ (sendUnsentWhenGoingOnlinePref == 0 &&
+ this.confirmSendUnsentMessages()) ||
+ sendUnsentWhenGoingOnlinePref == 1
+ ) {
+ return true;
+ }
+ }
+ return false;
+ },
+
+ /**
+ * Prompts the user to download messages for offline use before going offline.
+ * May update the value of offline.download.download_messages
+ *
+ * @returns true if the user wants to download messages for offline use.
+ */
+ confirmDownloadMessagesForOfflineUse() {
+ let alwaysAsk = { value: true };
+ let downloadMessages =
+ Services.prompt.confirmEx(
+ window,
+ this.offlineBundle.GetStringFromName("downloadMessagesWindowTitle1"),
+ this.offlineBundle.GetStringFromName("downloadMessagesLabel1"),
+ Services.prompt.BUTTON_TITLE_IS_STRING * Services.prompt.BUTTON_POS_0 +
+ Services.prompt.BUTTON_TITLE_IS_STRING * Services.prompt.BUTTON_POS_1,
+ this.offlineBundle.GetStringFromName("downloadMessagesNow2"),
+ this.offlineBundle.GetStringFromName("processMessagesLater2"),
+ null,
+ this.offlineBundle.GetStringFromName("downloadMessagesCheckboxLabel1"),
+ alwaysAsk
+ ) == 0;
+
+ // if the user changed the ask me setting then update the global pref based on their yes / no answer
+ if (!alwaysAsk.value) {
+ Services.prefs.setIntPref(
+ "offline.download.download_messages",
+ downloadMessages ? 1 : 2
+ );
+ }
+ return downloadMessages;
+ },
+
+ /**
+ * Get New Mail When Offline
+ * Prompts the user about going online in order to download new messages.
+ * Based on the response, will move us back to online mode.
+ *
+ * @returns true if the user confirms going online.
+ */
+ getNewMail() {
+ let goOnline = Services.prompt.confirm(
+ window,
+ this.offlineBundle.GetStringFromName("getMessagesOfflineWindowTitle1"),
+ this.offlineBundle.GetStringFromName("getMessagesOfflineLabel1")
+ );
+
+ if (goOnline) {
+ this.offlineManager.goOnline(
+ this.shouldSendUnsentMessages(),
+ false /* playbackOfflineImapOperations */,
+ msgWindow
+ );
+ }
+ return goOnline;
+ },
+
+ /**
+ * Private helper method to update the state of the Offline menu item
+ * and the offline status bar indicator
+ */
+ updateOfflineUI(aIsOffline) {
+ document
+ .getElementById("goOfflineMenuItem")
+ .setAttribute("checked", aIsOffline);
+ var statusBarPanel = document.getElementById("offline-status");
+ if (aIsOffline) {
+ statusBarPanel.setAttribute("offline", "true");
+ statusBarPanel.setAttribute(
+ "tooltiptext",
+ this.offlineBundle.GetStringFromName("offlineTooltip")
+ );
+ } else {
+ statusBarPanel.removeAttribute("offline");
+ statusBarPanel.setAttribute(
+ "tooltiptext",
+ this.offlineBundle.GetStringFromName("onlineTooltip")
+ );
+ }
+ },
+
+ /**
+ * private helper method called whenever we detect a change to the offline state
+ */
+ mailOfflineStateChanged(aGoingOffline) {
+ this.updateOfflineUI(aGoingOffline);
+ if (!aGoingOffline) {
+ let prefSendUnsentMessages = Services.prefs.getIntPref(
+ "offline.send.unsent_messages"
+ );
+ // 0 == Ask, 1 == Always Send, 2 == Never Send
+ let sendUnsentMessages =
+ (prefSendUnsentMessages == 0 &&
+ this.haveUnsentMessages() &&
+ this.confirmSendUnsentMessages()) ||
+ prefSendUnsentMessages == 1;
+ this.offlineManager.goOnline(sendUnsentMessages, true, msgWindow);
+ }
+ },
+};
diff --git a/comm/mail/base/content/mail3PaneWindowCommands.js b/comm/mail/base/content/mail3PaneWindowCommands.js
new file mode 100644
index 0000000000..8042022dcb
--- /dev/null
+++ b/comm/mail/base/content/mail3PaneWindowCommands.js
@@ -0,0 +1,456 @@
+/* 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/. */
+
+/**
+ * Functionality for the main application window (aka the 3pane) usually
+ * consisting of folder pane, thread pane and message pane.
+ */
+
+/* global MozElements */
+
+/* import-globals-from ../../components/im/content/chat-messenger.js */
+/* import-globals-from mailCore.js */
+/* import-globals-from mailWindow.js */ // msgWindow and a loooot more
+/* import-globals-from utilityOverlay.js */
+
+/* globals MailOfflineMgr */ // From mail-offline.js
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+var { PluralForm } = ChromeUtils.importESModule(
+ "resource://gre/modules/PluralForm.sys.mjs"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "MailUtils",
+ "resource:///modules/MailUtils.jsm"
+);
+
+// DefaultController object (handles commands when one of the trees does not have focus)
+var DefaultController = {
+ supportsCommand(command) {
+ switch (command) {
+ case "cmd_newMessage":
+ case "cmd_undoCloseTab":
+ case "cmd_undo":
+ case "cmd_redo":
+ case "cmd_sendUnsentMsgs":
+ case "cmd_subscribe":
+ case "cmd_getNewMessages":
+ case "cmd_getMsgsForAuthAccounts":
+ case "cmd_getNextNMessages":
+ case "cmd_settingsOffline":
+ case "cmd_viewAllHeader":
+ case "cmd_viewNormalHeader":
+ case "cmd_stop":
+ case "cmd_chat":
+ case "cmd_goFolder":
+ return true;
+ case "cmd_synchronizeOffline":
+ return MailOfflineMgr.isOnline();
+ case "cmd_joinChat":
+ case "cmd_addChatBuddy":
+ case "cmd_chatStatus":
+ return !!chatHandler;
+
+ default:
+ return false;
+ }
+ },
+
+ isCommandEnabled(command) {
+ if (document.getElementById("tabmail").globalOverlay) {
+ return false;
+ }
+ switch (command) {
+ case "cmd_newMessage":
+ return MailServices.accounts.allIdentities.length > 0;
+ case "cmd_viewAllHeader":
+ case "cmd_viewNormalHeader":
+ return true;
+ case "cmd_undoCloseTab":
+ return document.getElementById("tabmail").recentlyClosedTabs.length > 0;
+ case "cmd_stop":
+ return window.MsgStatusFeedback?._meteorsSpinning;
+ case "cmd_undo":
+ case "cmd_redo":
+ return SetupUndoRedoCommand(command);
+ case "cmd_sendUnsentMsgs":
+ return IsSendUnsentMsgsEnabled(null);
+ case "cmd_subscribe":
+ return IsSubscribeEnabled();
+ case "cmd_getNewMessages":
+ case "cmd_getMsgsForAuthAccounts":
+ return IsGetNewMessagesEnabled();
+ case "cmd_getNextNMessages":
+ return IsGetNextNMessagesEnabled();
+ case "cmd_synchronizeOffline":
+ return MailOfflineMgr.isOnline();
+ case "cmd_settingsOffline":
+ return IsAccountOfflineEnabled();
+ case "cmd_goFolder":
+ return isFolderPaneInitialized();
+ case "cmd_chat":
+ return true;
+ case "cmd_joinChat":
+ case "cmd_addChatBuddy":
+ case "cmd_chatStatus":
+ return !!chatHandler;
+ }
+ return false;
+ },
+
+ doCommand(command, event) {
+ // If the user invoked a key short cut then it is possible that we got here
+ // for a command which is really disabled. Kick out if the command should be disabled.
+ if (!this.isCommandEnabled(command)) {
+ return;
+ }
+
+ switch (command) {
+ case "cmd_getNewMessages":
+ MsgGetMessage();
+ break;
+ case "cmd_getMsgsForAuthAccounts":
+ MsgGetMessagesForAllAuthenticatedAccounts();
+ break;
+ case "cmd_getNextNMessages":
+ MsgGetNextNMessages();
+ break;
+ case "cmd_newMessage":
+ MsgNewMessage(event);
+ break;
+ case "cmd_undoCloseTab":
+ document.getElementById("tabmail").undoCloseTab();
+ break;
+ case "cmd_undo":
+ messenger.undo(msgWindow);
+ break;
+ case "cmd_redo":
+ messenger.redo(msgWindow);
+ break;
+ case "cmd_sendUnsentMsgs":
+ // if offline, prompt for sendUnsentMessages
+ if (MailOfflineMgr.isOnline()) {
+ SendUnsentMessages();
+ } else {
+ MailOfflineMgr.goOnlineToSendMessages(msgWindow);
+ }
+ return;
+ case "cmd_subscribe":
+ MsgSubscribe();
+ return;
+ case "cmd_stop":
+ msgWindow.StopUrls();
+ return;
+ case "cmd_viewAllHeader":
+ MsgViewAllHeaders();
+ return;
+ case "cmd_viewNormalHeader":
+ MsgViewNormalHeaders();
+ return;
+ case "cmd_synchronizeOffline":
+ MsgSynchronizeOffline();
+ break;
+ case "cmd_settingsOffline":
+ MailOfflineMgr.openOfflineAccountSettings();
+ break;
+ case "cmd_goFolder":
+ document
+ .getElementById("tabmail")
+ .currentAbout3Pane.displayFolder(event.target._folder);
+ break;
+ case "cmd_chat":
+ showChatTab();
+ break;
+ }
+ },
+
+ onEvent(event) {
+ // on blur events set the menu item texts back to the normal values
+ if (event == "blur") {
+ goSetMenuValue("cmd_undo", "valueDefault");
+ goSetMenuValue("cmd_redo", "valueDefault");
+ }
+ },
+};
+// This is the highest priority controller. It's followed by
+// tabmail.tabController and calendarController, then whatever Gecko adds.
+window.controllers.insertControllerAt(0, DefaultController);
+
+function CloseTabOrWindow() {
+ let tabmail = document.getElementById("tabmail");
+ if (tabmail.globalOverlay) {
+ return;
+ }
+ if (tabmail.tabInfo.length == 1) {
+ if (Services.prefs.getBoolPref("mail.tabs.closeWindowWithLastTab")) {
+ window.close();
+ }
+ } else {
+ tabmail.removeCurrentTab();
+ }
+}
+
+function IsSendUnsentMsgsEnabled(unsentMsgsFolder) {
+ // If no account has been configured, there are no messages for sending.
+ if (MailServices.accounts.accounts.length == 0) {
+ return false;
+ }
+
+ let msgSendlater;
+ try {
+ msgSendlater = Cc["@mozilla.org/messengercompose/sendlater;1"].getService(
+ Ci.nsIMsgSendLater
+ );
+ } catch (error) {}
+
+ // If we're currently sending unsent msgs, disable this cmd.
+ if (msgSendlater?.sendingMessages) {
+ return false;
+ }
+
+ if (unsentMsgsFolder) {
+ // If unsentMsgsFolder is non-null, it is the "Unsent Messages" folder.
+ // We're here because we've done a right click on the "Unsent Messages"
+ // folder (context menu), so we can use the folder and return true/false
+ // straight away.
+ return unsentMsgsFolder.getTotalMessages(false) > 0;
+ }
+
+ // Otherwise, we don't know where we are, so use the current identity and
+ // find out if we have messages or not via that.
+ let identity;
+ let folders = GetSelectedMsgFolders();
+ if (folders.length > 0) {
+ [identity] = MailUtils.getIdentityForServer(folders[0].server);
+ }
+
+ if (!identity) {
+ let defaultAccount = MailServices.accounts.defaultAccount;
+ if (defaultAccount) {
+ identity = defaultAccount.defaultIdentity;
+ }
+
+ if (!identity) {
+ return false;
+ }
+ }
+
+ let hasUnsentMessages = false;
+ try {
+ hasUnsentMessages = msgSendlater?.hasUnsentMessages(identity);
+ } catch (error) {}
+ return hasUnsentMessages;
+}
+
+/**
+ * Determine whether there exists any server for which to show the Subscribe dialog.
+ */
+function IsSubscribeEnabled() {
+ // If there are any IMAP or News servers, we can show the dialog any time and
+ // it will properly show those.
+ for (let server of MailServices.accounts.allServers) {
+ if (server.type == "imap" || server.type == "nntp") {
+ return true;
+ }
+ }
+
+ // RSS accounts use a separate Subscribe dialog that we can only show when
+ // such an account is selected.
+ let preselectedFolder = GetFirstSelectedMsgFolder();
+ if (preselectedFolder && preselectedFolder.server.type == "rss") {
+ return true;
+ }
+
+ return false;
+}
+
+/**
+ * Cycle through the various panes in the 3pane window.
+ *
+ * @param {Event} event - The keypress DOMEvent.
+ */
+function SwitchPaneFocus(event) {
+ let tabmail = document.getElementById("tabmail");
+ // Should not move the focus around when the entire window is covered with
+ // something else.
+ if (tabmail.globalOverlay) {
+ return;
+ }
+ // First, build an array of panes to cycle through based on our current state.
+ // This will usually be something like [folderTree, threadTree, messageBrowser].
+ let panes = [];
+ // The logically focused element. If the actually focused element is not one
+ // of the panes, the code below can change this variable to point to one of
+ // the panes.
+ let focusedElement = document.activeElement;
+ // If the actually focused element is between two of the panes, set this to
+ // -1, 0, or 1 (depending on the direction and where the focus is relative to
+ // `focusedElement`) so that the element to focus is correctly chosen.
+ let adjustment = 0;
+
+ let spacesElement = !gSpacesToolbar.isHidden
+ ? gSpacesToolbar.focusButton
+ : document.getElementById("spacesPinnedButton");
+ panes.push(spacesElement);
+
+ let toolbar = document.getElementById("unifiedToolbar");
+ if (!toolbar.hidden) {
+ // Prioritise the search bar, otherwise use the first available button.
+ let toolbarElement =
+ toolbar.querySelector("global-search-bar") ||
+ toolbar.querySelector("li:not([hidden]) button, #button-appmenu");
+ if (toolbarElement) {
+ panes.push(toolbarElement);
+ if (toolbar.matches(":focus-within") && focusedElement != spacesElement) {
+ focusedElement = toolbarElement;
+ }
+ }
+ }
+
+ let { currentTabInfo } = tabmail;
+ switch (currentTabInfo.mode.name) {
+ case "mail3PaneTab": {
+ let { contentWindow, contentDocument } = currentTabInfo.chromeBrowser;
+ let {
+ paneLayout,
+ folderTree,
+ threadTree,
+ webBrowser,
+ messageBrowser,
+ multiMessageBrowser,
+ accountCentralBrowser,
+ } = contentWindow;
+
+ if (paneLayout.folderPaneVisible) {
+ panes.push(folderTree);
+ }
+
+ if (accountCentralBrowser.hidden) {
+ panes.push(threadTree.table.body);
+ } else {
+ panes.push(accountCentralBrowser);
+ }
+
+ if (paneLayout.messagePaneVisible) {
+ if (!webBrowser.hidden) {
+ panes.push(webBrowser);
+ } else if (!messageBrowser.hidden) {
+ panes.push(messageBrowser.contentWindow.getMessagePaneBrowser());
+ } else if (!multiMessageBrowser.hidden) {
+ panes.push(multiMessageBrowser);
+ }
+ }
+
+ if (focusedElement == currentTabInfo.chromeBrowser) {
+ focusedElement = contentDocument.activeElement;
+ if (
+ focusedElement != folderTree &&
+ contentDocument.getElementById("folderPane").contains(focusedElement)
+ ) {
+ focusedElement = folderTree;
+ adjustment = event.shiftKey ? 0 : -1;
+ } else if (
+ contentDocument
+ .getElementById("threadPaneNotificationBox")
+ .contains(focusedElement)
+ ) {
+ focusedElement = threadTree.table.body;
+ adjustment = event.shiftKey ? 1 : 0;
+ } else if (
+ focusedElement != threadTree.table.body &&
+ contentDocument.getElementById("threadPane").contains(focusedElement)
+ ) {
+ focusedElement = threadTree.table.body;
+ adjustment = event.shiftKey ? 0 : -1;
+ } else if (focusedElement == messageBrowser) {
+ focusedElement = messageBrowser.contentWindow.getMessagePaneBrowser();
+ }
+ }
+ break;
+ }
+ case "mailMessageTab": {
+ let { content } = currentTabInfo.chromeBrowser.contentWindow;
+ panes.push(content);
+ if (focusedElement == currentTabInfo.chromeBrowser) {
+ focusedElement = content;
+ }
+ break;
+ }
+ case "addressBookTab": {
+ let { booksList, cardsPane, detailsPane } =
+ currentTabInfo.browser.contentWindow;
+
+ if (detailsPane.isEditing) {
+ panes.push(currentTabInfo.browser);
+ } else {
+ let targets = [
+ booksList,
+ cardsPane.searchInput,
+ cardsPane.cardsList.table.body,
+ ];
+ if (!detailsPane.node.hidden && !detailsPane.editButton.hidden) {
+ targets.push(detailsPane.editButton);
+ }
+
+ if (focusedElement == currentTabInfo.browser) {
+ focusedElement = targets.find(t => t.matches(":focus-within"));
+ }
+ panes.push(...targets);
+ }
+ break;
+ }
+ default:
+ if (currentTabInfo.browser) {
+ panes.push(currentTabInfo.browser);
+ }
+ break;
+ }
+
+ // Find our focused element in the array.
+ let focusedElementIndex = panes.indexOf(focusedElement) + adjustment;
+ if (event.shiftKey) {
+ focusedElementIndex--;
+ if (focusedElementIndex < 0) {
+ focusedElementIndex = panes.length - 1;
+ }
+ } else if (focusedElementIndex == -1) {
+ focusedElementIndex = 0;
+ } else {
+ focusedElementIndex++;
+ if (focusedElementIndex == panes.length) {
+ focusedElementIndex = 0;
+ }
+ }
+
+ if (panes[focusedElementIndex]) {
+ panes[focusedElementIndex].focus();
+ }
+}
+
+// Override F6 handling for remote browsers, and use our own logic to
+// determine the element to focus.
+addEventListener(
+ "keypress",
+ function (event) {
+ if (event.key == "F6" && Services.focus.focusedElement?.isRemoteBrowser) {
+ event.preventDefault();
+ SwitchPaneFocus(event);
+ }
+ },
+ true
+);
+
+/**
+ * Check the status of the folder pane, if available.
+ *
+ * @returns {boolean|undefined} The initialization state of the folder pane,
+ * or undefined if we can't access the document.
+ */
+function isFolderPaneInitialized() {
+ return document.getElementById("tabmail")?.currentAbout3Pane?.folderPane
+ .isInitialized;
+}
diff --git a/comm/mail/base/content/mailCommands.js b/comm/mail/base/content/mailCommands.js
new file mode 100644
index 0000000000..9c974202e5
--- /dev/null
+++ b/comm/mail/base/content/mailCommands.js
@@ -0,0 +1,667 @@
+/* -*- Mode: Javascript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*-
+ * 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 utilityOverlay.js */
+
+/* globals msgWindow, messenger */ // From mailWindow.js
+/* globals openComposeWindowForRSSArticle */ // From newsblogOverlay.js
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "FeedUtils",
+ "resource:///modules/FeedUtils.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "MailUtils",
+ "resource:///modules/MailUtils.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "MsgHdrToMimeMessage",
+ "resource:///modules/gloda/MimeMessage.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "EnigmailMime",
+ "chrome://openpgp/content/modules/mime.jsm"
+);
+
+function GetNextNMessages(folder) {
+ if (folder) {
+ var newsFolder = folder.QueryInterface(Ci.nsIMsgNewsFolder);
+ if (newsFolder) {
+ newsFolder.getNextNMessages(msgWindow);
+ }
+ }
+}
+
+/**
+ * Figure out the message key from the message uri.
+ *
+ * @param uri string defining internal storage
+ */
+function GetMsgKeyFromURI(uri) {
+ // Format of 'uri' : protocol://email/folder#key?params
+ // '?params' are optional
+ // ex : mailbox-message://john%2Edoe@pop.isp.invalid/Drafts#12345
+ // We keep only the part after '#' and before an optional '?'.
+ // The regexp expects 'key' to be an integer (a series of digits) : '\d+'.
+ let match = /.+#(\d+)/.exec(uri);
+ return match ? match[1] : null;
+}
+
+/* eslint-disable complexity */
+/**
+ * Compose a message.
+ *
+ * @param {nsIMsgCompType} type - Type of composition (new message, reply, draft, etc.)
+ * @param {nsIMsgCompFormat} format - Requested format (plain text, html, default)
+ * @param {nsIMsgFolder} folder - Folder where the original message is stored
+ * @param {string[]} messageArray - Array of message URIs to process, often only
+ * holding one element.
+ * @param {Selection} [selection=null] - A DOM selection to be quoted, or null
+ * to quote the whole message, if quoting is appropriate (e.g. in a reply).
+ * @param {boolean} [autodetectCharset=false] - If quoting the whole message,
+ * whether automatic character set detection should be used.
+ */
+async function ComposeMessage(
+ type,
+ format,
+ folder,
+ messageArray,
+ selection = null,
+ autodetectCharset = false
+) {
+ let aboutMessage =
+ document.getElementById("tabmail")?.currentAboutMessage ||
+ document.getElementById("messageBrowser")?.contentWindow;
+ let currentHeaderData = aboutMessage?.currentHeaderData;
+
+ function isCurrentlyDisplayed(hdr) {
+ return (
+ currentHeaderData && // ignoring enclosing brackets:
+ currentHeaderData["message-id"]?.headerValue.includes(hdr.messageId)
+ );
+ }
+
+ function findDeliveredToIdentityEmail(hdr) {
+ // This function reads from currentHeaderData, which is only useful if we're
+ // looking at the currently-displayed message. Otherwise, just return
+ // immediately so we don't waste time.
+ if (!isCurrentlyDisplayed(hdr)) {
+ return "";
+ }
+
+ // Get the delivered-to headers.
+ let key = "delivered-to";
+ let deliveredTos = [];
+ let index = 0;
+ let header = "";
+ while ((header = currentHeaderData[key])) {
+ deliveredTos.push(header.headerValue.toLowerCase().trim());
+ key = "delivered-to" + index++;
+ }
+
+ // Reverse the array so that the last delivered-to header will show at front.
+ deliveredTos.reverse();
+
+ for (let i = 0; i < deliveredTos.length; i++) {
+ for (let identity of MailServices.accounts.allIdentities) {
+ if (!identity.email) {
+ continue;
+ }
+ // If the deliver-to header contains the defined identity, that's it.
+ if (
+ deliveredTos[i] == identity.email.toLowerCase() ||
+ deliveredTos[i].includes("<" + identity.email.toLowerCase() + ">")
+ ) {
+ return identity.email;
+ }
+ }
+ }
+ return "";
+ }
+
+ let msgKey;
+ if (messageArray && messageArray.length == 1) {
+ msgKey = GetMsgKeyFromURI(messageArray[0]);
+ }
+
+ // Check if the draft is already open in another window. If it is, just focus the window.
+ if (type == Ci.nsIMsgCompType.Draft && messageArray.length == 1) {
+ // We'll search this uri in the opened windows.
+ for (let win of Services.wm.getEnumerator("")) {
+ // Check if it is a compose window.
+ if (
+ win.document.defaultView.gMsgCompose &&
+ win.document.defaultView.gMsgCompose.compFields.draftId
+ ) {
+ let wKey = GetMsgKeyFromURI(
+ win.document.defaultView.gMsgCompose.compFields.draftId
+ );
+ if (wKey == msgKey) {
+ // Found ! just focus it...
+ win.focus();
+ // ...and nothing to do anymore.
+ return;
+ }
+ }
+ }
+ }
+ var identity = null;
+ var newsgroup = null;
+ var hdr;
+
+ // dump("ComposeMessage folder=" + folder + "\n");
+ try {
+ if (folder) {
+ // Get the incoming server associated with this uri.
+ var server = folder.server;
+
+ // If they hit new or reply and they are reading a newsgroup,
+ // turn this into a new post or a reply to group.
+ if (
+ !folder.isServer &&
+ server.type == "nntp" &&
+ type == Ci.nsIMsgCompType.New
+ ) {
+ type = Ci.nsIMsgCompType.NewsPost;
+ newsgroup = folder.folderURL;
+ }
+
+ identity = folder.customIdentity;
+ if (!identity) {
+ [identity] = MailUtils.getIdentityForServer(server);
+ }
+ // dump("identity = " + identity + "\n");
+ }
+ } catch (ex) {
+ dump("failed to get an identity to pre-select: " + ex + "\n");
+ }
+
+ // dump("\nComposeMessage from XUL: " + identity + "\n");
+
+ switch (type) {
+ case Ci.nsIMsgCompType.New: // new message
+ // dump("OpenComposeWindow with " + identity + "\n");
+
+ MailServices.compose.OpenComposeWindow(
+ null,
+ null,
+ null,
+ type,
+ format,
+ identity,
+ null,
+ msgWindow
+ );
+ return;
+ case Ci.nsIMsgCompType.NewsPost:
+ // dump("OpenComposeWindow with " + identity + " and " + newsgroup + "\n");
+ MailServices.compose.OpenComposeWindow(
+ null,
+ null,
+ newsgroup,
+ type,
+ format,
+ identity,
+ null,
+ msgWindow
+ );
+ return;
+ case Ci.nsIMsgCompType.ForwardAsAttachment:
+ if (messageArray && messageArray.length) {
+ // If we have more than one ForwardAsAttachment then pass null instead
+ // of the header to tell the compose service to work out the attachment
+ // subjects from the URIs.
+ hdr =
+ messageArray.length > 1
+ ? null
+ : messenger.msgHdrFromURI(messageArray[0]);
+ MailServices.compose.OpenComposeWindow(
+ null,
+ hdr,
+ messageArray.join(","),
+ type,
+ format,
+ identity,
+ null,
+ msgWindow
+ );
+ }
+ return;
+ default:
+ if (!messageArray) {
+ return;
+ }
+
+ // Limit the number of new compose windows to 8. Why 8 ?
+ // I like that number :-)
+ if (messageArray.length > 8) {
+ messageArray.length = 8;
+ }
+
+ for (var i = 0; i < messageArray.length; ++i) {
+ var messageUri = messageArray[i];
+ hdr = messenger.msgHdrFromURI(messageUri);
+
+ if (
+ [
+ Ci.nsIMsgCompType.Reply,
+ Ci.nsIMsgCompType.ReplyAll,
+ Ci.nsIMsgCompType.ReplyToSender,
+ // Author's address doesn't matter for followup to a newsgroup.
+ // Ci.nsIMsgCompType.ReplyToGroup,
+ Ci.nsIMsgCompType.ReplyToSenderAndGroup,
+ Ci.nsIMsgCompType.ReplyWithTemplate,
+ Ci.nsIMsgCompType.ReplyToList,
+ ].includes(type)
+ ) {
+ let replyTo = hdr.getStringProperty("replyTo");
+ let from = replyTo || hdr.author;
+ let fromAddrs = MailServices.headerParser.parseEncodedHeader(
+ from,
+ null
+ );
+ let email = fromAddrs[0]?.email;
+ if (
+ type == Ci.nsIMsgCompType.ReplyToList &&
+ isCurrentlyDisplayed(hdr)
+ ) {
+ // ReplyToList is only enabled for current message (if at all), so
+ // using currentHeaderData is ok.
+ // List-Post value is of the format <mailto:list@example.com>
+ let listPost = currentHeaderData["list-post"]?.headerValue;
+ if (listPost) {
+ email = listPost.replace(/.*<mailto:(.+)>.*/, "$1");
+ }
+ }
+
+ if (
+ /^(.*[._-])?(do[._-]?not|no)[._-]?reply([._-].*)?@/i.test(email)
+ ) {
+ let [title, message, replyAnywayButton] =
+ await document.l10n.formatValues([
+ { id: "no-reply-title" },
+ { id: "no-reply-message", args: { email } },
+ { id: "no-reply-reply-anyway-button" },
+ ]);
+
+ let buttonFlags =
+ Ci.nsIPrompt.BUTTON_TITLE_IS_STRING * Ci.nsIPrompt.BUTTON_POS_0 +
+ Ci.nsIPrompt.BUTTON_TITLE_CANCEL * Ci.nsIPrompt.BUTTON_POS_1 +
+ Ci.nsIPrompt.BUTTON_POS_1_DEFAULT;
+
+ if (
+ Services.prompt.confirmEx(
+ window,
+ title,
+ message,
+ buttonFlags,
+ replyAnywayButton,
+ null, // cancel
+ null,
+ null,
+ {}
+ )
+ ) {
+ continue;
+ }
+ }
+ }
+
+ if (FeedUtils.isFeedMessage(hdr)) {
+ // Do not use the header derived identity for feeds, pass on only a
+ // possible server identity from above.
+ openComposeWindowForRSSArticle(
+ null,
+ hdr,
+ messageUri,
+ type,
+ format,
+ identity,
+ msgWindow
+ );
+ } else {
+ // Replies come here.
+
+ let useCatchAll = false;
+ // Check if we are using catchAll on any identity. If current
+ // folder has some customIdentity set, ignore catchAll settings.
+ // CatchAll is not applicable to news (and doesn't work, bug 545365).
+ if (
+ hdr.folder &&
+ hdr.folder.server.type != "nntp" &&
+ !hdr.folder.customIdentity
+ ) {
+ useCatchAll = MailServices.accounts.allIdentities.some(
+ identity => identity.catchAll
+ );
+ }
+
+ if (useCatchAll) {
+ // If we use catchAll, we need to get all headers.
+ // MsgHdr retrieval is asynchronous, do everything in the callback.
+ MsgHdrToMimeMessage(
+ hdr,
+ null,
+ function (hdr, mimeMsg) {
+ let catchAllHeaders = Services.prefs
+ .getStringPref("mail.compose.catchAllHeaders")
+ .split(",")
+ .map(header => header.toLowerCase().trim());
+ // Collect catchAll hints from given headers.
+ let collectedHeaderAddresses = "";
+ for (let header of catchAllHeaders) {
+ if (mimeMsg.has(header)) {
+ for (let mimeMsgHeader of mimeMsg.headers[header]) {
+ collectedHeaderAddresses +=
+ MailServices.headerParser
+ .parseEncodedHeaderW(mimeMsgHeader)
+ .toString() + ",";
+ }
+ }
+ }
+
+ let [identity, matchingHint] = MailUtils.getIdentityForHeader(
+ hdr,
+ type,
+ collectedHeaderAddresses
+ );
+
+ // The found identity might have no catchAll enabled.
+ if (identity.catchAll && matchingHint) {
+ // If name is not set in matchingHint, search trough other hints.
+ if (matchingHint.email && !matchingHint.name) {
+ let hints =
+ MailServices.headerParser.makeFromDisplayAddress(
+ hdr.recipients +
+ "," +
+ hdr.ccList +
+ "," +
+ collectedHeaderAddresses
+ );
+ for (let hint of hints) {
+ if (
+ hint.name &&
+ hint.email.toLowerCase() ==
+ matchingHint.email.toLowerCase()
+ ) {
+ matchingHint =
+ MailServices.headerParser.makeMailboxObject(
+ hint.name,
+ matchingHint.email
+ );
+ break;
+ }
+ }
+ }
+ } else {
+ matchingHint = MailServices.headerParser.makeMailboxObject(
+ "",
+ ""
+ );
+ }
+
+ // Now open compose window and use matching hint as reply sender.
+ MailServices.compose.OpenComposeWindow(
+ null,
+ hdr,
+ messageUri,
+ type,
+ format,
+ identity,
+ matchingHint.toString(),
+ msgWindow,
+ selection,
+ autodetectCharset
+ );
+ },
+ true,
+ { saneBodySize: true }
+ );
+ } else {
+ // Fall back to traditional behavior.
+ let [hdrIdentity] = MailUtils.getIdentityForHeader(
+ hdr,
+ type,
+ findDeliveredToIdentityEmail(hdr)
+ );
+ MailServices.compose.OpenComposeWindow(
+ null,
+ hdr,
+ messageUri,
+ type,
+ format,
+ hdrIdentity,
+ null,
+ msgWindow,
+ selection,
+ autodetectCharset
+ );
+ }
+ }
+ }
+ }
+}
+/* eslint-enable complexity */
+
+function Subscribe(preselectedMsgFolder) {
+ window.openDialog(
+ "chrome://messenger/content/subscribe.xhtml",
+ "subscribe",
+ "chrome,modal,titlebar,resizable=yes",
+ {
+ folder: preselectedMsgFolder,
+ okCallback: SubscribeOKCallback,
+ }
+ );
+}
+
+function SubscribeOKCallback(changeTable) {
+ for (var serverURI in changeTable) {
+ var folder = MailUtils.getExistingFolder(serverURI);
+ var server = folder.server;
+ var subscribableServer = server.QueryInterface(Ci.nsISubscribableServer);
+
+ for (var name in changeTable[serverURI]) {
+ if (changeTable[serverURI][name]) {
+ try {
+ subscribableServer.subscribe(name);
+ } catch (ex) {
+ dump("failed to subscribe to " + name + ": " + ex + "\n");
+ }
+ } else if (!changeTable[serverURI][name]) {
+ try {
+ subscribableServer.unsubscribe(name);
+ } catch (ex) {
+ dump("failed to unsubscribe to " + name + ": " + ex + "\n");
+ }
+ }
+ }
+
+ try {
+ subscribableServer.commitSubscribeChanges();
+ } catch (ex) {
+ dump("failed to commit the changes: " + ex + "\n");
+ }
+ }
+}
+
+function SaveAsFile(uris) {
+ let filenames = [];
+
+ for (let uri of uris) {
+ let msgHdr =
+ MailServices.messageServiceFromURI(uri).messageURIToMsgHdr(uri);
+ let nameBase = GenerateFilenameFromMsgHdr(msgHdr);
+ let name = GenerateValidFilename(nameBase, ".eml");
+
+ let number = 2;
+ while (filenames.includes(name)) {
+ // should be unlikely
+ name = GenerateValidFilename(nameBase + "-" + number, ".eml");
+ number++;
+ }
+ filenames.push(name);
+ }
+
+ if (uris.length == 1) {
+ messenger.saveAs(uris[0], true, null, filenames[0]);
+ } else {
+ messenger.saveMessages(filenames, uris);
+ }
+}
+
+function GenerateFilenameFromMsgHdr(msgHdr) {
+ function MakeIS8601ODateString(date) {
+ function pad(n) {
+ return n < 10 ? "0" + n : n;
+ }
+ return (
+ date.getFullYear() +
+ "-" +
+ pad(date.getMonth() + 1) +
+ "-" +
+ pad(date.getDate()) +
+ " " +
+ pad(date.getHours()) +
+ "" +
+ pad(date.getMinutes()) +
+ ""
+ );
+ }
+
+ let filename;
+ if (msgHdr.flags & Ci.nsMsgMessageFlags.HasRe) {
+ filename = msgHdr.mime2DecodedSubject
+ ? "Re: " + msgHdr.mime2DecodedSubject
+ : "Re: ";
+ } else {
+ filename = msgHdr.mime2DecodedSubject;
+ }
+
+ filename += " - ";
+ filename += msgHdr.mime2DecodedAuthor + " - ";
+ filename += MakeIS8601ODateString(new Date(msgHdr.date / 1000));
+
+ return filename;
+}
+
+function saveAsUrlListener(aUri, aIdentity) {
+ this.uri = aUri;
+ this.identity = aIdentity;
+}
+
+saveAsUrlListener.prototype = {
+ OnStartRunningUrl(aUrl) {},
+ OnStopRunningUrl(aUrl, aExitCode) {
+ messenger.saveAs(this.uri, false, this.identity, null);
+ },
+};
+
+function SaveAsTemplate(uri) {
+ if (uri) {
+ let hdr = messenger.msgHdrFromURI(uri);
+ let [identity] = MailUtils.getIdentityForHeader(
+ hdr,
+ Ci.nsIMsgCompType.Template
+ );
+ let templates = MailUtils.getOrCreateFolder(identity.stationeryFolder);
+ if (!templates.parent) {
+ templates.setFlag(Ci.nsMsgFolderFlags.Templates);
+ let isAsync = templates.server.protocolInfo.foldersCreatedAsync;
+ templates.createStorageIfMissing(new saveAsUrlListener(uri, identity));
+ if (isAsync) {
+ return;
+ }
+ }
+ messenger.saveAs(uri, false, identity, null);
+ }
+}
+
+function viewEncryptedPart(message) {
+ let url;
+ try {
+ url = MailServices.mailSession.ConvertMsgURIToMsgURL(message, msgWindow);
+ } catch (e) {
+ console.debug(e);
+ // Couldn't get mail session
+ return false;
+ }
+
+ // Strip out the message-display parameter to ensure that attached emails
+ // display the message source, not the processed HTML.
+ url = url.replace(/type=application\/x-message-display&/, "");
+
+ /**
+ * Save the given string to a file, then open it as an .eml file.
+ *
+ * @param {string} data - The message data.
+ */
+ let msgOpenMessageFromString = function (data) {
+ let tempFile = Services.dirsvc.get("TmpD", Ci.nsIFile);
+ tempFile.append("subPart.eml");
+ tempFile.createUnique(0, 0o600);
+
+ let outputStream = Cc[
+ "@mozilla.org/network/file-output-stream;1"
+ ].createInstance(Ci.nsIFileOutputStream);
+ outputStream.init(tempFile, 2, 0x200, false); // open as "write only"
+ outputStream.write(data, data.length);
+ outputStream.close();
+
+ // Delete file on exit, because Windows locks the file
+ let extAppLauncher = Cc[
+ "@mozilla.org/uriloader/external-helper-app-service;1"
+ ].getService(Ci.nsPIExternalAppLauncher);
+ extAppLauncher.deleteTemporaryFileOnExit(tempFile);
+
+ let url = Services.io
+ .getProtocolHandler("file")
+ .QueryInterface(Ci.nsIFileProtocolHandler)
+ .newFileURI(tempFile);
+
+ MailUtils.openEMLFile(window, tempFile, url);
+ };
+
+ function recursiveEmitEncryptedParts(mimeTree) {
+ for (let part of mimeTree.subParts) {
+ const ct = part.headers.contentType.type;
+ if (ct == "multipart/encrypted") {
+ const boundary = part.headers.contentType.get("boundary");
+ let full = `${part.headers.rawHeaderText}\n\n`;
+ for (let subPart of part.subParts) {
+ full += `${boundary}\n${subPart.headers.rawHeaderText}\n\n${subPart.body}\n`;
+ }
+ full += `${boundary}--\n`;
+ msgOpenMessageFromString(full);
+ continue;
+ }
+ recursiveEmitEncryptedParts(part);
+ }
+ }
+
+ EnigmailMime.getMimeTreeFromUrl(url, true, recursiveEmitEncryptedParts);
+ return true;
+}
+
+function viewEncryptedParts(messages) {
+ if (!messages?.length) {
+ dump("viewEncryptedParts(): No messages selected.\n");
+ return false;
+ }
+
+ if (messages.length > 1) {
+ dump("viewEncryptedParts(): Too many messages selected.\n");
+ return false;
+ }
+
+ return viewEncryptedPart(messages[0]);
+}
diff --git a/comm/mail/base/content/mailCommon.js b/comm/mail/base/content/mailCommon.js
new file mode 100644
index 0000000000..b74a3d8fba
--- /dev/null
+++ b/comm/mail/base/content/mailCommon.js
@@ -0,0 +1,1126 @@
+/* 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/. */
+
+// mailContext.js
+/* globals mailContextMenu */
+
+// msgViewNavigation.js
+/* globals CrossFolderNavigation */
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ TreeSelection: "chrome://messenger/content/tree-selection.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ ConversationOpener: "resource:///modules/ConversationOpener.jsm",
+ DBViewWrapper: "resource:///modules/DBViewWrapper.jsm",
+ EnigmailPersistentCrypto:
+ "chrome://openpgp/content/modules/persistentCrypto.jsm",
+ EnigmailURIs: "chrome://openpgp/content/modules/uris.jsm",
+ MailUtils: "resource:///modules/MailUtils.jsm",
+ MessageArchiver: "resource:///modules/MessageArchiver.jsm",
+ VirtualFolderHelper: "resource:///modules/VirtualFolderWrapper.jsm",
+});
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "gEncryptedURIService",
+ "@mozilla.org/messenger-smime/smime-encrypted-uris-service;1",
+ "nsIEncryptedSMIMEURIsService"
+);
+
+const nsMsgViewIndex_None = 0xffffffff;
+const nsMsgKey_None = 0xffffffff;
+
+var gDBView, gFolder, gViewWrapper;
+
+var commandController = {
+ _composeCommands: {
+ cmd_editDraftMsg: Ci.nsIMsgCompType.Draft,
+ cmd_newMsgFromTemplate: Ci.nsIMsgCompType.Template,
+ cmd_editTemplateMsg: Ci.nsIMsgCompType.EditTemplate,
+ cmd_newMessage: Ci.nsIMsgCompType.New,
+ cmd_replyGroup: Ci.nsIMsgCompType.ReplyToGroup,
+ cmd_replySender: Ci.nsIMsgCompType.ReplyToSender,
+ cmd_replyall: Ci.nsIMsgCompType.ReplyAll,
+ cmd_replylist: Ci.nsIMsgCompType.ReplyToList,
+ cmd_forwardInline: Ci.nsIMsgCompType.ForwardInline,
+ cmd_forwardAttachment: Ci.nsIMsgCompType.ForwardAsAttachment,
+ cmd_redirect: Ci.nsIMsgCompType.Redirect,
+ cmd_editAsNew: Ci.nsIMsgCompType.EditAsNew,
+ },
+ _navigationCommands: {
+ cmd_goForward: Ci.nsMsgNavigationType.forward,
+ cmd_goBack: Ci.nsMsgNavigationType.back,
+ cmd_nextUnreadMsg: Ci.nsMsgNavigationType.nextUnreadMessage,
+ cmd_nextUnreadThread: Ci.nsMsgNavigationType.nextUnreadThread,
+ cmd_nextMsg: Ci.nsMsgNavigationType.nextMessage,
+ cmd_nextFlaggedMsg: Ci.nsMsgNavigationType.nextFlagged,
+ cmd_previousMsg: Ci.nsMsgNavigationType.previousMessage,
+ cmd_previousUnreadMsg: Ci.nsMsgNavigationType.previousUnreadMessage,
+ cmd_previousFlaggedMsg: Ci.nsMsgNavigationType.previousFlagged,
+ },
+ _viewCommands: {
+ cmd_toggleRead: Ci.nsMsgViewCommandType.toggleMessageRead,
+ cmd_markAsRead: Ci.nsMsgViewCommandType.markMessagesRead,
+ cmd_markAsUnread: Ci.nsMsgViewCommandType.markMessagesUnread,
+ cmd_markThreadAsRead: Ci.nsMsgViewCommandType.markThreadRead,
+ cmd_markAsNotJunk: Ci.nsMsgViewCommandType.unjunk,
+ cmd_watchThread: Ci.nsMsgViewCommandType.toggleThreadWatched,
+ },
+ _callbackCommands: {
+ cmd_cancel() {
+ gFolder
+ .QueryInterface(Ci.nsIMsgNewsFolder)
+ .cancelMessage(gDBView.hdrForFirstSelectedMessage, top.msgWindow);
+ },
+ cmd_openConversation() {
+ new ConversationOpener(window).openConversationForMessages(
+ gDBView.getSelectedMsgHdrs()
+ );
+ },
+ cmd_reply(event) {
+ if (gFolder?.flags & Ci.nsMsgFolderFlags.Newsgroup) {
+ commandController.doCommand("cmd_replyGroup", event);
+ } else {
+ commandController.doCommand("cmd_replySender", event);
+ }
+ },
+ cmd_forward(event) {
+ if (Services.prefs.getIntPref("mail.forward_message_mode", 0) == 0) {
+ commandController.doCommand("cmd_forwardAttachment", event);
+ } else {
+ commandController.doCommand("cmd_forwardInline", event);
+ }
+ },
+ cmd_openMessage(event) {
+ MailUtils.displayMessages(
+ gDBView.getSelectedMsgHdrs(),
+ gViewWrapper,
+ top.document.getElementById("tabmail"),
+ event?.type == "auxclick" && !event?.shiftKey
+ );
+ },
+ cmd_tag() {
+ // Does nothing, just here to enable/disable the tags sub-menu.
+ },
+ cmd_tag1: mailContextMenu._toggleMessageTagKey.bind(mailContextMenu, 1),
+ cmd_tag2: mailContextMenu._toggleMessageTagKey.bind(mailContextMenu, 2),
+ cmd_tag3: mailContextMenu._toggleMessageTagKey.bind(mailContextMenu, 3),
+ cmd_tag4: mailContextMenu._toggleMessageTagKey.bind(mailContextMenu, 4),
+ cmd_tag5: mailContextMenu._toggleMessageTagKey.bind(mailContextMenu, 5),
+ cmd_tag6: mailContextMenu._toggleMessageTagKey.bind(mailContextMenu, 6),
+ cmd_tag7: mailContextMenu._toggleMessageTagKey.bind(mailContextMenu, 7),
+ cmd_tag8: mailContextMenu._toggleMessageTagKey.bind(mailContextMenu, 8),
+ cmd_tag9: mailContextMenu._toggleMessageTagKey.bind(mailContextMenu, 9),
+ cmd_addTag() {
+ mailContextMenu.addTag();
+ },
+ cmd_manageTags() {
+ window.browsingContext.topChromeWindow.openOptionsDialog(
+ "paneGeneral",
+ "tagsCategory"
+ );
+ },
+ cmd_removeTags() {
+ mailContextMenu.removeAllMessageTags();
+ },
+ cmd_toggleTag(event) {
+ mailContextMenu._toggleMessageTag(
+ event.target.value,
+ event.target.getAttribute("checked") == "true"
+ );
+ },
+ cmd_markReadByDate() {
+ window.browsingContext.topChromeWindow.openDialog(
+ "chrome://messenger/content/markByDate.xhtml",
+ "",
+ "chrome,modal,titlebar,centerscreen",
+ gFolder
+ );
+ },
+ cmd_markAsFlagged() {
+ gViewWrapper.dbView.doCommand(
+ gDBView.hdrForFirstSelectedMessage.isFlagged
+ ? Ci.nsMsgViewCommandType.unflagMessages
+ : Ci.nsMsgViewCommandType.flagMessages
+ );
+ },
+ cmd_markAsJunk() {
+ if (
+ Services.prefs.getBoolPref("mailnews.ui.junk.manualMarkAsJunkMarksRead")
+ ) {
+ gViewWrapper.dbView.doCommand(Ci.nsMsgViewCommandType.markMessagesRead);
+ }
+ gViewWrapper.dbView.doCommand(Ci.nsMsgViewCommandType.junk);
+ },
+ cmd_markAllRead() {
+ if (gFolder.flags & Ci.nsMsgFolderFlags.Virtual) {
+ top.MsgMarkAllRead(
+ VirtualFolderHelper.wrapVirtualFolder(gFolder).searchFolders
+ );
+ } else {
+ top.MsgMarkAllRead([gFolder]);
+ }
+ },
+ /**
+ * Moves the selected messages to the destination folder.
+ *
+ * @param {nsIMsgFolder} destFolder - the destination folder
+ */
+ cmd_moveMessage(destFolder) {
+ if (parent.location.href == "about:3pane") {
+ // If we're in about:message inside about:3pane, it's the parent
+ // window that needs to advance to the next message.
+ parent.commandController.doCommand("cmd_moveMessage", destFolder);
+ return;
+ }
+ dbViewWrapperListener.threadPaneCommandUpdater.updateNextMessageAfterDelete();
+ gViewWrapper.dbView.doCommandWithFolder(
+ Ci.nsMsgViewCommandType.moveMessages,
+ destFolder
+ );
+ Services.prefs.setStringPref(
+ "mail.last_msg_movecopy_target_uri",
+ destFolder.URI
+ );
+ Services.prefs.setBoolPref("mail.last_msg_movecopy_was_move", true);
+ },
+ async cmd_copyDecryptedTo(destFolder) {
+ let msgHdrs = gDBView.getSelectedMsgHdrs();
+ if (!msgHdrs || msgHdrs.length === 0) {
+ return;
+ }
+
+ let total = msgHdrs.length;
+ let failures = 0;
+ for (let msgHdr of msgHdrs) {
+ await EnigmailPersistentCrypto.cryptMessage(
+ msgHdr,
+ destFolder.URI,
+ false, // not moving
+ false
+ ).catch(err => {
+ failures++;
+ });
+ }
+
+ if (failures) {
+ let info = await document.l10n.formatValue(
+ "decrypt-and-copy-failures-multiple",
+ {
+ failures,
+ total,
+ }
+ );
+ Services.prompt.alert(null, document.title, info);
+ }
+ },
+ /**
+ * Copies the selected messages to the destination folder.
+ *
+ * @param {nsIMsgFolder} destFolder - the destination folder
+ */
+ cmd_copyMessage(destFolder) {
+ if (window.gMessageURI?.startsWith("file:")) {
+ let file = Services.io
+ .newURI(window.gMessageURI)
+ .QueryInterface(Ci.nsIFileURL).file;
+ MailServices.copy.copyFileMessage(
+ file,
+ destFolder,
+ null,
+ false,
+ Ci.nsMsgMessageFlags.Read,
+ "",
+ null,
+ top.msgWindow
+ );
+ } else {
+ gViewWrapper.dbView.doCommandWithFolder(
+ Ci.nsMsgViewCommandType.copyMessages,
+ destFolder
+ );
+ }
+ Services.prefs.setStringPref(
+ "mail.last_msg_movecopy_target_uri",
+ destFolder.URI
+ );
+ Services.prefs.setBoolPref("mail.last_msg_movecopy_was_move", false);
+ },
+ cmd_archive() {
+ if (parent.location.href == "about:3pane") {
+ // If we're in about:message inside about:3pane, it's the parent
+ // window that needs to advance to the next message.
+ parent.commandController.doCommand("cmd_archive");
+ return;
+ }
+ dbViewWrapperListener.threadPaneCommandUpdater.updateNextMessageAfterDelete();
+ let archiver = new MessageArchiver();
+ // The instance of nsITransactionManager to use here is tied to msgWindow. Set
+ // this property so the operation can be undone if requested.
+ archiver.msgWindow = top.msgWindow;
+ // Archive the selected message(s).
+ archiver.archiveMessages(gViewWrapper.dbView.getSelectedMsgHdrs());
+ },
+ cmd_moveToFolderAgain() {
+ if (parent.location.href == "about:3pane") {
+ // If we're in about:message inside about:3pane, it's the parent
+ // window that needs to advance to the next message.
+ parent.commandController.doCommand("cmd_moveToFolderAgain");
+ return;
+ }
+ let folder = MailUtils.getOrCreateFolder(
+ Services.prefs.getStringPref("mail.last_msg_movecopy_target_uri")
+ );
+ if (Services.prefs.getBoolPref("mail.last_msg_movecopy_was_move")) {
+ dbViewWrapperListener.threadPaneCommandUpdater.updateNextMessageAfterDelete();
+ commandController.doCommand("cmd_moveMessage", folder);
+ } else {
+ commandController.doCommand("cmd_copyMessage", folder);
+ }
+ },
+ cmd_deleteMessage() {
+ if (parent.location.href == "about:3pane") {
+ // If we're in about:message inside about:3pane, it's the parent
+ // window that needs to advance to the next message.
+ parent.commandController.doCommand("cmd_deleteMessage");
+ return;
+ }
+ dbViewWrapperListener.threadPaneCommandUpdater.updateNextMessageAfterDelete();
+ gViewWrapper.dbView.doCommand(Ci.nsMsgViewCommandType.deleteMsg);
+ },
+ cmd_shiftDeleteMessage() {
+ if (parent.location.href == "about:3pane") {
+ // If we're in about:message inside about:3pane, it's the parent
+ // window that needs to advance to the next message.
+ parent.commandController.doCommand("cmd_shiftDeleteMessage");
+ return;
+ }
+ dbViewWrapperListener.threadPaneCommandUpdater.updateNextMessageAfterDelete();
+ gViewWrapper.dbView.doCommand(Ci.nsMsgViewCommandType.deleteNoTrash);
+ },
+ cmd_createFilterFromMenu() {
+ let msgHdr = gDBView.hdrForFirstSelectedMessage;
+ let emailAddress =
+ MailServices.headerParser.extractHeaderAddressMailboxes(msgHdr.author);
+ if (emailAddress) {
+ top.MsgFilters(emailAddress, msgHdr.folder);
+ }
+ },
+ cmd_viewPageSource() {
+ let uris = window.gMessageURI
+ ? [window.gMessageURI]
+ : gDBView.getURIsForSelection();
+ for (let uri of uris) {
+ // Now, we need to get a URL from a URI
+ let url = MailServices.mailSession.ConvertMsgURIToMsgURL(
+ uri,
+ top.msgWindow
+ );
+
+ // Strip out the message-display parameter to ensure that attached emails
+ // display the message source, not the processed HTML.
+ url = url.replace(/type=application\/x-message-display&/, "");
+ window.openDialog(
+ "chrome://messenger/content/viewSource.xhtml",
+ "_blank",
+ "all,dialog=no",
+ { URL: url }
+ );
+ }
+ },
+ cmd_saveAsFile() {
+ let uris = window.gMessageURI
+ ? [window.gMessageURI]
+ : gDBView.getURIsForSelection();
+ top.SaveAsFile(uris);
+ },
+ cmd_saveAsTemplate() {
+ top.SaveAsTemplate(gDBView.getURIsForSelection()[0]);
+ },
+ cmd_applyFilters() {
+ let curFilterList = gFolder.getFilterList(top.msgWindow);
+ // Create a new filter list and copy over the enabled filters to it.
+ // We do this instead of having the filter after the fact code ignore
+ // disabled filters because the Filter Dialog filter after the fact
+ // code would have to clone filters to allow disabled filters to run,
+ // and we don't support cloning filters currently.
+ let tempFilterList = MailServices.filters.getTempFilterList(gFolder);
+ let numFilters = curFilterList.filterCount;
+ // Make sure the temp filter list uses the same log stream.
+ tempFilterList.loggingEnabled = curFilterList.loggingEnabled;
+ tempFilterList.logStream = curFilterList.logStream;
+ let newFilterIndex = 0;
+ for (let i = 0; i < numFilters; i++) {
+ let curFilter = curFilterList.getFilterAt(i);
+ // Only add enabled, UI visible filters that are in the manual context.
+ if (
+ curFilter.enabled &&
+ !curFilter.temporary &&
+ curFilter.filterType & Ci.nsMsgFilterType.Manual
+ ) {
+ tempFilterList.insertFilterAt(newFilterIndex, curFilter);
+ newFilterIndex++;
+ }
+ }
+ MailServices.filters.applyFiltersToFolders(
+ tempFilterList,
+ [gFolder],
+ top.msgWindow
+ );
+ },
+ cmd_applyFiltersToSelection() {
+ let selectedMessages = gDBView.getSelectedMsgHdrs();
+ if (selectedMessages.length) {
+ MailServices.filters.applyFilters(
+ Ci.nsMsgFilterType.Manual,
+ selectedMessages,
+ gFolder,
+ top.msgWindow
+ );
+ }
+ },
+ cmd_space(event) {
+ let messagePaneBrowser;
+ if (window.messageBrowser) {
+ messagePaneBrowser =
+ window.messageBrowser.contentWindow.getMessagePaneBrowser();
+ } else {
+ messagePaneBrowser = window.getMessagePaneBrowser();
+ }
+ let contentWindow = messagePaneBrowser.contentWindow;
+
+ if (event?.shiftKey) {
+ // If at the start of the message, go to the previous one.
+ if (contentWindow?.scrollY > 0) {
+ contentWindow.scrollByPages(-1);
+ } else if (Services.prefs.getBoolPref("mail.advance_on_spacebar")) {
+ top.goDoCommand("cmd_previousUnreadMsg");
+ }
+ } else if (
+ Math.ceil(contentWindow?.scrollY) < contentWindow?.scrollMaxY
+ ) {
+ // If at the end of the message, go to the next one.
+ contentWindow.scrollByPages(1);
+ } else if (Services.prefs.getBoolPref("mail.advance_on_spacebar")) {
+ top.goDoCommand("cmd_nextUnreadMsg");
+ }
+ },
+ cmd_searchMessages(folder = gFolder) {
+ // We always open a new search dialog for each search command.
+ top.openDialog(
+ "chrome://messenger/content/SearchDialog.xhtml",
+ "_blank",
+ "chrome,resizable,status,centerscreen,dialog=no",
+ { folder }
+ );
+ },
+ },
+ _isCallbackEnabled: {},
+
+ registerCallback(name, callback, isEnabled = true) {
+ this._callbackCommands[name] = callback;
+ this._isCallbackEnabled[name] = isEnabled;
+ },
+
+ supportsCommand(command) {
+ return (
+ command in this._composeCommands ||
+ command in this._navigationCommands ||
+ command in this._viewCommands ||
+ command in this._callbackCommands
+ );
+ },
+ // eslint-disable-next-line complexity
+ isCommandEnabled(command) {
+ let type = typeof this._isCallbackEnabled[command];
+ if (type == "function") {
+ return this._isCallbackEnabled[command]();
+ } else if (type == "boolean") {
+ return this._isCallbackEnabled[command];
+ }
+
+ const hasIdentities = MailServices.accounts.allIdentities.length;
+ switch (command) {
+ case "cmd_newMessage":
+ return hasIdentities;
+ case "cmd_searchMessages":
+ // TODO: This shouldn't be here, or should return false if there are no accounts.
+ return true;
+ case "cmd_space":
+ case "cmd_manageTags":
+ return true;
+ }
+
+ if (!gViewWrapper?.dbView) {
+ return false;
+ }
+
+ let isDummyMessage = !gViewWrapper.isSynthetic && !gFolder;
+
+ if (["cmd_goBack", "cmd_goForward"].includes(command)) {
+ let activeMessageHistory = (
+ window.messageBrowser?.contentWindow ?? window
+ ).messageHistory;
+ let relPos = command === "cmd_goBack" ? -1 : 1;
+ if (relPos === -1 && activeMessageHistory.canPop(0)) {
+ return !isDummyMessage;
+ }
+ return !isDummyMessage && activeMessageHistory.canPop(relPos);
+ }
+
+ if (command in this._navigationCommands) {
+ return !isDummyMessage;
+ }
+
+ let numSelectedMessages = isDummyMessage ? 1 : gDBView.numSelected;
+
+ // Evaluate these properties only if needed, not once for each command.
+ let folder = () => {
+ if (gFolder) {
+ return gFolder;
+ }
+ if (gDBView.numSelected >= 1) {
+ return gDBView.hdrForFirstSelectedMessage?.folder;
+ }
+ return null;
+ };
+ let isNewsgroup = () =>
+ folder()?.isSpecialFolder(Ci.nsMsgFolderFlags.Newsgroup, true);
+ let canMove = () =>
+ numSelectedMessages >= 1 &&
+ (folder()?.canDeleteMessages || gViewWrapper.isSynthetic);
+
+ switch (command) {
+ case "cmd_cancel":
+ if (numSelectedMessages == 1 && isNewsgroup()) {
+ // Ensure author of message matches own identity
+ let author = gDBView.hdrForFirstSelectedMessage.mime2DecodedAuthor;
+ return MailServices.accounts
+ .getIdentitiesForServer(folder().server)
+ .some(id => id.fullAddress == author);
+ }
+ return false;
+ case "cmd_openConversation":
+ return (
+ // This (instead of numSelectedMessages) is necessary to be able to
+ // also open a collapsed thread in conversation.
+ gDBView.selection.count == 1 &&
+ ConversationOpener.isMessageIndexed(
+ gDBView.hdrForFirstSelectedMessage
+ )
+ );
+ case "cmd_replylist":
+ if (
+ !mailContextMenu.selectionIsOverridden &&
+ hasIdentities &&
+ numSelectedMessages == 1
+ ) {
+ return (window.messageBrowser?.contentWindow ?? window)
+ .currentHeaderData?.["list-post"];
+ }
+ return false;
+ case "cmd_viewPageSource":
+ case "cmd_saveAsTemplate":
+ return numSelectedMessages == 1;
+ case "cmd_reply":
+ case "cmd_replySender":
+ case "cmd_replyall":
+ case "cmd_forward":
+ case "cmd_forwardInline":
+ case "cmd_forwardAttachment":
+ case "cmd_redirect":
+ case "cmd_editAsNew":
+ return (
+ hasIdentities &&
+ (numSelectedMessages == 1 ||
+ (numSelectedMessages > 1 &&
+ // Exclude collapsed threads.
+ numSelectedMessages == gDBView.selection.count))
+ );
+ case "cmd_copyMessage":
+ case "cmd_saveAsFile":
+ return numSelectedMessages >= 1;
+ case "cmd_openMessage":
+ return (
+ (location.href == "about:3pane" ||
+ parent.location.href == "about:3pane") &&
+ numSelectedMessages >= 1 &&
+ !isDummyMessage
+ );
+ case "cmd_tag":
+ case "cmd_tag1":
+ case "cmd_tag2":
+ case "cmd_tag3":
+ case "cmd_tag4":
+ case "cmd_tag5":
+ case "cmd_tag6":
+ case "cmd_tag7":
+ case "cmd_tag8":
+ case "cmd_tag9":
+ case "cmd_addTag":
+ case "cmd_removeTags":
+ case "cmd_toggleTag":
+ case "cmd_toggleRead":
+ case "cmd_markReadByDate":
+ case "cmd_markAsFlagged":
+ case "cmd_applyFiltersToSelection":
+ return numSelectedMessages >= 1 && !isDummyMessage;
+ case "cmd_copyDecryptedTo": {
+ let showDecrypt = numSelectedMessages > 1;
+ if (numSelectedMessages == 1 && !isDummyMessage) {
+ let msgURI = gDBView.URIForFirstSelectedMessage;
+ if (msgURI) {
+ showDecrypt =
+ EnigmailURIs.isEncryptedUri(msgURI) ||
+ gEncryptedURIService.isEncrypted(msgURI);
+ }
+ }
+ return showDecrypt;
+ }
+ case "cmd_editDraftMsg":
+ return (
+ numSelectedMessages >= 1 &&
+ folder()?.isSpecialFolder(Ci.nsMsgFolderFlags.Drafts, true)
+ );
+ case "cmd_newMsgFromTemplate":
+ case "cmd_editTemplateMsg":
+ return (
+ numSelectedMessages >= 1 &&
+ folder()?.isSpecialFolder(Ci.nsMsgFolderFlags.Templates, true)
+ );
+ case "cmd_replyGroup":
+ return isNewsgroup();
+ case "cmd_markAsRead":
+ return (
+ numSelectedMessages >= 1 &&
+ !isDummyMessage &&
+ gViewWrapper.dbView.getSelectedMsgHdrs().some(msg => !msg.isRead)
+ );
+ case "cmd_markAsUnread":
+ return (
+ numSelectedMessages >= 1 &&
+ !isDummyMessage &&
+ gViewWrapper.dbView.getSelectedMsgHdrs().some(msg => msg.isRead)
+ );
+ case "cmd_markThreadAsRead": {
+ if (numSelectedMessages == 0 || isDummyMessage) {
+ return false;
+ }
+ let sel = gViewWrapper.dbView.selection;
+ for (let i = 0; i < sel.getRangeCount(); i++) {
+ let start = {};
+ let end = {};
+ sel.getRangeAt(i, start, end);
+ for (let j = start.value; j <= end.value; j++) {
+ if (
+ gViewWrapper.dbView.getThreadContainingIndex(j)
+ .numUnreadChildren > 0
+ ) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+ case "cmd_markAllRead":
+ return gDBView?.msgFolder?.getNumUnread(false) > 0;
+ case "cmd_markAsJunk":
+ case "cmd_markAsNotJunk":
+ return this._getViewCommandStatus(Ci.nsMsgViewCommandType.junk);
+ case "cmd_archive":
+ return (
+ !isDummyMessage &&
+ MessageArchiver.canArchive(
+ gDBView.getSelectedMsgHdrs(),
+ gViewWrapper.isSingleFolder
+ )
+ );
+ case "cmd_moveMessage": {
+ return canMove();
+ }
+ case "cmd_moveToFolderAgain": {
+ // Disable "Move to <folder> Again" for news and other read only
+ // folders since we can't really move messages from there - only copy.
+ let canMoveAgain = numSelectedMessages >= 1;
+ if (Services.prefs.getBoolPref("mail.last_msg_movecopy_was_move")) {
+ canMoveAgain = canMove() && !isNewsgroup();
+ }
+ if (canMoveAgain) {
+ let targetURI = Services.prefs.getStringPref(
+ "mail.last_msg_movecopy_target_uri"
+ );
+ canMoveAgain = targetURI && MailUtils.getExistingFolder(targetURI);
+ }
+ return !!canMoveAgain;
+ }
+ case "cmd_deleteMessage":
+ return canMove();
+ case "cmd_shiftDeleteMessage":
+ return this._getViewCommandStatus(
+ Ci.nsMsgViewCommandType.deleteNoTrash
+ );
+ case "cmd_createFilterFromMenu":
+ return (
+ numSelectedMessages == 1 &&
+ !isDummyMessage &&
+ folder()?.server.canHaveFilters
+ );
+ case "cmd_watchThread": {
+ let enabledObj = {};
+ let checkStatusObj = {};
+ gViewWrapper.dbView.getCommandStatus(
+ Ci.nsMsgViewCommandType.toggleThreadWatched,
+ enabledObj,
+ checkStatusObj
+ );
+ return enabledObj.value;
+ }
+ case "cmd_applyFilters": {
+ return this._getViewCommandStatus(Ci.nsMsgViewCommandType.applyFilters);
+ }
+ }
+
+ return false;
+ },
+ doCommand(command, ...args) {
+ if (!this.isCommandEnabled(command)) {
+ return;
+ }
+
+ if (command in this._composeCommands) {
+ this._composeMsgByType(this._composeCommands[command], ...args);
+ return;
+ }
+
+ if (command in this._navigationCommands) {
+ if (parent.location.href == "about:3pane") {
+ // If we're in about:message inside about:3pane, it's the parent
+ // window that needs to advance to the next message.
+ parent.commandController.doCommand(command, ...args);
+ } else {
+ this._navigate(this._navigationCommands[command]);
+ }
+ return;
+ }
+
+ if (command in this._viewCommands) {
+ if (command.endsWith("Read") || command.endsWith("Unread")) {
+ if (window.ClearPendingReadTimer) {
+ window.ClearPendingReadTimer();
+ } else {
+ window.messageBrowser.contentWindow.ClearPendingReadTimer();
+ }
+ }
+ gViewWrapper.dbView.doCommand(this._viewCommands[command]);
+ return;
+ }
+
+ if (command in this._callbackCommands) {
+ this._callbackCommands[command](...args);
+ }
+ },
+
+ _getViewCommandStatus(commandType) {
+ if (!gViewWrapper?.dbView) {
+ return false;
+ }
+
+ let enabledObj = {};
+ let checkStatusObj = {};
+ gViewWrapper.dbView.getCommandStatus(
+ commandType,
+ enabledObj,
+ checkStatusObj
+ );
+ return enabledObj.value;
+ },
+
+ /**
+ * Calls the ComposeMessage function with the desired type, and proper default
+ * based on the event that fired it.
+ *
+ * @param composeType the nsIMsgCompType to pass to the function
+ * @param event (optional) the event that triggered the call
+ */
+ _composeMsgByType(composeType, event) {
+ // If we're the hidden window, then we're not going to have a gFolderDisplay
+ // to work out existing folders, so just use null.
+ let msgFolder = gFolder;
+ let msgUris =
+ gFolder || gViewWrapper.isSynthetic
+ ? gDBView?.getURIsForSelection()
+ : [window.gMessageURI];
+
+ let messagePaneBrowser;
+ let autodetectCharset;
+ let selection;
+ if (!mailContextMenu.selectionIsOverridden) {
+ if (window.messageBrowser) {
+ if (!window.messageBrowser.hidden) {
+ messagePaneBrowser =
+ window.messageBrowser.contentWindow.getMessagePaneBrowser();
+ autodetectCharset =
+ window.messageBrowser.contentWindow.autodetectCharset;
+ }
+ } else {
+ messagePaneBrowser = window.getMessagePaneBrowser();
+ autodetectCharset = window.autodetectCharset;
+ }
+ selection = messagePaneBrowser?.contentWindow?.getSelection();
+ }
+
+ if (event && event.shiftKey) {
+ window.browsingContext.topChromeWindow.ComposeMessage(
+ composeType,
+ Ci.nsIMsgCompFormat.OppositeOfDefault,
+ msgFolder,
+ msgUris,
+ selection,
+ autodetectCharset
+ );
+ } else {
+ window.browsingContext.topChromeWindow.ComposeMessage(
+ composeType,
+ Ci.nsIMsgCompFormat.Default,
+ msgFolder,
+ msgUris,
+ selection,
+ autodetectCharset
+ );
+ }
+ },
+
+ _navigate(navigationType) {
+ if (
+ [Ci.nsMsgNavigationType.back, Ci.nsMsgNavigationType.forward].includes(
+ navigationType
+ )
+ ) {
+ const { messageHistory } = window.messageBrowser?.contentWindow ?? window;
+ const noCurrentMessage = messageHistory.canPop(0);
+ let relativePosition = -1;
+ if (navigationType === Ci.nsMsgNavigationType.forward) {
+ relativePosition = 1;
+ } else if (noCurrentMessage) {
+ relativePosition = 0;
+ }
+ let newMessageURI = messageHistory.pop(relativePosition)?.messageURI;
+ if (!newMessageURI) {
+ return;
+ }
+ let msgHdr =
+ MailServices.messageServiceFromURI(newMessageURI).messageURIToMsgHdr(
+ newMessageURI
+ );
+ if (msgHdr) {
+ if (window.threadPane) {
+ window.selectMessage(msgHdr);
+ } else {
+ window.displayMessage(newMessageURI);
+ }
+ }
+ return;
+ }
+
+ let resultKey = { value: nsMsgKey_None };
+ let resultIndex = { value: nsMsgViewIndex_None };
+ let threadIndex = {};
+
+ let expandCurrentThread = false;
+ let currentIndex = window.threadTree ? window.threadTree.currentIndex : -1;
+
+ // 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 (
+ navigationType == Ci.nsMsgNavigationType.nextUnreadMessage &&
+ currentIndex != -1 &&
+ gViewWrapper.isCollapsedThreadAtIndex(currentIndex) &&
+ !(
+ gViewWrapper.dbView.getFlagsAt(currentIndex) & Ci.nsMsgMessageFlags.Read
+ )
+ ) {
+ expandCurrentThread = true;
+ resultIndex.value = currentIndex;
+ resultKey.value = gViewWrapper.dbView.getKeyAt(currentIndex);
+ } else {
+ gViewWrapper.dbView.viewNavigate(
+ navigationType,
+ resultKey,
+ resultIndex,
+ threadIndex,
+ true
+ );
+ if (resultIndex.value == nsMsgViewIndex_None) {
+ if (CrossFolderNavigation(navigationType)) {
+ this._navigate(navigationType);
+ }
+ return;
+ }
+ if (resultKey.value == nsMsgKey_None) {
+ return;
+ }
+ }
+
+ if (window.threadTree) {
+ if (
+ gDBView.selection.count == 1 &&
+ window.threadTree.selectedIndex == resultIndex.value &&
+ !expandCurrentThread
+ ) {
+ return;
+ }
+
+ window.threadTree.expandRowAtIndex(resultIndex.value);
+ // Do an instant scroll before setting the index to avoid animation.
+ window.threadTree.scrollToIndex(resultIndex.value, true);
+ window.threadTree.selectedIndex = resultIndex.value;
+ // Focus the thread tree, unless the message pane has focus.
+ if (
+ Services.focus.focusedWindow !=
+ window.messageBrowser.contentWindow?.getMessagePaneBrowser()
+ .contentWindow
+ ) {
+ // There's something strange going on here – calling `focus`
+ // immediately can cause the scroll position to return to where it was
+ // before changing folders, which starts a cascade of "scroll" events
+ // until the tree scrolls to the top.
+ setTimeout(() => window.threadTree.table.body.focus());
+ }
+ } else {
+ if (window.gMessage.messageKey == resultKey.value) {
+ return;
+ }
+
+ gViewWrapper.dbView.selection.select(resultIndex.value);
+ window.displayMessage(
+ gViewWrapper.dbView.URIForFirstSelectedMessage,
+ gViewWrapper
+ );
+ }
+ },
+};
+// Add the controller to this window's controllers, so that built-in commands
+// such as cmd_selectAll run our code instead of the default code.
+window.controllers.insertControllerAt(0, commandController);
+
+var dbViewWrapperListener = {
+ _nextViewIndexAfterDelete: null,
+
+ messenger: null,
+ msgWindow: top.msgWindow,
+ threadPaneCommandUpdater: {
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIMsgDBViewCommandUpdater",
+ "nsISupportsWeakReference",
+ ]),
+ updateCommandStatus() {},
+ displayMessageChanged(folder, subject, keywords) {},
+ updateNextMessageAfterDelete() {
+ dbViewWrapperListener._nextViewIndexAfterDelete = gDBView
+ ? gDBView.msgToSelectAfterDelete
+ : null;
+ },
+ summarizeSelection() {
+ return true;
+ },
+ selectedMessageRemoved() {
+ // We need to invalidate the tree, but this method could get called
+ // multiple times, so we won't invalidate until we get to the end of the
+ // event loop.
+ if (this._timeout) {
+ return;
+ }
+ this._timeout = setTimeout(() => {
+ dbViewWrapperListener.onMessagesRemoved();
+ window.threadTree?.invalidate();
+ delete this._timeout;
+ });
+ },
+ },
+
+ get shouldUseMailViews() {
+ return !!top.ViewPickerBinding?.isVisible;
+ },
+ get shouldDeferMessageDisplayUntilAfterServerConnect() {
+ return false;
+ },
+ shouldMarkMessagesReadOnLeavingFolder(msgFolder) {
+ return false;
+ },
+ onFolderLoading(isFolderLoading) {},
+ onSearching(isSearching) {},
+ onCreatedView() {
+ if (window.threadTree) {
+ window.threadPane.setTreeView(gViewWrapper.dbView);
+ // There is no persisted thread last expanded state for synthetic views.
+ if (!gViewWrapper.isSynthetic) {
+ window.threadPane.restoreThreadState();
+ }
+ window.threadPane.isFirstScroll = true;
+ window.threadPane.scrollDetected = false;
+ window.threadPane.scrollToLatestRowIfNoSelection();
+ }
+ },
+ onDestroyingView(folderIsComingBack) {
+ if (!window.threadTree) {
+ return;
+ }
+
+ if (folderIsComingBack) {
+ // We'll get a new view of the same folder (e.g. with a quick filter) -
+ // try to preserve the selection.
+ window.threadPane.saveSelection();
+ } else {
+ if (gDBView) {
+ gDBView.setJSTree(null);
+ }
+ window.threadTree.view = gDBView = null;
+ }
+ },
+ onLoadingFolder(dbFolderInfo) {
+ window.quickFilterBar?.onFolderChanged();
+ },
+ onDisplayingFolder() {},
+ onLeavingFolder() {},
+ onMessagesLoaded(all) {
+ if (!window.threadPane) {
+ return;
+ }
+ // Try to restore what was selected. Keep the saved selection (if there is
+ // one) until we have all of the messages. This will also reveal selected
+ // messages in collapsed threads.
+ window.threadPane.restoreSelection({ discard: all });
+
+ if (all || gViewWrapper.search.hasSearchTerms) {
+ window.threadPane.ensureThreadStateForQuickSearchView();
+ let newMessageFound = false;
+ if (window.threadPane.scrollToNewMessage) {
+ try {
+ let index = gDBView.findIndexOfMsgHdr(gFolder.firstNewMessage, true);
+ if (index != nsMsgViewIndex_None) {
+ window.threadTree.scrollToIndex(index, true);
+ newMessageFound = true;
+ }
+ } catch (ex) {
+ console.error(ex);
+ }
+ window.threadPane.scrollToNewMessage = false;
+ }
+ window.threadTree.reset();
+ if (!newMessageFound && !window.threadPane.scrollDetected) {
+ window.threadPane.scrollToLatestRowIfNoSelection();
+ }
+ }
+ window.quickFilterBar?.onMessagesChanged();
+ },
+ onMailViewChanged() {
+ window.dispatchEvent(new CustomEvent("MailViewChanged"));
+ },
+ onSortChanged() {
+ // If there is no selection, scroll to the most relevant end.
+ window.threadPane?.scrollToLatestRowIfNoSelection();
+ },
+ onMessagesRemoved() {
+ window.quickFilterBar?.onMessagesChanged();
+
+ if (!gDBView || (!gFolder && !gViewWrapper?.isSynthetic)) {
+ // This can't be a notification about the message currently displayed.
+ return;
+ }
+
+ let rowCount = gDBView.rowCount;
+
+ // There's no messages left.
+ if (rowCount == 0) {
+ if (location.href == "about:3pane") {
+ // In a 3-pane tab, clear the message pane and selection.
+ window.threadTree.selectedIndex = -1;
+ } else if (parent?.location != "about:3pane") {
+ // In a standalone message tab or window, close the tab or window.
+ let tabmail = top.document.getElementById("tabmail");
+ if (tabmail) {
+ tabmail.closeTab(window.tabOrWindow);
+ } else {
+ top.close();
+ }
+ }
+ this._nextViewIndexAfterDelete = null;
+ return;
+ }
+
+ if (
+ this._nextViewIndexAfterDelete != null &&
+ this._nextViewIndexAfterDelete != nsMsgViewIndex_None
+ ) {
+ // Select the next message in the view, based on what we were told in
+ // updateNextMessageAfterDelete.
+ if (this._nextViewIndexAfterDelete >= rowCount) {
+ this._nextViewIndexAfterDelete = rowCount - 1;
+ }
+ if (
+ this._nextViewIndexAfterDelete > -1 &&
+ !mailContextMenu.selectionIsOverridden
+ ) {
+ if (location.href == "about:3pane") {
+ // A "select" event should fire here, but setting the selected index
+ // might not fire it. OTOH, we want it to fire only once, so see if
+ // the event is fired, and if not, fire it.
+ let eventFired = false;
+ let onSelect = () => (eventFired = true);
+
+ window.threadTree.addEventListener("select", onSelect, {
+ once: true,
+ });
+ window.threadTree.selectedIndex = this._nextViewIndexAfterDelete;
+ window.threadTree.removeEventListener("select", onSelect);
+
+ if (!eventFired) {
+ window.threadTree.dispatchEvent(new CustomEvent("select"));
+ }
+ } else if (parent?.location != "about:3pane") {
+ if (
+ Services.prefs.getBoolPref("mail.close_message_window.on_delete")
+ ) {
+ // Bail out early if this is about a partial POP3 message that has
+ // just been completed and reloaded.
+ if (document.body.classList.contains("completed-message")) {
+ document.body.classList.remove("completed-message");
+ return;
+ }
+ // Close the tab or window if the displayed message is deleted.
+ let tabmail = top.document.getElementById("tabmail");
+ if (tabmail) {
+ tabmail.closeTab(window.tabOrWindow);
+ } else {
+ top.close();
+ }
+ return;
+ }
+ gDBView.selection.select(this._nextViewIndexAfterDelete);
+ window.displayMessage(
+ gDBView.getURIForViewIndex(this._nextViewIndexAfterDelete),
+ gViewWrapper
+ );
+ }
+ }
+ this._nextViewIndexAfterDelete = null;
+ }
+ },
+ onMessageRemovalFailed() {
+ this._nextViewIndexAfterDelete = null;
+ },
+ onMessageCountsChanged() {
+ window.quickFilterBar?.onMessagesChanged();
+ },
+};
diff --git a/comm/mail/base/content/mailContext.inc.xhtml b/comm/mail/base/content/mailContext.inc.xhtml
new file mode 100644
index 0000000000..989cdd710e
--- /dev/null
+++ b/comm/mail/base/content/mailContext.inc.xhtml
@@ -0,0 +1,324 @@
+# 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/.
+
+ <menupopup id="mailContext" needsgutter="true">
+ <!-- Links -->
+ <menuitem id="mailContext-openInBrowser"
+ class="menuitem-iconic"
+ label="&openInBrowser.label;"
+ accesskey="&openInBrowser.accesskey;"/>
+ <menuitem id="mailContext-openLinkInBrowser"
+ class="menuitem-iconic"
+ label="&openLinkInBrowser.label;"
+ accesskey="&openLinkInBrowser.accesskey;"/>
+ <menuitem id="mailContext-copylink"
+ class="menuitem-iconic"
+ label="&copyLinkCmd.label;"
+ accesskey="&copyLinkCmd.accesskey;"/>
+ <menuitem id="mailContext-savelink"
+ class="menuitem-iconic"
+ label="&saveLinkAsCmd.label;"
+ accesskey="&saveLinkAsCmd.accesskey;"/>
+ <menuitem id="mailContext-reportPhishingURL"
+ class="menuitem-iconic"
+ label="&reportPhishingURL.label;"
+ accesskey="&reportPhishingURL.accesskey;"/>
+ <menuitem id="mailContext-addemail"
+ class="menuitem-iconic"
+ label="&AddToAddressBook.label;"
+ accesskey="&AddToAddressBook.accesskey;"/>
+ <menuitem id="mailContext-composeemailto"
+ class="menuitem-iconic"
+ label="&SendMessageTo.label;"
+ accesskey="&SendMessageTo.accesskey;"/>
+ <menuitem id="mailContext-copyemail"
+ class="menuitem-iconic"
+ label="&copyEmailCmd.label;"
+ accesskey="&copyEmailCmd.accesskey;"/>
+ <menuseparator/>
+
+ <!-- Images -->
+ <menuitem id="mailContext-copyimage"
+ class="menuitem-iconic"
+ label="&copyImageAllCmd.label;"
+ accesskey="&copyImageAllCmd.accesskey;"/>
+ <menuitem id="mailContext-saveimage"
+ class="menuitem-iconic"
+ label="&saveImageAsCmd.label;"
+ accesskey="&saveImageAsCmd.accesskey;"/>
+ <menuseparator/>
+
+ <!-- Edit -->
+ <menuitem id="mailContext-copy"
+ class="menuitem-iconic"
+ data-l10n-id="text-action-copy"/>
+ <menuitem id="mailContext-selectall"
+ class="menuitem-iconic"
+ data-l10n-id="text-action-select-all"/>
+ <menuseparator/>
+
+ <!-- Search -->
+ <menuitem id="mailContext-searchTheWeb"
+ class="menuitem-iconic"/>
+ <menuseparator/>
+
+ <!-- Drafts/templates -->
+ <menuitem id="mailContext-editDraftMsg"
+ class="menuitem-iconic"
+ label="&contextEditDraftMsg.label;"
+ default="true"/>
+ <menuitem id="mailContext-newMsgFromTemplate"
+ class="menuitem-iconic"
+ label="&contextNewMsgFromTemplate.label;"
+ default="true"/>
+ <menuitem id="mailContext-editTemplateMsg"
+ class="menuitem-iconic"
+ label="&contextEditTemplate.label;"
+ accesskey="&contextEditTemplate.accesskey;"/>
+ <menuseparator/>
+
+ <!-- Open messages -->
+ <menuitem id="mailContext-openNewTab"
+ class="menuitem-iconic"
+ label="&contextOpenNewTab.label;"
+ accesskey="&contextOpenNewTab.accesskey;"/>
+ <menuitem id="mailContext-openNewWindow"
+ class="menuitem-iconic"
+ label="&contextOpenNewWindow.label;"
+ accesskey="&contextOpenNewWindow.accesskey;"/>
+ <menuitem id="mailContext-openConversation"
+ class="menuitem-iconic"
+ label="&contextOpenConversation.label;"
+ accesskey="&contextOpenConversation.accesskey;"/>
+ <menuitem id="mailContext-openContainingFolder"
+ class="menuitem-iconic"
+ label="&contextOpenContainingFolder.label;"
+ accesskey="&contextOpenContainingFolder.accesskey;"/>
+ <menuseparator/>
+
+ <!-- Reply/forward/redirect -->
+ <menuitem id="mailContext-replyNewsgroup"
+ class="menuitem-iconic"
+ label="&contextReplyNewsgroup2.label;"
+ accesskey="&contextReplyNewsgroup2.accesskey;"/>
+ <menuitem id="mailContext-replySender"
+ class="menuitem-iconic"
+ label="&contextReplySender.label;"
+ accesskey="&contextReplySender.accesskey;"/>
+ <menuitem id="mailContext-replyAll"
+ class="menuitem-iconic"
+ label="&contextReplyAll.label;"
+ accesskey="&contextReplyAll.accesskey;"/>
+ <menuitem id="mailContext-replyList"
+ class="menuitem-iconic"
+ label="&contextReplyList.label;"
+ accesskey="&contextReplyList.accesskey;"/>
+ <menuitem id="mailContext-forward"
+ class="menuitem-iconic"
+ label="&contextForward.label;"
+ accesskey="&contextForward.accesskey;"/>
+ <menu id="mailContext-forwardAsMenu"
+ class="menu-iconic"
+ label="&contextForwardAsMenu.label;"
+ accesskey="&contextForwardAsMenu.accesskey;">
+ <menupopup id="mailContext-forwardAsPopup">
+ <menuitem id="mailContext-forwardAsInline"
+ label="&contextForwardAsInline.label;"
+ accesskey="&contextForwardAsInline.accesskey;"/>
+ <menuitem id="mailContext-forwardAsAttachment"
+ label="&contextForwardAsAttachmentItem.label;"
+ accesskey="&contextForwardAsAttachmentItem.accesskey;"/>
+ </menupopup>
+ </menu>
+
+ <menuitem id="mailContext-multiForwardAsAttachment"
+ class="menuitem-iconic"
+ label="&contextMultiForwardAsAttachment.label;"
+ accesskey="&contextMultiForwardAsAttachment.accesskey;"/>
+ <menuitem id="mailContext-redirect"
+ class="menuitem-iconic"
+ data-l10n-id="context-menu-redirect-msg"/>
+ <menuitem id="mailContext-cancel"
+ class="menuitem-iconic"
+ data-l10n-id="context-menu-cancel-msg"/>
+ <menuitem id="mailContext-editAsNew"
+ class="menuitem-iconic"
+ label="&contextEditMsgAsNew.label;"
+ accesskey="&contextEditMsgAsNew.accesskey;"/>
+ <menuseparator/>
+
+ <!-- Tags/mark sub-menus -->
+ <menu id="mailContext-tags"
+ class="menu-iconic"
+ label="&tagMenu.label;"
+ accesskey="&tagMenu.accesskey;">
+ <menupopup id="mailContext-tagpopup" needsgutter="true">
+ <menuitem id="mailContext-addNewTag"
+ class="menuitem-iconic"
+ label="&addNewTag.label;"
+ accesskey="&addNewTag.accesskey;"/>
+ <menuitem id="mailContext-manageTags"
+ class="menuitem-iconic"
+ label="&manageTags.label;"
+ accesskey="&manageTags.accesskey;"/>
+ <menuseparator/>
+ <menuitem id="mailContext-tagRemoveAll"
+ class="menuitem-iconic"
+ accesskey="&tagCmd0.key;"/>
+ <menuseparator/>
+ </menupopup>
+ </menu>
+ <menu id="mailContext-mark"
+ class="menu-iconic"
+ label="&markMenu.label;"
+ accesskey="&markMenu.accesskey;">
+ <menupopup id="mailContext-markPopup">
+ <menuitem id="mailContext-markRead"
+ class="menuitem-iconic"
+ label="&markAsReadCmd.label;"
+ accesskey="&markAsReadCmd.accesskey;"/>
+ <menuitem id="mailContext-markUnread"
+ class="menuitem-iconic"
+ label="&markAsUnreadCmd.label;"
+ accesskey="&markAsUnreadCmd.accesskey;"/>
+ <menuitem id="mailContext-markThreadAsRead"
+ class="menuitem-iconic"
+ label="&markThreadAsReadCmd.label;"
+ accesskey="&markThreadAsReadCmd.accesskey;"/>
+ <menuitem id="mailContext-markReadByDate"
+ class="menuitem-iconic"
+ label="&markReadByDateCmd.label;"
+ accesskey="&markReadByDateCmd.accesskey;"/>
+ <menuitem id="mailContext-markAllRead"
+ class="menuitem-iconic"
+ label="&markAllReadCmd.label;"
+ accesskey="&markAllReadCmd.accesskey;"/>
+ <menuseparator/>
+ <menuitem id="mailContext-markFlagged"
+ type="checkbox"
+ label="&markStarredCmd.label;"
+ accesskey="&markStarredCmd.accesskey;"/>
+ <menuseparator/>
+ <menuitem id="mailContext-markAsJunk"
+ class="menuitem-iconic"
+ label="&markAsJunkCmd.label;"
+ accesskey="&markAsJunkCmd.accesskey;"/>
+ <menuitem id="mailContext-markAsNotJunk"
+ class="menuitem-iconic"
+ label="&markAsNotJunkCmd.label;"
+ accesskey="&markAsNotJunkCmd.accesskey;"/>
+ <menuitem id="mailContext-recalculateJunkScore"
+ class="menuitem-iconic"
+ label="&recalculateJunkScoreCmd.label;"
+ accesskey="&recalculateJunkScoreCmd.accesskey;"/>
+ </menupopup>
+ </menu>
+ <menuseparator/>
+
+ <!-- Move/copy/archive/convert/delete -->
+ <menuitem id="mailContext-copyMessageUrl"
+ class="menuitem-iconic"
+ label="&copyMessageLocation.label;"
+ accesskey="&copyMessageLocation.accesskey;"/>
+ <menuitem id="mailContext-archive"
+ class="menuitem-iconic"
+ label="&contextArchive.label;"
+ accesskey="&contextArchive.accesskey;"/>
+ <menu id="mailContext-moveMenu"
+ class="menu-iconic"
+ label="&contextMoveMsgMenu.label;"
+ accesskey="&contextMoveMsgMenu.accesskey;">
+ <menupopup is="folder-menupopup" id="mailContext-fileHereMenu"
+ mode="filing"
+ showFileHereLabel="true"
+ showRecent="true"
+ recentLabel="&contextMoveCopyMsgRecentMenu.label;"
+ recentAccessKey="&contextMoveCopyMsgRecentMenu.accesskey;"
+ showFavorites="true"
+ favoritesLabel="&contextMoveCopyMsgFavoritesMenu.label;"
+ favoritesAccessKey="&contextMoveCopyMsgFavoritesMenu.accesskey;"/>
+ </menu>
+ <menu id="mailContext-copyMenu"
+ class="menu-iconic"
+ label="&contextCopyMsgMenu.label;"
+ accesskey="&contextCopyMsgMenu.accesskey;">
+ <menupopup is="folder-menupopup" id="mailContext-copyHereMenu"
+ mode="filing"
+ showFileHereLabel="true"
+ showRecent="true"
+ recentLabel="&contextMoveCopyMsgRecentMenu.label;"
+ recentAccessKey="&contextMoveCopyMsgRecentMenu.accesskey;"
+ showFavorites="true"
+ favoritesLabel="&contextMoveCopyMsgFavoritesMenu.label;"
+ favoritesAccessKey="&contextMoveCopyMsgFavoritesMenu.accesskey;"/>
+ </menu>
+ <menuitem id="mailContext-moveToFolderAgain"
+ class="menuitem-iconic"
+ label="&moveToFolderAgain.label;"
+ accesskey="&moveToFolderAgain.accesskey;"/>
+
+ <menu id="mailContext-decryptToFolder"
+ class="menu-iconic"
+ data-l10n-id="context-menu-decrypt-to-folder2"
+ data-l10n-attrs="accesskey">
+ <menupopup is="folder-menupopup"
+ id="mailContext-decryptToTargetFolder"
+ mode="filing"
+ showFileHereLabel="true"
+ showRecent="true"
+ recentLabel="&contextMoveCopyMsgRecentMenu.label;"
+ recentAccessKey="&contextMoveCopyMsgRecentMenu.accesskey;"
+ showFavorites="true"
+ favoritesLabel="&contextMoveCopyMsgFavoritesMenu.label;"
+ favoritesAccessKey="&contextMoveCopyMsgFavoritesMenu.accesskey;"
+ hasbeenopened="false" />
+ </menu>
+
+ <menu id="mailContext-calendar-convert-menu"
+ class="menu-iconic hide-when-calendar-deactivated"
+ label="&calendar.context.convertmenu.label;"
+ accesskey="&calendar.context.convertmenu.accesskey.mail;">
+ <menupopup id="mailContext-calendar-convert-menupopup">
+ <menuitem id="mailContext-calendar-convert-event-menuitem"
+ label="&calendar.context.convertmenu.event.label;"
+ accesskey="&calendar.context.convertmenu.event.accesskey;"/>
+ <menuitem id="mailContext-calendar-convert-task-menuitem"
+ label="&calendar.context.convertmenu.task.label;"
+ accesskey="&calendar.context.convertmenu.task.accesskey;"/>
+ </menupopup>
+ </menu>
+ <menuitem id="mailContext-delete"
+ class="menuitem-iconic"/>
+ <menuseparator/>
+
+ <!-- Threads -->
+ <menuitem id="mailContext-ignoreThread"
+ type="checkbox"
+ label="&contextKillThreadMenu.label;"
+ accesskey="&contextKillThreadMenu.accesskey;"/>
+ <menuitem id="mailContext-ignoreSubthread"
+ type="checkbox"
+ label="&contextKillSubthreadMenu.label;"
+ accesskey="&contextKillSubthreadMenu.accesskey;"/>
+ <menuitem id="mailContext-watchThread"
+ type="checkbox"
+ label="&contextWatchThreadMenu.label;"
+ accesskey="&contextWatchThreadMenu.accesskey;"/>
+ <menuseparator/>
+
+ <!-- Save/print/download -->
+ <menuitem id="mailContext-saveAs"
+ class="menuitem-iconic"
+ label="&contextSaveAs.label;"
+ accesskey="&contextSaveAs.accesskey;"/>
+ <menuitem id="mailContext-print"
+ class="menuitem-iconic"
+ label="&contextPrint.label;"
+ accesskey="&contextPrint.accesskey;"
+ observes="cmd_print"/>
+ <menuitem id="mailContext-downloadSelected"
+ class="menuitem-iconic"
+ label="&downloadSelectedCmd.label;"
+ accesskey="&downloadSelectedCmd.accesskey;"/>
+ </menupopup>
diff --git a/comm/mail/base/content/mailContext.js b/comm/mail/base/content/mailContext.js
new file mode 100644
index 0000000000..7b2a050b1e
--- /dev/null
+++ b/comm/mail/base/content/mailContext.js
@@ -0,0 +1,822 @@
+/* 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/. */
+
+// mailCommon.js
+/* globals commandController */
+
+// about:3pane and about:message must BOTH provide these:
+
+/* globals goDoCommand */ // globalOverlay.js
+/* globals gDBView, gFolder, gViewWrapper, messengerBundle */
+
+/* globals gEncryptedURIService */ // mailCommon.js
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ calendarDeactivator:
+ "resource:///modules/calendar/calCalendarDeactivator.jsm",
+ EnigmailURIs: "chrome://openpgp/content/modules/uris.jsm",
+ MailUtils: "resource:///modules/MailUtils.jsm",
+ PhishingDetector: "resource:///modules/PhishingDetector.jsm",
+ TagUtils: "resource:///modules/TagUtils.jsm",
+});
+
+/**
+ * Called by ContextMenuParent if this window is about:3pane, or is
+ * about:message but not contained by about:3pane.
+ *
+ * @returns {boolean} true if this function opened the context menu
+ */
+function openContextMenu({ data, target }, browser) {
+ if (window.browsingContext.parent != window.browsingContext.top) {
+ // Not sure how we'd get here, but let's not continue if we do.
+ return false;
+ }
+
+ if (browser.getAttribute("context") != "mailContext") {
+ return false;
+ }
+
+ mailContextMenu.setAsMessagePaneContextMenu(data, target.browsingContext);
+ let screenX = data.context.screenXDevPx / window.devicePixelRatio;
+ let screenY = data.context.screenYDevPx / window.devicePixelRatio;
+ let popup = document.getElementById("mailContext");
+ popup.openPopupAtScreen(screenX, screenY, true);
+
+ return true;
+}
+
+var mailContextMenu = {
+ /**
+ * @type {XULPopupElement}
+ */
+ _menupopup: null,
+
+ // Commands handled by commandController.
+ _commands: {
+ "mailContext-editDraftMsg": "cmd_editDraftMsg",
+ "mailContext-newMsgFromTemplate": "cmd_newMsgFromTemplate",
+ "mailContext-editTemplateMsg": "cmd_editTemplateMsg",
+ "mailContext-openConversation": "cmd_openConversation",
+ "mailContext-replyNewsgroup": "cmd_replyGroup",
+ "mailContext-replySender": "cmd_replySender",
+ "mailContext-replyAll": "cmd_replyall",
+ "mailContext-replyList": "cmd_replylist",
+ "mailContext-forward": "cmd_forward",
+ "mailContext-forwardAsInline": "cmd_forwardInline",
+ "mailContext-forwardAsAttachment": "cmd_forwardAttachment",
+ "mailContext-multiForwardAsAttachment": "cmd_forwardAttachment",
+ "mailContext-redirect": "cmd_redirect",
+ "mailContext-cancel": "cmd_cancel",
+ "mailContext-editAsNew": "cmd_editAsNew",
+ "mailContext-addNewTag": "cmd_addTag",
+ "mailContext-manageTags": "cmd_manageTags",
+ "mailContext-tagRemoveAll": "cmd_removeTags",
+ "mailContext-markReadByDate": "cmd_markReadByDate",
+ "mailContext-markFlagged": "cmd_markAsFlagged",
+ "mailContext-archive": "cmd_archive",
+ "mailContext-moveToFolderAgain": "cmd_moveToFolderAgain",
+ "mailContext-decryptToFolder": "cmd_copyDecryptedTo",
+ "mailContext-delete": "cmd_deleteMessage",
+ "mailContext-ignoreThread": "cmd_killThread",
+ "mailContext-ignoreSubthread": "cmd_killSubthread",
+ "mailContext-watchThread": "cmd_watchThread",
+ "mailContext-saveAs": "cmd_saveAsFile",
+ "mailContext-print": "cmd_print",
+ "mailContext-downloadSelected": "cmd_downloadSelected",
+ },
+
+ // More commands handled by commandController, except these ones get
+ // disabled instead of hidden.
+ _alwaysVisibleCommands: {
+ "mailContext-markRead": "cmd_markAsRead",
+ "mailContext-markUnread": "cmd_markAsUnread",
+ "mailContext-markThreadAsRead": "cmd_markThreadAsRead",
+ "mailContext-markAllRead": "cmd_markAllRead",
+ "mailContext-markAsJunk": "cmd_markAsJunk",
+ "mailContext-markAsNotJunk": "cmd_markAsNotJunk",
+ "mailContext-recalculateJunkScore": "cmd_recalculateJunkScore",
+ },
+
+ /**
+ * If we have overridden the selection for the context menu.
+ *
+ * @see `setOverrideSelection`
+ * @type {boolean}
+ */
+ _selectionIsOverridden: false,
+
+ init() {
+ this._menupopup = document.getElementById("mailContext");
+ this._menupopup.addEventListener("popupshowing", this);
+ this._menupopup.addEventListener("popuphidden", this);
+ this._menupopup.addEventListener("command", this);
+ },
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "popupshowing":
+ this.onPopupShowing(event);
+ break;
+ case "popuphidden":
+ this.onPopupHidden(event);
+ break;
+ case "command":
+ this.onCommand(event);
+ break;
+ }
+ },
+
+ onPopupShowing(event) {
+ if (event.target == this._menupopup) {
+ this.fillMailContextMenu(event);
+ }
+ },
+
+ onPopupHidden(event) {
+ if (event.target == this._menupopup) {
+ this.clearOverrideSelection();
+ }
+ },
+
+ onCommand(event) {
+ this.onMailContextMenuCommand(event);
+ },
+
+ /**
+ * Override the selection that this context menu should operate on. The
+ * effect lasts until `clearOverrideSelection` is called by `onPopupHidden`.
+ *
+ * @param {integer} index - The index of the row to use as selection.
+ */
+ setOverrideSelection(index) {
+ this._selectionIsOverridden = true;
+ window.threadPane.saveSelection();
+ window.threadTree._selection.selectEventsSuppressed = true;
+ window.threadTree._selection.select(index);
+ },
+
+ /**
+ * Has the real selection been overridden by a right-click on a message that
+ * wasn't selected?
+ *
+ * @type {boolean}
+ */
+ get selectionIsOverridden() {
+ return this._selectionIsOverridden;
+ },
+
+ /**
+ * Clear the overriding selection, and go back to the previous selection.
+ */
+ clearOverrideSelection() {
+ if (!window.threadTree) {
+ return;
+ }
+ if (this._selectionIsOverridden) {
+ window.threadTree._selection.selectEventsSuppressed = true;
+ window.threadPane.restoreSelection({ notify: false });
+ this._selectionIsOverridden = false;
+ window.threadTree.invalidate();
+ }
+ window.threadTree
+ .querySelector(".context-menu-target")
+ ?.classList.remove("context-menu-target");
+ window.threadTree._selection.selectEventsSuppressed = false;
+ window.threadTree.table.body.focus();
+ },
+
+ setAsThreadPaneContextMenu() {
+ delete this.browsingContext;
+ delete this.context;
+ delete this.selectionInfo;
+ this.inThreadTree = true;
+
+ for (let id of [
+ "mailContext-openInBrowser",
+ "mailContext-openLinkInBrowser",
+ "mailContext-copylink",
+ "mailContext-savelink",
+ "mailContext-reportPhishingURL",
+ "mailContext-addemail",
+ "mailContext-composeemailto",
+ "mailContext-copyemail",
+ "mailContext-copyimage",
+ "mailContext-saveimage",
+ "mailContext-copy",
+ "mailContext-selectall",
+ "mailContext-searchTheWeb",
+ ]) {
+ document.getElementById(id).hidden = true;
+ }
+ },
+
+ setAsMessagePaneContextMenu({ context, selectionInfo }, browsingContext) {
+ function showItem(id, show) {
+ let item = document.getElementById(id);
+ if (item) {
+ item.hidden = !show;
+ }
+ }
+
+ delete this.inThreadTree;
+ this.browsingContext = browsingContext;
+ this.context = context;
+ this.selectionInfo = selectionInfo;
+
+ // showItem("mailContext-openInBrowser", false);
+ showItem(
+ "mailContext-openLinkInBrowser",
+ context.onLink && !context.onMailtoLink
+ );
+ showItem("mailContext-copylink", context.onLink && !context.onMailtoLink);
+ showItem("mailContext-savelink", context.onLink);
+ showItem(
+ "mailContext-reportPhishingURL",
+ context.onLink && !context.onMailtoLink
+ );
+ showItem("mailContext-addemail", context.onMailtoLink);
+ showItem("mailContext-composeemailto", context.onMailtoLink);
+ showItem("mailContext-copyemail", context.onMailtoLink);
+ showItem("mailContext-copyimage", context.onImage);
+ showItem("mailContext-saveimage", context.onLoadedImage);
+ showItem(
+ "mailContext-copy",
+ selectionInfo && !selectionInfo.docSelectionIsCollapsed
+ );
+ showItem("mailContext-selectall", true);
+ showItem(
+ "mailContext-searchTheWeb",
+ selectionInfo && !selectionInfo.docSelectionIsCollapsed
+ );
+
+ let searchTheWeb = document.getElementById("mailContext-searchTheWeb");
+ if (!searchTheWeb.hidden) {
+ let key = "openSearch.label";
+ let abbrSelection;
+ if (selectionInfo.text.length > 15) {
+ key += ".truncated";
+ abbrSelection = selectionInfo.text.slice(0, 15);
+ } else {
+ abbrSelection = selectionInfo.text;
+ }
+
+ searchTheWeb.label = messengerBundle.formatStringFromName(key, [
+ Services.search.defaultEngine.name,
+ abbrSelection,
+ ]);
+ }
+ },
+
+ fillMailContextMenu(event) {
+ function showItem(id, show) {
+ let item = document.getElementById(id);
+ if (item) {
+ item.hidden = !show;
+ }
+ }
+
+ function enableItem(id, enabled) {
+ let item = document.getElementById(id);
+ item.disabled = !enabled;
+ }
+
+ function checkItem(id, checked) {
+ let item = document.getElementById(id);
+ if (item) {
+ // Convert truthy/falsy to boolean before string.
+ item.setAttribute("checked", !!checked);
+ }
+ }
+
+ function setSingleSelection(id, show = true) {
+ showItem(id, numSelectedMessages == 1 && show);
+ enableItem(id, numSelectedMessages == 1);
+ }
+
+ // Hide things that don't work yet.
+ for (let id of [
+ "mailContext-openInBrowser",
+ "mailContext-recalculateJunkScore",
+ ]) {
+ showItem(id, false);
+ }
+
+ let onSpecialItem =
+ this.context?.isContentSelected ||
+ this.context?.onCanvas ||
+ this.context?.onLink ||
+ this.context?.onImage ||
+ this.context?.onAudio ||
+ this.context?.onVideo ||
+ this.context?.onTextInput;
+
+ for (let id of ["mailContext-tags", "mailContext-mark"]) {
+ showItem(id, !onSpecialItem);
+ }
+
+ // Ask commandController about the commands it controls.
+ for (let [id, command] of Object.entries(this._commands)) {
+ showItem(
+ id,
+ !onSpecialItem && commandController.isCommandEnabled(command)
+ );
+ }
+ for (let [id, command] of Object.entries(this._alwaysVisibleCommands)) {
+ showItem(id, !onSpecialItem);
+ enableItem(id, commandController.isCommandEnabled(command));
+ }
+
+ let inAbout3Pane = !!window.threadTree;
+ let inThreadTree = !!this.inThreadTree;
+
+ let message =
+ gFolder || gViewWrapper.isSynthetic
+ ? gDBView?.hdrForFirstSelectedMessage
+ : top.messenger.msgHdrFromURI(window.gMessageURI);
+ let folder = message?.folder;
+ let isDummyMessage = !gViewWrapper.isSynthetic && !folder;
+
+ let numSelectedMessages = isDummyMessage ? 1 : gDBView.numSelected;
+ let isNewsgroup = folder?.isSpecialFolder(
+ Ci.nsMsgFolderFlags.Newsgroup,
+ true
+ );
+ let canMove =
+ numSelectedMessages >= 1 && !isNewsgroup && folder?.canDeleteMessages;
+ let canCopy = numSelectedMessages >= 1;
+
+ setSingleSelection("mailContext-openNewTab", inThreadTree);
+ setSingleSelection("mailContext-openNewWindow", inThreadTree);
+ setSingleSelection(
+ "mailContext-openContainingFolder",
+ (!isDummyMessage && !inAbout3Pane) || gViewWrapper.isSynthetic
+ );
+ setSingleSelection("mailContext-forward", !onSpecialItem);
+ setSingleSelection("mailContext-forwardAsMenu", !onSpecialItem);
+ showItem(
+ "mailContext-multiForwardAsAttachment",
+ numSelectedMessages > 1 &&
+ commandController.isCommandEnabled("cmd_forwardAttachment")
+ );
+
+ if (isDummyMessage) {
+ showItem("mailContext-tags", false);
+ } else {
+ showItem("mailContext-tags", true);
+ this._initMessageTags();
+ }
+
+ showItem("mailContext-mark", !isDummyMessage);
+ checkItem("mailContext-markFlagged", message?.isFlagged);
+
+ setSingleSelection("mailContext-copyMessageUrl", !!isNewsgroup);
+ // Disable move if we can't delete message(s) from this folder.
+ showItem("mailContext-moveMenu", canMove && !onSpecialItem);
+ showItem("mailContext-copyMenu", canCopy && !onSpecialItem);
+
+ top.initMoveToFolderAgainMenu(
+ document.getElementById("mailContext-moveToFolderAgain")
+ );
+
+ // Show only if a message is actively selected in the DOM.
+ // extractFromEmail can't work on dummy messages.
+ showItem(
+ "mailContext-calendar-convert-menu",
+ numSelectedMessages == 1 &&
+ !isDummyMessage &&
+ calendarDeactivator.isCalendarActivated
+ );
+
+ document.l10n.setAttributes(
+ document.getElementById("mailContext-delete"),
+ message.flags & Ci.nsMsgMessageFlags.IMAPDeleted
+ ? "mail-context-undelete-messages"
+ : "mail-context-delete-messages",
+ {
+ count: numSelectedMessages,
+ }
+ );
+
+ checkItem(
+ "mailContext-ignoreThread",
+ folder?.msgDatabase.isIgnored(message?.messageKey)
+ );
+ checkItem(
+ "mailContext-ignoreSubthread",
+ folder && message.flags & Ci.nsMsgMessageFlags.Ignored
+ );
+ checkItem(
+ "mailContext-watchThread",
+ folder?.msgDatabase.isWatched(message?.messageKey)
+ );
+
+ showItem(
+ "mailContext-downloadSelected",
+ window.threadTree && numSelectedMessages > 1
+ );
+
+ let lastItem;
+ for (let child of document.getElementById("mailContext").children) {
+ if (child.localName == "menuseparator") {
+ child.hidden = !lastItem || lastItem.localName == "menuseparator";
+ }
+ if (!child.hidden) {
+ lastItem = child;
+ }
+ }
+ if (lastItem.localName == "menuseparator") {
+ lastItem.hidden = true;
+ }
+
+ // The rest of this block sends menu information to WebExtensions.
+
+ let selectionInfo = this.selectionInfo;
+ let isContentSelected = selectionInfo
+ ? !selectionInfo.docSelectionIsCollapsed
+ : false;
+ let textSelected = selectionInfo ? selectionInfo.text : "";
+ let isTextSelected = !!textSelected.length;
+
+ let tabmail = top.document.getElementById("tabmail");
+ let subject = {
+ menu: event.target,
+ tab: tabmail ? tabmail.currentTabInfo : top,
+ isContentSelected,
+ isTextSelected,
+ onTextInput: this.context?.onTextInput,
+ onLink: this.context?.onLink,
+ onImage: this.context?.onImage,
+ onEditable: this.context?.onEditable,
+ srcUrl: this.context?.mediaURL,
+ linkText: this.context?.linkTextStr,
+ linkUrl: this.context?.linkURL,
+ selectionText: isTextSelected ? selectionInfo.fullText : undefined,
+ pageUrl: this.browsingContext?.currentURI?.spec,
+ };
+
+ if (inThreadTree) {
+ subject.displayedFolder = folder;
+ subject.selectedMessages = gDBView.getSelectedMsgHdrs();
+ }
+
+ subject.context = subject;
+ subject.wrappedJSObject = subject;
+
+ Services.obs.notifyObservers(subject, "on-prepare-contextmenu");
+ Services.obs.notifyObservers(subject, "on-build-contextmenu");
+ },
+
+ onMailContextMenuCommand(event) {
+ // If commandController handles this command, ask it to do so.
+ if (event.target.id in this._commands) {
+ commandController.doCommand(this._commands[event.target.id], event);
+ return;
+ }
+ if (event.target.id in this._alwaysVisibleCommands) {
+ commandController.doCommand(
+ this._alwaysVisibleCommands[event.target.id],
+ event
+ );
+ return;
+ }
+
+ switch (event.target.id) {
+ // Links
+ // case "mailContext-openInBrowser":
+ // this._openInBrowser();
+ // break;
+ case "mailContext-openLinkInBrowser":
+ // Only called in about:message.
+ top.openLinkExternally(this.context.linkURL);
+ break;
+ case "mailContext-copylink":
+ goDoCommand("cmd_copyLink");
+ break;
+ case "mailContext-savelink":
+ top.saveURL(
+ this.context.linkURL, // URL
+ null, // originalURL
+ this.context.linkTextStr, // fileName
+ null, // filePickerTitleKey
+ true, // shouldBypassCache
+ false, // skipPrompt
+ null, // referrerInfo
+ null, // cookieJarSettings
+ this.browsingContext.window.document, // sourceDocument
+ null, // isContentWindowPrivate,
+ Services.scriptSecurityManager.getSystemPrincipal() // principal
+ );
+ break;
+ case "mailContext-reportPhishingURL":
+ PhishingDetector.reportPhishingURL(this.context.linkURL);
+ break;
+ case "mailContext-addemail":
+ top.addEmail(this.context.linkURL);
+ break;
+ case "mailContext-composeemailto":
+ top.composeEmailTo(
+ this.context.linkURL,
+ gFolder
+ ? MailServices.accounts.getFirstIdentityForServer(gFolder.server)
+ : null
+ );
+ break;
+ case "mailContext-copyemail": {
+ let addresses = top.getEmail(this.context.linkURL);
+ Cc["@mozilla.org/widget/clipboardhelper;1"]
+ .getService(Ci.nsIClipboardHelper)
+ .copyString(addresses);
+ break;
+ }
+
+ // Images
+ case "mailContext-copyimage":
+ goDoCommand("cmd_copyImageContents");
+ break;
+ case "mailContext-saveimage":
+ top.saveURL(
+ this.context.imageInfo.currentSrc, // URL
+ null, // originalURL
+ this.context.linkTextStr, // fileName
+ "SaveImageTitle", // filePickerTitleKey
+ true, // shouldBypassCache
+ false, // skipPrompt
+ null, // referrerInfo
+ null, // cookieJarSettings
+ this.browsingContext.window?.document, // sourceDocument
+ null, // isContentWindowPrivate,
+ Services.scriptSecurityManager.getSystemPrincipal() // principal
+ );
+ break;
+
+ // Edit
+ case "mailContext-copy":
+ goDoCommand("cmd_copy");
+ break;
+ case "mailContext-selectall":
+ goDoCommand("cmd_selectAll");
+ break;
+
+ // Search
+ case "mailContext-searchTheWeb":
+ top.openWebSearch(this.selectionInfo.text);
+ break;
+
+ // Open messages
+ case "mailContext-openNewTab":
+ top.OpenMessageInNewTab(gDBView.hdrForFirstSelectedMessage, {
+ event,
+ viewWrapper: gViewWrapper,
+ });
+ break;
+ case "mailContext-openNewWindow":
+ top.MsgOpenNewWindowForMessage(
+ gDBView.hdrForFirstSelectedMessage,
+ gViewWrapper
+ );
+ break;
+ case "mailContext-openContainingFolder":
+ MailUtils.displayMessageInFolderTab(gDBView.hdrForFirstSelectedMessage);
+ break;
+
+ // Move/copy/archive/convert/delete
+ // (Move and Copy sub-menus are handled in the default case.)
+ case "mailContext-copyMessageUrl": {
+ let message = gDBView.hdrForFirstSelectedMessage;
+ let server = message?.folder?.server;
+
+ if (!server) {
+ return;
+ }
+
+ // TODO let backend construct URL and return as attribute
+ let url =
+ server.socketType == Ci.nsMsgSocketType.SSL ? "snews://" : "news://";
+ url += server.hostName + ":" + server.port + "/" + message.messageId;
+
+ Cc["@mozilla.org/widget/clipboardhelper;1"]
+ .getService(Ci.nsIClipboardHelper)
+ .copyString(url);
+ break;
+ }
+
+ // Calendar Convert sub-menu
+ case "mailContext-calendar-convert-event-menuitem":
+ top.calendarExtract.extractFromEmail(
+ gDBView.hdrForFirstSelectedMessage,
+ true
+ );
+ break;
+ case "mailContext-calendar-convert-task-menuitem":
+ top.calendarExtract.extractFromEmail(
+ gDBView.hdrForFirstSelectedMessage,
+ false
+ );
+ break;
+
+ // Save/print/download
+ default: {
+ if (
+ document.getElementById("mailContext-moveMenu").contains(event.target)
+ ) {
+ commandController.doCommand("cmd_moveMessage", event.target._folder);
+ } else if (
+ document.getElementById("mailContext-copyMenu").contains(event.target)
+ ) {
+ commandController.doCommand("cmd_copyMessage", event.target._folder);
+ } else if (
+ document
+ .getElementById("mailContext-decryptToFolder")
+ .contains(event.target)
+ ) {
+ commandController.doCommand(
+ "cmd_copyDecryptedTo",
+ event.target._folder
+ );
+ }
+ break;
+ }
+ }
+ },
+
+ // Tags sub-menu
+
+ /**
+ * Refresh the contents of the tag popup menu/panel.
+ * Used for example for appmenu/Message/Tag panel.
+ *
+ * @param {Element} parent - Parent element that will contain the menu items.
+ * @param {string} [elementName] - Type of menu item, e.g. "menuitem", "toolbarbutton".
+ * @param {string} [classes] - Classes to set on the menu items.
+ */
+ _initMessageTags() {
+ let parent = document.getElementById("mailContext-tagpopup");
+ // Remove any existing non-static items (clear tags list before rebuilding it).
+ // There is a separator element above the dynamically added tag elements, so
+ // remove dynamically added elements below the separator.
+ while (parent.lastElementChild.localName == "menuitem") {
+ parent.lastElementChild.remove();
+ }
+
+ // Create label and accesskey for the static "remove all tags" item.
+ let removeItem = document.getElementById("mailContext-tagRemoveAll");
+ removeItem.label = messengerBundle.GetStringFromName(
+ "mailnews.tags.remove"
+ );
+
+ // Rebuild the list.
+ let message = gDBView.hdrForFirstSelectedMessage;
+ let currentTags = message
+ ? message.getStringProperty("keywords").split(" ")
+ : [];
+ let index = 1;
+
+ for (let tagInfo of MailServices.tags.getAllTags()) {
+ let msgHasTag = currentTags.includes(tagInfo.key);
+ if (tagInfo.ordinal.includes("~AUTOTAG") && !msgHasTag) {
+ return;
+ }
+
+ let item = document.createXULElement("menuitem");
+ item.accessKey = index < 10 ? index : "";
+ item.label = messengerBundle.formatStringFromName(
+ "mailnews.tags.format",
+ [item.accessKey, tagInfo.tag]
+ );
+ item.setAttribute("type", "checkbox");
+ if (msgHasTag) {
+ item.setAttribute("checked", "true");
+ }
+ item.value = tagInfo.key;
+ item.addEventListener("command", event =>
+ this._toggleMessageTag(
+ tagInfo.key,
+ item.getAttribute("checked") == "true"
+ )
+ );
+ if (tagInfo.color) {
+ item.style.color = tagInfo.color;
+ }
+ parent.appendChild(item);
+
+ index++;
+ }
+ },
+
+ removeAllMessageTags() {
+ let selectedMessages = gDBView.getSelectedMsgHdrs();
+ if (!selectedMessages.length) {
+ return;
+ }
+
+ let messages = [];
+ let allKeys = MailServices.tags
+ .getAllTags()
+ .map(t => t.key)
+ .join(" ");
+ let prevHdrFolder = null;
+
+ // This crudely handles cross-folder virtual folders with selected
+ // messages that spans folders, by coalescing consecutive messages in the
+ // selection that happen to be in the same folder. nsMsgSearchDBView does
+ // this better, but nsIMsgDBView doesn't handle commands with arguments,
+ // and untag takes a key argument. Furthermore, we only delete known tags,
+ // keeping other keywords like (non)junk intact.
+ for (let i = 0; i < selectedMessages.length; ++i) {
+ let msgHdr = selectedMessages[i];
+ if (prevHdrFolder != msgHdr.folder) {
+ if (prevHdrFolder) {
+ prevHdrFolder.removeKeywordsFromMessages(messages, allKeys);
+ }
+ messages = [];
+ prevHdrFolder = msgHdr.folder;
+ }
+ messages.push(msgHdr);
+ }
+ if (prevHdrFolder) {
+ prevHdrFolder.removeKeywordsFromMessages(messages, allKeys);
+ }
+ },
+
+ _toggleMessageTag(key, addKey) {
+ let messages = [];
+ let selectedMessages = gDBView.getSelectedMsgHdrs();
+ let toggler = addKey
+ ? "addKeywordsToMessages"
+ : "removeKeywordsFromMessages";
+ let prevHdrFolder = null;
+ // this crudely handles cross-folder virtual folders with selected messages
+ // that spans folders, by coalescing consecutive msgs in the selection
+ // that happen to be in the same folder. nsMsgSearchDBView does this
+ // better, but nsIMsgDBView doesn't handle commands with arguments,
+ // and (un)tag takes a key argument.
+ for (let i = 0; i < selectedMessages.length; ++i) {
+ let msgHdr = selectedMessages[i];
+ if (prevHdrFolder != msgHdr.folder) {
+ if (prevHdrFolder) {
+ prevHdrFolder[toggler](messages, key);
+ }
+ messages = [];
+ prevHdrFolder = msgHdr.folder;
+ }
+ messages.push(msgHdr);
+ }
+ if (prevHdrFolder) {
+ prevHdrFolder[toggler](messages, key);
+ }
+ },
+
+ /**
+ * Toggle the state of a message tag on the selected messages (based on the
+ * state of the first selected message, like for starring).
+ *
+ * @param {number} keyNumber - The number (1 through 9) associated with the tag.
+ */
+ _toggleMessageTagKey(keyNumber) {
+ let msgHdr = gDBView.hdrForFirstSelectedMessage;
+ if (!msgHdr) {
+ return;
+ }
+
+ let tagArray = MailServices.tags.getAllTags();
+ if (keyNumber > tagArray.length) {
+ return;
+ }
+
+ let key = tagArray[keyNumber - 1].key;
+ let curKeys = msgHdr.getStringProperty("keywords").split(" ");
+ if (msgHdr.label) {
+ curKeys.push("$label" + msgHdr.label);
+ }
+ let addKey = !curKeys.includes(key);
+
+ this._toggleMessageTag(key, addKey);
+ },
+
+ addTag() {
+ top.openDialog(
+ "chrome://messenger/content/newTagDialog.xhtml",
+ "",
+ "chrome,titlebar,modal,centerscreen",
+ {
+ result: "",
+ okCallback: (name, color) => {
+ MailServices.tags.addTag(name, color, "");
+ let key = MailServices.tags.getKeyForTag(name);
+ TagUtils.addTagToAllDocumentSheets(key, color);
+
+ this._toggleMessageTag(key, true);
+ return true;
+ },
+ }
+ );
+ },
+};
diff --git a/comm/mail/base/content/mailCore.js b/comm/mail/base/content/mailCore.js
new file mode 100644
index 0000000000..781d5449d6
--- /dev/null
+++ b/comm/mail/base/content/mailCore.js
@@ -0,0 +1,1063 @@
+/* -*- Mode: JS; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*-
+ * 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/. */
+
+/*
+ * Core mail routines used by all of the major mail windows (address book,
+ * 3-pane, compose and stand alone message window).
+ * Routines to support custom toolbars in mail windows, opening up a new window
+ * of a particular type all live here.
+ * Before adding to this file, ask yourself, is this a JS routine that is going
+ * to be used by all of the main mail windows?
+ */
+
+/* import-globals-from ../../extensions/mailviews/content/msgViewPickerOverlay.js */
+/* import-globals-from customizeToolbar.js */
+/* import-globals-from utilityOverlay.js */
+
+/* globals gChatTab */ // From globals chat-messenger.js
+/* globals currentAttachments */ // From msgHdrView.js
+
+var { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+XPCOMUtils.defineLazyGetter(this, "gViewSourceUtils", function () {
+ let scope = {};
+ Services.scriptloader.loadSubScript(
+ "chrome://global/content/viewSourceUtils.js",
+ scope
+ );
+ scope.gViewSourceUtils.viewSource = async function (aArgs) {
+ // Check if external view source is enabled. If so, try it. If it fails,
+ // fallback to internal view source.
+ if (Services.prefs.getBoolPref("view_source.editor.external")) {
+ try {
+ await this.openInExternalEditor(aArgs);
+ return;
+ } catch (ex) {}
+ }
+
+ window.openDialog(
+ "chrome://messenger/content/viewSource.xhtml",
+ "_blank",
+ "all,dialog=no",
+ aArgs
+ );
+ };
+ return scope.gViewSourceUtils;
+});
+
+Object.defineProperty(this, "BrowserConsoleManager", {
+ get() {
+ let { loader } = ChromeUtils.importESModule(
+ "resource://devtools/shared/loader/Loader.sys.mjs"
+ );
+ return loader.require("devtools/client/webconsole/browser-console-manager")
+ .BrowserConsoleManager;
+ },
+ configurable: true,
+ enumerable: true,
+});
+
+var gCustomizeSheet = false;
+
+function overlayRestoreDefaultSet() {
+ let toolbox = null;
+ if ("arguments" in window && window.arguments[0]) {
+ toolbox = window.arguments[0];
+ } else if (window.frameElement && "toolbox" in window.frameElement) {
+ toolbox = window.frameElement.toolbox;
+ }
+
+ let mode = toolbox.getAttribute("defaultmode");
+ let align = toolbox.getAttribute("defaultlabelalign");
+ let menulist = document.getElementById("modelist");
+
+ if (mode == "full" && align == "end") {
+ toolbox.setAttribute("mode", "textbesideicon");
+ toolbox.setAttribute("labelalign", align);
+ overlayUpdateToolbarMode("textbesideicon");
+ } else if (mode == "full" && align == "") {
+ toolbox.setAttribute("mode", "full");
+ toolbox.removeAttribute("labelalign");
+ overlayUpdateToolbarMode(mode);
+ }
+
+ restoreDefaultSet();
+
+ if (mode == "full" && align == "end") {
+ menulist.value = "textbesideicon";
+ }
+}
+
+function overlayUpdateToolbarMode(aModeValue) {
+ let toolbox = null;
+ if ("arguments" in window && window.arguments[0]) {
+ toolbox = window.arguments[0];
+ } else if (window.frameElement && "toolbox" in window.frameElement) {
+ toolbox = window.frameElement.toolbox;
+ }
+
+ // If they chose a mode of textbesideicon or full,
+ // then map that to a mode of full, and a labelalign of true or false.
+ if (aModeValue == "textbesideicon" || aModeValue == "full") {
+ var align = aModeValue == "textbesideicon" ? "end" : "bottom";
+ toolbox.setAttribute("labelalign", align);
+ Services.xulStore.persist(toolbox, "labelalign");
+ aModeValue = "full";
+ }
+ updateToolbarMode(aModeValue);
+}
+
+function overlayOnLoad() {
+ let restoreButton = document
+ .getElementById("main-box")
+ .querySelector("[oncommand*='restore']");
+ restoreButton.setAttribute("oncommand", "overlayRestoreDefaultSet();");
+
+ // Add the textBesideIcon menu item if it's not already there.
+ let menuitem = document.getElementById("textbesideiconItem");
+ if (!menuitem) {
+ let menulist = document.getElementById("modelist");
+ let label = document
+ .getElementById("iconsBesideText.label")
+ .getAttribute("value");
+ menuitem = menulist.appendItem(label, "textbesideicon");
+ menuitem.id = "textbesideiconItem";
+ }
+
+ // If they have a mode of full and a labelalign of true,
+ // then pretend the mode is textbesideicon when populating the popup.
+ let toolbox = null;
+ if ("arguments" in window && window.arguments[0]) {
+ toolbox = window.arguments[0];
+ } else if (window.frameElement && "toolbox" in window.frameElement) {
+ toolbox = window.frameElement.toolbox;
+ }
+
+ let toolbarWindow = document.getElementById("CustomizeToolbarWindow");
+ toolbarWindow.setAttribute("toolboxId", toolbox.id);
+ toolbox.setAttribute("doCustomization", "true");
+
+ let mode = toolbox.getAttribute("mode");
+ let align = toolbox.getAttribute("labelalign");
+ if (mode == "full" && align == "end") {
+ toolbox.setAttribute("mode", "textbesideicon");
+ }
+
+ onLoad();
+ overlayRepositionDialog();
+
+ // Re-set and re-persist the mode, if we changed it above.
+ if (mode == "full" && align == "end") {
+ toolbox.setAttribute("mode", mode);
+ Services.xulStore.persist(toolbox, "mode");
+ }
+}
+
+function overlayRepositionDialog() {
+ // Position the dialog so it is fully visible on the screen
+ // (if possible)
+
+ // Seems to be necessary to get the correct dialog height/width
+ window.sizeToContent();
+ var wH = window.outerHeight;
+ var wW = window.outerWidth;
+ var sH = window.screen.height;
+ var sW = window.screen.width;
+ var sX = window.screenX;
+ var sY = window.screenY;
+ var sAL = window.screen.availLeft;
+ var sAT = window.screen.availTop;
+
+ var nX = Math.max(Math.min(sX, sW - wW), sAL);
+ var nY = Math.max(Math.min(sY, sH - wH), sAT);
+ window.moveTo(nX, nY);
+}
+
+function CustomizeMailToolbar(toolboxId, customizePopupId) {
+ if (toolboxId === "mail-toolbox" && window.tabmail) {
+ // Open the unified toolbar customization panel only for mail.
+ document.querySelector("unified-toolbar").showCustomization();
+ return;
+ }
+
+ // Disable the toolbar context menu items
+ var menubar = document.getElementById("mail-menubar");
+ for (var i = 0; i < menubar.children.length; ++i) {
+ menubar.children[i].setAttribute("disabled", true);
+ }
+
+ var customizePopup = document.getElementById(customizePopupId);
+ customizePopup.setAttribute("disabled", "true");
+
+ var toolbox = document.getElementById(toolboxId);
+
+ var customizeURL = "chrome://messenger/content/customizeToolbar.xhtml";
+ gCustomizeSheet = Services.prefs.getBoolPref(
+ "toolbar.customization.usesheet"
+ );
+
+ let externalToolbars = [];
+ if (toolbox.getAttribute("id") == "mail-toolbox") {
+ if (
+ AppConstants.platform != "macosx" &&
+ document.getElementById("toolbar-menubar")
+ ) {
+ externalToolbars.push(document.getElementById("toolbar-menubar"));
+ }
+ }
+
+ if (gCustomizeSheet) {
+ var sheetFrame = document.getElementById("customizeToolbarSheetIFrame");
+ var panel = document.getElementById("customizeToolbarSheetPopup");
+ sheetFrame.hidden = false;
+ sheetFrame.toolbox = toolbox;
+ sheetFrame.panel = panel;
+ if (externalToolbars.length > 0) {
+ sheetFrame.externalToolbars = externalToolbars;
+ }
+
+ // The document might not have been loaded yet, if this is the first time.
+ // If it is already loaded, reload it so that the onload initialization code
+ // re-runs.
+ if (sheetFrame.getAttribute("src") == customizeURL) {
+ sheetFrame.contentWindow.location.reload();
+ } else {
+ sheetFrame.setAttribute("src", customizeURL);
+ }
+
+ // Open the panel, but make it invisible until the iframe has loaded so
+ // that the user doesn't see a white flash.
+ panel.style.visibility = "hidden";
+ toolbox.addEventListener(
+ "beforecustomization",
+ function () {
+ panel.style.removeProperty("visibility");
+ },
+ { capture: false, once: true }
+ );
+ panel.openPopup(toolbox, "after_start", 0, 0);
+ } else {
+ var wintype = document.documentElement.getAttribute("windowtype");
+ wintype = wintype.replace(/:/g, "");
+
+ window.openDialog(
+ customizeURL,
+ "CustomizeToolbar" + wintype,
+ "chrome,all,dependent",
+ toolbox,
+ externalToolbars
+ );
+ }
+}
+
+function MailToolboxCustomizeDone(aEvent, customizePopupId) {
+ if (gCustomizeSheet) {
+ document.getElementById("customizeToolbarSheetIFrame").hidden = true;
+ document.getElementById("customizeToolbarSheetPopup").hidePopup();
+ }
+
+ // Update global UI elements that may have been added or removed
+
+ // Re-enable parts of the UI we disabled during the dialog
+ var menubar = document.getElementById("mail-menubar");
+ for (var i = 0; i < menubar.children.length; ++i) {
+ menubar.children[i].setAttribute("disabled", false);
+ }
+
+ var customizePopup = document.getElementById(customizePopupId);
+ customizePopup.removeAttribute("disabled");
+
+ let toolbox = document.querySelector('[doCustomization="true"]');
+ if (toolbox) {
+ toolbox.removeAttribute("doCustomization");
+
+ // The GetMail button is stuck in a strange state right now, since the
+ // customization wrapping preserves its children, but not its initialized
+ // state. Fix that here.
+ // That is also true for the File -> "Get new messages for" menuitems in both
+ // menus (old and new App menu). And also Go -> Folder.
+ // TODO bug 904223: try to fix folderWidgets.xml to not do this.
+ // See Bug 520457 and Bug 534448 and Bug 709733.
+ // Fix Bug 565045: Only treat "Get Message Button" if it is in our toolbox
+ for (let popup of [
+ toolbox.querySelector("#button-getMsgPopup"),
+ document.getElementById("menu_getAllNewMsgPopup"),
+ document.getElementById("appmenu_getAllNewMsgPopup"),
+ document.getElementById("menu_GoFolderPopup"),
+ document.getElementById("appmenu_GoFolderPopup"),
+ ]) {
+ if (!popup) {
+ continue;
+ }
+
+ // .teardown() is only available here if the menu has its frame
+ // otherwise the folderWidgets.xml::folder-menupopup binding is not
+ // attached to the popup. So if it is not available, remove the items
+ // explicitly. Only remove elements that were generated by the binding.
+ if ("_teardown" in popup) {
+ popup._teardown();
+ } else {
+ for (let i = popup.children.length - 1; i >= 0; i--) {
+ let child = popup.children[i];
+ if (child.getAttribute("generated") != "true") {
+ continue;
+ }
+ if ("_teardown" in child) {
+ child._teardown();
+ }
+ child.remove();
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Sets up the menu popup that lets the user hide or display toolbars. For
+ * example, in the appmenu / Preferences view. Adds toolbar items to the popup
+ * and sets their attributes.
+ *
+ * @param {Event} event - Event causing the menu popup to appear.
+ * @param {string|string[]} toolboxIds - IDs of toolboxes that contain toolbars.
+ * @param {Element} insertPoint - Where to insert menu items.
+ * @param {string} elementName - What kind of menu item element to use. E.g.
+ * "toolbarbutton" for the appmenu.
+ * @param {string} classes - Classes to set on menu items.
+ * @param {boolean} keepOpen - If to force the menu to stay open when clicking
+ * on this element.
+ */
+function onViewToolbarsPopupShowing(
+ event,
+ toolboxIds,
+ insertPoint,
+ elementName = "menuitem",
+ classes,
+ keepOpen = false
+) {
+ if (!Array.isArray(toolboxIds)) {
+ toolboxIds = [toolboxIds];
+ }
+
+ let popup = event.target.querySelector(".panel-subview-body") || event.target;
+ // Limit the toolbar menu entries to the first level of context menus.
+ if (
+ popup != event.currentTarget &&
+ event.currentTarget.tagName == "menupopup"
+ ) {
+ return;
+ }
+
+ // Remove all collapsible nodes from the menu.
+ for (let i = popup.children.length - 1; i >= 0; --i) {
+ let deadItem = popup.children[i];
+
+ if (deadItem.hasAttribute("iscollapsible")) {
+ deadItem.remove();
+ }
+ }
+
+ // We insert menuitems before the first child if no insert point is given.
+ let firstMenuItem = insertPoint || popup.firstElementChild;
+
+ for (let toolboxId of toolboxIds) {
+ let toolbars = [];
+ let toolbox = document.getElementById(toolboxId);
+
+ if (toolbox) {
+ // We consider child nodes that have a toolbarname attribute.
+ toolbars = toolbars.concat(
+ Array.from(toolbox.querySelectorAll("[toolbarname]"))
+ );
+ }
+
+ if (
+ toolboxId == "mail-toolbox" &&
+ toolbars.every(
+ toolbar => toolbar.getAttribute("id") !== "toolbar-menubar"
+ )
+ ) {
+ if (
+ AppConstants.platform != "macosx" &&
+ document.getElementById("toolbar-menubar")
+ ) {
+ toolbars.push(document.getElementById("toolbar-menubar"));
+ }
+ }
+
+ for (let toolbar of toolbars) {
+ let toolbarName = toolbar.getAttribute("toolbarname");
+ if (!toolbarName) {
+ continue;
+ }
+
+ let menuItem = document.createXULElement(elementName);
+ let hidingAttribute =
+ toolbar.getAttribute("type") == "menubar" ? "autohide" : "collapsed";
+
+ menuItem.setAttribute("type", "checkbox");
+ // Mark this menuitem with an iscollapsible attribute, so we
+ // know we can wipe it out later on.
+ menuItem.setAttribute("iscollapsible", true);
+ menuItem.setAttribute("toolbarid", toolbar.id);
+ menuItem.setAttribute("label", toolbarName);
+ menuItem.setAttribute("accesskey", toolbar.getAttribute("accesskey"));
+ menuItem.setAttribute(
+ "checked",
+ toolbar.getAttribute(hidingAttribute) != "true"
+ );
+ if (classes) {
+ menuItem.setAttribute("class", classes);
+ }
+ if (keepOpen) {
+ menuItem.setAttribute("closemenu", "none");
+ }
+ popup.insertBefore(menuItem, firstMenuItem);
+
+ menuItem.addEventListener("command", () => {
+ if (toolbar.getAttribute(hidingAttribute) != "true") {
+ toolbar.setAttribute(hidingAttribute, "true");
+ menuItem.removeAttribute("checked");
+ } else {
+ menuItem.setAttribute("checked", true);
+ toolbar.removeAttribute(hidingAttribute);
+ }
+ Services.xulStore.persist(toolbar, hidingAttribute);
+ });
+ }
+ }
+}
+
+function toJavaScriptConsole() {
+ BrowserConsoleManager.openBrowserConsoleOrFocus();
+}
+
+function openAboutDebugging(hash) {
+ let url = "about:debugging" + (hash ? "#" + hash : "");
+ document.getElementById("tabmail").openTab("contentTab", { url });
+}
+
+function toOpenWindowByType(inType, uri) {
+ var topWindow = Services.wm.getMostRecentWindow(inType);
+ if (topWindow) {
+ topWindow.focus();
+ return topWindow;
+ }
+ return window.open(
+ uri,
+ "_blank",
+ "chrome,extrachrome,menubar,resizable,scrollbars,status,toolbar"
+ );
+}
+
+function toMessengerWindow() {
+ return toOpenWindowByType(
+ "mail:3pane",
+ "chrome://messenger/content/messenger.xhtml"
+ );
+}
+
+function focusOnMail(tabNo, event) {
+ // this is invoked by accel-<number>
+ var topWindow = Services.wm.getMostRecentWindow("mail:3pane");
+ if (topWindow) {
+ topWindow.focus();
+ const tabmail = document.getElementById("tabmail");
+ if (tabmail.globalOverlay) {
+ return;
+ }
+ tabmail.selectTabByIndex(event, tabNo);
+ } else {
+ window.open(
+ "chrome://messenger/content/messenger.xhtml",
+ "_blank",
+ "chrome,extrachrome,menubar,resizable,scrollbars,status,toolbar"
+ );
+ }
+}
+
+/**
+ * Open the address book and optionally display/edit a card.
+ *
+ * @param {?object} openArgs - Arguments to pass to the address book.
+ * See `externalAction` in aboutAddressBook.js for details.
+ * @returns {?Window} The address book's window global, if the address book was
+ * opened.
+ */
+async function toAddressBook(openArgs) {
+ let messengerWindow = toMessengerWindow();
+ if (messengerWindow.document.readyState != "complete") {
+ await new Promise(resolve => {
+ Services.obs.addObserver(
+ {
+ observe(subject) {
+ if (subject == messengerWindow) {
+ Services.obs.removeObserver(this, "mail-tabs-session-restored");
+ resolve();
+ }
+ },
+ },
+ "mail-tabs-session-restored"
+ );
+ });
+ }
+
+ if (messengerWindow.tabmail.globalOverlay) {
+ return null;
+ }
+
+ return new Promise(resolve => {
+ messengerWindow.tabmail.openTab("addressBookTab", {
+ onLoad(event, browser) {
+ if (openArgs) {
+ browser.contentWindow.externalAction(openArgs);
+ }
+ resolve(browser.contentWindow);
+ },
+ });
+ messengerWindow.focus();
+ });
+}
+
+/**
+ * Open the calendar.
+ */
+async function toCalendar() {
+ let messengerWindow = toMessengerWindow();
+ if (messengerWindow.document.readyState != "complete") {
+ await new Promise(resolve => {
+ Services.obs.addObserver(
+ {
+ observe(subject) {
+ if (subject == messengerWindow) {
+ Services.obs.removeObserver(this, "mail-tabs-session-restored");
+ resolve();
+ }
+ },
+ },
+ "mail-tabs-session-restored"
+ );
+ });
+ }
+
+ return new Promise(resolve => {
+ messengerWindow.tabmail.openTab("calendar", {
+ onLoad(event, browser) {
+ resolve(browser.contentWindow);
+ },
+ });
+ messengerWindow.focus();
+ });
+}
+
+function showChatTab() {
+ let tabmail = document.getElementById("tabmail");
+ if (gChatTab) {
+ tabmail.switchToTab(gChatTab);
+ } else {
+ tabmail.openTab("chat", {});
+ }
+}
+
+/**
+ * Open about:import or importDialog.xhtml.
+ *
+ * @param {"start"|"app"|"addressBook"|"calendar"|"export"} [tabId] - The tab
+ * to open in about:import.
+ */
+function toImport(tabId = "start") {
+ if (Services.prefs.getBoolPref("mail.import.in_new_tab")) {
+ let tab = toMessengerWindow().openTab("contentTab", {
+ url: "about:import",
+ onLoad(event, browser) {
+ if (tabId) {
+ browser.contentWindow.showTab(`tab-${tabId}`, true);
+ }
+ },
+ });
+ // Somehow DOMContentLoaded is called even when about:import is already
+ // open, which resets the active tab. Use setTimeout here as a workaround.
+ setTimeout(
+ () => tab.browser.contentWindow.showTab(`tab-${tabId}`, true),
+ 100
+ );
+ return;
+ }
+ window.openDialog(
+ "chrome://messenger/content/importDialog.xhtml",
+ "importDialog",
+ "chrome,modal,titlebar,centerscreen"
+ );
+}
+
+function toExport() {
+ if (Services.prefs.getBoolPref("mail.import.in_new_tab")) {
+ toImport("export");
+ return;
+ }
+ window.openDialog(
+ "chrome://messenger/content/exportDialog.xhtml",
+ "exportDialog",
+ "chrome,modal,titlebar,centerscreen"
+ );
+}
+
+function toSanitize() {
+ let sanitizerScope = {};
+ Services.scriptloader.loadSubScript(
+ "chrome://messenger/content/sanitize.js",
+ sanitizerScope
+ );
+ sanitizerScope.Sanitizer.sanitize(window);
+}
+
+/**
+ * Opens the Preferences (Options) dialog.
+ *
+ * @param aPaneID ID of prefpane to select automatically.
+ * @param aScrollPaneTo ID of the element to scroll into view.
+ * @param aOtherArgs other prefpane specific arguments
+ */
+function openOptionsDialog(aPaneID, aScrollPaneTo, aOtherArgs) {
+ openPreferencesTab(aPaneID, aScrollPaneTo, aOtherArgs);
+}
+
+function openAddonsMgr(aView) {
+ return new Promise(resolve => {
+ let emWindow;
+ let browserWindow;
+
+ let receivePong = function (aSubject, aTopic, aData) {
+ let browserWin = aSubject.browsingContext.topChromeWindow;
+ if (!emWindow || browserWin == window /* favor the current window */) {
+ emWindow = aSubject;
+ browserWindow = browserWin;
+ }
+ };
+ Services.obs.addObserver(receivePong, "EM-pong");
+ Services.obs.notifyObservers(null, "EM-ping");
+ Services.obs.removeObserver(receivePong, "EM-pong");
+
+ if (emWindow) {
+ if (aView) {
+ emWindow.loadView(aView);
+ }
+ let tabmail = browserWindow.document.getElementById("tabmail");
+ tabmail.switchToTab(tabmail.getBrowserForDocument(emWindow));
+ emWindow.focus();
+ resolve(emWindow);
+ return;
+ }
+
+ // This must be a new load, else the ping/pong would have
+ // found the window above.
+ let tab = openContentTab("about:addons");
+ // Also in `contentTabType.restoreTab` in specialTabs.js.
+ tab.browser.droppedLinkHandler = event =>
+ tab.browser.contentWindow.gDragDrop.onDrop(event);
+
+ Services.obs.addObserver(function observer(aSubject, aTopic, aData) {
+ Services.obs.removeObserver(observer, aTopic);
+ if (aView) {
+ aSubject.loadView(aView);
+ }
+ aSubject.focus();
+ resolve(aSubject);
+ }, "EM-loaded");
+ });
+}
+
+function openActivityMgr() {
+ Cc["@mozilla.org/activity-manager-ui;1"]
+ .getService(Ci.nsIActivityManagerUI)
+ .show(window);
+}
+
+/**
+ * Open the folder properties of current folder with the quota tab selected.
+ */
+function openFolderQuota() {
+ document
+ .getElementById("tabmail")
+ .currentAbout3Pane?.folderPane.editFolder("QuotaTab");
+}
+
+function openIMAccountMgr() {
+ var win = Services.wm.getMostRecentWindow("Messenger:Accounts");
+ if (win) {
+ win.focus();
+ } else {
+ win = Services.ww.openWindow(
+ null,
+ "chrome://messenger/content/chat/imAccounts.xhtml",
+ "Accounts",
+ "chrome,resizable,centerscreen",
+ null
+ );
+ }
+ return win;
+}
+
+function openIMAccountWizard() {
+ const kFeatures = "chrome,centerscreen,modal,titlebar";
+ const kUrl = "chrome://messenger/content/chat/imAccountWizard.xhtml";
+ const kName = "IMAccountWizard";
+
+ if (AppConstants.platform == "macosx") {
+ // On Mac, avoid using the hidden window as a parent as that would
+ // make it visible.
+ let hiddenWindowUrl = Services.prefs.getCharPref(
+ "browser.hiddenWindowChromeURL"
+ );
+ if (window.location.href == hiddenWindowUrl) {
+ Services.ww.openWindow(null, kUrl, kName, kFeatures, null);
+ return;
+ }
+ }
+
+ window.openDialog(kUrl, kName, kFeatures);
+}
+
+function openSavedFilesWnd() {
+ if (window.tabmail?.globalOverlay) {
+ return Promise.resolve();
+ }
+ return openContentTab("about:downloads");
+}
+
+function SetBusyCursor(window, enable) {
+ // setCursor() is only available for chrome windows.
+ // However one of our frames is the start page which
+ // is a non-chrome window, so check if this window has a
+ // setCursor method
+ if ("setCursor" in window) {
+ if (enable) {
+ window.setCursor("progress");
+ } else {
+ window.setCursor("auto");
+ }
+ }
+
+ var numFrames = window.frames.length;
+ for (var i = 0; i < numFrames; i++) {
+ SetBusyCursor(window.frames[i], enable);
+ }
+}
+
+function openAboutDialog() {
+ for (let win of Services.wm.getEnumerator("Mail:About")) {
+ // Only open one about window
+ win.focus();
+ return;
+ }
+
+ let features = "chrome,centerscreen,";
+ if (AppConstants.platform == "win") {
+ features += "dependent";
+ } else if (AppConstants.platform == "macosx") {
+ features += "resizable=no,minimizable=no";
+ } else {
+ features += "dependent,dialog=no";
+ }
+
+ window.openDialog(
+ "chrome://messenger/content/aboutDialog.xhtml",
+ "About",
+ features
+ );
+}
+
+/**
+ * Opens the support page based on the app.support.baseURL pref.
+ */
+function openSupportURL() {
+ openFormattedURL("app.support.baseURL");
+}
+
+/**
+ * Fetches the url for the passed in pref name, formats it and then loads it in the default
+ * browser.
+ *
+ * @param aPrefName - name of the pref that holds the url we want to format and open
+ */
+function openFormattedURL(aPrefName) {
+ var urlToOpen = Services.urlFormatter.formatURLPref(aPrefName);
+
+ var uri = Services.io.newURI(urlToOpen);
+
+ var protocolSvc = Cc[
+ "@mozilla.org/uriloader/external-protocol-service;1"
+ ].getService(Ci.nsIExternalProtocolService);
+ protocolSvc.loadURI(uri);
+}
+
+/**
+ * Opens the Troubleshooting page in a new tab.
+ */
+function openAboutSupport() {
+ let mailWindow = Services.wm.getMostRecentWindow("mail:3pane");
+ if (mailWindow) {
+ mailWindow.focus();
+ mailWindow.document.getElementById("tabmail").openTab("contentTab", {
+ url: "about:support",
+ });
+ return;
+ }
+
+ window.openDialog(
+ "chrome://messenger/content/messenger.xhtml",
+ "_blank",
+ "chrome,dialog=no,all",
+ null,
+ {
+ tabType: "contentTab",
+ tabParams: { url: "about:support" },
+ }
+ );
+}
+
+/**
+ * Prompt the user to restart the browser in safe mode.
+ */
+function safeModeRestart() {
+ // Is TB in safe mode?
+ if (Services.appinfo.inSafeMode) {
+ let cancelQuit = Cc["@mozilla.org/supports-PRBool;1"].createInstance(
+ Ci.nsISupportsPRBool
+ );
+ Services.obs.notifyObservers(
+ cancelQuit,
+ "quit-application-requested",
+ "restart"
+ );
+
+ if (cancelQuit.data) {
+ return;
+ }
+
+ Services.startup.quit(
+ Ci.nsIAppStartup.eRestart | Ci.nsIAppStartup.eAttemptQuit
+ );
+ return;
+ }
+ // prompt the user to confirm
+ let bundle = Services.strings.createBundle(
+ "chrome://messenger/locale/messenger.properties"
+ );
+ let promptTitle = bundle.GetStringFromName(
+ "troubleshootModeRestartPromptTitle"
+ );
+ let promptMessage = bundle.GetStringFromName(
+ "troubleshootModeRestartPromptMessage"
+ );
+ let restartText = bundle.GetStringFromName("troubleshootModeRestartButton");
+ let buttonFlags =
+ Services.prompt.BUTTON_POS_0 * Services.prompt.BUTTON_TITLE_IS_STRING +
+ Services.prompt.BUTTON_POS_1 * Services.prompt.BUTTON_TITLE_CANCEL +
+ Services.prompt.BUTTON_POS_0_DEFAULT;
+
+ let rv = Services.prompt.confirmEx(
+ window,
+ promptTitle,
+ promptMessage,
+ buttonFlags,
+ restartText,
+ null,
+ null,
+ null,
+ {}
+ );
+ if (rv == 0) {
+ Services.env.set("MOZ_SAFE_MODE_RESTART", "1");
+ let { MailUtils } = ChromeUtils.import("resource:///modules/MailUtils.jsm");
+ MailUtils.restartApplication();
+ }
+}
+
+function getMostRecentMailWindow() {
+ let win = null;
+
+ win = Services.wm.getMostRecentWindow("mail:3pane", true);
+
+ // If we're lucky, this isn't a popup, and we can just return this.
+ if (win && win.document.documentElement.getAttribute("chromehidden")) {
+ win = null;
+ // This is oldest to newest, so this gets a bit ugly.
+ for (let nextWin of Services.wm.getEnumerator("mail:3pane", true)) {
+ if (!nextWin.document.documentElement.getAttribute("chromehidden")) {
+ win = nextWin;
+ }
+ }
+ }
+
+ return win;
+}
+
+/**
+ * Create a sanitized display name for an attachment in order to help prevent
+ * people from hiding malicious extensions behind a run of spaces, etc. To do
+ * this, we strip leading/trailing whitespace and collapse long runs of either
+ * whitespace or identical characters. Windows especially will drop trailing
+ * dots and whitespace from filename extensions.
+ *
+ * @param aAttachment the AttachmentInfo object
+ * @returns a sanitized display name for the attachment
+ */
+function SanitizeAttachmentDisplayName(aAttachment) {
+ let displayName = aAttachment.name.trim().replace(/\s+/g, " ");
+ if (AppConstants.platform == "win") {
+ displayName = displayName.replace(/[ \.]+$/, "");
+ }
+ return displayName.replace(/(.)\1{9,}/g, "$1…$1");
+}
+
+/**
+ * Appends a dataTransferItem to the associated event for message attachments,
+ * either from the message reader or the composer.
+ *
+ * @param {Event} event - The associated event.
+ * @param {nsIMsgAttachment[]} attachments - The attachments to setup
+ */
+function setupDataTransfer(event, attachments) {
+ let index = 0;
+ for (let attachment of attachments) {
+ if (attachment.contentType == "text/x-moz-deleted") {
+ return;
+ }
+
+ let name = attachment.name || attachment.displayName;
+
+ if (!attachment.url || !name) {
+ continue;
+ }
+
+ // Only add type/filename info for non-file URLs that don't already
+ // have it.
+ let info = [];
+ if (/(^file:|&filename=)/.test(attachment.url)) {
+ info.push(attachment.url);
+ } else {
+ info.push(
+ attachment.url +
+ "&type=" +
+ attachment.contentType +
+ "&filename=" +
+ encodeURIComponent(name)
+ );
+ }
+ info.push(name, attachment.size, attachment.contentType, attachment.uri);
+ if (attachment.sendViaCloud) {
+ info.push(attachment.cloudFileAccountKey, attachment.cloudPartHeaderData);
+ }
+
+ event.dataTransfer.mozSetDataAt("text/x-moz-url", info.join("\n"), index);
+ event.dataTransfer.mozSetDataAt(
+ "text/x-moz-url-data",
+ attachment.url,
+ index
+ );
+ event.dataTransfer.mozSetDataAt("text/x-moz-url-desc", name, index);
+ event.dataTransfer.mozSetDataAt(
+ "application/x-moz-file-promise-url",
+ attachment.url,
+ index
+ );
+ event.dataTransfer.mozSetDataAt(
+ "application/x-moz-file-promise",
+ new nsFlavorDataProvider(),
+ index
+ );
+ event.dataTransfer.mozSetDataAt(
+ "application/x-moz-file-promise-dest-filename",
+ name.replace(/(.{74}).*(.{10})$/u, "$1...$2"),
+ index
+ );
+ index++;
+ }
+}
+
+/**
+ * Checks if Thunderbird was launched in safe mode and updates the menu items.
+ */
+function updateTroubleshootMenuItem() {
+ if (Services.appinfo.inSafeMode) {
+ let safeMode = document.getElementById("helpTroubleshootMode");
+ document.l10n.setAttributes(safeMode, "menu-help-exit-troubleshoot-mode");
+
+ let appSafeMode = document.getElementById("appmenu_troubleshootMode");
+ if (appSafeMode) {
+ document.l10n.setAttributes(
+ appSafeMode,
+ "appmenu-help-exit-troubleshoot-mode2"
+ );
+ }
+ }
+}
+
+function nsFlavorDataProvider() {}
+
+nsFlavorDataProvider.prototype = {
+ QueryInterface: ChromeUtils.generateQI(["nsIFlavorDataProvider"]),
+
+ getFlavorData(aTransferable, aFlavor, aData) {
+ // get the url for the attachment
+ if (aFlavor == "application/x-moz-file-promise") {
+ var urlPrimitive = {};
+ aTransferable.getTransferData(
+ "application/x-moz-file-promise-url",
+ urlPrimitive
+ );
+
+ var srcUrlPrimitive = urlPrimitive.value.QueryInterface(
+ Ci.nsISupportsString
+ );
+
+ // now get the destination file location from kFilePromiseDirectoryMime
+ var dirPrimitive = {};
+ aTransferable.getTransferData(
+ "application/x-moz-file-promise-dir",
+ dirPrimitive
+ );
+ var destDirectory = dirPrimitive.value.QueryInterface(Ci.nsIFile);
+
+ // now save the attachment to the specified location
+ // XXX: we need more information than just the attachment url to save it,
+ // fortunately, we have an array of all the current attachments so we can
+ // cheat and scan through them
+
+ var attachment = null;
+ for (let index of currentAttachments.keys()) {
+ attachment = currentAttachments[index];
+ if (attachment.url == srcUrlPrimitive) {
+ break;
+ }
+ }
+
+ // call our code for saving attachments
+ if (attachment) {
+ let messenger = Cc["@mozilla.org/messenger;1"].createInstance(
+ Ci.nsIMessenger
+ );
+ let name = attachment.name || attachment.displayName;
+ let destFilePath = messenger.saveAttachmentToFolder(
+ attachment.contentType,
+ attachment.url,
+ name.replace(/(.{74}).*(.{10})$/u, "$1...$2"),
+ attachment.uri,
+ destDirectory
+ );
+ aData.value = destFilePath.QueryInterface(Ci.nsISupports);
+ }
+ if (AppConstants.platform == "macosx") {
+ // Workaround dnd of multiple attachments creating duplicates. See bug 1494588.
+ aTransferable.removeDataFlavor("application/x-moz-file-promise");
+ }
+ }
+ },
+};
diff --git a/comm/mail/base/content/mailTabs.js b/comm/mail/base/content/mailTabs.js
new file mode 100644
index 0000000000..e805eb8afb
--- /dev/null
+++ b/comm/mail/base/content/mailTabs.js
@@ -0,0 +1,390 @@
+/* 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 mail3PaneWindowCommands.js */
+/* import-globals-from mailWindowOverlay.js */
+/* import-globals-from messenger.js */
+
+/* globals contentProgress, statusFeedback */ // From mailWindow.js
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ FolderUtils: "resource:///modules/FolderUtils.jsm",
+ GlodaSyntheticView: "resource:///modules/gloda/GlodaSyntheticView.jsm",
+ MailUtils: "resource:///modules/MailUtils.jsm",
+ MsgHdrSyntheticView: "resource:///modules/MsgHdrSyntheticView.jsm",
+ MsgHdrToMimeMessage: "resource:///modules/gloda/MimeMessage.jsm",
+});
+
+/**
+ * Tabs for displaying mail folders and messages.
+ */
+var mailTabType = {
+ name: "mailTab",
+ perTabPanel: "vbox",
+ _cloneTemplate(template, tab, onDOMContentLoaded, onLoad) {
+ let tabmail = document.getElementById("tabmail");
+
+ let clone = document.getElementById(template).content.cloneNode(true);
+ let browser = clone.querySelector("browser");
+ browser.id = `${tab.mode.name}Browser${tab.mode._nextId}`;
+ browser.addEventListener(
+ "DOMTitleChanged",
+ () => {
+ tab.title = browser.contentTitle;
+ tabmail.setTabTitle(tab);
+ },
+ true
+ );
+ let linkRelIconHandler = event => {
+ if (event.target.rel != "icon") {
+ return;
+ }
+ // Allow 3pane and message tab to set a tab favicon. Mail content should
+ // not be allowed to do that.
+ if (event.target.ownerGlobal.frameElement == browser) {
+ tabmail.setTabFavIcon(tab, event.target.href);
+ }
+ };
+ browser.addEventListener("DOMLinkAdded", linkRelIconHandler);
+ browser.addEventListener("DOMLinkChanged", linkRelIconHandler);
+ if (onDOMContentLoaded) {
+ browser.addEventListener(
+ "DOMContentLoaded",
+ event => {
+ if (!tab.closed) {
+ onDOMContentLoaded(event.target.ownerGlobal);
+ }
+ },
+ { capture: true, once: true }
+ );
+ }
+ browser.addEventListener(
+ "load",
+ event => {
+ if (!tab.closed) {
+ onLoad(event.target.ownerGlobal);
+ }
+ },
+ { capture: true, once: true }
+ );
+
+ tab.title = "";
+ tab.panel.id = `${tab.mode.name}${tab.mode._nextId}`;
+ tab.panel.appendChild(clone);
+ // `chromeBrowser` refers to the outermost browser in the tab, i.e. the
+ // browser displaying about:3pane or about:message.
+ tab.chromeBrowser = browser;
+ tab.mode._nextId++;
+ },
+
+ closeTab(tab) {},
+ saveTabState(tab) {},
+
+ modes: {
+ mail3PaneTab: {
+ _nextId: 1,
+ isDefault: true,
+
+ openTab(tab, args = {}) {
+ mailTabType._cloneTemplate(
+ "mail3PaneTabTemplate",
+ tab,
+ win => {
+ // Send the state to the page so it can restore immediately.
+ win.openingState = args;
+ },
+ async win => {
+ win.tabOrWindow = tab;
+ // onLoad has happened. async activities of scripts running of
+ // that may not have finished. Let's go back to the end of the
+ // event queue giving win.messageBrowser time to get defined.
+ await new Promise(resolve => win.setTimeout(resolve));
+ win.messageBrowser.contentWindow.tabOrWindow = tab;
+ if (!args.background) {
+ // Update telemetry once the tab has loaded and decided if the
+ // panes are visible.
+ Services.telemetry.keyedScalarSet(
+ "tb.ui.configuration.pane_visibility",
+ "folderPane",
+ win.paneLayout.folderPaneVisible
+ );
+ Services.telemetry.keyedScalarSet(
+ "tb.ui.configuration.pane_visibility",
+ "messagePane",
+ win.paneLayout.messagePaneVisible
+ );
+ }
+
+ // The first tab has loaded and ready for the user to interact with
+ // it. We can let the rest of the start-up happen now without
+ // appearing to slow the program down.
+ if (tab.first) {
+ Services.obs.notifyObservers(window, "mail-startup-done");
+ requestIdleCallback(function () {
+ if (!window.closed) {
+ Services.obs.notifyObservers(
+ window,
+ "mail-idle-startup-tasks-finished"
+ );
+ }
+ });
+ }
+ }
+ );
+
+ // `browser` and `linkedBrowser` refer to the message display browser
+ // within this tab. They may be null if the browser isn't visible.
+ // Extension APIs refer to these properties.
+ Object.defineProperty(tab, "browser", {
+ get() {
+ if (!tab.chromeBrowser.contentWindow) {
+ return null;
+ }
+
+ const { messageBrowser, webBrowser } =
+ tab.chromeBrowser.contentWindow;
+ if (messageBrowser && !messageBrowser.hidden) {
+ return messageBrowser.contentDocument.getElementById(
+ "messagepane"
+ );
+ }
+ if (webBrowser && !webBrowser.hidden) {
+ return webBrowser;
+ }
+
+ return null;
+ },
+ });
+ Object.defineProperty(tab, "linkedBrowser", {
+ get() {
+ return tab.browser;
+ },
+ });
+
+ // Content properties.
+ Object.defineProperty(tab, "message", {
+ get() {
+ let dbView = tab.chromeBrowser.contentWindow.gDBView;
+ if (dbView?.selection?.count) {
+ return dbView.hdrForFirstSelectedMessage;
+ }
+ return null;
+ },
+ });
+ Object.defineProperty(tab, "folder", {
+ get() {
+ return tab.chromeBrowser.contentWindow.gFolder;
+ },
+ set(folder) {
+ tab.chromeBrowser.contentWindow.displayFolder(folder.URI);
+ },
+ });
+
+ tab.canClose = !tab.first;
+ return tab;
+ },
+ persistTab(tab) {
+ if (!tab.folder) {
+ return null;
+ }
+ return {
+ firstTab: tab.first,
+ folderPaneVisible:
+ tab.chromeBrowser.contentWindow.paneLayout.folderPaneVisible,
+ folderURI: tab.folder.URI,
+ messagePaneVisible:
+ tab.chromeBrowser.contentWindow.paneLayout.messagePaneVisible,
+ };
+ },
+ restoreTab(tabmail, persistedState) {
+ if (!persistedState.firstTab) {
+ tabmail.openTab("mail3PaneTab", persistedState);
+ return;
+ }
+
+ // Manually call onTabRestored, since it is usually called by openTab(),
+ // which is skipped for the first tab.
+ let restoreState = tabmail._restoringTabState;
+ if (restoreState) {
+ for (let tabMonitor of tabmail.tabMonitors) {
+ try {
+ if (
+ "onTabRestored" in tabMonitor &&
+ restoreState &&
+ tabMonitor.monitorName in restoreState.ext
+ ) {
+ tabMonitor.onTabRestored(
+ tabmail.tabInfo[0],
+ restoreState.ext[tabMonitor.monitorName],
+ false
+ );
+ }
+ } catch (ex) {
+ console.error(ex);
+ }
+ }
+ }
+
+ let { chromeBrowser, closed } = tabmail.tabInfo[0];
+ if (
+ chromeBrowser.contentDocument.readyState == "complete" &&
+ chromeBrowser.currentURI.spec == "about:3pane"
+ ) {
+ chromeBrowser.contentWindow.restoreState(persistedState);
+ return;
+ }
+
+ // Send the state to the page so it can restore immediately. Don't
+ // overwrite any existing state properties from `openTab` (especially
+ // `first`), unless there is a newer value.
+ let sawDOMContentLoaded = false;
+ chromeBrowser.addEventListener(
+ "DOMContentLoaded",
+ event => {
+ if (!closed && event.target == chromeBrowser.contentDocument) {
+ let about3Pane = event.target.ownerGlobal;
+ about3Pane.openingState = {
+ ...about3Pane.openingState,
+ ...persistedState,
+ };
+ sawDOMContentLoaded = true;
+ }
+ },
+ { capture: true, once: true }
+ );
+ // Didn't see DOMContentLoaded? Restore the state on load. The state
+ // from `openTab` has been used by now.
+ chromeBrowser.addEventListener(
+ "load",
+ event => {
+ if (
+ !closed &&
+ !sawDOMContentLoaded &&
+ event.target == chromeBrowser.contentDocument
+ ) {
+ chromeBrowser.contentWindow.restoreState(persistedState);
+ }
+ },
+ { capture: true, once: true }
+ );
+ },
+ showTab(tab) {
+ if (
+ tab.chromeBrowser.currentURI.spec != "about:3pane" ||
+ tab.chromeBrowser.contentDocument.readyState != "complete"
+ ) {
+ return;
+ }
+
+ // Update telemetry when switching to a 3-pane tab. The telemetry
+ // reflects the state of the last 3-pane tab that was shown, but not
+ // if the state changed since it was shown.
+ Services.telemetry.keyedScalarSet(
+ "tb.ui.configuration.pane_visibility",
+ "folderPane",
+ tab.chromeBrowser.contentWindow.paneLayout.folderPaneVisible
+ );
+ Services.telemetry.keyedScalarSet(
+ "tb.ui.configuration.pane_visibility",
+ "messagePane",
+ tab.chromeBrowser.contentWindow.paneLayout.messagePaneVisible
+ );
+ },
+ supportsCommand(command, tab) {
+ return tab.chromeBrowser?.contentWindow.commandController?.supportsCommand(
+ command
+ );
+ },
+ isCommandEnabled(command, tab) {
+ return tab.chromeBrowser?.contentWindow.commandController?.isCommandEnabled(
+ command
+ );
+ },
+ doCommand(command, tab, ...args) {
+ tab.chromeBrowser?.contentWindow.commandController?.doCommand(
+ command,
+ ...args
+ );
+ },
+ getBrowser(tab) {
+ return tab.browser;
+ },
+ },
+ mailMessageTab: {
+ _nextId: 1,
+ openTab(tab, { messageURI, viewWrapper } = {}) {
+ mailTabType._cloneTemplate(
+ "mailMessageTabTemplate",
+ tab,
+ win => {
+ // Make tabmail give the message pane focus when this tab becomes
+ // the active tab.
+ tab.lastActiveElement = tab.browser;
+ },
+ win => {
+ win.tabOrWindow = tab;
+ win.displayMessage(messageURI, viewWrapper);
+ }
+ );
+
+ // `browser` and `linkedBrowser` refer to the message display browser
+ // within this tab. They may be null if the browser isn't visible.
+ // Extension APIs refer to these properties.
+ Object.defineProperty(tab, "browser", {
+ get() {
+ return tab.chromeBrowser.contentDocument?.getElementById(
+ "messagepane"
+ );
+ },
+ });
+ Object.defineProperty(tab, "linkedBrowser", {
+ get() {
+ return tab.browser;
+ },
+ });
+
+ // Content properties.
+ Object.defineProperty(tab, "message", {
+ get() {
+ return tab.chromeBrowser.contentWindow.gMessage;
+ },
+ });
+ Object.defineProperty(tab, "folder", {
+ get() {
+ return tab.chromeBrowser.contentWindow.gViewWrapper
+ ?.displayedFolder;
+ },
+ });
+
+ return tab;
+ },
+ persistTab(tab) {
+ return { messageURI: tab.chromeBrowser.contentWindow.gMessageURI };
+ },
+ restoreTab(tabmail, persistedState) {
+ tabmail.openTab("mailMessageTab", persistedState);
+ },
+ showTab(tab) {},
+ supportsCommand(command, tab) {
+ return tab.chromeBrowser?.contentWindow.commandController?.supportsCommand(
+ command
+ );
+ },
+ isCommandEnabled(command, tab) {
+ return tab.chromeBrowser.contentWindow.commandController?.isCommandEnabled(
+ command
+ );
+ },
+ doCommand(command, tab, ...args) {
+ tab.chromeBrowser?.contentWindow.commandController?.doCommand(
+ command,
+ ...args
+ );
+ },
+ getBrowser(tab) {
+ return tab.browser;
+ },
+ },
+ },
+};
diff --git a/comm/mail/base/content/mailWindow.js b/comm/mail/base/content/mailWindow.js
new file mode 100644
index 0000000000..106678ec56
--- /dev/null
+++ b/comm/mail/base/content/mailWindow.js
@@ -0,0 +1,1153 @@
+/**
+ * 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 ../../../../toolkit/content/contentAreaUtils.js */
+/* import-globals-from ../../../../toolkit/content/viewZoomOverlay.js */
+/* import-globals-from globalOverlay.js */
+/* import-globals-from mail-offline.js */
+/* import-globals-from mailCore.js */
+/* import-globals-from mailWindowOverlay.js */
+/* import-globals-from messenger.js */
+/* import-globals-from utilityOverlay.js */
+
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+XPCOMUtils.defineLazyModuleGetters(this, {
+ appIdleManager: "resource:///modules/AppIdleManager.jsm",
+ Gloda: "resource:///modules/gloda/GlodaPublic.jsm",
+ UIDensity: "resource:///modules/UIDensity.jsm",
+});
+
+XPCOMUtils.defineLazyScriptGetter(
+ this,
+ "PrintUtils",
+ "chrome://messenger/content/printUtils.js"
+);
+
+// This file stores variables common to mail windows
+var messenger;
+var statusFeedback;
+var msgWindow;
+
+UIDensity.registerWindow(window);
+
+/**
+ * Called by messageWindow.xhtml:onunload, the 'single message display window'.
+ *
+ * Also called by messenger.xhtml:onunload's (the 3-pane window inside of tabs
+ * window) unload function, OnUnloadMessenger.
+ */
+function OnMailWindowUnload() {
+ MailOfflineMgr.uninit();
+
+ // all dbview closing is handled by OnUnloadMessenger for the 3-pane (it closes
+ // the tabs which close their views) and OnUnloadMessageWindow for the
+ // standalone message window.
+
+ MailServices.mailSession.RemoveMsgWindow(msgWindow);
+ // the tabs have the FolderDisplayWidget close their 'messenger' instances for us
+
+ window.browserDOMWindow = null;
+
+ msgWindow.closeWindow();
+
+ msgWindow.notificationCallbacks = null;
+ window.MsgStatusFeedback.unload();
+ Cc["@mozilla.org/activity-manager;1"]
+ .getService(Ci.nsIActivityManager)
+ .removeListener(window.MsgStatusFeedback);
+}
+
+/**
+ * When copying/dragging, convert imap/mailbox URLs of images into data URLs so
+ * that the images can be accessed in a paste elsewhere.
+ */
+function onCopyOrDragStart(e) {
+ let browser = getBrowser();
+ if (!browser) {
+ return;
+ }
+
+ // We're only interested if this is in the message content.
+ let sourceDoc = browser.contentDocument;
+ if (e.target.ownerDocument != sourceDoc) {
+ return;
+ }
+ let sourceURL = sourceDoc.URL;
+ let protocol = sourceURL.substr(0, sourceURL.indexOf(":")).toLowerCase();
+ if (
+ !(
+ Services.io.getProtocolHandler(protocol) instanceof
+ Ci.nsIMsgMessageFetchPartService
+ )
+ ) {
+ // Can't fetch parts, not a message protocol, don't process.
+ return;
+ }
+
+ let imgMap = new Map(); // Mapping img.src -> dataURL.
+
+ // For copy, the data of what is to be copied is not accessible at this point.
+ // Figure out what images are a) part of the selection and b) visible in
+ // the current document. If their source isn't http or data already, convert
+ // them to data URLs.
+
+ let selection = sourceDoc.getSelection();
+ let draggedImg = selection.isCollapsed ? e.target : null;
+ for (let img of sourceDoc.images) {
+ if (/^(https?|data):/.test(img.src)) {
+ continue;
+ }
+
+ if (img.naturalWidth == 0) {
+ // Broken/inaccessible image then...
+ continue;
+ }
+
+ if (!draggedImg && !selection.containsNode(img, true)) {
+ continue;
+ }
+
+ let style = window.getComputedStyle(img);
+ if (style.display == "none" || style.visibility == "hidden") {
+ continue;
+ }
+
+ // Do not convert if the image is specifically flagged to not snarf.
+ if (img.getAttribute("moz-do-not-send") == "true") {
+ continue;
+ }
+
+ // We don't need to wait for the image to load. If it isn't already loaded
+ // in the source document, we wouldn't want it anyway.
+ let canvas = sourceDoc.createElement("canvas");
+ canvas.width = img.width;
+ canvas.height = img.height;
+ canvas.getContext("2d").drawImage(img, 0, 0, img.width, img.height);
+
+ let type = /\.jpe?g$/i.test(img.src) ? "image/jpg" : "image/png";
+ imgMap.set(img.src, canvas.toDataURL(type));
+ }
+
+ if (imgMap.size == 0) {
+ // Nothing that needs converting!
+ return;
+ }
+
+ let clonedSelection = draggedImg
+ ? draggedImg.cloneNode(false)
+ : selection.getRangeAt(0).cloneContents();
+ let div = sourceDoc.createElement("div");
+ div.appendChild(clonedSelection);
+
+ let images = div.querySelectorAll("img");
+ for (let img of images) {
+ if (!imgMap.has(img.src)) {
+ continue;
+ }
+ img.src = imgMap.get(img.src);
+ }
+
+ let html = div.innerHTML;
+ let parserUtils = Cc["@mozilla.org/parserutils;1"].getService(
+ Ci.nsIParserUtils
+ );
+ let plain = parserUtils.convertToPlainText(
+ html,
+ Ci.nsIDocumentEncoder.OutputForPlainTextClipboardCopy,
+ 0
+ );
+ if ("clipboardData" in e) {
+ // copy
+ e.clipboardData.setData("text/html", html);
+ e.clipboardData.setData("text/plain", plain);
+ e.preventDefault();
+ } else if ("dataTransfer" in e) {
+ // drag
+ e.dataTransfer.setData("text/html", html);
+ e.dataTransfer.setData("text/plain", plain);
+ }
+}
+
+function CreateMailWindowGlobals() {
+ // Create message window object
+ // eslint-disable-next-line no-global-assign
+ msgWindow = Cc["@mozilla.org/messenger/msgwindow;1"].createInstance(
+ Ci.nsIMsgWindow
+ );
+ // get the messenger instance
+ // eslint-disable-next-line no-global-assign
+ messenger = Cc["@mozilla.org/messenger;1"].createInstance(Ci.nsIMessenger);
+ messenger.setWindow(window, msgWindow);
+
+ window.addEventListener("blur", appIdleManager.onBlur);
+ window.addEventListener("focus", appIdleManager.onFocus);
+
+ // Create windows status feedback
+ // set the JS implementation of status feedback before creating the c++ one..
+ window.MsgStatusFeedback = new nsMsgStatusFeedback();
+ // double register the status feedback object as the xul browser window implementation
+ window
+ .getInterface(Ci.nsIWebNavigation)
+ .QueryInterface(Ci.nsIDocShellTreeItem)
+ .treeOwner.QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIAppWindow).XULBrowserWindow = window.MsgStatusFeedback;
+
+ window.browserDOMWindow = new nsBrowserAccess();
+
+ // eslint-disable-next-line no-global-assign
+ statusFeedback = Cc["@mozilla.org/messenger/statusfeedback;1"].createInstance(
+ Ci.nsIMsgStatusFeedback
+ );
+ statusFeedback.setWrappedStatusFeedback(window.MsgStatusFeedback);
+
+ Cc["@mozilla.org/activity-manager;1"]
+ .getService(Ci.nsIActivityManager)
+ .addListener(window.MsgStatusFeedback);
+}
+
+function toggleCaretBrowsing() {
+ const enabledPref = "accessibility.browsewithcaret_shortcut.enabled";
+ const warnPref = "accessibility.warn_on_browsewithcaret";
+ const caretPref = "accessibility.browsewithcaret";
+
+ if (!Services.prefs.getBoolPref(enabledPref)) {
+ return;
+ }
+
+ let useCaret = Services.prefs.getBoolPref(caretPref, false);
+ let warn = Services.prefs.getBoolPref(warnPref, true);
+ if (!warn || useCaret) {
+ // Toggle immediately.
+ try {
+ Services.prefs.setBoolPref(caretPref, !useCaret);
+ } catch (ex) {}
+ return;
+ }
+
+ // Async prompt.
+ document.l10n
+ .formatValues([
+ { id: "caret-browsing-prompt-title" },
+ { id: "caret-browsing-prompt-text" },
+ { id: "caret-browsing-prompt-check-text" },
+ ])
+ .then(([title, promptText, checkText]) => {
+ let checkValue = { value: false };
+
+ useCaret =
+ 0 ===
+ Services.prompt.confirmEx(
+ window,
+ title,
+ promptText,
+ Services.prompt.STD_YES_NO_BUTTONS |
+ Services.prompt.BUTTON_POS_1_DEFAULT,
+ null,
+ null,
+ null,
+ checkText,
+ checkValue
+ );
+
+ if (checkValue.value) {
+ if (useCaret) {
+ try {
+ Services.prefs.setBoolPref(warnPref, false);
+ } catch (ex) {}
+ } else {
+ try {
+ Services.prefs.setBoolPref(enabledPref, false);
+ } catch (ex) {}
+ }
+ }
+ try {
+ Services.prefs.setBoolPref(caretPref, useCaret);
+ } catch (ex) {}
+ });
+}
+
+function InitMsgWindow() {
+ // Set the domWindow before setting the status feedback object.
+ msgWindow.domWindow = window;
+ msgWindow.statusFeedback = statusFeedback;
+ MailServices.mailSession.AddMsgWindow(msgWindow);
+ msgWindow.rootDocShell.allowAuth = true;
+ msgWindow.rootDocShell.appType = Ci.nsIDocShell.APP_TYPE_MAIL;
+ // Ensure we don't load xul error pages into the main window
+ msgWindow.rootDocShell.useErrorPages = false;
+
+ document.addEventListener("dragstart", onCopyOrDragStart, true);
+
+ let keypressListener = {
+ handleEvent: event => {
+ if (event.defaultPrevented) {
+ return;
+ }
+
+ switch (event.code) {
+ case "F7":
+ // shift + F7 is the default DevTools shortcut for the Style Editor.
+ if (!event.shiftKey) {
+ toggleCaretBrowsing();
+ }
+ break;
+ }
+ },
+ };
+ Services.els.addSystemEventListener(
+ document,
+ "keypress",
+ keypressListener,
+ false
+ );
+}
+
+// We're going to implement our status feedback for the mail window in JS now.
+// the following contains the implementation of our status feedback object
+
+function nsMsgStatusFeedback() {
+ this._statusText = document.getElementById("statusText");
+ this._statusPanel = document.getElementById("statusbar-display");
+ this._progressBar = document.getElementById("statusbar-icon");
+ this._progressBarContainer = document.getElementById(
+ "statusbar-progresspanel"
+ );
+ this._throbber = document.getElementById("throbber-box");
+ this._activeProcesses = [];
+
+ // make sure the stop button is accurate from the get-go
+ goUpdateCommand("cmd_stop");
+}
+
+/**
+ * @implements {nsIMsgStatusFeedback}
+ * @implements {nsIXULBrowserWindow}
+ * @implements {nsIActivityMgrListener}
+ * @implements {nsIActivityListener}
+ * @implements {nsISupportsWeakReference}
+ */
+nsMsgStatusFeedback.prototype = {
+ // Document elements.
+ _statusText: null,
+ _statusPanel: null,
+ _progressBar: null,
+ _progressBarContainer: null,
+ _throbber: null,
+
+ // Member variables.
+ _startTimeoutID: null,
+ _stopTimeoutID: null,
+ // How many start meteors have been requested.
+ _startRequests: 0,
+ _meteorsSpinning: false,
+ _defaultStatusText: "",
+ _progressBarVisible: false,
+ _activeProcesses: null,
+ _statusFeedbackProgress: -1,
+ _statusLastShown: 0,
+ _lastStatusText: null,
+
+ // unload - call to remove links to listeners etc.
+ unload() {
+ // Remove listeners for any active processes we have hooked ourselves into.
+ this._activeProcesses.forEach(function (element) {
+ element.removeListener(this);
+ }, this);
+ },
+
+ // nsIXULBrowserWindow implementation.
+ setJSStatus(status) {
+ if (status.length > 0) {
+ this.showStatusString(status);
+ }
+ },
+
+ /*
+ * Set the statusbar display for hovered links, from browser.js.
+ *
+ * @param {String} url - The href to display.
+ * @param {Element} anchorElt - Element.
+ */
+ setOverLink(url, anchorElt) {
+ if (url) {
+ url = Services.textToSubURI.unEscapeURIForUI(url);
+
+ // Encode bidirectional formatting characters.
+ // (RFC 3987 sections 3.2 and 4.1 paragraph 6)
+ url = url.replace(
+ /[\u200e\u200f\u202a\u202b\u202c\u202d\u202e]/g,
+ encodeURIComponent
+ );
+ }
+
+ if (!document.getElementById("status-bar").hidden) {
+ this._statusText.value = url;
+ } else {
+ // Statusbar invisible: Show link in statuspanel instead.
+ // TODO: consider porting the Firefox implementation of LinkTargetDisplay.
+ this._statusPanel.label = url;
+ }
+ },
+
+ // Called before links are navigated to to allow us to retarget them if needed.
+ onBeforeLinkTraversal(originalTarget, linkURI, linkNode, isAppTab) {
+ return originalTarget;
+ },
+
+ // Called by BrowserParent::RecvShowTooltip, needed for tooltips in content tabs.
+ showTooltip(xDevPix, yDevPix, tooltip, direction, browser) {
+ if (
+ Cc["@mozilla.org/widget/dragservice;1"]
+ .getService(Ci.nsIDragService)
+ .getCurrentSession()
+ ) {
+ return;
+ }
+
+ let elt = document.getElementById("remoteBrowserTooltip");
+ elt.label = tooltip;
+ elt.style.direction = direction;
+ elt.openPopupAtScreen(
+ xDevPix / window.devicePixelRatio,
+ yDevPix / window.devicePixelRatio,
+ false,
+ null
+ );
+ },
+
+ // Called by BrowserParent::RecvHideTooltip, needed for tooltips in content tabs.
+ hideTooltip() {
+ let elt = document.getElementById("remoteBrowserTooltip");
+ elt.hidePopup();
+ },
+
+ getTabCount() {
+ let tabmail = document.getElementById("tabmail");
+ // messageWindow.xhtml does not have multiple tabs.
+ return tabmail ? tabmail.tabs.length : 1;
+ },
+
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIMsgStatusFeedback",
+ "nsIXULBrowserWindow",
+ "nsIActivityMgrListener",
+ "nsIActivityListener",
+ "nsISupportsWeakReference",
+ ]),
+
+ // nsIMsgStatusFeedback implementation.
+ showStatusString(statusText) {
+ if (!statusText) {
+ statusText = this._defaultStatusText;
+ } else {
+ this._defaultStatusText = "";
+ }
+ // Let's make sure the display doesn't flicker.
+ const timeBetweenDisplay = 500;
+ const now = Date.now();
+ if (now - this._statusLastShown > timeBetweenDisplay) {
+ // Cancel any pending status message. The timeout is not guaranteed
+ // to run within timeBetweenDisplay milliseconds.
+ this._lastStatusText = null;
+
+ this._statusLastShown = now;
+ if (this._statusText.value != statusText) {
+ this._statusText.value = statusText;
+ }
+ } else {
+ if (this._lastStatusText !== null) {
+ // There's already a pending display. Replace it.
+ this._lastStatusText = statusText;
+ return;
+ }
+ // Arrange for this to be shown in timeBetweenDisplay milliseconds.
+ this._lastStatusText = statusText;
+ setTimeout(() => {
+ if (this._lastStatusText !== null) {
+ this._statusLastShown = Date.now();
+ if (this._statusText.value != this._lastStatusText) {
+ this._statusText.value = this._lastStatusText;
+ }
+ this._lastStatusText = null;
+ }
+ }, timeBetweenDisplay);
+ }
+ },
+
+ setStatusString(status) {
+ if (status.length > 0) {
+ this._defaultStatusText = status;
+ this._statusText.value = status;
+ }
+ },
+
+ _startMeteors() {
+ this._meteorsSpinning = true;
+ this._startTimeoutID = null;
+
+ // Turn progress meter on.
+ this.updateProgress();
+
+ // Start the throbber.
+ if (this._throbber) {
+ this._throbber.setAttribute("busy", true);
+ }
+
+ document.querySelector(".throbber")?.classList.add("busy");
+
+ // Update the stop button
+ goUpdateCommand("cmd_stop");
+ },
+
+ startMeteors() {
+ this._startRequests++;
+ // If we don't already have a start meteor timeout pending
+ // and the meteors aren't spinning, then kick off a start.
+ if (
+ !this._startTimeoutID &&
+ !this._meteorsSpinning &&
+ "MsgStatusFeedback" in window
+ ) {
+ this._startTimeoutID = setTimeout(
+ () => window.MsgStatusFeedback._startMeteors(),
+ 500
+ );
+ }
+
+ // Since we are going to start up the throbber no sense in processing
+ // a stop timeout...
+ if (this._stopTimeoutID) {
+ clearTimeout(this._stopTimeoutID);
+ this._stopTimeoutID = null;
+ }
+ },
+
+ _stopMeteors() {
+ this.showStatusString(this._defaultStatusText);
+
+ // stop the throbber
+ if (this._throbber) {
+ this._throbber.setAttribute("busy", false);
+ }
+
+ document.querySelector(".throbber")?.classList.remove("busy");
+
+ this._meteorsSpinning = false;
+ this._stopTimeoutID = null;
+
+ // Turn progress meter off.
+ this._statusFeedbackProgress = -1;
+ this.updateProgress();
+
+ // Update the stop button
+ goUpdateCommand("cmd_stop");
+ },
+
+ stopMeteors() {
+ if (this._startRequests > 0) {
+ this._startRequests--;
+ }
+
+ // If we are going to be starting the meteors, cancel the start.
+ if (this._startRequests == 0 && this._startTimeoutID) {
+ clearTimeout(this._startTimeoutID);
+ this._startTimeoutID = null;
+ }
+
+ // If we have no more pending starts and we don't have a stop timeout
+ // already in progress AND the meteors are currently running then fire a
+ // stop timeout to shut them down.
+ if (
+ this._startRequests == 0 &&
+ !this._stopTimeoutID &&
+ this._meteorsSpinning &&
+ "MsgStatusFeedback" in window
+ ) {
+ this._stopTimeoutID = setTimeout(
+ () => window.MsgStatusFeedback._stopMeteors(),
+ 500
+ );
+ }
+ },
+
+ showProgress(percentage) {
+ this._statusFeedbackProgress = percentage;
+ this.updateProgress();
+ },
+
+ updateProgress() {
+ if (this._meteorsSpinning) {
+ // In this function, we expect that the maximum for each progress is 100,
+ // i.e. we are dealing with percentages. Hence we can combine several
+ // processes running at the same time.
+ let currentProgress = 0;
+ let progressCount = 0;
+
+ // For each activity that is in progress, get its status.
+
+ this._activeProcesses.forEach(function (element) {
+ if (
+ element.state == Ci.nsIActivityProcess.STATE_INPROGRESS &&
+ element.percentComplete != -1
+ ) {
+ currentProgress += element.percentComplete;
+ ++progressCount;
+ }
+ });
+
+ // Add the generic progress that's fed to the status feedback object if
+ // we've got one.
+ if (this._statusFeedbackProgress != -1) {
+ currentProgress += this._statusFeedbackProgress;
+ ++progressCount;
+ }
+
+ let percentage = 0;
+ if (progressCount) {
+ percentage = currentProgress / progressCount;
+ }
+
+ if (!percentage) {
+ this._progressBar.removeAttribute("value");
+ } else {
+ this._progressBar.value = percentage;
+ this._progressBar.label = Math.round(percentage) + "%";
+ }
+ if (!this._progressBarVisible) {
+ this._progressBarContainer.removeAttribute("collapsed");
+ this._progressBarVisible = true;
+ }
+ } else {
+ // Stop the bar spinning as we're not doing anything now.
+ this._progressBar.value = 0;
+ this._progressBar.label = "";
+
+ if (this._progressBarVisible) {
+ this._progressBarContainer.collapsed = true;
+ this._progressBarVisible = false;
+ }
+ }
+ },
+
+ // nsIActivityMgrListener
+ onAddedActivity(aID, aActivity) {
+ // ignore Gloda activity for status bar purposes
+ if (aActivity.initiator == Gloda) {
+ return;
+ }
+ if (aActivity instanceof Ci.nsIActivityEvent) {
+ this.showStatusString(aActivity.displayText);
+ } else if (aActivity instanceof Ci.nsIActivityProcess) {
+ this._activeProcesses.push(aActivity);
+ aActivity.addListener(this);
+ this.startMeteors();
+ }
+ },
+
+ onRemovedActivity(aID) {
+ this._activeProcesses = this._activeProcesses.filter(function (element) {
+ if (element.id == aID) {
+ element.removeListener(this);
+ this.stopMeteors();
+ return false;
+ }
+ return true;
+ }, this);
+ },
+
+ // nsIActivityListener
+ onStateChanged(aActivity, aOldState) {},
+
+ onProgressChanged(
+ aActivity,
+ aStatusText,
+ aWorkUnitsCompleted,
+ aTotalWorkUnits
+ ) {
+ let index = this._activeProcesses.indexOf(aActivity);
+
+ // Iterate through the list trying to find the first active process, but
+ // only go as far as our process.
+ for (var i = 0; i < index; ++i) {
+ if (
+ this._activeProcesses[i].status ==
+ Ci.nsIActivityProcess.STATE_INPROGRESS
+ ) {
+ break;
+ }
+ }
+
+ // If the found activity was the same as our activity, update the status
+ // text.
+ if (i == index) {
+ // Use the display text if we haven't got any status text. I'm assuming
+ // that the status text will be generally what we want to see on the
+ // status bar.
+ this.showStatusString(aStatusText ? aStatusText : aActivity.displayText);
+ }
+
+ this.updateProgress();
+ },
+
+ onHandlerChanged(aActivity) {},
+};
+
+/**
+ * Returns the browser element of the current tab.
+ * The zoom manager, view source and possibly some other functions still rely
+ * on the getBrowser function.
+ */
+function getBrowser() {
+ let tabmail = document.getElementById("tabmail");
+ return tabmail ? tabmail.getBrowserForSelectedTab() : null;
+}
+
+// Given the server, open the twisty and the set the selection
+// on inbox of that server.
+// prompt if offline.
+function OpenInboxForServer(server) {
+ // TODO: Reimplement this or fix the caller?
+}
+
+/** Update state of zoom type (text vs. full) menu item. */
+function UpdateFullZoomMenu() {
+ let cmdItem = document.getElementById("cmd_fullZoomToggle");
+ cmdItem.setAttribute("checked", !ZoomManager.useFullZoom);
+}
+
+window.addEventListener("DoZoomEnlargeBy10", event =>
+ ZoomManager.scrollZoomEnlarge(event.target)
+);
+
+window.addEventListener("DoZoomReduceBy10", event =>
+ ZoomManager.scrollReduceEnlarge(event.target)
+);
+
+function nsBrowserAccess() {}
+
+nsBrowserAccess.prototype = {
+ QueryInterface: ChromeUtils.generateQI(["nsIBrowserDOMWindow"]),
+
+ _openURIInNewTab(
+ aURI,
+ aReferrerInfo,
+ aIsExternal,
+ aOpenWindowInfo = null,
+ aTriggeringPrincipal = null,
+ aCsp = null,
+ aSkipLoad = false,
+ aMessageManagerGroup = null
+ ) {
+ let win, needToFocusWin;
+
+ // Try the current window. If we're in a popup, fall back on the most
+ // recent browser window.
+ if (!window.document.documentElement.getAttribute("chromehidden")) {
+ win = window;
+ } else {
+ win = getMostRecentMailWindow();
+ needToFocusWin = true;
+ }
+
+ if (!win) {
+ // we couldn't find a suitable window, a new one needs to be opened.
+ return null;
+ }
+
+ let loadInBackground = Services.prefs.getBoolPref(
+ "browser.tabs.loadDivertedInBackground"
+ );
+
+ let tabmail = win.document.getElementById("tabmail");
+ let newTab = tabmail.openTab("contentTab", {
+ background: loadInBackground,
+ csp: aCsp,
+ linkHandler: aMessageManagerGroup,
+ openWindowInfo: aOpenWindowInfo,
+ referrerInfo: aReferrerInfo,
+ skipLoad: aSkipLoad,
+ triggeringPrincipal: aTriggeringPrincipal,
+ url: aURI ? aURI.spec : "about:blank",
+ });
+
+ if (needToFocusWin || (!loadInBackground && aIsExternal)) {
+ win.focus();
+ }
+
+ return newTab.browser;
+ },
+
+ createContentWindow(
+ aURI,
+ aOpenWindowInfo,
+ aWhere,
+ aFlags,
+ aTriggeringPrincipal,
+ aCsp
+ ) {
+ return this.getContentWindowOrOpenURI(
+ null,
+ aOpenWindowInfo,
+ aWhere,
+ aFlags,
+ aTriggeringPrincipal,
+ aCsp,
+ true
+ );
+ },
+
+ createContentWindowInFrame(aURI, aParams, aWhere, aFlags, aName) {
+ // Passing a null-URI to only create the content window,
+ // and pass true for aSkipLoad to prevent loading of
+ // about:blank
+ return this.getContentWindowOrOpenURIInFrame(
+ null,
+ aParams,
+ aWhere,
+ aFlags,
+ aName,
+ true
+ );
+ },
+
+ openURI(aURI, aOpenWindowInfo, aWhere, aFlags, aTriggeringPrincipal, aCsp) {
+ if (!aURI) {
+ throw Components.Exception(
+ "openURI should only be called with a valid URI",
+ Cr.NS_ERROR_FAILURE
+ );
+ }
+ return this.getContentWindowOrOpenURI(
+ aURI,
+ aOpenWindowInfo,
+ aWhere,
+ aFlags,
+ aTriggeringPrincipal,
+ aCsp,
+ false
+ );
+ },
+
+ openURIInFrame(aURI, aParams, aWhere, aFlags, aName) {
+ return this.getContentWindowOrOpenURIInFrame(
+ aURI,
+ aParams,
+ aWhere,
+ aFlags,
+ aName,
+ false
+ );
+ },
+
+ getContentWindowOrOpenURI(
+ aURI,
+ aOpenWindowInfo,
+ aWhere,
+ aFlags,
+ aTriggeringPrincipal,
+ aCsp,
+ aSkipLoad
+ ) {
+ if (aWhere == Ci.nsIBrowserDOMWindow.OPEN_PRINT_BROWSER) {
+ let browser =
+ PrintUtils.handleStaticCloneCreatedForPrint(aOpenWindowInfo);
+ return browser ? browser.browsingContext : null;
+ }
+
+ let isExternal = !!(aFlags & Ci.nsIBrowserDOMWindow.OPEN_EXTERNAL);
+
+ if (aOpenWindowInfo && isExternal) {
+ throw Components.Exception(
+ "nsBrowserAccess.openURI did not expect aOpenWindowInfo to be " +
+ "passed if the context is OPEN_EXTERNAL.",
+ Cr.NS_ERROR_FAILURE
+ );
+ }
+
+ if (isExternal && aURI && aURI.schemeIs("chrome")) {
+ Services.console.logStringMessage(
+ "use -chrome command-line option to load external chrome urls\n"
+ );
+ return null;
+ }
+
+ const ReferrerInfo = Components.Constructor(
+ "@mozilla.org/referrer-info;1",
+ "nsIReferrerInfo",
+ "init"
+ );
+
+ let referrerInfo;
+ if (aFlags & Ci.nsIBrowserDOMWindow.OPEN_NO_REFERRER) {
+ referrerInfo = new ReferrerInfo(Ci.nsIReferrerInfo.EMPTY, false, null);
+ } else if (
+ aOpenWindowInfo &&
+ aOpenWindowInfo.parent &&
+ aOpenWindowInfo.parent.window
+ ) {
+ referrerInfo = new ReferrerInfo(
+ aOpenWindowInfo.parent.window.document.referrerInfo.referrerPolicy,
+ true,
+ makeURI(aOpenWindowInfo.parent.window.location.href)
+ );
+ } else {
+ referrerInfo = new ReferrerInfo(Ci.nsIReferrerInfo.EMPTY, true, null);
+ }
+
+ if (aWhere != Ci.nsIBrowserDOMWindow.OPEN_NEWTAB) {
+ Services.console.logStringMessage(
+ "Opening a URI in something other than a new tab is not supported, opening in new tab instead"
+ );
+ }
+
+ let browser = this._openURIInNewTab(
+ aURI,
+ referrerInfo,
+ isExternal,
+ aOpenWindowInfo,
+ aTriggeringPrincipal,
+ aCsp,
+ aSkipLoad,
+ aOpenWindowInfo?.openerBrowser?.getAttribute("messagemanagergroup")
+ );
+
+ return browser ? browser.browsingContext : null;
+ },
+
+ getContentWindowOrOpenURIInFrame(
+ aURI,
+ aParams,
+ aWhere,
+ aFlags,
+ aName,
+ aSkipLoad
+ ) {
+ if (aWhere == Ci.nsIBrowserDOMWindow.OPEN_PRINT_BROWSER) {
+ return PrintUtils.handleStaticCloneCreatedForPrint(
+ aParams.openWindowInfo
+ );
+ }
+
+ if (aWhere != Ci.nsIBrowserDOMWindow.OPEN_NEWTAB) {
+ Services.console.logStringMessage(
+ "Error: openURIInFrame can only open in new tabs or print"
+ );
+ return null;
+ }
+
+ let isExternal = !!(aFlags & Ci.nsIBrowserDOMWindow.OPEN_EXTERNAL);
+
+ return this._openURIInNewTab(
+ aURI,
+ aParams.referrerInfo,
+ isExternal,
+ aParams.openWindowInfo,
+ aParams.triggeringPrincipal,
+ aParams.csp,
+ aSkipLoad,
+ aParams.openerBrowser?.getAttribute("messagemanagergroup")
+ );
+ },
+
+ canClose() {
+ return true;
+ },
+
+ get tabCount() {
+ let tabmail = document.getElementById("tabmail");
+ // messageWindow.xhtml does not have multiple tabs.
+ return tabmail ? tabmail.tabInfo.length : 1;
+ },
+};
+
+/**
+ * Called from the extensions manager to open an add-on options XUL document.
+ * Only the "open in tab" option is supported, so that's what we'll do here.
+ */
+function switchToTabHavingURI(aURI, aOpenNew, aOpenParams = {}) {
+ let tabmail = document.getElementById("tabmail");
+ let matchingIndex = -1;
+ if (tabmail) {
+ // about:preferences should be opened through openPreferencesTab().
+ if (aURI == "about:preferences") {
+ openPreferencesTab();
+ return true;
+ }
+
+ let openURI = makeURI(aURI);
+ let tabInfo = tabmail.tabInfo;
+
+ // Check if we already have the same URL open in a content tab.
+ for (let tabIndex = 0; tabIndex < tabInfo.length; tabIndex++) {
+ if (tabInfo[tabIndex].mode.name == "contentTab") {
+ let browserFunc =
+ tabInfo[tabIndex].mode.getBrowser ||
+ tabInfo[tabIndex].mode.tabType.getBrowser;
+ if (browserFunc) {
+ let browser = browserFunc.call(
+ tabInfo[tabIndex].mode.tabType,
+ tabInfo[tabIndex]
+ );
+ if (browser.currentURI.equals(openURI)) {
+ matchingIndex = tabIndex;
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ // Open the found matching tab.
+ if (tabmail && matchingIndex > -1) {
+ tabmail.switchToTab(matchingIndex);
+ return true;
+ }
+
+ if (aOpenNew) {
+ tabmail.openTab("contentTab", { ...aOpenParams, url: aURI });
+ }
+
+ return false;
+}
+
+/**
+ * Combines all nsIWebProgress notifications from all content browsers in this
+ * window and reports them to the registered listeners.
+ *
+ * @see WindowTracker (ext-mail.js)
+ * @see StatusListener, WindowTrackerBase (ext-tabs-base.js)
+ */
+var contentProgress = {
+ _listeners: new Set(),
+
+ addListener(listener) {
+ this._listeners.add(listener);
+ },
+
+ removeListener(listener) {
+ this._listeners.delete(listener);
+ },
+
+ callListeners(method, args) {
+ for (let listener of this._listeners.values()) {
+ if (method in listener) {
+ try {
+ listener[method](...args);
+ } catch (e) {
+ console.error(e);
+ }
+ }
+ }
+ },
+
+ /**
+ * Ensure that `browser` has a ProgressListener attached to it.
+ *
+ * @param {Browser} browser
+ */
+ addProgressListenerToBrowser(browser) {
+ if (browser?.webProgress && !browser._progressListener) {
+ browser._progressListener = new contentProgress.ProgressListener(browser);
+ browser.webProgress.addProgressListener(
+ browser._progressListener,
+ Ci.nsIWebProgress.NOTIFY_ALL
+ );
+ }
+ },
+
+ // @implements {nsIWebProgressListener}
+ // @implements {nsIWebProgressListener2}
+ ProgressListener: class {
+ QueryInterface = ChromeUtils.generateQI([
+ "nsIWebProgressListener",
+ "nsIWebProgressListener2",
+ "nsISupportsWeakReference",
+ ]);
+
+ constructor(browser) {
+ this.browser = browser;
+ }
+
+ callListeners(method, args) {
+ if (this.browser.hidden) {
+ // Ignore events from hidden browsers. This should avoid confusion in
+ // about:3pane, where multiple browsers could send events.
+ return;
+ }
+ args.unshift(this.browser);
+ contentProgress.callListeners(method, args);
+ }
+
+ onProgressChange(...args) {
+ this.callListeners("onProgressChange", args);
+ }
+
+ onProgressChange64(...args) {
+ this.callListeners("onProgressChange64", args);
+ }
+
+ onLocationChange(...args) {
+ this.callListeners("onLocationChange", args);
+ }
+
+ onStateChange(...args) {
+ this.callListeners("onStateChange", args);
+ }
+
+ onStatusChange(...args) {
+ this.callListeners("onStatusChange", args);
+ }
+
+ onSecurityChange(...args) {
+ this.callListeners("onSecurityChange", args);
+ }
+
+ onContentBlockingEvent(...args) {
+ this.callListeners("onContentBlockingEvent", args);
+ }
+
+ onRefreshAttempted(...args) {
+ return this.callListeners("onRefreshAttempted", args);
+ }
+ },
+};
+
+window.addEventListener("aboutMessageLoaded", event => {
+ // Add a progress listener to any about:message content browser that comes
+ // along. This often happens after the tab is opened so the usual mechanism
+ // doesn't work. It also works for standalone message windows.
+ contentProgress.addProgressListenerToBrowser(
+ event.target.getMessagePaneBrowser()
+ );
+ // Also add a copy listener so we can process images.
+ event.target.document.addEventListener("copy", onCopyOrDragStart, true);
+});
+
+// Listener to correctly set the busy flag on the webBrowser in about:3pane. All
+// other content tabs are handled by tabmail.js.
+contentProgress.addListener({
+ onStateChange(browser, webProgress, request, stateFlags, statusCode) {
+ // Skip if this is not the webBrowser in about:3pane.
+ if (browser.id != "webBrowser") {
+ return;
+ }
+ let status;
+ if (stateFlags & Ci.nsIWebProgressListener.STATE_IS_WINDOW) {
+ if (stateFlags & Ci.nsIWebProgressListener.STATE_START) {
+ status = "loading";
+ } else if (stateFlags & Ci.nsIWebProgressListener.STATE_STOP) {
+ status = "complete";
+ }
+ } else if (
+ stateFlags & Ci.nsIWebProgressListener.STATE_STOP &&
+ statusCode == Cr.NS_BINDING_ABORTED
+ ) {
+ status = "complete";
+ }
+ browser.busy = status == "loading";
+ },
+});
diff --git a/comm/mail/base/content/mailWindowOverlay.js b/comm/mail/base/content/mailWindowOverlay.js
new file mode 100644
index 0000000000..449a92de07
--- /dev/null
+++ b/comm/mail/base/content/mailWindowOverlay.js
@@ -0,0 +1,2177 @@
+/* -*- indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* 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/. */
+
+/* global gSpacesToolbar */
+
+/* import-globals-from ../../../mailnews/extensions/newsblog/newsblogOverlay.js */
+/* import-globals-from contentAreaClick.js */
+/* import-globals-from mail3PaneWindowCommands.js */
+/* import-globals-from mailCommands.js */
+/* import-globals-from mailCore.js */
+
+/* import-globals-from utilityOverlay.js */
+
+/* globals messenger */ // From messageWindow.js
+/* globals GetSelectedMsgFolders */ // From messenger.js
+/* globals MailOfflineMgr */ // From mail-offline.js
+
+/* globals OnTagsChange, currentHeaderData */ // TODO: these aren't real.
+
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+
+ BrowserToolboxLauncher:
+ "resource://devtools/client/framework/browser-toolbox/Launcher.sys.mjs",
+});
+XPCOMUtils.defineLazyModuleGetters(this, {
+ MailUtils: "resource:///modules/MailUtils.jsm",
+ MimeParser: "resource:///modules/mimeParser.jsm",
+ UIDensity: "resource:///modules/UIDensity.jsm",
+ UIFontSize: "resource:///modules/UIFontSize.jsm",
+});
+
+Object.defineProperty(this, "BrowserConsoleManager", {
+ get() {
+ let { loader } = ChromeUtils.importESModule(
+ "resource://devtools/shared/loader/Loader.sys.mjs"
+ );
+ return loader.require("devtools/client/webconsole/browser-console-manager")
+ .BrowserConsoleManager;
+ },
+ configurable: true,
+ enumerable: true,
+});
+
+// the user preference,
+// if HTML is not allowed. I assume, that the user could have set this to a
+// value > 1 in his prefs.js or user.js, but that the value will not
+// change during runtime other than through the MsgBody*() functions below.
+var gDisallow_classes_no_html = 1;
+
+/**
+ * Disable the new account menu item if the account preference is locked.
+ * The other affected areas are the account central, the account manager
+ * dialog, and the account provisioner window.
+ */
+function menu_new_init() {
+ // If the account provisioner is pref'd off, we shouldn't display the menu
+ // item.
+ ShowMenuItem(
+ "newCreateEmailAccountMenuItem",
+ Services.prefs.getBoolPref("mail.provider.enabled")
+ );
+
+ // If we don't have a folder, just get out of here and leave the menu as it is.
+ let folder = document.getElementById("tabmail")?.currentTabInfo.folder;
+ if (!folder) {
+ return;
+ }
+
+ if (Services.prefs.prefIsLocked("mail.disable_new_account_addition")) {
+ document
+ .getElementById("newNewsgroupAccountMenuItem")
+ .setAttribute("disabled", "true");
+ document
+ .getElementById("appmenu_newNewsgroupAccountMenuItem")
+ .setAttribute("disabled", "true");
+ }
+
+ var isInbox = folder.isSpecialFolder(Ci.nsMsgFolderFlags.Inbox);
+ var showNew =
+ (folder.canCreateSubfolders ||
+ (isInbox && !folder.getFlag(Ci.nsMsgFolderFlags.Virtual))) &&
+ document.getElementById("cmd_newFolder").getAttribute("disabled") != "true";
+ ShowMenuItem("menu_newFolder", showNew);
+ ShowMenuItem("menu_newVirtualFolder", showNew);
+ ShowMenuItem("newAccountPopupMenuSeparator", showNew);
+
+ EnableMenuItem(
+ "menu_newFolder",
+ folder.server.type != "imap" || MailOfflineMgr.isOnline()
+ );
+ if (showNew) {
+ var bundle = document.getElementById("bundle_messenger");
+ // Change "New Folder..." menu according to the context.
+ SetMenuItemLabel(
+ "menu_newFolder",
+ bundle.getString(
+ folder.isServer || isInbox
+ ? "newFolderMenuItem"
+ : "newSubfolderMenuItem"
+ )
+ );
+ }
+
+ goUpdateCommand("cmd_newMessage");
+}
+
+function goUpdateMailMenuItems(commandset) {
+ for (var i = 0; i < commandset.children.length; i++) {
+ var commandID = commandset.children[i].getAttribute("id");
+ if (commandID) {
+ goUpdateCommand(commandID);
+ }
+ }
+
+ updateCheckedStateForIgnoreAndWatchThreadCmds();
+}
+
+/**
+ * Update the ignore (sub)thread, and watch thread commands so the menus
+ * using them get the checked state set up properly.
+ */
+function updateCheckedStateForIgnoreAndWatchThreadCmds() {
+ let message;
+
+ let tab = document.getElementById("tabmail")?.currentTabInfo;
+ if (["mail3PaneTab", "mailMessageTab"].includes(tab?.mode.name)) {
+ message = tab.message;
+ }
+
+ let folder = message?.folder;
+
+ let killThreadItem = document.getElementById("cmd_killThread");
+ if (folder?.msgDatabase.isIgnored(message.messageKey)) {
+ killThreadItem.setAttribute("checked", "true");
+ } else {
+ killThreadItem.removeAttribute("checked");
+ }
+ let killSubthreadItem = document.getElementById("cmd_killSubthread");
+ if (folder && message.flags & Ci.nsMsgMessageFlags.Ignored) {
+ killSubthreadItem.setAttribute("checked", "true");
+ } else {
+ killSubthreadItem.removeAttribute("checked");
+ }
+ let watchThreadItem = document.getElementById("cmd_watchThread");
+ if (folder?.msgDatabase.isWatched(message.messageKey)) {
+ watchThreadItem.setAttribute("checked", "true");
+ } else {
+ watchThreadItem.removeAttribute("checked");
+ }
+}
+
+function file_init() {
+ document.commandDispatcher.updateCommands("create-menu-file");
+}
+
+/**
+ * Update the menu items visibility in the Edit submenu.
+ */
+function InitEditMessagesMenu() {
+ document.commandDispatcher.updateCommands("create-menu-edit");
+
+ let chromeBrowser, folderTreeActive, folder, folderIsNewsgroup;
+ let tab = document.getElementById("tabmail")?.currentTabInfo;
+ if (tab?.mode.name == "mail3PaneTab") {
+ chromeBrowser = tab.chromeBrowser;
+ folderTreeActive =
+ chromeBrowser.contentDocument.activeElement.id == "folderTree";
+ folder = chromeBrowser.contentWindow.gFolder;
+ folderIsNewsgroup = folder?.server.type == "nntp";
+ } else if (tab?.mode.name == "mailMessageTab") {
+ chromeBrowser = tab.chromeBrowser;
+ } else {
+ chromeBrowser = document.getElementById("messageBrowser");
+ }
+
+ let deleteController = getEnabledControllerForCommand("cmd_delete");
+ // If the controller is a JS object, it must be one we've implemented,
+ // not the built-in controller for textboxes.
+
+ let dbView = chromeBrowser?.contentWindow.gDBView;
+ let numSelected = dbView?.numSelected;
+
+ let deleteMenuItem = document.getElementById("menu_delete");
+ if (deleteController?.wrappedJSObject && folderTreeActive) {
+ let value = folderIsNewsgroup
+ ? "menu-edit-unsubscribe-newsgroup"
+ : "menu-edit-delete-folder";
+ document.l10n.setAttributes(deleteMenuItem, value);
+ } else if (deleteController?.wrappedJSObject && numSelected) {
+ let message = dbView?.hdrForFirstSelectedMessage;
+ let value;
+ if (message && message.flags & Ci.nsMsgMessageFlags.IMAPDeleted) {
+ value = "menu-edit-undelete-messages";
+ } else {
+ value = "menu-edit-delete-messages";
+ }
+ document.l10n.setAttributes(deleteMenuItem, value, { count: numSelected });
+ } else {
+ document.l10n.setAttributes(deleteMenuItem, "text-action-delete");
+ }
+
+ // Initialize the Favorite Folder checkbox in the Edit menu.
+ let favoriteFolderMenu = document.getElementById("menu_favoriteFolder");
+ if (folder?.getFlag(Ci.nsMsgFolderFlags.Favorite)) {
+ favoriteFolderMenu.setAttribute("checked", "true");
+ } else {
+ favoriteFolderMenu.removeAttribute("checked");
+ }
+
+ let propertiesController = getEnabledControllerForCommand("cmd_properties");
+ let propertiesMenuItem = document.getElementById("menu_properties");
+ if (tab?.mode.name == "mail3PaneTab" && propertiesController) {
+ let value = folderIsNewsgroup
+ ? "menu-edit-newsgroup-properties"
+ : "menu-edit-folder-properties";
+ document.l10n.setAttributes(propertiesMenuItem, value);
+ } else {
+ document.l10n.setAttributes(propertiesMenuItem, "menu-edit-properties");
+ }
+}
+
+/**
+ * Update the menu items visibility in the Find submenu.
+ */
+function initSearchMessagesMenu() {
+ // Show 'Global Search' menu item only when global search is enabled.
+ let glodaEnabled = Services.prefs.getBoolPref(
+ "mailnews.database.global.indexer.enabled"
+ );
+ document.getElementById("glodaSearchCmd").hidden = !glodaEnabled;
+}
+
+function InitGoMessagesMenu() {
+ document.commandDispatcher.updateCommands("create-menu-go");
+}
+
+/**
+ * This is called every time the view menu popup is displayed (in the main menu
+ * bar or in the appmenu). It is responsible for updating the menu items'
+ * state to reflect reality.
+ */
+function view_init(event) {
+ if (event && event.target.id != "menu_View_Popup") {
+ return;
+ }
+
+ let accountCentralVisible;
+ let folderPaneVisible;
+ let message;
+ let messagePaneVisible;
+ let quickFilterBarVisible;
+ let threadPaneHeaderVisible;
+
+ let tab = document.getElementById("tabmail")?.currentTabInfo;
+ if (tab?.mode.name == "mail3PaneTab") {
+ let chromeBrowser;
+ ({ chromeBrowser, message } = tab);
+ let { paneLayout, quickFilterBar } = chromeBrowser.contentWindow;
+ ({ accountCentralVisible, folderPaneVisible, messagePaneVisible } =
+ paneLayout);
+ quickFilterBarVisible = quickFilterBar.filterer.visible;
+ threadPaneHeaderVisible = true;
+ } else if (tab?.mode.name == "mailMessageTab") {
+ message = tab.message;
+ messagePaneVisible = true;
+ threadPaneHeaderVisible = false;
+ }
+
+ let isFeed = FeedUtils.isFeedMessage(message);
+
+ let qfbMenuItem = document.getElementById(
+ "view_toolbars_popup_quickFilterBar"
+ );
+ if (qfbMenuItem) {
+ qfbMenuItem.setAttribute("checked", quickFilterBarVisible);
+ }
+
+ let qfbAppMenuItem = document.getElementById("appmenu_quickFilterBar");
+ if (qfbAppMenuItem) {
+ if (quickFilterBarVisible) {
+ qfbAppMenuItem.setAttribute("checked", "true");
+ } else {
+ qfbAppMenuItem.removeAttribute("checked");
+ }
+ }
+
+ let messagePaneMenuItem = document.getElementById("menu_showMessage");
+ if (!messagePaneMenuItem.hidden) {
+ // Hidden in the standalone msg window.
+ messagePaneMenuItem.setAttribute(
+ "checked",
+ accountCentralVisible ? false : messagePaneVisible
+ );
+ messagePaneMenuItem.disabled = accountCentralVisible;
+ }
+
+ let messagePaneAppMenuItem = document.getElementById("appmenu_showMessage");
+ if (messagePaneAppMenuItem && !messagePaneAppMenuItem.hidden) {
+ // Hidden in the standalone msg window.
+ messagePaneAppMenuItem.setAttribute(
+ "checked",
+ accountCentralVisible ? false : messagePaneVisible
+ );
+ messagePaneAppMenuItem.disabled = accountCentralVisible;
+ }
+
+ let folderPaneMenuItem = document.getElementById("menu_showFolderPane");
+ if (!folderPaneMenuItem.hidden) {
+ // Hidden in the standalone msg window.
+ folderPaneMenuItem.setAttribute("checked", folderPaneVisible);
+ }
+
+ let folderPaneAppMenuItem = document.getElementById("appmenu_showFolderPane");
+ if (!folderPaneAppMenuItem.hidden) {
+ // Hidden in the standalone msg window.
+ folderPaneAppMenuItem.setAttribute("checked", folderPaneVisible);
+ }
+
+ let threadPaneMenuItem = document.getElementById(
+ "menu_toggleThreadPaneHeader"
+ );
+ threadPaneMenuItem.setAttribute("disabled", !threadPaneHeaderVisible);
+
+ let threadPaneAppMenuItem = document.getElementById(
+ "appmenu_toggleThreadPaneHeader"
+ );
+ threadPaneAppMenuItem.toggleAttribute("disabled", !threadPaneHeaderVisible);
+
+ // Disable some menus if account manager is showing
+ document.getElementById("viewSortMenu").disabled = accountCentralVisible;
+
+ document.getElementById("viewMessageViewMenu").disabled =
+ accountCentralVisible;
+
+ document.getElementById("viewMessagesMenu").disabled = accountCentralVisible;
+
+ // Hide the "View > Messages" menu item if the user doesn't have the "Views"
+ // (aka "Mail Views") toolbar button in the main toolbar. (See bug 1563789.)
+ var viewsToolbarButton = window.ViewPickerBinding?.isVisible;
+ document.getElementById("viewMessageViewMenu").hidden = !viewsToolbarButton;
+
+ // Initialize the Message Body menuitem
+ document.getElementById("viewBodyMenu").hidden = isFeed;
+
+ // Initialize the Show Feed Summary menu
+ let viewFeedSummary = document.getElementById("viewFeedSummary");
+ viewFeedSummary.hidden = !isFeed;
+
+ let viewRssMenuItemIds = [
+ "bodyFeedGlobalWebPage",
+ "bodyFeedGlobalSummary",
+ "bodyFeedPerFolderPref",
+ ];
+ let checked = FeedMessageHandler.onSelectPref;
+ for (let [index, id] of viewRssMenuItemIds.entries()) {
+ document.getElementById(id).setAttribute("checked", index == checked);
+ }
+
+ // Initialize the View Attachment Inline menu
+ var viewAttachmentInline = Services.prefs.getBoolPref(
+ "mail.inline_attachments"
+ );
+ document
+ .getElementById("viewAttachmentsInlineMenuitem")
+ .setAttribute("checked", viewAttachmentInline);
+
+ document.commandDispatcher.updateCommands("create-menu-view");
+
+ // No need to do anything if we don't have a spaces toolbar like in standalone
+ // windows or another non tabmail window.
+ let spacesToolbarMenu = document.getElementById("appmenu_spacesToolbar");
+ if (spacesToolbarMenu) {
+ // Update the spaces toolbar menu items.
+ let isSpacesVisible = !gSpacesToolbar.isHidden;
+ spacesToolbarMenu.checked = isSpacesVisible;
+ document
+ .getElementById("viewToolbarsPopupSpacesToolbar")
+ .setAttribute("checked", isSpacesVisible);
+ }
+}
+
+function initUiDensityMenu(event) {
+ // Prevent submenus from unnecessarily triggering onViewToolbarsPopupShowing
+ // via bubbling of events.
+ event.stopImmediatePropagation();
+
+ // Apply the correct mode attribute to the various items.
+ document.getElementById("uiDensityCompact").mode = UIDensity.MODE_COMPACT;
+ document.getElementById("uiDensityNormal").mode = UIDensity.MODE_NORMAL;
+ document.getElementById("uiDensityTouch").mode = UIDensity.MODE_TOUCH;
+
+ // Fetch the currently active identity.
+ let currentDensity = UIDensity.prefValue;
+
+ for (let item of event.target.querySelectorAll("menuitem")) {
+ if (item.mode == currentDensity) {
+ item.setAttribute("checked", "true");
+ break;
+ }
+ }
+}
+
+/**
+ * Assign the proper mode to the UI density controls in the App Menu and set
+ * the correct checked state based on the current density.
+ */
+function initUiDensityAppMenu() {
+ // Apply the correct mode attribute to the various items.
+ document.getElementById("appmenu_uiDensityCompact").mode =
+ UIDensity.MODE_COMPACT;
+ document.getElementById("appmenu_uiDensityNormal").mode =
+ UIDensity.MODE_NORMAL;
+ document.getElementById("appmenu_uiDensityTouch").mode = UIDensity.MODE_TOUCH;
+
+ // Fetch the currently active identity.
+ let currentDensity = UIDensity.prefValue;
+
+ for (let item of document.querySelectorAll(
+ "#appMenu-uiDensity-controls > toolbarbutton"
+ )) {
+ if (item.mode == currentDensity) {
+ item.setAttribute("checked", "true");
+ } else {
+ item.removeAttribute("checked");
+ }
+ }
+}
+
+function InitViewLayoutStyleMenu(event, appmenu) {
+ // Prevent submenus from unnecessarily triggering onViewToolbarsPopupShowing
+ // via bubbling of events.
+ event.stopImmediatePropagation();
+ let paneConfig = Services.prefs.getIntPref("mail.pane_config.dynamic");
+
+ let parent = appmenu
+ ? event.target.querySelector(".panel-subview-body")
+ : event.target;
+
+ let layoutStyleMenuitem = parent.children[paneConfig];
+ if (layoutStyleMenuitem) {
+ layoutStyleMenuitem.setAttribute("checked", "true");
+ }
+
+ if (
+ Services.xulStore.getValue(
+ "chrome://messenger/content/messenger.xhtml",
+ "threadPaneHeader",
+ "hidden"
+ ) !== "true"
+ ) {
+ parent
+ .querySelector(`[name="threadheader"]`)
+ .setAttribute("checked", "true");
+ } else {
+ parent.querySelector(`[name="threadheader"]`).removeAttribute("checked");
+ }
+}
+
+/**
+ * Called when showing the menu_viewSortPopup menupopup, so it should always
+ * be up-to-date.
+ */
+function InitViewSortByMenu() {
+ let tab = document.getElementById("tabmail")?.currentTabInfo;
+ if (tab?.mode.name != "mail3PaneTab") {
+ return;
+ }
+
+ let { gViewWrapper, threadPane } = tab.chromeBrowser.contentWindow;
+ if (!gViewWrapper?.dbView) {
+ return;
+ }
+
+ let { primarySortType, primarySortOrder, showGroupedBySort, showThreaded } =
+ gViewWrapper;
+ let hiddenColumns = threadPane.columns
+ .filter(c => c.hidden)
+ .map(c => c.sortKey);
+
+ let isSortTypeValidForGrouping = [
+ Ci.nsMsgViewSortType.byAccount,
+ Ci.nsMsgViewSortType.byAttachments,
+ Ci.nsMsgViewSortType.byAuthor,
+ Ci.nsMsgViewSortType.byCorrespondent,
+ Ci.nsMsgViewSortType.byDate,
+ Ci.nsMsgViewSortType.byFlagged,
+ Ci.nsMsgViewSortType.byLocation,
+ Ci.nsMsgViewSortType.byPriority,
+ Ci.nsMsgViewSortType.byReceived,
+ Ci.nsMsgViewSortType.byRecipient,
+ Ci.nsMsgViewSortType.byStatus,
+ Ci.nsMsgViewSortType.bySubject,
+ Ci.nsMsgViewSortType.byTags,
+ Ci.nsMsgViewSortType.byCustom,
+ ].includes(primarySortType);
+
+ let setSortItemAttrs = function (id, sortKey) {
+ let menuItem = document.getElementById(id);
+ menuItem.setAttribute(
+ "checked",
+ primarySortType == Ci.nsMsgViewSortType[sortKey]
+ );
+ if (hiddenColumns.includes(sortKey)) {
+ menuItem.setAttribute("disabled", "true");
+ } else {
+ menuItem.removeAttribute("disabled");
+ }
+ };
+
+ setSortItemAttrs("sortByDateMenuitem", "byDate");
+ setSortItemAttrs("sortByReceivedMenuitem", "byReceived");
+ setSortItemAttrs("sortByFlagMenuitem", "byFlagged");
+ setSortItemAttrs("sortByOrderReceivedMenuitem", "byId");
+ setSortItemAttrs("sortByPriorityMenuitem", "byPriority");
+ setSortItemAttrs("sortBySizeMenuitem", "bySize");
+ setSortItemAttrs("sortByStatusMenuitem", "byStatus");
+ setSortItemAttrs("sortBySubjectMenuitem", "bySubject");
+ setSortItemAttrs("sortByUnreadMenuitem", "byUnread");
+ setSortItemAttrs("sortByTagsMenuitem", "byTags");
+ setSortItemAttrs("sortByJunkStatusMenuitem", "byJunkStatus");
+ setSortItemAttrs("sortByFromMenuitem", "byAuthor");
+ setSortItemAttrs("sortByRecipientMenuitem", "byRecipient");
+ setSortItemAttrs("sortByAttachmentsMenuitem", "byAttachments");
+ setSortItemAttrs("sortByCorrespondentMenuitem", "byCorrespondent");
+
+ document
+ .getElementById("sortAscending")
+ .setAttribute(
+ "checked",
+ primarySortOrder == Ci.nsMsgViewSortOrder.ascending
+ );
+ document
+ .getElementById("sortDescending")
+ .setAttribute(
+ "checked",
+ primarySortOrder == Ci.nsMsgViewSortOrder.descending
+ );
+
+ document.getElementById("sortThreaded").setAttribute("checked", showThreaded);
+ document
+ .getElementById("sortUnthreaded")
+ .setAttribute("checked", !showThreaded && !showGroupedBySort);
+
+ let groupBySortOrderMenuItem = document.getElementById("groupBySort");
+ groupBySortOrderMenuItem.setAttribute(
+ "disabled",
+ !isSortTypeValidForGrouping
+ );
+ groupBySortOrderMenuItem.setAttribute("checked", showGroupedBySort);
+}
+
+function InitViewMessagesMenu() {
+ let tab = document.getElementById("tabmail")?.currentTabInfo;
+ if (!["mail3PaneTab", "mailMessageTab"].includes(tab?.mode.name)) {
+ return;
+ }
+
+ let viewWrapper = tab.chromeBrowser.contentWindow.gViewWrapper;
+
+ document
+ .getElementById("viewAllMessagesMenuItem")
+ .setAttribute(
+ "checked",
+ !viewWrapper || (!viewWrapper.showUnreadOnly && !viewWrapper.specialView)
+ );
+
+ document
+ .getElementById("viewUnreadMessagesMenuItem")
+ .setAttribute("checked", !!viewWrapper?.showUnreadOnly);
+
+ document
+ .getElementById("viewThreadsWithUnreadMenuItem")
+ .setAttribute("checked", !!viewWrapper?.specialViewThreadsWithUnread);
+
+ document
+ .getElementById("viewWatchedThreadsWithUnreadMenuItem")
+ .setAttribute(
+ "checked",
+ !!viewWrapper?.specialViewWatchedThreadsWithUnread
+ );
+
+ document
+ .getElementById("viewIgnoredThreadsMenuItem")
+ .setAttribute("checked", !!viewWrapper?.showIgnored);
+}
+
+function InitMessageMenu() {
+ let tab = document.getElementById("tabmail")?.currentTabInfo;
+ let message, folder;
+ let isDummy;
+ if (["mail3PaneTab", "mailMessageTab"].includes(tab?.mode.name)) {
+ ({ message, folder } = tab);
+ isDummy = message && !folder;
+ } else {
+ message = document.getElementById("messageBrowser")?.contentWindow.gMessage;
+ isDummy = !message?.folder;
+ }
+
+ let isNews = message?.folder?.flags & Ci.nsMsgFolderFlags.Newsgroup;
+ let isFeed = message && FeedUtils.isFeedMessage(message);
+
+ // We show reply to Newsgroups only for news messages.
+ document.getElementById("replyNewsgroupMainMenu").hidden = !isNews;
+
+ // For mail messages we say reply. For news we say ReplyToSender.
+ document.getElementById("replyMainMenu").hidden = isNews;
+ document.getElementById("replySenderMainMenu").hidden = !isNews;
+
+ document.getElementById("menu_cancel").hidden =
+ !isNews || !getEnabledControllerForCommand("cmd_cancel");
+
+ // Disable the move menu if there are no messages selected or if
+ // the message is a dummy - e.g. opening a message in the standalone window.
+ let messageStoredInternally = message && !isDummy;
+ // Disable the move menu if we can't delete msgs from the folder.
+ let canMove =
+ messageStoredInternally && !isNews && message.folder.canDeleteMessages;
+
+ document.getElementById("moveMenu").disabled = !canMove;
+
+ document.getElementById("copyMenu").disabled = !message;
+
+ initMoveToFolderAgainMenu(document.getElementById("moveToFolderAgain"));
+
+ // Disable the Forward As menu item if no message is selected.
+ document.getElementById("forwardAsMenu").disabled = !message;
+
+ // Disable the Attachments menu if no message is selected and we don't have
+ // any attachment.
+ let aboutMessage =
+ document.getElementById("tabmail")?.currentAboutMessage ||
+ document.getElementById("messageBrowser")?.contentWindow;
+ document.getElementById("msgAttachmentMenu").disabled =
+ !message || !aboutMessage?.currentAttachments.length;
+
+ // Disable the Tag menu item if no message is selected or when we're
+ // not in a folder.
+ document.getElementById("tagMenu").disabled = !messageStoredInternally;
+
+ // Show "Edit Draft Message" menus only in a drafts folder; otherwise hide them.
+ showCommandInSpecialFolder("cmd_editDraftMsg", Ci.nsMsgFolderFlags.Drafts);
+ // Show "New Message from Template" and "Edit Template" menus only in a
+ // templates folder; otherwise hide them.
+ showCommandInSpecialFolder(
+ ["cmd_newMsgFromTemplate", "cmd_editTemplateMsg"],
+ Ci.nsMsgFolderFlags.Templates
+ );
+
+ // Initialize the Open Message menuitem
+ var winType = document.documentElement.getAttribute("windowtype");
+ if (winType == "mail:3pane") {
+ document.getElementById("openMessageWindowMenuitem").hidden = isFeed;
+ }
+
+ // Initialize the Open Feed Message handler menu
+ let index = FeedMessageHandler.onOpenPref;
+ document
+ .getElementById("menu_openFeedMessage")
+ .children[index].setAttribute("checked", true);
+
+ let openRssMenu = document.getElementById("openFeedMessage");
+ openRssMenu.hidden = !isFeed;
+ if (winType != "mail:3pane") {
+ openRssMenu.hidden = true;
+ }
+
+ // Disable mark menu when we're not in a folder.
+ document.getElementById("markMenu").disabled = !folder || folder.isServer;
+
+ document.commandDispatcher.updateCommands("create-menu-message");
+
+ for (let id of ["killThread", "killSubthread", "watchThread"]) {
+ let item = document.getElementById(id);
+ let command = document.getElementById(item.getAttribute("command"));
+ if (command.hasAttribute("checked")) {
+ item.setAttribute("checked", command.getAttribute("checked"));
+ } else {
+ item.removeAttribute("checked");
+ }
+ }
+}
+
+/**
+ * Show folder-specific menu items only for messages in special folders, e.g.
+ * show 'cmd_editDraftMsg' in Drafts folder, or
+ * show 'cmd_newMsgFromTemplate' in Templates folder.
+ *
+ * aCommandIds single ID string of command or array of IDs of commands
+ * to be shown in folders having aFolderFlag
+ * aFolderFlag the nsMsgFolderFlag that the folder must have to show the command
+ */
+function showCommandInSpecialFolder(aCommandIds, aFolderFlag) {
+ let folder, message;
+
+ let tab = document.getElementById("tabmail")?.currentTabInfo;
+ if (["mail3PaneTab", "mailMessageTab"].includes(tab?.mode.name)) {
+ ({ message, folder } = tab);
+ } else if (tab?.mode.tabType.name == "mail") {
+ ({ displayedFolder: folder, selectedMessage: message } = tab.folderDisplay);
+ }
+
+ let inSpecialFolder =
+ message?.folder?.isSpecialFolder(aFolderFlag, true) ||
+ (folder && folder.getFlag(aFolderFlag));
+ if (typeof aCommandIds === "string") {
+ aCommandIds = [aCommandIds];
+ }
+
+ aCommandIds.forEach(cmdId =>
+ document.getElementById(cmdId).setAttribute("hidden", !inSpecialFolder)
+ );
+}
+
+/**
+ * Initializes the menu item aMenuItem to show either "Move" or "Copy" to
+ * folder again, based on the value of mail.last_msg_movecopy_target_uri.
+ * The menu item label and accesskey are adjusted to include the folder name.
+ *
+ * @param aMenuItem the menu item to adjust
+ */
+function initMoveToFolderAgainMenu(aMenuItem) {
+ let lastFolderURI = Services.prefs.getStringPref(
+ "mail.last_msg_movecopy_target_uri"
+ );
+
+ if (!lastFolderURI) {
+ return;
+ }
+ let destMsgFolder = MailUtils.getExistingFolder(lastFolderURI);
+ if (!destMsgFolder) {
+ return;
+ }
+ let bundle = document.getElementById("bundle_messenger");
+ let isMove = Services.prefs.getBoolPref("mail.last_msg_movecopy_was_move");
+ let stringName = isMove ? "moveToFolderAgain" : "copyToFolderAgain";
+ aMenuItem.label = bundle.getFormattedString(
+ stringName,
+ [destMsgFolder.prettyName],
+ 1
+ );
+ // This gives us moveToFolderAgainAccessKey and copyToFolderAgainAccessKey.
+ aMenuItem.accesskey = bundle.getString(stringName + "AccessKey");
+}
+
+/**
+ * Update the "Show Header" menu items to reflect the current pref.
+ */
+function InitViewHeadersMenu() {
+ let dt = Ci.nsMimeHeaderDisplayTypes;
+ let headerchoice = Services.prefs.getIntPref("mail.show_headers");
+ document
+ .getElementById("cmd_viewAllHeader")
+ .setAttribute("checked", headerchoice == dt.AllHeaders);
+ document
+ .getElementById("cmd_viewNormalHeader")
+ .setAttribute("checked", headerchoice == dt.NormalHeaders);
+ document.commandDispatcher.updateCommands("create-menu-mark");
+}
+
+function InitViewBodyMenu() {
+ let message;
+
+ let tab = document.getElementById("tabmail")?.currentTabInfo;
+ if (["mail3PaneTab", "mailMessageTab"].includes(tab?.mode.name)) {
+ message = tab.message;
+ }
+
+ // Separate render prefs not implemented for feeds, bug 458606. Show the
+ // checked item for feeds as for the regular pref.
+ // let html_as = Services.prefs.getIntPref("rss.display.html_as");
+ // let prefer_plaintext = Services.prefs.getBoolPref("rss.display.prefer_plaintext");
+ // let disallow_classes = Services.prefs.getIntPref("rss.display.disallow_mime_handlers");
+ let html_as = Services.prefs.getIntPref("mailnews.display.html_as");
+ let prefer_plaintext = Services.prefs.getBoolPref(
+ "mailnews.display.prefer_plaintext"
+ );
+ let disallow_classes = Services.prefs.getIntPref(
+ "mailnews.display.disallow_mime_handlers"
+ );
+ let isFeed = FeedUtils.isFeedMessage(message);
+ const defaultIDs = [
+ "bodyAllowHTML",
+ "bodySanitized",
+ "bodyAsPlaintext",
+ "bodyAllParts",
+ ];
+ const rssIDs = [
+ "bodyFeedSummaryAllowHTML",
+ "bodyFeedSummarySanitized",
+ "bodyFeedSummaryAsPlaintext",
+ ];
+ let menuIDs = isFeed ? rssIDs : defaultIDs;
+
+ if (disallow_classes > 0) {
+ gDisallow_classes_no_html = disallow_classes;
+ }
+ // else gDisallow_classes_no_html keeps its initial value (see top)
+
+ let AllowHTML_menuitem = document.getElementById(menuIDs[0]);
+ let Sanitized_menuitem = document.getElementById(menuIDs[1]);
+ let AsPlaintext_menuitem = document.getElementById(menuIDs[2]);
+ let AllBodyParts_menuitem = menuIDs[3]
+ ? document.getElementById(menuIDs[3])
+ : null;
+
+ document.getElementById("bodyAllParts").hidden = !Services.prefs.getBoolPref(
+ "mailnews.display.show_all_body_parts_menu"
+ );
+
+ if (
+ !prefer_plaintext &&
+ !html_as &&
+ !disallow_classes &&
+ AllowHTML_menuitem
+ ) {
+ AllowHTML_menuitem.setAttribute("checked", true);
+ } else if (
+ !prefer_plaintext &&
+ html_as == 3 &&
+ disallow_classes > 0 &&
+ Sanitized_menuitem
+ ) {
+ Sanitized_menuitem.setAttribute("checked", true);
+ } else if (
+ prefer_plaintext &&
+ html_as == 1 &&
+ disallow_classes > 0 &&
+ AsPlaintext_menuitem
+ ) {
+ AsPlaintext_menuitem.setAttribute("checked", true);
+ } else if (
+ !prefer_plaintext &&
+ html_as == 4 &&
+ !disallow_classes &&
+ AllBodyParts_menuitem
+ ) {
+ AllBodyParts_menuitem.setAttribute("checked", true);
+ }
+ // else (the user edited prefs/user.js) check none of the radio menu items
+
+ if (isFeed) {
+ AllowHTML_menuitem.hidden = !FeedMessageHandler.gShowSummary;
+ Sanitized_menuitem.hidden = !FeedMessageHandler.gShowSummary;
+ AsPlaintext_menuitem.hidden = !FeedMessageHandler.gShowSummary;
+ document.getElementById("viewFeedSummarySeparator").hidden =
+ !gShowFeedSummary;
+ }
+}
+
+function ShowMenuItem(id, showItem) {
+ document.getElementById(id).hidden = !showItem;
+}
+
+function EnableMenuItem(id, enableItem) {
+ document.getElementById(id).disabled = !enableItem;
+}
+
+function SetMenuItemLabel(menuItemId, customLabel) {
+ var menuItem = document.getElementById(menuItemId);
+ if (menuItem) {
+ menuItem.setAttribute("label", customLabel);
+ }
+}
+
+/**
+ * Refresh the contents of the tag popup menu/panel.
+ * Used for example for appmenu/Message/Tag panel.
+ *
+ * @param {Element} parent - Parent element that will contain the menu items.
+ * @param {string} [elementName] - Type of menu item, e.g. "menuitem", "toolbarbutton".
+ * @param {string} [classes] - Classes to set on the menu items.
+ */
+function InitMessageTags(parent, elementName = "menuitem", classes) {
+ function SetMessageTagLabel(menuitem, index, name) {
+ // if a <key> is defined for this tag, use its key as the accesskey
+ // (the key for the tag at index n needs to have the id key_tag<n>)
+ let shortcutkey = document.getElementById("key_tag" + index);
+ let accesskey = shortcutkey ? shortcutkey.getAttribute("key") : " ";
+ if (accesskey != " ") {
+ menuitem.setAttribute("accesskey", accesskey);
+ menuitem.setAttribute("acceltext", accesskey);
+ }
+ let label = document
+ .getElementById("bundle_messenger")
+ .getFormattedString("mailnews.tags.format", [accesskey, name]);
+ menuitem.setAttribute("label", label);
+ }
+
+ let message;
+
+ let tab = document.getElementById("tabmail")?.currentTabInfo;
+ if (["mail3PaneTab", "mailMessageTab"].includes(tab?.mode.name)) {
+ message = tab.message;
+ } else {
+ message = document.getElementById("messageBrowser")?.contentWindow.gMessage;
+ }
+
+ const tagArray = MailServices.tags.getAllTags();
+ const elementNameUpperCase = elementName.toUpperCase();
+
+ // Remove any existing non-static items (clear tags list before rebuilding it).
+ // There is a separator element above the dynamically added tag elements, so
+ // remove dynamically added elements below the separator.
+ while (
+ parent.lastElementChild.tagName.toUpperCase() == elementNameUpperCase
+ ) {
+ parent.lastChild.remove();
+ }
+
+ // Create label and accesskey for the static "remove all tags" item.
+ const tagRemoveLabel = document
+ .getElementById("bundle_messenger")
+ .getString("mailnews.tags.remove");
+ SetMessageTagLabel(
+ parent.lastElementChild.previousElementSibling,
+ 0,
+ tagRemoveLabel
+ );
+
+ // Rebuild the list.
+ const curKeys = message.getStringProperty("keywords");
+
+ tagArray.forEach((tagInfo, index) => {
+ const removeKey = ` ${curKeys} `.includes(` ${tagInfo.key} `);
+
+ if (tagInfo.ordinal.includes("~AUTOTAG") && !removeKey) {
+ return;
+ }
+ // TODO We want to either remove or "check" the tags that already exist.
+ let item = parent.ownerDocument.createXULElement(elementName);
+ SetMessageTagLabel(item, index + 1, tagInfo.tag);
+
+ if (removeKey) {
+ item.setAttribute("checked", "true");
+ }
+ item.setAttribute("value", tagInfo.key);
+ item.setAttribute("type", "checkbox");
+ item.addEventListener("command", function (event) {
+ goDoCommand("cmd_toggleTag", event);
+ });
+
+ if (tagInfo.color) {
+ item.setAttribute("style", `color: ${tagInfo.color};`);
+ }
+ if (classes) {
+ item.setAttribute("class", classes);
+ }
+ parent.appendChild(item);
+ });
+}
+
+function getMsgToolbarMenu_init() {
+ document.commandDispatcher.updateCommands("create-menu-getMsgToolbar");
+}
+
+function InitMessageMark() {
+ let tab = document.getElementById("tabmail")?.currentTabInfo;
+ let flaggedItem = document.getElementById("markFlaggedMenuItem");
+ if (tab?.message?.isFlagged) {
+ flaggedItem.setAttribute("checked", "true");
+ } else {
+ flaggedItem.removeAttribute("checked");
+ }
+
+ document.commandDispatcher.updateCommands("create-menu-mark");
+}
+
+function GetFirstSelectedMsgFolder() {
+ try {
+ var selectedFolders = GetSelectedMsgFolders();
+ } catch (e) {
+ console.error(e);
+ }
+ return selectedFolders.length > 0 ? selectedFolders[0] : null;
+}
+
+function GetMessagesForInboxOnServer(server) {
+ var inboxFolder = MailUtils.getInboxFolder(server);
+
+ // If the server doesn't support an inbox it could be an RSS server or some
+ // other server type. Just use the root folder and the server implementation
+ // can figure out what to do.
+ if (!inboxFolder) {
+ inboxFolder = server.rootFolder;
+ }
+
+ GetNewMsgs(server, inboxFolder);
+}
+
+function MsgGetMessage(folders) {
+ // if offline, prompt for getting messages
+ if (MailOfflineMgr.isOnline() || MailOfflineMgr.getNewMail()) {
+ GetFolderMessages(folders);
+ }
+}
+
+function MsgPauseUpdates(selectedFolders = GetSelectedMsgFolders(), pause) {
+ // Pause single feed folder subscription updates, or all account updates if
+ // folder is the account folder.
+ let folder = selectedFolders.length ? selectedFolders[0] : null;
+ if (!FeedUtils.isFeedFolder(folder)) {
+ return;
+ }
+
+ FeedUtils.pauseFeedFolderUpdates(folder, pause, true);
+ Services.obs.notifyObservers(folder, "folder-properties-changed");
+}
+
+function MsgGetMessagesForAllServers(defaultServer) {
+ // now log into any server
+ try {
+ // Array of arrays of servers for a particular folder.
+ var pop3DownloadServersArray = [];
+ // Parallel array of folders to download to...
+ var localFoldersToDownloadTo = [];
+ var pop3Server;
+ for (let server of MailServices.accounts.allServers) {
+ if (server.protocolInfo.canLoginAtStartUp && server.loginAtStartUp) {
+ if (
+ defaultServer &&
+ defaultServer.equals(server) &&
+ !defaultServer.isDeferredTo &&
+ defaultServer.rootFolder == defaultServer.rootMsgFolder
+ ) {
+ // skip, already opened
+ } else if (server.type == "pop3" && server.downloadOnBiff) {
+ CoalesceGetMsgsForPop3ServersByDestFolder(
+ server,
+ pop3DownloadServersArray,
+ localFoldersToDownloadTo
+ );
+ pop3Server = server.QueryInterface(Ci.nsIPop3IncomingServer);
+ } else {
+ // Check to see if there are new messages on the server
+ server.performBiff(msgWindow);
+ }
+ }
+ }
+ for (let i = 0; i < pop3DownloadServersArray.length; ++i) {
+ // Any ol' pop3Server will do - the serversArray specifies which servers
+ // to download from.
+ pop3Server.downloadMailFromServers(
+ pop3DownloadServersArray[i],
+ msgWindow,
+ localFoldersToDownloadTo[i],
+ null
+ );
+ }
+ } catch (ex) {
+ dump(ex + "\n");
+ }
+}
+
+/**
+ * Get messages for all those accounts which have the capability
+ * of getting messages and have session password available i.e.,
+ * currently logged in accounts.
+ * if offline, prompt for getting messages.
+ */
+function MsgGetMessagesForAllAuthenticatedAccounts() {
+ if (MailOfflineMgr.isOnline() || MailOfflineMgr.getNewMail()) {
+ GetMessagesForAllAuthenticatedAccounts();
+ }
+}
+
+/**
+ * Get messages for the account selected from Menu dropdowns.
+ * if offline, prompt for getting messages.
+ *
+ * @param aFolder (optional) a folder in the account for which messages should
+ * be retrieved. If null, all accounts will be used.
+ */
+function MsgGetMessagesForAccount(aFolder) {
+ if (!aFolder) {
+ goDoCommand("cmd_getNewMessages");
+ return;
+ }
+
+ if (MailOfflineMgr.isOnline() || MailOfflineMgr.getNewMail()) {
+ var server = aFolder.server;
+ GetMessagesForInboxOnServer(server);
+ }
+}
+
+// if offline, prompt for getNextNMessages
+function MsgGetNextNMessages() {
+ if (MailOfflineMgr.isOnline() || MailOfflineMgr.getNewMail()) {
+ GetNextNMessages(GetFirstSelectedMsgFolder());
+ }
+}
+
+function MsgNewMessage(event) {
+ let msgFolder = document.getElementById("tabmail")?.currentTabInfo.folder;
+
+ if (event?.shiftKey) {
+ ComposeMessage(
+ Ci.nsIMsgCompType.New,
+ Ci.nsIMsgCompFormat.OppositeOfDefault,
+ msgFolder,
+ []
+ );
+ } else {
+ ComposeMessage(
+ Ci.nsIMsgCompType.New,
+ Ci.nsIMsgCompFormat.Default,
+ msgFolder,
+ []
+ );
+ }
+}
+
+/** Open subscribe window. */
+function MsgSubscribe(folder) {
+ var preselectedFolder = folder || GetFirstSelectedMsgFolder();
+
+ if (FeedUtils.isFeedFolder(preselectedFolder)) {
+ // Open feed subscription dialog.
+ openSubscriptionsDialog(preselectedFolder);
+ } else {
+ // Open IMAP/NNTP subscription dialog.
+ Subscribe(preselectedFolder);
+ }
+}
+
+/**
+ * Show a confirmation dialog - check if the user really want to unsubscribe
+ * from the given newsgroup/s.
+ *
+ * @folders an array of newsgroup folders to unsubscribe from
+ * @returns true if the user said it's ok to unsubscribe
+ */
+function ConfirmUnsubscribe(folders) {
+ var bundle = document.getElementById("bundle_messenger");
+ var titleMsg = bundle.getString("confirmUnsubscribeTitle");
+ var dialogMsg =
+ folders.length == 1
+ ? bundle.getFormattedString(
+ "confirmUnsubscribeText",
+ [folders[0].name],
+ 1
+ )
+ : bundle.getString("confirmUnsubscribeManyText");
+
+ return Services.prompt.confirm(window, titleMsg, dialogMsg);
+}
+
+/**
+ * Unsubscribe from selected or passed in newsgroup/s.
+ * @param {nsIMsgFolder[]} selectedFolders - The folders to unsubscribe.
+ */
+function MsgUnsubscribe(folders) {
+ if (!ConfirmUnsubscribe(folders)) {
+ return;
+ }
+
+ for (let i = 0; i < folders.length; i++) {
+ let subscribableServer = folders[i].server.QueryInterface(
+ Ci.nsISubscribableServer
+ );
+ subscribableServer.unsubscribe(folders[i].name);
+ subscribableServer.commitSubscribeChanges();
+ }
+}
+
+function MsgOpenNewWindowForFolder(folderURI, msgKeyToSelect) {
+ window.openDialog(
+ "chrome://messenger/content/messenger.xhtml",
+ "_blank",
+ "chrome,all,dialog=no",
+ folderURI,
+ msgKeyToSelect
+ );
+}
+
+/**
+ * UI-triggered command to open the currently selected folder(s) in new tabs.
+ *
+ * @param {nsIMsgFolder[]} folders - Folders to open in new tabs.
+ * @param {object} [tabParams] - Parameters to pass to the new tabs.
+ */
+function MsgOpenNewTabForFolders(folders, tabParams = {}) {
+ if (tabParams.background === undefined) {
+ tabParams.background = Services.prefs.getBoolPref(
+ "mail.tabs.loadInBackground"
+ );
+ if (tabParams.event?.shiftKey) {
+ tabParams.background = !tabParams.background;
+ }
+ }
+
+ let tabmail = document.getElementById("tabmail");
+ for (let i = 0; i < folders.length; i++) {
+ tabmail.openTab("mail3PaneTab", {
+ ...tabParams,
+ folderURI: folders[i].URI,
+ });
+ }
+}
+
+function MsgOpenFromFile() {
+ var fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker);
+
+ var bundle = document.getElementById("bundle_messenger");
+ var filterLabel = bundle.getString("EMLFiles");
+ var windowTitle = bundle.getString("OpenEMLFiles");
+
+ fp.init(window, windowTitle, Ci.nsIFilePicker.modeOpen);
+ fp.appendFilter(filterLabel, "*.eml");
+
+ // Default or last filter is "All Files".
+ fp.appendFilters(Ci.nsIFilePicker.filterAll);
+
+ fp.open(rv => {
+ if (rv != Ci.nsIFilePicker.returnOK || !fp.file) {
+ return;
+ }
+ MailUtils.openEMLFile(window, fp.file, fp.fileURL);
+ });
+}
+
+function MsgOpenNewWindowForMessage(aMsgHdr, aView) {
+ // We need to tell the window about our current view so that it can clone it.
+ // This enables advancing through the messages, etc.
+ return window.openDialog(
+ "chrome://messenger/content/messageWindow.xhtml",
+ "_blank",
+ "all,chrome,dialog=no,status,toolbar",
+ aMsgHdr,
+ aView
+ );
+}
+
+/**
+ * Display the given message in an existing folder tab.
+ *
+ * @param aMsgHdr The message header to display.
+ */
+function MsgDisplayMessageInFolderTab(aMsgHdr) {
+ let tabmail = document.getElementById("tabmail");
+ tabmail.switchToTab(0);
+ tabmail.currentAbout3Pane.selectMessage(aMsgHdr);
+}
+
+function MsgMarkAllRead(folders) {
+ for (let i = 0; i < folders.length; i++) {
+ folders[i].markAllMessagesRead(msgWindow);
+ }
+}
+
+/**
+ * Go through each selected server and mark all its folders read.
+ *
+ * @param {nsIMsgFolder[]} selectedFolders - Folders in the servers to be
+ * marked as read.
+ */
+function MsgMarkAllFoldersRead(selectedFolders) {
+ let selectedServers = selectedFolders.filter(folder => folder.isServer);
+ if (!selectedServers.length) {
+ return;
+ }
+
+ let bundle = document.getElementById("bundle_messenger");
+ if (
+ !Services.prompt.confirm(
+ window,
+ bundle.getString("confirmMarkAllFoldersReadTitle"),
+ bundle.getString("confirmMarkAllFoldersReadMessage")
+ )
+ ) {
+ return;
+ }
+
+ selectedServers.forEach(function (server) {
+ for (let folder of server.rootFolder.descendants) {
+ folder.markAllMessagesRead(msgWindow);
+ }
+ });
+}
+
+/**
+ * Opens the filter list.
+ * If an email address was passed, first a new filter is offered for creation
+ * with the data prefilled.
+ *
+ * @param {?string} emailAddress - An email address to use as value in the first
+ * search term.
+ * @param {?nsIMsgFolder} folder - The filter will be created in this folder's
+ * filter list.
+ * @param {?string} fieldName - Search field string, from
+ * nsMsgSearchTerm.cpp::SearchAttribEntryTable.
+ */
+function MsgFilters(emailAddress, folder, fieldName) {
+ // Don't trigger anything if there are no accounts configured. This is to
+ // disable potential triggers via shortcuts.
+ if (MailServices.accounts.accounts.length == 0) {
+ return;
+ }
+
+ if (!folder) {
+ let chromeBrowser =
+ document.getElementById("tabmail")?.currentTabInfo.chromeBrowser ||
+ document.getElementById("messageBrowser");
+ let dbView = chromeBrowser?.contentWindow?.gDBView;
+ // Try to determine the folder from the selected message.
+ if (dbView?.numSelected) {
+ // Here we face a decision. If the message has been moved to a different
+ // account, then a single filter cannot work for both manual and incoming
+ // scope. So we will create the filter based on its existing location,
+ // which will make it work properly in manual scope. This is the best
+ // solution for POP3 with global inbox (as then both manual and incoming
+ // filters work correctly), but may not be what IMAP users who filter to a
+ // local folder really want.
+ folder = dbView.hdrForFirstSelectedMessage.folder;
+ }
+ if (!folder) {
+ folder = GetFirstSelectedMsgFolder();
+ }
+ }
+ let args;
+ if (emailAddress) {
+ // We have to do prefill filter so we are going to launch the filterEditor
+ // dialog and prefill that with the emailAddress.
+ args = {
+ filterList: folder.getEditableFilterList(msgWindow),
+ filterName: emailAddress,
+ };
+ // Set the field name to prefill in the filter, if one was specified.
+ if (fieldName) {
+ args.fieldName = fieldName;
+ }
+
+ window.openDialog(
+ "chrome://messenger/content/FilterEditor.xhtml",
+ "",
+ "chrome, modal, resizable,centerscreen,dialog=yes",
+ args
+ );
+
+ // If the user hits OK in the filterEditor dialog we set args.refresh=true
+ // there and we check this here in args to show filterList dialog.
+ // We also received the filter created via args.newFilter.
+ if ("refresh" in args && args.refresh) {
+ args = { refresh: true, folder, filter: args.newFilter };
+ MsgFilterList(args);
+ }
+ } else {
+ // Just launch filterList dialog.
+ args = { refresh: false, folder };
+ MsgFilterList(args);
+ }
+}
+
+function MsgViewAllHeaders() {
+ Services.prefs.setIntPref(
+ "mail.show_headers",
+ Ci.nsMimeHeaderDisplayTypes.AllHeaders
+ );
+}
+
+function MsgViewNormalHeaders() {
+ Services.prefs.setIntPref(
+ "mail.show_headers",
+ Ci.nsMimeHeaderDisplayTypes.NormalHeaders
+ );
+}
+
+function MsgBodyAllowHTML() {
+ Services.prefs.setBoolPref("mailnews.display.prefer_plaintext", false);
+ Services.prefs.setIntPref("mailnews.display.html_as", 0);
+ Services.prefs.setIntPref("mailnews.display.disallow_mime_handlers", 0);
+}
+
+function MsgBodySanitized() {
+ Services.prefs.setBoolPref("mailnews.display.prefer_plaintext", false);
+ Services.prefs.setIntPref("mailnews.display.html_as", 3);
+ Services.prefs.setIntPref(
+ "mailnews.display.disallow_mime_handlers",
+ gDisallow_classes_no_html
+ );
+}
+
+function MsgBodyAsPlaintext() {
+ Services.prefs.setBoolPref("mailnews.display.prefer_plaintext", true);
+ Services.prefs.setIntPref("mailnews.display.html_as", 1);
+ Services.prefs.setIntPref(
+ "mailnews.display.disallow_mime_handlers",
+ gDisallow_classes_no_html
+ );
+}
+
+function MsgBodyAllParts() {
+ Services.prefs.setBoolPref("mailnews.display.prefer_plaintext", false);
+ Services.prefs.setIntPref("mailnews.display.html_as", 4);
+ Services.prefs.setIntPref("mailnews.display.disallow_mime_handlers", 0);
+}
+
+function MsgFeedBodyRenderPrefs(plaintext, html, mime) {
+ // Separate render prefs not implemented for feeds, bug 458606.
+ // Services.prefs.setBoolPref("rss.display.prefer_plaintext", plaintext);
+ // Services.prefs.setIntPref("rss.display.html_as", html);
+ // Services.prefs.setIntPref("rss.display.disallow_mime_handlers", mime);
+
+ Services.prefs.setBoolPref("mailnews.display.prefer_plaintext", plaintext);
+ Services.prefs.setIntPref("mailnews.display.html_as", html);
+ Services.prefs.setIntPref("mailnews.display.disallow_mime_handlers", mime);
+ // Reload only if showing rss summary; menuitem hidden if web page..
+}
+
+function ToggleInlineAttachment(target) {
+ var viewAttachmentInline = !Services.prefs.getBoolPref(
+ "mail.inline_attachments"
+ );
+ Services.prefs.setBoolPref("mail.inline_attachments", viewAttachmentInline);
+ target.setAttribute("checked", viewAttachmentInline ? "true" : "false");
+}
+
+function IsGetNewMessagesEnabled() {
+ for (let server of MailServices.accounts.allServers) {
+ if (server.type == "none") {
+ continue;
+ }
+ return true;
+ }
+ return false;
+}
+
+function IsGetNextNMessagesEnabled() {
+ let selectedFolders = GetSelectedMsgFolders();
+ let folder = selectedFolders.length ? selectedFolders[0] : null;
+
+ let menuItem = document.getElementById("menu_getnextnmsg");
+ if (
+ folder &&
+ !folder.isServer &&
+ folder.server instanceof Ci.nsINntpIncomingServer
+ ) {
+ menuItem.label = PluralForm.get(
+ folder.server.maxArticles,
+ document
+ .getElementById("bundle_messenger")
+ .getString("getNextNewsMessages")
+ ).replace("#1", folder.server.maxArticles);
+ menuItem.removeAttribute("hidden");
+ return true;
+ }
+
+ menuItem.setAttribute("hidden", "true");
+ return false;
+}
+
+function MsgSynchronizeOffline() {
+ window.openDialog(
+ "chrome://messenger/content/msgSynchronize.xhtml",
+ "",
+ "centerscreen,chrome,modal,titlebar,resizable=yes",
+ { msgWindow }
+ );
+}
+
+function IsAccountOfflineEnabled() {
+ var selectedFolders = GetSelectedMsgFolders();
+
+ if (selectedFolders && selectedFolders.length == 1) {
+ return selectedFolders[0].supportsOffline;
+ }
+ return false;
+}
+
+function GetDefaultAccountRootFolder() {
+ var account = MailServices.accounts.defaultAccount;
+ if (account) {
+ return account.incomingServer.rootMsgFolder;
+ }
+
+ return null;
+}
+
+/**
+ * Check for new messages for all selected folders, or for the default account
+ * in case no folders are selected.
+ */
+function GetFolderMessages(selectedFolders = GetSelectedMsgFolders()) {
+ var defaultAccountRootFolder = GetDefaultAccountRootFolder();
+
+ // if nothing selected, use the default
+ var folders = selectedFolders.length
+ ? selectedFolders
+ : [defaultAccountRootFolder];
+
+ if (!folders[0]) {
+ return;
+ }
+
+ for (var i = 0; i < folders.length; i++) {
+ var serverType = folders[i].server.type;
+ if (folders[i].isServer && serverType == "nntp") {
+ // If we're doing "get msgs" on a news server.
+ // Update unread counts on this server.
+ folders[i].server.performExpand(msgWindow);
+ } else if (folders[i].isServer && serverType == "imap") {
+ GetMessagesForInboxOnServer(folders[i].server);
+ } else if (serverType == "none") {
+ // If "Local Folders" is selected and the user does "Get Msgs" and
+ // LocalFolders is not deferred to, get new mail for the default account
+ //
+ // XXX TODO
+ // Should shift click get mail for all (authenticated) accounts?
+ // see bug #125885.
+ if (!folders[i].server.isDeferredTo) {
+ if (!defaultAccountRootFolder) {
+ continue;
+ }
+ GetNewMsgs(defaultAccountRootFolder.server, defaultAccountRootFolder);
+ } else {
+ GetNewMsgs(folders[i].server, folders[i]);
+ }
+ } else {
+ GetNewMsgs(folders[i].server, folders[i]);
+ }
+ }
+}
+
+/**
+ * Gets new messages for the given server, for the given folder.
+ *
+ * @param server which nsIMsgIncomingServer to check for new messages
+ * @param folder which nsIMsgFolder folder to check for new messages
+ */
+function GetNewMsgs(server, folder) {
+ // Note that for Global Inbox folder.server != server when we want to get
+ // messages for a specific account.
+
+ // Whenever we do get new messages, clear the old new messages.
+ folder.biffState = Ci.nsIMsgFolder.nsMsgBiffState_NoMail;
+ folder.clearNewMessages();
+ server.getNewMessages(folder, msgWindow, new TransportErrorUrlListener());
+}
+
+function InformUserOfCertError(secInfo, targetSite) {
+ let params = {
+ exceptionAdded: false,
+ securityInfo: secInfo,
+ prefetchCert: true,
+ location: targetSite,
+ };
+ window.openDialog(
+ "chrome://pippki/content/exceptionDialog.xhtml",
+ "",
+ "chrome,centerscreen,modal",
+ params
+ );
+}
+
+/**
+ * A listener to be passed to the url object of the server request being issued
+ * to detect the bad server certificates.
+ *
+ * @implements {nsIUrlListener}
+ */
+function TransportErrorUrlListener() {}
+
+TransportErrorUrlListener.prototype = {
+ OnStartRunningUrl(url) {},
+
+ OnStopRunningUrl(url, exitCode) {
+ if (Components.isSuccessCode(exitCode)) {
+ return;
+ }
+ let nssErrorsService = Cc["@mozilla.org/nss_errors_service;1"].getService(
+ Ci.nsINSSErrorsService
+ );
+ try {
+ let errorClass = nssErrorsService.getErrorClass(exitCode);
+ if (errorClass == Ci.nsINSSErrorsService.ERROR_CLASS_BAD_CERT) {
+ let mailNewsUrl = url.QueryInterface(Ci.nsIMsgMailNewsUrl);
+ let secInfo = mailNewsUrl.failedSecInfo;
+ InformUserOfCertError(secInfo, url.asciiHostPort);
+ }
+ } catch (e) {
+ // It's not an NSS error.
+ }
+ },
+
+ // nsISupports
+ QueryInterface: ChromeUtils.generateQI(["nsIUrlListener"]),
+};
+
+function SendUnsentMessages() {
+ let msgSendlater = Cc["@mozilla.org/messengercompose/sendlater;1"].getService(
+ Ci.nsIMsgSendLater
+ );
+
+ for (let identity of MailServices.accounts.allIdentities) {
+ let msgFolder = msgSendlater.getUnsentMessagesFolder(identity);
+ if (msgFolder) {
+ let numMessages = msgFolder.getTotalMessages(
+ false /* include subfolders */
+ );
+ if (numMessages > 0) {
+ msgSendlater.sendUnsentMessages(identity);
+ // Right now, all identities point to the same unsent messages
+ // folder, so to avoid sending multiple copies of the
+ // unsent messages, we only call messenger.SendUnsentMessages() once.
+ // See bug #89150 for details.
+ break;
+ }
+ }
+ }
+}
+
+function CoalesceGetMsgsForPop3ServersByDestFolder(
+ currentServer,
+ pop3DownloadServersArray,
+ localFoldersToDownloadTo
+) {
+ var inboxFolder = currentServer.rootMsgFolder.getFolderWithFlags(
+ Ci.nsMsgFolderFlags.Inbox
+ );
+ // coalesce the servers that download into the same folder...
+ var index = localFoldersToDownloadTo.indexOf(inboxFolder);
+ if (index == -1) {
+ if (inboxFolder) {
+ inboxFolder.biffState = Ci.nsIMsgFolder.nsMsgBiffState_NoMail;
+ inboxFolder.clearNewMessages();
+ }
+ localFoldersToDownloadTo.push(inboxFolder);
+ index = pop3DownloadServersArray.length;
+ pop3DownloadServersArray.push([]);
+ }
+ pop3DownloadServersArray[index].push(currentServer);
+}
+
+function GetMessagesForAllAuthenticatedAccounts() {
+ // now log into any server
+ try {
+ // Array of arrays of servers for a particular folder.
+ var pop3DownloadServersArray = [];
+ // parallel array of folders to download to...
+ var localFoldersToDownloadTo = [];
+ var pop3Server;
+
+ for (let server of MailServices.accounts.allServers) {
+ if (
+ server.protocolInfo.canGetMessages &&
+ !server.passwordPromptRequired
+ ) {
+ if (server.type == "pop3") {
+ CoalesceGetMsgsForPop3ServersByDestFolder(
+ server,
+ pop3DownloadServersArray,
+ localFoldersToDownloadTo
+ );
+ pop3Server = server.QueryInterface(Ci.nsIPop3IncomingServer);
+ } else {
+ // get new messages on the server for imap or rss
+ GetMessagesForInboxOnServer(server);
+ }
+ }
+ }
+ for (let i = 0; i < pop3DownloadServersArray.length; ++i) {
+ // any ol' pop3Server will do - the serversArray specifies which servers to download from
+ pop3Server.downloadMailFromServers(
+ pop3DownloadServersArray[i],
+ msgWindow,
+ localFoldersToDownloadTo[i],
+ null
+ );
+ }
+ } catch (ex) {
+ dump(ex + "\n");
+ }
+}
+
+function CommandUpdate_UndoRedo() {
+ EnableMenuItem("menu_undo", SetupUndoRedoCommand("cmd_undo"));
+ EnableMenuItem("menu_redo", SetupUndoRedoCommand("cmd_redo"));
+}
+
+function SetupUndoRedoCommand(command) {
+ let folder = document.getElementById("tabmail")?.currentTabInfo.folder;
+ if (!folder?.server.canUndoDeleteOnServer) {
+ return false;
+ }
+
+ let canUndoOrRedo = false;
+ let txnType;
+ try {
+ if (command == "cmd_undo") {
+ canUndoOrRedo = messenger.canUndo();
+ txnType = messenger.getUndoTransactionType();
+ } else {
+ canUndoOrRedo = messenger.canRedo();
+ txnType = messenger.getRedoTransactionType();
+ }
+ } catch (ex) {
+ // If this fails, assume we can't undo or redo.
+ console.error(ex);
+ }
+
+ if (canUndoOrRedo) {
+ let commands = {
+ [Ci.nsIMessenger.eUnknown]: "valueDefault",
+ [Ci.nsIMessenger.eDeleteMsg]: "valueDeleteMsg",
+ [Ci.nsIMessenger.eMoveMsg]: "valueMoveMsg",
+ [Ci.nsIMessenger.eCopyMsg]: "valueCopyMsg",
+ [Ci.nsIMessenger.eMarkAllMsg]: "valueUnmarkAllMsgs",
+ };
+ goSetMenuValue(command, commands[txnType]);
+ } else {
+ goSetMenuValue(command, "valueDefault");
+ }
+
+ return canUndoOrRedo;
+}
+
+/**
+ * Focus the gloda global search input box on current tab, or,
+ * if the search box is not available, open a new gloda search tab
+ * (with its search box focused).
+ */
+function QuickSearchFocus() {
+ // Default to focusing the search box on the current tab
+ let newTab = false;
+ let searchInput;
+ let tabmail = document.getElementById("tabmail");
+ // Tabmail should never be undefined.
+ if (!tabmail || tabmail.globalOverlay) {
+ return;
+ }
+
+ switch (tabmail.currentTabInfo.mode.name) {
+ case "glodaFacet":
+ // If we're currently viewing a Gloda tab, drill down to find the
+ // built-in search input, and select that.
+ searchInput = tabmail.currentTabInfo.panel.querySelector(
+ ".remote-gloda-search"
+ );
+ break;
+ default:
+ searchInput = document.querySelector(
+ "#unifiedToolbarContent .search-bar global-search-bar"
+ );
+ break;
+ }
+
+ if (!searchInput) {
+ // If searchInput is not found on current tab (e.g. removed by user),
+ // use a new tab.
+ newTab = true;
+ } else {
+ // The searchInput element exists on current tab.
+ // However, via toolbar customization, it can be in different places:
+ // Toolbars, tab bar, menu bar, etc. If the containing elements are hidden,
+ // searchInput will also be hidden, so clientHeight and clientWidth of the
+ // searchbox or one of its parents will typically be zero and we can test
+ // for that. If searchInput is hidden, use a new tab.
+ let element = searchInput;
+ while (element) {
+ if (element.clientHeight == 0 || element.clientWidth == 0) {
+ newTab = true;
+ }
+ element = element.parentElement;
+ }
+ }
+
+ if (!newTab) {
+ // Focus and select global search box on current tab.
+ if (searchInput.select) {
+ searchInput.select();
+ } else {
+ searchInput.focus();
+ }
+ } else {
+ // Open a new global search tab (with focus on its global search box)
+ tabmail.openTab("glodaFacet");
+ }
+}
+
+/**
+ * Open a new gloda search tab, with its search box focused.
+ */
+function openGlodaSearchTab() {
+ document.getElementById("tabmail").openTab("glodaFacet");
+}
+
+function MsgSearchAddresses() {
+ var args = { directory: null };
+ OpenOrFocusWindow(
+ args,
+ "mailnews:absearch",
+ "chrome://messenger/content/addressbook/abSearchDialog.xhtml"
+ );
+}
+
+function MsgFilterList(args) {
+ OpenOrFocusWindow(
+ args,
+ "mailnews:filterlist",
+ "chrome://messenger/content/FilterListDialog.xhtml"
+ );
+}
+
+function OpenOrFocusWindow(args, windowType, chromeURL) {
+ var desiredWindow = Services.wm.getMostRecentWindow(windowType);
+
+ if (desiredWindow) {
+ desiredWindow.focus();
+ if ("refresh" in args && args.refresh) {
+ desiredWindow.refresh(args);
+ }
+ } else {
+ window.openDialog(
+ chromeURL,
+ "",
+ "chrome,resizable,status,centerscreen,dialog=no",
+ args
+ );
+ }
+}
+
+function initAppMenuPopup() {
+ file_init();
+ view_init();
+ InitGoMessagesMenu();
+ menu_new_init();
+ CommandUpdate_UndoRedo();
+ document.commandDispatcher.updateCommands("create-menu-tasks");
+ UIFontSize.updateAppMenuButton(window);
+ initUiDensityAppMenu();
+
+ document.getElementById("appmenu_FolderViews").disabled =
+ document.getElementById("tabmail").currentTabInfo.mode.name !=
+ "mail3PaneTab";
+}
+
+/**
+ * Generate menu items that open a preferences dialog/tab for an installed addon,
+ * and add them to a menu popup. E.g. in the appmenu or Tools menu > addon prefs.
+ *
+ * @param {Element} parent - The element (e.g. menupopup) to populate.
+ * @param {string} [elementName] - The kind of menu item elements to create (e.g. "toolbarbutton").
+ * @param {string} [classes] - Classes for menu item elements with no icon.
+ * @param {string} [iconClasses] - Classes for menu item elements with an icon.
+ */
+async function initAddonPrefsMenu(
+ parent,
+ elementName = "menuitem",
+ classes,
+ iconClasses = "menuitem-iconic"
+) {
+ // Starting at the bottom, clear all menu items until we hit
+ // "no add-on prefs", which is the only disabled element. Above this element
+ // there may be further items that we want to preserve.
+ let noPrefsElem = parent.querySelector('[disabled="true"]');
+ while (parent.lastChild != noPrefsElem) {
+ parent.lastChild.remove();
+ }
+
+ // Enumerate all enabled addons with URL to XUL document with prefs.
+ let addonsFound = [];
+ for (let addon of await AddonManager.getAddonsByTypes(["extension"])) {
+ if (addon.userDisabled || addon.appDisabled || addon.softDisabled) {
+ continue;
+ }
+ if (addon.optionsURL) {
+ if (addon.optionsType == 5) {
+ addonsFound.push({
+ addon,
+ optionsURL: `addons://detail/${encodeURIComponent(
+ addon.id
+ )}/preferences`,
+ optionsOpenInAddons: true,
+ });
+ } else if (addon.optionsType === null || addon.optionsType == 3) {
+ addonsFound.push({
+ addon,
+ optionsURL: addon.optionsURL,
+ optionsOpenInTab: addon.optionsType == 3,
+ });
+ }
+ }
+ }
+
+ // Populate the menu with addon names and icons.
+ // Note: Having the following code in the getAddonsByTypes() async callback
+ // above works on Windows and Linux but doesn't work on Mac, see bug 1419145.
+ if (addonsFound.length > 0) {
+ addonsFound.sort((a, b) => a.addon.name.localeCompare(b.addon.name));
+ for (let {
+ addon,
+ optionsURL,
+ optionsOpenInTab,
+ optionsOpenInAddons,
+ } of addonsFound) {
+ let newItem = document.createXULElement(elementName);
+ newItem.setAttribute("label", addon.name);
+ newItem.setAttribute("value", optionsURL);
+ if (optionsOpenInTab) {
+ newItem.setAttribute("optionsType", "tab");
+ } else if (optionsOpenInAddons) {
+ newItem.setAttribute("optionsType", "addons");
+ }
+ let iconURL = addon.iconURL || addon.icon64URL;
+ if (iconURL) {
+ newItem.setAttribute("class", iconClasses);
+ newItem.setAttribute("image", iconURL);
+ } else if (classes) {
+ newItem.setAttribute("class", classes);
+ }
+ parent.appendChild(newItem);
+ }
+ noPrefsElem.setAttribute("collapsed", "true");
+ } else {
+ // Only show message that there are no addons with prefs.
+ noPrefsElem.setAttribute("collapsed", "false");
+ }
+}
+
+function openNewCardDialog() {
+ toAddressBook({ action: "create" });
+}
+
+/**
+ * Opens Address Book tab and triggers address book creation dialog defined
+ * type.
+ *
+ * @param {?string}[type = "JS"] type - The address book type needing creation.
+ */
+function openNewABDialog(type = "JS") {
+ toAddressBook({ action: `create_ab_${type}` });
+}
+
+/**
+ * Verifies we have the attachments in order to populate the menupopup.
+ * Resets the popup to be populated.
+ *
+ * @param {DOMEvent} event - The popupshowing event.
+ */
+function fillAttachmentListPopup(event) {
+ if (event.target.id != "attachmentMenuList") {
+ return;
+ }
+
+ const popup = event.target;
+
+ // Clear out the old menupopup.
+ while (popup.firstElementChild?.localName == "menu") {
+ popup.firstElementChild?.remove();
+ }
+
+ let aboutMessage =
+ document.getElementById("tabmail")?.currentAboutMessage ||
+ document.getElementById("messageBrowser")?.contentWindow;
+ if (!aboutMessage) {
+ return;
+ }
+
+ let attachments = aboutMessage.currentAttachments;
+ for (let [index, attachment] of attachments.entries()) {
+ addAttachmentToPopup(aboutMessage, popup, attachment, index);
+ }
+ aboutMessage.goUpdateAttachmentCommands();
+}
+
+/**
+ * Add each attachment to the menupop up before the menuseparator and create
+ * a submenu with the attachments' options (open, save, detach and delete).
+ *
+ * @param {?Window} aboutMessage - The current message on the message pane.
+ * @param {XULPopupElement} popup - #attachmentMenuList menupopup.
+ * @param {AttachmentInfo} attachment - The file attached to the email.
+ * @param {integer} attachmentIndex - The attachment's index.
+ */
+function addAttachmentToPopup(
+ aboutMessage,
+ popup,
+ attachment,
+ attachmentIndex
+) {
+ let item = document.createXULElement("menu");
+
+ function getString(aName) {
+ return document.getElementById("bundle_messenger").getString(aName);
+ }
+
+ // Insert the item just before the separator. The separator is the 2nd to
+ // last element in the popup.
+ item.classList.add("menu-iconic");
+ item.setAttribute("image", getIconForAttachment(attachment));
+
+ const separator = popup.querySelector("menuseparator");
+
+ // We increment the attachmentIndex here since we only use it for the
+ // label and accesskey attributes, and we want the accesskeys for the
+ // attachments list in the menu to be 1-indexed.
+ attachmentIndex++;
+
+ let displayName = SanitizeAttachmentDisplayName(attachment);
+ let label = document
+ .getElementById("bundle_messenger")
+ .getFormattedString("attachmentDisplayNameFormat", [
+ attachmentIndex,
+ displayName,
+ ]);
+ item.setAttribute("crop", "center");
+ item.setAttribute("label", label);
+ item.setAttribute("accesskey", attachmentIndex % 10);
+
+ // Each attachment in the list gets its own menupopup with options for
+ // saving, deleting, detaching, etc.
+ let menupopup = document.createXULElement("menupopup");
+ menupopup = item.appendChild(menupopup);
+
+ item = popup.insertBefore(item, separator);
+
+ if (attachment.isExternalAttachment) {
+ if (!attachment.hasFile) {
+ item.classList.add("notfound");
+ } else {
+ // The text-link class must be added to the <label> and have a <menu>
+ // hover rule. Adding to <menu> makes hover overflow the underline to
+ // the popup items.
+ let label = item.children[1];
+ label.classList.add("text-link");
+ }
+ }
+
+ if (attachment.isDeleted) {
+ item.classList.add("notfound");
+ }
+
+ let detached = attachment.isExternalAttachment;
+ let deleted = !attachment.hasFile;
+ let canDetach = aboutMessage?.CanDetachAttachments() && !deleted && !detached;
+
+ if (deleted) {
+ // We can't do anything with a deleted attachment, so just return.
+ item.disabled = true;
+ return;
+ }
+
+ // Create the "open" menu item
+ let menuitem = document.createXULElement("menuitem");
+ menuitem.attachment = attachment;
+ menuitem.addEventListener("command", () =>
+ attachment.open(aboutMessage.browsingContext)
+ );
+ menuitem.setAttribute("label", getString("openLabel"));
+ menuitem.setAttribute("accesskey", getString("openLabelAccesskey"));
+ menuitem.setAttribute("disabled", deleted);
+ menuitem = menupopup.appendChild(menuitem);
+
+ // Create the "save" menu item
+ menuitem = document.createXULElement("menuitem");
+ menuitem.attachment = attachment;
+ menuitem.addEventListener("command", () => attachment.save(messenger));
+ menuitem.setAttribute("label", getString("saveLabel"));
+ menuitem.setAttribute("accesskey", getString("saveLabelAccesskey"));
+ menuitem.setAttribute("disabled", deleted);
+ menuitem = menupopup.appendChild(menuitem);
+
+ // Create the "detach" menu item
+ menuitem = document.createXULElement("menuitem");
+ menuitem.attachment = attachment;
+ menuitem.addEventListener("command", () =>
+ attachment.detach(messenger, true)
+ );
+ menuitem.setAttribute("label", getString("detachLabel"));
+ menuitem.setAttribute("accesskey", getString("detachLabelAccesskey"));
+ menuitem.setAttribute("disabled", !canDetach);
+ menuitem = menupopup.appendChild(menuitem);
+
+ // Create the "delete" menu item
+ menuitem = document.createXULElement("menuitem");
+ menuitem.attachment = attachment;
+ menuitem.addEventListener("command", () =>
+ attachment.detach(messenger, false)
+ );
+ menuitem.setAttribute("label", getString("deleteLabel"));
+ menuitem.setAttribute("accesskey", getString("deleteLabelAccesskey"));
+ menuitem.setAttribute("disabled", !canDetach);
+ menuitem = menupopup.appendChild(menuitem);
+
+ // Create the "open containing folder" menu item, for existing detached only.
+ if (attachment.isFileAttachment) {
+ let menuseparator = document.createXULElement("menuseparator");
+ menupopup.appendChild(menuseparator);
+ menuitem = document.createXULElement("menuitem");
+ menuitem.attachment = attachment;
+ menuitem.setAttribute("oncommand", "this.attachment.openFolder();");
+ menuitem.setAttribute("label", getString("openFolderLabel"));
+ menuitem.setAttribute("accesskey", getString("openFolderLabelAccesskey"));
+ menuitem.setAttribute("disabled", !attachment.hasFile);
+ menuitem = menupopup.appendChild(menuitem);
+ }
+}
+
+/**
+ * Return the string of the corresponding type of attachment's icon.
+ *
+ * @param {AttachmentInfo} attachment - The file attached to the email.
+ * @returns {string}
+ */
+function getIconForAttachment(attachment) {
+ return attachment.isDeleted
+ ? "chrome://messenger/skin/icons/attachment-deleted.svg"
+ : `moz-icon://${attachment.name}?size=16&amp;contentType=${attachment.contentType}`;
+}
+
+/**
+ * Opens the Address Book to add the email address from the given mailto: URL.
+ *
+ * @param {string} url
+ */
+function addEmail(url) {
+ let addresses = getEmail(url);
+ toAddressBook({
+ action: "create",
+ address: addresses,
+ });
+}
+
+/**
+ * Extracts email address(es) from the given mailto: URL.
+ *
+ * @param {string} url
+ * @returns {string}
+ */
+function getEmail(url) {
+ let mailtolength = 7;
+ let qmark = url.indexOf("?");
+ let addresses;
+
+ if (qmark > mailtolength) {
+ addresses = url.substring(mailtolength, qmark);
+ } else {
+ addresses = url.substr(mailtolength);
+ }
+ // Let's try to unescape it using a character set
+ try {
+ addresses = Services.textToSubURI.unEscapeURIForUI(addresses);
+ } catch (ex) {
+ // Do nothing.
+ }
+ return addresses;
+}
+
+/**
+ * Begins composing an email to the address from the given mailto: URL.
+ *
+ * @param {string} linkURL
+ * @param {nsIMsgIdentity} [identity] - The identity to use, otherwise the
+ * default identity is used.
+ */
+function composeEmailTo(linkURL, identity) {
+ let fields = Cc[
+ "@mozilla.org/messengercompose/composefields;1"
+ ].createInstance(Ci.nsIMsgCompFields);
+ let params = Cc[
+ "@mozilla.org/messengercompose/composeparams;1"
+ ].createInstance(Ci.nsIMsgComposeParams);
+ fields.to = getEmail(linkURL);
+ params.type = Ci.nsIMsgCompType.New;
+ params.format = Ci.nsIMsgCompFormat.Default;
+ if (identity) {
+ params.identity = identity;
+ }
+ params.composeFields = fields;
+ MailServices.compose.OpenComposeWindowWithParams(null, params);
+}
diff --git a/comm/mail/base/content/mainCommandSet.inc.xhtml b/comm/mail/base/content/mainCommandSet.inc.xhtml
new file mode 100644
index 0000000000..ddc4fb6b66
--- /dev/null
+++ b/comm/mail/base/content/mainCommandSet.inc.xhtml
@@ -0,0 +1,247 @@
+# 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/.
+
+ <command id="cmd_quitApplication" oncommand="goQuitApplication(event)"/>
+ <command id="cmd_CustomizeMailToolbar"
+ oncommand="CustomizeMailToolbar('mail-toolbox', 'CustomizeMailToolbar')"/>
+
+ <commandset id="mailFileMenuItems"
+ commandupdater="true"
+ events="create-menu-file"
+ oncommandupdate="goUpdateMailMenuItems(this)">
+ <command id="cmd_newFolder" oncommand="goDoCommand('cmd_newFolder')" disabled="true"/>
+ <command id="cmd_newVirtualFolder" oncommand="goDoCommand('cmd_newVirtualFolder')" disabled="true"/>
+ <command id="cmd_getNewMessages" oncommand="goDoCommand('cmd_getNewMessages')" disabled="true"/>
+ <command id="cmd_open" oncommand="goDoCommand('cmd_open')"/>
+
+ <command id="cmd_emptyTrash" oncommand="goDoCommand('cmd_emptyTrash')" disabled="true"/>
+ <command id="cmd_compactFolder" oncommand="goDoCommand('cmd_compactFolder')" disabled="true"/>
+ <command id="cmd_print" oncommand="goDoCommand('cmd_print')" disabled="true"/>
+ <command id="cmd_saveAsFile" oncommand="goDoCommand('cmd_saveAsFile')" disabled="true"/>
+ <command id="cmd_saveAsTemplate" oncommand="goDoCommand('cmd_saveAsTemplate')" disabled="true"/>
+ <command id="cmd_getNextNMessages" oncommand="goDoCommand('cmd_getNextNMessages')" disabled="true"/>
+ <command id="cmd_deleteFolder" oncommand="goDoCommand('cmd_deleteFolder')"/>
+ <command id="cmd_renameFolder" oncommand="goDoCommand('cmd_renameFolder')" />
+ <command id="cmd_sendUnsentMsgs" oncommand="goDoCommand('cmd_sendUnsentMsgs')" />
+ <command id="cmd_subscribe" oncommand="goDoCommand('cmd_subscribe')" disabled="true"/>
+ <command id="cmd_synchronizeOffline" oncommand="goDoCommand('cmd_synchronizeOffline')" disabled="true"/>
+ <command id="cmd_downloadFlagged" oncommand="goDoCommand('cmd_downloadFlagged')" disabled="true"/>
+ <command id="cmd_downloadSelected" oncommand="goDoCommand('cmd_downloadSelected')" disabled="true"/>
+ <command id="cmd_settingsOffline" oncommand="goDoCommand('cmd_settingsOffline')" disabled="true"/>
+ </commandset>
+
+ <commandset id="mailViewMenuItems"
+ commandupdater="true"
+ events="create-menu-view"
+ oncommandupdate="goUpdateMailMenuItems(this)">
+ <command id="cmd_showQuickFilterBar"
+ oncommand="goDoCommand('cmd_showQuickFilterBar');"/>
+ <command id="cmd_toggleQuickFilterBar"
+ oncommand="goDoCommand('cmd_toggleQuickFilterBar');"/>
+ <command id="cmd_viewPageSource" oncommand="goDoCommand('cmd_viewPageSource')" disabled="true"/>
+ <command id="cmd_setFolderCharset" oncommand="goDoCommand('cmd_setFolderCharset')" />
+
+ <command id="cmd_expandAllThreads" oncommand="goDoCommand('cmd_expandAllThreads')" disabled="true"/>
+ <command id="cmd_collapseAllThreads" oncommand="goDoCommand('cmd_collapseAllThreads')" disabled="true"/>
+ <command id="cmd_viewClassicMailLayout" oncommand="goDoCommand('cmd_viewClassicMailLayout')" disabled="true"/>
+ <command id="cmd_viewWideMailLayout" oncommand="goDoCommand('cmd_viewWideMailLayout')" disabled="true"/>
+ <command id="cmd_viewVerticalMailLayout" oncommand="goDoCommand('cmd_viewVerticalMailLayout')" disabled="true"/>
+ <command id="cmd_toggleFolderPane" oncommand="goDoCommand('cmd_toggleFolderPane')" disabled="true"/>
+ <command id="cmd_toggleThreadPaneHeader" oncommand="goDoCommand('cmd_toggleThreadPaneHeader')" disabled="true"/>
+ <command id="cmd_toggleMessagePane" oncommand="goDoCommand('cmd_toggleMessagePane')" disabled="true"/>
+ <command id="cmd_viewAllMsgs" oncommand="goDoCommand('cmd_viewAllMsgs')" disabled="true"/>
+ <command id="cmd_viewUnreadMsgs" oncommand="goDoCommand('cmd_viewUnreadMsgs')" disabled="true"/>
+ <command id="cmd_viewThreadsWithUnread" oncommand="goDoCommand('cmd_viewThreadsWithUnread')" disabled="true"/>
+ <command id="cmd_viewWatchedThreadsWithUnread" oncommand="goDoCommand('cmd_viewWatchedThreadsWithUnread')" disabled="true"/>
+ <command id="cmd_viewIgnoredThreads" oncommand="goDoCommand('cmd_viewIgnoredThreads')" disabled="true"/>
+ <commandset id="viewZoomCommands"
+ commandupdater="true"
+ events="create-menu-view"
+ oncommandupdate="goUpdateMailMenuItems(this);">
+ <command id="cmd_fullZoomReduce"
+ oncommand="goDoCommand('cmd_fullZoomReduce');"/>
+ <command id="cmd_fullZoomEnlarge"
+ oncommand="goDoCommand('cmd_fullZoomEnlarge');"/>
+ <command id="cmd_fullZoomReset"
+ oncommand="goDoCommand('cmd_fullZoomReset');"/>
+ <command id="cmd_fullZoomToggle"
+ oncommand="goDoCommand('cmd_fullZoomToggle');"
+ checked="false"/>
+ </commandset>
+ </commandset>
+
+ <commandset id="mailEditMenuItems"
+ commandupdater="true"
+ events="create-menu-edit"
+ oncommandupdate="goUpdateMailMenuItems(this)">
+
+ <command id="cmd_undo"
+ oncommand="goDoCommand('cmd_undo')"
+ disabled="true"
+ valueDeleteMsg="&undoDeleteMsgCmd.label;"
+ valueMoveMsg="&undoMoveMsgCmd.label;"
+ valueCopyMsg="&undoCopyMsgCmd.label;"
+ valueUnmarkAllMsgs="&undoMarkAllCmd.label;"
+ valueDefault="&undoDefaultCmd.label;"/>
+ <command id="cmd_redo"
+ oncommand="goDoCommand('cmd_redo')"
+ disabled="true"
+ valueDeleteMsg="&redoDeleteMsgCmd.label;"
+ valueMoveMsg="&redoMoveMsgCmd.label;"
+ valueCopyMsg="&redoCopyMsgCmd.label;"
+ valueUnmarkAllMsgs="&redoMarkAllCmd.label;"
+ valueDefault="&redoDefaultCmd.label;"/>
+ <command id="cmd_cut"
+ oncommand="goDoCommand('cmd_cut')"
+ disabled="true"/>
+ <command id="cmd_copy"
+ oncommand="goDoCommand('cmd_copy')"
+ disabled="true"/>
+ <command id="cmd_paste"
+ oncommand="goDoCommand('cmd_paste')"
+ disabled="true"/>
+ <command id="cmd_delete"
+ oncommand="goDoCommand('cmd_delete')"
+ disabled="true"/>
+ <command id="cmd_cancel" oncommand="goDoCommand('cmd_cancel')"/>
+ <command id="cmd_selectThread" oncommand="goDoCommand('cmd_selectThread')"/>
+ <command id="cmd_selectFlagged" oncommand="goDoCommand('cmd_selectFlagged')"/>
+ <command id="cmd_toggleFavoriteFolder" oncommand="goDoCommand('cmd_toggleFavoriteFolder')"/>
+ <command id="cmd_properties" oncommand="goDoCommand('cmd_properties')"/>
+ <command id="cmd_find" oncommand="goDoCommand('cmd_find')" disabled="true"/>
+ <command id="cmd_findAgain" oncommand="goDoCommand('cmd_findAgain')" disabled="true"/>
+ <command id="cmd_findPrevious" oncommand="goDoCommand('cmd_findPrevious')"
+ disabled="true"/>
+ <command id="cmd_searchMessages" oncommand="goDoCommand('cmd_searchMessages');"/>
+ <!-- Stop/abort current network activities. -->
+ <command id="cmd_stop" oncommand="goDoCommand('cmd_stop')"/>
+ <command id="cmd_reload" oncommand="goDoCommand('cmd_reload')"/>
+ </commandset>
+
+ <commandset id="mailEditContextMenuItems">
+ <command id="cmd_copyLink" oncommand="goDoCommand('cmd_copyLink')" disabled="false"/>
+ <command id="cmd_copyImage" oncommand="goDoCommand('cmd_copyImageContents')" disabled="false"/>
+ </commandset>
+
+ <commandset id="mailGoMenuItems"
+ commandupdater="true"
+ events="create-menu-go"
+ oncommandupdate="goUpdateMailMenuItems(this)">
+
+ <command id="cmd_nextMsg" oncommand="goDoCommand('cmd_nextMsg')" disabled="true"/>
+ <command id="cmd_nextUnreadMsg" oncommand="goDoCommand('cmd_nextUnreadMsg')" disabled="true"/>
+ <command id="cmd_nextFlaggedMsg" oncommand="goDoCommand('cmd_nextFlaggedMsg')" disabled="true"/>
+ <command id="cmd_nextUnreadThread" oncommand="goDoCommand('cmd_nextUnreadThread')" disabled="true"/>
+ <command id="cmd_previousMsg" oncommand="goDoCommand('cmd_previousMsg')" disabled="true"/>
+ <command id="cmd_previousUnreadMsg" oncommand="goDoCommand('cmd_previousUnreadMsg')" disabled="true"/>
+ <command id="cmd_previousFlaggedMsg" oncommand="goDoCommand('cmd_previousFlaggedMsg')" disabled="true"/>
+ <command id="cmd_goStartPage" oncommand="goDoCommand('cmd_goStartPage');"/>
+ <command id="cmd_undoCloseTab" oncommand="goDoCommand('cmd_undoCloseTab');"/>
+ <command id="cmd_goForward" oncommand="goDoCommand('cmd_goForward')" disabled="true"/>
+ <command id="cmd_goBack" oncommand="goDoCommand('cmd_goBack')" disabled="true"/>
+ <command id="cmd_goFolder" oncommand="goDoCommand('cmd_goFolder', event)" disabled="true"/>
+ <command id="cmd_chat" oncommand="goDoCommand('cmd_chat')" disabled="true"/>
+ </commandset>
+
+ <commandset id="mailMessageMenuItems"
+ commandupdater="true"
+ events="create-menu-message"
+ oncommandupdate="goUpdateMailMenuItems(this)">
+ <command id="cmd_archive" oncommand="goDoCommand('cmd_archive')"/>
+ <command id="cmd_newMessage" oncommand="goDoCommand('cmd_newMessage', event)"/>
+ <command id="cmd_reply" oncommand="goDoCommand('cmd_reply', event)"/>
+ <command id="cmd_replySender" oncommand="goDoCommand('cmd_replySender', event)"/>
+ <command id="cmd_replyGroup" oncommand="goDoCommand('cmd_replyGroup', event)"/>
+ <command id="cmd_replyall" oncommand="goDoCommand('cmd_replyall', event)"/>
+ <command id="cmd_replylist" oncommand="goDoCommand('cmd_replylist', event)"/>
+ <command id="cmd_forward" oncommand="goDoCommand('cmd_forward', event);"/>
+ <command id="cmd_forwardInline" oncommand="goDoCommand('cmd_forwardInline', event)"/>
+ <command id="cmd_forwardAttachment" oncommand="goDoCommand('cmd_forwardAttachment', event);"/>
+ <command id="cmd_redirect" oncommand="goDoCommand('cmd_redirect', event)"/>
+ <command id="cmd_editAsNew" oncommand="goDoCommand('cmd_editAsNew', event)"/>
+ <command id="cmd_editDraftMsg" oncommand="goDoCommand('cmd_editDraftMsg', event)"/>
+ <command id="cmd_newMsgFromTemplate" oncommand="goDoCommand('cmd_newMsgFromTemplate', event)"/>
+ <command id="cmd_editTemplateMsg" oncommand="goDoCommand('cmd_editTemplateMsg', event)"/>
+ <command id="cmd_openMessage" oncommand="goDoCommand('cmd_openMessage')"/>
+ <command id="cmd_openConversation" oncommand="goDoCommand('cmd_openConversation')"/>
+ <command id="cmd_moveToFolderAgain" oncommand="goDoCommand('cmd_moveToFolderAgain')"/>
+ <command id="cmd_createFilterFromMenu" oncommand="goDoCommand('cmd_createFilterFromMenu')"/>
+ <command id="cmd_killThread" oncommand="goDoCommand('cmd_killThread')"/>
+ <command id="cmd_killSubthread" oncommand="goDoCommand('cmd_killSubthread')"/>
+ <command id="cmd_watchThread" oncommand="goDoCommand('cmd_watchThread')"/>
+ </commandset>
+
+ <commandset id="mailGetMsgMenuItems"
+ commandupdater="true"
+ events="create-menu-getMsgToolbar,create-menu-file"
+ oncommandupdate="goUpdateMailMenuItems(this)">
+
+ <command id="cmd_getMsgsForAuthAccounts"
+ oncommand="goDoCommand('cmd_getMsgsForAuthAccounts'); event.stopPropagation()"
+ disabled="true"/>
+ </commandset>
+
+ <commandset id="mailMarkMenuItems"
+ commandupdater="true"
+ events="create-menu-mark"
+ oncommandupdate="goUpdateMailMenuItems(this)">
+ <command id="cmd_mark"/>
+ <command id="cmd_tag"/>
+ <command id="cmd_toggleRead" oncommand="goDoCommand('cmd_toggleRead'); event.stopPropagation()" disabled="true"/>
+ <command id="cmd_markAsRead" oncommand="goDoCommand('cmd_markAsRead'); event.stopPropagation()" disabled="true"/>
+ <command id="cmd_markAsUnread" oncommand="goDoCommand('cmd_markAsUnread'); event.stopPropagation()" disabled="true"/>
+ <command id="cmd_markAllRead" oncommand="goDoCommand('cmd_markAllRead'); event.stopPropagation()" disabled="true"/>
+ <command id="cmd_markThreadAsRead" oncommand="goDoCommand('cmd_markThreadAsRead'); event.stopPropagation()" disabled="true"/>
+ <command id="cmd_markReadByDate" oncommand="goDoCommand('cmd_markReadByDate');" disabled="true"/>
+ <command id="cmd_markAsFlagged" oncommand="goDoCommand('cmd_markAsFlagged'); event.stopPropagation()" disabled="true"/>
+ <command id="cmd_markAsJunk" oncommand="goDoCommand('cmd_markAsJunk'); event.stopPropagation()" disabled="true"/>
+ <command id="cmd_markAsNotJunk" oncommand="goDoCommand('cmd_markAsNotJunk'); event.stopPropagation()" disabled="true"/>
+ <command id="cmd_recalculateJunkScore" oncommand="goDoCommand('cmd_recalculateJunkScore');" disabled="true"/>
+ <command id="cmd_viewAllHeader" oncommand="goDoCommand('cmd_viewAllHeader');" disabled="true"/>
+ <command id="cmd_viewNormalHeader" oncommand="goDoCommand('cmd_viewNormalHeader');" disabled="true"/>
+ </commandset>
+
+ <commandset id="mailTagMenuItems"
+ commandupdater="true"
+ events="create-menu-tag"
+ oncommandupdate="goUpdateMailMenuItems(this);">
+
+ <command id="cmd_addTag" oncommand="goDoCommand('cmd_addTag'); event.stopPropagation();"/>
+ <command id="cmd_manageTags" oncommand="goDoCommand('cmd_manageTags'); event.stopPropagation();"/>
+ <command id="cmd_removeTags" oncommand="goDoCommand('cmd_removeTags'); event.stopPropagation();"/>
+ </commandset>
+
+ <commandset id="mailToolsMenuItems"
+ commandupdater="true"
+ events="create-menu-tasks"
+ oncommandupdate="goUpdateMailMenuItems(this);">
+ <command id="cmd_applyFilters" oncommand="goDoCommand('cmd_applyFilters');" disabled="true"/>
+ <command id="cmd_applyFiltersToSelection"
+ oncommand="goDoCommand('cmd_applyFiltersToSelection');"
+ disabled="true"
+ valueSelection="&filtersApplyToSelection.label;"
+ valueSelectionAccessKey="&filtersApplyToSelection.accesskey;"
+ valueMessage="&filtersApplyToMessage.label;"
+ valueMessageAccessKey="&filtersApplyToMessage.accesskey;"/>
+ <command id="cmd_runJunkControls" oncommand="goDoCommand('cmd_runJunkControls');" disabled="true"/>
+ <command id="cmd_deleteJunk" oncommand="goDoCommand('cmd_deleteJunk');" disabled="true"/>
+ </commandset>
+
+ <commandset id="mailChatMenuItems"
+ commandupdater="true"
+ events="create-menu-tag">
+ <command id="cmd_joinChat" oncommand="chatHandler.joinChat();" disabled="true"/>
+ <command id="cmd_chatStatus" oncommand="chatHandler.setStatusMenupopupCommand(event);" disabled="true"/>
+ <command id="cmd_addChatBuddy" oncommand="chatHandler.addBuddy();" disabled="true"/>
+ </commandset>
+
+#ifdef XP_MACOSX
+ <commandset id="macWindowMenuItems">
+ <!-- Mac Window menu -->
+ <command id="minimizeWindow" label="&minimizeWindow.label;" oncommand="window.minimize();"/>
+ <command id="zoomWindow" label="&zoomWindow.label;" oncommand="zoomWindow();"/>
+ <command id="Tasks:Mail" oncommand="focusOnMail(1);"/>
+ <command id="Tasks:AddressBook" oncommand="toAddressBook();"/>
+ </commandset>
+#endif
diff --git a/comm/mail/base/content/mainKeySet.inc.xhtml b/comm/mail/base/content/mainKeySet.inc.xhtml
new file mode 100644
index 0000000000..6d79ad8c0c
--- /dev/null
+++ b/comm/mail/base/content/mainKeySet.inc.xhtml
@@ -0,0 +1,264 @@
+# 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/.
+
+#ifdef XP_UNIX
+#ifndef XP_MACOSX
+#define XP_GNOME 1
+#endif
+#endif
+
+ <key id="space" key=" " modifiers="shift any" oncommand="goDoCommand('cmd_space', event);"/>
+
+ <!-- File Menu -->
+ <key id="key_close" key="&closeCmd.key;" command="cmd_close" modifiers="accel"/>
+#ifndef XP_MACOSX
+ <key id="key_close2" keycode="VK_F4" modifiers="accel" command="cmd_close"/>
+ <key id="key_renameFolder" keycode="&renameFolder.key;" oncommand="goDoCommand('cmd_renameFolder')"/>
+#endif
+ <key id="key_quitApplication" data-l10n-id="quit-app-shortcut"
+#ifdef XP_WIN
+ modifiers="accel,shift"
+#else
+ modifiers="accel"
+#endif
+#ifdef XP_MACOSX
+ internal="true"
+#else
+ command="cmd_quitApplication"
+#endif
+ reserved="true"/>
+ <!-- Edit Menu -->
+ <key id="key_undo" data-l10n-id="text-action-undo-shortcut" modifiers="accel" internal="true"/>
+ <key id="key_redo"
+#ifdef XP_UNIX
+ data-l10n-id="text-action-undo-shortcut" modifiers="shift, accel"
+#else
+ data-l10n-id="text-action-redo-shortcut" modifiers="accel"
+#endif
+ internal="true"/>
+ <key id="key_cut" data-l10n-id="text-action-cut-shortcut" modifiers="accel" internal="true"/>
+ <key id="key_copy" data-l10n-id="text-action-copy-shortcut" modifiers="accel" internal="true"/>
+ <key id="key_paste" data-l10n-id="text-action-paste-shortcut" modifiers="accel" internal="true"/>
+#ifdef XP_MACOSX
+ <key id="key_delete" keycode="VK_BACK"
+ oncommand="goDoCommand('cmd_delete');"/>
+ <key id="key_delete2" keycode="VK_DELETE"
+ oncommand="goDoCommand('cmd_delete');"/>
+ <key id="cmd_shiftDelete" keycode="VK_BACK"
+ oncommand="goDoCommand('cmd_shiftDelete');" modifiers="shift"/>
+ <key id="cmd_shiftDelete2" keycode="VK_DELETE"
+ oncommand="goDoCommand('cmd_shiftDelete');" modifiers="shift"/>
+#else
+ <key id="key_delete" keycode="VK_DELETE"
+ oncommand="goDoCommand('cmd_delete');"/>
+ <key id="cmd_shiftDelete" keycode="VK_DELETE"
+ oncommand="goDoCommand('cmd_shiftDelete');" modifiers="shift"/>
+#endif
+ <key id="key_selectAll" data-l10n-id="text-action-select-all-shortcut" modifiers="accel" internal="true"/>
+ <key id="key_selectThread" key="&selectThreadCmd.key;" oncommand="goDoCommand('cmd_selectThread');" modifiers="accel, shift"/>
+
+ <key id="key_toggleRead" key="&toggleReadCmd.key;" oncommand="goDoCommand('cmd_toggleRead');"/>
+ <key id="key_toggleFlagged" key="&markStarredCmd.key;" oncommand="goDoCommand('cmd_markAsFlagged');"/>
+ <key id="key_markJunk" key="&markAsJunkCmd.key;" oncommand="goDoCommand('cmd_markAsJunk');"/>
+ <key id="key_markNotJunk" key="&markAsNotJunkCmd.key;" oncommand="goDoCommand('cmd_markAsNotJunk');"
+ modifiers="shift"/>
+
+ <key id="key_markAllRead" key="&markAllReadCmd.key;"
+ oncommand="goDoCommand('cmd_markAllRead');" modifiers="shift"/>
+
+ <key id="key_markThreadAsRead" key="&markThreadAsReadCmd.key;" oncommand="goDoCommand('cmd_markThreadAsRead')"/>
+ <key id="key_markReadByDate" key="&markReadByDateCmd.key;" oncommand="goDoCommand('cmd_markReadByDate')"/>
+ <key id="key_nextMsg" key="&nextMsgCmd.key;" oncommand="goDoCommand('cmd_nextMsg')"/>
+ <key id="key_nextUnreadMsg" key="&nextUnreadMsgCmd.key;" oncommand="goDoCommand('cmd_nextUnreadMsg')"/>
+ <key id="key_expandAllThreads" key="&expandAllThreadsCmd.key;" oncommand="goDoCommand('cmd_expandAllThreads')"/>
+ <key key="&expandAllThreadsCmd.key;" modifiers="shift" oncommand="goDoCommand('cmd_expandAllThreads')"/>
+ <key id="key_collapseAllThreads" key="&collapseAllThreadsCmd.key;" oncommand="goDoCommand('cmd_collapseAllThreads')"/>
+ <key key="&collapseAllThreadsCmd.key;" modifiers="shift" oncommand="goDoCommand('cmd_collapseAllThreads')"/>
+ <key id="key_nextUnreadThread" key="&nextUnreadThread.key;" oncommand="goDoCommand('cmd_nextUnreadThread')"/>
+ <key id="key_previousMsg" key="&prevMsgCmd.key;" oncommand="goDoCommand('cmd_previousMsg')"/>
+ <key id="key_previousUnreadMsg" key="&prevUnreadMsgCmd.key;" oncommand="goDoCommand('cmd_previousUnreadMsg')"/>
+ <key id="key_archive" key="&archiveMsgCmd.key;" oncommand="goDoCommand('cmd_archive')"/>
+ <key id="key_goForward" key="&goForwardCmd.commandKey;" oncommand="goDoCommand('cmd_goForward')"/>
+ <key id="key_goBack" key="&goBackCmd.commandKey;" oncommand="goDoCommand('cmd_goBack')"/>
+ <key id="key_goStartPage" keycode="VK_HOME" oncommand="goDoCommand('cmd_goStartPage')" modifiers="alt"/>
+ <key id="key_undoCloseTab" key="&undoCloseTabCmd.commandkey;" oncommand="goDoCommand('cmd_undoCloseTab')" modifiers="accel, shift"/>
+ <key id="key_reply" key="&replyMsgCmd.key;" oncommand="goDoCommand('cmd_reply')" modifiers="accel"/>
+ <key id="key_replyall" key="&replyToAllMsgCmd.key;" oncommand="goDoCommand('cmd_replyall')" modifiers="accel, shift"/>
+ <key id="key_replylist" key="&replyToListMsgCmd.key;" oncommand="goDoCommand('cmd_replylist')" modifiers="accel, shift"/>
+ <key id="key_forward" key="&forwardMsgCmd.key;" oncommand="goDoCommand('cmd_forward')" modifiers="accel"/>
+ <key id="key_editAsNew" key="&editAsNewMsgCmd.key;" oncommand="goDoCommand('cmd_editAsNew')" modifiers="accel"/>
+ <key id="key_newMsgFromTemplate" keycode="&newMsgFromTemplateCmd.keycode;" internal="true"/><!-- for display on menus only -->
+ <key id="key_watchThread" key="&watchThreadMenu.key;" oncommand="goDoCommand('cmd_watchThread')" />
+ <key id="key_killThread" key="&killThreadMenu.key;" oncommand="goDoCommand('cmd_killThread')" />
+ <key id="key_killSubthread" key="&killSubthreadMenu.key;" oncommand="goDoCommand('cmd_killSubthread')" modifiers="shift" />
+ <key id="key_openMessage" key="&openMessageWindowCmd.key;" oncommand="goDoCommand('cmd_openMessage')" modifiers="accel"/>
+ <key id="key_openConversation" key="&openInConversationCmd.key;" oncommand="goDoCommand('cmd_openConversation')" modifiers="accel, shift"/>
+#ifdef XP_MACOSX
+ <key id="key_moveToFolderAgain" key="&moveToFolderAgainCmd.key;" oncommand="goDoCommand('cmd_moveToFolderAgain')" modifiers="alt, accel"/>
+#else
+ <key id="key_moveToFolderAgain" key="&moveToFolderAgainCmd.key;" oncommand="goDoCommand('cmd_moveToFolderAgain')" modifiers="accel, shift"/>
+#endif
+ <key id="key_print" key="&printCmd.key;" oncommand="goDoCommand('cmd_print')" modifiers="accel"/>
+ <key id="key_saveAsFile" key="&saveAsFileCmd.key;" oncommand="goDoCommand('cmd_saveAsFile')" modifiers="accel"/>
+ <key id="key_viewPageSource" key="&pageSourceCmd.key;" oncommand="goDoCommand('cmd_viewPageSource')" modifiers="accel"/>
+#ifdef XP_MACOSX
+ <key id="key_getNewMessagesAlt" keycode="VK_F5"
+ oncommand="goDoCommand('cmd_getNewMessages');"/>
+ <key id="key_getAllNewMessagesAlt" keycode="VK_F5" modifiers="shift"
+ oncommand="goDoCommand('cmd_getMsgsForAuthAccounts');"/>
+ <key id="key_getNewMessages" key="&getNewMessagesCmd.key;" modifiers="accel"
+ oncommand="goDoCommand('cmd_getNewMessages');"/>
+ <key id="key_getAllNewMessages" key="&getAllNewMessagesCmd.key;" modifiers="accel, shift"
+ oncommand="goDoCommand('cmd_getMsgsForAuthAccounts');"/>
+#else
+ <key id="key_getNewMessages" keycode="VK_F5"
+ oncommand="goDoCommand('cmd_getNewMessages');"/>
+ <key id="key_getAllNewMessages" keycode="VK_F5" modifiers="shift"
+ oncommand="goDoCommand('cmd_getMsgsForAuthAccounts');"/>
+#endif
+#ifdef XP_GNOME
+ <key id="key_getNewMessages2" keycode="VK_F9"
+ oncommand="goDoCommand('cmd_getNewMessages');"/>
+ <key id="key_getAllNewMessages2" keycode="VK_F9" modifiers="shift"
+ oncommand="goDoCommand('cmd_getMsgsForAuthAccounts');"/>
+#endif
+ <key id="key_find" key="&findCmd.key;" oncommand="goDoCommand('cmd_find')" modifiers="accel"/>
+ <key id="key_findAgain" key="&findAgainCmd.key;" oncommand="goDoCommand('cmd_findAgain')" modifiers="accel"/>
+ <key id="key_findPrev" key="&findPrevCmd.key;" oncommand="goDoCommand('cmd_findPrevious')" modifiers="accel, shift"/>
+ <key keycode="&findAgainCmd.key2;" oncommand="goDoCommand('cmd_findAgain')"/>
+ <key keycode="&findPrevCmd.key2;" oncommand="goDoCommand('cmd_findPrevious')" modifiers="shift"/>
+ <key id="key_quickSearchFocus" key="&quickSearchCmd.key;" oncommand="QuickSearchFocus()" modifiers="accel"/>
+
+ <keyset id="viewZoomKeys">
+ <key id="key_fullZoomReduce" key="&fullZoomReduceCmd.commandkey;"
+ command="cmd_fullZoomReduce" modifiers="accel"/>
+ <key key="&fullZoomReduceCmd.commandkey2;"
+ command="cmd_fullZoomReduce" modifiers="accel"/>
+ <key id="key_fullZoomEnlarge" key="&fullZoomEnlargeCmd.commandkey;"
+ command="cmd_fullZoomEnlarge" modifiers="accel"/>
+ <key key="&fullZoomEnlargeCmd.commandkey2;"
+ command="cmd_fullZoomEnlarge" modifiers="accel"/>
+ <key key="&fullZoomEnlargeCmd.commandkey3;"
+ command="cmd_fullZoomEnlarge" modifiers="accel"/>
+ <key id="key_fullZoomReset" key="&fullZoomResetCmd.commandkey;"
+ command="cmd_fullZoomReset" modifiers="accel"/>
+ <key key="&fullZoomResetCmd.commandkey2;"
+ command="cmd_fullZoomReset" modifiers="accel"/>
+ </keyset>
+
+ <!-- View Toggle Keys (F8) -->
+ <key id="key_toggleMessagePane" keycode="VK_F8" oncommand="goDoCommand('cmd_toggleMessagePane');"/>
+
+ <!-- Tag Keys -->
+ <!-- Includes both shifted and not, for Azerty and other layouts where the
+ numeric keys are shifted. -->
+ <key id="key_tag0" key="&tagCmd0.key;" modifiers="shift any"
+ oncommand="goDoCommand('cmd_removeTags');"/>
+ <key id="key_tag1" key="&tagCmd1.key;" modifiers="shift any"
+ oncommand="goDoCommand('cmd_tag1');"/>
+ <key id="key_tag2" key="&tagCmd2.key;" modifiers="shift any"
+ oncommand="goDoCommand('cmd_tag2');"/>
+ <key id="key_tag3" key="&tagCmd3.key;" modifiers="shift any"
+ oncommand="goDoCommand('cmd_tag3');"/>
+ <key id="key_tag4" key="&tagCmd4.key;" modifiers="shift any"
+ oncommand="goDoCommand('cmd_tag4');"/>
+ <key id="key_tag5" key="&tagCmd5.key;" modifiers="shift any"
+ oncommand="goDoCommand('cmd_tag5');"/>
+ <key id="key_tag6" key="&tagCmd6.key;" modifiers="shift any"
+ oncommand="goDoCommand('cmd_tag6');"/>
+ <key id="key_tag7" key="&tagCmd7.key;" modifiers="shift any"
+ oncommand="goDoCommand('cmd_tag7');"/>
+ <key id="key_tag8" key="&tagCmd8.key;" modifiers="shift any"
+ oncommand="goDoCommand('cmd_tag8');"/>
+ <key id="key_tag9" key="&tagCmd9.key;" modifiers="shift any"
+ oncommand="goDoCommand('cmd_tag9');"/>
+
+ <!-- Tools Keys -->
+ <key id="key_searchMail"
+ key="&searchMailCmd.key;"
+ modifiers="accel,shift"
+ command="cmd_searchMessages"/>
+ <key id="key_errorConsole"
+ key="&errorConsoleCmd.commandkey;"
+ oncommand="toJavaScriptConsole();"
+ modifiers="accel,shift"/>
+ <key id="key_devtoolsToolbox"
+ key="&devToolboxCmd.commandkey;"
+#ifdef XP_MACOSX
+ modifiers="accel,alt"
+#else
+ modifiers="accel,shift"
+#endif
+ oncommand="BrowserToolboxLauncher.init();"/>
+ <key id="key_sanitizeHistory"
+ keycode="VK_DELETE"
+ oncommand="toSanitize();"
+ modifiers="accel,shift"/>
+#ifdef XP_MACOSX
+ <key id="key_sanitizeHistory_mac"
+ keycode="VK_BACK"
+ oncommand="toSanitize();"
+ modifiers="accel,shift"/>
+#endif
+ <key id="key_addressbook"
+ key="&addressBookCmd.key;"
+ modifiers="accel, shift"
+ oncommand="toAddressBook();"/>
+ <key id="key_savedFiles"
+ key="&savedFiles.key;"
+ modifiers="accel"
+ oncommand="openSavedFilesWnd();"/>
+
+ <key id="key_qfb_show"
+ modifiers="accel,shift"
+ data-l10n-id="quick-filter-bar-show"
+ command="cmd_showQuickFilterBar"/>
+#ifdef XP_GNOME
+#define NUM_SELECT_TAB_MODIFIER alt
+#else
+#define NUM_SELECT_TAB_MODIFIER accel
+#endif
+
+#expand <key id="key_mail" oncommand="focusOnMail(0, event);" key="1" modifiers="__NUM_SELECT_TAB_MODIFIER__"/>
+#expand <key id="key_selectTab2" oncommand="focusOnMail(1, event);" key="2" modifiers="__NUM_SELECT_TAB_MODIFIER__"/>
+#expand <key id="key_selectTab3" oncommand="focusOnMail(2, event);" key="3" modifiers="__NUM_SELECT_TAB_MODIFIER__"/>
+#expand <key id="key_selectTab4" oncommand="focusOnMail(3, event);" key="4" modifiers="__NUM_SELECT_TAB_MODIFIER__"/>
+#expand <key id="key_selectTab5" oncommand="focusOnMail(4, event);" key="5" modifiers="__NUM_SELECT_TAB_MODIFIER__"/>
+#expand <key id="key_selectTab6" oncommand="focusOnMail(5, event);" key="6" modifiers="__NUM_SELECT_TAB_MODIFIER__"/>
+#expand <key id="key_selectTab7" oncommand="focusOnMail(6, event);" key="7" modifiers="__NUM_SELECT_TAB_MODIFIER__"/>
+#expand <key id="key_selectTab8" oncommand="focusOnMail(7, event);" key="8" modifiers="__NUM_SELECT_TAB_MODIFIER__"/>
+#expand <key id="key_selectLastTab" oncommand="focusOnMail(-1, event);" key="9" modifiers="__NUM_SELECT_TAB_MODIFIER__"/>
+
+#ifdef XP_MACOSX
+ <!-- Mac Window menu keys -->
+ <key id="key_minimizeWindow"
+ command="minimizeWindow"
+ key="&minimizeWindow.key;"
+ modifiers="accel"/>
+ <key id="key_addressbook"
+ oncommand="toAddressBook();"
+ key="&addressBookCmd.key;"
+ modifiers="accel, shift"/>
+ <!-- the following 3 keys are used in the application menu on Mac OS X Cocoa widgets -->
+ <key id="key_preferencesCmdMac"
+ key="&preferencesCmdMac.commandkey;"
+ modifiers="&preferencesCmdMac.modifiers;"
+ internal="true"/>
+ <key id="key_hideThisAppCmdMac"
+ key="&hideThisAppCmdMac.commandkey;"
+ modifiers="&hideThisAppCmdMac.modifiers;"
+ internal="true"/>
+ <key id="key_hideOtherAppsCmdMac"
+ key="&hideOtherAppsCmdMac.commandkey;"
+ modifiers="&hideOtherAppsCmdMac.modifiers;"
+ internal="true"/>
+ <key id="key_openHelp"
+ oncommand="openSupportURL();"
+ key="&productHelpMac.commandkey;"
+ modifiers="&productHelpMac.modifiers;"/>
+#else
+ <key id="key_openHelp"
+ oncommand="openSupportURL();"
+ keycode="&productHelp.commandkey;"/>
+#endif
diff --git a/comm/mail/base/content/mainStatusbar.inc.xhtml b/comm/mail/base/content/mainStatusbar.inc.xhtml
new file mode 100644
index 0000000000..149d06fea7
--- /dev/null
+++ b/comm/mail/base/content/mainStatusbar.inc.xhtml
@@ -0,0 +1,19 @@
+# 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/.
+
+ <hbox id="statusTextBox" ondblclick="openActivityMgr();" flex="1">
+ <hbox class="statusbarpanel">
+ <toolbarbutton id="offline-status" oncommand="MailOfflineMgr.toggleOfflineStatus();"/>
+ </hbox>
+ <label id="statusText" class="statusbarpanel" value="&statusText.label;" flex="1" crop="end"/>
+ <hbox id="statusbar-progresspanel" class="statusbarpanel statusbarpanel-progress" collapsed="true">
+ <html:progress class="progressmeter-statusbar" id="statusbar-icon" value="0" max="100"/>
+ </hbox>
+ <hbox id="quotaPanel" class="statusbarpanel statusbarpanel-progress" hidden="true">
+ <stack>
+ <html:progress id="quotaMeter" class="progressmeter-statusbar" value="0" max="100"/>
+ <html:a id="quotaLabel" onclick="openFolderQuota();"></html:a>
+ </stack>
+ </hbox>
+ </hbox>
diff --git a/comm/mail/base/content/messageWindow.js b/comm/mail/base/content/messageWindow.js
new file mode 100644
index 0000000000..2b93de1fc9
--- /dev/null
+++ b/comm/mail/base/content/messageWindow.js
@@ -0,0 +1,742 @@
+/**
+ * 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/. */
+
+/* This is where functions related to the standalone message window are kept */
+
+/* import-globals-from ../../../../toolkit/content/viewZoomOverlay.js */
+/* import-globals-from ../../../mailnews/base/prefs/content/accountUtils.js */
+/* import-globals-from ../../components/customizableui/content/panelUI.js */
+/* import-globals-from mail-offline.js */
+/* import-globals-from mailCommands.js */
+/* import-globals-from mailCore.js */
+/* import-globals-from mailWindowOverlay.js */
+/* import-globals-from messenger-customization.js */
+/* import-globals-from toolbarIconColor.js */
+
+/* globals messenger, CreateMailWindowGlobals, InitMsgWindow, OnMailWindowUnload */ // From mailWindow.js
+
+var { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ BondOpenPGP: "chrome://openpgp/content/BondOpenPGP.jsm",
+ UIDensity: "resource:///modules/UIDensity.jsm",
+ UIFontSize: "resource:///modules/UIFontSize.jsm",
+});
+
+var messageBrowser;
+
+function getBrowser() {
+ return document
+ .getElementById("messageBrowser")
+ .contentDocument.getElementById("messagepane");
+}
+
+this.__defineGetter__("browser", getBrowser);
+
+window.addEventListener("DOMContentLoaded", event => {
+ if (event.target != document) {
+ return;
+ }
+
+ messageBrowser = document.getElementById("messageBrowser");
+ messageBrowser.addEventListener("messageURIChanged", () => {
+ // Update toolbar buttons.
+ goUpdateCommand("cmd_getNewMessages");
+ goUpdateCommand("cmd_print");
+ goUpdateCommand("cmd_delete");
+ document.commandDispatcher.updateCommands("create-menu-go");
+ document.commandDispatcher.updateCommands("create-menu-message");
+ });
+ messageBrowser.addEventListener(
+ "load",
+ event => (messageBrowser.contentWindow.tabOrWindow = window),
+ true
+ );
+});
+window.addEventListener("load", OnLoadMessageWindow);
+window.addEventListener("unload", OnUnloadMessageWindow);
+
+// we won't show the window until the onload() handler is finished
+// so we do this trick (suggested by hyatt / blaker)
+function OnLoadMessageWindow() {
+ // Set a sane starting width/height for all resolutions on new profiles.
+ // Do this before the window loads.
+ if (!document.documentElement.hasAttribute("width")) {
+ // Prefer 860xfull height.
+ let defaultHeight = screen.availHeight;
+ let defaultWidth = screen.availWidth >= 860 ? 860 : screen.availWidth;
+
+ // On small screens, default to maximized state.
+ if (defaultHeight <= 600) {
+ document.documentElement.setAttribute("sizemode", "maximized");
+ }
+
+ document.documentElement.setAttribute("width", defaultWidth);
+ document.documentElement.setAttribute("height", defaultHeight);
+ // Make sure we're safe at the left/top edge of screen
+ document.documentElement.setAttribute("screenX", screen.availLeft);
+ document.documentElement.setAttribute("screenY", screen.availTop);
+ }
+
+ updateTroubleshootMenuItem();
+ ToolbarIconColor.init();
+ BondOpenPGP.init();
+ PanelUI.init();
+ gExtensionsNotifications.init();
+
+ setTimeout(delayedOnLoadMessageWindow, 0); // when debugging, set this to 5000, so you can see what happens after the window comes up.
+
+ messageBrowser.addEventListener("DOMTitleChanged", () => {
+ if (messageBrowser.contentTitle) {
+ if (AppConstants.platform == "macosx") {
+ document.title = messageBrowser.contentTitle;
+ } else {
+ document.title =
+ messageBrowser.contentTitle +
+ document.documentElement.getAttribute("titlemenuseparator") +
+ document.documentElement.getAttribute("titlemodifier");
+ }
+ } else {
+ document.title = document.documentElement.getAttribute("titlemodifier");
+ }
+ });
+
+ UIDensity.registerWindow(window);
+ UIFontSize.registerWindow(window);
+}
+
+function delayedOnLoadMessageWindow() {
+ HideMenus();
+ ShowMenus();
+ MailOfflineMgr.init();
+ CreateMailWindowGlobals();
+
+ // Run menubar initialization first, to avoid TabsInTitlebar code picking
+ // up mutations from it and causing a reflow.
+ if (AppConstants.platform != "macosx") {
+ AutoHideMenubar.init();
+ }
+
+ InitMsgWindow();
+
+ // initialize the customizeDone method on the customizeable toolbar
+ var toolbox = document.getElementById("mail-toolbox");
+ toolbox.customizeDone = function (aEvent) {
+ MailToolboxCustomizeDone(aEvent, "CustomizeMailToolbar");
+ };
+
+ SetupCommandUpdateHandlers();
+
+ setTimeout(actuallyLoadMessage, 0);
+}
+
+function actuallyLoadMessage() {
+ /*
+ * Our actual use cases that drive the arguments we take are:
+ * 1) Displaying a message from disk or that was an attachment on a message.
+ * Such messages have no (real) message header and must come in the form of
+ * a URI. (The message display code creates a 'dummy' header.)
+ * 2) Displaying a message that has a header available, either as a result of
+ * the user selecting a message in another window to spawn us or through
+ * some indirection like displaying a message by message-id. (The
+ * newsgroup UI exposes this, as well as the spotlight/vista indexers.)
+ *
+ * We clone views when possible for:
+ * - Consistency of navigation within the message display. Users would find
+ * it odd if they showed a message from a cross-folder view but ended up
+ * navigating around the message's actual folder.
+ * - Efficiency. It's faster to clone a view than open a new one.
+ *
+ * Our argument idioms for the use cases are thus:
+ * 1) [{msgHdr: A message header, viewWrapperToClone: (optional) a view
+ * wrapper to clone}]
+ * 2) [A Message header, (optional) the origin DBViewWraper]
+ * 3) [A Message URI] where the URI is an nsIURL corresponding to a message
+ * on disk or that is an attachment part on another message.
+ *
+ * Our original set of arguments, in case these get passed in and you're
+ * wondering why we explode, was:
+ * 0: A message URI, string or nsIURI.
+ * 1: A folder URI. If arg 0 was an nsIURI, it may have had a folder attribute.
+ * 2: The nsIMsgDBView used to open us.
+ */
+ if (window.arguments && window.arguments.length) {
+ let contentWindow = messageBrowser.contentWindow;
+ if (window.arguments[0] instanceof Ci.nsIURI) {
+ contentWindow.displayMessage(window.arguments[0].spec);
+ return;
+ }
+
+ let msgHdr, viewWrapperToClone;
+ // message header as an object?
+ if ("wrappedJSObject" in window.arguments[0]) {
+ let hdrObject = window.arguments[0].wrappedJSObject;
+ ({ msgHdr, viewWrapperToClone } = hdrObject);
+ } else if (window.arguments[0] instanceof Ci.nsIMsgDBHdr) {
+ // message header as a separate param?
+ msgHdr = window.arguments[0];
+ viewWrapperToClone = window.arguments[1];
+ }
+
+ contentWindow.displayMessage(
+ msgHdr.folder.getUriForMsg(msgHdr),
+ viewWrapperToClone
+ );
+ }
+
+ // set focus to the message pane
+ window.content.focus();
+}
+
+/**
+ * Load the given message into this window, and bring it to the front. This is
+ * supposed to be called whenever a message is supposed to be displayed in this
+ * window.
+ *
+ * @param aMsgHdr the message to display
+ * @param aViewWrapperToClone [optional] a DB view wrapper to clone for the
+ * message window
+ */
+function displayMessage(aMsgHdr, aViewWrapperToClone) {
+ let contentWindow = messageBrowser.contentWindow;
+ contentWindow.displayMessage(
+ aMsgHdr.folder.getUriForMsg(aMsgHdr),
+ aViewWrapperToClone
+ );
+
+ // bring this window to the front
+ window.focus();
+}
+
+function ShowMenus() {
+ var openMail3Pane_menuitem = document.getElementById("tasksMenuMail");
+ if (openMail3Pane_menuitem) {
+ openMail3Pane_menuitem.removeAttribute("hidden");
+ }
+}
+
+/* eslint-disable complexity */
+function HideMenus() {
+ // TODO: Seems to be a lot of repetitive code.
+ // Can we just fold this into an array of element IDs and loop over them?
+ var message_menuitem = document.getElementById("menu_showMessage");
+ if (message_menuitem) {
+ message_menuitem.setAttribute("hidden", "true");
+ }
+
+ message_menuitem = document.getElementById("appmenu_showMessage");
+ if (message_menuitem) {
+ message_menuitem.setAttribute("hidden", "true");
+ }
+
+ var folderPane_menuitem = document.getElementById("menu_showFolderPane");
+ if (folderPane_menuitem) {
+ folderPane_menuitem.setAttribute("hidden", "true");
+ }
+
+ folderPane_menuitem = document.getElementById("appmenu_showFolderPane");
+ if (folderPane_menuitem) {
+ folderPane_menuitem.setAttribute("hidden", "true");
+ }
+
+ var showSearch_showMessage_Separator = document.getElementById(
+ "menu_showSearch_showMessage_Separator"
+ );
+ if (showSearch_showMessage_Separator) {
+ showSearch_showMessage_Separator.setAttribute("hidden", "true");
+ }
+
+ var expandOrCollapseMenu = document.getElementById("menu_expandOrCollapse");
+ if (expandOrCollapseMenu) {
+ expandOrCollapseMenu.setAttribute("hidden", "true");
+ }
+
+ var menuDeleteFolder = document.getElementById("menu_deleteFolder");
+ if (menuDeleteFolder) {
+ menuDeleteFolder.hidden = true;
+ }
+
+ var renameFolderMenu = document.getElementById("menu_renameFolder");
+ if (renameFolderMenu) {
+ renameFolderMenu.setAttribute("hidden", "true");
+ }
+
+ var viewLayoutMenu = document.getElementById("menu_MessagePaneLayout");
+ if (viewLayoutMenu) {
+ viewLayoutMenu.setAttribute("hidden", "true");
+ }
+
+ viewLayoutMenu = document.getElementById("appmenu_MessagePaneLayout");
+ if (viewLayoutMenu) {
+ viewLayoutMenu.setAttribute("hidden", "true");
+ }
+
+ let paneViewSeparator = document.getElementById("appmenu_paneViewSeparator");
+ if (paneViewSeparator) {
+ paneViewSeparator.setAttribute("hidden", "true");
+ }
+
+ var viewFolderMenu = document.getElementById("menu_FolderViews");
+ if (viewFolderMenu) {
+ viewFolderMenu.setAttribute("hidden", "true");
+ }
+
+ viewFolderMenu = document.getElementById("appmenu_FolderViews");
+ if (viewFolderMenu) {
+ viewFolderMenu.setAttribute("hidden", "true");
+ }
+
+ var viewMessagesMenu = document.getElementById("viewMessagesMenu");
+ if (viewMessagesMenu) {
+ viewMessagesMenu.setAttribute("hidden", "true");
+ }
+
+ viewMessagesMenu = document.getElementById("appmenu_viewMessagesMenu");
+ if (viewMessagesMenu) {
+ viewMessagesMenu.setAttribute("hidden", "true");
+ }
+
+ var viewMessageViewMenu = document.getElementById("viewMessageViewMenu");
+ if (viewMessageViewMenu) {
+ viewMessageViewMenu.setAttribute("hidden", "true");
+ }
+
+ var viewMessagesMenuSeparator = document.getElementById(
+ "viewMessagesMenuSeparator"
+ );
+ if (viewMessagesMenuSeparator) {
+ viewMessagesMenuSeparator.setAttribute("hidden", "true");
+ }
+
+ var openMessageMenu = document.getElementById("openMessageWindowMenuitem");
+ if (openMessageMenu) {
+ openMessageMenu.setAttribute("hidden", "true");
+ }
+
+ openMessageMenu = document.getElementById(
+ "appmenu_openMessageWindowMenuitem"
+ );
+ if (openMessageMenu) {
+ openMessageMenu.setAttribute("hidden", "true");
+ }
+
+ var viewSortMenuSeparator = document.getElementById("viewSortMenuSeparator");
+ if (viewSortMenuSeparator) {
+ viewSortMenuSeparator.setAttribute("hidden", "true");
+ }
+
+ viewSortMenuSeparator = document.getElementById(
+ "appmenu_viewAfterThreadsSeparator"
+ );
+ if (viewSortMenuSeparator) {
+ viewSortMenuSeparator.setAttribute("hidden", "true");
+ }
+
+ var viewSortMenu = document.getElementById("viewSortMenu");
+ if (viewSortMenu) {
+ viewSortMenu.setAttribute("hidden", "true");
+ }
+
+ var emptryTrashMenu = document.getElementById("menu_emptyTrash");
+ if (emptryTrashMenu) {
+ emptryTrashMenu.setAttribute("hidden", "true");
+ }
+
+ emptryTrashMenu = document.getElementById("appmenu_emptyTrash");
+ if (emptryTrashMenu) {
+ emptryTrashMenu.setAttribute("hidden", "true");
+ }
+
+ var menuPropertiesSeparator = document.getElementById(
+ "editPropertiesSeparator"
+ );
+ if (menuPropertiesSeparator) {
+ menuPropertiesSeparator.setAttribute("hidden", "true");
+ }
+
+ menuPropertiesSeparator = document.getElementById(
+ "appmenu_editPropertiesSeparator"
+ );
+ if (menuPropertiesSeparator) {
+ menuPropertiesSeparator.setAttribute("hidden", "true");
+ }
+
+ var menuProperties = document.getElementById("menu_properties");
+ if (menuProperties) {
+ menuProperties.setAttribute("hidden", "true");
+ }
+
+ menuProperties = document.getElementById("appmenu_properties");
+ if (menuProperties) {
+ menuProperties.setAttribute("hidden", "true");
+ }
+
+ var favoriteFolder = document.getElementById("menu_favoriteFolder");
+ if (favoriteFolder) {
+ favoriteFolder.setAttribute("disabled", "true");
+ favoriteFolder.setAttribute("hidden", "true");
+ }
+
+ favoriteFolder = document.getElementById("appmenu_favoriteFolder");
+ if (favoriteFolder) {
+ favoriteFolder.setAttribute("disabled", "true");
+ favoriteFolder.setAttribute("hidden", "true");
+ }
+
+ var compactFolderMenu = document.getElementById("menu_compactFolder");
+ if (compactFolderMenu) {
+ compactFolderMenu.setAttribute("hidden", "true");
+ }
+
+ let trashSeparator = document.getElementById("trashMenuSeparator");
+ if (trashSeparator) {
+ trashSeparator.setAttribute("hidden", "true");
+ }
+
+ let goStartPageSeparator = document.getElementById("goNextSeparator");
+ if (goStartPageSeparator) {
+ goStartPageSeparator.hidden = true;
+ }
+
+ let goRecentlyClosedTabsSeparator = document.getElementById(
+ "goRecentlyClosedTabsSeparator"
+ );
+ if (goRecentlyClosedTabsSeparator) {
+ goRecentlyClosedTabsSeparator.setAttribute("hidden", "true");
+ }
+
+ let goFolder = document.getElementById("goFolderMenu");
+ if (goFolder) {
+ goFolder.hidden = true;
+ }
+
+ goFolder = document.getElementById("goFolderSeparator");
+ if (goFolder) {
+ goFolder.hidden = true;
+ }
+
+ let goStartPage = document.getElementById("goStartPage");
+ if (goStartPage) {
+ goStartPage.hidden = true;
+ }
+
+ let quickFilterBar = document.getElementById("appmenu_quickFilterBar");
+ if (quickFilterBar) {
+ quickFilterBar.hidden = true;
+ }
+
+ var menuFileClose = document.getElementById("menu_close");
+ var menuFileQuit = document.getElementById("menu_FileQuitItem");
+ if (menuFileClose && menuFileQuit) {
+ menuFileQuit.parentNode.replaceChild(menuFileClose, menuFileQuit);
+ }
+}
+/* eslint-enable complexity */
+
+function OnUnloadMessageWindow() {
+ UnloadCommandUpdateHandlers();
+ ToolbarIconColor.uninit();
+ PanelUI.uninit();
+ OnMailWindowUnload();
+}
+
+// MessageWindowController object (handles commands when one of the trees does not have focus)
+var MessageWindowController = {
+ supportsCommand(command) {
+ switch (command) {
+ case "cmd_undo":
+ case "cmd_redo":
+ case "cmd_getMsgsForAuthAccounts":
+ case "cmd_newMessage":
+ case "cmd_getNextNMessages":
+ case "cmd_find":
+ case "cmd_findAgain":
+ case "cmd_findPrevious":
+ case "cmd_reload":
+ case "cmd_getNewMessages":
+ case "cmd_settingsOffline":
+ case "cmd_fullZoomReduce":
+ case "cmd_fullZoomEnlarge":
+ case "cmd_fullZoomReset":
+ case "cmd_fullZoomToggle":
+ case "cmd_viewAllHeader":
+ case "cmd_viewNormalHeader":
+ case "cmd_stop":
+ case "cmd_chat":
+ return true;
+ case "cmd_synchronizeOffline":
+ return MailOfflineMgr.isOnline();
+ default:
+ return false;
+ }
+ },
+
+ isCommandEnabled(command) {
+ switch (command) {
+ case "cmd_newMessage":
+ return MailServices.accounts.allIdentities.length > 0;
+ case "cmd_reload":
+ case "cmd_find":
+ case "cmd_stop":
+ return false;
+ case "cmd_getNewMessages":
+ case "cmd_getMsgsForAuthAccounts":
+ return IsGetNewMessagesEnabled();
+ case "cmd_getNextNMessages":
+ return IsGetNextNMessagesEnabled();
+ case "cmd_synchronizeOffline":
+ return MailOfflineMgr.isOnline();
+ case "cmd_settingsOffline":
+ return IsAccountOfflineEnabled();
+ case "cmd_findAgain":
+ case "cmd_findPrevious":
+ case "cmd_fullZoomReduce":
+ case "cmd_fullZoomEnlarge":
+ case "cmd_fullZoomReset":
+ case "cmd_fullZoomToggle":
+ case "cmd_viewAllHeader":
+ case "cmd_viewNormalHeader":
+ return true;
+ case "cmd_undo":
+ case "cmd_redo":
+ return SetupUndoRedoCommand(command);
+ case "cmd_chat":
+ return true;
+ default:
+ return false;
+ }
+ },
+
+ doCommand(command) {
+ // If the user invoked a key short cut then it is possible that we got here
+ // for a command which is really disabled. Kick out if the command should be disabled.
+ if (!this.isCommandEnabled(command)) {
+ return;
+ }
+
+ switch (command) {
+ case "cmd_getNewMessages":
+ MsgGetMessage();
+ break;
+ case "cmd_undo":
+ messenger.undo(msgWindow);
+ break;
+ case "cmd_redo":
+ messenger.redo(msgWindow);
+ break;
+ case "cmd_getMsgsForAuthAccounts":
+ MsgGetMessagesForAllAuthenticatedAccounts();
+ break;
+ case "cmd_getNextNMessages":
+ MsgGetNextNMessages();
+ break;
+ case "cmd_newMessage":
+ MsgNewMessage(null);
+ break;
+ case "cmd_reload":
+ ReloadMessage();
+ break;
+ case "cmd_find":
+ document.getElementById("FindToolbar").onFindCommand();
+ break;
+ case "cmd_findAgain":
+ document.getElementById("FindToolbar").onFindAgainCommand(false);
+ break;
+ case "cmd_findPrevious":
+ document.getElementById("FindToolbar").onFindAgainCommand(true);
+ break;
+ case "cmd_viewAllHeader":
+ MsgViewAllHeaders();
+ return;
+ case "cmd_viewNormalHeader":
+ MsgViewNormalHeaders();
+ return;
+ case "cmd_synchronizeOffline":
+ MsgSynchronizeOffline();
+ return;
+ case "cmd_settingsOffline":
+ MailOfflineMgr.openOfflineAccountSettings();
+ return;
+ case "cmd_fullZoomReduce":
+ ZoomManager.reduce();
+ break;
+ case "cmd_fullZoomEnlarge":
+ ZoomManager.enlarge();
+ break;
+ case "cmd_fullZoomReset":
+ ZoomManager.reset();
+ break;
+ case "cmd_fullZoomToggle":
+ ZoomManager.toggleZoom();
+ break;
+ case "cmd_stop":
+ msgWindow.StopUrls();
+ break;
+ case "cmd_chat":
+ let win = Services.wm.getMostRecentWindow("mail:3pane");
+ if (win) {
+ win.focus();
+ win.showChatTab();
+ } else {
+ window.openDialog(
+ "chrome://messenger/content/messenger.xhtml",
+ "_blank",
+ "chrome,extrachrome,menubar,resizable,scrollbars,status,toolbar",
+ null,
+ { tabType: "chat", tabParams: {} }
+ );
+ }
+ break;
+ }
+ },
+
+ onEvent(event) {},
+};
+
+function SetupCommandUpdateHandlers() {
+ top.controllers.insertControllerAt(0, MessageWindowController);
+ top.controllers.insertControllerAt(
+ 0,
+ messageBrowser.contentWindow.commandController
+ );
+}
+
+function UnloadCommandUpdateHandlers() {
+ top.controllers.removeController(MessageWindowController);
+ top.controllers.removeController(
+ messageBrowser.contentWindow.commandController
+ );
+}
+
+/**
+ * Message history popup implementation from mail-go-button ported for the old
+ * mail toolbar.
+ *
+ * @param {XULPopupElement} popup
+ */
+function messageHistoryMenu_init(popup) {
+ const { messageHistory } = messageBrowser.contentWindow;
+ const { entries, currentIndex } = messageHistory.getHistory();
+
+ // For populating the back menu, we want the most recently visited
+ // messages first in the menu. So we go backward from curPos to 0.
+ // For the forward menu, we want to go forward from curPos to the end.
+ const items = [];
+ const relativePositionBase = entries.length - 1 - currentIndex;
+ for (const [index, entry] of entries.reverse().entries()) {
+ const folder = MailServices.folderLookup.getFolderForURL(entry.folderURI);
+ if (!folder) {
+ // Where did the folder go?
+ continue;
+ }
+
+ let menuText = "";
+ let msgHdr;
+ try {
+ msgHdr = MailServices.messageServiceFromURI(
+ entry.messageURI
+ ).messageURIToMsgHdr(entry.messageURI);
+ } catch (ex) {
+ // Let's just ignore this history entry.
+ continue;
+ }
+ const messageSubject = msgHdr.mime2DecodedSubject;
+ const messageAuthor = msgHdr.mime2DecodedAuthor;
+
+ if (!messageAuthor && !messageSubject) {
+ // Avoid empty entries in the menu. The message was most likely (re)moved.
+ continue;
+ }
+
+ // If the message was not being displayed via the current folder, prepend
+ // the folder name. We do not need to check underlying folders for
+ // virtual folders because 'folder' is the display folder, not the
+ // underlying one.
+ if (folder != messageBrowser.contentWindow.gFolder) {
+ menuText = folder.prettyName + " - ";
+ }
+
+ let subject = "";
+ if (msgHdr.flags & Ci.nsMsgMessageFlags.HasRe) {
+ subject = "Re: ";
+ }
+ if (messageSubject) {
+ subject += messageSubject;
+ }
+ if (subject) {
+ menuText += subject + " - ";
+ }
+
+ menuText += messageAuthor;
+ const newMenuItem = document.createXULElement("menuitem");
+ newMenuItem.setAttribute("label", menuText);
+ const relativePosition = relativePositionBase - index;
+ newMenuItem.setAttribute("value", relativePosition);
+ newMenuItem.addEventListener("command", commandEvent => {
+ navigateToUri(commandEvent.target);
+ commandEvent.stopPropagation();
+ });
+ if (relativePosition === 0 && !messageHistory.canPop(0)) {
+ newMenuItem.setAttribute("checked", true);
+ newMenuItem.setAttribute("type", "radio");
+ }
+ items.push(newMenuItem);
+ }
+ popup.replaceChildren(...items);
+}
+
+/**
+ * Select the message in the appropriate folder for the history popup entry.
+ * Finds the message based on the value of the item, which is the relative
+ * index of the item in the message history.
+ *
+ * @param {Element} target
+ */
+function navigateToUri(target) {
+ const nsMsgViewIndex_None = 0xffffffff;
+ const historyIndex = Number.parseInt(target.getAttribute("value"), 10);
+ const currentWindow = messageBrowser.contentWindow;
+ const { messageHistory } = currentWindow;
+ if (!messageHistory || !messageHistory.canPop(historyIndex)) {
+ return;
+ }
+ const item = messageHistory.pop(historyIndex);
+
+ if (
+ currentWindow.displayFolder &&
+ currentWindow.gFolder?.URI !== item.folderURI
+ ) {
+ const folder = MailServices.folderLookup.getFolderForURL(item.folderURI);
+ currentWindow.displayFolder(folder);
+ }
+ const msgHdr = MailServices.messageServiceFromURI(
+ item.messageURI
+ ).messageURIToMsgHdr(item.messageURI);
+ const index = currentWindow.gDBView.findIndexOfMsgHdr(msgHdr, true);
+ if (index != nsMsgViewIndex_None) {
+ currentWindow.gViewWrapper.dbView.selection.select(index);
+ currentWindow.displayMessage(
+ currentWindow.gViewWrapper.dbView.URIForFirstSelectedMessage,
+ currentWindow.gViewWrapper
+ );
+ }
+}
+
+function backToolbarMenu_init(popup) {
+ messageHistoryMenu_init(popup);
+}
+
+function forwardToolbarMenu_init(popup) {
+ messageHistoryMenu_init(popup);
+}
+
+function GetSelectedMsgFolders() {
+ return [messageBrowser.contentWindow.gFolder];
+}
diff --git a/comm/mail/base/content/messageWindow.xhtml b/comm/mail/base/content/messageWindow.xhtml
new file mode 100644
index 0000000000..55b11f9c5d
--- /dev/null
+++ b/comm/mail/base/content/messageWindow.xhtml
@@ -0,0 +1,484 @@
+<?xml version="1.0"?>
+# 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/.
+
+#filter substitution
+
+<?xml-stylesheet href="chrome://messenger/skin/messageWindow.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/popupPanel.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/messageHeader.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/icons.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/folderMenus.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/attachmentList.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/searchBox.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/panelUI.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/contextMenu.css" type="text/css"?>
+<?xml-stylesheet href="chrome://calendar/skin/calendar.css" type="text/css"?>
+<?xml-stylesheet href="chrome://calendar/skin/shared/calendar-toolbar.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/openpgp/inlineNotification.css" type="text/css"?>
+
+<!DOCTYPE html [
+<!ENTITY % brandDTD SYSTEM "chrome://branding/locale/brand.dtd" >
+%brandDTD;
+<!ENTITY % msgHdrViewOverlayDTD SYSTEM "chrome://messenger/locale/msgHdrViewOverlay.dtd">
+%msgHdrViewOverlayDTD;
+<!ENTITY % messengerDTD SYSTEM "chrome://messenger/locale/messenger.dtd" >
+%messengerDTD;
+<!ENTITY % customizeToolbarDTD SYSTEM "chrome://messenger/locale/customizeToolbar.dtd">
+%customizeToolbarDTD;
+<!ENTITY % utilityDTD SYSTEM "chrome://communicator/locale/utilityOverlay.dtd">
+%utilityDTD;
+<!ENTITY % msgViewPickerDTD SYSTEM "chrome://messenger/locale/msgViewPickerOverlay.dtd" >
+%msgViewPickerDTD;
+<!ENTITY % baseMenuOverlayDTD SYSTEM "chrome://messenger/locale/baseMenuOverlay.dtd">
+%baseMenuOverlayDTD;
+<!ENTITY % utilityDTD SYSTEM "chrome://communicator/locale/utilityOverlay.dtd">
+%utilityDTD;
+<!ENTITY % viewZoomOverlayDTD SYSTEM "chrome://messenger/locale/viewZoomOverlay.dtd">
+%viewZoomOverlayDTD;
+<!ENTITY % msgViewPickerDTD SYSTEM "chrome://messenger/locale/msgViewPickerOverlay.dtd" >
+%msgViewPickerDTD;
+<!ENTITY % lightningDTD SYSTEM "chrome://lightning/locale/lightning.dtd">
+%lightningDTD;
+<!ENTITY % calendarDTD SYSTEM "chrome://calendar/locale/calendar.dtd" >
+%calendarDTD;
+<!ENTITY % calendarMenuOverlayDTD SYSTEM "chrome://calendar/locale/menuOverlay.dtd" >
+%calendarMenuOverlayDTD;
+<!ENTITY % toolbarDTD SYSTEM "chrome://lightning/locale/lightning-toolbar.dtd">
+%toolbarDTD;
+<!ENTITY % eventDialogDTD SYSTEM "chrome://calendar/locale/calendar-event-dialog.dtd" >
+%eventDialogDTD;
+<!ENTITY % smimeDTD SYSTEM "chrome://messenger-smime/locale/msgReadSecurityInfo.dtd">
+%smimeDTD;
+]>
+
+<!--
+ - This window displays a single message.
+ -->
+<html id="messengerWindow" xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ icon="messengerWindow"
+ scrolling="false"
+ titlemodifier="&titledefault.label;@PRE_RELEASE_SUFFIX@"
+ titlemenuseparator="&titleSeparator.label;"
+ persist="width height screenX screenY sizemode"
+ toggletoolbar="true"
+ windowtype="mail:messageWindow"
+#ifdef XP_MACOSX
+ macanimationtype="document"
+ chromemargin="0,-1,-1,-1"
+#endif
+ lightweightthemes="true"
+ fullscreenbutton="true">
+<head>
+ <title>&titledefault.label;@PRE_RELEASE_SUFFIX@</title>
+ <link rel="localization" href="branding/brand.ftl" />
+ <link rel="localization" href="messenger/messenger.ftl" />
+ <link rel="localization" href="toolkit/main-window/findbar.ftl" />
+ <link rel="localization" href="toolkit/global/textActions.ftl" />
+ <link rel="localization" href="toolkit/printing/printUI.ftl" />
+ <link rel="localization" href="messenger/menubar.ftl" />
+ <link rel="localization" href="messenger/appmenu.ftl" />
+ <link rel="localization" href="messenger/openpgp/openpgp.ftl" />
+ <link rel="localization" href="messenger/openpgp/openpgp-frontend.ftl" />
+
+ <script defer="defer" src="chrome://messenger/content/globalOverlay.js"></script>
+ <script defer="defer" src="chrome://messenger/content/mailWindow.js"></script>
+ <script defer="defer" src="chrome://messenger/content/messageWindow.js"></script>
+ <script defer="defer" src="chrome://global/content/contentAreaUtils.js"></script>
+ <script defer="defer" src="chrome://messenger/content/browserPopups.js"></script>
+ <script defer="defer" src="chrome://messenger/content/toolbarIconColor.js"></script>
+ <script defer="defer" src="chrome://messenger/content/mailCommands.js"></script>
+ <script defer="defer" src="chrome://messenger/content/mailWindowOverlay.js"></script>
+ <script defer="defer" src="chrome://messenger-newsblog/content/newsblogOverlay.js"></script>
+ <script defer="defer" src="chrome://messenger/content/mail-offline.js"></script>
+ <script defer="defer" src="chrome://messenger/content/viewZoomOverlay.js"></script>
+ <script defer="defer" src="chrome://communicator/content/utilityOverlay.js"></script>
+ <script defer="defer" src="chrome://messenger/content/mailCore.js"></script>
+#ifdef NIGHTLY_BUILD
+ <script defer="defer" src="chrome://messenger/content/sync.js"></script>
+#endif
+ <script defer="defer" src="chrome://messenger/content/panelUI.js"></script>
+ <script defer="defer" src="chrome://messenger/content/chat/toolbarbutton-badge-button.js"></script>
+ <script defer="defer" src="chrome://messenger/content/messenger-customization.js"></script>
+#ifdef XP_MACOSX
+ <script defer="defer" src="chrome://global/content/macWindowMenu.js"></script>
+#endif
+ <script defer="defer" src="chrome://messenger/content/customizable-toolbar.js"></script>
+ <script defer="defer" src="chrome://calendar/content/calendar-chrome-startup.js"></script>
+ <script defer="defer" src="chrome://calendar/content/calendar-management.js"></script>
+ <script defer="defer" src="chrome://calendar/content/calendar-item-editing.js"></script>
+ <script defer="defer" src="chrome://calendar/content/calendar-extract.js"></script>
+</head>
+<html:body xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+
+ <stringbundle id="bundle_brand" src="chrome://branding/locale/brand.properties"/>
+ <stringbundle id="bundle_messenger" src="chrome://messenger/locale/messenger.properties"/>
+
+ <commandset id="mailCommands">
+#include mainCommandSet.inc.xhtml
+ <commandset id="mailSearchMenuItems"/>
+ <commandset id="attachmentCommands">
+ <command id="cmd_openAllAttachments"
+ oncommand="goDoCommand('cmd_openAllAttachments');"/>
+ <command id="cmd_saveAllAttachments"
+ oncommand="goDoCommand('cmd_saveAllAttachments');"/>
+ <command id="cmd_detachAllAttachments"
+ oncommand="goDoCommand('cmd_detachAllAttachments');"/>
+ <command id="cmd_deleteAllAttachments"
+ oncommand="goDoCommand('cmd_deleteAllAttachments');"/>
+ </commandset>
+ <commandset id="tasksCommands">
+ <command id="cmd_newMessage" oncommand="goOpenNewMessage();"/>
+ <command id="cmd_newCard" oncommand="openNewCardDialog()"/>
+ </commandset>
+ <commandset id="commandKeys"/>
+ <command id="cmd_close" oncommand="window.close();"/>
+ </commandset>
+
+ <keyset id="mailKeys">
+ <key keycode="VK_ESCAPE" oncommand="window.close();"/>
+#include mainKeySet.inc.xhtml
+ <keyset id="tasksKeys">
+#ifdef XP_MACOSX
+ <key id="key_newMessage" key="&newMessageCmd.key;" command="cmd_newMessage"
+ modifiers="accel,shift"/>
+ <key id="key_newMessage2" key="&newMessageCmd2.key;" command="cmd_newMessage"
+ modifiers="accel"/>
+#else
+ <key id="key_newMessage" key="&newMessageCmd.key;" command="cmd_newMessage"
+ modifiers="accel"/>
+ <key id="key_newMessage2" key="&newMessageCmd2.key;" command="cmd_newMessage"
+ modifiers="accel"/>
+#endif
+ </keyset>
+ </keyset>
+
+ <popupset id="mainPopupSet">
+#include widgets/browserPopups.inc.xhtml
+#include widgets/toolbarContext.inc.xhtml
+<!-- The panelUI is for the appmenu. -->
+#include ../../components/customizableui/content/panelUI.inc.xhtml
+ </popupset>
+
+ <toolbox id="mail-toolbox"
+ class="mail-toolbox"
+ mode="full"
+ defaultmode="full"
+#ifdef XP_MACOSX
+ iconsize="small"
+ defaulticonsize="small"
+#endif
+ labelalign="end"
+ defaultlabelalign="end">
+#ifdef XP_MACOSX
+ <hbox id="titlebar">
+ <hbox id="titlebar-title" align="center" flex="1">
+ <label id="titlebar-title-label" value="&titledefault.label;" flex="1" crop="end"/>
+ </hbox>
+#include messenger-titlebar-items.inc.xhtml
+ </hbox>
+#endif
+ <!-- Menu -->
+ <toolbar is="customizable-toolbar" id="toolbar-menubar"
+ class="chromeclass-menubar themeable-full"
+ type="menubar"
+ customizable="true"
+ toolboxid="mail-toolbox"
+#ifdef XP_MACOSX
+ defaultset="menubar-items"
+ autohide="true"
+#else
+ defaultset="menubar-items,spring"
+#endif
+#ifndef XP_MACOSX
+ data-l10n-id="toolbar-context-menu-menu-bar"
+ data-l10n-attrs="toolbarname"
+#endif
+ context="toolbar-context-menu"
+ mode="icons"
+ insertbefore="tabs-toolbar"
+ prependmenuitem="true">
+ <toolbaritem id="menubar-items" align="center">
+# The entire main menubar is placed into messenger-menubar.inc.xhtml, so that it
+# can be shared with other top level windows.
+#include messenger-menubar.inc.xhtml
+ </toolbaritem>
+ </toolbar>
+ <!-- mail-toolbox with the main toolbarbuttons -->
+ <toolbarpalette id="MailToolbarPalette">
+ <toolbarbutton is="toolbarbutton-menu-button" id="button-getmsg"
+ type="menu"
+ class="toolbarbutton-1"
+ label="&getMsgButton1.label;"
+ tooltiptext="&getMsgButton.tooltip;"
+ command="cmd_getNewMessages">
+ <menupopup is="folder-menupopup" id="button-getMsgPopup"
+ onpopupshowing="getMsgToolbarMenu_init();"
+ oncommand="MsgGetMessagesForAccount(event.target._folder); event.stopPropagation();"
+ expandFolders="false"
+ mode="getMail">
+ <menuitem id="button-getAllNewMsg"
+ class="menuitem-iconic folderMenuItem"
+ label="&getAllNewMsgCmd.label;"
+ accesskey="&getAllNewMsgCmd.accesskey;"
+ command="cmd_getMsgsForAuthAccounts"/>
+ <menuseparator id="button-getAllNewMsgSeparator"/>
+ </menupopup>
+ </toolbarbutton>
+ <toolbarbutton id="button-newmsg"
+ class="toolbarbutton-1"
+ label="&newMsgButton.label;"
+ tooltiptext="&newMsgButton.tooltip;"
+ command="cmd_newMessage"/>
+ <toolbarbutton id="button-file"
+ type="menu"
+ wantdropmarker="true"
+ class="toolbarbutton-1"
+ label="&fileButton.label;"
+ tooltiptext="&fileButton.tooltip;"
+ oncommand="goDoCommand('cmd_moveMessage', event.target._folder)">
+ <menupopup is="folder-menupopup" id="button-filePopup"
+ mode="filing"
+ showRecent="true"
+ showFileHereLabel="true"
+ recentLabel="&moveCopyMsgRecentMenu.label;"
+ recentAccessKey="&moveCopyMsgRecentMenu.accesskey;"/>
+ </toolbarbutton>
+ <toolbarbutton id="button-showconversation"
+ class="toolbarbutton-1"
+ label="&openConversationButton.label;"
+ tooltiptext="&openMsgConversationButton.tooltip;"
+ command="cmd_openConversation"/>
+ <toolbarbutton is="toolbarbutton-menu-button" id="button-goback"
+ type="menu"
+ class="toolbarbutton-1"
+ label="&backButton1.label;"
+ command="cmd_goBack"
+ tooltiptext="&goBackButton.tooltip;">
+ <menupopup id="button-goBackPopup" onpopupshowing="backToolbarMenu_init(this)">
+ <menuitem id="button-goBack" label="&goBackCmd.label;" command="cmd_goBack"/>
+ <menuseparator id="button-goBackSeparator"/>
+ </menupopup>
+ </toolbarbutton>
+ <toolbarbutton is="toolbarbutton-menu-button" id="button-goforward"
+ type="menu"
+ class="toolbarbutton-1"
+ label="&goForwardButton1.label;"
+ command="cmd_goForward"
+ tooltiptext="&goForwardButton.tooltip;">
+ <menupopup id="button-goForwardPopup" onpopupshowing="forwardToolbarMenu_init(this)">
+ <menuitem id="button-goForward"
+ label="&goForwardCmd.label;"
+ command="cmd_goForward"/>
+ <menuseparator id="button-goForwardSeparator"/>
+ </menupopup>
+ </toolbarbutton>
+ <toolbaritem id="button-previous"
+ title="&previousButtonToolbarItem.label;"
+ align="center"
+ class="chromeclass-toolbar-additional">
+ <toolbarbutton id="button-previousUnread"
+ class="toolbarbutton-1"
+ label="&previousButton.label;"
+ command="cmd_previousUnreadMsg"
+ tooltiptext="&previousButton.tooltip;"/>
+ </toolbaritem>
+ <toolbarbutton id="button-previousMsg"
+ class="toolbarbutton-1"
+ label="&previousMsgButton.label;"
+ command="cmd_previousMsg"
+ tooltiptext="&previousMsgButton.tooltip;"/>
+ <toolbaritem id="button-next"
+ title="&nextButtonToolbarItem.label;"
+ align="center"
+ class="chromeclass-toolbar-additional">
+ <toolbarbutton id="button-nextUnread"
+ class="toolbarbutton-1"
+ label="&nextButton.label;"
+ command="cmd_nextUnreadMsg"
+ tooltiptext="&nextButton.tooltip;"/>
+ </toolbaritem>
+ <toolbarbutton id="button-nextMsg"
+ class="toolbarbutton-1"
+ label="&nextMsgButton.label;"
+ command="cmd_nextMsg"
+ tooltiptext="&nextMsgButton.tooltip;"/>
+ <toolbarbutton id="button-print"
+ class="toolbarbutton-1"
+ label="&printButton.label;"
+ command="cmd_print"
+ tooltiptext="&printButton.tooltip;"/>
+ <toolbarbutton is="toolbarbutton-menu-button" id="button-mark"
+ type="menu"
+ class="toolbarbutton-1"
+ label="&markButton.label;"
+ tooltiptext="&markButton.tooltip;">
+ <menupopup id="button-markPopup" onpopupshowing="InitMessageMark()">
+ <menuitem id="markReadToolbarItem"
+ label="&markAsReadCmd.label;"
+ accesskey="&markAsReadCmd.accesskey;"
+ key="key_toggleRead"
+ command="cmd_markAsRead"/>
+ <menuitem id="markUnreadToolbarItem"
+ label="&markAsUnreadCmd.label;"
+ accesskey="&markAsUnreadCmd.accesskey;"
+ key="key_toggleRead"
+ command="cmd_markAsUnread"/>
+ <menuitem id="button-markThreadAsRead"
+ label="&markThreadAsReadCmd.label;"
+ key="key_markThreadAsRead"
+ accesskey="&markThreadAsReadCmd.accesskey;"
+ command="cmd_markThreadAsRead"/>
+ <menuitem id="button-markReadByDate"
+ label="&markReadByDateCmd.label;"
+ key="key_markReadByDate"
+ accesskey="&markReadByDateCmd.accesskey;"
+ command="cmd_markReadByDate"/>
+ <menuitem id="button-markAllRead"
+ label="&markAllReadCmd.label;"
+ key="key_markAllRead"
+ accesskey="&markAllReadCmd.accesskey;"
+ command="cmd_markAllRead"/>
+ <menuseparator id="button-markAllReadSeparator"/>
+ <menuitem id="markFlaggedToolbarItem"
+ type="checkbox"
+ label="&markStarredCmd.label;"
+ accesskey="&markStarredCmd.accesskey;"
+ key="key_toggleFlagged"
+ command="cmd_markAsFlagged"/>
+ </menupopup>
+ </toolbarbutton>
+ <toolbarbutton id="button-tag"
+ type="menu"
+ wantdropmarker="true"
+ class="toolbarbutton-1"
+ label="&tagButton.label;"
+ tooltiptext="&tagButton.tooltip;"
+ command="cmd_tag">
+ <menupopup id="button-tagpopup"
+ onpopupshowing="InitMessageTags(this);">
+ <menuitem id="button-addNewTag"
+ label="&addNewTag.label;"
+ accesskey="&addNewTag.accesskey;"
+ command="cmd_addTag"/>
+ <menuitem id="button-manageTags"
+ label="&manageTags.label;"
+ accesskey="&manageTags.accesskey;"
+ command="cmd_manageTags"/>
+ <menuseparator id="button-tagpopup-sep-afterTagAddNew"/>
+ <menuitem id="button-tagRemoveAll"
+ command="cmd_removeTags"/>
+ <menuseparator id="button-afterTagRemoveAllSeparator"/>
+ </menupopup>
+ </toolbarbutton>
+ <toolbarbutton id="button-address"
+ class="toolbarbutton-1"
+ label="&addressBookButton.label;"
+ oncommand="toAddressBook();"
+ tooltiptext="&addressBookButton.tooltip;"/>
+ <toolbarbutton is="toolbarbutton-badge-button" id="button-chat"
+ image="chrome://messenger/skin/icons/new/compact/chat.svg"
+ class="toolbarbutton-1"
+ label="&chatButton.label;"
+ command="cmd_chat"
+ observes="cmd_chat"
+ tooltiptext="&chatButton.tooltip;"/>
+ <toolbaritem id="throbber-box" title="&throbberItem.title;">
+ <!-- NOTE: We only display up to one of these images at any given time.
+ - Only show the static icon when customizing the toolbar.
+ - Only show the animated icon when we are not customizing the toolbar
+ - and there is some activity.
+ - Once loading animation is handled by CSS, we can use a single image
+ - here instead. -->
+ <html:img class="animated-throbber-icon"
+ src="chrome://global/skin/icons/loading.png"
+ srcset="chrome://global/skin/icons/loading@2x.png 2x"
+ alt="" />
+ <html:img class="static-throbber-icon"
+ src="chrome://messenger/skin/icons/notloading.png"
+ srcset="chrome://messenger/skin/icons/notloading@2x.png 2x"
+ alt="" />
+ </toolbaritem>
+
+ <toolbarbutton id="button-addons" class="toolbarbutton-1"
+ data-l10n-id="addons-and-themes-toolbarbutton"
+ oncommand="openAddonsMgr();"/>
+
+ <toolbarbutton id="lightning-button-calendar"
+ class="toolbarbutton-1"
+ label="&lightning.toolbar.calendar.label;"
+ tooltiptext="&lightning.toolbar.calendar.tooltip;"
+ command="new_calendar_tab"/>
+ <toolbarbutton id="lightning-button-tasks"
+ class="toolbarbutton-1"
+ label="&lightning.toolbar.task.label;"
+ tooltiptext="&lightning.toolbar.task.tooltip;"
+ command="new_task_tab"/>
+ <toolbarbutton is="toolbarbutton-menu-button" id="extractEventButton"
+ type="menu"
+ class="toolbarbutton-1"
+ label="&calendar.extract.event.button;"
+ tooltiptext="&calendar.extract.event.button.tooltip;"
+ oncommand="calendarExtract.extractFromEmail(document.getElementById('messageBrowser').contentWindow.gMessage, true);">
+ <menupopup id="extractEventLocaleList"
+ oncommand="calendarExtract.extractWithLocale(event, true);"
+ onpopupshowing="calendarExtract.onShowLocaleMenu(event.target);"/>
+ </toolbarbutton>
+ <toolbarbutton is="toolbarbutton-menu-button" id="extractTaskButton"
+ type="menu"
+ class="toolbarbutton-1"
+ label="&calendar.extract.task.button;"
+ tooltiptext="&calendar.extract.task.button.tooltip;"
+ oncommand="calendarExtract.extractFromEmail(document.getElementById('messageBrowser').contentWindow.gMessage, false);">
+ <menupopup id="extractTaskLocaleList"
+ oncommand="calendarExtract.extractWithLocale(event, false);"
+ onpopupshowing="calendarExtract.onShowLocaleMenu(event.target);"/>
+ </toolbarbutton>
+ </toolbarpalette>
+
+ <!-- If changes are made to the default set of toolbar buttons, you may need to rev the id
+ of mail-bar in order to force the new default items to show up for users who customized their toolbar
+ in earlier versions. Bumping the id means users will have to re-customize their toolbar!
+ -->
+
+ <toolbar is="customizable-toolbar" id="mail-bar3"
+ class="inline-toolbar chromeclass-toolbar themeable-full"
+ toolbarname="&showMessengerToolbarCmd.label;"
+ accesskey="&showMessengerToolbarCmd.accesskey;"
+ fullscreentoolbar="true" mode="full"
+ customizable="true"
+ context="toolbar-context-menu"
+#ifdef XP_MACOSX
+ iconsize="small"
+ defaultset="button-getmsg,button-newmsg,spacer,button-tag,qfb-show-filter-bar,spring">
+#else
+ defaultset="button-getmsg,button-newmsg,separator,button-tag,qfb-show-filter-bar,spring">
+#endif
+ </toolbar>
+ </toolbox>
+
+ <stack flex="1" class="printPreviewStack">
+ <browser id="messageBrowser"
+ flex="1"
+ src="about:message"
+ autocompletepopup="PopupAutoComplete"
+ messagemanagergroup="single-page"/>
+ </stack>
+
+ <panel id="customizeToolbarSheetPopup" noautohide="true">
+ <iframe id="customizeToolbarSheetIFrame"
+ style="&dialog.dimensions;"
+ hidden="true"/>
+ </panel>
+
+ <hbox id="status-bar" class="statusbar chromeclass-status" role="status">
+#include mainStatusbar.inc.xhtml
+ </hbox>
+
+#include tabDialogs.inc.xhtml
+</html:body>
+</html>
diff --git a/comm/mail/base/content/messenger-customization.js b/comm/mail/base/content/messenger-customization.js
new file mode 100644
index 0000000000..bf6fc46834
--- /dev/null
+++ b/comm/mail/base/content/messenger-customization.js
@@ -0,0 +1,185 @@
+/**
+ * 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/. */
+
+var { AddonManager } = ChromeUtils.importESModule(
+ "resource://gre/modules/AddonManager.sys.mjs"
+);
+
+var AutoHideMenubar = {
+ get _node() {
+ delete this._node;
+ return (this._node =
+ document.getElementById("toolbar-menubar") ||
+ document.getElementById("compose-toolbar-menubar2") ||
+ document.getElementById("addrbook-toolbar-menubar2"));
+ },
+
+ _contextMenuListener: {
+ contextMenu: null,
+
+ get active() {
+ return !!this.contextMenu;
+ },
+
+ init(event) {
+ // Ignore mousedowns in <menupopup>s.
+ if (event.target.closest("menupopup")) {
+ return;
+ }
+
+ let contextMenuId = AutoHideMenubar._node.getAttribute("context");
+ this.contextMenu = document.getElementById(contextMenuId);
+ this.contextMenu.addEventListener("popupshown", this);
+ this.contextMenu.addEventListener("popuphiding", this);
+ AutoHideMenubar._node.addEventListener("mousemove", this);
+ },
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "popupshown":
+ AutoHideMenubar._node.removeEventListener("mousemove", this);
+ break;
+ case "popuphiding":
+ case "mousemove":
+ AutoHideMenubar._setInactiveAsync();
+ AutoHideMenubar._node.removeEventListener("mousemove", this);
+ this.contextMenu.removeEventListener("popuphiding", this);
+ this.contextMenu.removeEventListener("popupshown", this);
+ this.contextMenu = null;
+ break;
+ }
+ },
+ },
+
+ init() {
+ this._node.addEventListener("toolbarvisibilitychange", this);
+ this._enable();
+ },
+
+ _updateState() {
+ if (this._node.getAttribute("autohide") == "true") {
+ this._enable();
+ } else {
+ this._disable();
+ }
+ },
+
+ _events: [
+ "DOMMenuBarInactive",
+ "DOMMenuBarActive",
+ "popupshowing",
+ "mousedown",
+ ],
+ _enable() {
+ this._node.setAttribute("inactive", "true");
+ for (let event of this._events) {
+ this._node.addEventListener(event, this);
+ }
+ },
+
+ _disable() {
+ this._setActive();
+ for (let event of this._events) {
+ this._node.removeEventListener(event, this);
+ }
+ },
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "toolbarvisibilitychange":
+ this._updateState();
+ break;
+ case "popupshowing":
+ // fall through
+ case "DOMMenuBarActive":
+ this._setActive();
+ break;
+ case "mousedown":
+ if (event.button == 2) {
+ this._contextMenuListener.init(event);
+ }
+ break;
+ case "DOMMenuBarInactive":
+ if (!this._contextMenuListener.active) {
+ this._setInactiveAsync();
+ }
+ break;
+ }
+ },
+
+ _setInactiveAsync() {
+ this._inactiveTimeout = setTimeout(() => {
+ if (this._node.getAttribute("autohide") == "true") {
+ this._inactiveTimeout = null;
+ this._node.setAttribute("inactive", "true");
+ }
+ }, 0);
+ },
+
+ _setActive() {
+ if (this._inactiveTimeout) {
+ clearTimeout(this._inactiveTimeout);
+ this._inactiveTimeout = null;
+ }
+ this._node.removeAttribute("inactive");
+ },
+};
+
+var ToolbarContextMenu = {
+ _getExtensionId(popup) {
+ let node = popup.triggerNode;
+ if (!node) {
+ return null;
+ }
+ if (node.hasAttribute("data-extensionid")) {
+ return node.getAttribute("data-extensionid");
+ }
+ const extensionButton = node.closest('[item-id^="ext-"]');
+ return extensionButton?.getAttribute("item-id").slice(4);
+ },
+
+ async updateExtension(popup) {
+ let removeExtension = popup.querySelector(
+ ".customize-context-removeExtension"
+ );
+ let manageExtension = popup.querySelector(
+ ".customize-context-manageExtension"
+ );
+ let separator = popup.querySelector("#extensionsMailToolbarMenuSeparator");
+ let id = this._getExtensionId(popup);
+ let addon = id && (await AddonManager.getAddonByID(id));
+
+ for (let element of [removeExtension, manageExtension, separator]) {
+ if (!element) {
+ continue;
+ }
+
+ element.hidden = !addon;
+ }
+
+ if (addon) {
+ removeExtension.disabled = !(
+ addon.permissions & AddonManager.PERM_CAN_UNINSTALL
+ );
+ }
+ },
+
+ async removeExtensionForContextAction(popup) {
+ let id = this._getExtensionId(popup);
+
+ // This can be called from a composeAction button, where
+ // popup.ownerGlobal.BrowserAddonUI is undefined.
+ let win = Services.wm.getMostRecentWindow("mail:3pane");
+ await win.BrowserAddonUI.removeAddon(id);
+ },
+
+ openAboutAddonsForContextAction(popup) {
+ let id = this._getExtensionId(popup);
+ if (id) {
+ let viewID = "addons://detail/" + encodeURIComponent(id);
+ popup.ownerGlobal.openAddonsMgr(viewID);
+ }
+ },
+};
diff --git a/comm/mail/base/content/messenger-doctype.inc.dtd b/comm/mail/base/content/messenger-doctype.inc.dtd
new file mode 100644
index 0000000000..0d42d56feb
--- /dev/null
+++ b/comm/mail/base/content/messenger-doctype.inc.dtd
@@ -0,0 +1,42 @@
+# 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/.
+
+<!ENTITY % brandDTD SYSTEM "chrome://branding/locale/brand.dtd" >
+%brandDTD;
+<!ENTITY % msgHdrViewOverlayDTD SYSTEM "chrome://messenger/locale/msgHdrViewOverlay.dtd">
+%msgHdrViewOverlayDTD;
+<!ENTITY % messengerDTD SYSTEM "chrome://messenger/locale/messenger.dtd" >
+%messengerDTD;
+<!ENTITY % chatDTD SYSTEM "chrome://messenger/locale/chat.dtd">
+%chatDTD;
+<!ENTITY % customizeToolbarDTD SYSTEM "chrome://messenger/locale/customizeToolbar.dtd">
+%customizeToolbarDTD;
+<!ENTITY % tabMailDTD SYSTEM "chrome://messenger/locale/tabmail.dtd" >
+%tabMailDTD;
+<!ENTITY % utilityDTD SYSTEM "chrome://communicator/locale/utilityOverlay.dtd">
+%utilityDTD;
+<!ENTITY % msgViewPickerDTD SYSTEM "chrome://messenger/locale/msgViewPickerOverlay.dtd" >
+%msgViewPickerDTD;
+<!ENTITY % baseMenuOverlayDTD SYSTEM "chrome://messenger/locale/baseMenuOverlay.dtd">
+%baseMenuOverlayDTD;
+<!ENTITY % viewZoomOverlayDTD SYSTEM "chrome://messenger/locale/viewZoomOverlay.dtd">
+%viewZoomOverlayDTD;
+<!ENTITY % msgViewPickerDTD SYSTEM "chrome://messenger/locale/msgViewPickerOverlay.dtd" >
+%msgViewPickerDTD;
+<!ENTITY % calendarGlobalDTD SYSTEM "chrome://calendar/locale/global.dtd">
+%calendarGlobalDTD;
+<!ENTITY % calendarDTD SYSTEM "chrome://calendar/locale/calendar.dtd">
+%calendarDTD;
+<!ENTITY % calendarMenuOverlayDTD SYSTEM "chrome://calendar/locale/menuOverlay.dtd" >
+%calendarMenuOverlayDTD;
+<!ENTITY % eventDialogDTD SYSTEM "chrome://calendar/locale/calendar-event-dialog.dtd">
+%eventDialogDTD;
+<!ENTITY % lightningDTD SYSTEM "chrome://lightning/locale/lightning.dtd">
+%lightningDTD;
+<!ENTITY % lightningToolbarDTD SYSTEM "chrome://lightning/locale/lightning-toolbar.dtd" >
+%lightningToolbarDTD;
+<!ENTITY % mailOverlayDTD SYSTEM "chrome://messenger/locale/mailOverlay.dtd">
+%mailOverlayDTD;
+<!ENTITY % smimeDTD SYSTEM "chrome://messenger-smime/locale/msgReadSecurityInfo.dtd">
+%smimeDTD;
diff --git a/comm/mail/base/content/messenger-menubar.inc.xhtml b/comm/mail/base/content/messenger-menubar.inc.xhtml
new file mode 100644
index 0000000000..1a0859998e
--- /dev/null
+++ b/comm/mail/base/content/messenger-menubar.inc.xhtml
@@ -0,0 +1,1271 @@
+# 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/.
+
+<menubar id="mail-menubar">
+ <!-- File -->
+ <menu id="menu_File"
+ label="&fileMenu.label;"
+ accesskey="&fileMenu.accesskey;">
+ <menupopup id="menu_FilePopup" onpopupshowing="file_init();">
+ <menu id="menu_New"
+ label="&newMenu.label;"
+ accesskey="&newMenu.accesskey;">
+ <menupopup id="menu_NewPopup" onpopupshowing="menu_new_init();">
+ <menuitem id="menu_newNewMsgCmd" label="&newNewMsgCmd.label;"
+ accesskey="&newNewMsgCmd.accesskey;"
+ key="key_newMessage2"
+ command="cmd_newMessage"/>
+#ifdef MAIN_WINDOW
+ <menuitem id="calendar-new-event-menuitem"
+ class="hide-when-calendar-deactivated"
+ label="&lightning.menupopup.new.event.label;"
+ accesskey="&lightning.menupopup.new.event.accesskey;"
+ key="calendar-new-event-key"
+ command="calendar_new_event_command"/>
+ <menuitem id="calendar-new-task-menuitem"
+ class="hide-when-calendar-deactivated"
+ label="&lightning.menupopup.new.task.label;"
+ accesskey="&lightning.menupopup.new.task.accesskey;"
+ key="calendar-new-todo-key"
+ command="calendar_new_todo_command"/>
+ <menuseparator id="calendar-after-new-task-menuseparator"
+ class="hide-when-calendar-deactivated"
+ observes="menu_newFolder"/>
+#endif
+ <menuitem id="menu_newFolder" label="&newFolderCmd.label;"
+ command="cmd_newFolder"
+ accesskey="&newFolderCmd.accesskey;"/>
+ <menuitem id="menu_newVirtualFolder" label="&newVirtualFolderCmd.label;"
+ command="cmd_newVirtualFolder"
+ accesskey="&newVirtualFolderCmd.accesskey;"/>
+ <menuseparator id="newAccountPopupMenuSeparator"/>
+ <menuitem id="newCreateEmailAccountMenuItem"
+ label="&newCreateEmailAccountCmd.label;"
+ accesskey="&newCreateEmailAccountCmd.accesskey;"
+ oncommand="openAccountProvisionerTab();"/>
+ <menuitem id="newMailAccountMenuItem"
+ label="&newExistingEmailAccountCmd.label;"
+ accesskey="&newExistingEmailAccountCmd.accesskey;"
+ oncommand="openAccountSetupTab();"/>
+ <menuitem id="newIMAccountMenuItem"
+ label="&newIMAccountCmd.label;"
+ accesskey="&newIMAccountCmd.accesskey;"
+ oncommand="openIMAccountWizard();"/>
+ <menuitem id="newFeedAccountMenuItem"
+ label="&newFeedAccountCmd.label;"
+ accesskey="&newFeedAccountCmd.accesskey;"
+ oncommand="AddFeedAccount();"/>
+ <menuitem id="newNewsgroupAccountMenuItem"
+ data-l10n-id="file-new-newsgroup-account"
+ oncommand="openNewsgroupAccountWizard();"/>
+#ifdef MAIN_WINDOW
+ <menuitem id="calendar-new-calendar-menuitem"
+ label="&lightning.menupopup.new.calendar.label;"
+ command="calendar_new_calendar_command"
+ accesskey="&lightning.menupopup.new.calendar.accesskey;"/>
+#endif
+ <menuseparator id="newPopupMenuSeparator"/>
+ <menuitem id="menu_newCard"
+ label="&newContactCmd.label;"
+ accesskey="&newContactCmd.accesskey;"
+ command="cmd_newCard"/>
+ <menuitem id="newIMContactMenuItem"
+ label="&newIMContactCmd.label;"
+ accesskey="&newIMContactCmd.accesskey;"
+ command="cmd_addChatBuddy"/>
+ </menupopup>
+ </menu>
+ <menu id="menu_Open"
+ mode="calendar"
+ label="&openMenuCmd.label;"
+ accesskey="&openMenuCmd.accesskey;">
+ <menupopup id="menu_OpenPopup">
+ <menuitem id="openMessageFileMenuitem"
+ label="&openMessageFileCmd.label;"
+ accesskey="&openMessageFileCmd.accesskey;"
+ oncommand="MsgOpenFromFile();"/>
+#ifdef MAIN_WINDOW
+ <menuitem id="calendar-open-calendar-file-menuitem"
+ label="&lightning.menupopup.open.calendar.label;"
+ accesskey="&lightning.menupopup.open.calendar.accesskey;"
+ oncommand="openLocalCalendar();"/>
+#endif
+ </menupopup>
+ </menu>
+ <menuitem id="menu_close"
+ label="&closeCmd.label;"
+ key="key_close"
+ accesskey="&closeCmd.accesskey;"
+ command="cmd_close"/>
+ <menuseparator id="fileMenuAfterCloseSeparator"/>
+#ifdef MAIN_WINDOW
+ <menuitem id="calendar-save-menuitem"
+ class="hide-when-calendar-deactivated"
+ label="&event.menu.item.save.label;"
+ accesskey="&event.menu.item.save.tab.accesskey;"
+ key="save-key"
+ command="cmd_save"/>
+ <menuitem id="calendar-save-and-close-menuitem"
+ class="hide-when-calendar-deactivated"
+ label="&event.menu.item.saveandclose.label;"
+ accesskey="&event.menu.item.saveandclose.tab.accesskey;"
+ command="cmd_accept"/>
+#endif
+ <menu id="menu_saveAs"
+ label="&saveAsMenu.label;" accesskey="&saveAsMenu.accesskey;">
+ <menupopup id="menu_SavePopup">
+ <menuitem id="menu_saveAsFile"
+ data-l10n-id="menu-file-save-as-file"
+ key="key_saveAsFile"
+ command="cmd_saveAsFile"/>
+ <menuitem id="menu_saveAsTemplate"
+ label="&saveAsTemplateCmd.label;"
+ accesskey="&saveAsTemplateCmd.accesskey;"
+ command="cmd_saveAsTemplate"/>
+ </menupopup>
+ </menu>
+ <menuseparator id="fileMenuAfterSaveSeparator"/>
+ <menu label="&getNewMsgForCmd.label;" accesskey="&getNewMsgForCmd.accesskey;"
+ id="menu_getAllNewMsg"
+ oncommand="MsgGetMessagesForAccount();">
+ <menupopup is="folder-menupopup" id="menu_getAllNewMsgPopup"
+ expandFolders="false"
+ oncommand="MsgGetMessagesForAccount(event.target._folder); event.stopPropagation();">
+ <menuitem id="menu_getnewmsgs_all_accounts"
+ label="&getAllNewMsgCmdPopupMenu.label;"
+ accesskey="&getAllNewMsgCmdPopupMenu.accesskey;"
+ key="key_getAllNewMessages"
+ command="cmd_getMsgsForAuthAccounts"/>
+ <menuitem id="menu_getnewmsgs_current_account"
+ label="&getNewMsgCurrentAccountCmdPopupMenu.label;"
+ accesskey="&getNewMsgCurrentAccountCmdPopupMenu.accesskey;"
+ key="key_getNewMessages"
+ command="cmd_getNewMessages"/>
+ <menuseparator/>
+ </menupopup>
+ </menu>
+ <menuitem id="menu_getnextnmsg" label="&getNextNMsgCmd2.label;"
+ accesskey="&getNextNMsgCmd2.accesskey;"
+ command="cmd_getNextNMessages"/>
+ <menuitem id="menu_sendunsentmsgs" label="&sendUnsentCmd.label;"
+ accesskey="&sendUnsentCmd.accesskey;" command="cmd_sendUnsentMsgs"/>
+ <menuitem id="menu_subscribe" label="&subscribeCmd.label;"
+ accesskey="&subscribeCmd.accesskey;" command="cmd_subscribe"/>
+ <menuseparator id="fileMenuAfterSubscribeSeparator"/>
+ <menuitem id="menu_deleteFolder"
+ data-l10n-id="menu-edit-delete-folder"
+ command="cmd_deleteFolder"/>
+ <menuitem id="menu_renameFolder" label="&renameFolder.label;"
+ accesskey="&renameFolder.accesskey;"
+#ifndef XP_MACOSX
+ key="key_renameFolder"
+#endif
+ command="cmd_renameFolder"/>
+ <menuitem id="menu_compactFolder"
+ label="&compactFolders.label;"
+ accesskey="&compactFolders.accesskey;"
+ command="cmd_compactFolder"/>
+ <menuitem id="menu_emptyTrash" label="&emptyTrashCmd.label;"
+ accesskey="&emptyTrashCmd.accesskey;"
+ command="cmd_emptyTrash"/>
+ <menuseparator id="trashMenuSeparator"/>
+ <menu id="offlineMenuItem" label="&offlineMenu.label;" accesskey="&offlineMenu.accesskey;">
+ <menupopup id="menu_OfflinePopup">
+ <menuitem id="goOfflineMenuItem" type="checkbox" label="&offlineGoOfflineCmd.label;"
+ accesskey="&offlineGoOfflineCmd.accesskey;" oncommand="MailOfflineMgr.toggleOfflineStatus();"/>
+ <menuseparator id="offlineMenuAfterGoSeparator"/>
+ <menuitem id="menu_synchronizeOffline"
+ label="&synchronizeOfflineCmd.label;"
+ accesskey="&synchronizeOfflineCmd.accesskey;"
+ command="cmd_synchronizeOffline"/>
+ <menuitem id="menu_settingsOffline"
+ label="&settingsOfflineCmd2.label;"
+ accesskey="&settingsOfflineCmd2.accesskey;"
+ command="cmd_settingsOffline"/>
+ <menuseparator id="offlineMenuAfterSettingsSeparator"/>
+ <menuitem id="menu_downloadFlagged"
+ label="&downloadStarredCmd.label;"
+ accesskey="&downloadStarredCmd.accesskey;"
+ command="cmd_downloadFlagged"/>
+ <menuitem id="menu_downloadSelected"
+ label="&downloadSelectedCmd.label;"
+ accesskey="&downloadSelectedCmd.accesskey;"
+ command="cmd_downloadSelected"/>
+ </menupopup>
+ </menu>
+ <menuseparator id="fileMenuAfterOfflineSeparator"/>
+ <menuitem id="printMenuItem"
+ key="key_print"
+ label="&printCmd.label;"
+ accesskey="&printCmd.accesskey;"
+ command="cmd_print"/>
+ <menuseparator id="menu_FileQuitSeparator"/>
+ <menuitem id="menu_FileQuitItem"
+#ifdef XP_MACOSX
+ data-l10n-id="menu-quit-mac"
+#else
+ data-l10n-id="menu-quit"
+#endif
+ key="key_quitApplication"
+ command="cmd_quitApplication"/>
+ </menupopup>
+ </menu>
+
+<!-- Edit -->
+<menu id="menu_Edit"
+ label="&editMenu.label;"
+ accesskey="&editMenu.accesskey;"
+ oncommand="CommandUpdate_UndoRedo();">
+ <menupopup id="menu_EditPopup" onpopupshowing="InitEditMessagesMenu()">
+ <menuitem id="menu_undo"
+ label="&undoDefaultCmd.label;"
+ accesskey="&undoDefaultCmd.accesskey;"
+ key="key_undo"
+ command="cmd_undo"/>
+ <menuitem id="menu_redo"
+ label="&redoDefaultCmd.label;"
+ accesskey="&redoDefaultCmd.accesskey;"
+ key="key_redo"
+ command="cmd_redo"/>
+ <menuseparator id="editMenuAfterRedoSeparator"/>
+ <menuitem id="menu_cut"
+ data-l10n-id="text-action-cut"
+ key="key_cut"
+ command="cmd_cut"/>
+ <menuitem id="menu_copy"
+ data-l10n-id="text-action-copy"
+ key="key_copy"
+ command="cmd_copy"/>
+ <menuitem id="menu_paste"
+ data-l10n-id="text-action-paste"
+ key="key_paste"
+ command="cmd_paste"/>
+ <menuitem id="menu_delete"
+ key="key_delete"
+ command="cmd_delete"/>
+ <menuseparator id="editMenuAfterDeleteSeparator"/>
+ <menu id="menu_select" label="&selectMenu.label;" accesskey="&selectMenu.accesskey;">
+ <menupopup id="menu_SelectPopup">
+ <menuitem id="menu_SelectAll" label="&all.label;"
+ accesskey="&all.accesskey;" key="key_selectAll"
+ command="cmd_selectAll"/>
+ <menuseparator id="selectMenuSeparator"/>
+ <menuitem id="menu_selectThread" label="&selectThreadCmd.label;"
+ accesskey="&selectThreadCmd.accesskey;"
+ key="key_selectThread"
+ command="cmd_selectThread"/>
+ <menuitem id="menu_selectFlagged"
+ label="&selectFlaggedCmd.label;"
+ accesskey="&selectFlaggedCmd.accesskey;"
+ command="cmd_selectFlagged"/>
+ </menupopup>
+ </menu>
+ <menuseparator id="editMenuAfterSelectSeparator"/>
+ <menu id="menu_find"
+ label="&findMenu.label;" accesskey="&findMenu.accesskey;">
+ <menupopup id="menu_FindPopup"
+ onpopupshowing="initSearchMessagesMenu()">
+ <menuitem id="menu_findCmd"
+ label="&findCmd.label;"
+ key="key_find"
+ accesskey="&findCmd.accesskey;"
+ command="cmd_find"/>
+ <menuitem id="menu_findAgainCmd"
+ label="&findAgainCmd.label;"
+ key="key_findAgain"
+ accesskey="&findAgainCmd.accesskey;"
+ command="cmd_findAgain"/>
+ <menuseparator id="editMenuAfterFindSeparator"/>
+ <menuitem id="searchMailCmd" label="&searchMailCmd.label;"
+ key="key_searchMail"
+ accesskey="&searchMailCmd.accesskey;"
+ command="cmd_searchMessages"/>
+ <menuitem id="glodaSearchCmd"
+ label="&glodaSearchCmd.label;"
+ accesskey="&glodaSearchCmd.accesskey;"
+ oncommand="openGlodaSearchTab()"/>
+ <menuitem id="searchAddressesCmd" label="&searchAddressesCmd.label;"
+ accesskey="&searchAddressesCmd.accesskey;"
+ oncommand="MsgSearchAddresses()"/>
+ </menupopup>
+ </menu>
+ <menuseparator id="editPropertiesSeparator"/>
+ <menuitem id="menu_favoriteFolder"
+ type="checkbox"
+ label="&menuFavoriteFolder.label;"
+ accesskey="&menuFavoriteFolder.accesskey;"
+ checked="false"
+ command="cmd_toggleFavoriteFolder"/>
+ <menuitem id="menu_properties"
+ command="cmd_properties"/>
+#ifdef MAIN_WINDOW
+ <menuitem id="calendar-properties-menuitem"
+ label="&calendar.properties.label;"
+ accesskey="&calendar.properties.accesskey;"
+ command="calendar_edit_calendar_command"/>
+#endif
+#ifdef XP_UNIX
+#ifndef XP_MACOSX
+ <menuseparator id="prefSep"/>
+ <menuitem id="menu_preferences"
+ oncommand="openOptionsDialog()"
+ data-l10n-id="menu-tools-settings"/>
+ <menuitem id="menu_accountmgr"
+ label="&accountManagerCmd2.label;"
+ accesskey="&accountManagerCmdUnix2.accesskey;"
+ oncommand="MsgAccountManager(null);"/>
+#endif
+#endif
+ </menupopup>
+</menu>
+
+<!-- View -->
+<menu id="menu_View"
+ label="&viewMenu.label;"
+ accesskey="&viewMenu.accesskey;">
+ <menupopup id="menu_View_Popup" onpopupshowing="view_init(event);">
+ <menu id="menu_Toolbars"
+ label="&viewToolbarsMenu.label;"
+ accesskey="&viewToolbarsMenu.accesskey;"
+ onpopupshowing="calendarOnToolbarsPopupShowing(event);">
+ <menupopup id="view_toolbars_popup">
+#ifdef MAIN_WINDOW
+ <menuitem id="view_toolbars_popup_quickFilterBar"
+ type="checkbox"
+ command="cmd_toggleQuickFilterBar"
+ data-l10n-id="quick-filter-bar-toggle"/>
+ <menuitem id="viewToolbarsPopupSpacesToolbar"
+ type="checkbox"
+ data-l10n-id="menu-spaces-toolbar-button"
+ oncommand="gSpacesToolbar.toggleToolbarFromMenu();"/>
+#endif
+ <menuitem id="menu_showTaskbar"
+ type="checkbox"
+ label="&showTaskbarCmd.label;"
+ accesskey="&showTaskbarCmd.accesskey;"
+ oncommand="goToggleToolbar('status-bar', 'menu_showTaskbar')"
+ checked="true"/>
+ <menuseparator id="viewMenuBeforeCustomizeMailToolbarsSeparator"/>
+ <menuitem id="customizeMailToolbars"
+ command="cmd_CustomizeMailToolbar"
+ label="&customizeToolbar.label;"
+ accesskey="&customizeToolbar.accesskey;"/>
+ </menupopup>
+ </menu>
+ <menu id="menu_MessagePaneLayout" label="&messagePaneLayoutStyle.label;" accesskey="&messagePaneLayoutStyle.accesskey;">
+ <menupopup id="view_layout_popup" onpopupshowing="InitViewLayoutStyleMenu(event)">
+ <menuitem id="messagePaneClassic" type="radio" label="&messagePaneClassic.label;" name="viewlayoutgroup"
+ accesskey="&messagePaneClassic.accesskey;" command="cmd_viewClassicMailLayout"/>
+ <menuitem id="messagePaneWide" type="radio" label="&messagePaneWide.label;" name="viewlayoutgroup"
+ accesskey="&messagePaneWide.accesskey;" command="cmd_viewWideMailLayout"/>
+ <menuitem id="messagePaneVertical" type="radio" label="&messagePaneVertical.label;" name="viewlayoutgroup"
+ accesskey="&messagePaneVertical.accesskey;" command="cmd_viewVerticalMailLayout"/>
+ <menuseparator id="viewMenuAfterPaneVerticalSeparator"/>
+ <menuitem id="menu_showFolderPane" type="checkbox" label="&showFolderPaneCmd.label;"
+ accesskey="&showFolderPaneCmd.accesskey;" command="cmd_toggleFolderPane"/>
+ <menuitem id="menu_toggleThreadPaneHeader"
+ type="checkbox"
+ name="threadheader"
+ data-l10n-id="menu-view-toggle-thread-pane-header"
+ command="cmd_toggleThreadPaneHeader"/>
+ <menuitem id="menu_showMessage" type="checkbox" label="&showMessageCmd.label;" key="key_toggleMessagePane"
+ accesskey="&showMessageCmd.accesskey;" command="cmd_toggleMessagePane"/>
+ </menupopup>
+ </menu>
+ <menu id="menu_FolderViews" label="&folderView.label;" accesskey="&folderView.accesskey;">
+ <menupopup id="menu_FolderViewsPopup"
+ onpopupshowing="PanelUI._onFoldersViewShow(event)">
+ <menuitem id="menu_toggleFolderHeader"
+ name="paneheader"
+ value="toggle-header"
+ data-l10n-id="menu-view-folders-toggle-header"
+ type="checkbox"
+ closemenu="none"
+ oncommand="PanelUI.folderViewMenuOnCommand(event);"/>
+ <menuseparator id="folderViewsHeaderSeparator"/>
+ <menuitem id="menu_allFolders" value="all"
+ data-l10n-id="show-all-folders-label"
+ type="checkbox" name="viewmessages"
+ closemenu="none"
+ oncommand="PanelUI.folderViewMenuOnCommand(event);"/>
+ <menuitem id="menu_smartFolders" value="smart"
+ data-l10n-id="show-smart-folders-label"
+ type="checkbox" name="viewmessages"
+ closemenu="none"
+ oncommand="PanelUI.folderViewMenuOnCommand(event);"/>
+ <menuitem id="menu_unreadFolders" value="unread"
+ data-l10n-id="show-unread-folders-label"
+ type="checkbox" name="viewmessages"
+ closemenu="none"
+ oncommand="PanelUI.folderViewMenuOnCommand(event);"/>
+ <menuitem id="menu_favoriteFolders" value="favorite"
+ data-l10n-id="show-favorite-folders-label"
+ type="checkbox" name="viewmessages"
+ closemenu="none"
+ oncommand="PanelUI.folderViewMenuOnCommand(event);"/>
+ <menuitem id="menu_recentFolders" value="recent"
+ data-l10n-id="show-recent-folders-label"
+ type="checkbox" name="viewmessages"
+ closemenu="none"
+ oncommand="PanelUI.folderViewMenuOnCommand(event);"/>
+ <menuseparator/>
+ <menuitem id="menu_tags" value="tags"
+ data-l10n-id="show-tags-folders-label"
+ type="checkbox" name="viewmessages"
+ closemenu="none"
+ oncommand="PanelUI.folderViewMenuOnCommand(event);"/>
+ <menuseparator/>
+ <menuitem id="menu_compactMode" value="compact"
+ data-l10n-id="folder-toolbar-toggle-folder-compact-view"
+ type="checkbox" name="viewmessages"
+ closemenu="none"
+ oncommand="PanelUI.folderCompactMenuOnCommand(event);"/>
+ </menupopup>
+ </menu>
+ <menuseparator id="viewUIZoomMenuSeparator"/>
+ <menu id="menu_uiDensity"
+ data-l10n-id="mail-uidensity-label">
+ <menupopup id="view_density_popup" onpopupshowing="initUiDensityMenu(event);">
+ <menuitem id="uiDensityCompact"
+ data-l10n-id="mail-uidensity-compact"
+ type="radio"
+ name="uidensity"
+ closemenu="none"
+ oncommand="UIDensity.setMode(this.mode);"/>
+ <menuitem id="uiDensityNormal"
+ data-l10n-id="mail-uidensity-default"
+ type="radio"
+ name="uidensity"
+ closemenu="none"
+ oncommand="UIDensity.setMode(this.mode);"/>
+ <menuitem id="uiDensityTouch"
+ data-l10n-id="mail-uidensity-relaxed"
+ type="radio"
+ name="uidensity"
+ closemenu="none"
+ oncommand="UIDensity.setMode(this.mode);"/>
+ </menupopup>
+ </menu>
+ <menu id="viewFullZoomMenu" label="&fullZoom.label;" accesskey="&fullZoom.accesskey;"
+ onpopupshowing="UpdateFullZoomMenu()">
+ <menupopup id="viewFullZoomPopupMenu">
+ <menuitem id="menu_fullZoomEnlarge" key="key_fullZoomEnlarge"
+ label="&fullZoomEnlargeCmd.label;"
+ accesskey="&fullZoomEnlargeCmd.accesskey;"
+ command="cmd_fullZoomEnlarge"/>
+ <menuitem id="menu_fullZoomReduce" key="key_fullZoomReduce"
+ label="&fullZoomReduceCmd.label;"
+ accesskey="&fullZoomReduceCmd.accesskey;"
+ command="cmd_fullZoomReduce"/>
+ <menuseparator id="fullZoomAfterReduceSeparator"/>
+ <menuitem id="menu_fullZoomReset" key="key_fullZoomReset"
+ label="&fullZoomResetCmd.label;"
+ accesskey="&fullZoomResetCmd.accesskey;"
+ command="cmd_fullZoomReset"/>
+ <menuseparator id="fullZoomAfterResetSeparator"/>
+ <menuitem id="menu_fullZoomToggle" label="&fullZoomToggleCmd.label;"
+ accesskey="&fullZoomToggleCmd.accesskey;"
+ type="checkbox" command="cmd_fullZoomToggle" checked="false"/>
+ </menupopup>
+ </menu>
+ <menu id="menu_uiFontSize"
+ data-l10n-id="menu-font-size-label">
+ <menupopup id="view_font_size_popup">
+ <menuitem id="menu_fontSizeEnlarge"
+ data-l10n-id="menuitem-font-size-enlarge"
+ oncommand="UIFontSize.increaseSize();"
+ closemenu="none"/>
+ <menuitem id="menu_fontSizeReduce"
+ data-l10n-id="menuitem-font-size-reduce"
+ oncommand="UIFontSize.reduceSize();"
+ closemenu="none"/>
+ <menuseparator id="fontSizeAfterReduceSeparator"/>
+ <menuitem id="menu_fontSizeReset"
+ data-l10n-id="menuitem-font-size-reset"
+ oncommand="UIFontSize.resetSize();"
+ closemenu="none"/>
+ </menupopup>
+ </menu>
+
+#ifdef MAIN_WINDOW
+#include ../../../calendar/base/content/calendar-view-menu.inc.xhtml
+#endif
+
+ <menuseparator id="viewSortMenuSeparator"/>
+ <menu id="viewSortMenu" accesskey="&sortMenu.accesskey;" label="&sortMenu.label;">
+ <menupopup id="menu_viewSortPopup" oncommand="goDoCommand('cmd_sort', event);" onpopupshowing="InitViewSortByMenu()">
+ <menuitem id="sortByDateMenuitem"
+ type="radio"
+ name="sortby"
+ value="byDate"
+ label="&sortByDateCmd.label;"
+ accesskey="&sortByDateCmd.accesskey;"/>
+ <menuitem id="sortByReceivedMenuitem"
+ type="radio"
+ name="sortby"
+ value="byReceived"
+ label="&sortByReceivedCmd.label;"
+ accesskey="&sortByReceivedCmd.accesskey;"/>
+ <menuitem id="sortByFlagMenuitem"
+ type="radio"
+ name="sortby"
+ value="byFlagged"
+ label="&sortByStarCmd.label;"
+ accesskey="&sortByStarCmd.accesskey;"/>
+ <menuitem id="sortByOrderReceivedMenuitem"
+ type="radio"
+ name="sortby"
+ value="byId"
+ label="&sortByOrderReceivedCmd.label;"
+ accesskey="&sortByOrderReceivedCmd.accesskey;"/>
+ <menuitem id="sortByPriorityMenuitem"
+ type="radio"
+ name="sortby"
+ value="byPriority"
+ label="&sortByPriorityCmd.label;"
+ accesskey="&sortByPriorityCmd.accesskey;"/>
+ <menuitem id="sortByFromMenuitem"
+ type="radio"
+ name="sortby"
+ value="byAuthor"
+ label="&sortByFromCmd.label;"
+ accesskey="&sortByFromCmd.accesskey;"/>
+ <menuitem id="sortByRecipientMenuitem"
+ type="radio"
+ name="sortby"
+ value="byRecipient"
+ label="&sortByRecipientCmd.label;"
+ accesskey="&sortByRecipientCmd.accesskey;"/>
+ <menuitem id="sortByCorrespondentMenuitem"
+ type="radio"
+ name="sortby"
+ value="byCorrespondent"
+ label="&sortByCorrespondentCmd.label;"
+ accesskey="&sortByCorrespondentCmd.accesskey;"/>
+ <menuitem id="sortBySizeMenuitem"
+ type="radio"
+ name="sortby"
+ value="bySize"
+ label="&sortBySizeCmd.label;"
+ accesskey="&sortBySizeCmd.accesskey;"/>
+ <menuitem id="sortByStatusMenuitem"
+ type="radio"
+ name="sortby"
+ value="byStatus"
+ label="&sortByStatusCmd.label;"
+ accesskey="&sortByStatusCmd.accesskey;"/>
+ <menuitem id="sortBySubjectMenuitem"
+ type="radio"
+ name="sortby"
+ value="bySubject"
+ label="&sortBySubjectCmd.label;"
+ accesskey="&sortBySubjectCmd.accesskey;"/>
+ <menuitem id="sortByUnreadMenuitem"
+ type="radio"
+ name="sortby"
+ value="byUnread"
+ label="&sortByUnreadCmd.label;"
+ accesskey="&sortByUnreadCmd.accesskey;"/>
+ <menuitem id="sortByTagsMenuitem"
+ type="radio"
+ name="sortby"
+ value="byTags"
+ label="&sortByTagsCmd.label;"
+ accesskey="&sortByTagsCmd.accesskey;"/>
+ <menuitem id="sortByJunkStatusMenuitem"
+ type="radio"
+ name="sortby"
+ value="byJunkStatus"
+ label="&sortByJunkStatusCmd.label;"
+ accesskey="&sortByJunkStatusCmd.accesskey;"/>
+ <menuitem id="sortByAttachmentsMenuitem"
+ type="radio"
+ name="sortby"
+ value="byAttachments"
+ label="&sortByAttachmentsCmd.label;"
+ accesskey="&sortByAttachmentsCmd.accesskey;"/>
+ <menuseparator id="sortAfterAttachmentSeparator"/>
+ <menuitem id="sortAscending"
+ type="radio"
+ name="sortdirection"
+ value="ascending"
+ label="&sortAscending.label;"
+ accesskey="&sortAscending.accesskey;"/>
+ <menuitem id="sortDescending"
+ type="radio"
+ name="sortdirection"
+ value="descending"
+ label="&sortDescending.label;"
+ accesskey="&sortDescending.accesskey;"/>
+ <menuseparator id="sortAfterDescendingSeparator"/>
+ <menuitem id="sortThreaded"
+ type="radio"
+ name="threaded"
+ value="threaded"
+ label="&sortThreaded.label;"
+ accesskey="&sortThreaded.accesskey;"/>
+ <menuitem id="sortUnthreaded"
+ type="radio"
+ name="threaded"
+ value="unthreaded"
+ label="&sortUnthreaded.label;"
+ accesskey="&sortUnthreaded.accesskey;"/>
+ <menuitem id="groupBySort"
+ type="checkbox"
+ name="group"
+ value="group"
+ label="&groupBySort.label;"
+ accesskey="&groupBySort.accesskey;"/>
+ </menupopup>
+ </menu>
+ <menu id="viewMessageViewMenu" label="&msgsMenu.label;" accesskey="&msgsMenu.accesskey;"
+ command="mailHideMenus" oncommand="ViewChangeByMenuitem(event.target);">
+ <menupopup id="viewMessagePopup" onpopupshowing="RefreshViewPopup(this);">
+ <menuitem id="viewMessageAll" value="0" type="radio" label="&viewAll.label;" accesskey="&viewAll.accesskey;"/>
+ <menuitem id="viewMessageUnread" value="1" type="radio" label="&viewUnread.label;" accesskey="&viewUnread.accesskey;"/>
+ <menuitem id="viewMessageNotDeleted" value="3" type="radio" label="&viewNotDeleted.label;" accesskey="&viewNotDeleted.accesskey;"/>
+ <menuseparator id="messageViewAfterUnreadSeparator"/>
+ <menu id="viewMessageTags" label="&viewTags.label;" accesskey="&viewTags.accesskey;">
+ <menupopup id="viewMessageTagsPopup" onpopupshowing="RefreshTagsPopup(this);"/>
+ </menu>
+ <menu id="viewMessageCustomViews" label="&viewCustomViews.label;" accesskey="&viewCustomViews.accesskey;">
+ <menupopup id="viewMessageCustomViewsPopup" onpopupshowing="RefreshCustomViewsPopup(this);"/>
+ </menu>
+ <menuseparator id="messageViewAfterCustomSeparator"/>
+ <menuitem id="viewMessageVirtualFolder" value="7" label="&viewVirtualFolder.label;" accesskey="&viewVirtualFolder.accesskey;"/>
+ <menuitem id="viewMessageCustomize" value="8" label="&viewCustomizeView.label;" accesskey="&viewCustomizeView.accesskey;"/>
+ </menupopup>
+ </menu>
+
+ <menu label="&threads.label;" id="viewMessagesMenu" accesskey="&threads.accesskey;">
+ <menupopup id="menu_ThreadsPopup" onpopupshowing="InitViewMessagesMenu()">
+ <menuitem id="viewAllMessagesMenuItem" type="radio" name="viewmessages" label="&allMsgsCmd.label;" accesskey="&allMsgsCmd.accesskey;" disabled="true" command="cmd_viewAllMsgs"/>
+ <menuitem id="viewUnreadMessagesMenuItem" type="radio" name="viewmessages" label="&unreadMsgsCmd.label;" accesskey="&unreadMsgsCmd.accesskey;" disabled="true" command="cmd_viewUnreadMsgs"/>
+ <menuitem id="viewThreadsWithUnreadMenuItem" type="radio" name="viewmessages" label="&threadsWithUnreadCmd.label;" accesskey="&threadsWithUnreadCmd.accesskey;" disabled="true" command="cmd_viewThreadsWithUnread"/>
+ <menuitem id="viewWatchedThreadsWithUnreadMenuItem" type="radio" name="viewmessages" label="&watchedThreadsWithUnreadCmd.label;" accesskey="&watchedThreadsWithUnreadCmd.accesskey;" disabled="true" command="cmd_viewWatchedThreadsWithUnread"/>
+ <menuseparator id="threadsAfterWatchedSeparator"/>
+ <menuitem id="viewIgnoredThreadsMenuItem" type="checkbox" label="&ignoredThreadsCmd.label;" disabled="true" command="cmd_viewIgnoredThreads" accesskey="&ignoredThreadsCmd.accesskey;"/>
+ <menuseparator id="threadsAfterIgnoredSeparator"/>
+ <menuitem id="menu_expandAllThreads" label="&expandAllThreadsCmd.label;" accesskey="&expandAllThreadsCmd.accesskey;" key="key_expandAllThreads" disabled="true" command="cmd_expandAllThreads"/>
+ <menuitem id="collapseAllThreads" label="&collapseAllThreadsCmd.label;" accesskey="&collapseAllThreadsCmd.accesskey;" key="key_collapseAllThreads" disabled="true" command="cmd_collapseAllThreads"/>
+ </menupopup>
+ </menu>
+ <menuseparator id="viewAfterThreadsSeparator"/>
+ <menu id="viewheadersmenu" label="&headersMenu.label;" accesskey="&headersMenu.accesskey;">
+ <menupopup id="menu_HeadersPopup" onpopupshowing="InitViewHeadersMenu();">
+ <menuitem id="viewallheaders"
+ type="radio"
+ name="viewheadergroup"
+ label="&headersAllCmd.label;"
+ accesskey="&headersAllCmd.accesskey;"
+ command="cmd_viewAllHeader"/>
+ <menuitem id="viewnormalheaders"
+ type="radio"
+ name="viewheadergroup"
+ label="&headersNormalCmd.label;"
+ accesskey="&headersNormalCmd.accesskey;"
+ command="cmd_viewNormalHeader"/>
+ </menupopup>
+ </menu>
+ <menu id="viewBodyMenu" accesskey="&bodyMenu.accesskey;" label="&bodyMenu.label;">
+ <menupopup id="viewBodyPopMenu" onpopupshowing="InitViewBodyMenu()">
+ <menuitem id="bodyAllowHTML" type="radio" name="bodyPlaintextVsHTMLPref" label="&bodyAllowHTML.label;"
+ accesskey="&bodyAllowHTML.accesskey;" oncommand="MsgBodyAllowHTML()"/>
+ <menuitem id="bodySanitized" type="radio" name="bodyPlaintextVsHTMLPref" label="&bodySanitized.label;"
+ accesskey="&bodySanitized.accesskey;"
+ oncommand="MsgBodySanitized()"/>
+ <menuitem id="bodyAsPlaintext" type="radio" name="bodyPlaintextVsHTMLPref" label="&bodyAsPlaintext.label;"
+ accesskey="&bodyAsPlaintext.accesskey;" oncommand="MsgBodyAsPlaintext()"/>
+ <menuitem id="bodyAllParts" type="radio" name="bodyPlaintextVsHTMLPref" label="&bodyAllParts.label;"
+ accesskey="&bodyAllParts.accesskey;" oncommand="MsgBodyAllParts()"/>
+ </menupopup>
+ </menu>
+ <menu id="viewFeedSummary"
+ label="&bodyMenuFeed.label;"
+ accesskey="&bodyMenuFeed.accesskey;">
+ <menupopup id="viewFeedSummaryPopupMenu"
+ onpopupshowing="InitViewBodyMenu()">
+ <menuitem id="bodyFeedGlobalWebPage"
+ type="radio"
+ name="viewFeedSummaryGroup"
+ label="&viewFeedWebPage.label;"
+ accesskey="&viewFeedWebPage.accesskey;"
+ oncommand="FeedMessageHandler.onSelectPref = 0"/>
+ <menuitem id="bodyFeedGlobalSummary"
+ type="radio"
+ name="viewFeedSummaryGroup"
+ label="&viewFeedSummary.label;"
+ accesskey="&viewFeedSummary.accesskey;"
+ oncommand="FeedMessageHandler.onSelectPref = 1"/>
+ <menuitem id="bodyFeedPerFolderPref"
+ type="radio"
+ name="viewFeedSummaryGroup"
+ label="&viewFeedSummaryFeedPropsPref.label;"
+ accesskey="&viewFeedSummaryFeedPropsPref.accesskey;"
+ oncommand="FeedMessageHandler.onSelectPref = 2"/>
+ <menuseparator id="viewFeedSummarySeparator"/>
+ <menuitem id="bodyFeedSummaryAllowHTML"
+ type="radio"
+ name="viewFeedBodyHTMLGroup"
+ label="&bodyAllowHTML.label;"
+ accesskey="&bodyAllowHTML.accesskey;"
+ oncommand="MsgFeedBodyRenderPrefs(false, 0, 0)"/>
+ <menuitem id="bodyFeedSummarySanitized"
+ type="radio"
+ name="viewFeedBodyHTMLGroup"
+ label="&bodySanitized.label;"
+ accesskey="&bodySanitized.accesskey;"
+ oncommand="MsgFeedBodyRenderPrefs(false, 3, gDisallow_classes_no_html)"/>
+ <menuitem id="bodyFeedSummaryAsPlaintext"
+ type="radio"
+ name="viewFeedBodyHTMLGroup"
+ label="&bodyAsPlaintext.label;"
+ accesskey="&bodyAsPlaintext.accesskey;"
+ oncommand="MsgFeedBodyRenderPrefs(true, 1, gDisallow_classes_no_html)"/>
+ </menupopup>
+ </menu>
+ <menuitem id="viewAttachmentsInlineMenuitem" label="&viewAttachmentsInlineCmd.label;" accesskey="&viewAttachmentsInlineCmd.accesskey;"
+ oncommand="ToggleInlineAttachment(event.target)" type="checkbox" checked="true"/>
+ <menuseparator id="viewAfterAttachmentsSeparator"/>
+ <menuitem id="pageSourceMenuItem"
+ label="&pageSourceCmd.label;"
+ key="key_viewPageSource"
+ accesskey="&pageSourceCmd.accesskey;"
+ command="cmd_viewPageSource"/>
+ </menupopup>
+ </menu>
+
+ <!-- Go -->
+ <menu id="menu_Go" label="&goMenu.label;" accesskey="&goMenu.accesskey;">
+ <menupopup id="menu_GoPopup" onpopupshowing="InitGoMessagesMenu();">
+ <menu id="goNextMenu" label="&nextMenu.label;" accesskey="&nextMenu.accesskey;">
+ <menupopup id="menu_GoNextPopup">
+ <menuitem id="menu_nextMsg"
+ label="&nextMsgCmd.label;"
+ accesskey="&nextMsgCmd.accesskey;"
+ command="cmd_nextMsg"
+ key="key_nextMsg"/>
+ <menuitem id="menu_nextUnreadMsg"
+ label="&nextUnreadMsgCmd.label;"
+ accesskey="&nextUnreadMsgCmd.accesskey;"
+ command="cmd_nextUnreadMsg"
+ key="key_nextUnreadMsg"/>
+ <menuitem id="menu_nextFlaggedMsg"
+ label="&nextStarredMsgCmd.label;"
+ accesskey="&nextStarredMsgCmd.accesskey;"
+ command="cmd_nextFlaggedMsg"/>
+ <menuseparator id="goNextAfterFlaggedSeparator"/>
+ <menuitem id="menu_nextUnreadThread"
+ label="&nextUnreadThread.label;"
+ accesskey="&nextUnreadThread.accesskey;"
+ command="cmd_nextUnreadThread"
+ key="key_nextUnreadThread"/>
+#ifdef MAIN_WINDOW
+ <menuseparator id="goNextAfterUnreadThreadSeparator"
+ class="hide-before-calendar-loaded hide-when-calendar-deactivated"
+ hidden="true"/>
+ <!-- Label is set up automatically using the view id. When writing
+ a view extension, add a `label-<myviewtype>` attribute with
+ the correct label. -->
+ <menuitem id="calendar-go-menu-next"
+ class="hide-before-calendar-loaded hide-when-calendar-deactivated"
+ label=""
+ label-day="&lightning.toolbar.day.label;"
+ label-week="&lightning.toolbar.week.label;"
+ label-multiweek="&lightning.toolbar.week.label;"
+ label-month="&lightning.toolbar.month.label;"
+ accesskey-day="&lightning.toolbar.day.accesskey;"
+ accesskey-week="&lightning.toolbar.week.accesskey;"
+ accesskey-multiweek="&lightning.toolbar.week.accesskey;"
+ accesskey-month="&lightning.toolbar.month.accesskey;"
+ command="calendar_view_next_command"
+ hidden="true"/>
+#endif
+ </menupopup>
+ </menu>
+ <menu id="goPreviousMenu" label="&prevMenu.label;" accesskey="&prevMenu.accesskey;">
+ <menupopup id="menu_GoPreviousPopup">
+ <menuitem id="menu_prevMsg"
+ label="&prevMsgCmd.label;"
+ accesskey="&prevMsgCmd.accesskey;"
+ command="cmd_previousMsg"
+ key="key_previousMsg"/>
+ <menuitem id="menu_prevUnreadMsg"
+ label="&prevUnreadMsgCmd.label;"
+ accesskey="&prevUnreadMsgCmd.accesskey;"
+ command="cmd_previousUnreadMsg"
+ key="key_previousUnreadMsg"/>
+ <menuitem id="menu_prevFlaggedMsg"
+ label="&prevStarredMsgCmd.label;"
+ accesskey="&prevStarredMsgCmd.accesskey;"
+ command="cmd_previousFlaggedMsg"/>
+#ifdef MAIN_WINDOW
+ <menuseparator id="goPreviousAfterFlaggedSeparator"
+ class="hide-before-calendar-loaded hide-when-calendar-deactivated"
+ hidden="true"/>
+ <!-- Label is set up automatically using the view id. When writing
+ a view extension, add a `label-<myviewtype>` attribute with
+ the correct label. -->
+ <menuitem id="calendar-go-menu-previous"
+ class="hide-before-calendar-loaded hide-when-calendar-deactivated"
+ label=""
+ label-day="&lightning.toolbar.day.label;"
+ label-week="&lightning.toolbar.week.label;"
+ label-multiweek="&lightning.toolbar.week.label;"
+ label-month="&lightning.toolbar.month.label;"
+ accesskey-day="&lightning.toolbar.day.accesskey;"
+ accesskey-week="&lightning.toolbar.week.accesskey;"
+ accesskey-multiweek="&lightning.toolbar.week.accesskey;"
+ accesskey-month="&lightning.toolbar.month.accesskey;"
+ command="calendar_view_prev_command"
+ hidden="true"/>
+#endif
+ </menupopup>
+ </menu>
+ <menuitem id="menu_goForward" label="&goForwardCmd.label;"
+ accesskey="&goForwardCmd.accesskey;" command="cmd_goForward"
+ key="key_goForward"/>
+ <menuitem id="menu_goBack" label="&goBackCmd.label;"
+ accesskey="&goBackCmd.accesskey;" command="cmd_goBack"
+ key="key_goBack"/>
+ <menuseparator id="goNextSeparator"/>
+#ifdef MAIN_WINDOW
+ <menuitem id="calendar-go-to-today-menuitem"
+ class="hide-when-calendar-deactivated"
+ label="&goTodayCmd.label;"
+ accesskey="&goTodayCmd.accesskey;"
+ command="calendar_go_to_today_command"
+ key="calendar-go-to-today-key"/>
+#endif
+ <menuitem id="menu_goChat" label="&goChatCmd.label;"
+ accesskey="&goChatCmd.accesskey;"
+ command="cmd_chat"
+ data-l10n-attrs="acceltext"/>
+ <menuseparator id="goChatSeparator"/>
+ <menu id="goFolderMenu"
+ label="&folderMenu.label;"
+ accesskey="&folderMenu.accesskey;"
+ command="cmd_goFolder">
+ <menupopup is="folder-menupopup" id="menu_GoFolderPopup"
+ showFileHereLabel="true"
+ showRecent="true"
+ recentLabel="&contextMoveCopyMsgRecentMenu.label;"
+ recentAccessKey="&contextMoveCopyMsgRecentMenu.accesskey;"
+ showFavorites="true"
+ favoritesLabel="&contextMoveCopyMsgFavoritesMenu.label;"
+ favoritesAccessKey="&contextMoveCopyMsgFavoritesMenu.accesskey;"/>
+ </menu>
+ <menuseparator id="goFolderSeparator"/>
+
+ <menu id="goRecentlyClosedTabs"
+ label="&goRecentlyClosedTabs.label;"
+ accesskey="&goRecentlyClosedTabs.accesskey;"
+ observes="cmd_undoCloseTab">
+ <menupopup id="menu_GoRecentlyClosedTabsPopup"
+ onpopupshowing="return InitRecentlyClosedTabsPopup(this)" />
+ </menu>
+ <menuseparator id="goRecentlyClosedTabsSeparator"/>
+
+ <menuitem id="goStartPage"
+ label="&startPageCmd.label;"
+ accesskey="&startPageCmd.accesskey;"
+ command="cmd_goStartPage"
+ key="key_goStartPage"/>
+ </menupopup>
+ </menu>
+
+ <!-- Message -->
+ <menu id="messageMenu" label="&msgMenu.label;" accesskey="&msgMenu.accesskey;">
+ <menupopup id="messageMenuPopup" onpopupshowing="InitMessageMenu();">
+ <menuitem id="newMsgCmd" label="&newMsgCmd.label;"
+ accesskey="&newMsgCmd.accesskey;"
+ key="key_newMessage2"
+ command="cmd_newMessage"/>
+ <menuitem id="replyMainMenu" label="&replyMsgCmd.label;"
+ accesskey="&replyMsgCmd.accesskey;"
+ key="key_reply"
+ command="cmd_reply"/>
+ <menuitem id="replyNewsgroupMainMenu" label="&replyNewsgroupCmd2.label;"
+ accesskey="&replyNewsgroupCmd2.accesskey;"
+ key="key_reply"
+ command="cmd_replyGroup"/>
+ <menuitem id="replySenderMainMenu" label="&replySenderCmd.label;"
+ accesskey="&replySenderCmd.accesskey;"
+ command="cmd_replySender"/>
+ <menuitem id="menu_replyToAll" label="&replyToAllMsgCmd.label;"
+ accesskey="&replyToAllMsgCmd.accesskey;"
+ key="key_replyall"
+ command="cmd_replyall"/>
+ <menuitem id="menu_replyToList" label="&replyToListMsgCmd.label;"
+ accesskey="&replyToListMsgCmd.accesskey;"
+ key="key_replylist"
+ command="cmd_replylist"/>
+ <menuitem id="menu_forwardMsg" label="&forwardMsgCmd.label;"
+ accesskey="&forwardMsgCmd.accesskey;"
+ key="key_forward"
+ command="cmd_forward"/>
+ <menu id="forwardAsMenu" label="&forwardAsMenu.label;" accesskey="&forwardAsMenu.accesskey;">
+ <menupopup id="menu_forwardAsPopup">
+ <menuitem id="menu_forwardAsInline"
+ label="&forwardAsInline.label;"
+ accesskey="&forwardAsInline.accesskey;"
+ command="cmd_forwardInline"/>
+ <menuitem id="menu_forwardAsAttachment"
+ label="&forwardAsAttachmentCmd.label;"
+ accesskey="&forwardAsAttachmentCmd.accesskey;"
+ command="cmd_forwardAttachment"/>
+ </menupopup>
+ </menu>
+ <menuitem id="menu_redirectMsg"
+ data-l10n-id="redirect-msg-menuitem"
+ command="cmd_redirect"/>
+ <menuitem id="menu_editMsgAsNew" label="&editAsNewMsgCmd.label;"
+ accesskey="&editAsNewMsgCmd.accesskey;"
+ key="key_editAsNew"
+ command="cmd_editAsNew"/>
+ <menuitem id="menu_editDraftMsg"
+ label="&editDraftMsgCmd.label;"
+ accesskey="&editDraftMsgCmd.accesskey;"
+ command="cmd_editDraftMsg"/>
+ <menuitem id="menu_newMsgFromTemplate"
+ label="&newMsgFromTemplateCmd.label;"
+ key="key_newMsgFromTemplate"
+ command="cmd_newMsgFromTemplate"/>
+ <menuitem id="menu_editTemplate"
+ label="&editTemplateMsgCmd.label;"
+ accesskey="&editTemplateMsgCmd.accesskey;"
+ command="cmd_editTemplateMsg"/>
+ <menuseparator id="messageMenuAfterCompositionCommandsSeparator"/>
+ <menuitem id="openMessageWindowMenuitem" label="&openMessageWindowCmd.label;"
+ command="cmd_openMessage"
+ accesskey="&openMessageWindowCmd.accesskey;"
+ key="key_openMessage"/>
+#ifdef MAIN_WINDOW
+ <menuitem id="openConversationMenuitem" label="&openInConversationCmd.label;"
+ command="cmd_openConversation"
+ accesskey="&openInConversationCmd.accesskey;"
+ key="key_openConversation"/>
+#endif
+ <menu id="openFeedMessage"
+ label="&openFeedMessage1.label;"
+ accesskey="&openFeedMessage1.accesskey;">
+ <menupopup id="menu_openFeedMessage">
+ <menuitem id="menu_openFeedWebPage"
+ type="radio"
+ name="openFeedGroup"
+ label="&openFeedWebPage.label;"
+ accesskey="&openFeedWebPage.accesskey;"
+ oncommand="FeedMessageHandler.onOpenPref = 0"/>
+ <menuitem id="menu_openFeedSummary"
+ type="radio"
+ name="openFeedGroup"
+ label="&openFeedSummary.label;"
+ accesskey="&openFeedSummary.accesskey;"
+ oncommand="FeedMessageHandler.onOpenPref = 1"/>
+ <menuitem id="menu_openFeedWebPageInMessagePane"
+ type="radio"
+ name="openFeedGroup"
+ label="&openFeedWebPageInMP.label;"
+ accesskey="&openFeedWebPageInMP.accesskey;"
+ oncommand="FeedMessageHandler.onOpenPref = 2"/>
+ </menupopup>
+ </menu>
+#ifdef MAIN_WINDOW
+ <menuseparator id="messageAfterOpenMsgSeparator"/>
+#endif
+ <menu id="msgAttachmentMenu"
+ label="&openAttachmentListCmd.label;"
+ accesskey="&openAttachmentListCmd.accesskey;"
+ disabled="true">
+ <menupopup id="attachmentMenuList"
+ onpopupshowing="fillAttachmentListPopup(event);">
+ <menuseparator/>
+ <menuitem id="menu-openAllAttachments"
+ label="&openAllAttachmentsCmd.label;"
+ accesskey="&openAllAttachmentsCmd.accesskey;"
+ command="cmd_openAllAttachments"/>
+ <menuitem id="menu-saveAllAttachments"
+ label="&saveAllAttachmentsCmd.label;"
+ accesskey="&saveAllAttachmentsCmd.accesskey;"
+ command="cmd_saveAllAttachments"/>
+ <menuitem id="menu-detachAllAttachments"
+ label="&detachAllAttachmentsCmd.label;"
+ accesskey="&detachAllAttachmentsCmd.accesskey;"
+ command="cmd_detachAllAttachments"/>
+ <menuitem id="menu-deleteAllAttachments"
+ label="&deleteAllAttachmentsCmd.label;"
+ accesskey="&deleteAllAttachmentsCmd.accesskey;"
+ command="cmd_deleteAllAttachments"/>
+ </menupopup>
+ </menu>
+ <menuseparator id="messageAfterAttachmentMenuSeparator"/>
+ <menu id="tagMenu" label="&tagMenu.label;" accesskey="&tagMenu.accesskey;" command="cmd_tag">
+ <menupopup id="tagMenu-tagpopup"
+ onpopupshowing="InitMessageTags(this);">
+ <menuitem id="tagMenu-addNewTag"
+ label="&addNewTag.label;"
+ accesskey="&addNewTag.accesskey;"
+ command="cmd_addTag"/>
+ <menuitem id="tagMenu-manageTags"
+ label="&manageTags.label;"
+ accesskey="&manageTags.accesskey;"
+ command="cmd_manageTags"/>
+ <menuseparator id="tagMenu-sep-afterTagAddNew"/>
+ <menuitem id="tagMenu-tagRemoveAll"
+ command="cmd_removeTags"/>
+ <menuseparator id="tagMenuAfterRemoveSeparator"/>
+ </menupopup>
+ </menu>
+ <menu id="markMenu" label="&markMenu.label;" accesskey="&markMenu.accesskey;">
+ <menupopup id="menu_MarkPopup" onpopupshowing="InitMessageMark()">
+ <menuitem id="markReadMenuItem" label="&markAsReadCmd.label;"
+ accesskey="&markAsReadCmd.accesskey;"
+ key="key_toggleRead"
+ command="cmd_markAsRead"/>
+ <menuitem id="markUnreadMenuItem" label="&markAsUnreadCmd.label;"
+ accesskey="&markAsUnreadCmd.accesskey;"
+ key="key_toggleRead"
+ command="cmd_markAsUnread"/>
+ <menuitem id="menu_markThreadAsRead"
+ label="&markThreadAsReadCmd.label;"
+ accesskey="&markThreadAsReadCmd.accesskey;"
+ command="cmd_markThreadAsRead"
+ key="key_markThreadAsRead"/>
+ <menuitem id="menu_markReadByDate"
+ label="&markReadByDateCmd.label;"
+ accesskey="&markReadByDateCmd.accesskey;"
+ command="cmd_markReadByDate"
+ key="key_markReadByDate"/>
+ <menuitem id="menu_markAllRead"
+ label="&markAllReadCmd.label;"
+ key="key_markAllRead"
+ accesskey="&markAllReadCmd.accesskey;"
+ command="cmd_markAllRead"/>
+ <menuseparator id="markMenuAfterAllReadSeparator"/>
+ <menuitem id="markFlaggedMenuItem"
+ type="checkbox"
+ label="&markStarredCmd.label;"
+ accesskey="&markStarredCmd.accesskey;"
+ command="cmd_markAsFlagged"
+ key="key_toggleFlagged"/>
+ <menuseparator id="markMenuAfterFlaggedSeparator"/>
+ <menuitem id="menu_markAsJunk" label="&markAsJunkCmd.label;"
+ accesskey="&markAsJunkCmd.accesskey;"
+ command="cmd_markAsJunk"
+ key="key_markJunk"/>
+ <menuitem id="menu_markAsNotJunk" label="&markAsNotJunkCmd.label;"
+ key="key_markNotJunk"
+ accesskey="&markAsNotJunkCmd.accesskey;"
+ command="cmd_markAsNotJunk"/>
+ <menuitem id="menu_recalculateJunkScore"
+ label="&recalculateJunkScoreCmd.label;"
+ accesskey="&recalculateJunkScoreCmd.accesskey;"
+ command="cmd_recalculateJunkScore"/>
+ </menupopup>
+ </menu>
+ <menuseparator id="messageMenuAfterMarkSeparator"/>
+ <menuitem id="archiveMainMenu" label="&archiveMsgCmd.label;"
+ accesskey="&archiveMsgCmd.accesskey;"
+ key="key_archive"
+ command="cmd_archive"/>
+ <menuitem id="menu_cancel" command="cmd_cancel"
+ label="&cancelNewsMsgCmd.label;"
+ accesskey="&cancelNewsMsgCmd.accesskey;"/>
+ <menu id="moveMenu"
+ label="&moveMsgToMenu.label;"
+ accesskey="&moveMsgToMenu.accesskey;"
+ oncommand="goDoCommand('cmd_moveMessage', event.target._folder)">
+ <menupopup is="folder-menupopup"
+ mode="filing"
+ showFileHereLabel="true"
+ showRecent="true"
+ recentLabel="&moveCopyMsgRecentMenu.label;"
+ recentAccessKey="&moveCopyMsgRecentMenu.accesskey;"
+ showFavorites="true"
+ favoritesLabel="&contextMoveCopyMsgFavoritesMenu.label;"
+ favoritesAccessKey="&contextMoveCopyMsgFavoritesMenu.accesskey;"/>
+ </menu>
+ <menu id="copyMenu"
+ label="&copyMsgToMenu.label;"
+ accesskey="&copyMsgToMenu.accesskey;"
+ oncommand="goDoCommand('cmd_copyMessage', event.target._folder)">
+ <menupopup is="folder-menupopup"
+ mode="filing"
+ showFileHereLabel="true"
+ showRecent="true"
+ recentLabel="&moveCopyMsgRecentMenu.label;"
+ recentAccessKey="&moveCopyMsgRecentMenu.accesskey;"
+ showFavorites="true"
+ favoritesLabel="&contextMoveCopyMsgFavoritesMenu.label;"
+ favoritesAccessKey="&contextMoveCopyMsgFavoritesMenu.accesskey;"/>
+ </menu>
+ <menuitem id="moveToFolderAgain" key="key_moveToFolderAgain" command="cmd_moveToFolderAgain"
+ label="&moveToFolderAgain.label;" accesskey="&moveToFolderAgain.accesskey;"/>
+ <menuseparator id="messageMenuAfterMoveCommandsSeparator"/>
+ <menuitem id="createFilter" label="&createFilter.label;"
+ accesskey="&createFilter.accesskey;"
+ command="cmd_createFilterFromMenu"/>
+ <menuseparator id="threadItemsSeparator"/>
+ <menuitem id="killThread"
+ label="&killThreadMenu.label;"
+ accesskey="&killThreadMenu.accesskey;"
+ command="cmd_killThread"
+ type="checkbox"
+ key="key_killThread"/>
+ <menuitem id="killSubthread"
+ label="&killSubthreadMenu.label;"
+ accesskey="&killSubthreadMenu.accesskey;"
+ type="checkbox"
+ command="cmd_killSubthread"
+ key="key_killSubthread"/>
+ <menuitem id="watchThread"
+ label="&watchThreadMenu.label;"
+ accesskey="&watchThreadMenu.accesskey;"
+ type="checkbox"
+ command="cmd_watchThread"
+ key="key_watchThread"/>
+ </menupopup>
+</menu>
+
+#ifdef MAIN_WINDOW
+#include ../../../calendar/base/content/calendar-menu-events-tasks.inc.xhtml
+#endif
+
+<!-- Tools -->
+<menu id="tasksMenu" label="&tasksMenu.label;" accesskey="&tasksMenu.accesskey;">
+ <menupopup id="taskPopup" onpopupshowing="document.commandDispatcher.updateCommands('create-menu-tasks')">
+#ifndef XP_MACOSX
+ <menuitem hidden="true" accesskey="&messengerCmd.accesskey;" label="&messengerCmd.label;"
+ key="key_mail" oncommand="toMessengerWindow();" id="tasksMenuMail"/>
+ <menuitem id="addressBook"
+ label="&addressBookCmd.label;"
+ accesskey="&addressBookCmd.accesskey;"
+ key="key_addressbook"
+ oncommand="toAddressBook();"/>
+ <menuseparator id="devToolsSeparator"/>
+#endif
+ <menuitem id="menu_openSavedFilesWnd" label="&savedFiles.label;"
+ accesskey="&savedFiles.accesskey;"
+ key="key_savedFiles"
+ oncommand="openSavedFilesWnd();"/>
+ <menuitem id="addonsManager"
+ data-l10n-id="menu-addons-and-themes"
+ oncommand="openAddonsMgr();"/>
+ <menuitem id="activityManager" label="&activitymanager.label;"
+ accesskey="&activitymanager.accesskey;"
+ oncommand="openActivityMgr();"/>
+ <menu id="imAccountsStatus" label="&imAccountsStatus.label;"
+ accesskey="&imAccountsStatus.accesskey;"
+ command="cmd_chatStatus">
+ <menupopup id="imStatusMenupopup">
+ <menuitem id="imStatusAvailable" status="available" label="&imStatus.available;" class="menuitem-iconic"/>
+ <menuitem id="imStatusUnavailable" status="unavailable" label="&imStatus.unavailable;" class="menuitem-iconic"/>
+ <menuseparator id="imStatusOfflineSeparator"/>
+ <menuitem id="imStatusOffline" status="offline" label="&imStatus.offline;" class="menuitem-iconic"/>
+ <menuseparator id="imStatusShowAccountsSeparator"/>
+ <menuitem id="imStatusShowAccounts" label="&imStatus.showAccounts;"/>
+ </menupopup>
+ </menu>
+ <menuitem id="joinChatMenuItem"
+ label="&joinChatCmd.label;"
+ accesskey="&joinChatCmd.accesskey;"
+ command="cmd_joinChat"/>
+
+ <menuseparator id="devToolsSeparator"/>
+ <menuitem id="filtersCmd" label="&filtersCmd2.label;"
+ accesskey="&filtersCmd2.accesskey;"
+ oncommand="MsgFilters();"/>
+ <menuitem id="applyFilters"
+ label="&filtersApply.label;"
+ accesskey="&filtersApply.accesskey;"
+ command="cmd_applyFilters"/>
+ <menuitem id="applyFiltersToSelection"
+ label="&filtersApplyToMessage.label;"
+ accesskey="&filtersApplyToMessage.accesskey;"
+ command="cmd_applyFiltersToSelection"/>
+ <menuseparator id="tasksMenuAfterApplySeparator"/>
+ <menuitem id="runJunkControls"
+ label="&runJunkControls.label;"
+ accesskey="&runJunkControls.accesskey;"
+ command="cmd_runJunkControls"/>
+ <menuitem id="deleteJunk"
+ label="&deleteJunk.label;"
+ accesskey="&deleteJunk.accesskey;"
+ command="cmd_deleteJunk"/>
+ <menuseparator id="tasksMenuAfterDeleteSeparator"/>
+ <menuitem id="menu_import" label="&importCmd.label;"
+ accesskey="&importCmd.accesskey;"
+ oncommand="toImport();"/>
+ <menuitem id="menu_export" label="&exportCmd.label;"
+ accesskey="&exportCmd.accesskey;"
+ oncommand="toExport();"/>
+ <menuitem id="manageKeysOpenPGP"
+ data-l10n-id="openpgp-manage-keys-openpgp-cmd"
+ oncommand="openKeyManager()"/>
+ <menu id="devtoolsMenu" label="&devtoolsMenu.label;" accesskey="&devtoolsMenu.accesskey;">
+ <menupopup id="devtoolsPopup">
+ <menuitem id="devtoolsToolbox"
+ label="&devToolboxCmd.label;"
+ accesskey="&devToolboxCmd.accesskey;"
+ key="key_devtoolsToolbox"
+ oncommand="BrowserToolboxLauncher.init();"/>
+ <menuitem id="addonDebugging"
+ label="&debugAddonsCmd.label;"
+ accesskey="&debugAddonsCmd.accesskey;"
+ oncommand="openAboutDebugging('addons')"/>
+ <menuseparator id="debuggingSeparator"/>
+ <menuitem id="javascriptConsole"
+ label="&errorConsoleCmd.label;"
+ accesskey="&errorConsoleCmd.accesskey;"
+ key="key_errorConsole"
+ oncommand="toJavaScriptConsole();"/>
+ </menupopup>
+ </menu>
+ <menuitem id="sanitizeHistory"
+ label="&clearRecentHistory.label;"
+ accesskey="&clearRecentHistory.accesskey;"
+ key="key_sanitizeHistory"
+ oncommand="toSanitize();"/>
+#ifndef XP_UNIX
+ <menuseparator id="prefSep"/>
+ <menuitem id="menu_preferences"
+ oncommand="openOptionsDialog()"
+ data-l10n-id="menu-tools-settings"/>
+ <menuitem id="menu_accountmgr"
+ label="&accountManagerCmd2.label;"
+ accesskey="&accountManagerCmd2.accesskey;"
+ oncommand="MsgAccountManager(null);"/>
+#else
+#ifdef XP_MACOSX
+ <menuseparator id="prefSep"/>
+ <menuitem id="menu_preferences"
+ data-l10n-id="menu-tools-settings"
+ key="key_preferencesCmdMac"
+ oncommand="openOptionsDialog()"/>
+ <menuitem id="menu_accountmgr"
+ label="&accountManagerCmd2.label;"
+ accesskey="&accountManagerCmd2.accesskey;"
+ oncommand="MsgAccountManager(null);"/>
+ <menuitem id="menu_mac_services"
+ label="&servicesMenuMac.label;"/>
+ <menuitem id="menu_mac_hide_app"
+ label="&hideThisAppCmdMac.label;"
+ key="key_hideThisAppCmdMac"/>
+ <menuitem id="menu_mac_hide_others"
+ label="&hideOtherAppsCmdMac.label;"
+ key="key_hideOtherAppsCmdMac"/>
+ <menuitem id="menu_mac_show_all"
+ label="&showAllAppsCmdMac.label;"/>
+#endif
+#endif
+ </menupopup>
+ </menu>
+
+#ifdef XP_MACOSX
+#include macWindowMenu.inc.xhtml
+#endif
+
+ <!-- Help -->
+#include helpMenu.inc.xhtml
+</menubar>
diff --git a/comm/mail/base/content/messenger-titlebar-items.inc.xhtml b/comm/mail/base/content/messenger-titlebar-items.inc.xhtml
new file mode 100644
index 0000000000..999ad00830
--- /dev/null
+++ b/comm/mail/base/content/messenger-titlebar-items.inc.xhtml
@@ -0,0 +1,24 @@
+# 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/.
+
+<hbox class="titlebar-buttonbox-container" skipintoolbarset="true">
+ <hbox class="titlebar-buttonbox titlebar-color">
+ <toolbarbutton class="titlebar-button titlebar-min"
+ titlebar-btn="min"
+ oncommand="window.minimize();"
+ data-l10n-id="messenger-window-minimize-button"/>
+ <toolbarbutton class="titlebar-button titlebar-max"
+ titlebar-btn="max"
+ oncommand="window.maximize();"
+ data-l10n-id="messenger-window-maximize-button"/>
+ <toolbarbutton class="titlebar-button titlebar-restore"
+ titlebar-btn="max"
+ oncommand="window.restore();"
+ data-l10n-id="messenger-window-restore-down-button"/>
+ <toolbarbutton class="titlebar-button titlebar-close"
+ titlebar-btn="close"
+ oncommand="window.close()"
+ data-l10n-id="messenger-window-close-button"/>
+ </hbox>
+</hbox>
diff --git a/comm/mail/base/content/messenger.js b/comm/mail/base/content/messenger.js
new file mode 100644
index 0000000000..6e11a3b538
--- /dev/null
+++ b/comm/mail/base/content/messenger.js
@@ -0,0 +1,1289 @@
+/**
+ * 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 ../../../mailnews/base/prefs/content/accountUtils.js */
+/* import-globals-from ../../components/addrbook/content/addressBookTab.js */
+/* import-globals-from ../../components/customizableui/content/panelUI.js */
+/* import-globals-from ../../components/newmailaccount/content/provisionerCheckout.js */
+/* import-globals-from ../../components/preferences/preferencesTab.js */
+/* import-globals-from glodaFacetTab.js */
+/* import-globals-from mailCore.js */
+/* import-globals-from mail-offline.js */
+/* import-globals-from mailTabs.js */
+/* import-globals-from mailWindowOverlay.js */
+/* import-globals-from messenger-customization.js */
+/* import-globals-from searchBar.js */
+/* import-globals-from spacesToolbar.js */
+/* import-globals-from specialTabs.js */
+/* import-globals-from toolbarIconColor.js */
+
+/* globals CreateMailWindowGlobals, InitMsgWindow, OnMailWindowUnload */ // From mailWindow.js
+
+/* globals loadCalendarComponent */
+
+ChromeUtils.import("resource:///modules/activity/activityModules.jsm");
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+var { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ Color: "resource://gre/modules/Color.sys.mjs",
+ ctypes: "resource://gre/modules/ctypes.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ BondOpenPGP: "chrome://openpgp/content/BondOpenPGP.jsm",
+ MailConsts: "resource:///modules/MailConsts.jsm",
+ MailUtils: "resource:///modules/MailUtils.jsm",
+ msgDBCacheManager: "resource:///modules/MsgDBCacheManager.jsm",
+ PeriodicFilterManager: "resource:///modules/PeriodicFilterManager.jsm",
+ SessionStoreManager: "resource:///modules/SessionStoreManager.jsm",
+});
+
+XPCOMUtils.defineLazyGetter(this, "PopupNotifications", function () {
+ let { PopupNotifications } = ChromeUtils.import(
+ "resource:///modules/GlobalPopupNotifications.jsm"
+ );
+ try {
+ // Hide all notifications while the URL is being edited and the address bar
+ // has focus, including the virtual focus in the results popup.
+ // We also have to hide notifications explicitly when the window is
+ // minimized because of the effects of the "noautohide" attribute on Linux.
+ // This can be removed once bug 545265 and bug 1320361 are fixed.
+ let shouldSuppress = () => window.windowState == window.STATE_MINIMIZED;
+ return new PopupNotifications(
+ document.getElementById("tabmail"),
+ document.getElementById("notification-popup"),
+ document.getElementById("notification-popup-box"),
+ { shouldSuppress }
+ );
+ } catch (ex) {
+ console.error(ex);
+ return null;
+ }
+});
+
+/**
+ * Gets the service pack and build information on Windows platforms. The initial version
+ * was copied from nsUpdateService.js.
+ *
+ * @returns An object containing the service pack major and minor versions, along with the
+ * build number.
+ */
+function getWindowsVersionInfo() {
+ const UNKNOWN_VERSION_INFO = {
+ servicePackMajor: null,
+ servicePackMinor: null,
+ buildNumber: null,
+ };
+
+ if (AppConstants.platform !== "win") {
+ return UNKNOWN_VERSION_INFO;
+ }
+
+ const BYTE = ctypes.uint8_t;
+ const WORD = ctypes.uint16_t;
+ const DWORD = ctypes.uint32_t;
+ const WCHAR = ctypes.char16_t;
+ const BOOL = ctypes.int;
+
+ // This structure is described at:
+ // http://msdn.microsoft.com/en-us/library/ms724833%28v=vs.85%29.aspx
+ const SZCSDVERSIONLENGTH = 128;
+ const OSVERSIONINFOEXW = new ctypes.StructType("OSVERSIONINFOEXW", [
+ { dwOSVersionInfoSize: DWORD },
+ { dwMajorVersion: DWORD },
+ { dwMinorVersion: DWORD },
+ { dwBuildNumber: DWORD },
+ { dwPlatformId: DWORD },
+ { szCSDVersion: ctypes.ArrayType(WCHAR, SZCSDVERSIONLENGTH) },
+ { wServicePackMajor: WORD },
+ { wServicePackMinor: WORD },
+ { wSuiteMask: WORD },
+ { wProductType: BYTE },
+ { wReserved: BYTE },
+ ]);
+
+ let kernel32 = ctypes.open("kernel32");
+ try {
+ let GetVersionEx = kernel32.declare(
+ "GetVersionExW",
+ ctypes.winapi_abi,
+ BOOL,
+ OSVERSIONINFOEXW.ptr
+ );
+ let winVer = OSVERSIONINFOEXW();
+ winVer.dwOSVersionInfoSize = OSVERSIONINFOEXW.size;
+
+ if (0 === GetVersionEx(winVer.address())) {
+ throw new Error("Failure in GetVersionEx (returned 0)");
+ }
+
+ return {
+ servicePackMajor: winVer.wServicePackMajor,
+ servicePackMinor: winVer.wServicePackMinor,
+ buildNumber: winVer.dwBuildNumber,
+ };
+ } catch (e) {
+ return UNKNOWN_VERSION_INFO;
+ } finally {
+ kernel32.close();
+ }
+}
+
+/* This is where functions related to the 3 pane window are kept */
+
+// from MailNewsTypes.h
+var kMailCheckOncePrefName = "mail.startup.enabledMailCheckOnce";
+
+/**
+ * Tracks whether the right mouse button changed the selection or not. If the
+ * user right clicks on the selection, it stays the same. If they click outside
+ * of it, we alter the selection (but not the current index) to be the row they
+ * clicked on.
+ *
+ * The value of this variable is an object with "view" and "selection" keys
+ * and values. The view value is the view whose selection we saved off, and
+ * the selection value is the selection object we saved off.
+ */
+var gRightMouseButtonSavedSelection = null;
+var gNewAccountToLoad = null;
+
+// The object in charge of managing the mail summary pane
+var gSummaryFrameManager;
+
+/**
+ * Called on startup if there are no accounts.
+ */
+function verifyOpenAccountHubTab() {
+ let suppressDialogs = Services.prefs.getBoolPref(
+ "mail.provider.suppress_dialog_on_startup",
+ false
+ );
+
+ if (suppressDialogs) {
+ // Looks like we were in the middle of filling out an account form. We
+ // won't display the dialogs in that case.
+ Services.prefs.clearUserPref("mail.provider.suppress_dialog_on_startup");
+ loadPostAccountWizard();
+ return;
+ }
+
+ openAccountSetupTab();
+}
+
+let _resolveDelayedStartup;
+var delayedStartupPromise = new Promise(resolve => {
+ _resolveDelayedStartup = resolve;
+});
+
+var gMailInit = {
+ onBeforeInitialXULLayout() {
+ // Set a sane starting width/height for all resolutions on new profiles.
+ // Do this before the window loads.
+ if (!document.documentElement.hasAttribute("width")) {
+ const TARGET_WIDTH = 1280;
+ let defaultWidth = Math.min(screen.availWidth * 0.9, TARGET_WIDTH);
+ let defaultHeight = screen.availHeight;
+
+ document.documentElement.setAttribute("width", defaultWidth);
+ document.documentElement.setAttribute("height", defaultHeight);
+
+ // On small screens, default to maximized state.
+ if (defaultWidth < TARGET_WIDTH) {
+ document.documentElement.setAttribute("sizemode", "maximized");
+ }
+ // Make sure we're safe at the left/top edge of screen
+ document.documentElement.setAttribute("screenX", screen.availLeft);
+ document.documentElement.setAttribute("screenY", screen.availTop);
+ }
+
+ // Run menubar initialization first, to avoid TabsInTitlebar code picking
+ // up mutations from it and causing a reflow.
+ AutoHideMenubar.init();
+ TabsInTitlebar.init();
+
+ if (AppConstants.platform == "win") {
+ // On Win8 set an attribute when the window frame color is too dark for black text.
+ if (
+ window.matchMedia("(-moz-platform: windows-win8)").matches &&
+ window.matchMedia("(-moz-windows-default-theme)").matches
+ ) {
+ let { Windows8WindowFrameColor } = ChromeUtils.importESModule(
+ "resource:///modules/Windows8WindowFrameColor.sys.mjs"
+ );
+ let windowFrameColor = new Color(...Windows8WindowFrameColor.get());
+ // Default to black for foreground text.
+ if (!windowFrameColor.isContrastRatioAcceptable(new Color(0, 0, 0))) {
+ document.documentElement.setAttribute("darkwindowframe", "true");
+ }
+ } else if (AppConstants.isPlatformAndVersionAtLeast("win", "10")) {
+ // 17763 is the build number of Windows 10 version 1809
+ if (getWindowsVersionInfo().buildNumber < 17763) {
+ document.documentElement.setAttribute(
+ "always-use-accent-color-for-window-border",
+ ""
+ );
+ }
+ }
+ }
+
+ // Call this after we set attributes that might change toolbars' computed
+ // text color.
+ ToolbarIconColor.init();
+ },
+
+ /**
+ * Called on startup to initialize various parts of the main window.
+ * Most of this should be moved out into _delayedStartup or only
+ * initialized when needed.
+ */
+ onLoad() {
+ CreateMailWindowGlobals();
+
+ if (!Services.policies.isAllowed("devtools")) {
+ let devtoolsMenu = document.getElementById("devtoolsMenu");
+ if (devtoolsMenu) {
+ devtoolsMenu.hidden = true;
+ }
+ }
+
+ // - initialize tabmail system
+ // Do this before loadPostAccountWizard since that code selects the first
+ // folder for display, and we want gFolderDisplay setup and ready to handle
+ // that event chain.
+ // Also, we definitely need to register the tab type prior to the call to
+ // specialTabs.openSpecialTabsOnStartup below.
+ let tabmail = document.getElementById("tabmail");
+ if (tabmail) {
+ // mailTabType is defined in mailTabs.js
+ tabmail.registerTabType(mailTabType);
+ // glodaFacetTab* in glodaFacetTab.js
+ tabmail.registerTabType(glodaFacetTabType);
+ tabmail.registerTabMonitor(GlodaSearchBoxTabMonitor);
+ tabmail.openFirstTab();
+ }
+
+ // This also registers the contentTabType ("contentTab")
+ specialTabs.openSpecialTabsOnStartup();
+ tabmail.registerTabType(addressBookTabType);
+ tabmail.registerTabType(preferencesTabType);
+ // provisionerCheckoutTabType is defined in provisionerCheckout.js
+ tabmail.registerTabType(provisionerCheckoutTabType);
+
+ // Depending on the pref, hide/show the gloda toolbar search widgets.
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "gGlodaEnabled",
+ "mailnews.database.global.indexer.enabled",
+ true,
+ (pref, oldVal, newVal) => {
+ for (let widget of document.querySelectorAll(".gloda-search-widget")) {
+ widget.hidden = !newVal;
+ }
+ }
+ );
+ for (let widget of document.querySelectorAll(".gloda-search-widget")) {
+ widget.hidden = !this.gGlodaEnabled;
+ }
+
+ window.addEventListener("AppCommand", HandleAppCommandEvent, true);
+
+ this._boundDelayedStartup = this._delayedStartup.bind(this);
+ window.addEventListener("MozAfterPaint", this._boundDelayedStartup);
+
+ // Listen for the messages sent to the main 3 pane window.
+ window.addEventListener("message", this._onMessageReceived);
+ },
+
+ _cancelDelayedStartup() {
+ window.removeEventListener("MozAfterPaint", this._boundDelayedStartup);
+ this._boundDelayedStartup = null;
+ },
+
+ /**
+ * Handle the messages sent via postMessage() method to the main 3 pane
+ * window.
+ *
+ * @param {Event} event - The message event.
+ */
+ _onMessageReceived(event) {
+ switch (event.data) {
+ case "account-created":
+ case "account-created-in-backend":
+ case "account-created-from-provisioner":
+ // Set the pref to false in case it was previously changed.
+ Services.prefs.setBoolPref("app.use_without_mail_account", false);
+ loadPostAccountWizard();
+
+ // Always update the mail UI to guarantee all the panes are visible even
+ // if the mail tab is not the currently active tab.
+ updateMailPaneUI();
+ break;
+
+ case "account-setup-closed":
+ // The user closed the account setup after a successful run. Make sure
+ // to focus on the primary mail tab.
+ switchToMailTab();
+ gSpacesToolbar.onLoad();
+ // Trigger the integration dialog if necessary.
+ showSystemIntegrationDialog();
+ break;
+
+ case "account-setup-dismissed":
+ // The user closed the account setup before completing it. Be sure to
+ // initialize the few important areas we need.
+ if (!gSpacesToolbar.isLoaded) {
+ loadPostAccountWizard();
+ }
+ break;
+
+ case "open-account-setup-tab":
+ openAccountSetupTab();
+ break;
+ default:
+ break;
+ }
+ },
+
+ /**
+ * Delayed startup happens after the first paint of the window. Anything
+ * that can be delayed until after paint, should be to help give the
+ * illusion that Thunderbird is starting faster.
+ *
+ * Note: this only runs for the main 3 pane window.
+ */
+ _delayedStartup() {
+ this._cancelDelayedStartup();
+
+ MailOfflineMgr.init();
+
+ BondOpenPGP.init();
+
+ PanelUI.init();
+ gExtensionsNotifications.init();
+
+ Services.search.init();
+
+ PeriodicFilterManager.setupFiltering();
+ msgDBCacheManager.init();
+
+ this.delayedStartupFinished = true;
+ _resolveDelayedStartup(window);
+ Services.obs.notifyObservers(window, "browser-delayed-startup-finished");
+
+ // Notify observer to resolve the browserStartupPromise, which is used for the
+ // delayed background startup of WebExtensions.
+ Services.obs.notifyObservers(window, "extensions-late-startup");
+
+ this._loadComponentsAtStartup();
+ },
+
+ /**
+ * Load all the necessary components to make Thunderbird usable before
+ * checking for existing accounts.
+ */
+ async _loadComponentsAtStartup() {
+ updateTroubleshootMenuItem();
+ // The calendar component needs to be loaded before restoring any tabs.
+ await loadCalendarComponent();
+
+ // Don't trigger the existing account verification if the user wants to use
+ // Thunderbird without an email account.
+ if (!Services.prefs.getBoolPref("app.use_without_mail_account", false)) {
+ // Load the Mail UI only if we already have at least one account configured
+ // otherwise the verifyExistingAccounts will trigger the account wizard.
+ if (verifyExistingAccounts()) {
+ switchToMailTab();
+ await loadPostAccountWizard();
+ }
+ } else {
+ // Run the tabs restore method here since we're skipping the loading of
+ // the Mail UI which would have taken care of this to properly handle
+ // opened folders or messages in tabs.
+ await atStartupRestoreTabs(false);
+ gSpacesToolbar.onLoad();
+ }
+
+ // Show the end of year donation appeal page.
+ if (this.shouldShowEOYDonationAppeal()) {
+ // Add a timeout to prevent opening the browser immediately at startup.
+ setTimeout(this.showEOYDonationAppeal, 2000);
+ }
+ },
+
+ /**
+ * Called by messenger.xhtml:onunload, the 3-pane window inside of tabs window.
+ * It's being unloaded! Right now!
+ */
+ onUnload() {
+ Services.obs.notifyObservers(window, "mail-unloading-messenger");
+
+ if (gRightMouseButtonSavedSelection) {
+ // Avoid possible cycle leaks.
+ gRightMouseButtonSavedSelection.view = null;
+ gRightMouseButtonSavedSelection = null;
+ }
+
+ SessionStoreManager.unloadingWindow(window);
+ TabsInTitlebar.uninit();
+ ToolbarIconColor.uninit();
+ gSpacesToolbar.onUnload();
+
+ document.getElementById("tabmail")._teardown();
+
+ OnMailWindowUnload();
+ },
+
+ /**
+ * Check if we can trigger the opening of the donation appeal page.
+ *
+ * @returns {boolean} - True if the donation appeal page should be opened.
+ */
+ shouldShowEOYDonationAppeal() {
+ let currentEOY = Services.prefs.getIntPref("app.donation.eoy.version", 1);
+ let viewedEOY = Services.prefs.getIntPref(
+ "app.donation.eoy.version.viewed",
+ 0
+ );
+
+ // True if the user never saw the donation appeal, this is not a new
+ // profile (since users are already prompted to donate after downloading),
+ // and we're not running tests.
+ return (
+ viewedEOY < currentEOY &&
+ !specialTabs.shouldShowPolicyNotification() &&
+ !Cu.isInAutomation
+ );
+ },
+
+ /**
+ * Open the end of year appeal in a new web browser page. We don't open this
+ * in a tab due to the complexity of the donation site, and we don't want to
+ * handle that inside Thunderbird.
+ */
+ showEOYDonationAppeal() {
+ let url = Services.prefs.getStringPref("app.donation.eoy.url");
+ let protocolSvc = Cc[
+ "@mozilla.org/uriloader/external-protocol-service;1"
+ ].getService(Ci.nsIExternalProtocolService);
+ protocolSvc.loadURI(Services.io.newURI(url));
+
+ let currentEOY = Services.prefs.getIntPref("app.donation.eoy.version", 1);
+ Services.prefs.setIntPref("app.donation.eoy.version.viewed", currentEOY);
+ },
+};
+
+/**
+ * Called at startup to verify if we have ny existing account, even if invalid,
+ * and if not, it will trigger the Account Hub in a tab.
+ *
+ * @returns {boolean} - True if we have at least one existing account.
+ */
+function verifyExistingAccounts() {
+ try {
+ // Migrate quoting preferences from global to per account. This function
+ // returns true if it had to migrate, which we will use to mean this is a
+ // just migrated or new profile.
+ let newProfile = migrateGlobalQuotingPrefs(
+ MailServices.accounts.allIdentities
+ );
+
+ // If there are no accounts, or all accounts are "invalid" then kick off the
+ // account migration. Or if this is a new (to Mozilla) profile. MCD can set
+ // up accounts without the profile being used yet.
+ if (newProfile) {
+ // Check if MCD is configured. If not, say this is not a new profile so
+ // that we don't accidentally remigrate non MCD profiles.
+ var adminUrl = Services.prefs.getCharPref(
+ "autoadmin.global_config_url",
+ ""
+ );
+ if (!adminUrl) {
+ newProfile = false;
+ }
+ }
+
+ let accounts = MailServices.accounts.accounts;
+ let invalidAccounts = getInvalidAccounts(accounts);
+ // Trigger the new account configuration wizard only if we don't have any
+ // existing account, not even if we have at least one invalid account.
+ if (
+ (newProfile && !accounts.length) ||
+ accounts.length == invalidAccounts.length ||
+ (invalidAccounts.length > 0 &&
+ invalidAccounts.length == accounts.length &&
+ invalidAccounts[0])
+ ) {
+ verifyOpenAccountHubTab();
+ return false;
+ }
+
+ let localFoldersExists;
+ try {
+ localFoldersExists = MailServices.accounts.localFoldersServer;
+ } catch (ex) {
+ localFoldersExists = false;
+ }
+
+ // We didn't trigger the account configuration wizard, so we need to verify
+ // that local folders exists.
+ if (!localFoldersExists && requireLocalFoldersAccount()) {
+ MailServices.accounts.createLocalMailAccount();
+ }
+
+ return true;
+ } catch (ex) {
+ dump(`Error verifying accounts: ${ex}`);
+ return false;
+ }
+}
+
+/**
+ * Switch the view to the first Mail tab if the currently selected tab is not
+ * the first Mail tab.
+ */
+function switchToMailTab() {
+ let tabmail = document.getElementById("tabmail");
+ if (tabmail?.selectedTab.mode.name != "folder") {
+ tabmail.switchToTab(0);
+ }
+}
+
+/**
+ * Trigger the initialization of the entire UI. Called after the okCallback of
+ * the emailWizard during a first run, or directly from the accountProvisioner
+ * in case a user configures a new email account on first run.
+ */
+async function loadPostAccountWizard() {
+ InitMsgWindow();
+
+ MigrateJunkMailSettings();
+ MigrateFolderViews();
+ MigrateOpenMessageBehavior();
+
+ MailServices.accounts.setSpecialFolders();
+
+ try {
+ MailServices.accounts.loadVirtualFolders();
+ } catch (e) {
+ console.error(e);
+ }
+
+ // Init the mozINewMailListener service (MailNotificationManager) before
+ // any new mails are fetched.
+ // MailNotificationManager triggers mozINewMailNotificationService
+ // init as well.
+ Cc["@mozilla.org/mail/notification-manager;1"].getService(
+ Ci.mozINewMailListener
+ );
+
+ // Restore the previous folder selection before shutdown, or select the first
+ // inbox folder of a newly created account.
+ await selectFirstFolder();
+
+ gSpacesToolbar.onLoad();
+}
+
+/**
+ * Check if we need to show the system integration dialog before notifying the
+ * application that the startup process is completed.
+ */
+function showSystemIntegrationDialog() {
+ // Check the shell service.
+ let shellService;
+ try {
+ shellService = Cc["@mozilla.org/mail/shell-service;1"].getService(
+ Ci.nsIShellService
+ );
+ } catch (ex) {}
+ let defaultAccount = MailServices.accounts.defaultAccount;
+
+ // Load the search integration module.
+ let { SearchIntegration } = ChromeUtils.import(
+ "resource:///modules/SearchIntegration.jsm"
+ );
+
+ // Show the default client dialog only if
+ // EITHER: we have at least one account, and we aren't already the default
+ // for mail,
+ // OR: we have the search integration module, the OS version is suitable,
+ // and the first run hasn't already been completed.
+ // Needs to be shown outside the he normal load sequence so it doesn't appear
+ // before any other displays, in the wrong place of the screen.
+ if (
+ (shellService &&
+ defaultAccount &&
+ shellService.shouldCheckDefaultClient &&
+ !shellService.isDefaultClient(true, Ci.nsIShellService.MAIL)) ||
+ (SearchIntegration &&
+ !SearchIntegration.osVersionTooLow &&
+ !SearchIntegration.osComponentsNotRunning &&
+ !SearchIntegration.firstRunDone)
+ ) {
+ window.openDialog(
+ "chrome://messenger/content/systemIntegrationDialog.xhtml",
+ "SystemIntegration",
+ "modal,centerscreen,chrome,resizable=no"
+ );
+ // On Windows, there seems to be a delay between setting TB as the
+ // default client, and the isDefaultClient check succeeding.
+ if (shellService.isDefaultClient(true, Ci.nsIShellService.MAIL)) {
+ Services.obs.notifyObservers(window, "mail:setAsDefault");
+ }
+ }
+}
+
+/**
+ * Properly select the starting folder or message header if we have one.
+ */
+async function selectFirstFolder() {
+ let startFolderURI = null;
+ let startMsgHdr = null;
+
+ if ("arguments" in window && window.arguments.length > 0) {
+ let arg0 = window.arguments[0];
+ // If the argument is a string, it is folder URI.
+ if (typeof arg0 == "string") {
+ startFolderURI = arg0;
+ } else if (arg0) {
+ // arg0 is an object
+ if ("wrappedJSObject" in arg0 && arg0.wrappedJSObject) {
+ arg0 = arg0.wrappedJSObject;
+ }
+ startMsgHdr = "msgHdr" in arg0 ? arg0.msgHdr : null;
+ }
+ }
+
+ // Don't try to be smart with this because we need the loadStartFolder()
+ // method to run even if startFolderURI is null otherwise our UI won't
+ // properly restore.
+ if (startMsgHdr) {
+ await loadStartMsgHdr(startMsgHdr);
+ } else {
+ await loadStartFolder(startFolderURI);
+ }
+}
+
+function HandleAppCommandEvent(evt) {
+ evt.stopPropagation();
+ switch (evt.command) {
+ case "Back":
+ goDoCommand("cmd_goBack");
+ break;
+ case "Forward":
+ goDoCommand("cmd_goForward");
+ break;
+ case "Stop":
+ msgWindow.StopUrls();
+ break;
+ case "Bookmarks":
+ toAddressBook();
+ break;
+ case "Home":
+ case "Reload":
+ default:
+ break;
+ }
+}
+
+/**
+ * Called by the session store manager periodically and at shutdown to get
+ * the state of this window for persistence.
+ */
+function getWindowStateForSessionPersistence() {
+ let tabmail = document.getElementById("tabmail");
+ let tabsState = tabmail.persistTabs();
+ return { type: "3pane", tabs: tabsState };
+}
+
+/**
+ * Attempt to restore the previous tab states.
+ *
+ * @param {boolean} aDontRestoreFirstTab - If this is true, the first tab will
+ * not be restored, and will continue to retain focus at the end. This is
+ * needed if the window was opened with a folder or a message as an argument.
+ * @returns true if the restoration was successful, false otherwise.
+ */
+async function atStartupRestoreTabs(aDontRestoreFirstTab) {
+ let state = await SessionStoreManager.loadingWindow(window);
+ if (state) {
+ let tabsState = state.tabs;
+ let tabmail = document.getElementById("tabmail");
+ try {
+ tabmail.restoreTabs(tabsState, aDontRestoreFirstTab);
+ } catch (e) {
+ console.error(e);
+ }
+ }
+
+ // It's now safe to load extra Tabs.
+ loadExtraTabs();
+
+ // Note: The tabs have not finished loading at this point.
+ SessionStoreManager._restored = true;
+ Services.obs.notifyObservers(window, "mail-tabs-session-restored");
+
+ return !!state;
+}
+
+/**
+ * Loads and restores tabs upon opening a window by evaluating window.arguments[1].
+ *
+ * The type of the object is specified by it's action property. It can be
+ * either "restore" or "open". "restore" invokes tabmail.restoreTab() for each
+ * item in the tabs array. While "open" invokes tabmail.openTab() for each item.
+ *
+ * In case a tab can't be restored it will fail silently
+ *
+ * the object need at least the following properties:
+ *
+ * {
+ * action = "restore" | "open"
+ * tabs = [];
+ * }
+ *
+ */
+function loadExtraTabs() {
+ if (!("arguments" in window) || window.arguments.length < 2) {
+ return;
+ }
+
+ let tab = window.arguments[1];
+ if (!tab || typeof tab != "object") {
+ return;
+ }
+
+ if ("wrappedJSObject" in tab) {
+ tab = tab.wrappedJSObject;
+ }
+
+ let tabmail = document.getElementById("tabmail");
+
+ // we got no action, so suppose its "legacy" code
+ if (!("action" in tab)) {
+ if ("tabType" in tab) {
+ tabmail.openTab(tab.tabType, tab.tabParams);
+ }
+ return;
+ }
+
+ if (!("tabs" in tab)) {
+ return;
+ }
+
+ // this is used if a tab is detached to a new window.
+ if (tab.action == "restore") {
+ for (let i = 0; i < tab.tabs.length; i++) {
+ tabmail.restoreTab(tab.tabs[i]);
+ }
+
+ // we currently do not support opening in background or opening a
+ // special position. So select the last tab opened.
+ tabmail.switchToTab(tabmail.tabInfo[tabmail.tabInfo.length - 1]);
+ return;
+ }
+
+ if (tab.action == "open") {
+ for (let i = 0; i < tab.tabs.length; i++) {
+ if ("tabType" in tab.tabs[i]) {
+ tabmail.openTab(tab.tabs[i].tabType, tab.tabs[i].tabParams);
+ }
+ }
+ }
+}
+
+/**
+ * Loads the given message header at window open. Exactly one out of this and
+ * |loadStartFolder| should be called.
+ *
+ * @param aStartMsgHdr The message header to load at window open
+ */
+async function loadStartMsgHdr(aStartMsgHdr) {
+ // We'll just clobber the default tab
+ await atStartupRestoreTabs(true);
+
+ MsgDisplayMessageInFolderTab(aStartMsgHdr);
+}
+
+async function loadStartFolder(initialUri) {
+ var defaultServer = null;
+ var startFolder;
+ var isLoginAtStartUpEnabled = false;
+
+ // If a URI was explicitly specified, we'll just clobber the default tab
+ let loadFolder = !(await atStartupRestoreTabs(!!initialUri));
+
+ if (initialUri) {
+ loadFolder = true;
+ }
+
+ // First get default account
+ try {
+ if (initialUri) {
+ startFolder = MailUtils.getOrCreateFolder(initialUri);
+ } else {
+ let defaultAccount = MailServices.accounts.defaultAccount;
+ if (!defaultAccount) {
+ return;
+ }
+
+ defaultServer = defaultAccount.incomingServer;
+ var rootMsgFolder = defaultServer.rootMsgFolder;
+
+ startFolder = rootMsgFolder;
+
+ // Enable check new mail once by turning checkmail pref 'on' to bring
+ // all users to one plane. This allows all users to go to Inbox. User can
+ // always go to server settings panel and turn off "Check for new mail at startup"
+ if (!Services.prefs.getBoolPref(kMailCheckOncePrefName)) {
+ Services.prefs.setBoolPref(kMailCheckOncePrefName, true);
+ defaultServer.loginAtStartUp = true;
+ }
+
+ // Get the user pref to see if the login at startup is enabled for default account
+ isLoginAtStartUpEnabled = defaultServer.loginAtStartUp;
+
+ // Get Inbox only if login at startup is enabled.
+ if (isLoginAtStartUpEnabled) {
+ // now find Inbox
+ var inboxFolder = rootMsgFolder.getFolderWithFlags(
+ Ci.nsMsgFolderFlags.Inbox
+ );
+ if (!inboxFolder) {
+ return;
+ }
+
+ startFolder = inboxFolder;
+ }
+ }
+
+ // it is possible we were given an initial uri and we need to subscribe or try to add
+ // the folder. i.e. the user just clicked on a news folder they aren't subscribed to from a browser
+ // the news url comes in here.
+
+ // Perform biff on the server to check for new mail, except for imap
+ // or a pop3 account that is deferred or deferred to,
+ // or the case where initialUri is non-null (non-startup)
+ if (
+ !initialUri &&
+ isLoginAtStartUpEnabled &&
+ !defaultServer.isDeferredTo &&
+ defaultServer.rootFolder == defaultServer.rootMsgFolder
+ ) {
+ defaultServer.performBiff(msgWindow);
+ }
+ if (loadFolder) {
+ let tab = document.getElementById("tabmail")?.tabInfo[0];
+ tab.chromeBrowser.addEventListener(
+ "load",
+ () => (tab.folder = startFolder),
+ true
+ );
+ }
+ } catch (ex) {
+ console.error(ex);
+ }
+
+ MsgGetMessagesForAllServers(defaultServer);
+
+ if (MailOfflineMgr.isOnline()) {
+ // Check if we shut down offline, and restarted online, in which case
+ // we may have offline events to playback. Since this is not a pref
+ // the user should set, it's not in mailnews.js, so we need a try catch.
+ let playbackOfflineEvents = Services.prefs.getBoolPref(
+ "mailnews.playback_offline",
+ false
+ );
+ if (playbackOfflineEvents) {
+ Services.prefs.setBoolPref("mailnews.playback_offline", false);
+ MailOfflineMgr.offlineManager.goOnline(false, true, msgWindow);
+ }
+
+ // If appropriate, send unsent messages. This may end up prompting the user,
+ // so we need to get it out of the flow of the normal load sequence.
+ setTimeout(function () {
+ if (MailOfflineMgr.shouldSendUnsentMessages()) {
+ SendUnsentMessages();
+ }
+ }, 0);
+ }
+}
+
+function OpenMessageInNewTab(msgHdr, tabParams = {}) {
+ if (!msgHdr) {
+ return;
+ }
+
+ if (tabParams.background === undefined) {
+ tabParams.background = Services.prefs.getBoolPref(
+ "mail.tabs.loadInBackground"
+ );
+ if (tabParams.event?.shiftKey) {
+ tabParams.background = !tabParams.background;
+ }
+ }
+
+ let tabmail = document.getElementById("tabmail");
+ tabmail.openTab("mailMessageTab", {
+ ...tabParams,
+ messageURI: msgHdr.folder.getUriForMsg(msgHdr),
+ });
+}
+
+function GetSelectedMsgFolders() {
+ let tabInfo = document.getElementById("tabmail").currentTabInfo;
+ if (tabInfo.mode.name == "mail3PaneTab") {
+ let folder = tabInfo.folder;
+ if (folder) {
+ return [folder];
+ }
+ }
+ return [];
+}
+
+function SelectFolder(folderUri) {
+ // TODO: Replace this.
+}
+
+function ReloadMessage() {}
+
+// Some of the per account junk mail settings have been
+// converted to global prefs. Let's try to migrate some
+// of those settings from the default account.
+function MigrateJunkMailSettings() {
+ var junkMailSettingsVersion = Services.prefs.getIntPref("mail.spam.version");
+ if (!junkMailSettingsVersion) {
+ // Get the default account, check to see if we have values for our
+ // globally migrated prefs.
+ let defaultAccount = MailServices.accounts.defaultAccount;
+ if (defaultAccount) {
+ // we only care about
+ var prefix = "mail.server." + defaultAccount.incomingServer.key + ".";
+ if (Services.prefs.prefHasUserValue(prefix + "manualMark")) {
+ Services.prefs.setBoolPref(
+ "mail.spam.manualMark",
+ Services.prefs.getBoolPref(prefix + "manualMark")
+ );
+ }
+ if (Services.prefs.prefHasUserValue(prefix + "manualMarkMode")) {
+ Services.prefs.setIntPref(
+ "mail.spam.manualMarkMode",
+ Services.prefs.getIntPref(prefix + "manualMarkMode")
+ );
+ }
+ if (Services.prefs.prefHasUserValue(prefix + "spamLoggingEnabled")) {
+ Services.prefs.setBoolPref(
+ "mail.spam.logging.enabled",
+ Services.prefs.getBoolPref(prefix + "spamLoggingEnabled")
+ );
+ }
+ if (Services.prefs.prefHasUserValue(prefix + "markAsReadOnSpam")) {
+ Services.prefs.setBoolPref(
+ "mail.spam.markAsReadOnSpam",
+ Services.prefs.getBoolPref(prefix + "markAsReadOnSpam")
+ );
+ }
+ }
+ // bump the version so we don't bother doing this again.
+ Services.prefs.setIntPref("mail.spam.version", 1);
+ }
+}
+
+// The first time a user runs a build that supports folder views, pre-populate the favorite folders list
+// with the existing INBOX folders.
+function MigrateFolderViews() {
+ var folderViewsVersion = Services.prefs.getIntPref(
+ "mail.folder.views.version"
+ );
+ if (!folderViewsVersion) {
+ for (let server of MailServices.accounts.allServers) {
+ if (server) {
+ let inbox = MailUtils.getInboxFolder(server);
+ if (inbox) {
+ inbox.setFlag(Ci.nsMsgFolderFlags.Favorite);
+ }
+ }
+ }
+ Services.prefs.setIntPref("mail.folder.views.version", 1);
+ }
+}
+
+// Do a one-time migration of the old mailnews.reuse_message_window pref to the
+// newer mail.openMessageBehavior. This does the migration only if the old pref
+// is defined.
+function MigrateOpenMessageBehavior() {
+ let openMessageBehaviorVersion = Services.prefs.getIntPref(
+ "mail.openMessageBehavior.version"
+ );
+ if (!openMessageBehaviorVersion) {
+ // Don't touch this if it isn't defined
+ if (
+ Services.prefs.getPrefType("mailnews.reuse_message_window") ==
+ Ci.nsIPrefBranch.PREF_BOOL
+ ) {
+ if (Services.prefs.getBoolPref("mailnews.reuse_message_window")) {
+ Services.prefs.setIntPref(
+ "mail.openMessageBehavior",
+ MailConsts.OpenMessageBehavior.EXISTING_WINDOW
+ );
+ } else {
+ Services.prefs.setIntPref(
+ "mail.openMessageBehavior",
+ MailConsts.OpenMessageBehavior.NEW_TAB
+ );
+ }
+ }
+
+ Services.prefs.setIntPref("mail.openMessageBehavior.version", 1);
+ }
+}
+
+function messageFlavorDataProvider() {}
+
+messageFlavorDataProvider.prototype = {
+ QueryInterface: ChromeUtils.generateQI(["nsIFlavorDataProvider"]),
+
+ getFlavorData(aTransferable, aFlavor, aData) {
+ if (aFlavor !== "application/x-moz-file-promise") {
+ return;
+ }
+ let fileUriPrimitive = {};
+ aTransferable.getTransferData(
+ "application/x-moz-file-promise-url",
+ fileUriPrimitive
+ );
+
+ let fileUriStr = fileUriPrimitive.value.QueryInterface(
+ Ci.nsISupportsString
+ );
+ let fileUri = Services.io.newURI(fileUriStr.data);
+ let fileUrl = fileUri.QueryInterface(Ci.nsIURL);
+ let fileName = fileUrl.fileName.replace(/(.{74}).*(.{10})$/u, "$1...$2");
+
+ let destDirPrimitive = {};
+ aTransferable.getTransferData(
+ "application/x-moz-file-promise-dir",
+ destDirPrimitive
+ );
+ let destDirectory = destDirPrimitive.value.QueryInterface(Ci.nsIFile);
+ let file = destDirectory.clone();
+ file.append(fileName);
+
+ let messageUriPrimitive = {};
+ aTransferable.getTransferData("text/x-moz-message", messageUriPrimitive);
+ let messageUri = messageUriPrimitive.value.QueryInterface(
+ Ci.nsISupportsString
+ );
+
+ let messenger = Cc["@mozilla.org/messenger;1"].createInstance(
+ Ci.nsIMessenger
+ );
+ messenger.saveAs(
+ messageUri.data,
+ true,
+ null,
+ decodeURIComponent(file.path),
+ true
+ );
+ },
+};
+
+var TabsInTitlebar = {
+ init() {
+ this._readPref();
+ Services.prefs.addObserver(this._drawInTitlePref, this);
+
+ window.addEventListener("resolutionchange", this);
+ window.addEventListener("resize", this);
+
+ this._initialized = true;
+ this.update();
+ },
+
+ allowedBy(condition, allow) {
+ if (allow) {
+ if (condition in this._disallowed) {
+ delete this._disallowed[condition];
+ this.update();
+ }
+ } else if (!(condition in this._disallowed)) {
+ this._disallowed[condition] = null;
+ this.update();
+ }
+ },
+
+ get systemSupported() {
+ let isSupported = false;
+ switch (AppConstants.MOZ_WIDGET_TOOLKIT) {
+ case "windows":
+ case "cocoa":
+ isSupported = true;
+ break;
+ case "gtk":
+ isSupported = window.matchMedia("(-moz-gtk-csd-available)");
+ break;
+ }
+ delete this.systemSupported;
+ return (this.systemSupported = isSupported);
+ },
+
+ get enabled() {
+ return document.documentElement.getAttribute("tabsintitlebar") == "true";
+ },
+
+ observe(subject, topic, data) {
+ if (topic == "nsPref:changed") {
+ this._readPref();
+ }
+ },
+
+ handleEvent(aEvent) {
+ switch (aEvent.type) {
+ case "resolutionchange":
+ if (aEvent.target == window) {
+ this.update();
+ }
+ break;
+ case "resize":
+ // The spaces toolbar needs special styling for the fullscreen mode.
+ gSpacesToolbar.onWindowResize();
+ if (window.fullScreen || aEvent.target != window) {
+ break;
+ }
+ // We use resize events because the window is not ready after
+ // sizemodechange events. However, we only care about the event when
+ // the sizemode is different from the last time we updated the
+ // appearance of the tabs in the titlebar.
+ let sizemode = document.documentElement.getAttribute("sizemode");
+ if (this._lastSizeMode == sizemode) {
+ break;
+ }
+ let oldSizeMode = this._lastSizeMode;
+ this._lastSizeMode = sizemode;
+ // Don't update right now if we are leaving fullscreen, since the UI is
+ // still changing in the consequent "fullscreen" event. Code there will
+ // call this function again when everything is ready.
+ // See browser-fullScreen.js: FullScreen.toggle and bug 1173768.
+ if (oldSizeMode == "fullscreen") {
+ break;
+ }
+ this.update();
+ break;
+ }
+ },
+
+ _initialized: false,
+ _disallowed: {},
+ _drawInTitlePref: "mail.tabs.drawInTitlebar",
+ _lastSizeMode: null,
+
+ _readPref() {
+ // check is only true when drawInTitlebar=true
+ let check = Services.prefs.getBoolPref(this._drawInTitlePref);
+ this.allowedBy("pref", check);
+ },
+
+ update() {
+ if (!this._initialized || window.fullScreen) {
+ return;
+ }
+
+ let allowed =
+ this.systemSupported && Object.keys(this._disallowed).length == 0;
+
+ if (
+ document.documentElement.getAttribute("chromehidden")?.includes("toolbar")
+ ) {
+ // Don't draw in titlebar in case of a popup window.
+ allowed = false;
+ }
+
+ if (allowed) {
+ document.documentElement.setAttribute("tabsintitlebar", "true");
+ if (AppConstants.platform == "macosx") {
+ document.documentElement.setAttribute("chromemargin", "0,-1,-1,-1");
+ document.documentElement.removeAttribute("drawtitle");
+ } else {
+ document.documentElement.setAttribute("chromemargin", "0,2,2,2");
+ }
+ } else {
+ document.documentElement.removeAttribute("tabsintitlebar");
+ document.documentElement.removeAttribute("chromemargin");
+ if (AppConstants.platform == "macosx") {
+ document.documentElement.setAttribute("drawtitle", "true");
+ }
+ }
+ },
+
+ uninit() {
+ this._initialized = false;
+ Services.prefs.removeObserver(this._drawInTitlePref, this);
+ },
+};
+
+var BrowserAddonUI = {
+ async promptRemoveExtension(addon) {
+ let { name } = addon;
+ let [title, btnTitle] = await document.l10n.formatValues([
+ {
+ id: "addon-removal-title",
+ args: { name },
+ },
+ {
+ id: "addon-removal-confirmation-button",
+ },
+ ]);
+ let {
+ BUTTON_TITLE_IS_STRING: titleString,
+ BUTTON_TITLE_CANCEL: titleCancel,
+ BUTTON_POS_0,
+ BUTTON_POS_1,
+ confirmEx,
+ } = Services.prompt;
+ let btnFlags = BUTTON_POS_0 * titleString + BUTTON_POS_1 * titleCancel;
+ let message = null;
+
+ if (!Services.prefs.getBoolPref("prompts.windowPromptSubDialog", false)) {
+ message = await document.l10n.formatValue(
+ "addon-removal-confirmation-message",
+ {
+ name,
+ }
+ );
+ }
+
+ let checkboxState = { value: false };
+ let result = confirmEx(
+ window,
+ title,
+ message,
+ btnFlags,
+ btnTitle,
+ /* button1 */ null,
+ /* button2 */ null,
+ /* checkboxMessage */ null,
+ checkboxState
+ );
+
+ return { remove: result === 0, report: false };
+ },
+
+ async removeAddon(addonId) {
+ let addon = addonId && (await AddonManager.getAddonByID(addonId));
+ if (!addon || !(addon.permissions & AddonManager.PERM_CAN_UNINSTALL)) {
+ return;
+ }
+
+ let { remove, report } = await this.promptRemoveExtension(addon);
+
+ if (remove) {
+ await addon.uninstall(report);
+ }
+ },
+};
diff --git a/comm/mail/base/content/messenger.xhtml b/comm/mail/base/content/messenger.xhtml
new file mode 100644
index 0000000000..cf58784374
--- /dev/null
+++ b/comm/mail/base/content/messenger.xhtml
@@ -0,0 +1,671 @@
+<?xml version="1.0"?>
+# 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/.
+
+#filter substitution
+#define MAIN_WINDOW
+<?xml-stylesheet href="chrome://messenger/skin/mailWindow1.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/tagColors.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/shared/glodacomplete.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/tabmail.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/popupPanel.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/chat.css" type="text/css"?>
+<?xml-stylesheet href="chrome://chat/skin/imtooltip.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/messageHeader.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/icons.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/folderMenus.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/attachmentList.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/searchBox.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/panelUI.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/contextMenu.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/openpgp/inlineNotification.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/spacesToolbar.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/shared/unifiedToolbar.css" type="text/css"?>
+
+<!-- Calendar CSS -->
+<?xml-stylesheet href="chrome://calendar/skin/calendar.css" type="text/css"?>
+
+<?xml-stylesheet href="chrome://calendar/skin/calendar-event-dialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://calendar/skin/shared/dialogs/calendar-event-dialog.css" type="text/css"?>
+
+<?xml-stylesheet href="chrome://calendar/skin/today-pane.css" type="text/css"?>
+
+<?xml-stylesheet href="chrome://calendar/skin/calendar-unifinder.css" type="text/css"?>
+
+<?xml-stylesheet href="chrome://calendar/skin/calendar-task-tree.css" type="text/css"?>
+<?xml-stylesheet href="chrome://calendar/skin/calendar-task-view.css" type="text/css"?>
+
+<?xml-stylesheet href="chrome://calendar/skin/calendar-views.css" type="text/css"?>
+<?xml-stylesheet href="chrome://calendar/skin/shared/calendar-alarms.css" type="text/css"?>
+<?xml-stylesheet href="chrome://calendar/skin/shared/widgets/minimonth.css" type="text/css"?>
+<?xml-stylesheet href="chrome://calendar/skin/widgets/calendar-widgets.css" type="text/css"?>
+
+# All DTD information is stored in a separate file so that it can be shared by
+# hiddenWindowMac.xhtml.
+<!DOCTYPE html [
+#include messenger-doctype.inc.dtd
+]>
+
+<!--
+ - The 'what you think of when you think of thunderbird' window;
+ - 3-pane view inside of tabs.
+ -->
+<html id="messengerWindow" xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ icon="messengerWindow"
+ titlemodifier="&titledefault.label;@PRE_RELEASE_SUFFIX@"
+ titlemenuseparator="&titleSeparator.label;"
+ defaultTabTitle="&defaultTabTitle.label;"
+ windowtype="mail:3pane"
+ macanimationtype="document"
+ screenX="10" screenY="10"
+ scrolling="false"
+ persist="screenX screenY width height sizemode"
+ toggletoolbar="true"
+ lightweightthemes="true"
+ fullscreenbutton="true"
+ calendar-deactivated="">
+<head>
+ <link rel="localization" href="branding/brand.ftl" />
+ <link rel="localization" href="messenger/shortcuts.ftl" />
+ <link rel="localization" href="messenger/messenger.ftl" />
+ <link rel="localization" href="toolkit/main-window/findbar.ftl" />
+ <link rel="localization" href="toolkit/global/textActions.ftl" />
+ <link rel="localization" href="toolkit/printing/printUI.ftl" />
+ <link rel="localization" href="messenger/menubar.ftl" />
+ <link rel="localization" href="messenger/appmenu.ftl" />
+ <link rel="localization" href="messenger/openpgp/openpgp.ftl" />
+ <link rel="localization" href="messenger/openpgp/openpgp-frontend.ftl" />
+ <link rel="localization" href="messenger/openpgp/msgReadStatus.ftl"/>
+ <link rel="localization" href="calendar/calendar-widgets.ftl" />
+ <link rel="localization" href="calendar/calendar-context-menus.ftl" />
+ <link rel="localization" href="calendar/calendar-editable-item.ftl" />
+ <link rel="localization" href="messenger/chat.ftl" />
+ <link rel="localization" href="messenger/messageheader/headerFields.ftl" />
+ <link rel="localization" href="messenger/mailWidgets.ftl" />
+ <link rel="localization" href="messenger/unifiedToolbar.ftl" />
+ <link rel="localization" href="messenger/unifiedToolbarItems.ftl" />
+#ifdef NIGHTLY_BUILD
+ <link rel="localization" href="messenger/firefoxAccounts.ftl" />
+#endif
+
+ <title>&titledefault.label;@PRE_RELEASE_SUFFIX@</title>
+
+ <script defer="defer" src="chrome://messenger/content/globalOverlay.js"></script>
+ <script defer="defer" src="chrome://global/content/editMenuOverlay.js"></script>
+ <script defer="defer" src="chrome://messenger/content/mailWindow.js"></script>
+ <script defer="defer" src="chrome://messenger/content/selectionsummaries.js"></script>
+ <script defer="defer" src="chrome://messenger/content/messenger.js"></script>
+ <script defer="defer" src="chrome://messenger/content/specialTabs.js"></script>
+ <script defer="defer" src="chrome://messenger/content/spacesToolbar.js"></script>
+ <script defer="defer" src="chrome://messenger/content/newmailaccount/provisionerCheckout.js"></script>
+ <script defer="defer" src="chrome://messenger/content/glodaFacetTab.js"></script>
+ <script defer="defer" src="chrome://messenger/content/searchBar.js"></script>
+ <script defer="defer" src="chrome://messenger/content/mail3PaneWindowCommands.js"></script>
+ <script defer="defer" src="chrome://global/content/contentAreaUtils.js"></script>
+ <script defer="defer" src="chrome://messenger/content/browserPopups.js"></script>
+ <script defer="defer" src="chrome://messenger/content/accountUtils.js"></script>
+ <script defer="defer" src="chrome://communicator/content/contentAreaClick.js"></script>
+ <script defer="defer" src="chrome://messenger/content/toolbarIconColor.js"></script>
+ <script defer="defer" src="chrome://messenger/content/jsTreeView.js"></script>
+ <script defer="defer" src="chrome://messenger/content/chat/chat-messenger.js"></script>
+ <script defer="defer" src="chrome://messenger/content/chat/imStatusSelector.js"></script>
+ <script defer="defer" src="chrome://messenger/content/chat/imContextMenu.js"></script>
+ <script defer="defer" src="chrome://messenger/content/chat/chat-conversation.js"></script>
+ <script defer="defer" src="chrome://messenger/content/addressbook/addressBookTab.js"></script>
+ <script defer="defer" src="chrome://messenger/content/preferences/preferencesTab.js"></script>
+ <script defer="defer" src="chrome://messenger/content/mailCore.js"></script>
+ <script defer="defer" src="chrome://messenger/content/mailCommands.js"></script>
+ <script defer="defer" src="chrome://messenger/content/mailWindowOverlay.js"></script>
+ <script defer="defer" src="chrome://messenger/content/mailTabs.js"></script>
+ <script defer="defer" src="chrome://messenger-newsblog/content/newsblogOverlay.js"></script>
+ <script defer="defer" src="chrome://messenger/content/mail-offline.js"></script>
+ <script defer="defer" src="chrome://messenger/content/msgViewPickerOverlay.js"></script>
+ <script defer="defer" src="chrome://messenger/content/viewZoomOverlay.js"></script>
+ <script defer="defer" src="chrome://communicator/content/utilityOverlay.js"></script>
+ <script defer="defer" src="chrome://messenger/content/newmailaccount/uriListener.js"></script>
+ <script defer="defer" src="chrome://messenger/content/chat/chat-conversation-info.js"></script>
+ <script defer="defer" src="chrome://gloda/content/autocomplete-richlistitem.js"></script>
+ <script defer="defer" src="chrome://gloda/content/glodacomplete.js"></script>
+ <script defer="defer" src="chrome://messenger/content/chat/chat-contact.js"></script>
+ <script defer="defer" src="chrome://messenger/content/chat/chat-group.js"></script>
+ <script defer="defer" src="chrome://messenger/content/chat/chat-imconv.js"></script>
+ <script defer="defer" src="chrome://messenger/content/tabmail-tab.js"></script>
+ <script defer="defer" src="chrome://messenger/content/tabmail-tabs.js"></script>
+ <script defer="defer" src="chrome://messenger/content/tabmail.js"></script>
+ <script defer="defer" src="chrome://messenger/content/messenger-customization.js"></script>
+ <script defer="defer" src="chrome://messenger/content/customizable-toolbar.js"></script>
+#ifdef NIGHTLY_BUILD
+ <script defer="defer" src="chrome://messenger/content/sync.js"></script>
+#endif
+ <!-- panelUI.js is for the appmenus. -->
+ <script defer="defer" src="chrome://messenger/content/panelUI.js"></script>
+#ifdef XP_MACOSX
+ <script defer="defer" src="chrome://messenger/content/macMessengerMenu.js"></script>
+ <script defer="defer" src="chrome://global/content/macWindowMenu.js"></script>
+#endif
+#ifdef XP_WIN
+ <script defer="defer" src="chrome://messenger/content/minimizeToTray.js"></script>
+#endif
+ <!-- calendar-management.js also needed for multiple calendar support and today pane -->
+ <script defer="defer" src="chrome://calendar/content/calendar-management.js"></script>
+ <script defer="defer" src="chrome://calendar/content/calendar-ui-utils.js"></script>
+
+ <script defer="defer" src="chrome://calendar/content/calendar-tabs.js"></script>
+ <script defer="defer" src="chrome://calendar/content/calendar-modes.js"></script>
+
+ <script defer="defer" src="chrome://calendar/content/calendar-day-label.js"></script>
+
+ <script defer="defer" src="chrome://calendar/content/calendar-clipboard.js"></script>
+
+ <script defer="defer" src="chrome://calendar/content/import-export.js"></script>
+
+ <script defer="defer" src="chrome://calendar/content/publish.js"></script>
+
+ <script defer="defer" src="chrome://calendar/content/calendar-item-editing.js"></script>
+ <script defer="defer" src="chrome://calendar/content/calendar-chrome-startup.js"></script>
+ <script defer="defer" src="chrome://calendar/content/widgets/mouseoverPreviews.js"></script>
+
+ <script defer="defer" src="chrome://calendar/content/calendar-views-utils.js"></script>
+ <script defer="defer" src="chrome://calendar/content/widgets/calendar-filter.js"></script>
+ <script defer="defer" src="chrome://calendar/content/calendar-base-view.js"></script>
+ <script defer="defer" src="chrome://calendar/content/widgets/calendar-dnd-widgets.js"></script>
+ <script defer="defer" src="chrome://calendar/content/calendar-editable-item.js"></script>
+ <script defer="defer" src="chrome://calendar/content/calendar-month-view.js"></script>
+ <script defer="defer" src="chrome://calendar/content/calendar-multiday-view.js"></script>
+ <script defer="defer" src="chrome://calendar/content/calendar-views.js"></script>
+
+ <script defer="defer" src="chrome://calendar/content/calendar-dnd-listener.js"></script>
+ <script defer="defer" src="chrome://calendar/content/calendar-statusbar.js"></script>
+ <script defer="defer" src="chrome://calendar/content/widgets/calendar-invitation-panel.js"></script>
+ <script defer="defer" src="chrome://calendar/content/widgets/calendar-minidate.js"></script>
+ <script defer="defer" src="chrome://calendar/content/widgets/calendar-minimonth.js"></script>
+ <script defer="defer" src="chrome://calendar/content/widgets/calendar-modebox.js"></script>
+
+ <!-- NEEDED FOR TASK VIEW/LIST SUPPORT -->
+ <script defer="defer" src="chrome://calendar/content/calendar-task-editing.js"></script>
+
+ <script defer="defer" src="chrome://calendar/content/calendar-extract.js"></script>
+
+ <script defer="defer" src="chrome://calendar/content/calendar-invitations-manager.js"></script>
+
+ <!-- NEEDED FOR EVENT/TASK IN A TAB -->
+ <script defer="defer" src="chrome://calendar/content/calendar-item-panel.js"></script>
+
+ <script defer="defer" src="chrome://calendar/content/calendar-command-controller.js"></script>
+
+ <!-- NEEDED FOR EVENTS VIEW (UNIFINDER) -->
+ <script defer="defer" src="chrome://calendar/content/calendar-unifinder.js"></script>
+
+ <!-- NEEDED FOR TODAY PANE AND TASKS VIEW -->
+
+ <script defer="defer" src="chrome://messenger/content/tree-listbox.js"></script>
+ <script defer="defer" src="chrome://calendar/content/calendar-task-tree-utils.js"></script>
+ <script defer="defer" src="chrome://calendar/content/today-pane-agenda.js"></script>
+ <script defer="defer" src="chrome://calendar/content/today-pane.js"></script>
+
+ <!-- NEEDED FOR TASK VIEW -->
+ <script defer="defer" src="chrome://calendar/content/calendar-task-tree-view.js"></script>
+ <script defer="defer" src="chrome://calendar/content/calendar-task-tree.js"></script>
+ <script defer="defer" src="chrome://calendar/content/calendar-task-view.js"></script>
+ <script defer="defer" src="chrome://calendar/content/calendar-dialog-utils.js"></script>
+ <script defer="defer" src="chrome://calendar/content/calApplicationUtils.js"></script>
+ <script defer="defer" src="chrome://calendar/content/calendar-menus.js"></script>
+
+ <!-- NEEDED FOR MIGRATION CHECK AT INSTALL -->
+ <script defer="defer" src="chrome://calendar/content/calendar-migration.js"></script>
+ <script defer="defer" src="chrome://messenger/content/shortcutsOverlay.js"></script>
+
+ <script defer="defer" src="chrome://messenger/content/accountcreation/accountHub.js"></script>
+
+ <!-- Unified toolbar -->
+ <script type="module" defer="defer" src="chrome://messenger/content/unifiedtoolbar/unified-toolbar.mjs"></script>
+
+ <script>
+ window.onload = gMailInit.onLoad.bind(gMailInit);
+ window.onunload = gMailInit.onUnload.bind(gMailInit);
+
+ window.addEventListener("MozBeforeInitialXULLayout",
+ gMailInit.onBeforeInitialXULLayout.bind(gMailInit), { once: true });
+ </script>
+
+ <!-- Color customization for the folder pane. -->
+ <style id="folderColorsStyle"></style>
+ <style id="folderColorsStylePreview"></style>
+</head>
+<html:body xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+
+<stringbundle id="bundle_brand" src="chrome://branding/locale/brand.properties"/>
+<stringbundle id="bundle_messenger" src="chrome://messenger/locale/messenger.properties"/>
+
+<commandset id="mailCommands">
+#include mainCommandSet.inc.xhtml
+ <commandset id="mailSearchMenuItems"/>
+ <commandset id="globalEditMenuItems"
+ commandupdater="true"
+ events="create-menu-edit"
+ oncommandupdate="goUpdateGlobalEditMenuItems()"/>
+ <commandset id="selectEditMenuItems"
+ commandupdater="true"
+ events="create-menu-edit"
+ oncommandupdate="goUpdateSelectEditMenuItems()"/>
+ <commandset id="undoEditMenuItems"
+ commandupdater="true"
+ events="undo"
+ oncommandupdate="goUpdateUndoEditMenuItems()"/>
+ <commandset id="clipboardEditMenuItems"
+ commandupdater="true"
+ events="clipboard"
+ oncommandupdate="goUpdatePasteMenuItems()"/>
+ <commandset id="webSearchItems"/>
+ <commandset id="browserCommands">
+ <!-- Browsing back and forth inside the add-on manager and on content tabs -->
+ <command id="Browser:Back"
+ oncommand="goDoCommand('Browser:Back');"/>
+ <command id="Browser:Forward"
+ oncommand="goDoCommand('Browser:Forward');"/>
+ </commandset>
+ <commandset id="attachmentCommands">
+ <command id="cmd_openAllAttachments"
+ oncommand="goDoCommand('cmd_openAllAttachments');"/>
+ <command id="cmd_saveAllAttachments"
+ oncommand="goDoCommand('cmd_saveAllAttachments');"/>
+ <command id="cmd_detachAllAttachments"
+ oncommand="goDoCommand('cmd_detachAllAttachments');"/>
+ <command id="cmd_deleteAllAttachments"
+ oncommand="goDoCommand('cmd_deleteAllAttachments');"/>
+ </commandset>
+ <commandset id="tasksCommands">
+ <command id="cmd_newMessage" oncommand="goOpenNewMessage();"/>
+ <command id="cmd_newCard" oncommand="openNewCardDialog()"/>
+ </commandset>
+ <command id="cmd_close" oncommand="CloseTabOrWindow();"/>
+ <command id="cmd_CustomizeMailToolbar"
+ oncommand="customizeMailToolbarForTabType()"/>
+</commandset>
+
+#include ../../../calendar/base/content/calendar-commands.inc.xhtml
+
+<keyset id="browserKeys">
+#ifdef XP_MACOSX
+ <key id="key_goBackKb" keycode="VK_LEFT" oncommand="goDoCommand('Browser:Back');" modifiers="accel"/>
+ <key id="key_goForwardKb" keycode="VK_RIGHT" oncommand="goDoCommand('Browser:Forward');" modifiers="accel"/>
+#else
+ <key id="key_goBackKb" keycode="VK_LEFT" oncommand="goDoCommand('Browser:Back');" modifiers="alt" />
+ <key id="key_goForwardKb" keycode="VK_RIGHT" oncommand="goDoCommand('Browser:Forward');" modifiers="alt" />
+#endif
+</keyset>
+<keyset id="mailKeys">
+ <!-- Tab/F6 Keys -->
+ <key keycode="VK_TAB" oncommand="SwitchPaneFocus(event);" modifiers="control,shift"/>
+ <key keycode="VK_TAB" oncommand="SwitchPaneFocus(event);" modifiers="control"/>
+ <key keycode="VK_F6" oncommand="SwitchPaneFocus(event);" modifiers="control,shift"/>
+ <key keycode="VK_F6" oncommand="SwitchPaneFocus(event);" modifiers="control"/>
+ <key keycode="VK_F6" oncommand="SwitchPaneFocus(event);" modifiers="shift"/>
+ <key keycode="VK_F6" oncommand="SwitchPaneFocus(event);"/>
+#include mainKeySet.inc.xhtml
+ <keyset id="tasksKeys">
+#ifdef XP_MACOSX
+ <key id="key_newMessage" key="&newMessageCmd.key;" command="cmd_newMessage"
+ modifiers="accel,shift"/>
+ <key id="key_newMessage2" key="&newMessageCmd2.key;" command="cmd_newMessage"
+ modifiers="accel"/>
+#else
+ <key id="key_newMessage" key="&newMessageCmd.key;" command="cmd_newMessage"
+ modifiers="accel"/>
+ <key id="key_newMessage2" key="&newMessageCmd2.key;" command="cmd_newMessage"
+ modifiers="accel"/>
+#endif
+ </keyset>
+</keyset>
+
+#include ../../../calendar/base/content/calendar-keys.inc.xhtml
+
+<popupset id="mainPopupSet">
+#include widgets/browserPopups.inc.xhtml
+#include widgets/toolbarContext.inc.xhtml
+ <menupopup id="aboutPagesContext"
+ onpopupshowing="goUpdateCommand('cmd_copy'); goUpdateCommand('cmd_paste'); goUpdateCommand('cmd_selectAll');">
+ <menuitem id="aboutPagesContext-copy"
+ data-l10n-id="text-action-copy"
+ command="cmd_copy"/>
+ <menuitem id="aboutPagesContext-paste"
+ data-l10n-id="text-action-paste"
+ command="cmd_paste"/>
+ <menuitem id="aboutPagesContext-selectall"
+ data-l10n-id="text-action-select-all"
+ command="cmd_selectAll"/>
+ </menupopup>
+
+<!-- The panelUI is for the appmenu. -->
+#include ../../components/customizableui/content/panelUI.inc.xhtml
+#include ../../components/unifiedtoolbar/content/unifiedToolbarPopups.inc.xhtml
+ <panel is="glodacomplete-rich-result-popup"
+ id="PopupGlodaAutocomplete"
+ noautofocus="true"/>
+
+ <tooltip id="attachmentListTooltip"/>
+
+ <!-- We want to be able to do the following:
+
+ 1) Open the tabContextMenu by right-clicking on individual tab selectors
+ 2) Open the mail-toolbox customize context menu when right-clicking on
+ the empty space of the tab selector.
+
+ In order to do that, we make the tabContextMenu available in the main
+ document, and refer to it via the context attributes of each newly spawned
+ tab selector. We also make the context attribute of the tab strip default
+ to the mail-toolbox customization popup.
+
+ So, when right-clicking on a tab, the tabContextMenu opens up, and stops
+ the click event from propagating - but when the strip is right-clicked
+ outside of any tabs, the mail-toolbox context menu opens, as desired.
+ -->
+
+ <menupopup id="tabContextMenu">
+ <menuitem id="tabContextMenuOpenInWindow"
+ label="&moveToNewWindow.label;"
+ accesskey="&moveToNewWindow.accesskey;"/>
+ <menuseparator />
+ <menuitem id="tabContextMenuCloseOtherTabs"
+ label="&closeOtherTabsCmd2.label;"
+ accesskey="&closeOtherTabsCmd2.accesskey;"/>
+ <menuseparator />
+ <menu id="tabContextMenuRecentlyClosed"
+ label="&recentlyClosedTabsCmd.label;"
+ accesskey="&recentlyClosedTabsCmd.accesskey;">
+ <menupopup />
+ </menu>
+ <menuitem id="tabContextMenuClose"
+ label="&closeTabCmd2.label;"
+ accesskey="&closeTabCmd2.accesskey;"/>
+ </menupopup>
+
+ <tooltip id="aHTMLTooltip" page="true"/>
+
+ <panel id="notification-popup"
+ position="after_end"
+ orient="vertical"
+ noautofocus="true"
+ role="alert"/>
+
+ <popupnotification id="addon-progress-notification" hasicon="true" hidden="true">
+ <popupnotificationcontent orient="vertical">
+ <html:progress id="addon-progress-notification-progressmeter" max="100"/>
+ <label id="addon-progress-notification-progresstext" crop="end"/>
+ </popupnotificationcontent>
+ </popupnotification>
+
+ <popupnotification id="addon-install-confirmation-notification" hasicon="true" hidden="true">
+ <popupnotificationcontent id="addon-install-confirmation-content" orient="vertical"/>
+ </popupnotification>
+
+ <popupnotification id="addon-webext-permissions-notification" hasicon="true" hidden="true">
+ <popupnotificationcontent class="addon-webext-perm-notification-content" orient="vertical">
+ <description id="addon-webext-perm-text" class="addon-webext-perm-text"/>
+ <label id="addon-webext-perm-intro" class="addon-webext-perm-text"/>
+ <label id="addon-webext-perm-single-entry" class="addon-webext-perm-single-entry"/>
+ <html:ul id="addon-webext-perm-list" class="addon-webext-perm-list"/>
+ <description id="addon-webext-experiment-warning" class="addon-webext-experiment-warning"/>
+ <hbox>
+ <label id="addon-webext-perm-info" is="text-link" class="popup-notification-learnmore-link"/>
+ </hbox>
+ </popupnotificationcontent>
+ </popupnotification>
+
+ <popupnotification id="addon-installed-notification" hasicon="true" hidden="true">
+ <popupnotificationcontent class="addon-installed-notification-content" orient="vertical">
+ <html:ul id="addon-installed-list" class="addon-installed-list"/>
+ </popupnotificationcontent>
+ </popupnotification>
+
+ <popupnotification id="addon-install-blocked-notification" hasicon="true" hidden="true">
+ <popupnotificationcontent id="addon-install-blocked-content" orient="vertical">
+ <description id="addon-install-blocked-message" class="popup-notification-description"></description>
+ <hbox>
+ <label id="addon-install-blocked-info" class="popup-notification-learnmore-link" is="text-link"/>
+ </hbox>
+ </popupnotificationcontent>
+ </popupnotification>
+
+#include ../../components/im/content/chat-menu.inc.xhtml
+</popupset>
+#ifdef XP_MACOSX
+<popupset>
+ <menupopup id="menu_mac_dockmenu">
+ <menuitem label="&writeNewMessageDock.label;" id="tasksWriteNewMessage"
+ oncommand="writeNewMessageDock();"/>
+ <menuitem label="&openAddressBookDock.label;" id="tasksOpenAddressBook"
+ oncommand="openAddressBookDock();"/>
+ <menuitem label="&dockOptions.label;" id="tasksMenuDockOptions"
+ oncommand="openDockOptions();"/>
+ </menupopup>
+</popupset>
+#endif
+
+#include ../../../calendar/base/content/calendar-context-menus-and-tooltips.inc.xhtml
+#include ../../components/unifiedtoolbar/content/unifiedToolbarTemplates.inc.xhtml
+
+#include spacesToolbar.inc.xhtml
+
+<!--
+ GTK needs to draw behind the lightweight theme toolbox backgrounds, thus the
+ extra box. Also this box allows a negative margin-top to slide the toolbox off
+ screen in fullscreen layout.
+-->
+<box id="navigation-toolbox-background">
+ <toolbox id="navigation-toolbox" flex="1" labelalign="end" defaultlabelalign="end">
+
+ <vbox id="titlebar">
+ <html:unified-toolbar></html:unified-toolbar>
+ <!-- Menu -->
+ <toolbar id="toolbar-menubar"
+ class="chromeclass-menubar themeable-full"
+ type="menubar"
+#ifdef XP_MACOSX
+ autohide="true"
+#endif
+#ifndef XP_MACOSX
+ data-l10n-id="toolbar-context-menu-menu-bar"
+ data-l10n-attrs="toolbarname"
+#endif
+ context="toolbar-context-menu"
+ mode="icons"
+ insertbefore="tabs-toolbar"
+ prependmenuitem="true">
+# The entire main menubar is placed into messenger-menubar.inc.xhtml, so that it
+# can be shared with other top level windows.
+#include messenger-menubar.inc.xhtml
+ </toolbar>
+
+ <toolbar id="tabs-toolbar" class="chromeclass-toolbar">
+ <tabs is="tabmail-tabs" id="tabmail-tabs"
+ flex="1"
+ align="end"
+ setfocus="false"
+ alltabsbutton="alltabs-button"
+ context="toolbar-context-menu"
+ collapsetoolbar="tabs-toolbar">
+ <html:img class="tab-drop-indicator"
+ src="chrome://messenger/skin/icons/tab-drag-indicator.svg"
+ alt=""
+ hidden="hidden" />
+ <arrowscrollbox id="tabmail-arrowscrollbox"
+ orient="horizontal"
+ flex="1"
+ clicktoscroll="true"
+ style="min-width: 1px;">
+ <tab is="tabmail-tab" selected="true"
+ class="tabmail-tab" crop="end"/>
+ </arrowscrollbox>
+ </tabs>
+
+ <toolbarbutton class="toolbarbutton-1 tabs-alltabs-button"
+ id="alltabs-button"
+ type="menu"
+ hidden="true"
+ tooltiptext="&listAllTabs.label;">
+ <menupopup is="tabmail-alltabs-menupopup" id="alltabs-popup"
+ position="after_end"
+ tabcontainer="tabmail-tabs"/>
+ </toolbarbutton>
+
+ </toolbar>
+
+ </vbox>
+
+ </toolbox>
+</box>
+
+<vbox id="messengerBody">
+ <!-- XXX This extension point (tabmail-container) is only temporary!
+ Horizontal space shouldn't be wasted if it isn't absolutely critical.
+ A mechanism for adding sidebar panes will be added in bug 476154. -->
+ <hbox id="tabmail-container" flex="1">
+ <!-- Beware! Do NOT use overlays to append nodes directly to tabmail (children
+ of tabmail is OK though). This will break Ctrl-tab switching because
+ the Custom Element will choke when it finds a child of tabmail that is
+ not a tabpanels node. -->
+ <tabmail id="tabmail"
+ class="printPreviewStack"
+ flex="1"
+ panelcontainer="tabpanelcontainer"
+ tabcontainer="tabmail-tabs">
+ <tabbox id="tabmail-tabbox" flex="1" eventnode="document" tabcontainer="tabmail-tabs">
+ <tabpanels id="tabpanelcontainer" flex="1" class="plain" selectedIndex="0">
+#include ../../components/im/content/chat-messenger.inc.xhtml
+#include ../../../calendar/base/content/calendar-tab-panels.inc.xhtml
+#include ../../../calendar/base/content/item-editing/calendar-item-panel.inc.xhtml
+ </tabpanels>
+ </tabbox>
+ <html:template id="mail3PaneTabTemplate">
+ <stack flex="1">
+ <browser flex="1"
+ src="about:3pane"
+ autocompletepopup="PopupAutoComplete"
+ messagemanagergroup="single-page"/>
+ </stack>
+ </html:template>
+ <html:template id="mailMessageTabTemplate">
+ <stack flex="1">
+ <browser flex="1"
+ src="about:message"
+ autocompletepopup="PopupAutoComplete"
+ messagemanagergroup="single-page"/>
+ </stack>
+ </html:template>
+#include ../../../calendar/base/content/widgets/calendar-invitation-panel.xhtml
+#include ../../../calendar/base/content/widgets/calendar-minidate.xhtml
+ <!-- Hidden browser used for printing documents without displaying them. -->
+ <browser id="hiddenPrintContent"
+ type="content"
+ nodefaultsrc="true"
+ maychangeremoteness="true"
+ hidden="true"/>
+ </tabmail>
+#include ../../../calendar/base/content/calendar-today-pane.inc.xhtml
+ <vbox id="contentTab" collapsed="true">
+ <vbox flex="1" class="contentTabInstance">
+ <vbox id="dummycontenttoolbox" class="contentTabToolbox themeable-full">
+ <hbox id="dummycontenttoolbar" class="contentTabToolbar">
+ <toolbarbutton class="back-btn nav-button"
+ tooltiptext="&browseBackButton.tooltip;"
+ disabled="true"/>
+ <toolbarbutton class="forward-btn nav-button"
+ tooltiptext="&browseForwardButton.tooltip;"
+ disabled="true"/>
+ <toolbaritem class="contentTabAddress" flex="1">
+ <html:img class="contentTabSecurity" />
+ <html:input class="contentTabUrlInput themeableSearchBox"
+ readonly="readonly" />
+ </toolbaritem>
+ </hbox>
+ </vbox>
+ <stack flex="1"><!-- Insert browser here. --></stack>
+ </vbox>
+ </vbox>
+ <vbox id="glodaTab" collapsed="true">
+ <vbox flex="1" class="chromeTabInstance">
+ <vbox class="contentTabToolbox themeable-full">
+ <hbox class="glodaTabToolbar inline-toolbar chromeclass-toolbar" flex="1">
+ <spacer flex="1" />
+ <spacer flex="1" />
+ <hbox flex="1" class="remote-gloda-search-container">
+ <html:img class="search-icon" alt=""
+ src="chrome://global/skin/icons/search-textbox.svg" />
+ <html:input is="gloda-autocomplete-input"
+ type="text"
+ class="remote-gloda-search searchBox gloda-search"
+ searchbutton="true"
+ autocompletesearch="gloda"
+ autocompletepopup="PopupGlodaAutocomplete"
+ autocompletesearchparam="global"
+ timeout="200"
+ maxlength="192"
+ placeholder=""
+ emptytextbase="&search.label.base1;"
+ keyLabelNonMac="&search.keyLabel.nonmac;"
+ keyLabelMac="&search.keyLabel.mac;"/>
+ </hbox>
+ </hbox>
+ </vbox>
+ <iframe flex="1"/>
+ </vbox>
+ </vbox>
+ <vbox id="preferencesTab" collapsed="true">
+ <vbox flex="1">
+ <hbox flex="1">
+ <browser id="preferencesbrowser"
+ type="content"
+ flex="1"
+ disablehistory="true"
+ autocompletepopup="PopupAutoComplete"
+ messagemanagergroup="single-site"
+ onclick="return contentAreaClick(event);"/>
+ </hbox>
+ </vbox>
+ </vbox>
+ </hbox>
+ <panel id="customizeToolbarSheetPopup" noautohide="true">
+ <iframe id="customizeToolbarSheetIFrame"
+ style="&dialog.dimensions;"
+ hidden="true"/>
+ </panel>
+
+ <vbox id="messenger-notification-bottom">
+ <!-- notificationbox will be added here lazily. -->
+ </vbox>
+ <statuspanel id="statusbar-display"/>
+ <hbox id="status-bar" class="statusbar chromeclass-status">
+ <html:button type="button" id="spacesToolbarReveal"
+ onclick="gSpacesToolbar.toggleToolbar(false);"
+ data-l10n-id="spaces-toolbar-button-show"
+ class="plain spaces-toolbar-statusbar-button"
+ hidden="hidden">
+ <html:img src="chrome://messenger/skin/icons/new/compact/collapse.svg" alt="" />
+ </html:button>
+ <!-- We put the role="status" only around the information that is actually
+ - status information for the mail tabs. Specifically, we exclude the
+ - Spaces toolbar button and the calendar status bar (which is used when
+ - editing events in a tab and for the today pane button). -->
+ <hbox role="status" aria-live="off" flex="1">
+#include mainStatusbar.inc.xhtml
+ <hbox id="calendar-invitations-panel" class="statusbarpanel" hidden="true">
+ <label id="calendar-invitations-label"
+ class="text-link"
+ onclick="openInvitationsDialog()"
+ onkeypress="if (event.key == 'Enter') { openInvitationsDialog(); }"/>
+ </hbox>
+ </hbox>
+#include ../../../calendar/base/content/calendar-status-bar.inc.xhtml
+ </hbox>
+</vbox><!-- Closing #messengerBody. -->
+
+#include tabDialogs.inc.xhtml
+#include ../../components/accountcreation/templates/accountHubTemplate.inc.xhtml
+</html:body>
+</html>
diff --git a/comm/mail/base/content/migrationProgress.js b/comm/mail/base/content/migrationProgress.js
new file mode 100644
index 0000000000..d75536a137
--- /dev/null
+++ b/comm/mail/base/content/migrationProgress.js
@@ -0,0 +1,64 @@
+/* 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/. */
+
+var { MigrationTasks } = ChromeUtils.import(
+ "resource:///modules/MailMigrator.jsm"
+);
+
+window.addEventListener("load", async function () {
+ let list = document.getElementById("tasks");
+ let itemTemplate = document.getElementById("taskItem");
+ let progress = document.querySelector("progress");
+ let l10nElements = [];
+
+ for (let task of MigrationTasks.tasks) {
+ if (!task.fluentID) {
+ continue;
+ }
+
+ let item = itemTemplate.content.firstElementChild.cloneNode(true);
+ item.classList.add(task.status);
+
+ let name = item.querySelector(".task-name");
+ document.l10n.setAttributes(name, task.fluentID);
+ l10nElements.push(name);
+
+ if (task.status == "running") {
+ if (task.subTasks.length) {
+ progress.value = task.subTasks.filter(
+ t => t.status == "finished"
+ ).length;
+ progress.max = task.subTasks.length;
+ progress.style.visibility = null;
+ } else {
+ progress.style.visibility = "hidden";
+ }
+ }
+
+ list.appendChild(item);
+
+ task.on("status-change", (event, status) => {
+ item.classList.remove("pending", "running", "finished");
+ item.classList.add(status);
+
+ if (status == "running") {
+ // Always hide the progress bar when starting a task. If there are
+ // sub-tasks, it will be shown by a progress event.
+ progress.style.visibility = "hidden";
+ }
+ });
+ task.on("progress", (event, value, max) => {
+ progress.value = value;
+ progress.max = max;
+ progress.style.visibility = null;
+ });
+ }
+
+ await document.l10n.translateElements(l10nElements);
+ window.sizeToContent();
+ window.moveTo(
+ (screen.width - window.outerWidth) / 2,
+ (screen.height - window.outerHeight) / 2
+ );
+});
diff --git a/comm/mail/base/content/migrationProgress.xhtml b/comm/mail/base/content/migrationProgress.xhtml
new file mode 100644
index 0000000000..c196d92668
--- /dev/null
+++ b/comm/mail/base/content/migrationProgress.xhtml
@@ -0,0 +1,39 @@
+<?xml version="1.0"?>
+<!-- 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/. -->
+<!DOCTYPE html>
+<html xmlns="http://www.w3.org/1999/xhtml">
+ <head>
+ <meta charset="utf-8" />
+ <title data-l10n-id="migration-progress-header"></title>
+ <link rel="localization" href="branding/brand.ftl" />
+ <link rel="localization" href="messenger/migration.ftl" />
+ <link rel="stylesheet" href="chrome://global/skin/global.css" />
+ <link
+ rel="stylesheet"
+ href="chrome://messenger/skin/migrationProgress.css"
+ />
+ <script
+ defer="defer"
+ src="chrome://messenger/content/migrationProgress.js"
+ ></script>
+ </head>
+ <body>
+ <img
+ src="chrome://branding/content/icon256.png"
+ width="256"
+ height="256"
+ alt=""
+ />
+ <h1 data-l10n-id="migration-progress-header"></h1>
+ <ol id="tasks"></ol>
+ <progress value="0"></progress>
+ <template id="taskItem">
+ <li>
+ <div class="task-icon"></div>
+ <span class="task-name"></span>
+ </li>
+ </template>
+ </body>
+</html>
diff --git a/comm/mail/base/content/minimizeToTray.js b/comm/mail/base/content/minimizeToTray.js
new file mode 100644
index 0000000000..f65fcc7b43
--- /dev/null
+++ b/comm/mail/base/content/minimizeToTray.js
@@ -0,0 +1,19 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals docShell, Services, windowState */
+
+addEventListener("sizemodechange", () => {
+ if (
+ windowState == window.STATE_MINIMIZED &&
+ Services.prefs.getBoolPref("mail.minimizeToTray", false)
+ ) {
+ setTimeout(() => {
+ var bw = docShell.treeOwner.QueryInterface(Ci.nsIBaseWindow);
+ Cc["@mozilla.org/messenger/osintegration;1"]
+ .getService(Ci.nsIMessengerWindowsIntegration)
+ .hideWindow(bw);
+ });
+ }
+});
diff --git a/comm/mail/base/content/modules/thread-pane-columns.mjs b/comm/mail/base/content/modules/thread-pane-columns.mjs
new file mode 100644
index 0000000000..2361379509
--- /dev/null
+++ b/comm/mail/base/content/modules/thread-pane-columns.mjs
@@ -0,0 +1,385 @@
+/* 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 { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const lazy = {};
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "USE_CORRESPONDENTS",
+ "mail.threadpane.use_correspondents",
+ true
+);
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ FeedUtils: "resource:///modules/FeedUtils.jsm",
+ DBViewWrapper: "resource:///modules/DBViewWrapper.jsm",
+});
+
+/**
+ * The array of columns for the table layout. This must be kept in sync with
+ * the row template #threadPaneRowTemplate in about3Pane.xhtml.
+ *
+ * @type {Array}
+ */
+const DEFAULT_COLUMNS = [
+ {
+ id: "selectCol",
+ l10n: {
+ header: "threadpane-column-header-select",
+ menuitem: "threadpane-column-label-select",
+ },
+ ordinal: 1,
+ select: true,
+ icon: true,
+ resizable: false,
+ sortable: false,
+ hidden: true,
+ },
+ {
+ id: "threadCol",
+ l10n: {
+ header: "threadpane-column-header-thread",
+ menuitem: "threadpane-column-label-thread",
+ },
+ ordinal: 2,
+ thread: true,
+ icon: true,
+ resizable: false,
+ sortable: false,
+ },
+ {
+ id: "flaggedCol",
+ l10n: {
+ header: "threadpane-column-header-flagged",
+ menuitem: "threadpane-column-label-flagged",
+ },
+ ordinal: 3,
+ sortKey: "byFlagged",
+ star: true,
+ icon: true,
+ resizable: false,
+ },
+ {
+ id: "attachmentCol",
+ l10n: {
+ header: "threadpane-column-header-attachments",
+ menuitem: "threadpane-column-label-attachments",
+ },
+ ordinal: 4,
+ sortKey: "byAttachments",
+ icon: true,
+ resizable: false,
+ },
+ {
+ id: "subjectCol",
+ l10n: {
+ header: "threadpane-column-header-subject",
+ menuitem: "threadpane-column-label-subject",
+ },
+ ordinal: 5,
+ picker: false,
+ sortKey: "bySubject",
+ },
+ {
+ id: "unreadButtonColHeader",
+ l10n: {
+ header: "threadpane-column-header-unread-button",
+ menuitem: "threadpane-column-label-unread-button",
+ },
+ ordinal: 6,
+ sortKey: "byUnread",
+ icon: true,
+ resizable: false,
+ unread: true,
+ },
+ {
+ id: "senderCol",
+ l10n: {
+ header: "threadpane-column-header-sender",
+ menuitem: "threadpane-column-label-sender",
+ },
+ ordinal: 7,
+ sortKey: "byAuthor",
+ hidden: true,
+ },
+ {
+ id: "recipientCol",
+ l10n: {
+ header: "threadpane-column-header-recipient",
+ menuitem: "threadpane-column-label-recipient",
+ },
+ ordinal: 8,
+ sortKey: "byRecipient",
+ hidden: true,
+ },
+ {
+ id: "correspondentCol",
+ l10n: {
+ header: "threadpane-column-header-correspondents",
+ menuitem: "threadpane-column-label-correspondents",
+ },
+ ordinal: 9,
+ sortKey: "byCorrespondent",
+ },
+ {
+ id: "junkStatusCol",
+ l10n: {
+ header: "threadpane-column-header-spam",
+ menuitem: "threadpane-column-label-spam",
+ },
+ ordinal: 10,
+ sortKey: "byJunkStatus",
+ spam: true,
+ icon: true,
+ resizable: false,
+ },
+ {
+ id: "dateCol",
+ l10n: {
+ header: "threadpane-column-header-date",
+ menuitem: "threadpane-column-label-date",
+ },
+ ordinal: 11,
+ sortKey: "byDate",
+ },
+ {
+ id: "receivedCol",
+ l10n: {
+ header: "threadpane-column-header-received",
+ menuitem: "threadpane-column-label-received",
+ },
+ ordinal: 12,
+ sortKey: "byReceived",
+ hidden: true,
+ },
+ {
+ id: "statusCol",
+ l10n: {
+ header: "threadpane-column-header-status",
+ menuitem: "threadpane-column-label-status",
+ },
+ ordinal: 13,
+ sortKey: "byStatus",
+ hidden: true,
+ },
+ {
+ id: "sizeCol",
+ l10n: {
+ header: "threadpane-column-header-size",
+ menuitem: "threadpane-column-label-size",
+ },
+ ordinal: 14,
+ sortKey: "bySize",
+ hidden: true,
+ },
+ {
+ id: "tagsCol",
+ l10n: {
+ header: "threadpane-column-header-tags",
+ menuitem: "threadpane-column-label-tags",
+ },
+ ordinal: 15,
+ sortKey: "byTags",
+ hidden: true,
+ },
+ {
+ id: "accountCol",
+ l10n: {
+ header: "threadpane-column-header-account",
+ menuitem: "threadpane-column-label-account",
+ },
+ ordinal: 16,
+ sortKey: "byAccount",
+ hidden: true,
+ },
+ {
+ id: "priorityCol",
+ l10n: {
+ header: "threadpane-column-header-priority",
+ menuitem: "threadpane-column-label-priority",
+ },
+ ordinal: 17,
+ sortKey: "byPriority",
+ hidden: true,
+ },
+ {
+ id: "unreadCol",
+ l10n: {
+ header: "threadpane-column-header-unread",
+ menuitem: "threadpane-column-label-unread",
+ },
+ ordinal: 18,
+ sortable: false,
+ hidden: true,
+ },
+ {
+ id: "totalCol",
+ l10n: {
+ header: "threadpane-column-header-total",
+ menuitem: "threadpane-column-label-total",
+ },
+ ordinal: 19,
+ sortable: false,
+ hidden: true,
+ },
+ {
+ id: "locationCol",
+ l10n: {
+ header: "threadpane-column-header-location",
+ menuitem: "threadpane-column-label-location",
+ },
+ ordinal: 20,
+ sortKey: "byLocation",
+ hidden: true,
+ },
+ {
+ id: "idCol",
+ l10n: {
+ header: "threadpane-column-header-id",
+ menuitem: "threadpane-column-label-id",
+ },
+ ordinal: 21,
+ sortKey: "byId",
+ hidden: true,
+ },
+ {
+ id: "deleteCol",
+ l10n: {
+ header: "threadpane-column-header-delete",
+ menuitem: "threadpane-column-label-delete",
+ },
+ ordinal: 22,
+ delete: true,
+ icon: true,
+ resizable: false,
+ sortable: false,
+ hidden: true,
+ },
+];
+
+/**
+ * Check if the current folder is a special Outgoing folder.
+ *
+ * @param {nsIMsgFolder} folder - The message folder.
+ * @returns {boolean} True if the folder is Outgoing.
+ */
+export const isOutgoing = folder => {
+ return folder.isSpecialFolder(
+ lazy.DBViewWrapper.prototype.OUTGOING_FOLDER_FLAGS,
+ true
+ );
+};
+
+/**
+ * Generate the correct default array of columns, accounting for different views
+ * and folder states.
+ *
+ * @param {?nsIMsgFolder} folder - The currently viewed folder if available.
+ * @param {boolean} [isSynthetic=false] - If the current view is synthetic,
+ * meaning we are not visualizing a real folder, but rather
+ * the gloda results list.
+ * @returns {object[]}
+ */
+export function getDefaultColumns(folder, isSynthetic = false) {
+ // Create a clone we can edit.
+ let updatedColumns = DEFAULT_COLUMNS.map(column => ({ ...column }));
+
+ if (isSynthetic) {
+ // Synthetic views usually can contain messages from multiple folders.
+ // Folder for the selected message will still be set.
+ for (let c of updatedColumns) {
+ switch (c.id) {
+ case "correspondentCol":
+ // Don't show the correspondent if is not wanted.
+ c.hidden = !lazy.USE_CORRESPONDENTS;
+ break;
+ case "senderCol":
+ // Hide the sender if correspondent is enabled.
+ c.hidden = lazy.USE_CORRESPONDENTS;
+ break;
+ case "attachmentCol":
+ case "unreadButtonColHeader":
+ case "junkStatusCol":
+ // Hide all the columns we don't want in a default gloda view.
+ c.hidden = true;
+ break;
+ case "locationCol":
+ // Always show the location by default in a gloda view.
+ c.hidden = false;
+ break;
+ }
+ }
+ return updatedColumns;
+ }
+
+ if (!folder) {
+ // We don't have a folder yet. Use defaults.
+ return updatedColumns;
+ }
+
+ for (let c of updatedColumns) {
+ switch (c.id) {
+ case "correspondentCol":
+ // Don't show the correspondent for news or RSS.
+ c.hidden = lazy.USE_CORRESPONDENTS
+ ? !folder.getFlag(Ci.nsMsgFolderFlags.Mail) ||
+ lazy.FeedUtils.isFeedFolder(folder)
+ : true;
+ break;
+ case "senderCol":
+ // Show the sender even if correspondent is enabled for news and feeds.
+ c.hidden = lazy.USE_CORRESPONDENTS
+ ? !folder.getFlag(Ci.nsMsgFolderFlags.Newsgroup) &&
+ !lazy.FeedUtils.isFeedFolder(folder)
+ : isOutgoing(folder);
+ break;
+ case "recipientCol":
+ // No recipient column if we use correspondent. Otherwise hide it if is
+ // not an outgoing folder.
+ c.hidden = lazy.USE_CORRESPONDENTS ? true : !isOutgoing(folder);
+ break;
+ case "junkStatusCol":
+ // No ability to mark newsgroup or feed messages as spam.
+ c.hidden =
+ folder.getFlag(Ci.nsMsgFolderFlags.Newsgroup) ||
+ lazy.FeedUtils.isFeedFolder(folder);
+ break;
+ }
+ }
+ return updatedColumns;
+}
+
+/**
+ * Find the proper column to use as sender field for the cards view.
+ *
+ * @param {?nsIMsgFolder} folder - The currently viewed folder if available.
+ * @returns {string} - The name of the column to use as sender field.
+ */
+function getProperSenderForCardsView(folder) {
+ // Default to correspondent as it's the safest choice most of the times.
+ if (!folder) {
+ return "correspondentCol";
+ }
+
+ // Show the recipient for outgoing folders.
+ if (isOutgoing(folder)) {
+ return "recipientCol";
+ }
+
+ // Show the sender for any other scenario, including news and feeds folders.
+ return "senderCol";
+}
+
+/**
+ * Get the default array of columns to fetch data for the cards view.
+ *
+ * @param {?nsIMsgFolder} folder - The currently viewed folder if available.
+ * @returns {string[]}
+ */
+export function getDefaultColumnsForCardsView(folder) {
+ const sender = getProperSenderForCardsView(folder);
+ return ["subjectCol", sender, "dateCol", "tagsCol"];
+}
diff --git a/comm/mail/base/content/msgAttachmentView.inc.xhtml b/comm/mail/base/content/msgAttachmentView.inc.xhtml
new file mode 100644
index 0000000000..934ff7bc18
--- /dev/null
+++ b/comm/mail/base/content/msgAttachmentView.inc.xhtml
@@ -0,0 +1,102 @@
+# 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/.
+
+ <!-- the message pane consists of 4 'boxes'. Box #4 is the attachment
+ box which can be toggled into a slim or an expanded view -->
+ <hbox align="center" id="attachmentBar"
+ context="attachment-toolbar-context-menu"
+ onclick="if (event.button == 0) { toggleAttachmentList(undefined, true); }">
+ <button type="checkbox" id="attachmentToggle"
+ onmousedown="event.preventDefault();"
+ onclick="event.stopPropagation();"
+ oncommand="toggleAttachmentList(this.checked, true);"/>
+ <hbox align="center" id="attachmentInfo">
+ <html:img id="attachmentIcon"
+ src="chrome://messenger/skin/icons/attach.svg"
+ alt="" />
+ <label id="attachmentCount"/>
+ <label id="attachmentName" crop="center"
+ role="button"
+ tooltiptext="&openAttachment.tooltip;"
+ tooltiptextopen="&openAttachment.tooltip;"
+ onclick="OpenAttachmentFromBar(event);"
+ ondragstart="attachmentNameDNDObserver.onDragStart(event);"/>
+ <label id="attachmentSize"/>
+ </hbox>
+ <spacer flex="1"/>
+
+ <vbox id="attachment-view-toolbox" class="inline-toolbox">
+ <hbox id="attachment-view-toolbar"
+ class="toolbar themeable-brighttext"
+ context="attachment-toolbar-context-menu">
+ <toolbaritem id="attachmentSaveAll"
+ title="&saveAllAttachmentsButton1.label;">
+ <toolbarbutton is="toolbarbutton-menu-button" id="attachmentSaveAllSingle"
+ type="menu"
+ class="toolbarbutton-1 message-header-view-button"
+ label="&saveAttachmentButton1.label;"
+ tooltiptext="&saveAttachmentButton1.tooltip;"
+ onclick="event.stopPropagation();"
+ oncommand="TryHandleAllAttachments('saveAs');"
+ hidden="true">
+ <menupopup id="attachmentSaveAllSingleMenu"
+ onpopupshowing="onShowSaveAttachmentMenuSingle();">
+ <menuitem id="button-openAttachment"
+ oncommand="TryHandleAllAttachments('open'); event.stopPropagation();"
+ label="&openAttachmentCmd.label;"
+ accesskey="&openAttachmentCmd.accesskey;"/>
+ <menuitem id="button-saveAttachment"
+ oncommand="TryHandleAllAttachments('saveAs'); event.stopPropagation();"
+ label="&saveAsAttachmentCmd.label;"
+ accesskey="&saveAsAttachmentCmd.accesskey;"/>
+ <menuseparator id="button-menu-separator"/>
+ <menuitem id="button-detachAttachment"
+ oncommand="TryHandleAllAttachments('detach'); event.stopPropagation();"
+ label="&detachAttachmentCmd.label;"
+ accesskey="&detachAttachmentCmd.accesskey;"/>
+ <menuitem id="button-deleteAttachment"
+ oncommand="TryHandleAllAttachments('delete'); event.stopPropagation();"
+ label="&deleteAttachmentCmd.label;"
+ accesskey="&deleteAttachmentCmd.accesskey;"/>
+ </menupopup>
+ </toolbarbutton>
+ <toolbarbutton is="toolbarbutton-menu-button" id="attachmentSaveAllMultiple"
+ type="menu"
+ class="toolbarbutton-1 message-header-view-button"
+ label="&saveAllAttachmentsButton1.label;"
+ tooltiptext="&saveAllAttachmentsButton1.tooltip;"
+ onclick="event.stopPropagation();"
+ oncommand="TryHandleAllAttachments('save');">
+ <menupopup id="attachmentSaveAllMultipleMenu"
+ onpopupshowing="onShowSaveAttachmentMenuMultiple();">
+ <menuitem id="button-openAllAttachments"
+ oncommand="TryHandleAllAttachments('open'); event.stopPropagation();"
+ label="&openAllAttachmentsCmd.label;"
+ accesskey="&openAllAttachmentsCmd.accesskey;"/>
+ <menuitem id="button-saveAllAttachments"
+ oncommand="TryHandleAllAttachments('save'); event.stopPropagation();"
+ label="&saveAllAttachmentsCmd.label;"
+ accesskey="&saveAllAttachmentsCmd.accesskey;"/>
+ <menuseparator id="button-menu-separator-all"/>
+ <menuitem id="button-detachAllAttachments"
+ oncommand="TryHandleAllAttachments('detach'); event.stopPropagation();"
+ label="&detachAllAttachmentsCmd.label;"
+ accesskey="&detachAllAttachmentsCmd.accesskey;"/>
+ <menuitem id="button-deleteAllAttachments"
+ oncommand="TryHandleAllAttachments('delete'); event.stopPropagation();"
+ label="&deleteAllAttachmentsCmd.label;"
+ accesskey="&deleteAllAttachmentsCmd.accesskey;"/>
+ </menupopup>
+ </toolbarbutton>
+ </toolbaritem>
+ </hbox>
+ </vbox>
+ </hbox>
+ <richlistbox is="attachment-list" id="attachmentList"
+ class="attachmentList"
+ seltype="multiple"
+ context="attachmentListContext"
+ itemcontext="attachmentItemContext"
+ role="listbox"
+ ondragstart="attachmentListDNDObserver.onDragStart(event);"/>
diff --git a/comm/mail/base/content/msgHdrPopup.inc.xhtml b/comm/mail/base/content/msgHdrPopup.inc.xhtml
new file mode 100644
index 0000000000..3c0b9826bb
--- /dev/null
+++ b/comm/mail/base/content/msgHdrPopup.inc.xhtml
@@ -0,0 +1,224 @@
+# 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/.
+
+ <menupopup id="messageIdContext">
+ <menuitem id="messageIdContext-messageIdTarget" disabled="true"/>
+ <menuseparator id="messageIdContext-separator"/>
+ <menuitem id="messageIdContext-openMessageForMsgId"
+ label="&OpenMessageForMsgId.label;"
+ accesskey="&OpenMessageForMsgId.accesskey;"
+ oncommand="gMessageHeader.openMessage(event);"/>
+ <menuitem id="messageIdContext-openBrowserWithMsgId"
+ label="&OpenBrowserWithMsgId.label;"
+ accesskey="&OpenBrowserWithMsgId.accesskey;"
+ oncommand="gMessageHeader.openBrowser(event);"/>
+ <menuitem id="messageIdContext-copyMessageId"
+ label="&CopyMessageId.label;"
+ accesskey="&CopyMessageId.accesskey;"
+ oncommand="gMessageHeader.copyMessageId(event);"/>
+ </menupopup>
+
+ <menupopup id="attachmentItemContext"
+ onpopupshowing="return onShowAttachmentItemContextMenu();"
+ onpopuphiding="return onHideAttachmentItemContextMenu();">
+ <menuitem id="context-openAttachment"
+ label="&openAttachmentCmd.label;"
+ accesskey="&openAttachmentCmd.accesskey;"
+ oncommand="HandleMultipleAttachments(this.parentNode.attachments, 'open');"/>
+ <menuitem id="context-saveAttachment"
+ label="&saveAsAttachmentCmd.label;"
+ accesskey="&saveAsAttachmentCmd.accesskey;"
+ oncommand="HandleMultipleAttachments(this.parentNode.attachments, 'saveAs');"/>
+ <menuseparator id="context-menu-separator"/>
+ <menuitem id="context-detachAttachment"
+ label="&detachAttachmentCmd.label;"
+ accesskey="&detachAttachmentCmd.accesskey;"
+ oncommand="HandleMultipleAttachments(this.parentNode.attachments, 'detach');"/>
+ <menuitem id="context-deleteAttachment"
+ label="&deleteAttachmentCmd.label;"
+ accesskey="&deleteAttachmentCmd.accesskey;"
+ oncommand="HandleMultipleAttachments(this.parentNode.attachments, 'delete');"/>
+ <menuseparator id="context-menu-copyurl-separator"/>
+ <menuitem id="context-copyAttachmentUrl"
+ label="&copyLinkCmd.label;"
+ accesskey="&copyLinkCmd.accesskey;"
+ oncommand="HandleMultipleAttachments(this.parentNode.attachments, 'copyUrl');"/>
+ <menuitem id="context-openFolder"
+#ifdef XP_MACOSX
+ label="&detachedAttachmentFolder.showMac.label;"
+ accesskey="&detachedAttachmentFolder.showMac.accesskey;"
+#else
+ label="&detachedAttachmentFolder.show.label;"
+ accesskey="&detachedAttachmentFolder.show.accesskey;"
+#endif
+ oncommand="HandleMultipleAttachments(this.parentNode.attachments, 'openFolder');"/>
+#include ../../extensions/openpgp/content/ui/attachmentItemContext.inc.xhtml
+ </menupopup>
+
+ <menupopup id="attachmentListContext"
+ onpopupshowing="goUpdateAttachmentCommands();">
+ <menuitem id="context-openAllAttachments"
+ label="&openAllAttachmentsCmd.label;"
+ accesskey="&openAllAttachmentsCmd.accesskey;"
+ command="cmd_openAllAttachments"/>
+ <menuitem id="context-saveAllAttachments"
+ label="&saveAllAttachmentsCmd.label;"
+ accesskey="&saveAllAttachmentsCmd.accesskey;"
+ command="cmd_saveAllAttachments"/>
+ <menuseparator id="context-menu-separator-all"/>
+ <menuitem id="context-detachAllAttachments"
+ label="&detachAllAttachmentsCmd.label;"
+ accesskey="&detachAllAttachmentsCmd.accesskey;"
+ command="cmd_detachAllAttachments"/>
+ <menuitem id="context-deleteAllAttachments"
+ label="&deleteAllAttachmentsCmd.label;"
+ accesskey="&deleteAllAttachmentsCmd.accesskey;"
+ command="cmd_deleteAllAttachments"/>
+ </menupopup>
+
+ <menupopup id="attachment-toolbar-context-menu"
+ onpopupshowing="return onShowAttachmentToolbarContextMenu(event);">
+ <menuitem id="context-expandAttachmentBar"
+ type="checkbox"
+ label="&startExpandedCmd.label;"
+ accesskey="&startExpandedCmd.accesskey;"
+ oncommand="Services.prefs.setBoolPref('mailnews.attachments.display.start_expanded', this.getAttribute('checked'));"/>
+ </menupopup>
+
+ <menupopup id="emailAddressPopup"
+ position="after_start"
+ class="no-icon-menupopup">
+ <menuitem id="emailAddressPlaceHolder"
+ class="menuitem-iconic"
+ disabled="true"/>
+ <menuseparator/>
+ <menuitem id="addToAddressBookItem"
+ label="&AddDirectlyToAddressBook.label;"
+ accesskey="&AddDirectlyToAddressBook.accesskey;"
+ class="menuitem-iconic"
+ oncommand="gMessageHeader.addContact(event);"/>
+ <menuitem id="editContactItem" label="&EditContact1.label;" hidden="true"
+ accesskey="&EditContact1.accesskey;"
+ class="menuitem-iconic"
+ oncommand="gMessageHeader.showContactEdit(event);"/>
+ <menuitem id="viewContactItem" label="&ViewContact.label;" hidden="true"
+ accesskey="&ViewContact.accesskey;"
+ class="menuitem-iconic"
+ oncommand="gMessageHeader.showContactEdit(event);"/>
+ <menuitem id="sendMailToItem" label="&SendMessageTo.label;"
+ accesskey="&SendMessageTo.accesskey;"
+ class="menuitem-iconic"
+ oncommand="gMessageHeader.composeMessage(event);"/>
+ <menuitem id="copyEmailAddressItem" label="&CopyEmailAddress.label;"
+ accesskey="&CopyEmailAddress.accesskey;"
+ class="menuitem-iconic"
+ oncommand="gMessageHeader.copyAddress(event)"/>
+ <menuitem id="copyNameAndEmailAddressItem" label="&CopyNameAndEmailAddress.label;"
+ accesskey="&CopyNameAndEmailAddress.accesskey;"
+ class="menuitem-iconic"
+ oncommand="gMessageHeader.copyAddress(event, true);"/>
+ <menuseparator/>
+ <menuitem id="searchKeysOpenPGP" data-l10n-id="openpgp-search-keys-openpgp"
+ class="menuitem-iconic"
+ oncommand="Enigmail.msg.searchKeysOnInternet(event)"/>
+ <menuseparator/>
+ <menuitem id="createFilterFrom" label="&CreateFilterFrom.label;"
+ accesskey="&CreateFilterFrom.accesskey;"
+ class="menuitem-iconic"
+ oncommand="gMessageHeader.createFilter(event);"/>
+ </menupopup>
+
+ <menupopup id="copyPopup" class="no-icon-menupopup">
+ <menuitem id="copyMenuitem"
+ data-l10n-id="text-action-copy"
+ class="menuitem-iconic"
+ oncommand="gMessageHeader.copyString(event);"/>
+ <menuitem id="copyCreateFilterFrom"
+ label="&CreateFilterFrom.label;"
+ accesskey="&CreateFilterFrom.accesskey;"
+ class="menuitem-iconic"
+ oncommand="gMessageHeader.createFilter(event);"/>
+ </menupopup>
+
+ <menupopup id="copyUrlPopup"
+ popupanchor="bottomleft">
+ <menuitem label="&copyLinkCmd.label;"
+ accesskey="&copyLinkCmd.accesskey;"
+ oncommand="gMessageHeader.copyWebsiteUrl(event);"/>
+ </menupopup>
+
+ <menupopup id="simpleCopyPopup" class="no-icon-menupopup">
+ <menuitem id="copyMenuitem"
+ data-l10n-id="text-action-copy"
+ class="menuitem-iconic"
+ oncommand="gMessageHeader.copyString(event);"/>
+ </menupopup>
+
+ <menupopup id="newsgroupPopup"
+ position="after_start"
+ class="newsgroupPopup no-icon-menupopup">
+ <menuitem id="newsgroupPlaceHolder"
+ class="menuitem-iconic"
+ disabled="true"/>
+ <menuseparator/>
+ <menuitem id="sendMessageToNewsgroupItem"
+ label="&SendMessageTo.label;"
+ accesskey="&SendMessageTo.accesskey;"
+ class="menuitem-iconic"
+ oncommand="gMessageHeader.composeMessage(event);"/>
+ <menuitem id="copyNewsgroupNameItem"
+ label="&CopyNewsgroupName.label;"
+ accesskey="&CopyNewsgroupName.accesskey;"
+ class="menuitem-iconic"
+ oncommand="gMessageHeader.copyAddress(event);"/>
+ <menuitem id="copyNewsgroupURLItem"
+ label="&CopyNewsgroupURL.label;"
+ accesskey="&CopyNewsgroupURL.accesskey;"
+ class="menuitem-iconic"
+ oncommand="gMessageHeader.copyNewsgroupURL(event);"/>
+ <menuseparator id="subscribeToNewsgroupSeparator"/>
+ <menuitem id="subscribeToNewsgroupItem"
+ label="&SubscribeToNewsgroup.label;"
+ accesskey="&SubscribeToNewsgroup.accesskey;"
+ class="menuitem-iconic"
+ oncommand="gMessageHeader.subscribeToNewsgroup(event)"/>
+ </menupopup>
+
+ <menupopup id="remoteContentOptions" value=""
+ onpopupshowing="onRemoteContentOptionsShowing(event);">
+ <menuitem id="remoteContentOptionAllowForMsg"
+ label="&remoteContentOptionsAllowForMsg.label;"
+ accesskey="&remoteContentOptionsAllowForMsg.accesskey;"
+ oncommand="LoadMsgWithRemoteContent();"/>
+ <menuseparator id="remoteContentSettingsMenuSeparator"/>
+ <menuitem id="editRemoteContentSettings"
+#ifdef XP_WIN
+ label="&editRemoteContentSettings.label;"
+ accesskey="&editRemoteContentSettings.accesskey;"
+#else
+ label="&editRemoteContentSettingsUnix.label;"
+ accesskey="&editRemoteContentSettingsUnix.accesskey;"
+#endif
+ oncommand="editRemoteContentSettings();"/>
+ <menuseparator id="remoteContentOriginsMenuSeparator"/>
+ <menuseparator id="remoteContentAllMenuSeparator"/>
+ <menuitem id="remoteContentOptionAllowAll"
+ oncommand="allowRemoteContentForAll(this.parentNode);"/>
+ </menupopup>
+
+ <menupopup id="phishingOptions">
+ <menuitem id="phishingOptionIgnore"
+ label="&phishingOptionIgnore.label;"
+ accesskey="&phishingOptionIgnore.accesskey;"
+ oncommand="IgnorePhishingWarning();"/>
+ <menuitem id="phishingOptionSettings"
+#ifdef XP_WIN
+ label="&phishingOptionSettings.label;"
+ accesskey="&phishingOptionSettings.accesskey;"
+#else
+ label="&phishingOptionSettingsUnix.label;"
+ accesskey="&phishingOptionSettingsUnix.accesskey;"
+#endif
+ oncommand="OpenPhishingSettings();"/>
+ </menupopup>
diff --git a/comm/mail/base/content/msgHdrView.inc.xhtml b/comm/mail/base/content/msgHdrView.inc.xhtml
new file mode 100644
index 0000000000..fa9565d7cc
--- /dev/null
+++ b/comm/mail/base/content/msgHdrView.inc.xhtml
@@ -0,0 +1,559 @@
+# 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/.
+
+<menupopup id="header-toolbar-context-menu"
+ onpopupshowing="ToolbarContextMenu.updateExtension(this);">
+ <menuitem oncommand="ToolbarContextMenu.openAboutAddonsForContextAction(this.parentElement)"
+ data-l10n-id="toolbar-context-menu-manage-extension"
+ class="customize-context-manageExtension"/>
+ <menuitem oncommand="ToolbarContextMenu.removeExtensionForContextAction(this.parentElement)"
+ data-l10n-id="toolbar-context-menu-remove-extension"
+ class="customize-context-removeExtension"/>
+</menupopup>
+
+<!-- Header container -->
+<html:header id="messageHeader"
+ class="message-header-container message-header-large-subject message-header-hide-label-column message-header-show-recipient-avatar message-header-show-sender-full-address">
+ <!-- From + buttons -->
+ <html:div id="headerSenderToolbarContainer"
+ class="message-header-row header-row-reverse message-header-wrap items-center">
+ <html:div id="header-view-toolbox" role="toolbar" class="header-buttons-container">
+ <!-- NOTE: Temporarily keep the hbox to allow extensions to add custom sets of
+ toolbar buttons. This can be later removed once a new API that doesn't rely
+ on XUL currentset is in place. -->
+ <hbox id="header-view-toolbar" class="header-buttons-container themeable-brighttext">
+ <toolbarbutton id="hdrReplyToSenderButton" label="&hdrReplyButton1.label;"
+ tooltiptext="&hdrReplyButton2.tooltip;"
+ oncommand="MsgReplySender(event);"
+ class="toolbarbutton-1 message-header-view-button hdrReplyToSenderButton"/>
+
+ <toolbaritem id="hdrSmartReplyButton" label="&hdrSmartReplyButton1.label;">
+ <!-- This button is a dummy and should only be shown when customizing
+ the toolbar to distinguish the smart reply button from the reply
+ to sender button. -->
+ <toolbarbutton id="hdrDummyReplyButton" label="&hdrSmartReplyButton1.label;"
+ hidden="true"
+ class="toolbarbutton-1 message-header-view-button hdrDummyReplyButton"/>
+
+ <toolbarbutton id="hdrReplyButton" label="&hdrReplyButton1.label;"
+ tooltiptext="&hdrReplyButton2.tooltip;"
+ oncommand="MsgReplySender(event);"
+ class="toolbarbutton-1 message-header-view-button hdrReplyButton"/>
+
+ <toolbarbutton is="toolbarbutton-menu-button" id="hdrReplyAllButton"
+ type="menu"
+ label="&hdrReplyAllButton1.label;"
+ tooltiptext="&hdrReplyAllButton1.tooltip;"
+ oncommand="MsgReplyToAllMessage(event);"
+ class="toolbarbutton-1 message-header-view-button hdrReplyButton hdrReplyAllButton"
+ hidden="true">
+ <menupopup id="hdrReplyAllDropdown"
+ class="no-icon-menupopup">
+ <menuitem id="hdrReplyAll_ReplyAllSubButton"
+ class="menuitem-iconic"
+ label="&hdrReplyAllButton1.label;"
+ tooltiptext="&hdrReplyAllButton1.tooltip;"/>
+ <toolbarseparator id="hdrReplyAllSubSeparator"/>
+ <menuitem id="hdrReplySubButton"
+ class="menuitem-iconic"
+ label="&hdrReplyButton1.label;"
+ tooltiptext="&hdrReplyButton2.tooltip;"
+ oncommand="MsgReplySender(event); event.stopPropagation();"/>
+ </menupopup>
+ </toolbarbutton>
+
+ <toolbarbutton is="toolbarbutton-menu-button" id="hdrReplyListButton"
+ type="menu"
+ label="&hdrReplyListButton1.label;"
+ tooltiptext="&hdrReplyListButton1.tooltip;"
+ oncommand="MsgReplyToListMessage(event);"
+ class="toolbarbutton-1 message-header-view-button hdrReplyButton hdrReplyListButton"
+ hidden="true">
+ <menupopup id="hdrReplyListDropdown"
+ class="no-icon-menupopup">
+ <menuitem id="hdrReplyList_ReplyListSubButton"
+ class="menuitem-iconic"
+ label="&hdrReplyListButton1.label;"
+ tooltiptext="&hdrReplyListButton1.tooltip;"/>
+ <toolbarseparator id="hdrReplyListSubSeparator"/>
+ <menuitem id="hdrRelplyList_ReplyAllSubButton"
+ class="menuitem-iconic"
+ label="&hdrReplyAllButton1.label;"
+ tooltiptext="&hdrReplyAllButton1.tooltip;"
+ oncommand="MsgReplyToAllMessage(event); event.stopPropagation();"/>
+ <menuitem id="hdrReplyList_ReplySubButton"
+ class="menuitem-iconic"
+ label="&hdrReplyButton1.label;"
+ tooltiptext="&hdrReplyButton2.tooltip;"
+ oncommand="MsgReplySender(event); event.stopPropagation();"/>
+ </menupopup>
+ </toolbarbutton>
+
+ <toolbarbutton is="toolbarbutton-menu-button" id="hdrFollowupButton"
+ label="&hdrFollowupButton1.label;"
+ type="menu"
+ tooltiptext="&hdrFollowupButton1.tooltip;"
+ oncommand="MsgReplyGroup(event);"
+ class="toolbarbutton-1 message-header-view-button hdrFollowupButton">
+ <menupopup id="hdrFollowupDropdown"
+ class="no-icon-menupopup">
+ <menuitem id="hdrFollowup_FollowupSubButton"
+ class="menuitem-iconic"
+ label="&hdrFollowupButton1.label;"
+ tooltiptext="&hdrFollowupButton1.tooltip;"/>
+ <toolbarseparator id="hdrFollowupSubSeparator"/>
+ <menuitem id="hdrFollowup_ReplyAllSubButton"
+ class="menuitem-iconic"
+ label="&hdrReplyAllButton1.label;"
+ tooltiptext="&hdrReplyAllButton1.tooltip;"
+ oncommand="MsgReplyToAllMessage(event); event.stopPropagation();"/>
+ <menuitem id="hdrFollowup_ReplySubButton"
+ class="menuitem-iconic"
+ label="&hdrReplyButton1.label;"
+ tooltiptext="&hdrReplyButton2.tooltip;"
+ oncommand="MsgReplySender(event); event.stopPropagation();"/>
+ </menupopup>
+ </toolbarbutton>
+ </toolbaritem>
+
+ <toolbarbutton id="hdrForwardButton"
+ label="&hdrForwardButton1.label;"
+ tooltiptext="&hdrForwardButton1.tooltip;"
+ oncommand="MsgForwardMessage(event);"
+ class="toolbarbutton-1 message-header-view-button hdrForwardButton"/>
+ <toolbarbutton id="hdrArchiveButton"
+ label="&hdrArchiveButton1.label;"
+ tooltiptext="&hdrArchiveButton1.tooltip;"
+ oncommand="goDoCommand('cmd_archive');"
+ class="toolbarbutton-1 message-header-view-button hdrArchiveButton"/>
+ <toolbarbutton id="hdrJunkButton" label="&hdrJunkButton1.label;"
+ tooltiptext="&hdrJunkButton1.tooltip;"
+ observes="cmd_markAsJunk"
+ class="toolbarbutton-1 message-header-view-button hdrJunkButton"
+ oncommand="goDoCommand('cmd_markAsJunk');"/>
+ <toolbarbutton id="hdrTrashButton"
+ label="&hdrTrashButton1.label;"
+ tooltiptext="&hdrTrashButton1.tooltip;"
+ observes="cmd_delete"
+ class="toolbarbutton-1 message-header-view-button hdrTrashButton"
+ oncommand="goDoCommand(event.shiftKey ? 'cmd_shiftDelete' : 'cmd_delete');"/>
+ <toolbarbutton id="otherActionsButton"
+ type="menu"
+ wantdropmarker="true"
+ label="&otherActionsButton2.label;"
+ tooltiptext="&otherActionsButton.tooltip;"
+ class="toolbarbutton-1 message-header-view-button">
+ <menupopup id="otherActionsPopup"
+ class="no-icon-menupopup"
+ onpopupshowing="onShowOtherActionsPopup();">
+ <menuitem id="otherActionsRedirect"
+ class="menuitem-iconic"
+ data-l10n-id="other-action-redirect-msg"
+ oncommand="MsgRedirectMessage();"/>
+#ifdef MAIN_WINDOW
+ <menuseparator id="otherActionsRedirectSeparator"/>
+ <menuitem id="otherActionsOpenConversation"
+ class="menuitem-iconic"
+ label="&otherActionsOpenConversation1.label;"
+ accesskey="&otherActionsOpenConversation1.accesskey;"
+ oncommand="new ConversationOpener(window).openConversationForMessages(gFolderDisplay.selectedMessages);"/>
+ <menuitem id="otherActionsOpenInNewWindow"
+ class="menuitem-iconic"
+ label="&otherActionsOpenInNewWindow1.label;"
+ accesskey="&otherActionsOpenInNewWindow1.accesskey;"
+ oncommand="MsgOpenNewWindowForMessage();"/>
+ <menuitem id="otherActionsOpenInNewTab"
+ class="menuitem-iconic"
+ label="&otherActionsOpenInNewTab1.label;"
+ accesskey="&otherActionsOpenInNewTab1.accesskey;"
+ oncommand="OpenMessageInNewTab(gFolderDisplay.selectedMessage, { event });"/>
+#endif
+ <menuseparator id="otherActionsSeparator"/>
+ <menu id="otherActionsTag"
+ class="menu-iconic"
+ label="&tagMenu.label;"
+ accesskey="&tagMenu.accesskey;">
+ <menupopup id="hdrTagDropdown"
+ onpopupshowing="window.top.InitMessageTags(this);">
+ <menuitem id="hdrTagDropdown-addNewTag"
+ label="&addNewTag.label;"
+ accesskey="&addNewTag.accesskey;"
+ oncommand="goDoCommand('cmd_addTag');"/>
+ <menuitem id="manageTags"
+ label="&manageTags.label;"
+ accesskey="&manageTags.accesskey;"
+ oncommand="goDoCommand('cmd_manageTags');"/>
+ <menuseparator id="hdrTagDropdown-sep-afterAddNewTag"/>
+ <menuitem id="hdrTagDropdown-tagRemoveAll"
+ oncommand="goDoCommand('cmd_removeTags');"/>
+ <menuseparator id="hdrTagDropdown-sep-afterTagRemoveAll"/>
+ </menupopup>
+ </menu>
+ <menuitem id="markAsReadMenuItem"
+ class="menuitem-iconic"
+ label="&markAsReadMenuItem1.label;"
+ accesskey="&markAsReadMenuItem1.accesskey;"
+ oncommand="MsgMarkMsgAsRead();"/>
+ <menuitem id="markAsUnreadMenuItem"
+ class="menuitem-iconic"
+ label="&markAsUnreadMenuItem1.label;"
+ accesskey="&markAsUnreadMenuItem1.accesskey;"
+ oncommand="MsgMarkMsgAsRead();"/>
+ <menuitem id="saveAsMenuItem"
+ class="menuitem-iconic"
+ label="&saveAsMenuItem1.label;"
+ accesskey="&saveAsMenuItem1.accesskey;"
+ oncommand="goDoCommand('cmd_saveAsFile')"/>
+ <menuitem id="otherActionsPrint"
+ class="menuitem-iconic"
+ label="&otherActionsPrint1.label;"
+ accesskey="&otherActionsPrint1.accesskey;"
+ oncommand="goDoCommand('cmd_print');"/>
+ <menu id="otherActions-calendar-convert-menu"
+ class="menu-iconic"
+ label="&calendar.context.convertmenu.label;"
+ accesskey="&calendar.context.convertmenu.accesskey.mail;">
+ <menupopup id="otherActions-calendar-convert-menupopup"
+ class="no-icon-menupopup">
+ <menuitem id="otherActions-calendar-convert-event-menuitem"
+ class="menuitem-iconic"
+ label="&calendar.context.convertmenu.event.label;"
+ accesskey="&calendar.context.convertmenu.event.accesskey;"
+ oncommand="convertToEventOrTask(true);"/>
+ <menuitem id="otherActions-calendar-convert-task-menuitem"
+ class="menuitem-iconic"
+ label="&calendar.context.convertmenu.task.label;"
+ accesskey="&calendar.context.convertmenu.task.accesskey;"
+ oncommand="convertToEventOrTask(false);"/>
+ </menupopup>
+ </menu>
+ <menuseparator id="otherActionsComposeSeparator"/>
+ <menuitem id="viewSourceMenuItem"
+ class="menuitem-iconic"
+ label="&viewSourceMenuItem1.label;"
+ accesskey="&viewSourceMenuItem1.accesskey;"
+ oncommand="goDoCommand('cmd_viewPageSource');"/>
+ <menuitem id="charsetRepairMenuitem"
+ class="menuitem-iconic"
+ data-l10n-id="repair-text-encoding-button"
+ oncommand="MailSetCharacterSet()"/>
+ <menu id="otherActionsMessageBodyAs"
+ class="menu-iconic"
+ label="&bodyMenu.label;">
+ <menupopup id="hdrMessageBodyAsDropdown"
+ class="menu-iconic"
+ onpopupshowing="InitOtherActionsViewBodyMenu();">
+ <menuitem id="otherActionsMenu_bodyAllowHTML"
+ class="menuitem-iconic"
+ type="radio"
+ name="bodyPlaintextVsHTMLPref"
+ label="&bodyAllowHTML.label;"
+ oncommand="top.MsgBodyAllowHTML()"/>
+ <menuitem id="otherActionsMenu_bodySanitized"
+ class="menuitem-iconic"
+ type="radio"
+ name="bodyPlaintextVsHTMLPref"
+ label="&bodySanitized.label;"
+ oncommand="top.MsgBodySanitized()"/>
+ <menuitem id="otherActionsMenu_bodyAsPlaintext"
+ class="menuitem-iconic"
+ type="radio"
+ name="bodyPlaintextVsHTMLPref"
+ label="&bodyAsPlaintext.label;"
+ oncommand="top.MsgBodyAsPlaintext()"/>
+ <menuitem id="otherActionsMenu_bodyAllParts"
+ class="menuitem-iconic"
+ type="radio"
+ name="bodyPlaintextVsHTMLPref"
+ label="&bodyAllParts.label;"
+ oncommand="top.MsgBodyAllParts()"/>
+ </menupopup>
+ </menu>
+ <menu id="otherActionsFeedBodyAs"
+ class="menu-iconic"
+ label="&bodyMenu.label;">
+ <menupopup id="hdrFeedBodyAsDropdown"
+ class="menu-iconic"
+ onpopupshowing="InitOtherActionsViewBodyMenu();">
+ <menuitem id="otherActionsMenu_bodyFeedGlobalWebPage"
+ class="menuitem-iconic"
+ type="radio"
+ name="viewFeedSummaryGroup"
+ label="&viewFeedWebPage.label;"
+ observes="bodyFeedGlobalWebPage"
+ oncommand="FeedMessageHandler.onSelectPref = 0"/>
+ <menuitem id="otherActionsMenu_bodyFeedGlobalSummary"
+ class="menuitem-iconic"
+ type="radio"
+ name="viewFeedSummaryGroup"
+ label="&viewFeedSummary.label;"
+ observes="bodyFeedGlobalSummary"
+ oncommand="FeedMessageHandler.onSelectPref = 1"/>
+ <menuitem id="otherActionsMenu_bodyFeedPerFolderPref"
+ class="menuitem-iconic"
+ type="radio"
+ name="viewFeedSummaryGroup"
+ label="&viewFeedSummaryFeedPropsPref.label;"
+ observes="bodyFeedPerFolderPref"
+ oncommand="FeedMessageHandler.onSelectPref = 2"/>
+ <menuseparator id="otherActionsMenu_viewFeedSummarySeparator"/>
+ <menuitem id="otherActionsMenu_bodyFeedSummaryAllowHTML"
+ class="menuitem-iconic"
+ type="radio"
+ name="viewFeedBodyHTMLGroup"
+ label="&bodyAllowHTML.label;"
+ oncommand="top.MsgFeedBodyRenderPrefs(false, 0, 0)"/>
+ <menuitem id="otherActionsMenu_bodyFeedSummarySanitized"
+ class="menuitem-iconic"
+ type="radio"
+ name="viewFeedBodyHTMLGroup"
+ label="&bodySanitized.label;"
+ oncommand="top.MsgFeedBodyRenderPrefs(false, 3, gDisallow_classes_no_html)"/>
+ <menuitem id="otherActionsMenu_bodyFeedSummaryAsPlaintext"
+ class="menuitem-iconic"
+ type="radio"
+ name="viewFeedBodyHTMLGroup"
+ label="&bodyAsPlaintext.label;"
+ oncommand="top.MsgFeedBodyRenderPrefs(true, 1, gDisallow_classes_no_html)"/>
+ </menupopup>
+ </menu>
+ <menuseparator/>
+ <menuitem id="messageHeaderMoreMenuCustomize"
+ class="menuitem-iconic"
+ data-l10n-id="menuitem-customize-label"
+ oncommand="gHeaderCustomize.showPanel()"/>
+ </menupopup>
+ </toolbarbutton>
+ <html:button id="starMessageButton" role="checkbox" aria-checked="false"
+ class="plain-button email-action-button email-action-flagged"
+ data-l10n-id="message-header-msg-flagged">
+ <html:img src="chrome://messenger/skin/icons/new/compact/star.svg" alt="" />
+ </html:button>
+ </hbox>
+ </html:div>
+
+ <html:div id="expandedfromRow" class="header-row-grow">
+ <label id="expandedfromLabel" class="message-header-label header-pill-label"
+ value="&fromField4.label;"
+ valueFrom="&fromField4.label;" valueAuthor="&author.label;"/>
+ <html:div id="expandedfromBox" is="multi-recipient-row"
+ data-header-name="from" data-show-all="true"></html:div>
+ </html:div>
+ </html:div>
+
+ <!-- To recipients + date -->
+ <html:div id="expandedtoRow" class="message-header-row">
+ <html:div class="header-row-grow">
+ <label id="expandedtoLabel" class="message-header-label header-pill-label"
+ value="&toField4.label;"/>
+ <html:div id="expandedtoBox" is="multi-recipient-row"
+ data-header-name="to"></html:div>
+ </html:div>
+ <html:time id="dateLabel"
+ class="message-header-datetime"
+ aria-readonly="true"></html:time>
+ </html:div>
+
+ <!-- Cc recipients -->
+ <html:div id="expandedccRow" class="message-header-row" hidden="hidden">
+ <label id="expandedccLabel" class="message-header-label header-pill-label"
+ value="&ccField4.label;"/>
+ <html:div id="expandedccBox" is="multi-recipient-row"
+ data-header-name="cc"></html:div>
+ </html:div>
+
+ <!-- Bcc recipients -->
+ <html:div id="expandedbccRow" class="message-header-row" hidden="hidden">
+ <label id="expandedbccLabel" class="message-header-label header-pill-label"
+ value="&bccField4.label;"/>
+ <html:div id="expandedbccBox" is="multi-recipient-row"
+ data-header-name="bcc"></html:div>
+ </html:div>
+
+ <!-- Reply-to -->
+ <html:div id="expandedreply-toRow" class="message-header-row" hidden="hidden">
+ <label id="expandedreply-toLabel" class="message-header-label header-pill-label"
+ value="&replyToField4.label;"/>
+ <html:div id="expandedreply-toBox" is="multi-recipient-row"
+ data-header-name="reply-to"></html:div>
+ </html:div>
+
+ <!-- Organization -->
+ <html:div id="expandedorganizationRow" class="message-header-row" hidden="hidden">
+ <label id="expandedorganizationLabel" class="message-header-label"
+ value="&organizationField4.label;"/>
+ <html:div id="expandedorganizationBox" is="simple-header-row"
+ data-header-name="organization"></html:div>
+ </html:div>
+
+ <!-- Sender -->
+ <html:div id="expandedsenderRow" class="message-header-row" hidden="hidden">
+ <label id="expandedsenderLabel" class="message-header-label header-pill-label"
+ value="&senderField4.label;"/>
+ <html:div id="expandedsenderBox" is="multi-recipient-row"
+ data-header-name="sender"></html:div>
+ </html:div>
+
+ <!-- Newsgroups -->
+ <html:div id="expandednewsgroupsRow" class="message-header-row" hidden="hidden">
+ <label id="expandednewsgroupsLabel" class="message-header-label header-pill-label"
+ value="&newsgroupsField4.label;"/>
+ <html:div id="expandednewsgroupsBox" is="header-newsgroups-row"
+ data-header-name="newsgroups"/>
+ </html:div>
+
+ <!-- Follow up -->
+ <html:div id="expandedfollowup-toRow" class="message-header-row" hidden="hidden">
+ <label id="expandedfollowup-toLabel" class="message-header-label header-pill-label"
+ value="&followupToField4.label;"/>
+ <html:div id="expandedfollowup-toBox"
+ is="header-newsgroups-row"
+ data-header-name="followup-to"/>
+ </html:div>
+
+ <!-- Subject + security info + extra date label for hidden To variation -->
+ <html:div id="headerSubjectSecurityContainer" class="message-header-row">
+ <html:div id="expandedsubjectRow" class="header-row-grow">
+ <label id="expandedsubjectLabel" class="message-header-label"
+ value="&subjectField4.label;"/>
+ <html:div id="expandedsubjectBox" is="simple-header-row"
+ data-header-name="subject"></html:div>
+ </html:div>
+ <html:div id="cryptoBox" hidden="hidden">
+ <html:button id="encryptionTechBtn"
+ class="toolbarbutton-1 crypto-button themeable-brighttext button-focusable"
+ data-l10n-id="message-security-button">
+ <html:span class="crypto-label"></html:span>
+ <html:img id="encryptedHdrIcon" hidden="hidden" alt="" />
+ <html:img id="signedHdrIcon" hidden="hidden" alt="" />
+ </html:button>
+ </html:div>
+ <html:time id="dateLabelSubject"
+ class="message-header-datetime"
+ aria-readonly="true"
+ hidden="hidden"></html:time>
+ </html:div>
+
+ <!-- Tags -->
+ <html:div id="expandedtagsRow" class="message-header-row" hidden="hidden">
+ <label id="expandedtagsLabel" class="message-header-label"
+ value="&tagsHdr4.label;"/>
+ <html:div id="expandedtagsBox" is="header-tags-row"
+ data-header-name="tags"></html:div>
+ </html:div>
+
+ <!-- Date -->
+ <html:div id="expandeddateRow" class="message-header-row" hidden="hidden">
+ <label id="expandeddateLabel" class="message-header-label"
+ value="&dateField4.label;"/>
+ <html:div id="expandeddateBox" is="simple-header-row"
+ data-header-name="date"></html:div>
+ </html:div>
+
+ <!-- Message ID -->
+ <html:div id="expandedmessage-idRow" class="message-header-row" hidden="hidden">
+ <label id="expandedmessage-idLabel" class="message-header-label header-pill-label"
+ value="&messageIdField4.label;"/>
+ <html:div id="expandedmessage-idBox" is="multi-message-ids-row"
+ data-header-name="message-id"></html:div>
+ </html:div>
+
+ <!-- In reply to -->
+ <html:div id="expandedin-reply-toRow" class="message-header-row" hidden="hidden">
+ <label id="expandedin-reply-toLabel" class="message-header-label header-pill-label"
+ value="&inReplyToField4.label;"/>
+ <html:div id="expandedin-reply-toBox" is="multi-message-ids-row"
+ data-header-name="in-reply-to"></html:div>
+ </html:div>
+
+ <!-- Reference -->
+ <html:div id="expandedreferencesRow" class="message-header-row" hidden="hidden">
+ <label id="expandedreferencesLabel" class="message-header-label header-pill-label"
+ value="&referencesField4.label;"/>
+ <html:div id="expandedreferencesBox" is="multi-message-ids-row"
+ data-header-name="references"></html:div>
+ </html:div>
+
+ <!-- Content base -->
+ <html:div id="expandedcontent-baseRow" class="message-header-row" hidden="hidden">
+ <label id="expandedcontent-baseLabel" class="message-header-label"
+ value="&originalWebsite4.label;"/>
+ <html:div id="expandedcontent-baseBox" is="url-header-row"
+ data-header-name="content-base"></html:div>
+ </html:div>
+
+ <!-- User agent -->
+ <html:div id="expandeduser-agentRow" class="message-header-row" hidden="hidden">
+ <label id="expandeduser-agentLabel" class="message-header-label"
+ value="&userAgentField4.label;"/>
+ <html:div id="expandeduser-agentBox" is="simple-header-row"
+ data-header-name="user-agent"></html:div>
+ </html:div>
+
+ <!-- All extra headers will be dynamically added here. -->
+ <html:div id="extraHeadersArea" class="message-header-extra-container"></html:div>
+
+</html:header>
+<!-- END Header container -->
+
+<panel id="messageHeaderCustomizationPanel"
+ type="arrow"
+ orient="vertical"
+ class="cui-widget-panel popup-panel panel-no-padding"
+ onpopupshowing="gHeaderCustomize.onPanelShowing();"
+ onpopuphidden="gHeaderCustomize.onPanelHidden();">
+ <html:div class="popup-panel-body">
+ <html:h3 data-l10n-id="message-header-customize-panel-title"></html:h3>
+
+ <html:div class="popup-panel-options-grid">
+ <label data-l10n-id="message-header-customize-button-style"
+ control="headerButtonStyle"/>
+ <menulist id="headerButtonStyle"
+ oncommand="gHeaderCustomize.updateButtonStyle(event);"
+ crop="none">
+ <menupopup>
+ <menuitem value="default" data-l10n-id="message-header-button-style-default"/>
+ <menuitem value="only-text" data-l10n-id="message-header-button-style-text"/>
+ <menuitem value="only-icons" data-l10n-id="message-header-button-style-icons"/>
+ </menupopup>
+ </menulist>
+
+ <html:div class="popup-panel-column-container">
+ <checkbox id="headerShowAvatar"
+ data-l10n-id="message-header-show-recipient-avatar"
+ oncommand="gHeaderCustomize.toggleAvatar(event);"/>
+
+ <checkbox id="headerShowBigAvatar" class="indent"
+ data-l10n-id="message-header-show-big-avatar"
+ oncommand="gHeaderCustomize.toggleBigAvatar(event);"/>
+
+ <checkbox id="headerShowFullAddress"
+ data-l10n-id="message-header-show-sender-full-address"
+ oncommand="gHeaderCustomize.toggleSenderAddress(event);"/>
+ <html:span class="checkbox-description"
+ data-l10n-id="message-header-show-sender-full-address-description"></html:span>
+
+ <checkbox id="headerHideLabels"
+ data-l10n-id="message-header-hide-label-column"
+ oncommand="gHeaderCustomize.toggleLabelColumn(event);"/>
+
+ <checkbox id="headerSubjectLarge"
+ data-l10n-id="message-header-large-subject"
+ oncommand="gHeaderCustomize.updateSubjectStyle(event);"/>
+
+ <checkbox id="headerViewAllHeaders"
+ data-l10n-id="message-header-all-headers"
+ oncommand="gHeaderCustomize.toggleAllHeaders(event);"/>
+ </html:div>
+ </html:div>
+
+ <html:div class="popup-panel-buttons-container">
+ <html:button type="button"
+ data-l10n-id="customize-panel-button-save"
+ onclick="gHeaderCustomize.closePanel()"
+ class="primary">
+ </html:button>
+ </html:div>
+ </html:div>
+</panel>
diff --git a/comm/mail/base/content/msgHdrView.js b/comm/mail/base/content/msgHdrView.js
new file mode 100644
index 0000000000..1579cddbfe
--- /dev/null
+++ b/comm/mail/base/content/msgHdrView.js
@@ -0,0 +1,4501 @@
+/* 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/. */
+
+/**
+ * Functions related to displaying the headers for a selected message in the
+ * message pane.
+ */
+
+/* import-globals-from ../../../../toolkit/content/contentAreaUtils.js */
+/* import-globals-from ../../../calendar/base/content/imip-bar.js */
+/* import-globals-from ../../../mailnews/extensions/newsblog/newsblogOverlay.js */
+/* import-globals-from ../../extensions/smime/content/msgHdrViewSMIMEOverlay.js */
+/* import-globals-from aboutMessage.js */
+/* import-globals-from editContactPanel.js */
+/* import-globals-from globalOverlay.js */
+/* import-globals-from mailContext.js */
+/* import-globals-from mail-offline.js */
+/* import-globals-from mailCore.js */
+/* import-globals-from msgSecurityPane.js */
+
+/* globals MozElements */
+
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ AttachmentInfo: "resource:///modules/AttachmentInfo.sys.mjs",
+ PluralForm: "resource://gre/modules/PluralForm.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ calendarDeactivator:
+ "resource:///modules/calendar/calCalendarDeactivator.jsm",
+ Gloda: "resource:///modules/gloda/GlodaPublic.jsm",
+ GlodaUtils: "resource:///modules/gloda/GlodaUtils.jsm",
+ MailUtils: "resource:///modules/MailUtils.jsm",
+ MessageArchiver: "resource:///modules/MessageArchiver.jsm",
+ PgpSqliteDb2: "chrome://openpgp/content/modules/sqliteDb.jsm",
+});
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "gDbService",
+ "@mozilla.org/msgDatabase/msgDBService;1",
+ "nsIMsgDBService"
+);
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "gMIMEService",
+ "@mozilla.org/mime;1",
+ "nsIMIMEService"
+);
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "gHandlerService",
+ "@mozilla.org/uriloader/handler-service;1",
+ "nsIHandlerService"
+);
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "gEncryptedSMIMEURIsService",
+ "@mozilla.org/messenger-smime/smime-encrypted-uris-service;1",
+ Ci.nsIEncryptedSMIMEURIsService
+);
+
+// Warning: It's critical that the code in here for displaying the message
+// headers for a selected message remain as fast as possible. In particular,
+// right now, we only introduce one reflow per message. i.e. if you click on
+// a message in the thread pane, we batch up all the changes for displaying
+// the header pane (to, cc, attachments button, etc.) and we make a single
+// pass to display them. It's critical that we maintain this one reflow per
+// message view in the message header pane.
+
+var gViewAllHeaders = false;
+var gMinNumberOfHeaders = 0;
+var gDummyHeaderIdIndex = 0;
+var gBuildAttachmentsForCurrentMsg = false;
+var gBuiltExpandedView = false;
+var gHeadersShowReferences = false;
+
+/**
+ * Show the friendly display names for people I know,
+ * instead of the name + email address.
+ */
+var gShowCondensedEmailAddresses;
+
+/**
+ * Other components may listen to on start header & on end header notifications
+ * for each message we display: to do that you need to add yourself to our
+ * gMessageListeners array with an object that supports the three properties:
+ * onStartHeaders, onEndHeaders and onEndAttachments.
+ *
+ * Additionally, if your object has an onBeforeShowHeaderPane() method, it will
+ * be called at the appropriate time. This is designed to give add-ons a
+ * chance to examine and modify the currentHeaderData array before it gets
+ * displayed.
+ */
+var gMessageListeners = [];
+
+/**
+ * List fo common headers that need to be populated.
+ *
+ * For every possible "view" in the message pane, you need to define the header
+ * names you want to see in that view. In addition, include information
+ * describing how you want that header field to be presented. We'll then use
+ * this static table to dynamically generate header view entries which
+ * manipulate the UI.
+ *
+ * @param {string} name - The name of the header. i.e. "to", "subject". This
+ * must be in lower case and the name of the header is used to help
+ * dynamically generate ids for objects in the document.
+ * @param {Function} outputFunction - This is a method which takes a headerEntry
+ * (see the definition below) and a header value. This allows to provide a
+ * unique methods for determining how the header value is displayed. Defaults
+ * to updateHeaderValue which just sets the header value on the text node.
+ */
+const gExpandedHeaderList = [
+ { name: "subject" },
+ { name: "from", outputFunction: outputEmailAddresses },
+ { name: "reply-to", outputFunction: outputEmailAddresses },
+ { name: "to", outputFunction: outputEmailAddresses },
+ { name: "cc", outputFunction: outputEmailAddresses },
+ { name: "bcc", outputFunction: outputEmailAddresses },
+ { name: "newsgroups", outputFunction: outputNewsgroups },
+ { name: "references", outputFunction: outputMessageIds },
+ { name: "followup-to", outputFunction: outputNewsgroups },
+ { name: "content-base" },
+ { name: "tags", outputFunction: outputTags },
+];
+
+/**
+ * These are all the items that use a multi-recipient-row widget and
+ * therefore may require updating if the address book changes.
+ */
+var gEmailAddressHeaderNames = [
+ "from",
+ "reply-to",
+ "to",
+ "cc",
+ "bcc",
+ "toCcBcc",
+];
+
+/**
+ * Now, for each view the message pane can generate, we need a global table of
+ * headerEntries. These header entry objects are generated dynamically based on
+ * the static data in the header lists (see above) and elements we find in the
+ * DOM based on properties in the header lists.
+ */
+var gExpandedHeaderView = {};
+
+/**
+ * This is an array of header name and value pairs for the currently displayed
+ * message. It's purely a data object and has no view information. View
+ * information is contained in the view objects.
+ * For a given entry in this array you can ask for:
+ * .headerName name of the header (i.e. 'to'). Always stored in lower case
+ * .headerValue value of the header "johndoe@example.com"
+ */
+var currentHeaderData = {};
+
+/**
+ * CurrentAttachments is an array of AttachmentInfo objects.
+ */
+var currentAttachments = [];
+
+/**
+ * The character set of the message, according to the MIME parser.
+ */
+var currentCharacterSet = "";
+
+/**
+ * Folder database listener object. This is used alongside the
+ * nsIDBChangeListener implementation in order to listen for the changes of the
+ * messages' flags that don't trigger a messageHeaderSink.processHeaders().
+ * For now, it's used only for the flagged/marked/starred flag, but it could be
+ * extended to handle other flags changes and remove the full header reload.
+ */
+var gFolderDBListener = null;
+
+// Timer to mark read, if the user has configured the app to mark a message as
+// read if it is viewed for more than n seconds.
+var gMarkViewedMessageAsReadTimer = null;
+
+// Per message header flags to keep track of whether the user is allowing remote
+// content for a particular message.
+// if you change or add more values to these constants, be sure to modify
+// the corresponding definitions in nsMsgContentPolicy.cpp
+var kNoRemoteContentPolicy = 0;
+var kBlockRemoteContent = 1;
+var kAllowRemoteContent = 2;
+
+class FolderDBListener {
+ constructor(folder) {
+ // Keep a record of the currently selected folder to check when the
+ // selection changes to avoid initializing the DBListener in case the same
+ // folder is selected.
+ this.selectedFolder = folder;
+ this.isRegistered = false;
+ }
+
+ register() {
+ gDbService.registerPendingListener(this.selectedFolder, this);
+ this.isRegistered = true;
+ }
+
+ unregister() {
+ gDbService.unregisterPendingListener(this);
+ this.isRegistered = false;
+ }
+
+ /** @implements {nsIDBChangeListener} */
+ onHdrFlagsChanged(hdrChanged, oldFlags, newFlags, instigator) {
+ // Bail out if the changed message isn't the one currently displayed.
+ if (hdrChanged != gMessage) {
+ return;
+ }
+
+ // Check if the flagged/marked/starred state was changed.
+ if (
+ newFlags & Ci.nsMsgMessageFlags.Marked ||
+ oldFlags & Ci.nsMsgMessageFlags.Marked
+ ) {
+ updateStarButton();
+ }
+ }
+ onHdrDeleted(hdrChanged, parentKey, flags, instigator) {}
+ onHdrAdded(hdrChanged, parentKey, flags, instigator) {}
+ onParentChanged(keyChanged, oldParent, newParent, instigator) {}
+ onAnnouncerGoingAway(instigator) {}
+ onReadChanged(instigator) {}
+ onJunkScoreChanged(instigator) {}
+ onHdrPropertyChanged(hdrToChange, property, preChange, status, instigator) {
+ // Not interested before a change, or if the message isn't the one displayed,
+ // or an .eml file from disk or an attachment.
+ if (preChange || gMessage != hdrToChange) {
+ return;
+ }
+ switch (property) {
+ case "keywords":
+ OnTagsChange();
+ break;
+ case "junkscore":
+ HandleJunkStatusChanged(hdrToChange);
+ break;
+ }
+ }
+ onEvent(db, event) {}
+}
+
+/**
+ * Initialize the nsIDBChangeListener when a new folder is selected in order to
+ * listen for any flags change happening in the currently displayed messages.
+ */
+function initFolderDBListener() {
+ // Bail out if we don't have a selected message, or we already have a
+ // DBListener initialized and the folder didn't change.
+ if (
+ !gFolder ||
+ (gFolderDBListener?.isRegistered &&
+ gFolderDBListener.selectedFolder == gFolder)
+ ) {
+ return;
+ }
+
+ // Clearly we are viewing a different message in a different folder, so clear
+ // any remaining of the old DBListener.
+ clearFolderDBListener();
+
+ gFolderDBListener = new FolderDBListener(gFolder);
+ gFolderDBListener.register();
+}
+
+/**
+ * Unregister the listener and clear the object if we already have one, meaning
+ * the user just changed folder or deselected all messages.
+ */
+function clearFolderDBListener() {
+ if (gFolderDBListener?.isRegistered) {
+ gFolderDBListener.unregister();
+ gFolderDBListener = null;
+ }
+}
+
+/**
+ * Our class constructor method which creates a header Entry based on an entry
+ * in one of the header lists. A header entry is different from a header list.
+ * A header list just describes how you want a particular header to be
+ * presented. The header entry actually has knowledge about the DOM
+ * and the actual DOM elements associated with the header.
+ *
+ * @param prefix the name of the view (e.g. "expanded")
+ * @param headerListInfo entry from a header list.
+ */
+class MsgHeaderEntry {
+ constructor(prefix, headerListInfo) {
+ this.enclosingBox = document.getElementById(
+ `${prefix}${headerListInfo.name}Box`
+ );
+ this.enclosingRow = this.enclosingBox.closest(".message-header-row");
+ this.isNewHeader = false;
+ this.valid = false;
+ this.outputFunction = headerListInfo.outputFunction || updateHeaderValue;
+ }
+}
+
+function initializeHeaderViewTables() {
+ // Iterate over each header in our header list arrays and create header entries
+ // for each one. These header entries are then stored in the appropriate header
+ // table.
+ for (let header of gExpandedHeaderList) {
+ gExpandedHeaderView[header.name] = new MsgHeaderEntry("expanded", header);
+ }
+
+ let extraHeaders = Services.prefs
+ .getCharPref("mailnews.headers.extraExpandedHeaders")
+ .split(" ");
+ for (let extraHeaderName of extraHeaders) {
+ if (!extraHeaderName.trim()) {
+ continue;
+ }
+ gExpandedHeaderView[extraHeaderName.toLowerCase()] = new HeaderView(
+ extraHeaderName,
+ extraHeaderName
+ );
+ }
+
+ let otherHeaders = Services.prefs
+ .getCharPref("mail.compose.other.header", "")
+ .split(",")
+ .map(h => h.trim())
+ .filter(Boolean);
+
+ for (let otherHeaderName of otherHeaders) {
+ gExpandedHeaderView[otherHeaderName.toLowerCase()] = new HeaderView(
+ otherHeaderName,
+ otherHeaderName
+ );
+ }
+
+ if (Services.prefs.getBoolPref("mailnews.headers.showOrganization")) {
+ var organizationEntry = {
+ name: "organization",
+ outputFunction: updateHeaderValue,
+ };
+ gExpandedHeaderView[organizationEntry.name] = new MsgHeaderEntry(
+ "expanded",
+ organizationEntry
+ );
+ }
+
+ if (Services.prefs.getBoolPref("mailnews.headers.showUserAgent")) {
+ var userAgentEntry = {
+ name: "user-agent",
+ outputFunction: updateHeaderValue,
+ };
+ gExpandedHeaderView[userAgentEntry.name] = new MsgHeaderEntry(
+ "expanded",
+ userAgentEntry
+ );
+ }
+
+ if (Services.prefs.getBoolPref("mailnews.headers.showMessageId")) {
+ var messageIdEntry = {
+ name: "message-id",
+ outputFunction: outputMessageIds,
+ };
+ gExpandedHeaderView[messageIdEntry.name] = new MsgHeaderEntry(
+ "expanded",
+ messageIdEntry
+ );
+ }
+
+ if (Services.prefs.getBoolPref("mailnews.headers.showSender")) {
+ let senderEntry = {
+ name: "sender",
+ outputFunction: outputEmailAddresses,
+ };
+ gExpandedHeaderView[senderEntry.name] = new MsgHeaderEntry(
+ "expanded",
+ senderEntry
+ );
+ }
+}
+
+async function OnLoadMsgHeaderPane() {
+ // Load any preferences that at are global with regards to
+ // displaying a message...
+ gMinNumberOfHeaders = Services.prefs.getIntPref(
+ "mailnews.headers.minNumHeaders"
+ );
+ gShowCondensedEmailAddresses = Services.prefs.getBoolPref(
+ "mail.showCondensedAddresses"
+ );
+ gHeadersShowReferences = Services.prefs.getBoolPref(
+ "mailnews.headers.showReferences"
+ );
+
+ Services.obs.addObserver(MsgHdrViewObserver, "remote-content-blocked");
+ Services.prefs.addObserver("mail.showCondensedAddresses", MsgHdrViewObserver);
+ Services.prefs.addObserver(
+ "mailnews.headers.showReferences",
+ MsgHdrViewObserver
+ );
+
+ initializeHeaderViewTables();
+
+ // Add the keyboard shortcut event listener for the message header.
+ // Ctrl+Alt+S / Cmd+Control+S. We don't use the Alt/Option key on macOS
+ // because it alters the pressed key to an ASCII character. See bug 1692263.
+ let shortcut = await document.l10n.formatValue(
+ "message-header-show-security-info-key"
+ );
+ document.addEventListener("keypress", event => {
+ if (
+ event.ctrlKey &&
+ (event.altKey || event.metaKey) &&
+ event.key.toLowerCase() == shortcut.toLowerCase()
+ ) {
+ showMessageReadSecurityInfo();
+ }
+ });
+
+ headerToolbarNavigation.init();
+
+ // Set up event listeners for the encryption technology button and panel.
+ document
+ .getElementById("encryptionTechBtn")
+ .addEventListener("click", showMessageReadSecurityInfo);
+ let panel = document.getElementById("messageSecurityPanel");
+ panel.addEventListener("popuphidden", onMessageSecurityPopupHidden);
+
+ // Set the flag/star button on click listener.
+ document
+ .getElementById("starMessageButton")
+ .addEventListener("click", MsgMarkAsFlagged);
+
+ // Dispatch an event letting any listeners know that we have loaded
+ // the message pane.
+ let headerViewElement = document.getElementById("msgHeaderView");
+ headerViewElement.loaded = true;
+ headerViewElement.dispatchEvent(
+ new Event("messagepane-loaded", { bubbles: false, cancelable: true })
+ );
+
+ getMessagePaneBrowser().addProgressListener(
+ messageProgressListener,
+ Ci.nsIWebProgress.NOTIFY_STATE_ALL
+ );
+
+ gHeaderCustomize.init();
+}
+
+function OnUnloadMsgHeaderPane() {
+ let headerViewElement = document.getElementById("msgHeaderView");
+ if (!headerViewElement.loaded) {
+ // We're unloading, but we never loaded.
+ return;
+ }
+
+ Services.obs.removeObserver(MsgHdrViewObserver, "remote-content-blocked");
+ Services.prefs.removeObserver(
+ "mail.showCondensedAddresses",
+ MsgHdrViewObserver
+ );
+ Services.prefs.removeObserver(
+ "mailnews.headers.showReferences",
+ MsgHdrViewObserver
+ );
+
+ clearFolderDBListener();
+
+ // Dispatch an event letting any listeners know that we have unloaded
+ // the message pane.
+ headerViewElement.dispatchEvent(
+ new Event("messagepane-unloaded", { bubbles: false, cancelable: true })
+ );
+}
+
+var MsgHdrViewObserver = {
+ observe(subject, topic, data) {
+ // verify that we're changing the mail pane config pref
+ if (topic == "nsPref:changed") {
+ // We don't need to call ReloadMessage() in either of these conditions
+ // because a preference observer for these preferences already does it.
+ if (data == "mail.showCondensedAddresses") {
+ gShowCondensedEmailAddresses = Services.prefs.getBoolPref(
+ "mail.showCondensedAddresses"
+ );
+ } else if (data == "mailnews.headers.showReferences") {
+ gHeadersShowReferences = Services.prefs.getBoolPref(
+ "mailnews.headers.showReferences"
+ );
+ }
+ } else if (topic == "remote-content-blocked") {
+ let browser = getMessagePaneBrowser();
+ if (
+ browser.browsingContext.id == data ||
+ browser.browsingContext == BrowsingContext.get(data)?.top
+ ) {
+ gMessageNotificationBar.setRemoteContentMsg(
+ null,
+ subject,
+ !gEncryptedSMIMEURIsService.isEncrypted(browser.currentURI.spec)
+ );
+ }
+ }
+ },
+};
+
+/**
+ * Receives a message's headers as we display the message through our mime converter.
+ *
+ * @see {nsIMailChannel}
+ * @implements {nsIMailProgressListener}
+ * @implements {nsIWebProgressListener}
+ * @implements {nsISupportsWeakReference}
+ */
+var messageProgressListener = {
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIMailProgressListener",
+ "nsIWebProgressListener",
+ "nsISupportsWeakReference",
+ ]),
+
+ /**
+ * Step 1: A message has started loading (if the flags include STATE_START).
+ *
+ * @param {nsIWebProgress} webProgress
+ * @param {nsIRequest} request
+ * @param {integer} stateFlags
+ * @param {nsresult} status
+ * @see {nsIWebProgressListener}
+ */
+ onStateChange(webProgress, request, stateFlags, status) {
+ if (
+ !(request instanceof Ci.nsIMailChannel) ||
+ !(stateFlags & Ci.nsIWebProgressListener.STATE_START)
+ ) {
+ return;
+ }
+
+ // Clear the previously displayed message.
+ const previousDocElement =
+ getMessagePaneBrowser().contentDocument?.documentElement;
+ if (previousDocElement) {
+ previousDocElement.style.display = "none";
+ }
+ ClearAttachmentList();
+ gMessageNotificationBar.clearMsgNotifications();
+
+ request.listener = this;
+ request.smimeHeaderSink = smimeHeaderSink;
+ this.onStartHeaders();
+ },
+
+ /**
+ * Step 2: The message headers are available on the channel.
+ *
+ * @param {nsIMailChannel} mailChannel
+ * @see {nsIMailProgressListener}
+ */
+ onHeadersComplete(mailChannel) {
+ const domWindow = getMessagePaneBrowser().docShell.DOMWindow;
+ domWindow.addEventListener(
+ "DOMContentLoaded",
+ event => this.onDOMContentLoaded(event),
+ { once: true }
+ );
+ this.processHeaders(mailChannel.headerNames, mailChannel.headerValues);
+ },
+
+ /**
+ * Step 3: The parser has finished reading the body of the message.
+ *
+ * @param {nsIMailChannel} mailChannel
+ * @see {nsIMailProgressListener}
+ */
+ onBodyComplete(mailChannel) {
+ autoMarkAsRead();
+ },
+
+ /**
+ * Step 4: The attachment information is available on the channel.
+ *
+ * @param {nsIMailChannel} mailChannel
+ * @see {nsIMailProgressListener}
+ */
+ onAttachmentsComplete(mailChannel) {
+ for (const attachment of mailChannel.attachments) {
+ this.handleAttachment(
+ attachment.getProperty("contentType"),
+ attachment.getProperty("url"),
+ attachment.getProperty("displayName"),
+ attachment.getProperty("uri"),
+ attachment.getProperty("notDownloaded")
+ );
+ for (const key of [
+ "X-Mozilla-PartURL",
+ "X-Mozilla-PartSize",
+ "X-Mozilla-PartDownloaded",
+ "Content-Description",
+ "Content-Type",
+ "Content-Encoding",
+ ]) {
+ if (attachment.hasKey(key)) {
+ this.addAttachmentField(key, attachment.getProperty(key));
+ }
+ }
+ }
+ },
+
+ /**
+ * Step 5: The message HTML is complete, but external resources such as may
+ * not have loaded yet. The docShell will handle them – for our purposes,
+ * message loading has finished.
+ */
+ onDOMContentLoaded(event) {
+ const { docShell } = event.target.ownerGlobal;
+ if (!docShell.isTopLevelContentDocShell) {
+ return;
+ }
+
+ const channel = docShell.currentDocumentChannel;
+ channel.QueryInterface(Ci.nsIMailChannel);
+ currentCharacterSet = channel.mailCharacterSet;
+ channel.smimeHeaderSink = null;
+ if (channel.imipItem) {
+ calImipBar.showImipBar(channel.imipItem, channel.imipMethod);
+ }
+ this.onEndAllAttachments();
+ const uri = channel.URI.QueryInterface(Ci.nsIMsgMailNewsUrl);
+ this.onEndMsgHeaders(uri);
+ this.onEndMsgDownload(uri);
+ },
+
+ onStartHeaders() {
+ // Every time we start to redisplay a message, check the view all headers
+ // pref...
+ let showAllHeadersPref = Services.prefs.getIntPref("mail.show_headers");
+ if (showAllHeadersPref == 2) {
+ // eslint-disable-next-line no-global-assign
+ gViewAllHeaders = true;
+ } else {
+ if (gViewAllHeaders) {
+ // If we currently are in view all header mode, rebuild our header
+ // view so we remove most of the header data.
+ hideHeaderView(gExpandedHeaderView);
+ RemoveNewHeaderViews(gExpandedHeaderView);
+ gDummyHeaderIdIndex = 0;
+ // eslint-disable-next-line no-global-assign
+ gExpandedHeaderView = {};
+ initializeHeaderViewTables();
+ }
+
+ // eslint-disable-next-line no-global-assign
+ gViewAllHeaders = false;
+ }
+
+ document.title = "";
+ ClearCurrentHeaders();
+ gBuiltExpandedView = false;
+ gBuildAttachmentsForCurrentMsg = false;
+ ClearAttachmentList();
+ gMessageNotificationBar.clearMsgNotifications();
+
+ // Reset the blocked hosts so we can populate it again for this message.
+ document.getElementById("remoteContentOptions").value = "";
+
+ for (let listener of gMessageListeners) {
+ listener.onStartHeaders();
+ }
+ },
+
+ onEndHeaders() {
+ if (!gViewWrapper || !gMessage) {
+ // The view wrapper and/or message went away before we finished loading
+ // the message. Bail out.
+ return;
+ }
+
+ // Give add-ons a chance to modify currentHeaderData before it actually
+ // gets displayed.
+ for (let listener of gMessageListeners) {
+ if ("onBeforeShowHeaderPane" in listener) {
+ listener.onBeforeShowHeaderPane();
+ }
+ }
+
+ // Load feed web page if so configured. This entry point works for
+ // messagepane loads in 3pane folder tab, 3pane message tab, and the
+ // standalone message window.
+ if (!FeedMessageHandler.shouldShowSummary(gMessage, false)) {
+ FeedMessageHandler.setContent(gMessage, false);
+ }
+
+ ShowMessageHeaderPane();
+ // WARNING: This is the ONLY routine inside of the message Header Sink
+ // that should trigger a reflow!
+ ClearHeaderView(gExpandedHeaderView);
+
+ // Make sure there is a subject even if it's empty so we'll show the
+ // subject and the twisty.
+ EnsureSubjectValue();
+
+ // Make sure there is a from value even if empty so the header toolbar
+ // will show up.
+ EnsureFromValue();
+
+ // Only update the expanded view if it's actually selected and needs updating.
+ if (!gBuiltExpandedView) {
+ UpdateExpandedMessageHeaders();
+ }
+
+ gMessageNotificationBar.setDraftEditMessage();
+ updateHeaderToolbarButtons();
+
+ for (let listener of gMessageListeners) {
+ listener.onEndHeaders();
+ }
+ },
+
+ processHeaders(headerNames, headerValues) {
+ const kMailboxSeparator = ", ";
+ var index = 0;
+ for (let i = 0; i < headerNames.length; i++) {
+ let header = {
+ headerName: headerNames[i],
+ headerValue: headerValues[i],
+ };
+
+ // For consistency's sake, let us force all header names to be lower
+ // case so we don't have to worry about looking for: Cc and CC, etc.
+ var lowerCaseHeaderName = header.headerName.toLowerCase();
+
+ // If we have an x-mailer, x-mimeole, or x-newsreader string,
+ // put it in the user-agent slot which we know how to handle already.
+ if (/^x-(mailer|mimeole|newsreader)$/.test(lowerCaseHeaderName)) {
+ lowerCaseHeaderName = "user-agent";
+ }
+
+ // According to RFC 2822, certain headers can occur "unlimited" times.
+ if (lowerCaseHeaderName in currentHeaderData) {
+ // Sometimes, you can have multiple To or Cc lines....
+ // In this case, we want to append these headers into one.
+ if (lowerCaseHeaderName == "to" || lowerCaseHeaderName == "cc") {
+ currentHeaderData[lowerCaseHeaderName].headerValue =
+ currentHeaderData[lowerCaseHeaderName].headerValue +
+ "," +
+ header.headerValue;
+ } else {
+ // Use the index to create a unique header name like:
+ // received5, received6, etc
+ currentHeaderData[lowerCaseHeaderName + index++] = header;
+ }
+ } else {
+ currentHeaderData[lowerCaseHeaderName] = header;
+ }
+
+ // See RFC 5322 section 3.6 for min-max number for given header.
+ // If multiple headers exist we need to make sure to use the first one.
+ if (lowerCaseHeaderName == "subject" && !document.title) {
+ let fullSubject = "";
+ // Use the subject from the database, which may have been put there in
+ // decrypted form.
+ if (gMessage?.subject) {
+ if (gMessage.flags & Ci.nsMsgMessageFlags.HasRe) {
+ fullSubject = "Re: ";
+ }
+ fullSubject += gMessage.mime2DecodedSubject;
+ }
+ document.title = fullSubject || header.headerValue;
+ currentHeaderData.subject.headerValue = document.title;
+ }
+ } // while we have more headers to parse
+
+ // Process message tags as if they were headers in the message.
+ gMessageHeader.setTags();
+ updateStarButton();
+
+ if ("from" in currentHeaderData && "sender" in currentHeaderData) {
+ let senderMailbox =
+ kMailboxSeparator +
+ MailServices.headerParser.extractHeaderAddressMailboxes(
+ currentHeaderData.sender.headerValue
+ ) +
+ kMailboxSeparator;
+ let fromMailboxes =
+ kMailboxSeparator +
+ MailServices.headerParser.extractHeaderAddressMailboxes(
+ currentHeaderData.from.headerValue
+ ) +
+ kMailboxSeparator;
+ if (fromMailboxes.includes(senderMailbox)) {
+ delete currentHeaderData.sender;
+ }
+ }
+
+ // We don't need to show the reply-to header if its value is either
+ // the From field (totally pointless) or the To field (common for
+ // mailing lists, but not that useful).
+ if (
+ "from" in currentHeaderData &&
+ "to" in currentHeaderData &&
+ "reply-to" in currentHeaderData
+ ) {
+ let replyToMailbox =
+ MailServices.headerParser.extractHeaderAddressMailboxes(
+ currentHeaderData["reply-to"].headerValue
+ );
+ let fromMailboxes =
+ MailServices.headerParser.extractHeaderAddressMailboxes(
+ currentHeaderData.from.headerValue
+ );
+ let toMailboxes = MailServices.headerParser.extractHeaderAddressMailboxes(
+ currentHeaderData.to.headerValue
+ );
+
+ if (replyToMailbox == fromMailboxes || replyToMailbox == toMailboxes) {
+ delete currentHeaderData["reply-to"];
+ }
+ }
+
+ // For content-base urls stored uri encoded, we want to decode for
+ // display (and encode for external link open).
+ if ("content-base" in currentHeaderData) {
+ currentHeaderData["content-base"].headerValue = decodeURI(
+ currentHeaderData["content-base"].headerValue
+ );
+ }
+
+ let expandedfromLabel = document.getElementById("expandedfromLabel");
+ if (FeedUtils.isFeedMessage(gMessage)) {
+ expandedfromLabel.value = expandedfromLabel.getAttribute("valueAuthor");
+ } else {
+ expandedfromLabel.value = expandedfromLabel.getAttribute("valueFrom");
+ }
+
+ this.onEndHeaders();
+ },
+
+ handleAttachment(contentType, url, displayName, uri, isExternalAttachment) {
+ let newAttachment = new AttachmentInfo({
+ contentType,
+ url,
+ name: displayName,
+ uri,
+ isExternalAttachment,
+ message: gMessage,
+ updateAttachmentsDisplayFn: updateAttachmentsDisplay,
+ });
+ currentAttachments.push(newAttachment);
+
+ if (contentType == "application/pgp-keys" || displayName.endsWith(".asc")) {
+ Enigmail.msg.autoProcessPgpKeyAttachment(newAttachment);
+ }
+ },
+
+ addAttachmentField(field, value) {
+ let last = currentAttachments[currentAttachments.length - 1];
+ if (
+ field == "X-Mozilla-PartSize" &&
+ !last.isFileAttachment &&
+ !last.isDeleted
+ ) {
+ let size = parseInt(value);
+
+ if (last.isLinkAttachment) {
+ // Check if an external link attachment's reported size is sane.
+ // A size of < 2 isn't sensical so ignore such placeholder values.
+ // Don't accept a size with any non numerics. Also cap the number.
+ // We want the size to be checked again, upon user action, to make
+ // sure size is updated with an accurate value, so |sizeResolved|
+ // remains false.
+ if (isNaN(size) || size.toString().length != value.length || size < 2) {
+ last.size = -1;
+ } else if (size > Number.MAX_SAFE_INTEGER) {
+ last.size = Number.MAX_SAFE_INTEGER;
+ } else {
+ last.size = size;
+ }
+ } else {
+ // For internal or file (detached) attachments, save the size.
+ last.size = size;
+ // For external file attachments, we won't have a valid size.
+ if (!last.isFileAttachment && size > -1) {
+ last.sizeResolved = true;
+ }
+ }
+ } else if (field == "X-Mozilla-PartDownloaded" && value == "0") {
+ // We haven't downloaded the attachment, so any size we get from
+ // libmime is almost certainly inaccurate. Just get rid of it. (Note:
+ // this relies on the fact that PartDownloaded comes after PartSize from
+ // the MIME emitter.)
+ // Note: for imap parts_on_demand, a small size consisting of the part
+ // headers would have been returned above.
+ last.size = -1;
+ last.sizeResolved = false;
+ }
+ },
+
+ onEndAllAttachments() {
+ Enigmail.msg.notifyEndAllAttachments();
+
+ displayAttachmentsForExpandedView();
+
+ for (let listener of gMessageListeners) {
+ if ("onEndAttachments" in listener) {
+ listener.onEndAttachments();
+ }
+ }
+ },
+
+ /**
+ * This event is generated by nsMsgStatusFeedback when it gets an
+ * OnStateChange event for STATE_STOP. This is the same event that
+ * generates the "msgLoaded" property flag change event. This best
+ * corresponds to the end of the streaming process.
+ */
+ onEndMsgDownload(url) {
+ let browser = getMessagePaneBrowser();
+
+ // If we have no attachments, we hide the attachment icon in the message
+ // tree.
+ // PGP key attachments do not count as attachments for the purposes of the
+ // message tree, even though we still show them in the attachment list.
+ // Otherwise the attachment icon becomes less useful when someone receives
+ // lots of signed messages.
+ // We do the same if we only have text/vcard attachments because we
+ // *assume* the vcard attachment is a personal vcard (rather than an
+ // addressbook, or a shared contact) that is attached to every message.
+ // NOTE: There would be some obvious give-aways in the vcard content that
+ // this personal vcard assumption is incorrect (multiple contacts, or a
+ // contact with an address that is different from the sender address) but we
+ // do not have easy access to the attachment content here, so we just stick
+ // to the assumption.
+ // NOTE: If the message contains two vcard attachments (or more) then this
+ // would hint that one of the vcards is not personal, but we won't make an
+ // exception here to keep the implementation simple.
+ gMessage?.markHasAttachments(
+ currentAttachments.some(
+ att =>
+ att.contentType != "text/vcard" &&
+ att.contentType != "text/x-vcard" &&
+ att.contentType != "application/pgp-keys"
+ )
+ );
+
+ if (
+ currentAttachments.length &&
+ Services.prefs.getBoolPref("mail.inline_attachments") &&
+ FeedUtils.isFeedMessage(gMessage) &&
+ browser &&
+ browser.contentDocument &&
+ browser.contentDocument.body
+ ) {
+ for (let img of browser.contentDocument.body.getElementsByClassName(
+ "moz-attached-image"
+ )) {
+ for (let attachment of currentAttachments) {
+ let partID = img.src.split("&part=")[1];
+ partID = partID ? partID.split("&")[0] : null;
+ if (attachment.partID && partID == attachment.partID) {
+ img.src = attachment.url;
+ break;
+ }
+ }
+
+ img.addEventListener("load", function (event) {
+ if (this.clientWidth > this.parentNode.clientWidth) {
+ img.setAttribute("overflowing", "true");
+ img.setAttribute("shrinktofit", "true");
+ }
+ });
+ }
+ }
+
+ OnMsgParsed(url);
+ },
+
+ onEndMsgHeaders(url) {
+ if (!url.errorCode) {
+ // Should not mark a message as read if failed to load.
+ OnMsgLoaded(url);
+ }
+ },
+};
+
+/**
+ * Update the flagged (starred) state of the currently selected message.
+ */
+function updateStarButton() {
+ if (!gMessage || !gFolder) {
+ // No msgHdr to update, or we're dealing with an .eml.
+ document.getElementById("starMessageButton").hidden = true;
+ return;
+ }
+
+ let flagButton = document.getElementById("starMessageButton");
+ flagButton.hidden = false;
+
+ let isFlagged = gMessage.isFlagged;
+ flagButton.classList.toggle("flagged", isFlagged);
+ flagButton.setAttribute("aria-checked", isFlagged);
+}
+
+function EnsureSubjectValue() {
+ if (!("subject" in currentHeaderData)) {
+ let foo = {};
+ foo.headerValue = "";
+ foo.headerName = "subject";
+ currentHeaderData[foo.headerName] = foo;
+ }
+}
+
+function EnsureFromValue() {
+ if (!("from" in currentHeaderData)) {
+ let foo = {};
+ foo.headerValue = "";
+ foo.headerName = "from";
+ currentHeaderData[foo.headerName] = foo;
+ }
+}
+
+function OnTagsChange() {
+ // rebuild the tag headers
+ gMessageHeader.setTags();
+
+ // Now update the expanded header view to rebuild the tags,
+ // and then show or hide the tag header box.
+ if (gBuiltExpandedView) {
+ let headerEntry = gExpandedHeaderView.tags;
+ if (headerEntry) {
+ headerEntry.valid = "tags" in currentHeaderData;
+ if (headerEntry.valid) {
+ headerEntry.outputFunction(
+ headerEntry,
+ currentHeaderData.tags.headerValue
+ );
+ }
+
+ // we may need to collapse or show the tag header row...
+ headerEntry.enclosingRow.hidden = !headerEntry.valid;
+ // ... and ensure that all headers remain correctly aligned
+ gMessageHeader.syncLabelsColumnWidths();
+ }
+ }
+}
+
+/**
+ * Flush out any local state being held by a header entry for a given table.
+ *
+ * @param aHeaderTable Table of header entries
+ */
+function ClearHeaderView(aHeaderTable) {
+ for (let name in aHeaderTable) {
+ let headerEntry = aHeaderTable[name];
+ headerEntry.enclosingBox.clearHeaderValues?.();
+ headerEntry.enclosingBox.clear?.();
+
+ headerEntry.valid = false;
+ }
+}
+
+/**
+ * Make sure that any valid header entry in the table is collapsed.
+ *
+ * @param aHeaderTable Table of header entries
+ */
+function hideHeaderView(aHeaderTable) {
+ for (let name in aHeaderTable) {
+ let headerEntry = aHeaderTable[name];
+ headerEntry.enclosingRow.hidden = true;
+ }
+}
+
+/**
+ * Make sure that any valid header entry in the table specified is visible.
+ *
+ * @param aHeaderTable Table of header entries
+ */
+function showHeaderView(aHeaderTable) {
+ for (let name in aHeaderTable) {
+ let headerEntry = aHeaderTable[name];
+ headerEntry.enclosingRow.hidden = !headerEntry.valid;
+
+ // If we're hiding the To field, we need to hide the date inline and show
+ // the duplicate on the subject line.
+ if (headerEntry.enclosingRow.id == "expandedtoRow") {
+ let dateLabel = document.getElementById("dateLabel");
+ let dateLabelSubject = document.getElementById("dateLabelSubject");
+ if (!headerEntry.valid) {
+ dateLabelSubject.setAttribute(
+ "datetime",
+ dateLabel.getAttribute("datetime")
+ );
+ dateLabelSubject.textContent = dateLabel.textContent;
+ dateLabelSubject.hidden = false;
+ } else {
+ dateLabelSubject.removeAttribute("datetime");
+ dateLabelSubject.textContent = "";
+ dateLabelSubject.hidden = true;
+ }
+ }
+ }
+}
+
+/**
+ * Enumerate through the list of headers and find the number that are visible
+ * add empty entries if we don't have the minimum number of rows.
+ */
+function EnsureMinimumNumberOfHeaders(headerTable) {
+ // 0 means we don't have a minimum... do nothing special
+ if (!gMinNumberOfHeaders) {
+ return;
+ }
+
+ var numVisibleHeaders = 0;
+ for (let name in headerTable) {
+ let headerEntry = headerTable[name];
+ if (headerEntry.valid) {
+ numVisibleHeaders++;
+ }
+ }
+
+ if (numVisibleHeaders < gMinNumberOfHeaders) {
+ // How many empty headers do we need to add?
+ var numEmptyHeaders = gMinNumberOfHeaders - numVisibleHeaders;
+
+ // We may have already dynamically created our empty rows and we just need
+ // to make them visible.
+ for (let index in headerTable) {
+ let headerEntry = headerTable[index];
+ if (index.startsWith("Dummy-Header") && numEmptyHeaders) {
+ headerEntry.valid = true;
+ numEmptyHeaders--;
+ }
+ }
+
+ // Ok, now if we have any extra dummy headers we need to add, create a new
+ // header widget for them.
+ while (numEmptyHeaders) {
+ var dummyHeaderId = "Dummy-Header" + gDummyHeaderIdIndex;
+ gExpandedHeaderView[dummyHeaderId] = new HeaderView(dummyHeaderId, "");
+ gExpandedHeaderView[dummyHeaderId].valid = true;
+
+ gDummyHeaderIdIndex++;
+ numEmptyHeaders--;
+ }
+ }
+}
+
+/**
+ * Make sure the appropriate fields in the expanded header view are collapsed
+ * or visible...
+ */
+function updateExpandedView() {
+ if (gMinNumberOfHeaders) {
+ EnsureMinimumNumberOfHeaders(gExpandedHeaderView);
+ }
+ showHeaderView(gExpandedHeaderView);
+
+ // Now that we have all the headers, ensure that the name columns of both
+ // grids are the same size so that they don't look weird.
+ gMessageHeader.syncLabelsColumnWidths();
+
+ UpdateReplyButtons();
+ updateHeaderToolbarButtons();
+ updateComposeButtons();
+ displayAttachmentsForExpandedView();
+
+ try {
+ AdjustHeaderView(Services.prefs.getIntPref("mail.show_headers"));
+ } catch (e) {
+ console.error(e);
+ }
+}
+
+/**
+ * Default method for updating a header value into a header entry
+ *
+ * @param aHeaderEntry A single header from currentHeaderData
+ * @param aHeaderValue The new value for headerEntry
+ */
+function updateHeaderValue(aHeaderEntry, aHeaderValue) {
+ aHeaderEntry.enclosingBox.headerValue = aHeaderValue;
+}
+
+/**
+ * Create the DOM nodes (aka "View") for a non-standard header and insert them
+ * into the grid. Create and return the corresponding headerEntry object.
+ *
+ * @param {string} headerName - name of the header we're adding, used to
+ * construct the element IDs (in lower case)
+ * @param {string} label - name of the header as displayed in the UI
+ */
+class HeaderView {
+ constructor(headerName, label) {
+ headerName = headerName.toLowerCase();
+ let rowId = "expanded" + headerName + "Row";
+ let idName = "expanded" + headerName + "Box";
+ let newHeaderNode;
+ // If a row for this header already exists, do not create another one.
+ let newRowNode = document.getElementById(rowId);
+ if (!newRowNode) {
+ // Create new collapsed row.
+ newRowNode = document.createElementNS(
+ "http://www.w3.org/1999/xhtml",
+ "div"
+ );
+ newRowNode.setAttribute("id", rowId);
+ newRowNode.classList.add("message-header-row");
+ newRowNode.hidden = true;
+
+ // Create and append the label which contains the header name.
+ let newLabelNode = document.createXULElement("label");
+ newLabelNode.setAttribute("id", "expanded" + headerName + "Label");
+ newLabelNode.setAttribute("value", label);
+ newLabelNode.setAttribute("class", "message-header-label");
+
+ newRowNode.appendChild(newLabelNode);
+
+ // Create and append the new header value.
+ newHeaderNode = document.createElement("div", {
+ is: "simple-header-row",
+ });
+ newHeaderNode.setAttribute("id", idName);
+ newHeaderNode.dataset.prettyHeaderName = label;
+ newHeaderNode.dataset.headerName = headerName;
+ newRowNode.appendChild(newHeaderNode);
+
+ // Add the new row to the extra headers container.
+ document.getElementById("extraHeadersArea").appendChild(newRowNode);
+ this.isNewHeader = true;
+ } else {
+ newRowNode.hidden = true;
+ newHeaderNode = document.getElementById(idName);
+ this.isNewHeader = false;
+ }
+
+ this.enclosingBox = newHeaderNode;
+ this.enclosingRow = newRowNode;
+ this.valid = false;
+ this.outputFunction = updateHeaderValue;
+ }
+}
+
+/**
+ * Removes all non-predefined header nodes from the view.
+ *
+ * @param aHeaderTable Table of header entries.
+ */
+function RemoveNewHeaderViews(aHeaderTable) {
+ for (let name in aHeaderTable) {
+ let headerEntry = aHeaderTable[name];
+ if (headerEntry.isNewHeader) {
+ headerEntry.enclosingRow.remove();
+ }
+ }
+}
+
+/**
+ * UpdateExpandedMessageHeaders: Iterate through all the current header data
+ * we received from mime for this message for the expanded header entry table,
+ * and see if we have a corresponding entry for that header (i.e.
+ * whether the expanded header view cares about this header value)
+ * If so, then call updateHeaderEntry
+ */
+function UpdateExpandedMessageHeaders() {
+ // Iterate over each header we received and see if we have a matching entry
+ // in each header view table...
+ var headerName;
+
+ // Remove the height attr so that it redraws correctly. Works around a problem
+ // that attachment-splitter causes if it's moved high enough to affect
+ // the header box:
+ document.getElementById("msgHeaderView").removeAttribute("height");
+ // This height attribute may be set by toggleWrap() if the user clicked
+ // the "more" button" in the header.
+ // Remove it so that the height is determined automatically.
+
+ for (headerName in currentHeaderData) {
+ var headerField = currentHeaderData[headerName];
+ var headerEntry = null;
+
+ if (headerName in gExpandedHeaderView) {
+ headerEntry = gExpandedHeaderView[headerName];
+ }
+
+ if (!headerEntry && gViewAllHeaders) {
+ // For view all headers, if we don't have a header field for this
+ // value, cheat and create one then fill in a headerEntry.
+ if (headerName == "message-id" || headerName == "in-reply-to") {
+ var messageIdEntry = {
+ name: headerName,
+ outputFunction: outputMessageIds,
+ };
+ gExpandedHeaderView[headerName] = new MsgHeaderEntry(
+ "expanded",
+ messageIdEntry
+ );
+ } else if (headerName != "x-mozilla-localizeddate") {
+ // Don't bother showing X-Mozilla-LocalizedDate, since that value is
+ // displayed below the message header toolbar.
+ gExpandedHeaderView[headerName] = new HeaderView(
+ headerName,
+ currentHeaderData[headerName].headerName
+ );
+ }
+
+ headerEntry = gExpandedHeaderView[headerName];
+ }
+
+ if (headerEntry) {
+ if (
+ headerName == "references" &&
+ !(
+ gViewAllHeaders ||
+ gHeadersShowReferences ||
+ gFolder?.isSpecialFolder(Ci.nsMsgFolderFlags.Newsgroup, false)
+ )
+ ) {
+ // Hide references header if view all headers mode isn't selected, the
+ // pref show references is deactivated and the currently displayed
+ // message isn't a newsgroup posting.
+ headerEntry.valid = false;
+ } else {
+ // Set the row element visible before populating the field with addresses.
+ headerEntry.enclosingRow.hidden = false;
+ headerEntry.outputFunction(headerEntry, headerField.headerValue);
+ headerEntry.valid = true;
+ }
+ }
+ }
+
+ let otherHeaders = Services.prefs
+ .getCharPref("mail.compose.other.header", "")
+ .split(",")
+ .map(h => h.trim())
+ .filter(Boolean);
+
+ for (let otherHeaderName of otherHeaders) {
+ let toLowerCaseHeaderName = otherHeaderName.toLowerCase();
+ let headerEntry = gExpandedHeaderView[toLowerCaseHeaderName];
+ let headerData = currentHeaderData[toLowerCaseHeaderName];
+
+ if (headerEntry && headerData) {
+ headerEntry.outputFunction(headerEntry, headerData.headerValue);
+ headerEntry.valid = true;
+ }
+ }
+
+ let dateLabel = document.getElementById("dateLabel");
+ dateLabel.hidden = true;
+ if (
+ "x-mozilla-localizeddate" in currentHeaderData &&
+ currentHeaderData["x-mozilla-localizeddate"].headerValue
+ ) {
+ dateLabel.textContent =
+ currentHeaderData["x-mozilla-localizeddate"].headerValue;
+ let date = new Date(currentHeaderData.date.headerValue);
+ if (!isNaN(date)) {
+ dateLabel.setAttribute("datetime", date.toISOString());
+ dateLabel.hidden = false;
+ }
+ }
+
+ gBuiltExpandedView = true;
+
+ // Now update the view to make sure the right elements are visible.
+ updateExpandedView();
+}
+
+function ClearCurrentHeaders() {
+ gSecureMsgProbe = {};
+ // eslint-disable-next-line no-global-assign
+ currentHeaderData = {};
+ // eslint-disable-next-line no-global-assign
+ currentAttachments = [];
+ currentCharacterSet = "";
+}
+
+function ShowMessageHeaderPane() {
+ document.getElementById("msgHeaderView").collapsed = false;
+ document.getElementById("mail-notification-top").collapsed = false;
+
+ // Initialize the DBListener if we don't have one. This might happen when the
+ // message pane is hidden or no message was selected before, which caused the
+ // clearing of the the DBListener.
+ initFolderDBListener();
+}
+
+function HideMessageHeaderPane() {
+ let header = document.getElementById("msgHeaderView");
+ header.collapsed = true;
+ document.getElementById("mail-notification-top").collapsed = true;
+
+ // Disable the attachment box.
+ document.getElementById("attachmentView").collapsed = true;
+ document.getElementById("attachment-splitter").collapsed = true;
+
+ gMessageNotificationBar.clearMsgNotifications();
+ // Clear the DBListener since we don't have any visible UI to update.
+ clearFolderDBListener();
+
+ // Now let interested listeners know the pane has been hidden.
+ header.dispatchEvent(new Event("message-header-pane-hidden"));
+}
+
+/**
+ * Take a string of newsgroups separated by commas, split it into newsgroups and
+ * add them to the corresponding header-newsgroups-row element.
+ *
+ * @param {MsgHeaderEntry} headerEntry - The data structure for this header.
+ * @param {string} headerValue - The string of newsgroups from the message.
+ */
+function outputNewsgroups(headerEntry, headerValue) {
+ headerValue
+ .split(",")
+ .forEach(newsgroup => headerEntry.enclosingBox.addNewsgroup(newsgroup));
+ headerEntry.enclosingBox.buildView();
+}
+
+/**
+ * Take a string of tags separated by space, split them and add them to the
+ * corresponding header-tags-row element.
+ *
+ * @param {MsgHeaderEntry} headerEntry - The data structure for this header.
+ * @param {string} headerValue - The string of tags from the message.
+ */
+function outputTags(headerEntry, headerValue) {
+ headerEntry.enclosingBox.buildTags(headerValue.split(" "));
+}
+
+/**
+ * Take a string of message-ids separated by whitespace, split it and send them
+ * to the corresponding header-message-ids-row element.
+ *
+ * @param {MsgHeaderEntry} headerEntry - The data structure for this header.
+ * @param {string} headerValue - The string of message IDs from the message.
+ */
+function outputMessageIds(headerEntry, headerValue) {
+ headerEntry.enclosingBox.clear();
+
+ for (let id of headerValue.split(/\s+/)) {
+ headerEntry.enclosingBox.addId(id);
+ }
+
+ headerEntry.enclosingBox.buildView();
+}
+
+/**
+ * Take a string of addresses separated by commas, split it into separated
+ * recipient objects and add them to the related parent container row.
+ *
+ * @param {MsgHeaderEntry} headerEntry - The data structure for this header.
+ * @param {string} emailAddresses - The string of addresses from the message.
+ */
+function outputEmailAddresses(headerEntry, emailAddresses) {
+ if (!emailAddresses) {
+ return;
+ }
+
+ // The email addresses are still RFC2047 encoded but libmime has already
+ // converted from "raw UTF-8" to "wide" (UTF-16) characters.
+ let addresses = MailServices.headerParser.parseEncodedHeaderW(emailAddresses);
+
+ // Make sure we start clean.
+ headerEntry.enclosingBox.clear();
+
+ // No addresses and a colon, so an empty group like "undisclosed-recipients: ;".
+ // Add group name so at least something displays.
+ if (!addresses.length && emailAddresses.includes(":")) {
+ let address = { displayName: emailAddresses };
+ headerEntry.enclosingBox.addRecipient(address);
+ }
+
+ for (let addr of addresses) {
+ // If we want to include short/long toggle views and we have a long view,
+ // always add it. If we aren't including a short/long view OR if we are and
+ // we haven't parsed enough addresses to reach the cutoff valve yet then add
+ // it to the default (short) div.
+ let address = {};
+ address.emailAddress = addr.email;
+ address.fullAddress = addr.toString();
+ address.displayName = addr.name;
+ headerEntry.enclosingBox.addRecipient(address);
+ }
+
+ headerEntry.enclosingBox.buildView();
+}
+
+/**
+ * Return true if possible attachments in the currently loaded message can be
+ * deleted/detached.
+ */
+function CanDetachAttachments() {
+ var canDetach =
+ !gFolder.isSpecialFolder(Ci.nsMsgFolderFlags.Newsgroup, false) &&
+ (!gFolder.isSpecialFolder(Ci.nsMsgFolderFlags.ImapBox, false) ||
+ MailOfflineMgr.isOnline()) &&
+ gFolder; // We can't detach from loaded eml files yet.
+ if (canDetach && "content-type" in currentHeaderData) {
+ canDetach = !ContentTypeIsSMIME(
+ currentHeaderData["content-type"].headerValue
+ );
+ }
+ if (canDetach) {
+ canDetach = Enigmail.hdrView.enigCanDetachAttachments();
+ }
+
+ return canDetach;
+}
+
+/**
+ * Return true if the content type is an S/MIME one.
+ */
+function ContentTypeIsSMIME(contentType) {
+ // S/MIME is application/pkcs7-mime and application/pkcs7-signature
+ // - also match application/x-pkcs7-mime and application/x-pkcs7-signature.
+ return /application\/(x-)?pkcs7-(mime|signature)/.test(contentType);
+}
+
+function onShowAttachmentToolbarContextMenu() {
+ let expandBar = document.getElementById("context-expandAttachmentBar");
+ let expanded = Services.prefs.getBoolPref(
+ "mailnews.attachments.display.start_expanded"
+ );
+ expandBar.setAttribute("checked", expanded);
+}
+
+/**
+ * Set up the attachment item context menu, showing or hiding the appropriate
+ * menu items.
+ */
+function onShowAttachmentItemContextMenu() {
+ let attachmentList = document.getElementById("attachmentList");
+ let attachmentInfo = document.getElementById("attachmentInfo");
+ let attachmentName = document.getElementById("attachmentName");
+ let contextMenu = document.getElementById("attachmentItemContext");
+ let openMenu = document.getElementById("context-openAttachment");
+ let saveMenu = document.getElementById("context-saveAttachment");
+ let detachMenu = document.getElementById("context-detachAttachment");
+ let deleteMenu = document.getElementById("context-deleteAttachment");
+ let copyUrlMenuSep = document.getElementById(
+ "context-menu-copyurl-separator"
+ );
+ let copyUrlMenu = document.getElementById("context-copyAttachmentUrl");
+ let openFolderMenu = document.getElementById("context-openFolder");
+
+ // If we opened the context menu from the attachment info area (the paperclip,
+ // "1 attachment" label, filename, or file size, just grab the first (and
+ // only) attachment as our "selected" attachments.
+ var selectedAttachments;
+ if (
+ contextMenu.triggerNode == attachmentInfo ||
+ contextMenu.triggerNode.parentNode == attachmentInfo
+ ) {
+ selectedAttachments = [attachmentList.getItemAtIndex(0).attachment];
+ if (contextMenu.triggerNode == attachmentName) {
+ attachmentName.setAttribute("selected", true);
+ }
+ } else {
+ selectedAttachments = [...attachmentList.selectedItems].map(
+ item => item.attachment
+ );
+ }
+ contextMenu.attachments = selectedAttachments;
+
+ var allSelectedDetached = selectedAttachments.every(function (attachment) {
+ return attachment.isExternalAttachment;
+ });
+ var allSelectedDeleted = selectedAttachments.every(function (attachment) {
+ return !attachment.hasFile;
+ });
+ var canDetachSelected =
+ CanDetachAttachments() && !allSelectedDetached && !allSelectedDeleted;
+ let allSelectedHttp = selectedAttachments.every(function (attachment) {
+ return attachment.isLinkAttachment;
+ });
+ let allSelectedFile = selectedAttachments.every(function (attachment) {
+ return attachment.isFileAttachment;
+ });
+
+ openMenu.disabled = allSelectedDeleted;
+ saveMenu.disabled = allSelectedDeleted;
+ detachMenu.disabled = !canDetachSelected;
+ deleteMenu.disabled = !canDetachSelected;
+ copyUrlMenuSep.hidden = copyUrlMenu.hidden = !(
+ allSelectedHttp || allSelectedFile
+ );
+ openFolderMenu.hidden = !allSelectedFile;
+ openFolderMenu.disabled = allSelectedDeleted;
+
+ Enigmail.hdrView.onShowAttachmentContextMenu();
+}
+
+/**
+ * Close the attachment item context menu, performing any cleanup as necessary.
+ */
+function onHideAttachmentItemContextMenu() {
+ let attachmentName = document.getElementById("attachmentName");
+ let contextMenu = document.getElementById("attachmentItemContext");
+
+ // If we opened the context menu from the attachmentName label, we need to
+ // get rid of the "selected" attribute.
+ if (contextMenu.triggerNode == attachmentName) {
+ attachmentName.removeAttribute("selected");
+ }
+}
+
+/**
+ * Enable/disable menu items as appropriate for the single-attachment save all
+ * toolbar button.
+ */
+function onShowSaveAttachmentMenuSingle() {
+ let openItem = document.getElementById("button-openAttachment");
+ let saveItem = document.getElementById("button-saveAttachment");
+ let detachItem = document.getElementById("button-detachAttachment");
+ let deleteItem = document.getElementById("button-deleteAttachment");
+
+ let detached = currentAttachments[0].isExternalAttachment;
+ let deleted = !currentAttachments[0].hasFile;
+ let canDetach = CanDetachAttachments() && !deleted && !detached;
+
+ openItem.disabled = deleted;
+ saveItem.disabled = deleted;
+ detachItem.disabled = !canDetach;
+ deleteItem.disabled = !canDetach;
+}
+
+/**
+ * Enable/disable menu items as appropriate for the multiple-attachment save all
+ * toolbar button.
+ */
+function onShowSaveAttachmentMenuMultiple() {
+ let openAllItem = document.getElementById("button-openAllAttachments");
+ let saveAllItem = document.getElementById("button-saveAllAttachments");
+ let detachAllItem = document.getElementById("button-detachAllAttachments");
+ let deleteAllItem = document.getElementById("button-deleteAllAttachments");
+
+ let allDetached = currentAttachments.every(function (attachment) {
+ return attachment.isExternalAttachment;
+ });
+ let allDeleted = currentAttachments.every(function (attachment) {
+ return !attachment.hasFile;
+ });
+ let canDetach = CanDetachAttachments() && !allDeleted && !allDetached;
+
+ openAllItem.disabled = allDeleted;
+ saveAllItem.disabled = allDeleted;
+ detachAllItem.disabled = !canDetach;
+ deleteAllItem.disabled = !canDetach;
+}
+
+/**
+ * This is our oncommand handler for the attachment list items. A double click
+ * or enter press in an attachmentitem simulates "opening" the attachment.
+ *
+ * @param event the event object
+ */
+function attachmentItemCommand(event) {
+ HandleSelectedAttachments("open");
+}
+
+var AttachmentListController = {
+ supportsCommand(command) {
+ switch (command) {
+ case "cmd_selectAll":
+ case "cmd_delete":
+ case "cmd_shiftDelete":
+ case "cmd_saveAsFile":
+ return true;
+ default:
+ return false;
+ }
+ },
+
+ isCommandEnabled(command) {
+ switch (command) {
+ case "cmd_selectAll":
+ case "cmd_delete":
+ case "cmd_shiftDelete":
+ case "cmd_saveAsFile":
+ return true;
+ default:
+ return false;
+ }
+ },
+
+ doCommand(command) {
+ // If the user invoked a key short cut then it is possible that we got here
+ // for a command which is really disabled. kick out if the command should
+ // be disabled.
+ if (!this.isCommandEnabled(command)) {
+ return;
+ }
+
+ var attachmentList = document.getElementById("attachmentList");
+
+ switch (command) {
+ case "cmd_selectAll":
+ attachmentList.selectAll();
+ return;
+ case "cmd_delete":
+ case "cmd_shiftDelete":
+ HandleSelectedAttachments("delete");
+ return;
+ case "cmd_saveAsFile":
+ HandleSelectedAttachments("saveAs");
+ }
+ },
+
+ onEvent(event) {},
+};
+
+var AttachmentMenuController = {
+ canDetachFiles() {
+ let someNotDetached = currentAttachments.some(function (aAttachment) {
+ return !aAttachment.isExternalAttachment;
+ });
+
+ return (
+ CanDetachAttachments() && someNotDetached && this.someFilesAvailable()
+ );
+ },
+
+ someFilesAvailable() {
+ return currentAttachments.some(function (aAttachment) {
+ return aAttachment.hasFile;
+ });
+ },
+
+ supportsCommand(aCommand) {
+ return aCommand in this.commands;
+ },
+};
+
+function goUpdateAttachmentCommands() {
+ for (let action of ["open", "save", "detach", "delete"]) {
+ goUpdateCommand(`cmd_${action}AllAttachments`);
+ }
+}
+
+async function displayAttachmentsForExpandedView() {
+ var bundle = document.getElementById("bundle_messenger");
+ var numAttachments = currentAttachments.length;
+ var attachmentView = document.getElementById("attachmentView");
+ var attachmentSplitter = document.getElementById("attachment-splitter");
+ document
+ .getElementById("attachmentIcon")
+ .setAttribute("src", "chrome://messenger/skin/icons/attach.svg");
+
+ if (numAttachments <= 0) {
+ attachmentView.collapsed = true;
+ attachmentSplitter.collapsed = true;
+ } else if (!gBuildAttachmentsForCurrentMsg) {
+ attachmentView.collapsed = false;
+
+ var attachmentList = document.getElementById("attachmentList");
+
+ attachmentList.controllers.appendController(AttachmentListController);
+
+ toggleAttachmentList(false);
+
+ for (let attachment of currentAttachments) {
+ // Create a new attachment widget
+ var displayName = SanitizeAttachmentDisplayName(attachment);
+ var item = attachmentList.appendItem(attachment, displayName);
+ item.setAttribute("tooltiptext", attachment.name);
+ item.addEventListener("command", attachmentItemCommand);
+
+ // Get a detached file's size. For link attachments, the user must always
+ // initiate the fetch for privacy reasons.
+ if (attachment.isFileAttachment) {
+ await attachment.isEmpty();
+ }
+ }
+
+ if (
+ Services.prefs.getBoolPref("mailnews.attachments.display.start_expanded")
+ ) {
+ toggleAttachmentList(true);
+ }
+
+ let attachmentInfo = document.getElementById("attachmentInfo");
+ let attachmentCount = document.getElementById("attachmentCount");
+ let attachmentName = document.getElementById("attachmentName");
+ let attachmentSize = document.getElementById("attachmentSize");
+
+ if (numAttachments == 1) {
+ let count = bundle.getString("attachmentCountSingle");
+ let name = SanitizeAttachmentDisplayName(currentAttachments[0]);
+
+ attachmentInfo.setAttribute("contextmenu", "attachmentItemContext");
+ attachmentCount.setAttribute("value", count);
+ attachmentName.hidden = false;
+ attachmentName.setAttribute("value", name);
+ } else {
+ let words = bundle.getString("attachmentCount");
+ let count = PluralForm.get(currentAttachments.length, words).replace(
+ "#1",
+ currentAttachments.length
+ );
+
+ attachmentInfo.setAttribute("contextmenu", "attachmentListContext");
+ attachmentCount.setAttribute("value", count);
+ attachmentName.hidden = true;
+ }
+
+ attachmentSize.value = getAttachmentsTotalSizeStr();
+
+ // Extra candy for external attachments.
+ displayAttachmentsForExpandedViewExternal();
+
+ // Show the appropriate toolbar button and label based on the number of
+ // attachments.
+ updateSaveAllAttachmentsButton();
+
+ gBuildAttachmentsForCurrentMsg = true;
+ }
+}
+
+function displayAttachmentsForExpandedViewExternal() {
+ let bundleMessenger = document.getElementById("bundle_messenger");
+ let attachmentName = document.getElementById("attachmentName");
+ let attachmentList = document.getElementById("attachmentList");
+
+ // Attachment bar single.
+ let firstAttachment = attachmentList.firstElementChild.attachment;
+ let isExternalAttachment = firstAttachment.isExternalAttachment;
+ let displayUrl = isExternalAttachment ? firstAttachment.displayUrl : "";
+ let tooltiptext =
+ isExternalAttachment || firstAttachment.isDeleted
+ ? ""
+ : attachmentName.getAttribute("tooltiptextopen");
+ let externalAttachmentNotFound = bundleMessenger.getString(
+ "externalAttachmentNotFound"
+ );
+
+ attachmentName.textContent = displayUrl;
+ attachmentName.tooltipText = tooltiptext;
+ attachmentName.setAttribute(
+ "tooltiptextexternalnotfound",
+ externalAttachmentNotFound
+ );
+ attachmentName.addEventListener("mouseover", () =>
+ top.MsgStatusFeedback.setOverLink(displayUrl)
+ );
+ attachmentName.addEventListener("mouseout", () =>
+ top.MsgStatusFeedback.setOverLink("")
+ );
+ attachmentName.addEventListener("focus", () =>
+ top.MsgStatusFeedback.setOverLink(displayUrl)
+ );
+ attachmentName.addEventListener("blur", () =>
+ top.MsgStatusFeedback.setOverLink("")
+ );
+ attachmentName.classList.remove("text-link");
+ attachmentName.classList.remove("notfound");
+
+ if (firstAttachment.isDeleted) {
+ attachmentName.classList.add("notfound");
+ }
+
+ if (isExternalAttachment) {
+ attachmentName.classList.add("text-link");
+
+ if (!firstAttachment.hasFile) {
+ attachmentName.setAttribute("tooltiptext", externalAttachmentNotFound);
+ attachmentName.classList.add("notfound");
+ }
+ }
+
+ // Expanded attachment list.
+ let index = 0;
+ for (let attachmentitem of attachmentList.children) {
+ let attachment = attachmentitem.attachment;
+ if (attachment.isDeleted) {
+ attachmentitem.classList.add("notfound");
+ }
+
+ if (attachment.isExternalAttachment) {
+ displayUrl = attachment.displayUrl;
+ attachmentitem.setAttribute("tooltiptext", "");
+ attachmentitem.addEventListener("mouseover", () =>
+ top.MsgStatusFeedback.setOverLink(displayUrl)
+ );
+ attachmentitem.addEventListener("mouseout", () =>
+ top.MsgStatusFeedback.setOverLink("")
+ );
+ attachmentitem.addEventListener("focus", () =>
+ top.MsgStatusFeedback.setOverLink(displayUrl)
+ );
+ attachmentitem.addEventListener("blur", () =>
+ top.MsgStatusFeedback.setOverLink("")
+ );
+
+ attachmentitem
+ .querySelector(".attachmentcell-name")
+ .classList.add("text-link");
+ attachmentitem
+ .querySelector(".attachmentcell-extension")
+ .classList.add("text-link");
+
+ if (attachment.isLinkAttachment) {
+ if (index == 0) {
+ attachment.size = currentAttachments[index].size;
+ }
+ }
+
+ if (!attachment.hasFile) {
+ attachmentitem.setAttribute("tooltiptext", externalAttachmentNotFound);
+ attachmentitem.classList.add("notfound");
+ }
+ }
+
+ index++;
+ }
+}
+
+/**
+ * Update the "save all attachments" button in the attachment pane, showing
+ * the proper button and enabling/disabling it as appropriate.
+ */
+function updateSaveAllAttachmentsButton() {
+ let saveAllSingle = document.getElementById("attachmentSaveAllSingle");
+ let saveAllMultiple = document.getElementById("attachmentSaveAllMultiple");
+
+ // If we can't find the buttons, they're not on the toolbar, so bail out!
+ if (!saveAllSingle || !saveAllMultiple) {
+ return;
+ }
+
+ let allDeleted = currentAttachments.every(function (attachment) {
+ return !attachment.hasFile;
+ });
+ let single = currentAttachments.length == 1;
+
+ saveAllSingle.hidden = !single;
+ saveAllMultiple.hidden = single;
+ saveAllSingle.disabled = saveAllMultiple.disabled = allDeleted;
+}
+
+/**
+ * Update the attachments display info after a particular attachment's
+ * existence has been verified.
+ *
+ * @param {AttachmentInfo} attachmentInfo
+ * @param {boolean} isFetching
+ */
+function updateAttachmentsDisplay(attachmentInfo, isFetching) {
+ if (attachmentInfo.isExternalAttachment) {
+ let attachmentList = document.getElementById("attachmentList");
+ let attachmentIcon = document.getElementById("attachmentIcon");
+ let attachmentName = document.getElementById("attachmentName");
+ let attachmentSize = document.getElementById("attachmentSize");
+ let attachmentItem = attachmentList.findItemForAttachment(attachmentInfo);
+ let index = attachmentList.getIndexOfItem(attachmentItem);
+
+ if (isFetching) {
+ // Set elements busy to show the user this is potentially a long network
+ // fetch for the link attachment.
+ attachmentList.setAttachmentLoaded(attachmentItem, false);
+ return;
+ }
+
+ if (attachmentInfo.message != gMessage) {
+ // The user changed messages while fetching, reset the bar and exit;
+ // the listitems are torn down/rebuilt on each message load.
+ attachmentIcon.setAttribute(
+ "src",
+ "chrome://messenger/skin/icons/attach.svg"
+ );
+ return;
+ }
+
+ if (index == -1) {
+ // The user changed messages while fetching, then came back to the same
+ // message. The reset of busy state has already happened and anyway the
+ // item has already been torn down so the index will be invalid; exit.
+ return;
+ }
+
+ currentAttachments[index].size = attachmentInfo.size;
+ let tooltiptextExternalNotFound = attachmentName.getAttribute(
+ "tooltiptextexternalnotfound"
+ );
+
+ let sizeStr;
+ let bundle = document.getElementById("bundle_messenger");
+ if (attachmentInfo.size < 1) {
+ sizeStr = bundle.getString("attachmentSizeUnknown");
+ } else {
+ sizeStr = top.messenger.formatFileSize(attachmentInfo.size);
+ }
+
+ // The attachment listitem.
+ attachmentList.setAttachmentLoaded(attachmentItem, true);
+ attachmentList.setAttachmentSize(
+ attachmentItem,
+ attachmentInfo.hasFile ? sizeStr : ""
+ );
+
+ // FIXME: The UI logic for this should be moved to the attachment list or
+ // item itself.
+ if (attachmentInfo.hasFile) {
+ attachmentItem.removeAttribute("tooltiptext");
+ attachmentItem.classList.remove("notfound");
+ } else {
+ attachmentItem.setAttribute("tooltiptext", tooltiptextExternalNotFound);
+ attachmentItem.classList.add("notfound");
+ }
+
+ // The attachmentbar.
+ updateSaveAllAttachmentsButton();
+ attachmentSize.value = getAttachmentsTotalSizeStr();
+ if (attachmentList.isLoaded()) {
+ attachmentIcon.setAttribute(
+ "src",
+ "chrome://messenger/skin/icons/attach.svg"
+ );
+ }
+
+ // If it's the first one (and there's only one).
+ if (index == 0) {
+ if (attachmentInfo.hasFile) {
+ attachmentName.removeAttribute("tooltiptext");
+ attachmentName.classList.remove("notfound");
+ } else {
+ attachmentName.setAttribute("tooltiptext", tooltiptextExternalNotFound);
+ attachmentName.classList.add("notfound");
+ }
+ }
+
+ // Reset widths since size may have changed; ensure no false cropping of
+ // the attachment item name.
+ attachmentList.setOptimumWidth();
+ }
+}
+
+/**
+ * Calculate the total size of all attachments in the message as emitted to
+ * |currentAttachments| and return a pretty string.
+ *
+ * @returns {string} - Description of the attachment size (e.g. 123 KB or 3.1MB)
+ */
+function getAttachmentsTotalSizeStr() {
+ let bundle = document.getElementById("bundle_messenger");
+ let totalSize = 0;
+ let lastPartID;
+ let unknownSize = false;
+ for (let attachment of currentAttachments) {
+ // Check if this attachment's part ID is a child of the last attachment
+ // we counted. If so, skip it, since we already accounted for its size
+ // from its parent.
+ if (!lastPartID || attachment.partID.indexOf(lastPartID) != 0) {
+ lastPartID = attachment.partID;
+ if (attachment.size != -1) {
+ totalSize += Number(attachment.size);
+ } else if (!attachment.isDeleted) {
+ unknownSize = true;
+ }
+ }
+ }
+
+ let sizeStr = top.messenger.formatFileSize(totalSize);
+ if (unknownSize) {
+ if (totalSize == 0) {
+ sizeStr = bundle.getString("attachmentSizeUnknown");
+ } else {
+ sizeStr = bundle.getFormattedString("attachmentSizeAtLeast", [sizeStr]);
+ }
+ }
+
+ return sizeStr;
+}
+
+/**
+ * Expand/collapse the attachment list. When expanding it, automatically resize
+ * it to an appropriate height (1/4 the message pane or smaller).
+ *
+ * @param expanded True if the attachment list should be expanded, false
+ * otherwise. If |expanded| is not specified, toggle the state.
+ * @param updateFocus (optional) True if the focus should be updated, focusing
+ * on the attachmentList when expanding, or the messagepane
+ * when collapsing (but only when the attachmentList was
+ * originally focused).
+ */
+function toggleAttachmentList(expanded, updateFocus) {
+ var attachmentView = document.getElementById("attachmentView");
+ var attachmentBar = document.getElementById("attachmentBar");
+ var attachmentToggle = document.getElementById("attachmentToggle");
+ var attachmentList = document.getElementById("attachmentList");
+ var attachmentSplitter = document.getElementById("attachment-splitter");
+ var bundle = document.getElementById("bundle_messenger");
+
+ if (expanded === undefined) {
+ expanded = !attachmentToggle.checked;
+ }
+
+ attachmentToggle.checked = expanded;
+
+ if (expanded) {
+ attachmentList.collapsed = false;
+ if (!attachmentView.collapsed) {
+ attachmentSplitter.collapsed = false;
+ }
+ attachmentBar.setAttribute(
+ "tooltiptext",
+ bundle.getString("collapseAttachmentPaneTooltip")
+ );
+
+ attachmentList.setOptimumWidth();
+
+ // By design, attachmentView should not take up more than 1/4 of the message
+ // pane space
+ attachmentView.setAttribute(
+ "height",
+ Math.min(
+ attachmentList.preferredHeight,
+ document.getElementById("messagepanebox").getBoundingClientRect()
+ .height / 4
+ )
+ );
+
+ if (updateFocus) {
+ attachmentList.focus();
+ }
+ } else {
+ attachmentList.collapsed = true;
+ attachmentSplitter.collapsed = true;
+ attachmentBar.setAttribute(
+ "tooltiptext",
+ bundle.getString("expandAttachmentPaneTooltip")
+ );
+ attachmentView.removeAttribute("height");
+
+ if (updateFocus && document.activeElement == attachmentList) {
+ // TODO
+ }
+ }
+}
+
+/**
+ * Open an attachment from the attachment bar.
+ *
+ * @param event the event that triggered this action
+ */
+function OpenAttachmentFromBar(event) {
+ if (event.button == 0) {
+ // Only open on the first click; ignore double-clicks so that the user
+ // doesn't end up with the attachment opened multiple times.
+ if (event.detail == 1) {
+ TryHandleAllAttachments("open");
+ }
+ event.stopPropagation();
+ }
+}
+
+/**
+ * Handle all the attachments in this message (save them, open them, etc).
+ *
+ * @param action one of "open", "save", "saveAs", "detach", or "delete"
+ */
+function HandleAllAttachments(action) {
+ HandleMultipleAttachments(currentAttachments, action);
+}
+
+/**
+ * Try to handle all the attachments in this message (save them, open them,
+ * etc). If the action fails for whatever reason, catch the error and report it.
+ *
+ * @param action one of "open", "save", "saveAs", "detach", or "delete"
+ */
+function TryHandleAllAttachments(action) {
+ try {
+ HandleAllAttachments(action);
+ } catch (e) {
+ console.error(e);
+ }
+}
+
+/**
+ * Handle the currently-selected attachments in this message (save them, open
+ * them, etc).
+ *
+ * @param action one of "open", "save", "saveAs", "detach", or "delete"
+ */
+function HandleSelectedAttachments(action) {
+ let attachmentList = document.getElementById("attachmentList");
+ let selectedAttachments = [];
+ for (let item of attachmentList.selectedItems) {
+ selectedAttachments.push(item.attachment);
+ }
+
+ HandleMultipleAttachments(selectedAttachments, action);
+}
+
+/**
+ * Perform an action on multiple attachments (e.g. open or save)
+ *
+ * @param attachments an array of AttachmentInfo objects to work with
+ * @param action one of "open", "save", "saveAs", "detach", or "delete"
+ */
+function HandleMultipleAttachments(attachments, action) {
+ // Feed message link attachments save handling.
+ if (
+ FeedUtils.isFeedMessage(gMessage) &&
+ (action == "save" || action == "saveAs")
+ ) {
+ saveLinkAttachmentsToFile(attachments);
+ return;
+ }
+
+ // convert our attachment data into some c++ friendly structs
+ var attachmentContentTypeArray = [];
+ var attachmentUrlArray = [];
+ var attachmentDisplayUrlArray = [];
+ var attachmentDisplayNameArray = [];
+ var attachmentMessageUriArray = [];
+
+ // populate these arrays..
+ var actionIndex = 0;
+ for (let attachment of attachments) {
+ // Exclude attachment which are 1) deleted, or 2) detached with missing
+ // external files, unless copying urls.
+ if (!attachment.hasFile && action != "copyUrl") {
+ continue;
+ }
+
+ attachmentContentTypeArray[actionIndex] = attachment.contentType;
+ attachmentUrlArray[actionIndex] = attachment.url;
+ attachmentDisplayUrlArray[actionIndex] = attachment.displayUrl;
+ attachmentDisplayNameArray[actionIndex] = encodeURI(attachment.name);
+ attachmentMessageUriArray[actionIndex] = attachment.uri;
+ ++actionIndex;
+ }
+
+ // The list has been built. Now call our action code...
+ switch (action) {
+ case "save":
+ top.messenger.saveAllAttachments(
+ attachmentContentTypeArray,
+ attachmentUrlArray,
+ attachmentDisplayNameArray,
+ attachmentMessageUriArray
+ );
+ return;
+ case "detach":
+ // "detach" on a multiple selection of attachments is so far not really
+ // supported. As a workaround, resort to normal detach-"all". See also
+ // the comment on 'detaching a multiple selection of attachments' below.
+ if (attachments.length == 1) {
+ attachments[0].detach(top.messenger, true);
+ } else {
+ top.messenger.detachAllAttachments(
+ attachmentContentTypeArray,
+ attachmentUrlArray,
+ attachmentDisplayNameArray,
+ attachmentMessageUriArray,
+ true // save
+ );
+ }
+ return;
+ case "delete":
+ top.messenger.detachAllAttachments(
+ attachmentContentTypeArray,
+ attachmentUrlArray,
+ attachmentDisplayNameArray,
+ attachmentMessageUriArray,
+ false // don't save
+ );
+ return;
+ case "open":
+ // XXX hack alert. If we sit in tight loop and open multiple
+ // attachments, we get chrome errors in layout as we start loading the
+ // first helper app dialog then before it loads, we kick off the next
+ // one and the next one. Subsequent helper app dialogs were failing
+ // because we were still loading the chrome files for the first attempt
+ // (error about the xul cache being empty). For now, work around this by
+ // doing the first helper app dialog right away, then waiting a bit
+ // before we launch the rest.
+ let actionFunction = function (aAttachment) {
+ aAttachment.open(getMessagePaneBrowser().browsingContext);
+ };
+
+ for (let i = 0; i < attachments.length; i++) {
+ if (i == 0) {
+ actionFunction(attachments[i]);
+ } else {
+ setTimeout(actionFunction, 100, attachments[i]);
+ }
+ }
+ return;
+ case "saveAs":
+ // Show one save dialog at a time, which allows to adjust the file name
+ // and folder path for each attachment. For added convenience, we remember
+ // the folder path of each file for the save dialog of the next one.
+ let saveAttachments = function (attachments) {
+ if (attachments.length > 0) {
+ attachments[0].save(top.messenger).then(function () {
+ saveAttachments(attachments.slice(1));
+ });
+ }
+ };
+
+ saveAttachments(attachments);
+ return;
+ case "copyUrl":
+ // Copy external http url(s) to clipboard. The menuitem is hidden unless
+ // all selected attachment urls are http.
+ navigator.clipboard.writeText(attachmentDisplayUrlArray.join("\n"));
+ return;
+ case "openFolder":
+ for (let attachment of attachments) {
+ setTimeout(() => attachment.openFolder());
+ }
+ return;
+ default:
+ throw new Error("unknown HandleMultipleAttachments action: " + action);
+ }
+}
+
+/**
+ * Link attachments are passed as an array of AttachmentInfo objects. This
+ * is meant to download http link content using the browser method.
+ *
+ * @param {AttachmentInfo[]} aAttachmentInfoArray - Array of attachmentInfo.
+ */
+async function saveLinkAttachmentsToFile(aAttachmentInfoArray) {
+ for (let attachment of aAttachmentInfoArray) {
+ if (!attachment.hasFile || attachment.message != gMessage) {
+ continue;
+ }
+
+ let empty = await attachment.isEmpty();
+ if (empty) {
+ continue;
+ }
+
+ // internalSave() is part of saveURL() internals...
+ internalSave(
+ attachment.url, // aURL,
+ null, // aOriginalUrl,
+ undefined, // aDocument,
+ attachment.name, // aDefaultFileName,
+ undefined, // aContentDisposition,
+ undefined, // aContentType,
+ undefined, // aShouldBypassCache,
+ undefined, // aFilePickerTitleKey,
+ undefined, // aChosenData,
+ undefined, // aReferrer,
+ undefined, // aCookieJarSettings,
+ document, // aInitiatingDocument,
+ undefined, // aSkipPrompt,
+ undefined, // aCacheKey,
+ undefined // aIsContentWindowPrivate
+ );
+ }
+}
+
+function ClearAttachmentList() {
+ // clear selection
+ var list = document.getElementById("attachmentList");
+ list.clearSelection();
+
+ while (list.hasChildNodes()) {
+ list.lastChild.remove();
+ }
+}
+
+// See attachmentBucketDNDObserver, which should have the same logic.
+let attachmentListDNDObserver = {
+ onDragStart(event) {
+ // NOTE: Starting a drag on an attachment item will normally also select
+ // the attachment item before this method is called. But this is not
+ // necessarily the case. E.g. holding Shift when starting the drag
+ // operation. When it isn't selected, we just don't transfer.
+ if (event.target.matches(".attachmentItem[selected]")) {
+ // Also transfer other selected attachment items.
+ let attachments = Array.from(
+ document.querySelectorAll("#attachmentList .attachmentItem[selected]"),
+ item => item.attachment
+ );
+ setupDataTransfer(event, attachments);
+ }
+ event.stopPropagation();
+ },
+};
+
+let attachmentNameDNDObserver = {
+ onDragStart(event) {
+ let attachmentList = document.getElementById("attachmentList");
+ setupDataTransfer(event, [attachmentList.getItemAtIndex(0).attachment]);
+ event.stopPropagation();
+ },
+};
+
+function onShowOtherActionsPopup() {
+ // Enable/disable the Open Conversation button.
+ let glodaEnabled = Services.prefs.getBoolPref(
+ "mailnews.database.global.indexer.enabled"
+ );
+
+ let openConversation = document.getElementById(
+ "otherActionsOpenConversation"
+ );
+ // Check because this menuitem element is not present in messageWindow.xhtml.
+ if (openConversation) {
+ openConversation.disabled = !(
+ glodaEnabled && Gloda.isMessageIndexed(gMessage)
+ );
+ }
+
+ let isDummyMessage = !gViewWrapper.isSynthetic && !gMessage.folder;
+ let tagsItem = document.getElementById("otherActionsTag");
+ let markAsReadItem = document.getElementById("markAsReadMenuItem");
+ let markAsUnreadItem = document.getElementById("markAsUnreadMenuItem");
+
+ if (isDummyMessage) {
+ tagsItem.disabled = true;
+ markAsReadItem.disabled = true;
+ markAsReadItem.removeAttribute("hidden");
+ markAsUnreadItem.setAttribute("hidden", true);
+ } else {
+ tagsItem.disabled = false;
+ markAsReadItem.disabled = false;
+ if (SelectedMessagesAreRead()) {
+ markAsReadItem.setAttribute("hidden", true);
+ markAsUnreadItem.removeAttribute("hidden");
+ } else {
+ markAsReadItem.removeAttribute("hidden");
+ markAsUnreadItem.setAttribute("hidden", true);
+ }
+ }
+
+ document.getElementById("otherActions-calendar-convert-menu").hidden =
+ isDummyMessage || !calendarDeactivator.isCalendarActivated;
+
+ // Check if the current message is feed or not.
+ let isFeed = FeedUtils.isFeedMessage(gMessage);
+ document.getElementById("otherActionsMessageBodyAs").hidden = isFeed;
+ document.getElementById("otherActionsFeedBodyAs").hidden = !isFeed;
+}
+
+function InitOtherActionsViewBodyMenu() {
+ let html_as = Services.prefs.getIntPref("mailnews.display.html_as");
+ let prefer_plaintext = Services.prefs.getBoolPref(
+ "mailnews.display.prefer_plaintext"
+ );
+ let disallow_classes = Services.prefs.getIntPref(
+ "mailnews.display.disallow_mime_handlers"
+ );
+ let isFeed = false; // TODO
+ const kDefaultIDs = [
+ "otherActionsMenu_bodyAllowHTML",
+ "otherActionsMenu_bodySanitized",
+ "otherActionsMenu_bodyAsPlaintext",
+ "otherActionsMenu_bodyAllParts",
+ ];
+ const kRssIDs = [
+ "otherActionsMenu_bodyFeedSummaryAllowHTML",
+ "otherActionsMenu_bodyFeedSummarySanitized",
+ "otherActionsMenu_bodyFeedSummaryAsPlaintext",
+ ];
+ let menuIDs = isFeed ? kRssIDs : kDefaultIDs;
+
+ if (disallow_classes > 0) {
+ window.top.gDisallow_classes_no_html = disallow_classes;
+ }
+ // else gDisallow_classes_no_html keeps its initial value (see top)
+
+ let AllowHTML_menuitem = document.getElementById(menuIDs[0]);
+ let Sanitized_menuitem = document.getElementById(menuIDs[1]);
+ let AsPlaintext_menuitem = document.getElementById(menuIDs[2]);
+ let AllBodyParts_menuitem = menuIDs[3]
+ ? document.getElementById(menuIDs[3])
+ : null;
+
+ document.getElementById("otherActionsMenu_bodyAllParts").hidden =
+ !Services.prefs.getBoolPref("mailnews.display.show_all_body_parts_menu");
+
+ // Clear all checkmarks.
+ AllowHTML_menuitem.removeAttribute("checked");
+ Sanitized_menuitem.removeAttribute("checked");
+ AsPlaintext_menuitem.removeAttribute("checked");
+ if (AllBodyParts_menuitem) {
+ AllBodyParts_menuitem.removeAttribute("checked");
+ }
+
+ if (
+ !prefer_plaintext &&
+ !html_as &&
+ !disallow_classes &&
+ AllowHTML_menuitem
+ ) {
+ AllowHTML_menuitem.setAttribute("checked", true);
+ } else if (
+ !prefer_plaintext &&
+ html_as == 3 &&
+ disallow_classes > 0 &&
+ Sanitized_menuitem
+ ) {
+ Sanitized_menuitem.setAttribute("checked", true);
+ } else if (
+ prefer_plaintext &&
+ html_as == 1 &&
+ disallow_classes > 0 &&
+ AsPlaintext_menuitem
+ ) {
+ AsPlaintext_menuitem.setAttribute("checked", true);
+ } else if (
+ !prefer_plaintext &&
+ html_as == 4 &&
+ !disallow_classes &&
+ AllBodyParts_menuitem
+ ) {
+ AllBodyParts_menuitem.setAttribute("checked", true);
+ }
+ // else (the user edited prefs/user.js) check none of the radio menu items
+
+ if (isFeed) {
+ AllowHTML_menuitem.hidden = !gShowFeedSummary;
+ Sanitized_menuitem.hidden = !gShowFeedSummary;
+ AsPlaintext_menuitem.hidden = !gShowFeedSummary;
+ document.getElementById(
+ "otherActionsMenu_viewFeedSummarySeparator"
+ ).hidden = !gShowFeedSummary;
+ }
+}
+
+/**
+ * Object literal to handle a few simple customization options for the message
+ * header.
+ */
+const gHeaderCustomize = {
+ docURL: "chrome://messenger/content/messenger.xhtml",
+ /**
+ * The DOM element panel collecting all customization options.
+ *
+ * @type {XULElement}
+ */
+ customizePanel: null,
+ /**
+ * The object storing all saved customization options.
+ *
+ * @note Any keys added to this object should also be added to the telemetry
+ * scalar tb.ui.configuration.message_header.
+ *
+ * @type {object}
+ * @property {boolean} showAvatar - If the profile picture of the sender
+ * should be shown.
+ * @property {boolean} showBigAvatar - If a big profile picture of the sender
+ * should be shown.
+ * @property {boolean} showFullAddress - If the sender should always be
+ * shown with the full name and email address.
+ * @property {boolean} hideLabels - If the labels column should be hidden.
+ * @property {boolean} subjectLarge - If the font size of the subject line
+ * should be increased.
+ * @property {string} buttonStyle - The style in which the buttons should be
+ * rendered:
+ * - "default" = icons+text
+ * - "only-icons" = only icons
+ * - "only-text" = only text
+ */
+ customizeData: {
+ showAvatar: true,
+ showBigAvatar: false,
+ showFullAddress: true,
+ hideLabels: true,
+ subjectLarge: true,
+ buttonStyle: "default",
+ },
+
+ /**
+ * Initialize the customizer.
+ */
+ init() {
+ this.customizePanel = document.getElementById(
+ "messageHeaderCustomizationPanel"
+ );
+
+ if (Services.xulStore.hasValue(this.docURL, "messageHeader", "layout")) {
+ this.customizeData = JSON.parse(
+ Services.xulStore.getValue(this.docURL, "messageHeader", "layout")
+ );
+ this.updateLayout();
+ }
+ },
+
+ /**
+ * Reset and update the customized style of the message header.
+ */
+ updateLayout() {
+ let header = document.getElementById("messageHeader");
+ // Always clear existing styles to avoid visual issues.
+ header.classList.remove(
+ "message-header-large-subject",
+ "message-header-buttons-only-icons",
+ "message-header-buttons-only-text",
+ "message-header-hide-label-column"
+ );
+
+ // Bail out if we don't have anything to customize.
+ if (!Object.keys(this.customizeData).length) {
+ header.classList.add(
+ "message-header-large-subject",
+ "message-header-show-recipient-avatar",
+ "message-header-show-sender-full-address",
+ "message-header-hide-label-column"
+ );
+ return;
+ }
+
+ header.classList.toggle(
+ "message-header-large-subject",
+ this.customizeData.subjectLarge || false
+ );
+
+ header.classList.toggle(
+ "message-header-hide-label-column",
+ this.customizeData.hideLabels || false
+ );
+
+ header.classList.toggle(
+ "message-header-show-recipient-avatar",
+ this.customizeData.showAvatar || false
+ );
+
+ header.classList.toggle(
+ "message-header-show-big-avatar",
+ this.customizeData.showBigAvatar || false
+ );
+
+ header.classList.toggle(
+ "message-header-show-sender-full-address",
+ this.customizeData.showFullAddress || false
+ );
+
+ switch (this.customizeData.buttonStyle) {
+ case "only-icons":
+ case "only-text":
+ header.classList.add(
+ `message-header-buttons-${this.customizeData.buttonStyle}`
+ );
+ break;
+
+ case "default":
+ default:
+ header.classList.remove(
+ "message-header-buttons-only-icons",
+ "message-header-buttons-only-text"
+ );
+ break;
+ }
+
+ gMessageHeader.syncLabelsColumnWidths();
+ },
+
+ /**
+ * Show the customization panel for the message header.
+ */
+ showPanel() {
+ this.customizePanel.openPopup(
+ document.getElementById("otherActionsButton"),
+ "after_end",
+ 6,
+ 6,
+ false
+ );
+ },
+
+ /**
+ * Update the panel's elements to reflect the users' customization.
+ */
+ onPanelShowing() {
+ document.getElementById("headerButtonStyle").value =
+ this.customizeData.buttonStyle || "default";
+
+ document.getElementById("headerShowAvatar").checked =
+ this.customizeData.showAvatar || false;
+
+ document.getElementById("headerShowBigAvatar").checked =
+ this.customizeData.showBigAvatar || false;
+
+ document.getElementById("headerShowFullAddress").checked =
+ this.customizeData.showFullAddress || false;
+
+ document.getElementById("headerHideLabels").checked =
+ this.customizeData.hideLabels || false;
+
+ document.getElementById("headerSubjectLarge").checked =
+ this.customizeData.subjectLarge || false;
+
+ let type = Ci.nsMimeHeaderDisplayTypes;
+ let pref = Services.prefs.getIntPref("mail.show_headers");
+
+ document.getElementById("headerViewAllHeaders").checked =
+ type.AllHeaders == pref;
+ },
+
+ /**
+ * Update the buttons style when the menuitem value is changed.
+ *
+ * @param {Event} event - The menuitem command event.
+ */
+ updateButtonStyle(event) {
+ this.customizeData.buttonStyle = event.target.value;
+ this.updateLayout();
+ },
+
+ /**
+ * Show or hide the profile picture of the sender recipient.
+ *
+ * @param {Event} event - The checkbox command event.
+ */
+ toggleAvatar(event) {
+ const isChecked = event.target.checked;
+ this.customizeData.showAvatar = isChecked;
+ document.getElementById("headerShowBigAvatar").disabled = !isChecked;
+ this.updateLayout();
+ },
+
+ /**
+ * Show big or small profile picture of the sender recipient.
+ *
+ * @param {Event} event - The checkbox command event.
+ */
+ toggleBigAvatar(event) {
+ this.customizeData.showBigAvatar = event.target.checked;
+ this.updateLayout();
+ },
+
+ /**
+ * Show or hide the sender's full address, which will show the display name
+ * and the email address on two different lines.
+ *
+ * @param {Event} event - The checkbox command event.
+ */
+ toggleSenderAddress(event) {
+ this.customizeData.showFullAddress = event.target.checked;
+ this.updateLayout();
+ },
+
+ /**
+ * Show or hide the labels column.
+ *
+ * @param {Event} event - The checkbox command event.
+ */
+ toggleLabelColumn(event) {
+ this.customizeData.hideLabels = event.target.checked;
+ this.updateLayout();
+ },
+
+ /**
+ * Update the subject style when the checkbox is clicked.
+ *
+ * @param {Event} event - The checkbox command event.
+ */
+ updateSubjectStyle(event) {
+ this.customizeData.subjectLarge = event.target.checked;
+ this.updateLayout();
+ },
+
+ /**
+ * Show or hide all the headers of a message.
+ *
+ * @param {Event} event - The checkbox command event.
+ */
+ toggleAllHeaders(event) {
+ let mode = event.target.checked
+ ? Ci.nsMimeHeaderDisplayTypes.AllHeaders
+ : Ci.nsMimeHeaderDisplayTypes.NormalHeaders;
+ Services.prefs.setIntPref("mail.show_headers", mode);
+ AdjustHeaderView(mode);
+ ReloadMessage();
+ },
+
+ /**
+ * Close the customize panel.
+ */
+ closePanel() {
+ this.customizePanel.hidePopup();
+ },
+
+ /**
+ * Update the xulStore only when the panel is closed.
+ */
+ onPanelHidden() {
+ Services.xulStore.setValue(
+ this.docURL,
+ "messageHeader",
+ "layout",
+ JSON.stringify(this.customizeData)
+ );
+ },
+};
+
+/**
+ * Object to handle the creation, destruction, and update of all recipient
+ * fields that will be showed in the message header.
+ */
+const gMessageHeader = {
+ /**
+ * Get the newsgroup server corresponding to the currently selected message.
+ *
+ * @returns {?nsISubscribableServer} The server for the newsgroup, or null.
+ */
+ get newsgroupServer() {
+ if (gFolder.isSpecialFolder(Ci.nsMsgFolderFlags.Newsgroup, false)) {
+ return gFolder.server?.QueryInterface(Ci.nsISubscribableServer);
+ }
+
+ return null;
+ },
+
+ /**
+ * Toggle the scrollable style of the message header area.
+ *
+ * @param {boolean} showAllHeaders - True if we need to show all header fields
+ * and ignore the space limit for multi recipients row.
+ */
+ toggleScrollableHeader(showAllHeaders) {
+ document
+ .getElementById("messageHeader")
+ .classList.toggle("scrollable", showAllHeaders);
+ },
+
+ /**
+ * Ensure that the all visible labels have the same size.
+ */
+ syncLabelsColumnWidths() {
+ let allHeaderLabels = document.querySelectorAll(
+ ".message-header-row:not([hidden]) .message-header-label"
+ );
+
+ // Clear existing style.
+ for (let label of allHeaderLabels) {
+ label.style.minWidth = null;
+ }
+
+ let minWidth = Math.max(...Array.from(allHeaderLabels, i => i.clientWidth));
+ for (let label of allHeaderLabels) {
+ label.style.minWidth = `${minWidth}px`;
+ }
+ },
+
+ openCopyPopup(event, element) {
+ document.getElementById("copyCreateFilterFrom").disabled =
+ !gFolder?.server.canHaveFilters;
+
+ let popup = document.getElementById(
+ element.matches(`:scope[is="url-header-row"]`)
+ ? "copyUrlPopup"
+ : "copyPopup"
+ );
+ popup.headerField = element;
+ popup.openPopupAtScreen(event.screenX, event.screenY, true);
+ },
+
+ async openEmailAddressPopup(event, element) {
+ // Bail out if we don't have an email address.
+ if (!element.emailAddress) {
+ return;
+ }
+
+ document
+ .getElementById("emailAddressPlaceHolder")
+ .setAttribute("label", element.emailAddress);
+
+ document.getElementById("addToAddressBookItem").hidden =
+ element.cardDetails.card;
+ document.getElementById("editContactItem").hidden =
+ !element.cardDetails.card || element.cardDetails.book?.readOnly;
+ document.getElementById("viewContactItem").hidden =
+ !element.cardDetails.card || !element.cardDetails.book?.readOnly;
+
+ let discoverKeyMenuItem = document.getElementById("searchKeysOpenPGP");
+ if (discoverKeyMenuItem) {
+ let hidden = await PgpSqliteDb2.hasAnyPositivelyAcceptedKeyForEmail(
+ element.emailAddress
+ );
+ discoverKeyMenuItem.hidden = hidden;
+ discoverKeyMenuItem.nextElementSibling.hidden = hidden; // Hide separator.
+ }
+
+ document.getElementById("createFilterFrom").disabled =
+ !gFolder?.server.canHaveFilters;
+
+ let popup = document.getElementById("emailAddressPopup");
+ popup.headerField = element;
+
+ if (!event.screenX) {
+ popup.openPopup(event.target, "after_start", 0, 0, true);
+ return;
+ }
+
+ popup.openPopupAtScreen(event.screenX, event.screenY, true);
+ },
+
+ openNewsgroupPopup(event, element) {
+ document
+ .getElementById("newsgroupPlaceHolder")
+ .setAttribute("label", element.textContent);
+
+ let subscribed = this.newsgroupServer
+ ?.QueryInterface(Ci.nsINntpIncomingServer)
+ .containsNewsgroup(element.textContent);
+ document.getElementById("subscribeToNewsgroupItem").hidden = subscribed;
+ document.getElementById("subscribeToNewsgroupSeparator").hidden =
+ subscribed;
+
+ let popup = document.getElementById("newsgroupPopup");
+ popup.headerField = element;
+
+ if (!event.screenX) {
+ popup.openPopup(event.target, "after_start", 0, 0, true);
+ return;
+ }
+
+ popup.openPopupAtScreen(event.screenX, event.screenY, true);
+ },
+
+ openMessageIdPopup(event, element) {
+ document
+ .getElementById("messageIdContext-messageIdTarget")
+ .setAttribute("label", element.id);
+
+ // We don't want to show "Open Message For ID" for the same message
+ // we're viewing.
+ document.getElementById("messageIdContext-openMessageForMsgId").hidden =
+ `<${gMessage.messageId}>` == element.id;
+
+ // We don't want to show "Open Browser With Message-ID" for non-nntp
+ // messages.
+ document.getElementById("messageIdContext-openBrowserWithMsgId").hidden =
+ !gFolder.isSpecialFolder(Ci.nsMsgFolderFlags.Newsgroup, false);
+
+ let popup = document.getElementById("messageIdContext");
+ popup.headerField = element;
+
+ if (!event.screenX) {
+ popup.openPopup(event.target, "after_start", 0, 0, true);
+ return;
+ }
+
+ popup.openPopupAtScreen(event.screenX, event.screenY, true);
+ },
+
+ /**
+ * Add a contact to the address book.
+ *
+ * @param {Event} event - The DOM Event.
+ */
+ addContact(event) {
+ event.currentTarget.parentNode.headerField.addToAddressBook();
+ },
+
+ /**
+ * Show the edit card popup panel.
+ *
+ * @param {Event} event - The DOM Event.
+ */
+ showContactEdit(event) {
+ this.editContact(event.currentTarget.parentNode.headerField);
+ },
+
+ /**
+ * Trigger a new message compose window.
+ *
+ * @param {Event} event - The click DOMEvent.
+ */
+ composeMessage(event) {
+ let recipient = event.currentTarget.parentNode.headerField;
+
+ let fields = Cc[
+ "@mozilla.org/messengercompose/composefields;1"
+ ].createInstance(Ci.nsIMsgCompFields);
+
+ if (recipient.classList.contains("header-newsgroup")) {
+ fields.newsgroups = recipient.textContent;
+ }
+
+ if (recipient.fullAddress) {
+ let addresses = MailServices.headerParser.makeFromDisplayAddress(
+ recipient.fullAddress
+ );
+ if (addresses.length) {
+ fields.to = MailServices.headerParser.makeMimeHeader([addresses[0]]);
+ }
+ }
+
+ let params = Cc[
+ "@mozilla.org/messengercompose/composeparams;1"
+ ].createInstance(Ci.nsIMsgComposeParams);
+ params.type = Ci.nsIMsgCompType.New;
+
+ // If the Shift key was pressed toggle the composition format
+ // (HTML vs. plaintext).
+ params.format = event.shiftKey
+ ? Ci.nsIMsgCompFormat.OppositeOfDefault
+ : Ci.nsIMsgCompFormat.Default;
+
+ if (gFolder) {
+ params.identity = MailServices.accounts.getFirstIdentityForServer(
+ gFolder.server
+ );
+ }
+ params.composeFields = fields;
+ MailServices.compose.OpenComposeWindowWithParams(null, params);
+ },
+
+ /**
+ * Copy the email address, as well as the name if wanted, in the clipboard.
+ *
+ * @param {Event} event - The DOM Event.
+ * @param {boolean} withName - True if we need to copy also the name.
+ */
+ copyAddress(event, withName = false) {
+ let recipient = event.currentTarget.parentNode.headerField;
+ let address;
+ if (recipient.classList.contains("header-newsgroup")) {
+ address = recipient.textContent;
+ } else {
+ address = withName ? recipient.fullAddress : recipient.emailAddress;
+ }
+ navigator.clipboard.writeText(address);
+ },
+
+ copyNewsgroupURL(event) {
+ let server = this.newsgroupServer;
+ if (!server) {
+ return;
+ }
+
+ let newsgroup = event.currentTarget.parentNode.headerField.textContent;
+
+ let url;
+ if (server.socketType != Ci.nsMsgSocketType.SSL) {
+ url = "news://" + server.hostName;
+ if (server.port != Ci.nsINntpUrl.DEFAULT_NNTP_PORT) {
+ url += ":" + server.port;
+ }
+ url += "/" + newsgroup;
+ } else {
+ url = "snews://" + server.hostName;
+ if (server.port != Ci.nsINntpUrl.DEFAULT_NNTPS_PORT) {
+ url += ":" + server.port;
+ }
+ url += "/" + newsgroup;
+ }
+
+ try {
+ let uri = Services.io.newURI(url);
+ navigator.clipboard.writeText(decodeURI(uri.spec));
+ } catch (e) {
+ console.error("Invalid URL: " + url);
+ }
+ },
+
+ /**
+ * Subscribe to a newsgroup.
+ *
+ * @param {Event} event - The DOM Event.
+ */
+ subscribeToNewsgroup(event) {
+ let server = this.newsgroupServer;
+ if (server) {
+ let newsgroup = event.currentTarget.parentNode.headerField.textContent;
+ server.subscribe(newsgroup);
+ server.commitSubscribeChanges();
+ }
+ },
+
+ /**
+ * Copy the text value of an header field.
+ *
+ * @param {Event} event - The DOM Event.
+ */
+ copyString(event) {
+ // This method is used inside the copyPopup menupopup, which is triggered by
+ // both HTML headers fields and XUL labels. We need to account for those
+ // different widgets in order to properly copy the text.
+ let target =
+ event.currentTarget.parentNode.triggerNode ||
+ event.currentTarget.parentNode.headerField;
+ navigator.clipboard.writeText(
+ window.getSelection().isCollapsed
+ ? target.textContent
+ : window.getSelection().toString()
+ );
+ },
+
+ /**
+ * Open the message filter dialog prefilled with available data.
+ *
+ * @param {Event} event - The DOM Event.
+ */
+ createFilter(event) {
+ let element = event.currentTarget.parentNode.headerField;
+ top.MsgFilters(
+ element.emailAddress || element.value.textContent,
+ gFolder,
+ element.dataset.headerName
+ );
+ },
+
+ /**
+ * Show the edit contact popup panel.
+ *
+ * @param {HTMLLIElement} element - The recipient element.
+ */
+ editContact(element) {
+ editContactInlineUI.showEditContactPanel(element.cardDetails, element);
+ },
+
+ /**
+ * Set the tags to the message header tag element.
+ */
+ setTags() {
+ // Bail out if we don't have a message selected.
+ if (!gMessage || !gFolder) {
+ return;
+ }
+
+ // Extract the tag keys from the message header.
+ let msgKeyArray = gMessage.getStringProperty("keywords").split(" ");
+
+ // Get the list of known tags.
+ let tagsArray = MailServices.tags.getAllTags().filter(t => t.tag);
+ let tagKeys = {};
+ for (let tagInfo of tagsArray) {
+ tagKeys[tagInfo.key] = true;
+ }
+ // Only use tags that match our saved tags.
+ let msgKeys = msgKeyArray.filter(k => k in tagKeys);
+
+ if (msgKeys.length) {
+ currentHeaderData.tags = {
+ headerName: "tags",
+ headerValue: msgKeys.join(" "),
+ };
+ return;
+ }
+
+ // No more tags, so clear out the header field.
+ delete currentHeaderData.tags;
+ },
+
+ onMessageIdClick(event) {
+ let id = event.currentTarget.closest(".header-message-id").id;
+ if (event.button == 0) {
+ // Remove the < and > symbols.
+ OpenMessageForMessageId(id.substring(1, id.length - 1));
+ }
+ },
+
+ openMessage(event) {
+ let id = event.currentTarget.parentNode.headerField.id;
+ // Remove the < and > symbols.
+ OpenMessageForMessageId(id.substring(1, id.length - 1));
+ },
+
+ openBrowser(event) {
+ let id = event.currentTarget.parentNode.headerField.id;
+ // Remove the < and > symbols.
+ OpenBrowserWithMessageId(id.substring(1, id.length - 1));
+ },
+
+ copyMessageId(event) {
+ navigator.clipboard.writeText(
+ event.currentTarget.parentNode.headerField.id
+ );
+ },
+
+ copyWebsiteUrl(event) {
+ navigator.clipboard.writeText(
+ event.currentTarget.parentNode.headerField.value.textContent
+ );
+ },
+};
+
+function MarkSelectedMessagesRead(markRead) {
+ ClearPendingReadTimer();
+ gDBView.doCommand(
+ markRead
+ ? Ci.nsMsgViewCommandType.markMessagesRead
+ : Ci.nsMsgViewCommandType.markMessagesUnread
+ );
+ if (markRead) {
+ reportMsgRead({ isNewRead: true });
+ }
+}
+
+function MarkSelectedMessagesFlagged(markFlagged) {
+ gDBView.doCommand(
+ markFlagged
+ ? Ci.nsMsgViewCommandType.flagMessages
+ : Ci.nsMsgViewCommandType.unflagMessages
+ );
+}
+
+/**
+ * Take the message id from the messageIdNode and use the url defined in the
+ * hidden pref "mailnews.messageid_browser.url" to open it in a browser window
+ * (%mid is replaced by the message id).
+ * @param {string} messageId - The message id to open.
+ */
+function OpenBrowserWithMessageId(messageId) {
+ var browserURL = Services.prefs.getComplexValue(
+ "mailnews.messageid_browser.url",
+ Ci.nsIPrefLocalizedString
+ ).data;
+ browserURL = browserURL.replace(/%mid/, messageId);
+ try {
+ Cc["@mozilla.org/uriloader/external-protocol-service;1"]
+ .getService(Ci.nsIExternalProtocolService)
+ .loadURI(Services.io.newURI(browserURL));
+ } catch (ex) {
+ console.error(
+ "Failed to open message-id in browser; browserURL=" + browserURL
+ );
+ }
+}
+
+/**
+ * Take the message id from the messageIdNode, search for the corresponding
+ * message in all folders starting with the current selected folder, then the
+ * current account followed by the other accounts and open corresponding
+ * message if found.
+ * @param {string} messageId - The message id to open.
+ */
+function OpenMessageForMessageId(messageId) {
+ let startServer = gFolder?.server;
+
+ window.setCursor("wait");
+ let msgHdr = MailUtils.getMsgHdrForMsgId(messageId, startServer);
+ window.setCursor("auto");
+
+ // If message was found open corresponding message.
+ if (msgHdr) {
+ if (parent.location == "about:3pane") {
+ // Message in 3pane.
+ parent.selectMessage(msgHdr);
+ } else {
+ // Message in tab, standalone message window.
+ let uri = msgHdr.folder.getUriForMsg(msgHdr);
+ window.displayMessage(uri);
+ }
+ return;
+ }
+ let messageIdStr = "<" + messageId + ">";
+ let bundle = document.getElementById("bundle_messenger");
+ let errorTitle = bundle.getString("errorOpenMessageForMessageIdTitle");
+ let errorMessage = bundle.getFormattedString(
+ "errorOpenMessageForMessageIdMessage",
+ [messageIdStr]
+ );
+ Services.prompt.alert(window, errorTitle, errorMessage);
+}
+
+/**
+ * @param headermode {Ci.nsMimeHeaderDisplayTypes}
+ */
+function AdjustHeaderView(headermode) {
+ const all = Ci.nsMimeHeaderDisplayTypes.AllHeaders;
+ document
+ .getElementById("messageHeader")
+ .setAttribute("show_header_mode", headermode == all ? "all" : "normal");
+}
+
+/**
+ * Should the reply command/button be enabled?
+ *
+ * @return whether the reply command/button should be enabled.
+ */
+function IsReplyEnabled() {
+ // If we're in an rss item, we never want to Reply, because there's
+ // usually no-one useful to reply to.
+ return !FeedUtils.isFeedMessage(gMessage);
+}
+
+/**
+ * Should the reply-all command/button be enabled?
+ *
+ * @return whether the reply-all command/button should be enabled.
+ */
+function IsReplyAllEnabled() {
+ if (gFolder?.isSpecialFolder(Ci.nsMsgFolderFlags.Newsgroup, false)) {
+ // If we're in a news item, we always want ReplyAll, because we can
+ // reply to the sender and the newsgroup.
+ return true;
+ }
+ if (FeedUtils.isFeedMessage(gMessage)) {
+ // If we're in an rss item, we never want to ReplyAll, because there's
+ // usually no-one useful to reply to.
+ return false;
+ }
+
+ let addresses =
+ gMessage.author + "," + gMessage.recipients + "," + gMessage.ccList;
+
+ // If we've got any BCCed addresses (because we sent the message), add
+ // them as well.
+ if ("bcc" in currentHeaderData) {
+ addresses += currentHeaderData.bcc.headerValue;
+ }
+
+ // Check to see if my email address is in the list of addresses.
+ let [myIdentity] = MailUtils.getIdentityForHeader(gMessage);
+ let myEmail = myIdentity ? myIdentity.email : null;
+ // We aren't guaranteed to have an email address, so guard against that.
+ let imInAddresses =
+ myEmail && addresses.toLowerCase().includes(myEmail.toLowerCase());
+
+ // Now, let's get the number of unique addresses.
+ let uniqueAddresses = MailServices.headerParser.removeDuplicateAddresses(
+ addresses,
+ ""
+ );
+ let numAddresses =
+ MailServices.headerParser.parseEncodedHeader(uniqueAddresses).length;
+
+ // I don't want to count my address in the number of addresses to reply
+ // to, since I won't be emailing myself.
+ if (imInAddresses) {
+ numAddresses--;
+ }
+
+ // ReplyAll is enabled if there is more than 1 person to reply to.
+ return numAddresses > 1;
+}
+
+/**
+ * Should the reply-list command/button be enabled?
+ *
+ * @return whether the reply-list command/button should be enabled.
+ */
+function IsReplyListEnabled() {
+ // ReplyToList is enabled if there is a List-Post header
+ // with the correct format.
+ let listPost = currentHeaderData["list-post"];
+ if (!listPost) {
+ return false;
+ }
+
+ // XXX: Once Bug 496914 provides a parser, we should use that instead.
+ // Until then, we need to keep the following regex in sync with the
+ // listPost parsing in nsMsgCompose.cpp's
+ // QuotingOutputStreamListener::OnStopRequest.
+ return /<mailto:.+>/.test(listPost.headerValue);
+}
+
+/**
+ * Update the enabled/disabled states of the Reply, Reply-All, and
+ * Reply-List buttons. (After this function runs, one of the buttons
+ * should be shown, and the others should be hidden.)
+ */
+function UpdateReplyButtons() {
+ // If we have no message, because we're being called from
+ // MailToolboxCustomizeDone before someone selected a message, then just
+ // return.
+ if (!gMessage) {
+ return;
+ }
+
+ let buttonToShow;
+ if (gFolder?.isSpecialFolder(Ci.nsMsgFolderFlags.Newsgroup, false)) {
+ // News messages always default to the "followup" dual-button.
+ buttonToShow = "followup";
+ } else if (FeedUtils.isFeedMessage(gMessage)) {
+ // RSS items hide all the reply buttons.
+ buttonToShow = null;
+ } else if (IsReplyListEnabled()) {
+ // Mail messages show the "reply" button (not the dual-button) and
+ // possibly the "reply all" and "reply list" buttons.
+ buttonToShow = "replyList";
+ } else if (IsReplyAllEnabled()) {
+ buttonToShow = "replyAll";
+ } else {
+ buttonToShow = "reply";
+ }
+
+ let smartReplyButton = document.getElementById("hdrSmartReplyButton");
+ if (smartReplyButton) {
+ let replyButton = document.getElementById("hdrReplyButton");
+ let replyAllButton = document.getElementById("hdrReplyAllButton");
+ let replyListButton = document.getElementById("hdrReplyListButton");
+ let followupButton = document.getElementById("hdrFollowupButton");
+
+ replyButton.hidden = buttonToShow != "reply";
+ replyAllButton.hidden = buttonToShow != "replyAll";
+ replyListButton.hidden = buttonToShow != "replyList";
+ followupButton.hidden = buttonToShow != "followup";
+ }
+
+ let replyToSenderButton = document.getElementById("hdrReplyToSenderButton");
+ if (replyToSenderButton) {
+ if (FeedUtils.isFeedMessage(gMessage)) {
+ replyToSenderButton.hidden = true;
+ } else if (smartReplyButton) {
+ replyToSenderButton.hidden = buttonToShow == "reply";
+ } else {
+ replyToSenderButton.hidden = false;
+ }
+ }
+
+ // Run this method only after all the header toolbar buttons have been updated
+ // so we deal with the actual state.
+ headerToolbarNavigation.updateRovingTab();
+}
+
+/**
+ * Update the enabled/disabled states of the Reply, Reply-All, Reply-List,
+ * Followup, and Forward buttons based on the number of identities.
+ * If there are no identities, all of these buttons should be disabled.
+ */
+function updateComposeButtons() {
+ const hasIdentities = MailServices.accounts.allIdentities.length;
+ for (let id of [
+ "hdrReplyButton",
+ "hdrReplyAllButton",
+ "hdrReplyListButton",
+ "hdrFollowupButton",
+ "hdrForwardButton",
+ "hdrReplyToSenderButton",
+ ]) {
+ document.getElementById(id).disabled = !hasIdentities;
+ }
+}
+
+function SelectedMessagesAreJunk() {
+ try {
+ let junkScore = gMessage.getStringProperty("junkscore");
+ return junkScore != "" && junkScore != "0";
+ } catch (ex) {
+ return false;
+ }
+}
+
+function SelectedMessagesAreRead() {
+ return gMessage?.isRead;
+}
+
+function SelectedMessagesAreFlagged() {
+ return gMessage?.isFlagged;
+}
+
+function MsgReplyMessage(event) {
+ if (gFolder.isSpecialFolder(Ci.nsMsgFolderFlags.Newsgroup, false)) {
+ MsgReplyGroup(event);
+ } else {
+ MsgReplySender(event);
+ }
+}
+
+function MsgReplySender(event) {
+ commandController._composeMsgByType(Ci.nsIMsgCompType.ReplyToSender, event);
+}
+
+function MsgReplyGroup(event) {
+ commandController._composeMsgByType(Ci.nsIMsgCompType.ReplyToGroup, event);
+}
+
+function MsgReplyToAllMessage(event) {
+ commandController._composeMsgByType(Ci.nsIMsgCompType.ReplyAll, event);
+}
+
+function MsgReplyToListMessage(event) {
+ commandController._composeMsgByType(Ci.nsIMsgCompType.ReplyToList, event);
+}
+
+function MsgForwardMessage(event) {
+ var forwardType = Services.prefs.getIntPref("mail.forward_message_mode", 0);
+
+ // mail.forward_message_mode could be 1, if the user migrated from 4.x
+ // 1 (forward as quoted) is obsolete, so we treat is as forward inline
+ // since that is more like forward as quoted then forward as attachment
+ if (forwardType == 0) {
+ MsgForwardAsAttachment(event);
+ } else {
+ MsgForwardAsInline(event);
+ }
+}
+
+function MsgForwardAsAttachment(event) {
+ commandController._composeMsgByType(
+ Ci.nsIMsgCompType.ForwardAsAttachment,
+ event
+ );
+}
+
+function MsgForwardAsInline(event) {
+ commandController._composeMsgByType(Ci.nsIMsgCompType.ForwardInline, event);
+}
+
+function MsgRedirectMessage(event) {
+ commandController._composeMsgByType(Ci.nsIMsgCompType.Redirect, event);
+}
+
+function MsgEditMessageAsNew(aEvent) {
+ commandController._composeMsgByType(Ci.nsIMsgCompType.EditAsNew, aEvent);
+}
+
+function MsgEditDraftMessage(aEvent) {
+ commandController._composeMsgByType(Ci.nsIMsgCompType.Draft, aEvent);
+}
+
+function MsgNewMessageFromTemplate(aEvent) {
+ commandController._composeMsgByType(Ci.nsIMsgCompType.Template, aEvent);
+}
+
+function MsgEditTemplateMessage(aEvent) {
+ commandController._composeMsgByType(Ci.nsIMsgCompType.EditTemplate, aEvent);
+}
+
+function MsgComposeDraftMessage() {
+ top.ComposeMessage(
+ Ci.nsIMsgCompType.Draft,
+ Ci.nsIMsgCompFormat.Default,
+ gFolder,
+ [gMessageURI]
+ );
+}
+
+/**
+ * Update the "archive", "junk" and "delete" buttons in the message header area.
+ */
+function updateHeaderToolbarButtons() {
+ let isDummyMessage = !gViewWrapper.isSynthetic && !gMessage.folder;
+ let archiveButton = document.getElementById("hdrArchiveButton");
+ let junkButton = document.getElementById("hdrJunkButton");
+ let trashButton = document.getElementById("hdrTrashButton");
+
+ if (isDummyMessage) {
+ archiveButton.disabled = true;
+ junkButton.disabled = true;
+ trashButton.disabled = true;
+ return;
+ }
+
+ archiveButton.disabled = !MessageArchiver.canArchive([gMessage]);
+ let junkScore = gMessage.getStringProperty("junkscore");
+ let hideJunk = junkScore == Ci.nsIJunkMailPlugin.IS_SPAM_SCORE;
+ if (!commandController._getViewCommandStatus(Ci.nsMsgViewCommandType.junk)) {
+ hideJunk = true;
+ }
+ junkButton.disabled = hideJunk;
+ trashButton.disabled = false;
+}
+
+/**
+ * Checks if the selected messages can be marked as read or unread
+ *
+ * @param markingRead true if trying to mark messages as read, false otherwise
+ * @return true if the chosen operation can be performed
+ */
+function CanMarkMsgAsRead(markingRead) {
+ return gMessage && SelectedMessagesAreRead() != markingRead;
+}
+
+/**
+ * Marks the selected messages as read or unread
+ *
+ * @param read true if trying to mark messages as read, false if marking unread,
+ * undefined if toggling the read status
+ */
+function MsgMarkMsgAsRead(read) {
+ if (read == undefined) {
+ read = !gMessage.isRead;
+ }
+ MarkSelectedMessagesRead(read);
+}
+
+function MsgMarkAsFlagged() {
+ MarkSelectedMessagesFlagged(!SelectedMessagesAreFlagged());
+}
+
+/**
+ * Extract email data and prefill the event/task dialog with that data.
+ */
+function convertToEventOrTask(isTask = false) {
+ window.top.calendarExtract.extractFromEmail(gMessage, isTask);
+}
+
+/**
+ * Triggered by the onHdrPropertyChanged notification for a single message being
+ * displayed. We handle updating the message display if our displayed message
+ * might have had its junk status change. This primarily entails updating the
+ * notification bar (that thing that appears above the message and says "this
+ * message might be junk") and (potentially) reloading the message because junk
+ * status affects the form of HTML display used (sanitized vs not).
+ * When our tab implementation is no longer multiplexed (reusing the same
+ * display widget), this must be moved into the MessageDisplayWidget or
+ * otherwise be scoped to the tab.
+ *
+ * @param {nsIMsgHdr} msgHdr - The nsIMsgHdr of the message with a junk status change.
+ */
+function HandleJunkStatusChanged(msgHdr) {
+ if (!msgHdr || !msgHdr.folder) {
+ return;
+ }
+
+ let junkBarStatus = gMessageNotificationBar.checkJunkMsgStatus(msgHdr);
+
+ // Only reload message if junk bar display state is changing and only if the
+ // reload is really needed.
+ if (junkBarStatus != 0) {
+ // We may be forcing junk mail to be rendered with sanitized html.
+ // In that scenario, we want to reload the message if the status has just
+ // changed to not junk.
+ var sanitizeJunkMail = Services.prefs.getBoolPref(
+ "mail.spam.display.sanitize"
+ );
+
+ // Only bother doing this if we are modifying the html for junk mail....
+ if (sanitizeJunkMail) {
+ let junkScore = msgHdr.getStringProperty("junkscore");
+ let isJunk = junkScore == Ci.nsIJunkMailPlugin.IS_SPAM_SCORE;
+
+ // If the current row isn't going to change, reload to show sanitized or
+ // unsanitized. Otherwise we wouldn't see the reloaded version anyway.
+ // 1) When marking as non-junk from the Junk folder, the msg would move
+ // back to the Inbox -> no reload needed
+ // When marking as non-junk from a folder other than the Junk folder,
+ // the message isn't moved back to Inbox -> reload needed
+ // (see nsMsgDBView::DetermineActionsForJunkChange)
+ // 2) When marking as junk, the msg will move or delete, if manualMark is set.
+ // 3) Marking as junk in the junk folder just changes the junk status.
+ if (
+ (!isJunk && !msgHdr.folder.isSpecialFolder(Ci.nsMsgFolderFlags.Junk)) ||
+ (isJunk && !msgHdr.folder.server.spamSettings.manualMark) ||
+ (isJunk && msgHdr.folder.isSpecialFolder(Ci.nsMsgFolderFlags.Junk))
+ ) {
+ ReloadMessage();
+ return;
+ }
+ }
+ }
+
+ gMessageNotificationBar.setJunkMsg(msgHdr);
+}
+
+/**
+ * Object to handle message related notifications that are showing in a
+ * notificationbox above the message content.
+ */
+var gMessageNotificationBar = {
+ get stringBundle() {
+ delete this.stringBundle;
+ return (this.stringBundle = document.getElementById("bundle_messenger"));
+ },
+
+ get brandBundle() {
+ delete this.brandBundle;
+ return (this.brandBundle = document.getElementById("bundle_brand"));
+ },
+
+ get msgNotificationBar() {
+ if (!this._notificationBox) {
+ this._notificationBox = new MozElements.NotificationBox(element => {
+ element.setAttribute("notificationside", "top");
+ document.getElementById("mail-notification-top").append(element);
+ });
+ }
+ return this._notificationBox;
+ },
+
+ /**
+ * Check if the current status of the junk notification is correct or not.
+ *
+ * @param {nsIMsgDBHdr} aMsgHdr - Information about the message
+ * @returns {integer} Tri-state status information.
+ * 1: notification is missing
+ * 0: notification is correct
+ * -1: notification must be removed
+ */
+ checkJunkMsgStatus(aMsgHdr) {
+ let junkScore = aMsgHdr ? aMsgHdr.getStringProperty("junkscore") : "";
+ let junkStatus = this.isShowingJunkNotification();
+
+ if (junkScore == "" || junkScore == Ci.nsIJunkMailPlugin.IS_HAM_SCORE) {
+ // This is not junk. The notification should not be shown.
+ return junkStatus ? -1 : 0;
+ }
+
+ // This is junk. The notification should be shown.
+ return junkStatus ? 0 : 1;
+ },
+
+ setJunkMsg(aMsgHdr) {
+ goUpdateCommand("cmd_junk");
+
+ let junkBarStatus = this.checkJunkMsgStatus(aMsgHdr);
+ if (junkBarStatus == -1) {
+ this.msgNotificationBar.removeNotification(
+ this.msgNotificationBar.getNotificationWithValue("junkContent"),
+ true
+ );
+ } else if (junkBarStatus == 1) {
+ let brandName = this.brandBundle.getString("brandShortName");
+ let junkBarMsg = this.stringBundle.getFormattedString("junkBarMessage", [
+ brandName,
+ ]);
+
+ let buttons = [
+ {
+ label: this.stringBundle.getString("junkBarInfoButton"),
+ accessKey: this.stringBundle.getString("junkBarInfoButtonKey"),
+ popup: null,
+ callback(aNotification, aButton) {
+ // TODO: This doesn't work in a message window.
+ top.openContentTab(
+ "https://support.mozilla.org/kb/thunderbird-and-junk-spam-messages"
+ );
+ return true; // keep notification open
+ },
+ },
+ {
+ label: this.stringBundle.getString("junkBarButton"),
+ accessKey: this.stringBundle.getString("junkBarButtonKey"),
+ popup: null,
+ callback(aNotification, aButton) {
+ commandController.doCommand("cmd_markAsNotJunk");
+ // Return true (=don't close) since changing junk status will fire a
+ // JunkStatusChanged notification which will make the junk bar go away
+ // for this message -> no notification to close anymore -> trying to
+ // close would just fail.
+ return true;
+ },
+ },
+ ];
+
+ this.msgNotificationBar.appendNotification(
+ "junkContent",
+ {
+ label: junkBarMsg,
+ image: "chrome://messenger/skin/icons/junk.svg",
+ priority: this.msgNotificationBar.PRIORITY_WARNING_HIGH,
+ },
+ buttons
+ );
+ }
+ },
+
+ isShowingJunkNotification() {
+ return !!this.msgNotificationBar.getNotificationWithValue("junkContent");
+ },
+
+ setRemoteContentMsg(aMsgHdr, aContentURI, aCanOverride) {
+ // update the allow remote content for sender string
+ let brandName = this.brandBundle.getString("brandShortName");
+ let remoteContentMsg = this.stringBundle.getFormattedString(
+ "remoteContentBarMessage",
+ [brandName]
+ );
+
+ let buttonLabel = this.stringBundle.getString(
+ AppConstants.platform == "win"
+ ? "remoteContentPrefLabel"
+ : "remoteContentPrefLabelUnix"
+ );
+ let buttonAccesskey = this.stringBundle.getString(
+ AppConstants.platform == "win"
+ ? "remoteContentPrefAccesskey"
+ : "remoteContentPrefAccesskeyUnix"
+ );
+
+ let buttons = [
+ {
+ label: buttonLabel,
+ accessKey: buttonAccesskey,
+ popup: "remoteContentOptions",
+ callback() {},
+ },
+ ];
+
+ // The popup value is a space separated list of all the blocked origins.
+ let popup = document.getElementById("remoteContentOptions");
+ let principal = Services.scriptSecurityManager.createContentPrincipal(
+ aContentURI,
+ {}
+ );
+ let origins = popup.value ? popup.value.split(" ") : [];
+ if (!origins.includes(principal.origin)) {
+ origins.push(principal.origin);
+ }
+ popup.value = origins.join(" ");
+
+ if (!this.isShowingRemoteContentNotification()) {
+ let notification = this.msgNotificationBar.appendNotification(
+ "remoteContent",
+ {
+ label: remoteContentMsg,
+ image: "chrome://messenger/skin/icons/remote-blocked.svg",
+ priority: this.msgNotificationBar.PRIORITY_WARNING_MEDIUM,
+ },
+ aCanOverride ? buttons : []
+ );
+
+ notification.buttonContainer.firstElementChild.classList.add(
+ "button-menu-list"
+ );
+ }
+ },
+
+ isShowingRemoteContentNotification() {
+ return !!this.msgNotificationBar.getNotificationWithValue("remoteContent");
+ },
+
+ setPhishingMsg() {
+ let phishingMsgNote = this.stringBundle.getString("phishingBarMessage");
+
+ let buttonLabel = this.stringBundle.getString(
+ AppConstants.platform == "win"
+ ? "phishingBarPrefLabel"
+ : "phishingBarPrefLabelUnix"
+ );
+ let buttonAccesskey = this.stringBundle.getString(
+ AppConstants.platform == "win"
+ ? "phishingBarPrefAccesskey"
+ : "phishingBarPrefAccesskeyUnix"
+ );
+
+ let buttons = [
+ {
+ label: buttonLabel,
+ accessKey: buttonAccesskey,
+ popup: "phishingOptions",
+ callback(aNotification, aButton) {},
+ },
+ ];
+
+ if (!this.isShowingPhishingNotification()) {
+ let notification = this.msgNotificationBar.appendNotification(
+ "maybeScam",
+ {
+ label: phishingMsgNote,
+ image: "chrome://messenger/skin/icons/phishing.svg",
+ priority: this.msgNotificationBar.PRIORITY_CRITICAL_MEDIUM,
+ },
+ buttons
+ );
+
+ notification.buttonContainer.firstElementChild.classList.add(
+ "button-menu-list"
+ );
+ }
+ },
+
+ isShowingPhishingNotification() {
+ return !!this.msgNotificationBar.getNotificationWithValue("maybeScam");
+ },
+
+ setMDNMsg(aMdnGenerator, aMsgHeader, aMimeHdr) {
+ this.mdnGenerator = aMdnGenerator;
+ // Return receipts can be RFC 3798 or not.
+ let mdnHdr =
+ aMimeHdr.extractHeader("Disposition-Notification-To", false) ||
+ aMimeHdr.extractHeader("Return-Receipt-To", false); // not
+ let fromHdr = aMimeHdr.extractHeader("From", false);
+
+ let mdnAddr =
+ MailServices.headerParser.extractHeaderAddressMailboxes(mdnHdr);
+ let fromAddr =
+ MailServices.headerParser.extractHeaderAddressMailboxes(fromHdr);
+
+ let authorName =
+ MailServices.headerParser.extractFirstName(
+ aMsgHeader.mime2DecodedAuthor
+ ) || aMsgHeader.author;
+
+ // If the return receipt doesn't go to the sender address, note that in the
+ // notification.
+ let mdnBarMsg =
+ mdnAddr != fromAddr
+ ? this.stringBundle.getFormattedString("mdnBarMessageAddressDiffers", [
+ authorName,
+ mdnAddr,
+ ])
+ : this.stringBundle.getFormattedString("mdnBarMessageNormal", [
+ authorName,
+ ]);
+
+ let buttons = [
+ {
+ label: this.stringBundle.getString("mdnBarSendReqButton"),
+ accessKey: this.stringBundle.getString("mdnBarSendReqButtonKey"),
+ popup: null,
+ callback(aNotification, aButton) {
+ SendMDNResponse();
+ return false; // close notification
+ },
+ },
+ {
+ label: this.stringBundle.getString("mdnBarIgnoreButton"),
+ accessKey: this.stringBundle.getString("mdnBarIgnoreButtonKey"),
+ popup: null,
+ callback(aNotification, aButton) {
+ IgnoreMDNResponse();
+ return false; // close notification
+ },
+ },
+ ];
+
+ this.msgNotificationBar.appendNotification(
+ "mdnRequested",
+ {
+ label: mdnBarMsg,
+ priority: this.msgNotificationBar.PRIORITY_INFO_MEDIUM,
+ },
+ buttons
+ );
+ },
+
+ setDraftEditMessage() {
+ if (!gMessage || !gFolder) {
+ return;
+ }
+
+ if (gFolder.isSpecialFolder(Ci.nsMsgFolderFlags.Drafts, true)) {
+ let draftMsgNote = this.stringBundle.getString("draftMessageMsg");
+
+ let buttons = [
+ {
+ label: this.stringBundle.getString("draftMessageButton"),
+ accessKey: this.stringBundle.getString("draftMessageButtonKey"),
+ popup: null,
+ callback(aNotification, aButton) {
+ MsgComposeDraftMessage();
+ return true; // keep notification open
+ },
+ },
+ ];
+
+ this.msgNotificationBar.appendNotification(
+ "draftMsgContent",
+ {
+ label: draftMsgNote,
+ priority: this.msgNotificationBar.PRIORITY_INFO_HIGH,
+ },
+ buttons
+ );
+ }
+ },
+
+ clearMsgNotifications() {
+ this.msgNotificationBar.removeAllNotifications(true);
+ },
+};
+
+/**
+ * LoadMsgWithRemoteContent
+ * Reload the current message, allowing remote content
+ */
+function LoadMsgWithRemoteContent() {
+ // we want to get the msg hdr for the currently selected message
+ // change the "remoteContentBar" property on it
+ // then reload the message
+
+ setMsgHdrPropertyAndReload("remoteContentPolicy", kAllowRemoteContent);
+ window.content?.focus();
+}
+
+/**
+ * Populate the remote content options for the current message.
+ */
+function onRemoteContentOptionsShowing(aEvent) {
+ let origins = aEvent.target.value ? aEvent.target.value.split(" ") : [];
+
+ let addresses = MailServices.headerParser.parseEncodedHeader(gMessage.author);
+ addresses = addresses.slice(0, 1);
+ // If there is an author's email, put it also in the menu.
+ let adrCount = addresses.length;
+ if (adrCount > 0) {
+ let authorEmailAddress = addresses[0].email;
+ let authorEmailAddressURI = Services.io.newURI(
+ "chrome://messenger/content/email=" + authorEmailAddress
+ );
+ let mailPrincipal = Services.scriptSecurityManager.createContentPrincipal(
+ authorEmailAddressURI,
+ {}
+ );
+ origins.push(mailPrincipal.origin);
+ }
+
+ let messengerBundle = document.getElementById("bundle_messenger");
+
+ // Out with the old...
+ let children = aEvent.target.children;
+ for (let i = children.length - 1; i >= 0; i--) {
+ if (children[i].getAttribute("class") == "allow-remote-uri") {
+ children[i].remove();
+ }
+ }
+
+ let urlSepar = document.getElementById("remoteContentAllMenuSeparator");
+
+ // ... and in with the new.
+ for (let origin of origins) {
+ let menuitem = document.createXULElement("menuitem");
+ menuitem.setAttribute(
+ "label",
+ messengerBundle.getFormattedString("remoteAllowResource", [
+ origin.replace("chrome://messenger/content/email=", ""),
+ ])
+ );
+ menuitem.setAttribute("value", origin);
+ menuitem.setAttribute("class", "allow-remote-uri");
+ menuitem.setAttribute("oncommand", "allowRemoteContentForURI(this.value);");
+ if (origin.startsWith("chrome://messenger/content/email=")) {
+ aEvent.target.appendChild(menuitem);
+ } else {
+ aEvent.target.insertBefore(menuitem, urlSepar);
+ }
+ }
+
+ let URLcount = origins.length - adrCount;
+ let allowAllItem = document.getElementById("remoteContentOptionAllowAll");
+ let allURLLabel = messengerBundle.getString("remoteAllowAll");
+ allowAllItem.label = PluralForm.get(URLcount, allURLLabel).replace(
+ "#1",
+ URLcount
+ );
+
+ allowAllItem.collapsed = URLcount < 2;
+ document.getElementById("remoteContentOriginsMenuSeparator").collapsed =
+ urlSepar.collapsed = allowAllItem.collapsed && adrCount == 0;
+}
+
+/**
+ * Add privileges to display remote content for the given uri.
+ *
+ * @param aUriSpec |String| uri for the site to add permissions for.
+ * @param aReload Reload the message display after allowing the URI.
+ */
+function allowRemoteContentForURI(aUriSpec, aReload = true) {
+ let uri = Services.io.newURI(aUriSpec);
+ Services.perms.addFromPrincipal(
+ Services.scriptSecurityManager.createContentPrincipal(uri, {}),
+ "image",
+ Services.perms.ALLOW_ACTION
+ );
+ if (aReload) {
+ ReloadMessage();
+ }
+}
+
+/**
+ * Add privileges to display remote content for the given uri.
+ *
+ * @param aListNode The menulist element containing the URIs to allow.
+ */
+function allowRemoteContentForAll(aListNode) {
+ let uriNodes = aListNode.querySelectorAll(".allow-remote-uri");
+ for (let uriNode of uriNodes) {
+ if (!uriNode.value.startsWith("chrome://messenger/content/email=")) {
+ allowRemoteContentForURI(uriNode.value, false);
+ }
+ }
+ ReloadMessage();
+}
+
+/**
+ * Displays fine-grained, per-site preferences for remote content.
+ */
+function editRemoteContentSettings() {
+ top.openOptionsDialog("panePrivacy", "privacyCategory");
+}
+
+/**
+ * Set the msg hdr flag to ignore the phishing warning and reload the message.
+ */
+function IgnorePhishingWarning() {
+ // This property should really be called skipPhishingWarning or something
+ // like that, but it's too late to change that now.
+ // This property is used to suppress the phishing bar for the message.
+ setMsgHdrPropertyAndReload("notAPhishMessage", 1);
+}
+
+/**
+ * Open the preferences dialog to allow disabling the scam feature.
+ */
+function OpenPhishingSettings() {
+ top.openOptionsDialog("panePrivacy", "privacySecurityCategory");
+}
+
+function setMsgHdrPropertyAndReload(aProperty, aValue) {
+ // we want to get the msg hdr for the currently selected message
+ // change the appropriate property on it then reload the message
+ if (gMessage) {
+ gMessage.setUint32Property(aProperty, aValue);
+ ReloadMessage();
+ }
+}
+
+/**
+ * Mark a specified message as read.
+ * @param msgHdr header (nsIMsgDBHdr) of the message to mark as read
+ */
+function MarkMessageAsRead(msgHdr) {
+ ClearPendingReadTimer();
+ msgHdr.folder.markMessagesRead([msgHdr], true);
+ reportMsgRead({ isNewRead: true });
+}
+
+function ClearPendingReadTimer() {
+ if (gMarkViewedMessageAsReadTimer) {
+ clearTimeout(gMarkViewedMessageAsReadTimer);
+ gMarkViewedMessageAsReadTimer = null;
+ }
+}
+
+// this is called when layout is actually finished rendering a
+// mail message. OnMsgLoaded is called when libmime is done parsing the message
+function OnMsgParsed(aUrl) {
+ // browser doesn't do this, but I thought it could be a useful thing to test out...
+ // If the find bar is visible and we just loaded a new message, re-run
+ // the find command. This means the new message will get highlighted and
+ // we'll scroll to the first word in the message that matches the find text.
+ var findBar = document.getElementById("FindToolbar");
+ if (!findBar.hidden) {
+ findBar.onFindAgainCommand(false);
+ }
+
+ let browser = getMessagePaneBrowser();
+ // Run the phishing detector on the message if it hasn't been marked as not
+ // a scam already.
+ if (
+ gMessage &&
+ !gMessage.getUint32Property("notAPhishMessage") &&
+ PhishingDetector.analyzeMsgForPhishingURLs(aUrl, browser)
+ ) {
+ gMessageNotificationBar.setPhishingMsg();
+ }
+
+ // Notify anyone (e.g., extensions) who's interested in when a message is loaded.
+ Services.obs.notifyObservers(null, "MsgMsgDisplayed", gMessageURI);
+
+ let doc = browser && browser.contentDocument ? browser.contentDocument : null;
+
+ // Rewrite any anchor elements' href attribute to reflect that the loaded
+ // document is a mailnews url. This will cause docShell to scroll to the
+ // element in the document rather than opening the link externally.
+ let links = doc && doc.links ? doc.links : [];
+ for (let linkNode of links) {
+ if (!linkNode.hash) {
+ continue;
+ }
+
+ // We have a ref fragment which may reference a node in this document.
+ // Ensure html in mail anchors work as expected.
+ let anchorId = linkNode.hash.replace("#", "");
+ // Continue if an id (html5) or name attribute value for the ref is not
+ // found in this document.
+ let selector = "#" + anchorId + ", [name='" + anchorId + "']";
+ try {
+ if (!linkNode.ownerDocument.querySelector(selector)) {
+ continue;
+ }
+ } catch (ex) {
+ continue;
+ }
+
+ // Then check if the href url matches the document baseURL.
+ if (
+ makeURI(linkNode.href).specIgnoringRef !=
+ makeURI(linkNode.baseURI).specIgnoringRef
+ ) {
+ continue;
+ }
+
+ // Finally, if the document url is a message url, and the anchor href is
+ // http, it needs to be adjusted so docShell finds the node.
+ let messageURI = makeURI(linkNode.ownerDocument.URL);
+ if (
+ messageURI instanceof Ci.nsIMsgMailNewsUrl &&
+ linkNode.href.startsWith("http")
+ ) {
+ linkNode.href = messageURI.specIgnoringRef + linkNode.hash;
+ }
+ }
+
+ // Scale any overflowing images, exclude http content.
+ let imgs = doc && !doc.URL.startsWith("http") ? doc.images : [];
+ for (let img of imgs) {
+ if (
+ img.clientWidth - doc.body.offsetWidth >= 0 &&
+ (img.clientWidth <= img.naturalWidth || !img.naturalWidth)
+ ) {
+ img.setAttribute("overflowing", "true");
+ }
+
+ // This is the default case for images when a message is loaded.
+ img.setAttribute("shrinktofit", "true");
+ }
+}
+
+function OnMsgLoaded(aUrl) {
+ if (!aUrl) {
+ return;
+ }
+
+ window.msgLoaded = true;
+ window.dispatchEvent(
+ new CustomEvent("MsgLoaded", { detail: gMessage, bubbles: true })
+ );
+ window.dispatchEvent(
+ new CustomEvent("MsgsLoaded", { detail: [gMessage], bubbles: true })
+ );
+
+ if (!gFolder) {
+ return;
+ }
+
+ gMessageNotificationBar.setJunkMsg(gMessage);
+
+ // See if MDN was requested but has not been sent.
+ HandleMDNResponse(aUrl);
+}
+
+/**
+ * Marks the message as read, optionally after a delay, if the preferences say
+ * we should do so.
+ */
+function autoMarkAsRead() {
+ if (!gMessage?.folder) {
+ // The message can't be marked read or unread.
+ return;
+ }
+
+ if (document.hidden) {
+ // We're in an inactive docShell (probably a background tab). Wait until
+ // it becomes active before marking the message as read.
+ document.addEventListener("visibilitychange", () => autoMarkAsRead(), {
+ once: true,
+ });
+ return;
+ }
+
+ let markReadAutoMode = Services.prefs.getBoolPref(
+ "mailnews.mark_message_read.auto"
+ );
+
+ // We just finished loading a message. If messages are to be marked as read
+ // automatically, set a timer to mark the message is read after n seconds
+ // where n can be configured by the user.
+ if (!gMessage.isRead && markReadAutoMode) {
+ let markReadOnADelay = Services.prefs.getBoolPref(
+ "mailnews.mark_message_read.delay"
+ );
+
+ let winType = top.document.documentElement.getAttribute("windowtype");
+ // Only use the timer if viewing using the 3-pane preview pane and the
+ // user has set the pref.
+ if (markReadOnADelay && winType == "mail:3pane") {
+ // 3-pane window
+ ClearPendingReadTimer();
+ let markReadDelayTime = Services.prefs.getIntPref(
+ "mailnews.mark_message_read.delay.interval"
+ );
+ if (markReadDelayTime == 0) {
+ MarkMessageAsRead(gMessage);
+ } else {
+ gMarkViewedMessageAsReadTimer = setTimeout(
+ MarkMessageAsRead,
+ markReadDelayTime * 1000,
+ gMessage
+ );
+ }
+ } else {
+ // standalone msg window
+ MarkMessageAsRead(gMessage);
+ }
+ }
+}
+
+/**
+ * This function handles all mdn response generation (ie, imap and pop).
+ * For pop the msg uid can be 0 (ie, 1st msg in a local folder) so no
+ * need to check uid here. No one seems to set mimeHeaders to null so
+ * no need to check it either.
+ */
+function HandleMDNResponse(aUrl) {
+ if (!aUrl) {
+ return;
+ }
+
+ var msgFolder = aUrl.folder;
+ if (
+ !msgFolder ||
+ !gMessage ||
+ gFolder.isSpecialFolder(Ci.nsMsgFolderFlags.Newsgroup, false)
+ ) {
+ return;
+ }
+
+ // if the message is marked as junk, do NOT attempt to process a return receipt
+ // in order to better protect the user
+ if (SelectedMessagesAreJunk()) {
+ return;
+ }
+
+ var mimeHdr;
+
+ try {
+ mimeHdr = aUrl.mimeHeaders;
+ } catch (ex) {
+ return;
+ }
+
+ // If we didn't get the message id when we downloaded the message header,
+ // we cons up an md5: message id. If we've done that, we'll try to extract
+ // the message id out of the mime headers for the whole message.
+ let msgId = gMessage.messageId;
+ if (msgId.startsWith("md5:")) {
+ var mimeMsgId = mimeHdr.extractHeader("Message-Id", false);
+ if (mimeMsgId) {
+ gMessage.messageId = mimeMsgId;
+ }
+ }
+
+ // After a msg is downloaded it's already marked READ at this point so we must check if
+ // the msg has a "Disposition-Notification-To" header and no MDN report has been sent yet.
+ if (gMessage.flags & Ci.nsMsgMessageFlags.MDNReportSent) {
+ return;
+ }
+
+ var DNTHeader = mimeHdr.extractHeader("Disposition-Notification-To", false);
+ var oldDNTHeader = mimeHdr.extractHeader("Return-Receipt-To", false);
+ if (!DNTHeader && !oldDNTHeader) {
+ return;
+ }
+
+ // Everything looks good so far, let's generate the MDN response.
+ var mdnGenerator = Cc[
+ "@mozilla.org/messenger-mdn/generator;1"
+ ].createInstance(Ci.nsIMsgMdnGenerator);
+ const MDN_DISPOSE_TYPE_DISPLAYED = 0;
+ let askUser = mdnGenerator.process(
+ MDN_DISPOSE_TYPE_DISPLAYED,
+ top.msgWindow,
+ msgFolder,
+ gMessage.messageKey,
+ mimeHdr,
+ false
+ );
+ if (askUser) {
+ gMessageNotificationBar.setMDNMsg(mdnGenerator, gMessage, mimeHdr);
+ }
+}
+
+function SendMDNResponse() {
+ gMessageNotificationBar.mdnGenerator.userAgreed();
+}
+
+function IgnoreMDNResponse() {
+ gMessageNotificationBar.mdnGenerator.userDeclined();
+}
+
+// An object to help collecting reading statistics of secure emails.
+var gSecureMsgProbe = {};
+
+/**
+ * Update gSecureMsgProbe and report to telemetry if necessary.
+ */
+function reportMsgRead({ isNewRead = false, key = null }) {
+ if (isNewRead) {
+ gSecureMsgProbe.isNewRead = true;
+ }
+ if (key) {
+ gSecureMsgProbe.key = key;
+ }
+ if (gSecureMsgProbe.key && gSecureMsgProbe.isNewRead) {
+ Services.telemetry.keyedScalarAdd(
+ "tb.mails.read_secure",
+ gSecureMsgProbe.key,
+ 1
+ );
+ }
+}
+
+window.addEventListener("secureMsgLoaded", event => {
+ reportMsgRead({ key: event.detail.key });
+});
+
+/**
+ * Roving tab navigation for the header buttons.
+ */
+var headerToolbarNavigation = {
+ /**
+ * Get all currently visible buttons of the message header toolbar.
+ *
+ * @returns {Array} An array of buttons.
+ */
+ get headerButtons() {
+ return this.headerToolbar.querySelectorAll(
+ `toolbarbutton:not([hidden="true"],[is="toolbarbutton-menu-button"]),toolbaritem[id="hdrSmartReplyButton"]>toolbarbutton:not([hidden="true"])>dropmarker, button:not([hidden])`
+ );
+ },
+
+ init() {
+ this.headerToolbar = document.getElementById("header-view-toolbar");
+ this.headerToolbar.addEventListener("keypress", event => {
+ this.triggerMessageHeaderRovingTab(event);
+ });
+ },
+
+ /**
+ * Update the `tabindex` attribute of the currently visible buttons.
+ */
+ updateRovingTab() {
+ for (let button of this.headerButtons) {
+ button.tabIndex = -1;
+ }
+ // Allow focus on the first available button.
+ // We use `setAttribute` to guarantee compatibility with XUL toolbarbuttons.
+ this.headerButtons[0].setAttribute("tabindex", "0");
+ },
+
+ /**
+ * Handles the keypress event on the message header toolbar.
+ *
+ * @param {Event} event - The keypress DOMEvent.
+ */
+ triggerMessageHeaderRovingTab(event) {
+ // Expected keyboard actions are Left, Right, Home, End, Space, and Enter.
+ if (
+ !["ArrowRight", "ArrowLeft", "Home", "End", " ", "Enter"].includes(
+ event.key
+ )
+ ) {
+ return;
+ }
+
+ const headerButtons = [...this.headerButtons];
+ let focusableButton = headerButtons.find(b => b.tabIndex != -1);
+ let elementIndex = headerButtons.indexOf(focusableButton);
+
+ // TODO: Remove once the buttons are updated to not be XUL
+ // NOTE: Normally a button click handler would cover Enter and Space key
+ // events. However, we need to prevent the default behavior and explicitly
+ // trigger the button click because the XUL toolbarbuttons do not work when
+ // the Enter key is pressed. They do work when the Space key is pressed.
+ // However, if the toolbarbutton is a dropdown menu, the Space key
+ // does not open the menu.
+ if (
+ event.key == "Enter" ||
+ (event.key == " " && event.target.hasAttribute("type"))
+ ) {
+ if (
+ event.target.getAttribute("class") ==
+ "toolbarbutton-menubutton-dropmarker"
+ ) {
+ event.preventDefault();
+ event.target.parentNode
+ .querySelector("menupopup")
+ .openPopup(event.target.parentNode, "after_end", {
+ triggerEvent: event,
+ });
+ } else {
+ event.preventDefault();
+ event.target.click();
+ return;
+ }
+ }
+
+ // Find the adjacent focusable element based on the pressed key.
+ if (
+ (document.dir == "rtl" && event.key == "ArrowLeft") ||
+ (document.dir == "ltr" && event.key == "ArrowRight")
+ ) {
+ elementIndex++;
+ if (elementIndex > headerButtons.length - 1) {
+ elementIndex = 0;
+ }
+ } else if (
+ (document.dir == "ltr" && event.key == "ArrowLeft") ||
+ (document.dir == "rtl" && event.key == "ArrowRight")
+ ) {
+ elementIndex--;
+ if (elementIndex == -1) {
+ elementIndex = headerButtons.length - 1;
+ }
+ }
+
+ // Move the focus to a new toolbar button and update the tabindex attribute.
+ let newFocusableButton = headerButtons[elementIndex];
+ if (newFocusableButton) {
+ focusableButton.tabIndex = -1;
+ newFocusableButton.setAttribute("tabindex", "0");
+ newFocusableButton.focus();
+ }
+ },
+};
diff --git a/comm/mail/base/content/msgSecurityPane.inc.xhtml b/comm/mail/base/content/msgSecurityPane.inc.xhtml
new file mode 100644
index 0000000000..a6a35e4b76
--- /dev/null
+++ b/comm/mail/base/content/msgSecurityPane.inc.xhtml
@@ -0,0 +1,131 @@
+# 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/.
+ <panel id="messageSecurityPanel"
+ type="arrow"
+ orient="vertical"
+ class="cui-widget-panel"
+ position="bottomright topright"
+ tabindex="-1">
+ <vbox class="security-panel-body">
+ <html:header class="message-security-header">
+ <html:h3>&status.label; <html:span id="techLabel"></html:span></html:h3>
+ </html:header>
+
+ <vbox class="message-security-body">
+ <!-- Notification container -->
+ <vbox>
+ <!-- Import OpenPGP key -->
+ <hbox id="openpgpKeyBox" hidden="true"
+ class="inline-notification-container info-container">
+ <hbox class="inline-notification-wrapper align-center">
+ <html:img class="notification-image"
+ src="chrome://messenger/skin/icons/information.svg"
+ alt="" />
+ <description>
+ <html:span data-l10n-id="openpgp-has-sender-key"></html:span>
+ </description>
+ <button id="openpgpImportButton"
+ class="button-focusable"
+ data-l10n-id="openpgp-import-sender-key"
+ oncommand="Enigmail.msg.importAttachedSenderKey();"/>
+ </hbox>
+ </hbox>
+
+ <!-- Missing signature -->
+ <hbox id="signatureKeyBox" hidden="true"
+ class="inline-notification-container info-container">
+ <hbox class="inline-notification-wrapper align-center">
+ <html:img class="notification-image"
+ src="chrome://messenger/skin/icons/information.svg"
+ alt="" />
+ <description data-l10n-id="openpgp-missing-signature-key"/>
+ <button data-l10n-id="openpgp-search-signature-key"
+ class="button-focusable"
+ oncommand="Enigmail.msg.searchSignatureKey();"/>
+ </hbox>
+ </hbox>
+ </vbox>
+
+ <label id="signatureLabel" class="message-security-label"/>
+ <label id="signatureHeader" collapsed="true" />
+ <description id="signatureExplanation"/>
+
+ <vbox id="signatureCert" class="message-security-container"
+ collapsed="true">
+ <hbox>
+ <label id="signedByLabel" class="cert-label">&signer.name;</label>
+ <description id="signedBy" />
+ </hbox>
+ <hbox>
+ <label id="signerEmailLabel"
+ class="cert-label">&email.address;</label>
+ <description id="signerEmail" />
+ </hbox>
+ <hbox>
+ <label id="sigCertIssuedByLabel"
+ class="cert-label">&issuer.name;</label>
+ <description id="sigCertIssuedBy" />
+ </hbox>
+ <hbox pack="end">
+ <button id="signatureCertView" label="&signatureCert.label;"
+ class="button-focusable"
+ oncommand="viewSignatureCert()" />
+ </hbox>
+ </vbox>
+
+ <hbox id="signatureKey" class="message-security-container"
+ collapsed="true" align="center">
+ <label id="signatureKeyId" flex="1" context="simpleCopyPopup"/>
+ <button id="viewSignatureKey" data-l10n-id="openpgp-view-signer-key"
+ class="button-focusable"
+ oncommand="viewSignatureKey()" collapsed="true"/>
+ </hbox>
+
+ <label id="encryptionLabel" class="message-security-label"/>
+ <label id="encryptionHeader" collapsed="true" />
+ <description id="encryptionExplanation"/>
+
+ <vbox id="encryptionCert" class="message-security-container"
+ collapsed="true">
+ <hbox>
+ <label id="encryptedForLabel"
+ class="cert-label">&recipient.name;</label>
+ <description id="encryptedFor" />
+ </hbox>
+ <hbox>
+ <label id="recipientEmailLabel"
+ class="cert-label">&email.address;</label>
+ <description id="recipientEmail" />
+ </hbox>
+ <hbox>
+ <label id="encCertIssuedByLabel"
+ class="cert-label">&issuer.name;</label>
+ <description id="encCertIssuedBy" />
+ </hbox>
+ <hbox pack="end">
+ <button id="encryptionCertView" label="&encryptionCert.label;"
+ class="button-focusable"
+ oncommand="viewEncryptionCert()" />
+ </hbox>
+ </vbox>
+
+ <vbox id="encryptionKey" class="message-security-container"
+ collapsed="true">
+ <label id="encryptionKeyId" context="simpleCopyPopup"/>
+ <hbox pack="end">
+ <button id="viewEncryptionKey"
+ data-l10n-id="openpgp-view-your-encryption-key"
+ class="button-focusable"
+ oncommand="viewEncryptionKey()"
+ collapsed="true"/>
+ </hbox>
+ </vbox>
+
+ <vbox id="otherEncryptionKeys" collapsed="true">
+ <label id="otherLabel" class="message-security-label none"/>
+ <vbox id="otherEncryptionKeysList"/>
+ </vbox>
+ </vbox>
+ </vbox>
+ </panel>
diff --git a/comm/mail/base/content/msgSecurityPane.js b/comm/mail/base/content/msgSecurityPane.js
new file mode 100644
index 0000000000..e8887363ce
--- /dev/null
+++ b/comm/mail/base/content/msgSecurityPane.js
@@ -0,0 +1,111 @@
+/* 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/. */
+
+/**
+ * Functions related to the msgSecurityPane.inc.xhtml file, used in the message
+ * header to display S/MIME and OpenPGP encryption and signature info.
+ */
+
+/* import-globals-from ../../../mailnews/extensions/smime/msgReadSMIMEOverlay.js */
+/* import-globals-from ../../extensions/openpgp/content/ui/enigmailMessengerOverlay.js */
+/* import-globals-from aboutMessage.js */
+
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ EnigmailConstants: "chrome://openpgp/content/modules/constants.jsm",
+ EnigmailKeyRing: "chrome://openpgp/content/modules/keyRing.jsm",
+ EnigmailWindows: "chrome://openpgp/content/modules/windows.jsm",
+});
+
+var gSigKeyId = null;
+var gEncKeyId = null;
+
+/**
+ * Reveal message security popup panel with updated OpenPGP or S/MIME info.
+ */
+function showMessageReadSecurityInfo() {
+ // Interrupt if no message is selected or no encryption technology was used.
+ if (!gMessage || document.getElementById("cryptoBox").hidden) {
+ return;
+ }
+
+ // OpenPGP.
+ if (document.getElementById("cryptoBox").getAttribute("tech") === "OpenPGP") {
+ Enigmail.msg.loadOpenPgpMessageSecurityInfo();
+ showMessageSecurityPanel();
+ return;
+ }
+
+ // S/MIME.
+ if (gSignatureStatus === Ci.nsICMSMessageErrors.VERIFY_NOT_YET_ATTEMPTED) {
+ showImapSignatureUnknown();
+ return;
+ }
+
+ loadSmimeMessageSecurityInfo();
+ showMessageSecurityPanel();
+}
+
+/**
+ * Reveal the popup panel with the populated message security info.
+ */
+function showMessageSecurityPanel() {
+ document
+ .getElementById("messageSecurityPanel")
+ .openPopup(
+ document.getElementById("encryptionTechBtn"),
+ "bottomright topright",
+ 0,
+ 0,
+ false
+ );
+}
+
+/**
+ * Reset all values and clear the text of the message security popup panel.
+ */
+function onMessageSecurityPopupHidden() {
+ // Clear the variables for signature and encryption.
+ gSigKeyId = null;
+ gEncKeyId = null;
+
+ // Hide the UI elements.
+ document.getElementById("signatureHeader").collapsed = true;
+ document.getElementById("encryptionHeader").collapsed = true;
+ document.getElementById("signatureCert").collapsed = true;
+ document.getElementById("signatureKey").collapsed = true;
+ document.getElementById("viewSignatureKey").collapsed = true;
+ document.getElementById("encryptionKey").collapsed = true;
+ document.getElementById("encryptionCert").collapsed = true;
+ document.getElementById("viewEncryptionKey").collapsed = true;
+ document.getElementById("otherEncryptionKeys").collapsed = true;
+
+ let keyList = document.getElementById("otherEncryptionKeysList");
+ // Clear any possible existing key previously appended to the DOM.
+ for (let node of keyList.children) {
+ keyList.removeChild(node);
+ }
+}
+
+async function viewSignatureKey() {
+ if (!gSigKeyId) {
+ return;
+ }
+
+ // If the signature acceptance was edited, reload the current message.
+ if (await EnigmailWindows.openKeyDetails(window, gSigKeyId, false)) {
+ ReloadMessage();
+ }
+}
+
+function viewEncryptionKey() {
+ if (!gEncKeyId) {
+ return;
+ }
+
+ EnigmailWindows.openKeyDetails(window, gEncKeyId, false);
+}
diff --git a/comm/mail/base/content/msgViewNavigation.js b/comm/mail/base/content/msgViewNavigation.js
new file mode 100644
index 0000000000..6ff860a717
--- /dev/null
+++ b/comm/mail/base/content/msgViewNavigation.js
@@ -0,0 +1,207 @@
+/* -*- Mode: Javascript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*-
+ * 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/. */
+
+/* This file contains the js functions necessary to implement view navigation within the 3 pane. */
+
+/* globals DBViewWrapper, dbViewWrapperListener, TreeSelection */
+/* globals gDBView: true, gFolder: true, gViewWrapper: true */ // mailCommon.js
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "FolderUtils",
+ "resource:///modules/FolderUtils.jsm"
+);
+
+function GetSubFoldersInFolderPaneOrder(folder) {
+ function compareFolderSortKey(folder1, folder2) {
+ return folder1.compareSortKeys(folder2);
+ }
+ // sort the subfolders
+ return folder.subFolders.sort(compareFolderSortKey);
+}
+
+function FindNextChildFolder(aParent, aAfter) {
+ // Search the child folders of aParent for unread messages
+ // but in the case that we are working up from the current folder
+ // we need to skip up to and including the current folder
+ // we skip the current folder in case a mail view is hiding unread messages
+ if (aParent.getNumUnread(true) > 0) {
+ var subFolders = GetSubFoldersInFolderPaneOrder(aParent);
+ var i = 0;
+ var folder = null;
+
+ // Skip folders until after the specified child
+ while (folder != aAfter) {
+ folder = subFolders[i++];
+ }
+
+ let ignoreFlags =
+ Ci.nsMsgFolderFlags.Trash |
+ Ci.nsMsgFolderFlags.SentMail |
+ Ci.nsMsgFolderFlags.Drafts |
+ Ci.nsMsgFolderFlags.Queue |
+ Ci.nsMsgFolderFlags.Templates |
+ Ci.nsMsgFolderFlags.Junk;
+ while (i < subFolders.length) {
+ folder = subFolders[i++];
+ // If there is unread mail in the trash, sent, drafts, unsent messages
+ // templates or junk special folder,
+ // we ignore it when doing cross folder "next" navigation.
+ if (!folder.isSpecialFolder(ignoreFlags, true)) {
+ if (folder.getNumUnread(false) > 0) {
+ return folder;
+ }
+
+ folder = FindNextChildFolder(folder, null);
+ if (folder) {
+ return folder;
+ }
+ }
+ }
+ }
+
+ return null;
+}
+
+function FindNextFolder() {
+ // look for the next folder, this will only look on the current account
+ // and below us, in the folder pane
+ // note use of gDBView restricts this function to message folders
+ // otherwise you could go next unread from a server
+ var folder = FindNextChildFolder(gDBView.msgFolder, null);
+ if (folder) {
+ return folder;
+ }
+
+ // didn't find folder in children
+ // go up to the parent, and start at the folder after the current one
+ // unless we are at a server, in which case bail out.
+ folder = gDBView.msgFolder;
+ while (!folder.isServer) {
+ var parent = folder.parent;
+ folder = FindNextChildFolder(parent, folder);
+ if (folder) {
+ return folder;
+ }
+
+ // none at this level after the current folder. go up.
+ folder = parent;
+ }
+
+ // nothing in the current account, start with the next account (below)
+ // and try until we hit the bottom of the folder pane
+
+ // start at the account after the current account
+ var rootFolders = GetRootFoldersInFolderPaneOrder();
+ for (var i = 0; i < rootFolders.length; i++) {
+ if (rootFolders[i].URI == gDBView.msgFolder.server.serverURI) {
+ break;
+ }
+ }
+
+ for (var j = i + 1; j < rootFolders.length; j++) {
+ folder = FindNextChildFolder(rootFolders[j], null);
+ if (folder) {
+ return folder;
+ }
+ }
+
+ // if nothing from the current account down to the bottom
+ // (of the folder pane), start again at the top.
+ for (j = 0; j <= i; j++) {
+ folder = FindNextChildFolder(rootFolders[j], null);
+ if (folder) {
+ return folder;
+ }
+ }
+ return null;
+}
+
+function GetRootFoldersInFolderPaneOrder() {
+ let accounts = FolderUtils.allAccountsSorted(false);
+
+ let serversMsgFolders = [];
+ for (let account of accounts) {
+ serversMsgFolders.push(account.incomingServer.rootMsgFolder);
+ }
+
+ return serversMsgFolders;
+}
+
+/**
+ * Handle switching the folder if required for the given kind of navigation.
+ * Only used in about:3pane.
+ *
+ * @param {nsMsgNavigationType} type - The type of navigation.
+ * @returns {boolean} If the folder was changed for the navigation.
+ */
+function CrossFolderNavigation(type) {
+ // do cross folder navigation for next unread message/thread and message history
+ if (
+ type != Ci.nsMsgNavigationType.nextUnreadMessage &&
+ type != Ci.nsMsgNavigationType.nextUnreadThread
+ ) {
+ return false;
+ }
+
+ let nextMode = Services.prefs.getIntPref("mailnews.nav_crosses_folders");
+ // 0: "next" goes to the next folder, without prompting
+ // 1: "next" goes to the next folder, and prompts (the default)
+ // 2: "next" does nothing when there are no unread messages
+
+ // not crossing folders, don't find next
+ if (nextMode == 2) {
+ return false;
+ }
+
+ let folder = FindNextFolder();
+ if (!folder || gDBView.msgFolder.URI == folder.URI) {
+ return false;
+ }
+
+ if (nextMode == 1) {
+ let messengerBundle =
+ window.messengerBundle ||
+ Services.strings.createBundle(
+ "chrome://messenger/locale/messenger.properties"
+ );
+ let promptText = messengerBundle.formatStringFromName("advanceNextPrompt", [
+ folder.name,
+ ]);
+ if (
+ Services.prompt.confirmEx(
+ window,
+ null,
+ promptText,
+ Services.prompt.STD_YES_NO_BUTTONS,
+ null,
+ null,
+ null,
+ null,
+ {}
+ )
+ ) {
+ return false;
+ }
+ }
+
+ if (window.threadPane) {
+ // In about:3pane.
+ window.threadPane.forgetSelection(folder.URI);
+ window.displayFolder(folder.URI);
+ } else {
+ // In standalone about:message. Do just enough to call
+ // `commandController._navigate` again.
+ gViewWrapper = new DBViewWrapper(dbViewWrapperListener);
+ gViewWrapper._viewFlags = Ci.nsMsgViewFlagsType.kThreadedDisplay;
+ gViewWrapper.open(folder);
+ gDBView = gViewWrapper.dbView;
+ let selection = (gDBView.selection = new TreeSelection());
+ selection.view = gDBView;
+ // We're now in a bit of a weird state until `displayMessage` is called,
+ // but being here means we have everything we need for that to happen.
+ }
+ return true;
+}
diff --git a/comm/mail/base/content/multimessageview.js b/comm/mail/base/content/multimessageview.js
new file mode 100644
index 0000000000..9b4c593fde
--- /dev/null
+++ b/comm/mail/base/content/multimessageview.js
@@ -0,0 +1,844 @@
+/* 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/. */
+
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ DisplayNameUtils: "resource:///modules/DisplayNameUtils.jsm",
+ Gloda: "resource:///modules/gloda/Gloda.jsm",
+ makeFriendlyDateAgo: "resource:///modules/TemplateUtils.jsm",
+ MessageArchiver: "resource:///modules/MessageArchiver.jsm",
+ mimeMsgToContentSnippetAndMeta: "resource:///modules/gloda/GlodaContent.jsm",
+ MsgHdrToMimeMessage: "resource:///modules/gloda/MimeMessage.jsm",
+ PluralStringFormatter: "resource:///modules/TemplateUtils.jsm",
+ TagUtils: "resource:///modules/TagUtils.jsm",
+});
+
+var gMessenger = Cc["@mozilla.org/messenger;1"].createInstance(Ci.nsIMessenger);
+
+// Set up our string formatter for localizing strings.
+XPCOMUtils.defineLazyGetter(this, "formatString", function () {
+ let formatter = new PluralStringFormatter(
+ "chrome://messenger/locale/multimessageview.properties"
+ );
+ return function (...args) {
+ return formatter.get(...args);
+ };
+});
+
+/**
+ * A LimitIterator is a utility class that allows limiting the maximum number
+ * of items to iterate over.
+ *
+ * @param aArray The array to iterate over (can be anything with a .length
+ * property and a subscript operator.
+ * @param aMaxLength The maximum number of items to iterate over.
+ */
+function LimitIterator(aArray, aMaxLength) {
+ this._array = aArray;
+ this._maxLength = aMaxLength;
+}
+
+LimitIterator.prototype = {
+ /**
+ * Returns true if the iterator won't actually iterate over everything in the
+ * array.
+ */
+ get limited() {
+ return this._array.length > this._maxLength;
+ },
+
+ /**
+ * Returns the number of elements that will actually be iterated over.
+ */
+ get length() {
+ return Math.min(this._array.length, this._maxLength);
+ },
+
+ /**
+ * Returns the real number of elements in the array.
+ */
+ get trueLength() {
+ return this._array.length;
+ },
+};
+
+var JS_HAS_SYMBOLS = typeof Symbol === "function";
+var ITERATOR_SYMBOL = JS_HAS_SYMBOLS ? Symbol.iterator : "@@iterator";
+
+/**
+ * Iterate over the array until we hit the end or the maximum length,
+ * whichever comes first.
+ */
+LimitIterator.prototype[ITERATOR_SYMBOL] = function* () {
+ let length = this.length;
+ for (let i = 0; i < length; i++) {
+ yield this._array[i];
+ }
+};
+
+/**
+ * The MultiMessageSummary class is responsible for populating the message pane
+ * with a reasonable summary of a set of messages.
+ */
+function MultiMessageSummary() {
+ this._summarizers = {};
+}
+
+MultiMessageSummary.prototype = {
+ /**
+ * The maximum number of messages to examine in any way.
+ */
+ kMaxMessages: 10000,
+
+ /**
+ * Register a summarizer for a particular type of message summary.
+ *
+ * @param aSummarizer The summarizer object.
+ */
+ registerSummarizer(aSummarizer) {
+ this._summarizers[aSummarizer.name] = aSummarizer;
+ aSummarizer.onregistered(this);
+ },
+
+ /**
+ * Store a mapping from a message header to the summary node in the DOM. We
+ * use this to update things when Gloda tells us to.
+ *
+ * @param aMsgHdr The nsIMsgDBHdr.
+ * @param aNode The related DOM node.
+ */
+ mapMsgToNode(aMsgHdr, aNode) {
+ let key = aMsgHdr.messageKey + aMsgHdr.folder.URI;
+ this._msgNodes[key] = aNode;
+ },
+
+ /**
+ * Clear all the content from the summary.
+ */
+ clear() {
+ this._selectCallback = null;
+ this._listener = null;
+ this._glodaQuery = null;
+ this._msgNodes = {};
+
+ // Clear the messages list.
+ let messageList = document.getElementById("message_list");
+ while (messageList.hasChildNodes()) {
+ messageList.lastChild.remove();
+ }
+
+ // Clear the notice.
+ document.getElementById("notice").textContent = "";
+ },
+
+ /**
+ * Fill in the summary pane describing the selected messages.
+ *
+ * @param aType The type of summary to perform (e.g. 'multimessage').
+ * @param aMessages The messages to summarize.
+ * @param aDBView The current DB view.
+ * @param aSelectCallback Called with an array of nsIMsgHdrs when one of
+ * a summarized message is clicked on.
+ * @param [aListener] A listener to be notified when the summary starts and
+ * finishes.
+ */
+ summarize(aType, aMessages, aDBView, aSelectCallback, aListener) {
+ this.clear();
+
+ this._selectCallback = aSelectCallback;
+ this._listener = aListener;
+ if (this._listener) {
+ this._listener.onLoadStarted();
+ }
+
+ // Enable/disable the archive button as appropriate.
+ let archiveBtn = document.getElementById("hdrArchiveButton");
+ archiveBtn.hidden = !MessageArchiver.canArchive(aMessages);
+
+ // Set archive and delete button listeners.
+ let topChromeWindow = window.browsingContext.topChromeWindow;
+ archiveBtn.onclick = event => {
+ if (event.button == 0) {
+ topChromeWindow.goDoCommand("cmd_archive");
+ }
+ };
+ document.getElementById("hdrTrashButton").onclick = event => {
+ if (event.button == 0) {
+ topChromeWindow.goDoCommand("cmd_delete");
+ }
+ };
+
+ headerToolbarNavigation.init();
+
+ let summarizer = this._summarizers[aType];
+ if (!summarizer) {
+ throw new Error('Unknown summarizer "' + aType + '"');
+ }
+
+ let messages = new LimitIterator(aMessages, this.kMaxMessages);
+ let summarizedMessages = summarizer.summarize(messages, aDBView);
+
+ // Stash somewhere so it doesn't get GC'ed.
+ this._glodaQuery = Gloda.getMessageCollectionForHeaders(
+ summarizedMessages,
+ this
+ );
+ this._computeSize(messages);
+ },
+
+ /**
+ * Set the heading for the summary.
+ *
+ * @param title The title for the heading.
+ * @param subtitle A smaller subtitle for the heading.
+ */
+ setHeading(title, subtitle) {
+ let titleNode = document.getElementById("summary_title");
+ let subtitleNode = document.getElementById("summary_subtitle");
+ titleNode.textContent = title || "";
+ subtitleNode.textContent = subtitle || "";
+ },
+
+ /**
+ * Create a summary item for a message or thread.
+ *
+ * @param aMsgOrThread An nsIMsgDBHdr or an array thereof
+ * @param [aOptions] An optional object to customize the output:
+ * showSubject: true if the subject of the message
+ * should be shown; defaults to false
+ * snippetLength: the length in bytes of the message
+ * snippet; defaults to undefined (let Gloda decide)
+ * @returns A DOM node for the summary item.
+ */
+ makeSummaryItem(aMsgOrThread, aOptions) {
+ let message, thread, numUnread, isStarred, tags;
+ if (aMsgOrThread instanceof Ci.nsIMsgDBHdr) {
+ thread = null;
+ message = aMsgOrThread;
+
+ numUnread = message.isRead ? 0 : 1;
+ isStarred = message.isFlagged;
+
+ tags = this._getTagsForMsg(message);
+ } else {
+ thread = aMsgOrThread;
+ message = thread[0];
+
+ numUnread = thread.reduce(function (x, hdr) {
+ return x + (hdr.isRead ? 0 : 1);
+ }, 0);
+ isStarred = thread.some(function (hdr) {
+ return hdr.isFlagged;
+ });
+
+ tags = new Set();
+ for (let message of thread) {
+ for (let tag of this._getTagsForMsg(message)) {
+ tags.add(tag);
+ }
+ }
+ }
+
+ let row = document.createElement("li");
+ row.dataset.messageId = message.messageId;
+ row.classList.toggle("thread", thread && thread.length > 1);
+ row.classList.toggle("unread", numUnread > 0);
+ row.classList.toggle("starred", isStarred);
+
+ row.appendChild(document.createElement("div")).classList.add("star");
+
+ let summary = document.createElement("div");
+ summary.classList.add("item_summary");
+ summary
+ .appendChild(document.createElement("div"))
+ .classList.add("item_header");
+ summary.appendChild(document.createElement("div")).classList.add("snippet");
+ row.appendChild(summary);
+
+ let itemHeaderNode = row.querySelector(".item_header");
+
+ let authorNode = document.createElement("span");
+ authorNode.classList.add("author");
+ authorNode.textContent = DisplayNameUtils.formatDisplayNameList(
+ message.mime2DecodedAuthor,
+ "from"
+ );
+
+ if (aOptions && aOptions.showSubject) {
+ authorNode.classList.add("right");
+ itemHeaderNode.appendChild(authorNode);
+
+ let subjectNode = document.createElement("span");
+ subjectNode.classList.add("subject", "primary_header", "link");
+ subjectNode.textContent =
+ message.mime2DecodedSubject || formatString("noSubject");
+ subjectNode.addEventListener("click", () => this._selectCallback(thread));
+ itemHeaderNode.appendChild(subjectNode);
+
+ if (thread && thread.length > 1) {
+ let numUnreadStr = "";
+ if (numUnread) {
+ numUnreadStr = formatString(
+ "numUnread",
+ [numUnread.toLocaleString()],
+ numUnread
+ );
+ }
+ let countStr =
+ "(" +
+ formatString(
+ "numMessages",
+ [thread.length.toLocaleString()],
+ thread.length
+ ) +
+ numUnreadStr +
+ ")";
+
+ let countNode = document.createElement("span");
+ countNode.classList.add("count");
+ countNode.textContent = countStr;
+ itemHeaderNode.appendChild(countNode);
+ }
+ } else {
+ let dateNode = document.createElement("span");
+ dateNode.classList.add("date", "right");
+ dateNode.textContent = makeFriendlyDateAgo(new Date(message.date / 1000));
+ itemHeaderNode.appendChild(dateNode);
+
+ authorNode.classList.add("primary_header", "link");
+ authorNode.addEventListener("click", () => {
+ this._selectCallback([message]);
+ });
+ itemHeaderNode.appendChild(authorNode);
+ }
+
+ let tagNode = document.createElement("span");
+ tagNode.classList.add("tags");
+ this._addTagNodes(tags, tagNode);
+ itemHeaderNode.appendChild(tagNode);
+
+ let snippetNode = row.querySelector(".snippet");
+ try {
+ const kSnippetLength = aOptions && aOptions.snippetLength;
+ MsgHdrToMimeMessage(
+ message,
+ null,
+ function (aMsgHdr, aMimeMsg) {
+ if (aMimeMsg == null) {
+ // Shouldn't happen, but sometimes does?
+ return;
+ }
+ let [text, meta] = mimeMsgToContentSnippetAndMeta(
+ aMimeMsg,
+ aMsgHdr.folder,
+ kSnippetLength
+ );
+ snippetNode.textContent = text;
+ if (meta.author) {
+ authorNode.textContent = meta.author;
+ }
+ },
+ false,
+ { saneBodySize: true }
+ );
+ } catch (e) {
+ if (e.result == Cr.NS_ERROR_FAILURE) {
+ // Offline messages generate exceptions, which is unfortunate. When
+ // that's fixed, this code should adapt. XXX
+ snippetNode.textContent = "...";
+ } else {
+ throw e;
+ }
+ }
+ return row;
+ },
+
+ /**
+ * Show an informative notice about the summarized messages (e.g. if we only
+ * summarized some of them).
+ *
+ * @param aNoticeText The text to show in the notice.
+ */
+ showNotice(aNoticeText) {
+ let notice = document.getElementById("notice");
+ notice.textContent = aNoticeText;
+ },
+
+ /**
+ * Given a msgHdr, return a list of tag objects. This function just does the
+ * messy work of understanding how tags are stored in nsIMsgDBHdrs. It would
+ * be a good candidate for a utility library.
+ *
+ * @param aMsgHdr The msgHdr whose tags we want.
+ * @returns An array of nsIMsgTag objects.
+ */
+ _getTagsForMsg(aMsgHdr) {
+ let keywords = new Set(aMsgHdr.getStringProperty("keywords").split(" "));
+ let allTags = MailServices.tags.getAllTags();
+
+ return allTags.filter(function (tag) {
+ return keywords.has(tag.key);
+ });
+ },
+
+ /**
+ * Add a list of tags to a DOM node.
+ *
+ * @param aTags An array (or any iterable) of nsIMsgTag objects.
+ * @param aTagsNode The DOM node to contain the list of tags.
+ */
+ _addTagNodes(aTags, aTagsNode) {
+ // Make sure the tags are sorted in their natural order.
+ let sortedTags = [...aTags];
+ sortedTags.sort(function (a, b) {
+ return a.key.localeCompare(b.key) || a.ordinal.localeCompare(b.ordinal);
+ });
+
+ for (let tag of sortedTags) {
+ let tagNode = document.createElement("span");
+
+ tagNode.className = "tag";
+ let color = MailServices.tags.getColorForKey(tag.key);
+ if (color) {
+ let textColor = !TagUtils.isColorContrastEnough(color)
+ ? "white"
+ : "black";
+ tagNode.setAttribute(
+ "style",
+ "color: " + textColor + "; background-color: " + color + ";"
+ );
+ }
+ tagNode.dataset.tag = tag.tag;
+ tagNode.textContent = tag.tag;
+ aTagsNode.appendChild(tagNode);
+ }
+ },
+
+ /**
+ * Compute the size of the messages in the selection and display it in the
+ * element of id "size".
+ *
+ * @param aMessages A LimitIterator of the messages to calculate the size of.
+ */
+ _computeSize(aMessages) {
+ let numBytes = 0;
+ for (let msgHdr of aMessages) {
+ numBytes += msgHdr.messageSize;
+ // XXX do something about news?
+ }
+
+ let format = aMessages.limited
+ ? "messagesTotalSizeMoreThan"
+ : "messagesTotalSize";
+ document.getElementById("size").textContent = formatString(format, [
+ gMessenger.formatFileSize(numBytes),
+ ]);
+ },
+
+ // These are listeners for the gloda collections.
+ onItemsAdded(aItems) {},
+ onItemsModified(aItems) {
+ this._processItems(aItems);
+ },
+ onItemsRemoved(aItems) {},
+
+ /**
+ * Given a set of items from a gloda collection, process them and update
+ * the display accordingly.
+ *
+ * @param aItems Contents of a gloda collection.
+ */
+ _processItems(aItems) {
+ let knownMessageNodes = new Map();
+
+ for (let glodaMsg of aItems) {
+ // Unread and starred will get set if any of the messages in a collapsed
+ // thread qualify. The trick here is that we may get multiple items
+ // corresponding to the same thread (and hence DOM node), so we need to
+ // detect when we get the first item for a particular DOM node, stash the
+ // preexisting status of that DOM node, an only do transitions if the
+ // items warrant it.
+ let key = glodaMsg.messageKey + glodaMsg.folder.uri;
+ let headerNode = this._msgNodes[key];
+ if (!headerNode) {
+ continue;
+ }
+ if (!knownMessageNodes.has(headerNode)) {
+ knownMessageNodes.set(headerNode, {
+ read: true,
+ starred: false,
+ tags: new Set(),
+ });
+ }
+
+ let flags = knownMessageNodes.get(headerNode);
+
+ // Count as read if *all* the messages are read.
+ flags.read &= glodaMsg.read;
+ // Count as starred if *any* of the messages are starred.
+ flags.starred |= glodaMsg.starred;
+ // Count as tagged with a tag if *any* of the messages have that tag.
+ for (let tag of this._getTagsForMsg(glodaMsg.folderMessage)) {
+ flags.tags.add(tag);
+ }
+ }
+
+ for (let [headerNode, flags] of knownMessageNodes) {
+ headerNode.classList.toggle("unread", !flags.read);
+ headerNode.classList.toggle("starred", flags.starred);
+
+ // Clear out all the tags and start fresh, just to make sure we don't get
+ // out of sync.
+ let tagsNode = headerNode.querySelector(".tags");
+ while (tagsNode.hasChildNodes()) {
+ tagsNode.lastChild.remove();
+ }
+ this._addTagNodes(flags.tags, tagsNode);
+ }
+ },
+
+ onQueryCompleted(aCollection) {
+ // If we need something that's just available from GlodaMessages, this is
+ // where we'll get it initially.
+ if (this._listener) {
+ this._listener.onLoadCompleted();
+ }
+ },
+};
+
+/**
+ * A summarizer to use for a single thread.
+ */
+function ThreadSummarizer() {}
+
+ThreadSummarizer.prototype = {
+ /**
+ * The maximum number of messages to summarize.
+ */
+ kMaxSummarizedMessages: 100,
+
+ /**
+ * The length of message snippets to fetch from Gloda.
+ */
+ kSnippetLength: 300,
+
+ /**
+ * Returns a canonical name for this summarizer.
+ */
+ get name() {
+ return "thread";
+ },
+
+ /**
+ * A function to be called once the summarizer has been registered with the
+ * main summary object.
+ *
+ * @param aContext The MultiMessageSummary object holding this summarizer.
+ */
+ onregistered(aContext) {
+ this.context = aContext;
+ },
+
+ /**
+ * Summarize a list of messages.
+ *
+ * @param aMessages A LimitIterator of the messages to summarize.
+ * @returns An array of the messages actually summarized.
+ */
+ summarize(aMessages, aDBView) {
+ let messageList = document.getElementById("message_list");
+
+ // Remove all ignored messages from summarization.
+ let summarizedMessages = [];
+ for (let message of aMessages) {
+ if (!message.isKilled) {
+ summarizedMessages.push(message);
+ }
+ }
+ let ignoredCount = aMessages.trueLength - summarizedMessages.length;
+
+ // Summarize the selected messages.
+ let subject = null;
+ let maxCountExceeded = false;
+ for (let [i, msgHdr] of summarizedMessages.entries()) {
+ if (i == this.kMaxSummarizedMessages) {
+ summarizedMessages.length = i;
+ maxCountExceeded = true;
+ break;
+ }
+
+ if (subject == null) {
+ subject = msgHdr.mime2DecodedSubject;
+ }
+
+ let msgNode = this.context.makeSummaryItem(msgHdr, {
+ snippetLength: this.kSnippetLength,
+ });
+ messageList.appendChild(msgNode);
+
+ this.context.mapMsgToNode(msgHdr, msgNode);
+ }
+
+ // Set the heading based on the subject and number of messages.
+ let countInfo = formatString(
+ "numMessages",
+ [aMessages.length.toLocaleString()],
+ aMessages.length
+ );
+ if (ignoredCount != 0) {
+ let format = aMessages.limited ? "atLeastNumIgnored" : "numIgnored";
+ countInfo += formatString(
+ format,
+ [ignoredCount.toLocaleString()],
+ ignoredCount
+ );
+ }
+
+ this.context.setHeading(subject || formatString("noSubject"), countInfo);
+
+ if (maxCountExceeded) {
+ this.context.showNotice(
+ formatString("maxCountExceeded", [
+ aMessages.trueLength.toLocaleString(),
+ this.kMaxSummarizedMessages.toLocaleString(),
+ ])
+ );
+ }
+ return summarizedMessages;
+ },
+};
+
+/**
+ * A summarizer to use when multiple threads are selected.
+ */
+function MultipleSelectionSummarizer() {}
+
+MultipleSelectionSummarizer.prototype = {
+ /**
+ * The maximum number of threads to summarize.
+ */
+ kMaxSummarizedThreads: 100,
+
+ /**
+ * The length of message snippets to fetch from Gloda.
+ */
+ kSnippetLength: 300,
+
+ /**
+ * Returns a canonical name for this summarizer.
+ */
+ get name() {
+ return "multipleselection";
+ },
+
+ /**
+ * A function to be called once the summarizer has been registered with the
+ * main summary object.
+ *
+ * @param aContext The MultiMessageSummary object holding this summarizer.
+ */
+ onregistered(aContext) {
+ this.context = aContext;
+ },
+
+ /**
+ * Summarize a list of messages.
+ *
+ * @param aMessages The messages to summarize.
+ */
+ summarize(aMessages, aDBView) {
+ let messageList = document.getElementById("message_list");
+
+ let threads = this._buildThreads(aMessages, aDBView);
+ let threadsCount = threads.length;
+
+ // Set the heading based on the number of messages & threads.
+ let format = aMessages.limited
+ ? "atLeastNumConversations"
+ : "numConversations";
+ this.context.setHeading(
+ formatString(format, [threads.length.toLocaleString()], threads.length)
+ );
+
+ // Summarize the selected messages by thread.
+ let maxCountExceeded = false;
+ for (let [i, msgs] of threads.entries()) {
+ if (i == this.kMaxSummarizedThreads) {
+ threads.length = i;
+ maxCountExceeded = true;
+ break;
+ }
+
+ let msgNode = this.context.makeSummaryItem(msgs, {
+ showSubject: true,
+ snippetLength: this.kSnippetLength,
+ });
+ messageList.appendChild(msgNode);
+
+ for (let msgHdr of msgs) {
+ this.context.mapMsgToNode(msgHdr, msgNode);
+ }
+ }
+
+ if (maxCountExceeded) {
+ this.context.showNotice(
+ formatString("maxThreadCountExceeded", [
+ threadsCount.toLocaleString(),
+ this.kMaxSummarizedThreads.toLocaleString(),
+ ])
+ );
+
+ // Return only the messages for the threads we're actually showing. We
+ // need to collapse our array-of-arrays into a flat array.
+ return threads.reduce(function (accum, curr) {
+ accum.push(...curr);
+ return accum;
+ }, []);
+ }
+
+ // Return everything, since we're showing all the threads. Don't forget to
+ // turn it into an array, though!
+ return [...aMessages];
+ },
+
+ /**
+ * Group all the messages to be summarized into threads.
+ *
+ * @param aMessages The messages to group.
+ * @returns An array of arrays of messages, grouped by thread.
+ */
+ _buildThreads(aMessages, aDBView) {
+ // First, we group the messages in threads and count the threads.
+ let threads = [];
+ let threadMap = {};
+ for (let msgHdr of aMessages) {
+ let viewThreadId = aDBView.getThreadContainingMsgHdr(msgHdr).threadKey;
+ if (!(viewThreadId in threadMap)) {
+ threadMap[viewThreadId] = threads.length;
+ threads.push([msgHdr]);
+ } else {
+ threads[threadMap[viewThreadId]].push(msgHdr);
+ }
+ }
+ return threads;
+ },
+};
+
+var gMessageSummary = new MultiMessageSummary();
+
+gMessageSummary.registerSummarizer(new ThreadSummarizer());
+gMessageSummary.registerSummarizer(new MultipleSelectionSummarizer());
+
+/**
+ * Roving tab navigation for the header buttons.
+ */
+const headerToolbarNavigation = {
+ /**
+ * If the roving tab has already been loaded.
+ *
+ * @type {boolean}
+ */
+ isLoaded: false,
+ /**
+ * Get all currently visible buttons of the message header toolbar.
+ *
+ * @returns {Array} An array of buttons.
+ */
+ get headerButtons() {
+ return this.headerToolbar.querySelectorAll(
+ `toolbarbutton:not([hidden="true"])`
+ );
+ },
+
+ init() {
+ // Bail out if we already initialized this.
+ if (this.isLoaded) {
+ return;
+ }
+ this.headerToolbar = document.getElementById("header-view-toolbar");
+ this.headerToolbar.addEventListener("keypress", event => {
+ this.triggerMessageHeaderRovingTab(event);
+ });
+ this.updateRovingTab();
+ this.isLoaded = true;
+ },
+
+ /**
+ * Update the `tabindex` attribute of the currently visible buttons.
+ */
+ updateRovingTab() {
+ for (const button of this.headerButtons) {
+ button.tabIndex = -1;
+ }
+ // Allow focus on the first available button.
+ // We use `setAttribute` to guarantee compatibility with XUL toolbarbuttons.
+ this.headerButtons[0].setAttribute("tabindex", "0");
+ },
+
+ /**
+ * Handles the keypress event on the message header toolbar.
+ *
+ * @param {Event} event - The keypress DOMEvent.
+ */
+ triggerMessageHeaderRovingTab(event) {
+ // Expected keyboard actions are Left, Right, Home, End, Space, and Enter.
+ if (!["ArrowRight", "ArrowLeft", " ", "Enter"].includes(event.key)) {
+ return;
+ }
+
+ const headerButtons = [...this.headerButtons];
+ const focusableButton = headerButtons.find(b => b.tabIndex != -1);
+ let elementIndex = headerButtons.indexOf(focusableButton);
+
+ // TODO: Remove once the buttons are updated to not be XUL
+ // NOTE: Normally a button click handler would cover Enter and Space key
+ // events. However, we need to prevent the default behavior and explicitly
+ // trigger the button click because the XUL toolbarbuttons do not work when
+ // the Enter key is pressed. They do work when the Space key is pressed.
+ // However, if the toolbarbutton is a dropdown menu, the Space key
+ // does not open the menu.
+ if (
+ event.key == "Enter" ||
+ (event.key == " " && event.target.hasAttribute("type"))
+ ) {
+ event.preventDefault();
+ event.target.click();
+ return;
+ }
+
+ // Find the adjacent focusable element based on the pressed key.
+ const isRTL = document.dir == "rtl";
+ if (
+ (isRTL && event.key == "ArrowLeft") ||
+ (!isRTL && event.key == "ArrowRight")
+ ) {
+ elementIndex++;
+ if (elementIndex > headerButtons.length - 1) {
+ elementIndex = 0;
+ }
+ } else if (
+ (!isRTL && event.key == "ArrowLeft") ||
+ (isRTL && event.key == "ArrowRight")
+ ) {
+ elementIndex--;
+ if (elementIndex == -1) {
+ elementIndex = headerButtons.length - 1;
+ }
+ }
+
+ // Move the focus to a new toolbar button and update the tabindex attribute.
+ const newFocusableButton = headerButtons[elementIndex];
+ if (newFocusableButton) {
+ focusableButton.tabIndex = -1;
+ newFocusableButton.setAttribute("tabindex", "0");
+ newFocusableButton.focus();
+ }
+ },
+};
diff --git a/comm/mail/base/content/multimessageview.xhtml b/comm/mail/base/content/multimessageview.xhtml
new file mode 100644
index 0000000000..17241ce537
--- /dev/null
+++ b/comm/mail/base/content/multimessageview.xhtml
@@ -0,0 +1,79 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?>
+
+<!-- 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/. -->
+
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd" [ <!ENTITY % brandDTD SYSTEM "chrome://branding/locale/brand.dtd">
+%brandDTD;
+<!ENTITY % startDTD SYSTEM "chrome://messenger/locale/multimessageview.dtd">
+%startDTD; ]>
+
+<html
+ xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+>
+ <head>
+ <link
+ rel="stylesheet"
+ media="screen"
+ type="text/css"
+ href="chrome://messenger/skin/messenger.css"
+ />
+ <link
+ rel="stylesheet"
+ media="screen"
+ type="text/css"
+ href="chrome://messenger/skin/primaryToolbar.css"
+ />
+ <link
+ rel="stylesheet"
+ media="screen"
+ type="text/css"
+ href="chrome://messenger/skin/messageHeader.css"
+ />
+ <link
+ rel="stylesheet"
+ media="screen, print"
+ type="text/css"
+ href="chrome://messenger/skin/multimessageview.css"
+ />
+ <link rel="localization" href="messenger/multimessageview.ftl" />
+ <title data-l10n-id="multi-message-window-title"></title>
+ <script src="chrome://messenger/content/multimessageview.js" />
+ </head>
+ <body>
+ <div id="headingWrapper">
+ <vbox
+ id="header-view-toolbox"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ >
+ <hbox id="header-view-toolbar" class="header-buttons-container">
+ <toolbarbutton
+ id="hdrArchiveButton"
+ class="toolbarbutton-1 message-header-view-button hdrArchiveButton"
+ data-l10n-id="multi-message-archive-button"
+ />
+ <toolbarbutton
+ id="hdrTrashButton"
+ class="toolbarbutton-1 message-header-view-button hdrTrashButton"
+ data-l10n-id="multi-message-delete-button"
+ />
+ </hbox>
+ </vbox>
+ <h1 id="heading">
+ <span id="summary_title" data-l10n-id="selected-messages-label"></span
+ >&#x200B;
+ <span id="summary_subtitle" />
+ </h1>
+ </div>
+ <div id="content">
+ <ul id="message_list" />
+ <div id="footer">
+ <span class="info" id="size" /> <span class="info" id="notice" />
+ </div>
+ </div>
+ </body>
+</html>
diff --git a/comm/mail/base/content/newTagDialog.js b/comm/mail/base/content/newTagDialog.js
new file mode 100644
index 0000000000..01af8892cc
--- /dev/null
+++ b/comm/mail/base/content/newTagDialog.js
@@ -0,0 +1,108 @@
+/* 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/. */
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+var dialog;
+
+/**
+ * Pass in keyToEdit as a window argument to turn this dialog into an edit
+ * tag dialog.
+ */
+function onLoad() {
+ let windowArgs = window.arguments[0];
+
+ dialog = {};
+
+ dialog.OKButton = document.querySelector("dialog").getButton("accept");
+ dialog.nameField = document.getElementById("name");
+ dialog.nameField.focus();
+
+ // call this when OK is pressed
+ dialog.okCallback = windowArgs.okCallback;
+ if (windowArgs.keyToEdit) {
+ initializeForEditing(windowArgs.keyToEdit);
+ document.addEventListener("dialogaccept", onOKEditTag);
+ } else {
+ document.addEventListener("dialogaccept", onOKNewTag);
+ }
+
+ doEnabling();
+}
+
+/**
+ * Turn the new tag dialog into an edit existing tag dialog
+ */
+function initializeForEditing(aTagKey) {
+ dialog.editTagKey = aTagKey;
+
+ // Change the title of the dialog
+ var messengerBundle = document.getElementById("bundle_messenger");
+ document.title = messengerBundle.getString("editTagTitle");
+
+ // extract the color and name for the current tag
+ document.getElementById("tagColorPicker").value =
+ MailServices.tags.getColorForKey(aTagKey);
+ dialog.nameField.value = MailServices.tags.getTagForKey(aTagKey);
+}
+
+/**
+ * on OK handler for editing a new tag.
+ */
+function onOKEditTag(event) {
+ // get the tag name of the current key we are editing
+ let existingTagName = MailServices.tags.getTagForKey(dialog.editTagKey);
+
+ // it's ok if the name didn't change
+ if (existingTagName != dialog.nameField.value) {
+ // don't let the user edit a tag to the name of another existing tag
+ if (MailServices.tags.getKeyForTag(dialog.nameField.value)) {
+ alertForExistingTag();
+ event.preventDefault();
+ return;
+ }
+
+ MailServices.tags.setTagForKey(dialog.editTagKey, dialog.nameField.value);
+ }
+
+ MailServices.tags.setColorForKey(
+ dialog.editTagKey,
+ document.getElementById("tagColorPicker").value
+ );
+}
+
+/**
+ * on OK handler for creating a new tag. Alerts the user if a tag with
+ * the name already exists.
+ */
+function onOKNewTag(event) {
+ var name = dialog.nameField.value;
+
+ if (MailServices.tags.getKeyForTag(name)) {
+ alertForExistingTag();
+ event.preventDefault();
+ return;
+ }
+ if (
+ !dialog.okCallback(name, document.getElementById("tagColorPicker").value)
+ ) {
+ event.preventDefault();
+ }
+}
+
+/**
+ * Alerts the user that they are trying to create a tag with a name that
+ * already exists.
+ */
+function alertForExistingTag() {
+ var messengerBundle = document.getElementById("bundle_messenger");
+ var alertText = messengerBundle.getString("tagExists");
+ Services.prompt.alert(window, document.title, alertText);
+}
+
+function doEnabling() {
+ dialog.OKButton.disabled = !dialog.nameField.value;
+}
diff --git a/comm/mail/base/content/newTagDialog.xhtml b/comm/mail/base/content/newTagDialog.xhtml
new file mode 100644
index 0000000000..38eccafec7
--- /dev/null
+++ b/comm/mail/base/content/newTagDialog.xhtml
@@ -0,0 +1,30 @@
+<?xml version="1.0"?>
+
+<!-- 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/. -->
+
+<?xml-stylesheet href="chrome://messenger/skin/messenger.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?>
+
+<!DOCTYPE window>
+
+<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ data-l10n-id="tag-dialog-window"
+ lightweightthemes="true"
+ style="min-width: 25em;"
+ onload="onLoad();">
+<dialog>
+
+ <stringbundle id="bundle_messenger" src="chrome://messenger/locale/messenger.properties"/>
+
+ <script src="chrome://messenger/content/globalOverlay.js"/>
+ <script src="chrome://global/content/editMenuOverlay.js"/>
+ <script src="chrome://messenger/content/newTagDialog.js"/>
+ <script src="chrome://messenger/content/dialogShadowDom.js"/>
+#include tagDialog.inc.xhtml
+</dialog>
+</window>
diff --git a/comm/mail/base/content/overrides/app-license-body.html b/comm/mail/base/content/overrides/app-license-body.html
new file mode 100644
index 0000000000..4c4669bc32
--- /dev/null
+++ b/comm/mail/base/content/overrides/app-license-body.html
@@ -0,0 +1,1274 @@
+<!-- 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/. -->
+
+<hr />
+
+<h1><a id="tb-apache"></a>Apache License 2.0</h1>
+
+<p>This license applies to the following files:</p>
+<ul>
+ <li><code>chat/protocols/matrix/lib/@matrix-org/olm</code></li>
+ <li><code>chat/protocols/matrix/lib/another-json</code></li>
+ <li><code>chat/protocols/matrix/lib/matrix-events-sdk</code></li>
+ <li><code>chat/protocols/matrix/lib/matrix-sdk</code></li>
+ <li><code>chat/protocols/matrix/lib/matrix-widget-api</code></li>
+</ul>
+
+<pre>
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+</pre>
+
+<h1><a id="tb-bsd3clause"></a>BSD-3-Clause License</h1>
+
+<p>This license applies to the following files:</p>
+<ul>
+ <li><code>third_party/libgcrypt/cipher/sha256-avx-amd64.S</code></li>
+ <li><code>third_party/libgcrypt/cipher/sha256-avx2-bmi2-amd64.S</code></li>
+ <li><code>third_party/libgcrypt/cipher/sha256-ssse3-amd64.S</code></li>
+ <li><code>third_party/libgcrypt/cipher/sha512-avx-amd64.S</code></li>
+ <li><code>third_party/libgcrypt/cipher/sha512-avx2-bmi2-amd64.S</code></li>
+ <li><code>third_party/libgcrypt/cipher/sha512-ssse3-amd64.S</code></li>
+</ul>
+
+See the individual LICENSE files for copyright owners.
+
+<pre>
+ All rights reserved.
+
+ Redistribution and use in source and binary forms, with or without
+ modification, are permitted provided that the following conditions are
+ met:
+
+ * Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+
+ * Redistributions in binary form must reproduce the above copyright
+ notice, this list of conditions and the following disclaimer in the
+ documentation and/or other materials provided with the
+ distribution.
+
+ * Neither the name of the Intel Corporation nor the names of its
+ contributors may be used to endorse or promote products derived from
+ this software without specific prior written permission.
+
+
+ THIS SOFTWARE IS PROVIDED BY INTEL CORPORATION "AS IS" AND ANY
+ EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+ PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL INTEL CORPORATION OR
+ CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+ EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
+ PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+ PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+ LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+ NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+</pre>
+
+<p>This license applies to the following files:</p>
+<ul>
+ <li><code>third_party/libgcrypt/random/jitterentropy-base.c</code></li>
+ <li><code>third_party/libgcrypt/random/jitterentropy.h</code></li>
+ <li>
+ <code
+ >third_party/libgcrypt/random/rndjent.c (plus common Libgcrypt copyright
+ holders)</code
+ >
+ </li>
+</ul>
+
+<pre>
+ * Copyright Stephan Mueller &lt;smueller@chronox.de&gt;, 2013
+ *
+ * License
+ * =======
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions
+ * are met:
+ * 1. Redistributions of source code must retain the above copyright
+ * notice, and the entire permission notice in its entirety,
+ * including the disclaimer of warranties.
+ * 2. Redistributions in binary form must reproduce the above copyright
+ * notice, this list of conditions and the following disclaimer in the
+ * documentation and/or other materials provided with the distribution.
+ * 3. The name of the author may not be used to endorse or promote
+ * products derived from this software without specific prior
+ * written permission.
+ *
+ * ALTERNATIVELY, this product may be distributed under the terms of
+ * the GNU General Public License, in which case the provisions of the GPL are
+ * required INSTEAD OF the above restrictions. (This clause is
+ * necessary due to a potential bad interaction between the GPL and
+ * the restrictions contained in a BSD-style copyright.)
+ *
+ * THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED
+ * WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
+ * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE, ALL OF
+ * WHICH ARE HEREBY DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT
+ * OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
+ * BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+ * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
+ * USE OF THIS SOFTWARE, EVEN IF NOT ADVISED OF THE POSSIBILITY OF SUCH
+ * DAMAGE.
+</pre>
+
+<h1><a id="tb-xlicense"></a>X License</h1>
+
+<p>This license applies to the following files:</p>
+<ul>
+ <li><code>third_party/libgcrypt/install.sh</code></li>
+</ul>
+
+<pre>
+ Copyright (C) 1994 X Consortium
+
+ Permission is hereby granted, free of charge, to any person obtaining a copy
+ of this software and associated documentation files (the "Software"), to
+ deal in the Software without restriction, including without limitation the
+ rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+ sell copies of the Software, and to permit persons to whom the Software is
+ furnished to do so, subject to the following conditions:
+
+ The above copyright notice and this permission notice shall be included in
+ all copies or substantial portions of the Software.
+
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ X CONSORTIUM BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNEC-
+ TION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+ Except as contained in this notice, the name of the X Consortium shall not
+ be used in advertising or otherwise to promote the sale, use or other deal-
+ ings in this Software without prior written authorization from the X Consor-
+ tium.
+</pre>
+
+<h1><a id="tb-publicdomain"></a>Public domain</h1>
+
+<p>This license applies to the following files:</p>
+<ul>
+ <li><code>third_party/libgcrypt/cipher/arcfour-amd64.S</code></li>
+</ul>
+
+<pre>
+ Author: Marc Bevand &lt;bevand_m (at) epita.fr&gt;
+ Licence: I hereby disclaim the copyright on this code and place it
+ in the public domain.
+</pre>
+
+<h1><a id="tb-ocblicense1"></a>OCB license 1</h1>
+
+<p>This license applies to the following files:</p>
+<ul>
+ <li><code>third_party/libgcrypt/cipher/cipher-ocb.c</code></li>
+</ul>
+
+<pre>
+ OCB is covered by several patents but may be used freely by most
+ software. See http://web.cs.ucdavis.edu/~rogaway/ocb/license.htm .
+ In particular license 1 is suitable for Libgcrypt: See
+ http://web.cs.ucdavis.edu/~rogaway/ocb/license1.pdf for the full
+ license document; it basically says:
+
+ License 1 — License for Open-Source Software Implementations of OCB
+ (Jan 9, 2013)
+
+ Under this license, you are authorized to make, use, and
+ distribute open-source software implementations of OCB. This
+ license terminates for you if you sue someone over their
+ open-source software implementation of OCB claiming that you have
+ a patent covering their implementation.
+
+
+
+ License for Open Source Software Implementations of OCB
+ January 9, 2013
+
+ 1 Definitions
+
+ 1.1 “Licensor” means Phillip Rogaway.
+
+ 1.2 “Licensed Patents” means any patent that claims priority to United
+ States Patent Application No. 09/918,615 entitled “Method and Apparatus
+ for Facilitating Efficient Authenticated Encryption,” and any utility,
+ divisional, provisional, continuation, continuations-in-part, reexamination,
+ reissue, or foreign counterpart patents that may issue with respect to the
+ aforesaid patent application. This includes, but is not limited to, United
+ States Patent No. 7,046,802; United States Patent No. 7,200,227; United
+ States Patent No. 7,949,129; United States Patent No. 8,321,675 ; and any
+ patent that issues out of United States Patent Application No. 13/669,114.
+
+ 1.3 “Use” means any practice of any invention claimed in the Licensed Patents.
+
+ 1.4 “Software Implementation” means any practice of any invention
+ claimed in the Licensed Patents that takes the form of software executing on
+ a user-programmable, general-purpose computer or that takes the form of a
+ computer-readable medium storing such software. Software Implementation does
+ not include, for example, application-specific integrated circuits (ASICs),
+ field-programmable gate arrays (FPGAs), embedded systems, or IP cores.
+
+ 1.5 “Open Source Software” means software whose source code is published
+ and made available for inspection and use by anyone because either (a) the
+ source code is subject to a license that permits recipients to copy, modify,
+ and distribute the source code without payment of fees or royalties, or
+ (b) the source code is in the public domain, including code released for
+ public use through a CC0 waiver. All licenses certified by the Open Source
+ Initiative at opensource.org as of January 9, 2013 and all Creative Commons
+ licenses identified on the creativecommons.org website as of January 9,
+ 2013, including the Public License Fallback of the CC0 waiver, satisfy these
+ requirements for the purposes of this license.
+
+ 1.6 “Open Source Software Implementation” means a Software
+ Implementation in which the software implicating the Licensed Patents is
+ Open Source Software. Open Source Software Implementation does not include
+ any Software Implementation in which the software implicating the Licensed
+ Patents is combined, so as to form a larger program, with software that is
+ not Open Source Software.
+
+ 2 License Grant
+
+ 2.1 License. Subject to your compliance with the term s of this license,
+ including the restriction set forth in Section 2.2, Licensor hereby
+ grants to you a perpetual, worldwide, non-exclusive, non-transferable,
+ non-sublicenseable, no-charge, royalty-free, irrevocable license to practice
+ any invention claimed in the Licensed Patents in any Open Source Software
+ Implementation.
+
+ 2.2 Restriction. If you or your affiliates institute patent litigation
+ (including, but not limited to, a cross-claim or counterclaim in a lawsuit)
+ against any entity alleging that any Use authorized by this license
+ infringes another patent, then any rights granted to you under this license
+ automatically terminate as of the date such litigation is filed.
+
+ 3 Disclaimer
+ YOUR USE OF THE LICENSED PATENTS IS AT YOUR OWN RISK AND UNLESS REQUIRED
+ BY APPLICABLE LAW, LICENSOR MAKES NO REPRESENTATIONS OR WARRANTIES OF ANY
+ KIND CONCERNING THE LICENSED PATENTS OR ANY PRODUCT EMBODYING ANY LICENSED
+ PATENT, EXPRESS OR IMPLIED, STATUT ORY OR OTHERWISE, INCLUDING, WITHOUT
+ LIMITATION, WARRANTIES OF TITLE, MERCHANTIBILITY, FITNESS FOR A PARTICULAR
+ PURPOSE, OR NONINFRINGEMENT. IN NO EVENT WILL LICENSOR BE LIABLE FOR ANY
+ CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT OR OTHERWISE,
+ ARISING FROM OR RELATED TO ANY USE OF THE LICENSED PATENTS, INCLUDING,
+ WITHOUT LIMITATION, DIRECT, INDIRECT, INCIDENTAL, CONSEQUENTIAL, PUNITIVE
+ OR SPECIAL DAMAGES, EVEN IF LICENSOR HAS BEEN ADVISED OF THE POSSIBILITY OF
+ SUCH DAMAGES PRIOR TO SUCH AN OCCURRENCE.
+</pre>
+
+<h1><a id="tb-bzip2license"></a>Bzip2 License</h1>
+
+<p>This license applies to files in <code>third_party/bzip2</code>.</p>
+
+<pre>
+This program, "bzip2", the associated library "libbzip2", and all
+documentation, are copyright (C) 1996-2019 Julian R Seward. All
+rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions
+are met:
+
+1. Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+
+2. The origin of this software must not be misrepresented; you must
+ not claim that you wrote the original software. If you use this
+ software in a product, an acknowledgment in the product
+ documentation would be appreciated but is not required.
+
+3. Altered source versions must be plainly marked as such, and must
+ not be misrepresented as being the original software.
+
+4. The name of the author may not be used to endorse or promote
+ products derived from this software without specific prior written
+ permission.
+
+THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS
+OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
+DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE
+GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
+WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+Julian Seward, jseward@acm.org
+bzip2/libbzip2 version 1.0.8 of 13 July 2019
+ </pre
+>
+
+<h1><a id="tb-jsonclicense"></a>Json-C License</h1>
+
+<p>This license applies to files in <code>third_party/json-c</code>.</p>
+
+<pre>
+
+Copyright (c) 2009-2012 Eric Haszlakiewicz
+
+Permission is hereby granted, free of charge, to any person obtaining a
+copy of this software and associated documentation files (the "Software"),
+to deal in the Software without restriction, including without limitation
+the rights to use, copy, modify, merge, publish, distribute, sublicense,
+and/or sell copies of the Software, and to permit persons to whom the
+Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included
+in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+----------------------------------------------------------------
+
+Copyright (c) 2004, 2005 Metaparadigm Pte Ltd
+
+Permission is hereby granted, free of charge, to any person obtaining a
+copy of this software and associated documentation files (the "Software"),
+to deal in the Software without restriction, including without limitation
+the rights to use, copy, modify, merge, publish, distribute, sublicense,
+and/or sell copies of the Software, and to permit persons to whom the
+Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included
+in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+ </pre
+>
+
+<h1><a id="tb-botanlicense"></a>Botan License</h1>
+
+<p>This license applies to files in <code>third_party/botan</code>.</p>
+
+<pre>
+Copyright (C) 1999-2020 The Botan Authors
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice,
+ this list of conditions, and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright
+ notice, this list of conditions, and the following disclaimer in the
+ documentation and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGE.
+ </pre
+>
+
+<h1><a id="tb-rnplicense"></a>RNP Licenses</h1>
+
+<p>These licenses apply to files in <code>third_party/rnp</code>.</p>
+
+<h2>Ribose's BSD 2-Clause License</h2>
+
+<pre>
+Copyright (c) 2017, <a href="https://www.ribose.com">Ribose Inc</a>.
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without modification,
+are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice,
+ this list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
+THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ </pre>
+
+<h2>NetBSD's BSD 2-Clause License</h2>
+<p>This license applies to the following files:</p>
+<ul>
+ <li><code>third_party/rnpinclude/rekey/rnp_key_store.h</code></li>
+ <li><code>third_party/rnpinclude/repgp/repgp_def.h</code></li>
+ <li><code>third_party/rnpinclude/rnp.h</code></li>
+ <li><code>third_party/rnpinclude/rnp/rnp_sdk.h</code></li>
+ <li><code>third_party/rnpsrc/librekey/key_store_pgp.h</code></li>
+ <li><code>third_party/rnpsrc/librekey/key_store_pgp.cpp</code></li>
+ <li><code>third_party/rnpsrc/librekey/rnp_key_store.cpp</code></li>
+ <li><code>third_party/rnpsrc/rnpkeys/main.cpp</code></li>
+ <li><code>third_party/rnpsrc/rnpkeys/rnpkeys.cpp</code></li>
+ <li><code>third_party/rnpsrc/lib/crypto.cpp</code></li>
+ <li><code>third_party/rnpsrc/lib/pgp-key.cpp</code></li>
+ <li><code>third_party/rnpsrc/lib/crypto.h</code></li>
+ <li><code>third_party/rnpsrc/lib/types.h</code></li>
+ <li><code>third_party/rnpsrc/lib/misc.cpp</code></li>
+ <li><code>third_party/rnpsrc/lib/pgp-key.h</code></li>
+ <li><code>third_party/rnpsrc/lib/fingerprint.cpp</code></li>
+ <li><code>third_party/rnpsrc/lib/crypto/ec.h</code></li>
+ <li><code>third_party/rnpsrc/lib/crypto/s2k.cpp</code></li>
+ <li><code>third_party/rnpsrc/lib/crypto/hash.cpp</code></li>
+ <li><code>third_party/rnpsrc/lib/crypto/symmetric.h</code></li>
+ <li><code>third_party/rnpsrc/lib/crypto/s2k.h</code></li>
+ <li><code>third_party/rnpsrc/lib/crypto/bn.h</code></li>
+ <li><code>third_party/rnpsrc/lib/crypto/rng.h</code></li>
+ <li><code>third_party/rnpsrc/lib/crypto/rng.cpp</code></li>
+ <li><code>third_party/rnpsrc/lib/crypto/dsa.cpp</code></li>
+ <li><code>third_party/rnpsrc/lib/crypto/symmetric.cpp</code></li>
+ <li><code>third_party/rnpsrc/lib/crypto/elgamal.h</code></li>
+ <li><code>third_party/rnpsrc/lib/crypto/bn.cpp</code></li>
+ <li><code>third_party/rnpsrc/lib/crypto/hash.h</code></li>
+ <li><code>third_party/rnpsrc/lib/crypto/dsa.h</code></li>
+ <li><code>third_party/rnpsrc/lib/crypto/rsa.h</code></li>
+ <li><code>third_party/rnpsrc/lib/crypto/elgamal.cpp</code></li>
+ <li><code>third_party/rnpsrc/lib/crypto/eddsa.h</code></li>
+ <li><code>third_party/rnpsrc/lib/crypto/rsa.cpp</code></li>
+ <li><code>third_party/rnpsrc/rnp/rnp.cpp</code></li>
+</ul>
+
+<pre>
+This software contains source code originating from NetPGP, which
+carries the following copyright notice and license.
+
+Copyright (c) 2009-2016, <a href="https://www.netbsd.org">The NetBSD Foundation, Inc</a>.
+All rights reserved.
+
+This code is derived from software contributed to The NetBSD Foundation
+by Alistair Crooks &lt;agc at NetBSD.org&gt;
+
+Redistribution and use in source and binary forms, with or without modification,
+are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice,
+ this list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
+THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+</pre>
+
+<h2>Nominet UK's Apache 2.0 Licence</h2>
+<p>This license applies to the following files:</p>
+<ul>
+ <li><code>third_party/rnp/src/librekey/key_store_pgp.cpp</code></li>
+ <li><code>third_party/rnp/src/librekey/key_store_pgp.h</code></li>
+ <li><code>third_party/rnp/src/lib/crypto.cpp</code></li>
+ <li><code>third_party/rnp/src/lib/crypto.h</code></li>
+ <li><code>third_party/rnp/src/lib/misc.cpp</code></li>
+ <li><code>third_party/rnp/src/lib/pgp-key.cpp</code></li>
+ <li><code>third_party/rnp/src/lib/pgp-key.h</code></li>
+ <li><code>third_party/rnp/src/lib/types.h</code></li>
+ <li><code>third_party/rnp/src/lib/crypto/dsa.cpp</code></li>
+ <li><code>third_party/rnp/src/lib/crypto/elgamal.cpp</code></li>
+ <li><code>third_party/rnp/src/lib/crypto/hash.cpp</code></li>
+ <li><code>third_party/rnp/src/lib/crypto/rsa.cpp</code></li>
+ <li><code>third_party/rnp/src/lib/crypto/symmetric.cpp</code></li>
+ <li><code>third_party/rnp/src/lib/crypto/symmetric.h</code></li>
+</ul>
+
+<pre>
+This software contains source code originating from NetPGP, which
+carries the following copyright notice and license.
+
+Copyright (c) 2005-2008 <a href="http://www.nic.uk">Nominet UK</a>
+All rights reserved.
+
+Contributors: Ben Laurie, Rachel Willmer. The Contributors have asserted
+their moral rights under the UK Copyright Design and Patents Act 1988 to
+be recorded as the authors of this copyright work.
+
+Licensed under the Apache License, Version 2.0 (the "License"); you may not use
+this file except in compliance with the License. You may obtain a copy of the
+License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software distributed
+under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
+CONDITIONS OF ANY KIND, either express or implied. See the License for the
+specific language governing permissions and limitations under the License.
+ </pre>
+
+<h2>Nominet UK's BSD 3-Clause License</h2>
+
+<pre>
+This software contains source code originating from NetPGP, which
+carries the following copyright notice and license.
+
+Copyright (c) 2005 <a href="http://www.nic.uk">Nominet UK</a>
+All rights reserved.
+
+Contributors: Ben Laurie, Rachel Willmer. The Contributors have asserted
+their moral rights under the UK Copyright Design and Patents Act 1988 to
+be recorded as the authors of this copyright work.
+
+This is a BSD-style Open Source licence.
+
+Redistribution and use in source and binary forms, with or without modification,
+are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice,
+ this list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+
+3. The name of Nominet UK or the contributors may not be used to
+ endorse or promote products derived from this software without specific
+ prior written permission;
+
+and provided that the user accepts the terms of the following disclaimer:
+
+THIS SOFTWARE IS PROVIDED BY NOMINET UK AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ARE DISCLAIMED. IN NO EVENT SHALL NOMINET UK OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
+OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
+HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
+LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
+OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGE.
+ </pre>
+
+<h2>OCB Patent License for Ribose Inc.</h2>
+
+<pre>
+This license has been graciously granted by Professor Phillip Rogaway to allow
+users of "rnp" to utilize the patented OCB blockcipher mode of operation,
+which simultaneously provides privacy and authenticity.
+
+The license text is presented below in plain text form purely for referencial
+purposes. The original signed license is available on request from Ribose Inc.,
+reachable at open.source@ribose.com.
+
+1. Definitions
+
+1.1 "Licensor" means Phillip Rogaway, of 1212 Purdue Dr., Davis, California, USA.
+
+1.2 "Licensed Patents" means any patent that claims priority to United States
+Patent Application No. 09/918,615 entitled "Method and Apparatus for
+Facilitating Efficient Authenticated Encryption," and any utility, divisional,
+provisional, continuation, continuations in part, reexamination, reissue, or
+foreign counterpart patents that may issue with respect to the aforesaid patent
+application. This includes, but is not limited to, United States Patent No.
+7,046,802; United States Patent No. 7,200,227; United States Patent No.
+7,949,129; United States Patent No. 8,321,675; and any patent that issues out
+or United States Patent Application No. 13/669,114.
+
+1.3 "Licensee" means Ribose Inc., at Suite 1, 8/F, 10 Ice House Street,
+Central, Hong Kong, its affiliates, assignees, or successors in interest, or
+anyone using, making, copying, modifying, distributing, having made, importing,
+or having imported any program, software, or computer system including or based
+upon Open Source Software published by Ribose Inc., or their customers,
+suppliers, importers, manufacturers, distributors, or insurers.
+
+1.4 "Use in Licensee Products" means using, making, copying, modifying,
+distributing, having made, importing or having imported any program, software,
+or computer system published by Licensee, which contains or is based upon Open
+Source Software which may include any implementation of the Licensed Patents.
+
+1.5 "Open Source Software" means software whose source code is published and
+made available for inspection and use by anyone because either (a) the source
+code is subject to a license that permits recipients to copy, modify, and
+distribute the source code without payment of fees or royalties, or (b) the
+source code is in the public domain, including code released for public use
+through a CC0 waiver. All licenses certified by the Open Source Initiative at
+opensource.org as of January 1, 2017 and all Creative Commons licenses
+identified on the creativecommons.org website as of January 1, 2017, including
+the Public License Fallback of the CC0 waiver, satisfy these requirements for
+the purposes of this license.
+
+2. Grant of License
+
+2.1 Licensor hereby grants to Licensee a perpetual, worldwide, non-exclusive,
+nontransferable, non-sublicenseable, no-charge, royalty-free, irrevocable
+license to Use in Licensee Products any invention claimed in the Licensed
+Patents in any Open Source Software Implementation and in hardware as long as
+the Open Source Software incorporated in such hardware is freely licensed for
+hardware embodiment.
+
+3. Disclaimer
+
+3.1 LICENSEE'S USE OF THE LICENSED PATENTS IS AT LICENSEE'S OWN RISK AND UNLESS
+REQUIRED BY APPLICABLE LAW, LICENSOR MAKES NO REPRESENTATIONS OR WARRANTIES OF
+ANY KIND CONCERNING THE LICENSED PATENTS OR ANY PRODUCT EMBODYING ANY LICENSED
+PATENT, EXPRESS OR IMPLIED, STATUTORY OR OTHERWISE, INCLUDING, WITHOUT
+LIMITATION, WARRANTIES OF TITLE, MERCHANTIBILITY, FITNESS FOR A PARTICULAR
+PURPOSE, OR NONINFRINGEMENT. IN NO EVENT WILL LICENSOR BE LIABLE FOR ANY CLAIM,
+DAMAGES OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT OR OTHERWISE, ARISING
+FROM OR RELATED TO ANY USE OF THE LICENSED PATENTS, INCLUDING, WITHOUT
+LIMITATION, DIRECT, INDIRECT, INCIDENTAL, CONSEQUENTIAL, PUNITIVE OR SPECIAL
+DAMAGES, EVEN IF LICENSOR HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES
+PRIOR TO SUCH AN OCCURRENCE.
+
+[SIGNATURE by Phillip Rogaway]
+
+Date: August 28, 2017
+
+ </pre
+>
+
+<h1><a id="tb-direntlicense"></a>Dirent License</h1>
+
+<p>This license applies to <code>third_party/niwcompat/dirent.h</code>.</p>
+
+<pre>
+The MIT License (MIT)
+
+Copyright (c) 1998-2019 Toni Ronkko
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+ </pre
+>
+
+<h1><a id="tb-mapiheaders"></a>MAPI Headers License</h1>
+
+<p>This license applies to <code>mailnews/mapi/include/</code>.</p>
+
+<pre>
+MIT License
+
+Copyright (c) 2018 Microsoft
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software is furnished to do so,
+subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
+IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ </pre
+>
+
+<h1><a id="tb-getopt">getopt.c License</a></h1>
+<p>This license applies to the following files:</p>
+<ul>
+ <li><code>third_party/niwcompat/getopt.c</code></li>
+ <li><code>third_party/niwcompat/getopt.h</code></li>
+</ul>
+<pre>
+Copyright (c) 1987, 1993, 1994, 1996
+ The Regents of the University of California. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions
+are met:
+1. Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+2. Redistributions in binary form must reproduce the above copyright
+ notice, this list of conditions and the following disclaimer in the
+ documentation and/or other materials provided with the distribution.
+3. Neither the names of the copyright holders nor the names of its
+ contributors may be used to endorse or promote products derived from
+ this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS ``AS
+IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE
+LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGE.
+ </pre
+>
+
+<h1><a id="tb-asn1js">ASN1.js License</a></h1>
+<p>This license applies to the following files:</p>
+<ul>
+ <li><code>third_party/asn1js/</code></li>
+</ul>
+<pre>
+Copyright (c) 2014, GMO GlobalSign
+Copyright (c) 2015-2022, Peculiar Ventures
+All rights reserved.
+
+Author 2014-2019, Yury Strozhevsky
+
+Redistribution and use in source and binary forms, with or without modification,
+are permitted provided that the following conditions are met:
+
+* Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.
+
+* Redistributions in binary form must reproduce the above copyright notice, this
+ list of conditions and the following disclaimer in the documentation and/or
+ other materials provided with the distribution.
+
+* Neither the name of the copyright holder nor the names of its
+ contributors may be used to endorse or promote products derived from
+ this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
+ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ </pre
+>
+
+<h1><a id="tb-base-x">base-x License</a></h1>
+<p>This license applies to the following files:</p>
+<ul>
+ <li><code>chat/protocols/matrix/lib/base-x</code></li>
+</ul>
+<pre>
+The MIT License (MIT)
+
+Copyright (c) 2018 base-x contributors
+Copyright (c) 2014-2018 The Bitcoin Core developers
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+ </pre
+>
+
+<h1><a id="tb-bs58">bs58 License</a></h1>
+<p>This license applies to the following files:</p>
+<ul>
+ <li><code>chat/protocols/matrix/lib/bs58</code></li>
+</ul>
+<pre>
+MIT License
+
+Copyright (c) 2018 cryptocoinjs
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+ </pre
+>
+
+<h1><a id="tb-content-type">content-type License</a></h1>
+<p>This license applies to the following files:</p>
+<ul>
+ <li><code>chat/protocols/matrix/lib/content-type</code></li>
+</ul>
+<pre>
+(The MIT License)
+
+Copyright (c) 2015 Douglas Christopher Wilson
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+'Software'), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ </pre
+>
+
+<h1><a id="tb-events">events License</a></h1>
+<p>This license applies to the following files:</p>
+<ul>
+ <li><code>chat/protocols/matrix/lib/events</code></li>
+</ul>
+<pre>
+MIT
+
+Copyright Joyent, Inc. and other Node contributors.
+
+Permission is hereby granted, free of charge, to any person obtaining a
+copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to permit
+persons to whom the Software is furnished to do so, subject to the
+following conditions:
+
+The above copyright notice and this permission notice shall be included
+in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
+OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN
+NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
+DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
+USE OR OTHER DEALINGS IN THE SOFTWARE.
+ </pre
+>
+
+<h1><a id="tb-p-retry">p-retry License</a></h1>
+<p>This license applies to the following files:</p>
+<ul>
+ <li><code>chat/protocols/matrix/lib/p-retry</code></li>
+</ul>
+<pre>
+MIT License
+
+Copyright (c) Sindre Sorhus &lt;sindresorhus@gmail.com&gt; (sindresorhus.com)
+
+Permission is hereby granted, free of charge, to any person obtaining a
+copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to permit
+persons to whom the Software is furnished to do so, subject to the
+following conditions:
+
+The above copyright notice and this permission notice shall be included
+in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
+OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN
+NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
+DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
+USE OR OTHER DEALINGS IN THE SOFTWARE.
+ </pre
+>
+
+<h1><a id="tb-retry">retry License</a></h1>
+<p>This license applies to the following files:</p>
+<ul>
+ <li><code>chat/protocols/matrix/lib/retry</code></li>
+</ul>
+<pre>
+Copyright (c) 2011:
+Tim Koschützki (tim@debuggable.com)
+Felix Geisendörfer (felix@debuggable.com)
+
+ Permission is hereby granted, free of charge, to any person obtaining a copy
+ of this software and associated documentation files (the "Software"), to deal
+ in the Software without restriction, including without limitation the rights
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ copies of the Software, and to permit persons to whom the Software is
+ furnished to do so, subject to the following conditions:
+
+ The above copyright notice and this permission notice shall be included in
+ all copies or substantial portions of the Software.
+
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ THE SOFTWARE.
+ </pre
+>
+
+<h1><a id="tb-unhomoglyph">unhomoglyph License</a></h1>
+<p>This license applies to the following files:</p>
+<ul>
+ <li><code>chat/protocols/matrix/lib/unhomoglyph</code></li>
+</ul>
+<pre>
+Copyright (c) 2016 Vitaly Puzrin.
+
+Permission is hereby granted, free of charge, to any person
+obtaining a copy of this software and associated documentation
+files (the "Software"), to deal in the Software without
+restriction, including without limitation the rights to use,
+copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the
+Software is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+OTHER DEALINGS IN THE SOFTWARE.
+ </pre
+>
+
+<h1><a id="tb-sax">sax License</a></h1>
+<p>This license applies to the following files:</p>
+<ul>
+ <li><code>chat/protocols/xmpp/lib/sax</code></li>
+</ul>
+<pre>
+The ISC License
+
+Copyright (c) Isaac Z. Schlueter and Contributors
+
+Permission to use, copy, modify, and/or distribute this software for any
+purpose with or without fee is hereby granted, provided that the above
+copyright notice and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
+IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+====
+
+`String.fromCodePoint` by Mathias Bynens used according to terms of MIT
+License, as follows:
+
+ Copyright Mathias Bynens &lt;https://mathiasbynens.be/&gt;
+
+ Permission is hereby granted, free of charge, to any person obtaining
+ a copy of this software and associated documentation files (the
+ "Software"), to deal in the Software without restriction, including
+ without limitation the rights to use, copy, modify, merge, publish,
+ distribute, sublicense, and/or sell copies of the Software, and to
+ permit persons to whom the Software is furnished to do so, subject to
+ the following conditions:
+
+ The above copyright notice and this permission notice shall be
+ included in all copies or substantial portions of the Software.
+
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ </pre
+>
+
+<h1><a id="tb-lgpl">GNU Lesser General Public License 2.1</a></h1>
+<p>This product contains code from the following LGPLed libraries:</p>
+<ul>
+ <li><a href="https://www.gnupg.org/ftp/gcrypt/libgcrypt/">libgcrypt</a></li>
+ <li>
+ <a href="https://www.gnupg.org/ftp/gcrypt/libgpg-error/">gpg-error</a>
+ </li>
+ <li><a href="https://otr.cypherpunks.ca/">libotr</a></li>
+</ul>
+
+(These libraries only ship in some versions of this product.)
+<a href="#lgpl">Read the license above.</a>
+
+<h1><a id="tb-sdp-transform">sdp-transform License</a></h1>
+<p>This license applies to the following files:</p>
+<ul>
+ <li><code>chat/protocols/matrix/lib/sdp-transform</code></li>
+</ul>
+<pre>
+(The MIT License)
+
+Copyright (c) 2013 Eirik Albrigtsen
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+'Software'), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ </pre
+>
diff --git a/comm/mail/base/content/overrides/app-license-list.html b/comm/mail/base/content/overrides/app-license-list.html
new file mode 100644
index 0000000000..19cbec0b01
--- /dev/null
+++ b/comm/mail/base/content/overrides/app-license-list.html
@@ -0,0 +1,33 @@
+<!-- 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/. -->
+
+<br />
+
+<ul>
+ <li><a href="about:license#tb-apache">Apache License 2.0</a></li>
+ <li><a href="about:license#tb-bsd3clause">BSD-3-Clause License</a></li>
+ <li><a href="about:license#tb-xlicense">X License</a></li>
+ <li><a href="about:license#tb-publicdomain">Public domain</a></li>
+ <li><a href="about:license#tb-ocblicense1">OCB license 1</a></li>
+ <li><a href="about:license#tb-bzip2license">Bzip2 License</a></li>
+ <li><a href="about:license#tb-jsonclicense">Json-C License</a></li>
+ <li><a href="about:license#tb-botanlicense">Botan License</a></li>
+ <li><a href="about:license#tb-rnplicense">RNP Licenses</a></li>
+ <li><a href="about:license#tb-direntlicense">Dirent License</a></li>
+ <li><a href="about:license#tb-mapiheaders">MAPI Headers License</a></li>
+ <li><a href="about:license#tb-getopt">Getopt.c License</a></li>
+ <li><a href="about:license#tb-asn1js">ASN1.js License</a></li>
+ <li><a href="about:license#tb-base-x">base-x License</a></li>
+ <li><a href="about:license#tb-bs58">bs58 License</a></li>
+ <li><a href="about:license#tb-content-type">content-type License</a></li>
+ <li><a href="about:license#tb-events">events License</a></li>
+ <li><a href="about:license#tb-p-retry">p-retry License</a></li>
+ <li><a href="about:license#tb-retry">retry License</a></li>
+ <li><a href="about:license#tb-unhomoglyph">unhomoglyph License</a></li>
+ <li><a href="about:license#tb-sax">sax License</a></li>
+ <li>
+ <a href="about:license#tb-lgpl">GNU Lesser General Public License 2.1</a>
+ </li>
+ <li><a href="about:license#tb-sdp-transform">sdp-transform License</a></li>
+</ul>
diff --git a/comm/mail/base/content/overrides/app-license-name.html b/comm/mail/base/content/overrides/app-license-name.html
new file mode 100644
index 0000000000..2bcdf72c40
--- /dev/null
+++ b/comm/mail/base/content/overrides/app-license-name.html
@@ -0,0 +1 @@
+Thunderbird
diff --git a/comm/mail/base/content/overrides/app-license.html b/comm/mail/base/content/overrides/app-license.html
new file mode 100644
index 0000000000..4a66b5bc53
--- /dev/null
+++ b/comm/mail/base/content/overrides/app-license.html
@@ -0,0 +1,9 @@
+<!-- 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/. -->
+
+<p>
+ <b>Binaries</b> of this product have been made available to you by the
+ <a href="https://www.thunderbird.net/">Thunderbird Project</a> under the
+ Mozilla Public License. <a href="about:rights">Know your rights</a>.
+</p>
diff --git a/comm/mail/base/content/printUtils.js b/comm/mail/base/content/printUtils.js
new file mode 100644
index 0000000000..129cc6a41a
--- /dev/null
+++ b/comm/mail/base/content/printUtils.js
@@ -0,0 +1,428 @@
+/* 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/. */
+
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ SubDialogManager: "resource://gre/modules/SubDialog.sys.mjs",
+});
+
+var { MailE10SUtils } = ChromeUtils.import(
+ "resource:///modules/MailE10SUtils.jsm"
+);
+
+// Load PrintUtils lazily and modify it to suit.
+XPCOMUtils.defineLazyGetter(this, "PrintUtils", () => {
+ let scope = {};
+ Services.scriptloader.loadSubScript(
+ "chrome://global/content/printUtils.js",
+ scope
+ );
+ scope.PrintUtils.getTabDialogBox = function (browser) {
+ if (!browser.tabDialogBox) {
+ browser.tabDialogBox = new TabDialogBox(browser);
+ }
+ return browser.tabDialogBox;
+ };
+ scope.PrintUtils.createBrowser = function ({
+ remoteType,
+ initialBrowsingContextGroupId,
+ userContextId,
+ skipLoad,
+ initiallyActive,
+ } = {}) {
+ let b = document.createXULElement("browser");
+ // Use the JSM global to create the permanentKey, so that if the
+ // permanentKey is held by something after this window closes, it
+ // doesn't keep the window alive.
+ b.permanentKey = new (Cu.getGlobalForObject(Services).Object)();
+
+ const defaultBrowserAttributes = {
+ maychangeremoteness: "true",
+ messagemanagergroup: "browsers",
+ type: "content",
+ };
+ for (let attribute in defaultBrowserAttributes) {
+ b.setAttribute(attribute, defaultBrowserAttributes[attribute]);
+ }
+
+ if (userContextId) {
+ b.setAttribute("usercontextid", userContextId);
+ }
+
+ if (remoteType) {
+ b.setAttribute("remoteType", remoteType);
+ b.setAttribute("remote", "true");
+ }
+
+ // Ensure that the browser will be created in a specific initial
+ // BrowsingContextGroup. This may change the process selection behaviour
+ // of the newly created browser, and is often used in combination with
+ // "remoteType" to ensure that the initial about:blank load occurs
+ // within the same process as another window.
+ if (initialBrowsingContextGroupId) {
+ b.setAttribute(
+ "initialBrowsingContextGroupId",
+ initialBrowsingContextGroupId
+ );
+ }
+
+ // We set large flex on both containers to allow the devtools toolbox to
+ // set a flex attribute. We don't want the toolbox to actually take up free
+ // space, but we do want it to collapse when the window shrinks, and with
+ // flex=0 it can't. When the toolbox is on the bottom it's a sibling of
+ // browserStack, and when it's on the side it's a sibling of
+ // browserContainer.
+ let stack = document.createXULElement("stack");
+ stack.className = "browserStack";
+ stack.appendChild(b);
+
+ let browserContainer = document.createXULElement("vbox");
+ browserContainer.className = "browserContainer";
+ browserContainer.appendChild(stack);
+
+ let browserSidebarContainer = document.createXULElement("hbox");
+ browserSidebarContainer.className = "browserSidebarContainer";
+ browserSidebarContainer.appendChild(browserContainer);
+
+ // Prevent the superfluous initial load of a blank document
+ // if we're going to load something other than about:blank.
+ if (skipLoad) {
+ b.setAttribute("nodefaultsrc", "true");
+ }
+
+ return b;
+ };
+
+ scope.PrintUtils.__defineGetter__("printBrowser", () =>
+ document.getElementById("hiddenPrintContent")
+ );
+ scope.PrintUtils.loadPrintBrowser = async function (url) {
+ let printBrowser = this.printBrowser;
+ if (printBrowser.currentURI?.spec == url) {
+ return;
+ }
+
+ // The template page hasn't been loaded yet. Do that now.
+ await new Promise(resolve => {
+ // Store a strong reference to this progress listener.
+ printBrowser.progressListener = {
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIWebProgressListener",
+ "nsISupportsWeakReference",
+ ]),
+
+ /** nsIWebProgressListener */
+ onStateChange(webProgress, request, stateFlags, status) {
+ if (
+ stateFlags & Ci.nsIWebProgressListener.STATE_STOP &&
+ printBrowser.currentURI.spec != "about:blank"
+ ) {
+ printBrowser.webProgress.removeProgressListener(this);
+ delete printBrowser.progressListener;
+ resolve();
+ }
+ },
+ };
+
+ printBrowser.webProgress.addProgressListener(
+ printBrowser.progressListener,
+ Ci.nsIWebProgress.NOTIFY_STATE_ALL
+ );
+ MailE10SUtils.loadURI(printBrowser, url);
+ });
+ };
+ return scope.PrintUtils;
+});
+
+/**
+ * The TabDialogBox supports opening window dialogs as SubDialogs on the tab and content
+ * level. Both tab and content dialogs have their own separate managers.
+ * Dialogs will be queued FIFO and cover the web content.
+ * Dialogs are closed when the user reloads or leaves the page.
+ * While a dialog is open PopupNotifications, such as permission prompts, are
+ * suppressed.
+ */
+class TabDialogBox {
+ constructor(browser) {
+ this._weakBrowserRef = Cu.getWeakReference(browser);
+
+ // Create parent element for tab dialogs
+ let template = document.getElementById("dialogStackTemplate");
+ this.dialogStack = template.content.cloneNode(true).firstElementChild;
+ this.dialogStack.classList.add("tab-prompt-dialog");
+
+ while (browser.ownerDocument != document) {
+ // Find an ancestor <browser> in this document so that we can locate the
+ // print preview appropriately.
+ browser = browser.ownerGlobal.browsingContext.embedderElement;
+ }
+
+ // This differs from Firefox by using a specific ancestor <stack> rather
+ // than the parent of the <browser>, so that a larger area of the screen
+ // is used for the preview.
+ this.printPreviewStack = document.querySelector(".printPreviewStack");
+ if (this.printPreviewStack && this.printPreviewStack.contains(browser)) {
+ this.printPreviewStack.appendChild(this.dialogStack);
+ } else {
+ this.printPreviewStack = this.browser.parentNode;
+ this.browser.parentNode.insertBefore(
+ this.dialogStack,
+ this.browser.nextElementSibling
+ );
+ }
+
+ // Initially the stack only contains the template
+ let dialogTemplate = this.dialogStack.firstElementChild;
+
+ // Create dialog manager for prompts at the tab level.
+ this._tabDialogManager = new SubDialogManager({
+ dialogStack: this.dialogStack,
+ dialogTemplate,
+ orderType: SubDialogManager.ORDER_QUEUE,
+ allowDuplicateDialogs: true,
+ dialogOptions: {
+ consumeOutsideClicks: false,
+ },
+ });
+ }
+
+ /**
+ * Open a dialog on tab or content level.
+ *
+ * @param {string} aURL - URL of the dialog to load in the tab box.
+ * @param {object} [aOptions]
+ * @param {string} [aOptions.features] - Comma separated list of window
+ * features.
+ * @param {boolean} [aOptions.allowDuplicateDialogs] - Whether to allow
+ * showing multiple dialogs with aURL at the same time. If false calls for
+ * duplicate dialogs will be dropped.
+ * @param {string} [aOptions.sizeTo] - Pass "available" to stretch dialog to
+ * roughly content size.
+ * @param {boolean} [aOptions.keepOpenSameOriginNav] - By default dialogs are
+ * aborted on any navigation.
+ * Set to true to keep the dialog open for same origin navigation.
+ * @param {number} [aOptions.modalType] - The modal type to create the dialog for.
+ * By default, we show the dialog for tab prompts.
+ * @returns {object} [result] Returns an object { closedPromise, dialog }.
+ * @returns {Promise} [result.closedPromise] Resolves once the dialog has been closed.
+ * @returns {SubDialog} [result.dialog] A reference to the opened SubDialog.
+ */
+ open(
+ aURL,
+ {
+ features = null,
+ allowDuplicateDialogs = true,
+ sizeTo,
+ keepOpenSameOriginNav,
+ modalType = null,
+ allowFocusCheckbox = false,
+ } = {},
+ ...aParams
+ ) {
+ let resolveClosed;
+ let closedPromise = new Promise(resolve => (resolveClosed = resolve));
+ // Get the dialog manager to open the prompt with.
+ let dialogManager =
+ modalType === Ci.nsIPrompt.MODAL_TYPE_CONTENT
+ ? this.getContentDialogManager()
+ : this._tabDialogManager;
+ let hasDialogs =
+ this._tabDialogManager.hasDialogs ||
+ this._contentDialogManager?.hasDialogs;
+
+ if (!hasDialogs) {
+ this._onFirstDialogOpen();
+ }
+
+ let closingCallback = event => {
+ if (!hasDialogs) {
+ this._onLastDialogClose();
+ }
+
+ if (allowFocusCheckbox && !event.detail?.abort) {
+ this.maybeSetAllowTabSwitchPermission(event.target);
+ }
+ };
+
+ if (modalType == Ci.nsIPrompt.MODAL_TYPE_CONTENT) {
+ sizeTo = "limitheight";
+ }
+
+ // Open dialog and resolve once it has been closed
+ let dialog = dialogManager.open(
+ aURL,
+ {
+ features,
+ allowDuplicateDialogs,
+ sizeTo,
+ closingCallback,
+ closedCallback: resolveClosed,
+ },
+ ...aParams
+ );
+
+ // Marking the dialog externally, instead of passing it as an option.
+ // The SubDialog(Manager) does not care about navigation.
+ // dialog can be null here if allowDuplicateDialogs = false.
+ if (dialog) {
+ dialog._keepOpenSameOriginNav = keepOpenSameOriginNav;
+ }
+ return { closedPromise, dialog };
+ }
+
+ _onFirstDialogOpen() {
+ for (let element of this.printPreviewStack.children) {
+ if (element != this.dialogStack) {
+ element.setAttribute("tabDialogShowing", true);
+ }
+ }
+
+ // Register listeners
+ this._lastPrincipal = this.browser.contentPrincipal;
+ if ("addProgressListener" in this.browser) {
+ this.browser.addProgressListener(this, Ci.nsIWebProgress.NOTIFY_LOCATION);
+ }
+ }
+
+ _onLastDialogClose() {
+ for (let element of this.printPreviewStack.children) {
+ if (element != this.dialogStack) {
+ element.removeAttribute("tabDialogShowing");
+ }
+ }
+
+ // Clean up listeners
+ if ("removeProgressListener" in this.browser) {
+ this.browser.removeProgressListener(this);
+ }
+ this._lastPrincipal = null;
+ }
+
+ _buildContentPromptDialog() {
+ let template = document.getElementById("dialogStackTemplate");
+ let contentDialogStack = template.content.cloneNode(true).firstElementChild;
+ contentDialogStack.classList.add("content-prompt-dialog");
+
+ // Create a dialog manager for content prompts.
+ let tabPromptDialog =
+ this.browser.parentNode.querySelector(".tab-prompt-dialog");
+ this.browser.parentNode.insertBefore(contentDialogStack, tabPromptDialog);
+
+ let contentDialogTemplate = contentDialogStack.firstElementChild;
+ this._contentDialogManager = new SubDialogManager({
+ dialogStack: contentDialogStack,
+ dialogTemplate: contentDialogTemplate,
+ orderType: SubDialogManager.ORDER_QUEUE,
+ allowDuplicateDialogs: true,
+ dialogOptions: {
+ consumeOutsideClicks: false,
+ },
+ });
+ }
+
+ handleEvent(event) {
+ if (event.type !== "TabClose") {
+ return;
+ }
+ this.abortAllDialogs();
+ }
+
+ abortAllDialogs() {
+ this._tabDialogManager.abortDialogs();
+ this._contentDialogManager?.abortDialogs();
+ }
+
+ focus() {
+ // Prioritize focusing the dialog manager for tab prompts
+ if (this._tabDialogManager._dialogs.length) {
+ this._tabDialogManager.focusTopDialog();
+ return;
+ }
+ this._contentDialogManager?.focusTopDialog();
+ }
+
+ /**
+ * If the user navigates away or refreshes the page, close all dialogs for
+ * the current browser.
+ */
+ onLocationChange(aWebProgress, aRequest, aLocation, aFlags) {
+ if (
+ !aWebProgress.isTopLevel ||
+ aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT
+ ) {
+ return;
+ }
+
+ // Dialogs can be exempt from closing on same origin location change.
+ let filterFn;
+
+ // Test for same origin location change
+ if (
+ this._lastPrincipal?.isSameOrigin(
+ aLocation,
+ this.browser.browsingContext.usePrivateBrowsing
+ )
+ ) {
+ filterFn = dialog => !dialog._keepOpenSameOriginNav;
+ }
+
+ this._lastPrincipal = this.browser.contentPrincipal;
+
+ this._tabDialogManager.abortDialogs(filterFn);
+ this._contentDialogManager?.abortDialogs(filterFn);
+ }
+
+ get tab() {
+ return document.getElementById("tabmail").getTabForBrowser(this.browser);
+ }
+
+ get browser() {
+ let browser = this._weakBrowserRef.get();
+ if (!browser) {
+ throw new Error("Stale dialog box! The associated browser is gone.");
+ }
+ return browser;
+ }
+
+ getTabDialogManager() {
+ return this._tabDialogManager;
+ }
+
+ getContentDialogManager() {
+ if (!this._contentDialogManager) {
+ this._buildContentPromptDialog();
+ }
+ return this._contentDialogManager;
+ }
+
+ onNextPromptShowAllowFocusCheckboxFor(principal) {
+ this._allowTabFocusByPromptPrincipal = principal;
+ }
+
+ /**
+ * Sets the "focus-tab-by-prompt" permission for the dialog.
+ */
+ maybeSetAllowTabSwitchPermission(dialog) {
+ let checkbox = dialog.querySelector("checkbox");
+
+ if (checkbox.checked) {
+ Services.perms.addFromPrincipal(
+ this._allowTabFocusByPromptPrincipal,
+ "focus-tab-by-prompt",
+ Services.perms.ALLOW_ACTION
+ );
+ }
+
+ // Don't show the "allow tab switch checkbox" for subsequent prompts.
+ this._allowTabFocusByPromptPrincipal = null;
+ }
+}
+
+TabDialogBox.prototype.QueryInterface = ChromeUtils.generateQI([
+ "nsIWebProgressListener",
+ "nsISupportsWeakReference",
+]);
diff --git a/comm/mail/base/content/profileDowngrade.js b/comm/mail/base/content/profileDowngrade.js
new file mode 100644
index 0000000000..3a9038e8e5
--- /dev/null
+++ b/comm/mail/base/content/profileDowngrade.js
@@ -0,0 +1,53 @@
+/* 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/. */
+
+let gParams;
+
+const { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+
+window.addEventListener("load", event => {
+ init();
+});
+
+function init() {
+ /*
+ * The C++ code passes a dialog param block using its integers as in and out
+ * arguments for this UI. The following are the uses of the integers:
+ *
+ * 0: A set of flags from nsIToolkitProfileService.downgradeUIFlags.
+ * 1: A return argument, one of nsIToolkitProfileService.downgradeUIChoice.
+ */
+ gParams = window.arguments[0].QueryInterface(Ci.nsIDialogParamBlock);
+
+ document.addEventListener("dialogextra1", createProfile);
+ document.addEventListener("dialogaccept", quit);
+ document.addEventListener("dialogcancel", quit);
+
+ document.querySelector("dialog").getButton("accept").focus();
+}
+
+function quit() {
+ gParams.SetInt(1, Ci.nsIToolkitProfileService.quit);
+}
+
+function createProfile() {
+ gParams.SetInt(1, Ci.nsIToolkitProfileService.createNewProfile);
+ window.close();
+}
+
+function moreInfo(event) {
+ if (event.type == "keypress" && event.key != "Enter") {
+ return;
+ }
+ event.preventDefault();
+
+ let uri = Services.io.newURI(
+ "https://support.mozilla.org/kb/unable-launch-older-version-profile"
+ );
+ Cc["@mozilla.org/uriloader/external-protocol-service;1"]
+ .getService(Ci.nsIExternalProtocolService)
+ .loadURI(uri);
+}
diff --git a/comm/mail/base/content/profileDowngrade.xhtml b/comm/mail/base/content/profileDowngrade.xhtml
new file mode 100644
index 0000000000..4768f930fb
--- /dev/null
+++ b/comm/mail/base/content/profileDowngrade.xhtml
@@ -0,0 +1,48 @@
+<?xml version="1.0"?>
+<!-- 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/. -->
+
+<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/profileDowngrade.css" type="text/css"?>
+
+<!DOCTYPE html [
+<!ENTITY % brandDTD SYSTEM "chrome://branding/locale/brand.dtd">
+%brandDTD;
+<!ENTITY % profileDTD SYSTEM "chrome://messenger/locale/profileDowngrade.dtd">
+%profileDTD;
+]>
+<html xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ scrolling="false">
+<head>
+ <title>&window.title;</title>
+ <script defer="defer" src="chrome://global/content/customElements.js"></script>
+ <script defer="defer" src="profileDowngrade.js"></script>
+</head>
+<body>
+<xul:dialog buttonlabelextra1="&window.create;"
+#ifdef XP_WIN
+ buttonlabelaccept="&window.quit-win;"
+#else
+ buttonlabelaccept="&window.quit-nonwin;"
+#endif
+ buttons="accept,extra1" buttonpack="end"
+ nobuttonspacer="true">
+ <div id="contentWrapper">
+ <img src="chrome://messenger/skin/icons/new/activity/info.svg"
+ alt=""
+ role="presentation" />
+ <div>
+ <p id="nosync">&window.nosync2;</p>
+ <p>
+ <a class="text-link"
+ onclick="moreInfo(event);"
+ onkeypress="moreInfo(event);">&window.moreinfo;</a>
+ </p>
+ </div>
+ </div>
+</xul:dialog>
+</body>
+</html>
diff --git a/comm/mail/base/content/protovis-r2.6-modded.js b/comm/mail/base/content/protovis-r2.6-modded.js
new file mode 100644
index 0000000000..547dedd17a
--- /dev/null
+++ b/comm/mail/base/content/protovis-r2.6-modded.js
@@ -0,0 +1,5349 @@
+/* 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/. */
+
+var pv = function () {
+/**
+ * @namespace The Protovis namespace, <tt>pv</tt>. All public methods and fields
+ * should be registered on this object. Note that core Protovis source is
+ * surrounded by an anonymous function, so any other declared globals will not
+ * be visible outside of core methods. This also allows multiple versions of
+ * Protovis to coexist, since each version will see their own <tt>pv</tt>
+ * namespace.
+ */
+var pv = {};
+
+/**
+ * Returns a prototype object suitable for extending the given class
+ * <tt>f</tt>. Rather than constructing a new instance of <tt>f</tt> to serve as
+ * the prototype (which unnecessarily runs the constructor on the created
+ * prototype object, potentially polluting it), an anonymous function is
+ * generated internally that shares the same prototype:
+ *
+ * <pre>function g() {}
+ * g.prototype = f.prototype;
+ * return new g();</pre>
+ *
+ * For more details, see Douglas Crockford's essay on prototypal inheritance.
+ *
+ * @param {function} f a constructor.
+ * @returns a suitable prototype object.
+ * @see Douglas Crockford's essay on <a
+ * href="http://javascript.crockford.com/prototypal.html">prototypal
+ * inheritance</a>.
+ */
+pv.extend = function(f) {
+ function g() {}
+ g.prototype = f.prototype;
+ return new g();
+};
+
+/**
+ * Returns the passed-in argument, <tt>x</tt>; the identity function. This method
+ * is provided for convenience since it is used as the default behavior for a
+ * number of property functions.
+ *
+ * @param x a value.
+ * @returns the value <tt>x</tt>.
+ */
+pv.identity = function(x) { return x; };
+
+/**
+ * Returns an array of numbers, starting at <tt>start</tt>, incrementing by
+ * <tt>step</tt>, until <tt>stop</tt> is reached. The stop value is exclusive. If
+ * only a single argument is specified, this value is interpreted as the
+ * <i>stop</i> value, with the <i>start</i> value as zero. If only two arguments
+ * are specified, the step value is implied to be one.
+ *
+ * <p>The method is modeled after the built-in <tt>range</tt> method from
+ * Python. See the Python documentation for more details.
+ *
+ * @see <a href="http://docs.python.org/library/functions.html#range">Python range</a>.
+ * @param {number} [start] the start value.
+ * @param {number} stop the stop value.
+ * @param {number} [step] the step value.
+ * @returns {number[]} an array of numbers.
+ */
+pv.range = function(start, stop, step) {
+ if (arguments.length == 1) {
+ stop = start;
+ start = 0;
+ }
+ if (step == undefined) step = 1;
+ else if (!step) throw new Error("step must be non-zero");
+ var array = [], i = 0, j;
+ if (step < 0) {
+ while ((j = start + step * i++) > stop) {
+ array.push(j);
+ }
+ } else {
+ while ((j = start + step * i++) < stop) {
+ array.push(j);
+ }
+ }
+ return array;
+};
+
+/**
+ * Given two arrays <tt>a</tt> and <tt>b</tt>, returns an array of all possible
+ * pairs of elements [a<sub>i</sub>, b<sub>j</sub>]. The outer loop is on array
+ * <i>a</i>, while the inner loop is on <i>b</i>, such that the order of
+ * returned elements is [a<sub>0</sub>, b<sub>0</sub>], [a<sub>0</sub>,
+ * b<sub>1</sub>], ... [a<sub>0</sub>, b<sub>m</sub>], [a<sub>1</sub>,
+ * b<sub>0</sub>], [a<sub>1</sub>, b<sub>1</sub>], ... [a<sub>1</sub>,
+ * b<sub>m</sub>], ... [a<sub>n</sub>, b<sub>m</sub>]. If either array is empty,
+ * an empty array is returned.
+ *
+ * @param {array} a an array.
+ * @param {array} b an array.
+ * @returns {array} an array of pairs of elements in <tt>a</tt> and <tt>b</tt>.
+ */
+pv.cross = function(a, b) {
+ var array = [];
+ for (var i = 0, n = a.length, m = b.length; i < n; i++) {
+ for (var j = 0, x = a[i]; j < m; j++) {
+ array.push([x, b[j]]);
+ }
+ }
+ return array;
+};
+
+/**
+ * Given the specified array of <tt>arrays</tt>, concatenates the arrays into a
+ * single array. If the individual arrays are explicitly known, an alternative
+ * to blend is to use JavaScript's <tt>concat</tt> method directly. These two
+ * equivalent expressions:<ul>
+ *
+ * <li><tt>pv.blend([[1, 2, 3], ["a", "b", "c"]])</tt>
+ * <li><tt>[1, 2, 3].concat(["a", "b", "c"])</tt>
+ *
+ * </ul>return [1, 2, 3, "a", "b", "c"].
+ *
+ * @param {array[]} arrays an array of arrays.
+ * @returns {array} an array containing all the elements of each array in
+ * <tt>arrays</tt>.
+ */
+pv.blend = function(arrays) {
+ return Array.prototype.concat.apply([], arrays);
+};
+
+/**
+ * Returns all of the property names (keys) of the specified object (a map). The
+ * order of the returned array is not defined.
+ *
+ * @param map an object.
+ * @returns {string[]} an array of strings corresponding to the keys.
+ * @see #entries
+ */
+pv.keys = function(map) {
+ var array = [];
+ for (var key in map) {
+ array.push(key);
+ }
+ return array;
+};
+
+/**
+ * Returns all of the entries (key-value pairs) of the specified object (a
+ * map). The order of the returned array is not defined. Each key-value pair is
+ * represented as an object with <tt>key</tt> and <tt>value</tt> attributes,
+ * e.g., <tt>{key: "foo", value: 42}</tt>.
+ *
+ * @param map an object.
+ * @returns {array} an array of key-value pairs corresponding to the keys.
+ */
+pv.entries = function(map) {
+ var array = [];
+ for (var key in map) {
+ array.push({ key: key, value: map[key] });
+ }
+ return array;
+};
+
+/**
+ * Returns all of the values (attribute values) of the specified object (a
+ * map). The order of the returned array is not defined.
+ *
+ * @param map an object.
+ * @returns {array} an array of objects corresponding to the values.
+ * @see #entries
+ */
+pv.values = function(map) {
+ var array = [];
+ for (var key in map) {
+ array.push(map[key]);
+ }
+ return array;
+};
+
+/**
+ * Returns a normalized copy of the specified array, such that the sum of the
+ * returned elements sum to one. If the specified array is not an array of
+ * numbers, an optional accessor function <tt>f</tt> can be specified to map the
+ * elements to numbers. For example, if <tt>array</tt> is an array of objects,
+ * and each object has a numeric property "foo", the expression
+ *
+ * <pre>pv.normalize(array, function(d) d.foo)</pre>
+ *
+ * returns a normalized array on the "foo" property. If an accessor function is
+ * not specified, the identity function is used.
+ *
+ * @param {array} array an array of objects, or numbers.
+ * @param {function} [f] an optional accessor function.
+ * @returns {number[]} an array of numbers that sums to one.
+ */
+pv.normalize = function(array, f) {
+ if (!f) f = pv.identity;
+ var sum = pv.sum(array, f);
+ return array.map(function(d) { return f(d) / sum; });
+};
+
+/**
+ * Returns the sum of the specified array. If the specified array is not an
+ * array of numbers, an optional accessor function <tt>f</tt> can be specified
+ * to map the elements to numbers. See {@link #normalize} for an example.
+ *
+ * @param {array} array an array of objects, or numbers.
+ * @param {function} [f] an optional accessor function.
+ * @returns {number} the sum of the specified array.
+ */
+pv.sum = function(array, f) {
+ if (!f) f = pv.identity;
+ return pv.reduce(array, function(p, d) { return p + f(d); }, 0);
+};
+
+/**
+ * Returns the maximum value of the specified array. If the specified array is
+ * not an array of numbers, an optional accessor function <tt>f</tt> can be
+ * specified to map the elements to numbers. See {@link #normalize} for an
+ * example.
+ *
+ * @param {array} array an array of objects, or numbers.
+ * @param {function} [f] an optional accessor function.
+ * @returns {number} the maximum value of the specified array.
+ */
+pv.max = function(array, f) {
+ if (!f) f = pv.identity;
+ return pv.reduce(array, function(p, d) { return Math.max(p, f(d)); }, -Infinity);
+};
+
+/**
+ * Returns the index of the maximum value of the specified array. If the
+ * specified array is not an array of numbers, an optional accessor function
+ * <tt>f</tt> can be specified to map the elements to numbers. See
+ * {@link #normalize} for an example.
+ *
+ * @param {array} array an array of objects, or numbers.
+ * @param {function} [f] an optional accessor function.
+ * @returns {number} the index of the maximum value of the specified array.
+ */
+pv.max.index = function(array, f) {
+ if (!f) f = pv.identity;
+ var maxi = -1, maxx = -Infinity;
+ for (var i = 0; i < array.length; i++) {
+ var x = f(array[i]);
+ if (x > maxx) {
+ maxx = x;
+ maxi = i;
+ }
+ }
+ return maxi;
+}
+
+/**
+ * Returns the minimum value of the specified array of numbers. If the specified
+ * array is not an array of numbers, an optional accessor function <tt>f</tt>
+ * can be specified to map the elements to numbers. See {@link #normalize} for
+ * an example.
+ *
+ * @param {array} array an array of objects, or numbers.
+ * @param {function} [f] an optional accessor function.
+ * @returns {number} the minimum value of the specified array.
+ */
+pv.min = function(array, f) {
+ if (!f) f = pv.identity;
+ return pv.reduce(array, function(p, d) { return Math.min(p, f(d)); }, Infinity);
+};
+
+/**
+ * Returns the index of the minimum value of the specified array. If the
+ * specified array is not an array of numbers, an optional accessor function
+ * <tt>f</tt> can be specified to map the elements to numbers. See
+ * {@link #normalize} for an example.
+ *
+ * @param {array} array an array of objects, or numbers.
+ * @param {function} [f] an optional accessor function.
+ * @returns {number} the index of the minimum value of the specified array.
+ */
+pv.min.index = function(array, f) {
+ if (!f) f = pv.identity;
+ var mini = -1, minx = Infinity;
+ for (var i = 0; i < array.length; i++) {
+ var x = f(array[i]);
+ if (x < minx) {
+ minx = x;
+ mini = i;
+ }
+ }
+ return mini;
+}
+
+/**
+ * Returns the arithmetic mean, or average, of the specified array. If the
+ * specified array is not an array of numbers, an optional accessor function
+ * <tt>f</tt> can be specified to map the elements to numbers. See
+ * {@link #normalize} for an example.
+ *
+ * @param {array} array an array of objects, or numbers.
+ * @param {function} [f] an optional accessor function.
+ * @returns {number} the mean of the specified array.
+ */
+pv.mean = function(array, f) {
+ return pv.sum(array, f) / array.length;
+};
+
+/**
+ * Returns the median of the specified array. If the specified array is not an
+ * array of numbers, an optional accessor function <tt>f</tt> can be specified
+ * to map the elements to numbers. See {@link #normalize} for an example.
+ *
+ * @param {array} array an array of objects, or numbers.
+ * @param {function} [f] an optional accessor function.
+ * @returns {number} the median of the specified array.
+ */
+pv.median = function(array, f) {
+ if (!f) f = pv.identity;
+ array = array.map(f).sort(function(a, b) { return a - b; });
+ if (array.length % 2) return array[Math.floor(array.length / 2)];
+ var i = array.length / 2;
+ return (array[i - 1] + array[i]) / 2;
+};
+
+if (/\[native code\]/.test(Array.prototype.reduce)) {
+/**
+ * Applies the specified function <tt>f</tt> against an accumulator and each
+ * value of the specified array (from left-ot-right) so as to reduce it to a
+ * single value.
+ *
+ * <p>Array reduce was added in JavaScript 1.8. This implementation uses the native
+ * method if provided; otherwise we use our own implementation derived from the
+ * JavaScript documentation. Note that we don't want to add it to the Array
+ * prototype directly because this breaks certain (bad) for loop idioms.
+ *
+ * @see <a
+ * href="http://developer.mozilla.org/en/Core_JavaScript_1.5_Reference/Objects/Array/reduce">Array.reduce</a>.
+ * @param {array} array an array.
+ * @param {function} [f] a callback function to execute on each value in the array.
+ * @param [v] the object to use as the first argument to the first callback.
+ * @returns the reduced value.
+ */
+ pv.reduce = function(array, f, v) {
+ var p = Array.prototype;
+ return p.reduce.apply(array, p.slice.call(arguments, 1));
+ };
+} else {
+ pv.reduce = function(array, f, v) {
+ var len = array.length;
+ if (!len && (arguments.length == 2)) {
+ throw new Error();
+ }
+
+ var i = 0;
+ if (arguments.length < 3) {
+ while (true) {
+ if (i in array) {
+ v = array[i++];
+ break;
+ }
+ if (++i >= len) {
+ throw new Error();
+ }
+ }
+ }
+
+ for (; i < len; i++) {
+ if (i in array) {
+ v = f.call(null, v, array[i], i, array);
+ }
+ }
+ return v;
+ };
+};
+
+/**
+ * Returns a map constructed from the specified <tt>keys</tt>, using the function
+ * <tt>f</tt> to compute the value for each key. The arguments to the value
+ * function are the same as those used in the built-in array <tt>map</tt>
+ * function: the key, the index, and the array itself. The callback is invoked
+ * only for indexes of the array which have assigned values; it is not invoked
+ * for indexes which have been deleted or which have never been assigned values.
+ *
+ * <p>For example, this expression creates a map from strings to string length:
+ *
+ * <pre>pv.dict(["one", "three", "seventeen"], function(s) s.length)</pre>
+ *
+ * The returned value is <tt>{one: 3, three: 5, seventeen: 9}</tt>.
+ *
+ * @see <a
+ * href="http://developer.mozilla.org/en/Core_JavaScript_1.5_Reference/Global_Objects/Array/map">Array.map</a>.
+ * @param {array} keys an array.
+ * @param {function} f a value function.
+ * @returns a map from keys to values.
+ */
+pv.dict = function(keys, f) {
+ var m = {};
+ for (var i = 0; i < keys.length; i++) {
+ if (i in keys) {
+ var k = keys[i];
+ m[k] = f.call(null, k, i, keys);
+ }
+ }
+ return m;
+};
+
+/**
+ * Returns a permutation of the specified array, using the specified array of
+ * indexes. The returned array contains the corresponding element in
+ * <tt>array</tt> for each index in <tt>indexes</tt>, in order. For example,
+ *
+ * <pre>pv.permute(["a", "b", "c"], [1, 2, 0])</pre>
+ *
+ * returns <tt>["b", "c", "a"]</tt>. It is acceptable for the array of indexes
+ * to be a different length from the array of elements, and for indexes to be
+ * duplicated or omitted. The optional accessor function <tt>f</tt> can be used
+ * to perform a simultaneous mapping of the array elements.
+ *
+ * @param {array} array an array.
+ * @param {number[]} indexes an array of indexes into <tt>array</tt>.
+ * @param {function} [f] an optional accessor function.
+ * @returns {array} an array of elements from <tt>array</tt>; a permutation.
+ */
+pv.permute = function(array, indexes, f) {
+ if (!f) f = pv.identity;
+ var p = new Array(indexes.length);
+ indexes.forEach(function(j, i) { p[i] = f(array[j]); });
+ return p;
+};
+
+/**
+ * Returns a map from key to index for the specified <tt>keys</tt> array. For
+ * example,
+ *
+ * <pre>pv.numerate(["a", "b", "c"])</pre>
+ *
+ * returns <tt>{a: 0, b: 1, c: 2}</tt>. Note that since JavaScript maps only
+ * support string keys, <tt>keys</tt> must contain strings, or other values that
+ * naturally map to distinct string values. Alternatively, an optional accessor
+ * function <tt>f</tt> can be specified to compute the string key for the given
+ * element.
+ *
+ * @param {array} keys an array, usually of string keys.
+ * @param {function} [f] an optional key function.
+ * @returns a map from key to index.
+ */
+pv.numerate = function(keys, f) {
+ if (!f) f = pv.identity;
+ var map = {};
+ keys.forEach(function(x, i) { map[f(x)] = i; });
+ return map;
+};
+
+/**
+ * The comparator function for natural order. This can be used in conjunction with
+ * the built-in array <tt>sort</tt> method to sort elements by their natural
+ * order, ascending. Note that if no comparator function is specified to the
+ * built-in <tt>sort</tt> method, the default order is lexicographic, <i>not</i>
+ * natural!
+ *
+ * @see <a
+ * href="http://developer.mozilla.org/en/Core_JavaScript_1.5_Reference/Global_Objects/Array/sort">Array.sort</a>.
+ * @param a an element to compare.
+ * @param b an element to compare.
+ * @returns {number} negative if a &lt; b; positive if a &gt; b; otherwise 0.
+ */
+pv.naturalOrder = function(a, b) {
+ return (a < b) ? -1 : ((a > b) ? 1 : 0);
+};
+
+/**
+ * The comparator function for reverse natural order. This can be used in
+ * conjunction with the built-in array <tt>sort</tt> method to sort elements by
+ * their natural order, descending. Note that if no comparator function is
+ * specified to the built-in <tt>sort</tt> method, the default order is
+ * lexicographic, <i>not</i> natural!
+ *
+ * @see #naturalOrder
+ * @param a an element to compare.
+ * @param b an element to compare.
+ * @returns {number} negative if a &lt; b; positive if a &gt; b; otherwise 0.
+ */
+pv.reverseOrder = function(b, a) {
+ return (a < b) ? -1 : ((a > b) ? 1 : 0);
+};
+
+/** @namespace Namespace constants for SVG, XMLNS, and XLINK. */
+pv.ns = {
+ /**
+ * The SVG namespace, "http://www.w3.org/2000/svg".
+ *
+ * @type string
+ */
+ svg: "http://www.w3.org/2000/svg",
+
+ /**
+ * The XMLNS namespace, "http://www.w3.org/2000/xmlns".
+ *
+ * @type string
+ */
+ xmlns: "http://www.w3.org/2000/xmlns",
+
+ /**
+ * The XLINK namespace, "http://www.w3.org/1999/xlink".
+ *
+ * @type string
+ */
+ xlink: "http://www.w3.org/1999/xlink"
+};
+
+/** @namespace Protovis major and minor version numbers. */
+pv.version = {
+ /**
+ * The major version number.
+ *
+ * @type number
+ */
+ major: 2,
+
+ /**
+ * The minor version number.
+ *
+ * @type number
+ */
+ minor: 6
+};
+/**
+ * Returns the {@link pv.Color} for the specified color format string. Colors
+ * may have an associated opacity, or alpha channel. Color formats are specified
+ * by CSS Color Modular Level 3, using either in RGB or HSL color space. For
+ * example:<ul>
+ *
+ * <li>#f00 // #rgb
+ * <li>#ff0000 // #rrggbb
+ * <li>rgb(255, 0, 0)
+ * <li>rgb(100%, 0%, 0%)
+ * <li>hsl(0, 100%, 50%)
+ * <li>rgba(0, 0, 255, 0.5)
+ * <li>hsla(120, 100%, 50%, 1)
+ *
+ * </ul>The SVG 1.0 color keywords names are also supported, such as "aliceblue"
+ * and yellowgreen". The "transparent" keyword is also supported for a
+ * fully-transparent color.
+ *
+ * <p>If the <tt>format</tt> argument is already an instance of <tt>Color</tt>,
+ * the argument is returned with no further processing.
+ *
+ * @param {string} format the color specification string, e.g., "#f00".
+ * @returns {pv.Color} the corresponding <tt>Color</tt>.
+ * @see <a href="http://www.w3.org/TR/SVG/types.html#ColorKeywords">SVG color keywords</a>.
+ * @see <a href="http://www.w3.org/TR/css3-color/">CSS3 color module</a>.
+ */
+pv.color = function(format) {
+ if (!format || (format == "transparent")) {
+ return new pv.Color.Rgb(0, 0, 0, 0);
+ }
+ if (format instanceof pv.Color) {
+ return format;
+ }
+
+ /* Handle hsl, rgb. */
+ var m1 = /([a-z]+)\((.*)\)/i.exec(format);
+ if (m1) {
+ var m2 = m1[2].split(","), a = 1;
+ switch (m1[1]) {
+ case "hsla":
+ case "rgba": {
+ a = parseFloat(m2[3]);
+ break;
+ }
+ }
+ switch (m1[1]) {
+ case "hsla":
+ case "hsl": {
+ var h = parseFloat(m2[0]), // degrees
+ s = parseFloat(m2[1]) / 100, // percentage
+ l = parseFloat(m2[2]) / 100; // percentage
+ return (new pv.Color.Hsl(h, s, l, a)).rgb();
+ }
+ case "rgba":
+ case "rgb": {
+ let parse = function(c) { // either integer or percentage
+ let f = parseFloat(c);
+ return (c[c.length - 1] == '%') ? Math.round(f * 2.55) : f;
+ };
+ let r = parse(m2[0]), g = parse(m2[1]), b = parse(m2[2]);
+ return new pv.Color.Rgb(r, g, b, a);
+ }
+ }
+ }
+
+ /* Otherwise, assume named colors. TODO allow lazy conversion to RGB. */
+ return new pv.Color(format, 1);
+};
+
+/**
+ * Constructs a color with the specified color format string and opacity. This
+ * constructor should not be invoked directly; use {@link pv.color} instead.
+ *
+ * @class Represents an abstract (possibly translucent) color. The color is
+ * divided into two parts: the <tt>color</tt> attribute, an opaque color format
+ * string, and the <tt>opacity</tt> attribute, a float in [0, 1]. The color
+ * space is dependent on the implementing class; all colors support the
+ * {@link #rgb} method to convert to RGB color space for interpolation.
+ *
+ * <p>See also the <a href="../../api/Color.html">Color guide</a>.
+ *
+ * @param {string} color an opaque color format string, such as "#f00".
+ * @param {number} opacity the opacity, in [0,1].
+ * @see pv.color
+ */
+pv.Color = function(color, opacity) {
+ /**
+ * An opaque color format string, such as "#f00".
+ *
+ * @type string
+ * @see <a href="http://www.w3.org/TR/SVG/types.html#ColorKeywords">SVG color keywords</a>.
+ * @see <a href="http://www.w3.org/TR/css3-color/">CSS3 color module</a>.
+ */
+ this.color = color;
+
+ /**
+ * The opacity, a float in [0, 1].
+ *
+ * @type number
+ */
+ this.opacity = opacity;
+};
+
+/**
+ * Constructs a new RGB color with the specified channel values.
+ *
+ * @class Represents a color in RGB space.
+ *
+ * @param {number} r the red channel, an integer in [0,255].
+ * @param {number} g the green channel, an integer in [0,255].
+ * @param {number} b the blue channel, an integer in [0,255].
+ * @param {number} a the alpha channel, a float in [0,1].
+ * @extends pv.Color
+ */
+pv.Color.Rgb = function(r, g, b, a) {
+ pv.Color.call(this, a ? ("rgb(" + r + "," + g + "," + b + ")") : "none", a);
+
+ /**
+ * The red channel, an integer in [0, 255].
+ *
+ * @type number
+ */
+ this.r = r;
+
+ /**
+ * The green channel, an integer in [0, 255].
+ *
+ * @type number
+ */
+ this.g = g;
+
+ /**
+ * The blue channel, an integer in [0, 255].
+ *
+ * @type number
+ */
+ this.b = b;
+
+ /**
+ * The alpha channel, a float in [0, 1].
+ *
+ * @type number
+ */
+ this.a = a;
+};
+pv.Color.Rgb.prototype = pv.extend(pv.Color);
+
+/**
+ * Returns the RGB color equivalent to this color. This method is abstract and
+ * must be implemented by subclasses.
+ *
+ * @returns {pv.Color.Rgb} an RGB color.
+ * @function
+ * @name pv.Color.prototype.rgb
+ */
+
+/**
+ * Returns this.
+ *
+ * @returns {pv.Color.Rgb} this.
+ */
+pv.Color.Rgb.prototype.rgb = function() { return this; };
+
+/**
+ * Constructs a new HSL color with the specified values.
+ *
+ * @class Represents a color in HSL space.
+ *
+ * @param {number} h the hue, an integer in [0, 360].
+ * @param {number} s the saturation, a float in [0, 1].
+ * @param {number} l the lightness, a float in [0, 1].
+ * @param {number} a the opacity, a float in [0, 1].
+ * @extends pv.Color
+ */
+pv.Color.Hsl = function(h, s, l, a) {
+ pv.Color.call(this, "hsl(" + h + "," + (s * 100) + "%," + (l * 100) + "%)", a);
+
+ /**
+ * The hue, an integer in [0, 360].
+ *
+ * @type number
+ */
+ this.h = h;
+
+ /**
+ * The saturation, a float in [0, 1].
+ *
+ * @type number
+ */
+ this.s = s;
+
+ /**
+ * The lightness, a float in [0, 1].
+ *
+ * @type number
+ */
+ this.l = l;
+
+ /**
+ * The opacity, a float in [0, 1].
+ *
+ * @type number
+ */
+ this.a = a;
+};
+pv.Color.Hsl.prototype = pv.extend(pv.Color);
+
+/**
+ * Returns the RGB color equivalent to this HSL color.
+ *
+ * @returns {pv.Color.Rgb} an RGB color.
+ */
+pv.Color.Hsl.prototype.rgb = function() {
+ var h = this.h, s = this.s, l = this.l;
+
+ /* Some simple corrections for h, s and l. */
+ h = h % 360; if (h < 0) h += 360;
+ s = Math.max(0, Math.min(s, 1));
+ l = Math.max(0, Math.min(l, 1));
+
+ /* From FvD 13.37 */
+ var m2 = (l < .5) ? (l * (l + s)) : (l + s - l * s);
+ var m1 = 2 * l - m2;
+ if (s == 0) {
+ return new rgb(l, l, l);
+ }
+ function v(h) {
+ if (h > 360) h -= 360;
+ else if (h < 0) h += 360;
+ if (h < 60) return m1 + (m2 - m1) * h / 60;
+ else if (h < 180) return m2;
+ else if (h < 240) return m1 + (m2 - m1) * (240 - h) / 60;
+ return m1;
+ }
+ function vv(h) {
+ return Math.round(v(h) * 255);
+ }
+
+ return new pv.Color.Rgb(vv(h + 120), vv(h), vv(h - 120), this.a);
+};
+/**
+ * Returns a new categorical color encoding using the specified colors. The
+ * arguments to this method are an array of colors; see {@link pv.color}. For
+ * example, to create a categorical color encoding using the <tt>species</tt>
+ * attribute:
+ *
+ * <pre>pv.colors("red", "green", "blue").by(function(d) d.species)</pre>
+ *
+ * The result of this expression can be used as a fill- or stroke-style
+ * property. This assumes that the data's <tt>species</tt> attribute is a
+ * string.
+ *
+ * @returns {pv.Colors} a new categorical color encoding.
+ * @param {string} colors... categorical colors.
+ * @see pv.Colors
+ */
+pv.colors = function() {
+ return pv.Colors(arguments);
+};
+
+/**
+ * Returns a new categorical color encoding using the specified colors. This
+ * constructor is typically not used directly; use {@link pv.colors} instead.
+ *
+ * @class Represents a categorical color encoding using the specified colors.
+ * The returned object can be used as a property function; the appropriate
+ * categorical color will be returned by evaluating the current datum, or
+ * through whatever other means the encoding uses to determine uniqueness, per
+ * the {@link #by} method. The default implementation allocates a distinct color
+ * per {@link pv.Mark#childIndex}.
+ *
+ * @param {string[]} values an array of colors; see {@link pv.color}.
+ * @returns {pv.Colors} a new categorical color encoding.
+ * @see pv.colors
+ */
+pv.Colors = function(values) {
+
+ /**
+ * @ignore Each set of colors has an associated (numeric) ID that is used to
+ * store a cache of assigned colors on the root scene. As unique keys are
+ * discovered, a new color is allocated and assigned to the given key.
+ *
+ * The key function determines how uniqueness is determined. By default,
+ * colors are assigned using the mark's childIndex, such that each new mark
+ * added is given a new color. Note that derived marks will not inherit the
+ * exact color of the prototype, but instead inherit the set of colors.
+ */
+ function colors(keyf) {
+ var id = pv.Colors.count++;
+
+ function color() {
+ var key = keyf.apply(this, this.root.scene.data);
+ var state = this.root.scene.colors;
+ if (!state) this.root.scene.colors = state = {};
+ if (!state[id]) state[id] = { count: 0 };
+ var color = state[id][key];
+ if (color == undefined) {
+ color = state[id][key] = values[state[id].count++ % values.length];
+ }
+ return color;
+ }
+ return color;
+ }
+
+ var c = colors(function() { return this.childIndex; });
+
+ /**
+ * Allows a new set of colors to be derived from the current set using a
+ * different key function. For instance, to color marks using the value of the
+ * field "foo", say:
+ *
+ * <pre>pv.Colors.category10.by(function(d) d.foo)</pre>
+ *
+ * For convenience, "index" and "parent.index" keys are predefined.
+ *
+ * @param {function} v the new key function.
+ * @name pv.Colors.prototype.by
+ * @function
+ * @returns {pv.Colors} a new color scheme
+ */
+ c.by = colors;
+
+ /**
+ * A derivative color encoding using the same colors, but allocating unique
+ * colors based on the mark index.
+ *
+ * @name pv.Colors.prototype.unique
+ * @type pv.Colors
+ */
+ c.unique = c.by(function() { return this.index; });
+
+ /**
+ * A derivative color encoding using the same colors, but allocating unique
+ * colors based on the parent index.
+ *
+ * @name pv.Colors.prototype.parent
+ * @type pv.Colors
+ */
+ c.parent = c.by(function() { return this.parent.index; });
+
+ /**
+ * The underlying array of colors.
+ *
+ * @type string[]
+ * @name pv.Colors.prototype.values
+ */
+ c.values = values;
+
+ return c;
+};
+
+/** @private */
+pv.Colors.count = 0;
+
+/* From Flare. */
+
+/**
+ * A 10-color scheme.
+ *
+ * @type pv.Colors
+ */
+pv.Colors.category10 = pv.colors(
+ "#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd",
+ "#8c564b", "#e377c2", "#7f7f7f", "#bcbd22", "#17becf"
+);
+
+/**
+ * A 20-color scheme.
+ *
+ * @type pv.Colors
+ */
+pv.Colors.category20 = pv.colors(
+ "#1f77b4", "#aec7e8", "#ff7f0e", "#ffbb78", "#2ca02c",
+ "#98df8a", "#d62728", "#ff9896", "#9467bd", "#c5b0d5",
+ "#8c564b", "#c49c94", "#e377c2", "#f7b6d2", "#7f7f7f",
+ "#c7c7c7", "#bcbd22", "#dbdb8d", "#17becf", "#9edae5"
+);
+
+/**
+ * An alternative 19-color scheme.
+ *
+ * @type pv.Colors
+ */
+pv.Colors.category19 = pv.colors(
+ "#9c9ede", "#7375b5", "#4a5584", "#cedb9c", "#b5cf6b",
+ "#8ca252", "#637939", "#e7cb94", "#e7ba52", "#bd9e39",
+ "#8c6d31", "#e7969c", "#d6616b", "#ad494a", "#843c39",
+ "#de9ed6", "#ce6dbd", "#a55194", "#7b4173"
+);
+// TODO support arbitrary color stops
+
+/**
+ * Returns a linear color ramp from the specified <tt>start</tt> color to the
+ * specified <tt>end</tt> color. The color arguments may be specified either as
+ * <tt>string</tt>s or as {@link pv.Color}s.
+ *
+ * @param {string} start the start color; may be a <tt>pv.Color</tt>.
+ * @param {string} end the end color; may be a <tt>pv.Color</tt>.
+ * @returns {pv.Ramp} a color ramp from <tt>start</tt> to <tt>end</tt>.
+ */
+pv.ramp = function(start, end) {
+ return pv.Ramp(pv.color(start), pv.color(end));
+};
+
+/**
+ * Constructs a ramp from the specified start color to the specified end
+ * color. This constructor should not be invoked directly; use {@link pv.ramp}
+ * instead.
+ *
+ * @class Represents a linear color ramp from the specified <tt>start</tt> color
+ * to the specified <tt>end</tt> color. Ramps can be used as property functions;
+ * their behavior is equivalent to calling {@link #value}, passing in the
+ * current datum as the sample point. If the data is <i>not</i> a float in [0,
+ * 1], the {@link #by} method can be used to map the datum to a suitable sample
+ * point.
+ *
+ * @extends Function
+ * @param {pv.Color} start the start color.
+ * @param {pv.Color} end the end color.
+ * @see pv.ramp
+ */
+pv.Ramp = function(start, end) {
+ var s = start.rgb(), e = end.rgb(), f = pv.identity;
+
+ /** @ignore Property function. */
+ function ramp() {
+ return value(f.apply(this, this.root.scene.data));
+ }
+
+ /** @ignore Interpolates between start and end at value aT in [0,1]. */
+ function value(aT) {
+ var t = Math.max(0, Math.min(1, aT));
+ var a = s.a * (1 - t) + e.a * t;
+ if (a < 1e-5) a = 0; // avoid scientific notation
+ return (s.a == 0) ? new pv.Color.Rgb(e.r, e.g, e.b, a)
+ : ((e.a == 0) ? new pv.Color.Rgb(s.r, s.g, s.b, a)
+ : new pv.Color.Rgb(
+ Math.round(s.r * (1 - t) + e.r * t),
+ Math.round(s.g * (1 - t) + e.g * t),
+ Math.round(s.b * (1 - t) + e.b * t), a));
+ }
+
+ /**
+ * Sets the sample function to be the specified function <tt>v</tt>.
+ *
+ * @param {function} v the new sample function.
+ * @name pv.Ramp.prototype.by
+ * @function
+ * @returns {pv.Ramp} this.
+ */
+ ramp.by = function(v) { f = v; return this; };
+
+ /**
+ * Returns the interpolated color at the specified sample point.
+ *
+ * @param {number} t the sample point in [0, 1].
+ * @name pv.Ramp.prototype.value
+ * @function
+ * @returns {pv.Color.Rgb} the interpolated color.
+ */
+ ramp.value = value;
+
+ return ramp;
+};
+/**
+ * Constructs a new mark with default properties. Marks, with the exception of
+ * the root panel, are not typically constructed directly; instead, they are
+ * added to a panel or an existing mark via {@link pv.Mark#add}.
+ *
+ * @class Represents a data-driven graphical mark. The <tt>Mark</tt> class is
+ * the base class for all graphical marks in Protovis; it does not provide any
+ * specific rendering functionality, but together with {@link Panel} establishes
+ * the core framework.
+ *
+ * <p>Concrete mark types include familiar visual elements such as bars, lines
+ * and labels. Although a bar mark may be used to construct a bar chart, marks
+ * know nothing about charts; it is only through their specification and
+ * composition that charts are produced. These building blocks permit many
+ * combinatorial possibilities.
+ *
+ * <p>Marks are associated with <b>data</b>: a mark is generated once per
+ * associated datum, mapping the datum to visual <b>properties</b> such as
+ * position and color. Thus, a single mark specification represents a set of
+ * visual elements that share the same data and visual encoding. The type of
+ * mark defines the names of properties and their meaning. A property may be
+ * static, ignoring the associated datum and returning a constant; or, it may be
+ * dynamic, derived from the associated datum or index. Such dynamic encodings
+ * can be specified succinctly using anonymous functions. Special properties
+ * called event handlers can be registered to add interactivity.
+ *
+ * <p>While most properties are <i>variable</i>, some mark types, such as lines
+ * and areas, generate a single visual element rather than a distinct visual
+ * element per datum. With these marks, some properties may be <b>fixed</b>.
+ * Fixed properties can vary per mark, but not <i>per datum</i>! These
+ * properties are evaluated solely for the first (0-index) datum, and typically
+ * are specified as a constant. However, it is valid to use a function if the
+ * property varies between panels or is dynamically generated.
+ *
+ * <p>Protovis uses <b>inheritance</b> to simplify the specification of related
+ * marks: a new mark can be derived from an existing mark, inheriting its
+ * properties. The new mark can then override properties to specify new
+ * behavior, potentially in terms of the old behavior. In this way, the old mark
+ * serves as the <b>prototype</b> for the new mark. Most mark types share the
+ * same basic properties for consistency and to facilitate inheritance.
+ *
+ * <p>See also the <a href="../../api/">Protovis guide</a>.
+ */
+pv.Mark = function() {};
+
+/**
+ * Returns the mark type name. Names should be lower case, with words separated
+ * by hyphens. For example, the mark class <tt>FooBar</tt> should return
+ * "foo-bar".
+ *
+ * <p>Note that this method is defined on the constructor, not on the prototype,
+ * and thus is a static method. The constructor is accessible through the
+ * {@link #type} field.
+ *
+ * @returns {string} the mark type name, such as "mark".
+ */
+pv.Mark.toString = function() { return "mark"; };
+
+/**
+ * Defines and registers a property method for the property with the given name.
+ * This method should be called on a mark class prototype to define each exposed
+ * property. (Note this refers to the JavaScript <tt>prototype</tt>, not the
+ * Protovis mark prototype, which is the {@link #proto} field.)
+ *
+ * <p>The created property method supports several modes of invocation: <ol>
+ *
+ * <li>If invoked with a <tt>Function</tt> argument, this function is evaluated
+ * for each associated datum. The return value of the function is used as the
+ * computed property value. The context of the function (<tt>this</tt>) is this
+ * mark. The arguments to the function are the associated data of this mark and
+ * any enclosing panels. For example, a linear encoding of numerical data to
+ * height is specified as
+ *
+ * <pre>m.height(function(d) d * 100);</pre>
+ *
+ * The expression <tt>d * 100</tt> will be evaluated for the height property of
+ * each mark instance. This function is stored in the <tt>$height</tt> field. The
+ * return value of the property method (e.g., <tt>m.height</tt>) is this mark
+ * (<tt>m</tt>)).<p>
+ *
+ * <li>If invoked with a non-function argument, the property is treated as a
+ * constant, and wrapped with an accessor function. This wrapper function is
+ * stored in the equivalent internal (<tt>$</tt>-prefixed) field. The return
+ * value of the property method (e.g., <tt>m.height</tt>) is this mark.<p>
+ *
+ * <li>If invoked from an event handler, the property is set to the specified
+ * value on the current instance (i.e., the instance that triggered the event,
+ * such as a mouse click). In this case, the value should be a constant and not
+ * a function. The return value is this mark. For example, saying
+ *
+ * <pre>this.fillStyle("red").strokeStyle("black");</pre>
+ *
+ * from a "click" event handler will set the fill color to red, and the stroke
+ * color to black, for any marks that are clicked.<p>
+ *
+ * <li>If invoked with no arguments, the computed property value for the current
+ * mark instance in the scene graph is returned. This facilitates <i>property
+ * chaining</i>, where one mark's properties are defined in terms of another's.
+ * For example, to offset a mark's location from its prototype, you might say
+ *
+ * <pre>m.top(function() this.proto.top() + 10);</pre>
+ *
+ * Note that the index of the mark being evaluated (in the above example,
+ * <tt>this.proto</tt>) is inherited from the <tt>Mark</tt> class and set by
+ * this mark. So, if the fifth element's top property is being evaluated, the
+ * fifth instance of <tt>this.proto</tt> will similarly be queried for the value
+ * of its top property. If the mark being evaluated has a different number of
+ * instances, or its data is unrelated, the behavior of this method is
+ * undefined. In these cases it may be better to index the <tt>scene</tt>
+ * explicitly to specify the exact instance.
+ *
+ * </ol><p>Property names should follow standard JavaScript method naming
+ * conventions, using lowerCamel-style capitalization.
+ *
+ * <p>In addition to creating the property method, every property is registered
+ * in the {@link #properties} array on the <tt>prototype</tt>. Although this
+ * array is an instance field, it is considered immutable and shared by all
+ * instances of a given mark type. The <tt>properties</tt> array can be queried
+ * to see if a mark type defines a particular property, such as width or height.
+ *
+ * @param {string} name the property name.
+ */
+pv.Mark.prototype.defineProperty = function(name) {
+ if (!this.hasOwnProperty("properties")) {
+ this.properties = (this.properties || []).concat();
+ }
+ this.properties.push(name);
+ this[name] = function(v) {
+ if (arguments.length) {
+ if (this.scene) {
+ this.scene[this.index][name] = v;
+ } else {
+ this["$" + name] = (v instanceof Function) ? v : function() { return v; };
+ }
+ return this;
+ }
+ return this.scene[this.index][name];
+ };
+};
+
+/**
+ * The constructor; the mark type. This mark type may define default property
+ * functions (see {@link #defaults}) that are used if the property is not
+ * overridden by the mark or any of its prototypes.
+ *
+ * @type function
+ */
+pv.Mark.prototype.type = pv.Mark;
+
+/**
+ * The mark prototype, possibly null, from which to inherit property
+ * functions. The mark prototype is not necessarily of the same type as this
+ * mark. Any properties defined on this mark will override properties inherited
+ * either from the prototype or from the type-specific defaults.
+ *
+ * @type pv.Mark
+ */
+pv.Mark.prototype.proto = null;
+
+/**
+ * The enclosing parent panel. The parent panel is generally null only for the
+ * root panel; however, it is possible to create "offscreen" marks that are used
+ * only for inheritance purposes.
+ *
+ * @type pv.Panel
+ */
+pv.Mark.prototype.parent = null;
+
+/**
+ * The child index. -1 if the enclosing parent panel is null; otherwise, the
+ * zero-based index of this mark into the parent panel's <tt>children</tt> array.
+ *
+ * @type number
+ */
+pv.Mark.prototype.childIndex = -1;
+
+/**
+ * The mark index. The value of this field depends on which instance (i.e.,
+ * which element of the data array) is currently being evaluated. During the
+ * build phase, the index is incremented over each datum; when handling events,
+ * the index is set to the instance that triggered the event.
+ *
+ * @type number
+ */
+pv.Mark.prototype.index = -1;
+
+/**
+ * The scene graph. The scene graph is an array of objects; each object (or
+ * "node") corresponds to an instance of this mark and an element in the data
+ * array. The scene graph can be traversed to lookup previously-evaluated
+ * properties.
+ *
+ * <p>For instance, consider a stacked area chart. The bottom property of the
+ * area can be defined using the <i>cousin</i> instance, which is the current
+ * area instance in the previous instantiation of the parent panel. In this
+ * sample code,
+ *
+ * <pre>new pv.Panel()
+ * .width(150).height(150)
+ * .add(pv.Panel)
+ * .data([[1, 1.2, 1.7, 1.5, 1.7],
+ * [.5, 1, .8, 1.1, 1.3],
+ * [.2, .5, .8, .9, 1]])
+ * .add(pv.Area)
+ * .data(function(d) d)
+ * .bottom(function() {
+ * var c = this.cousin();
+ * return c ? (c.bottom + c.height) : 0;
+ * })
+ * .height(function(d) d * 40)
+ * .left(function() this.index * 35)
+ * .root.render();</pre>
+ *
+ * the bottom property is computed based on the upper edge of the corresponding
+ * datum in the previous series. The area's parent panel is instantiated once
+ * per series, so the cousin refers to the previous (below) area mark. (Note
+ * that the position of the upper edge is not the same as the top property,
+ * which refers to the top margin: the distance from the top edge of the panel
+ * to the top edge of the mark.)
+ *
+ * @see #first
+ * @see #last
+ * @see #sibling
+ * @see #cousin
+ */
+pv.Mark.prototype.scene = null;
+
+/**
+ * The root parent panel. This may be null for "offscreen" marks that are
+ * created for inheritance purposes only.
+ *
+ * @type pv.Panel
+ */
+pv.Mark.prototype.root = null;
+
+/**
+ * The data property; an array of objects. The size of the array determines the
+ * number of marks that will be instantiated; each element in the array will be
+ * passed to property functions to compute the property values. Typically, the
+ * data property is specified as a constant array, such as
+ *
+ * <pre>m.data([1, 2, 3, 4, 5]);</pre>
+ *
+ * However, it is perfectly acceptable to define the data property as a
+ * function. This function might compute the data dynamically, allowing
+ * different data to be used per enclosing panel. For instance, in the stacked
+ * area graph example (see {@link #scene}), the data function on the area mark
+ * dereferences each series.
+ *
+ * @type array
+ * @name pv.Mark.prototype.data
+ */
+pv.Mark.prototype.defineProperty("data");
+
+/**
+ * The visible property; a boolean determining whether or not the mark instance
+ * is visible. If a mark instance is not visible, its other properties will not
+ * be evaluated. Similarly, for panels no child marks will be rendered.
+ *
+ * @type boolean
+ * @name pv.Mark.prototype.visible
+ */
+pv.Mark.prototype.defineProperty("visible");
+
+/**
+ * The left margin; the distance, in pixels, between the left edge of the
+ * enclosing panel and the left edge of this mark. Note that in some cases this
+ * property may be redundant with the right property, or with the conjunction of
+ * right and width.
+ *
+ * @type number
+ * @name pv.Mark.prototype.left
+ */
+pv.Mark.prototype.defineProperty("left");
+
+/**
+ * The right margin; the distance, in pixels, between the right edge of the
+ * enclosing panel and the right edge of this mark. Note that in some cases this
+ * property may be redundant with the left property, or with the conjunction of
+ * left and width.
+ *
+ * @type number
+ * @name pv.Mark.prototype.right
+ */
+pv.Mark.prototype.defineProperty("right");
+
+/**
+ * The top margin; the distance, in pixels, between the top edge of the
+ * enclosing panel and the top edge of this mark. Note that in some cases this
+ * property may be redundant with the bottom property, or with the conjunction
+ * of bottom and height.
+ *
+ * @type number
+ * @name pv.Mark.prototype.top
+ */
+pv.Mark.prototype.defineProperty("top");
+
+/**
+ * The bottom margin; the distance, in pixels, between the bottom edge of the
+ * enclosing panel and the bottom edge of this mark. Note that in some cases
+ * this property may be redundant with the top property, or with the conjunction
+ * of top and height.
+ *
+ * @type number
+ * @name pv.Mark.prototype.bottom
+ */
+pv.Mark.prototype.defineProperty("bottom");
+
+/**
+ * The cursor property; corresponds to the CSS cursor property. This is
+ * typically used in conjunction with event handlers to indicate interactivity.
+ *
+ * @type string
+ * @name pv.Mark.prototype.cursor
+ * @see <a href="http://www.w3.org/TR/CSS2/ui.html#propdef-cursor">CSS2 cursor</a>.
+ */
+pv.Mark.prototype.defineProperty("cursor");
+
+/**
+ * The title property; corresponds to the HTML/SVG title property, allowing the
+ * general of simple plain text tooltips.
+ *
+ * @type string
+ * @name pv.Mark.prototype.title
+ */
+pv.Mark.prototype.defineProperty("title");
+
+/**
+ * Default properties for all mark types. By default, the data array is a single
+ * null element; if the data property is not specified, this causes each mark to
+ * be instantiated as a singleton. The visible property is true by default.
+ *
+ * @type pv.Mark
+ */
+pv.Mark.defaults = new pv.Mark()
+ .data([null])
+ .visible(true);
+
+/**
+ * Sets the prototype of this mark to the specified mark. Any properties not
+ * defined on this mark may be inherited from the specified prototype mark, or
+ * its prototype, and so on. The prototype mark need not be the same type of
+ * mark as this mark. (Note that for inheritance to be useful, properties with
+ * the same name on different mark types should have equivalent meaning.)
+ *
+ * @param {pv.Mark} proto the new prototype.
+ * @return {pv.Mark} this mark.
+ */
+pv.Mark.prototype.extend = function(proto) {
+ this.proto = proto;
+ return this;
+};
+
+/**
+ * Adds a new mark of the specified type to the enclosing parent panel, whilst
+ * simultaneously setting the prototype of the new mark to be this mark.
+ *
+ * @param {function} type the type of mark to add; a constructor, such as
+ * <tt>pv.Bar</tt>.
+ * @return {pv.Mark} the new mark.
+ */
+pv.Mark.prototype.add = function(type) {
+ return this.parent.add(type).extend(this);
+};
+
+/**
+ * Constructs a new mark anchor with default properties.
+ *
+ * @class Represents an anchor on a given mark. An anchor is itself a mark, but
+ * without a visual representation. It serves only to provide useful default
+ * properties that can be inherited by other marks. Each type of mark can define
+ * any number of named anchors for convenience. If the concrete mark type does
+ * not define an anchor implementation specifically, one will be inherited from
+ * the mark's parent class.
+ *
+ * <p>For example, the bar mark provides anchors for its four sides: left,
+ * right, top and bottom. Adding a label to the top anchor of a bar,
+ *
+ * <pre>bar.anchor("top").add(pv.Label);</pre>
+ *
+ * will render a text label on the top edge of the bar; the top anchor defines
+ * the appropriate position properties (top and left), as well as text-rendering
+ * properties for convenience (textAlign and textBaseline).
+ *
+ * @extends pv.Mark
+ */
+pv.Mark.Anchor = function() {
+ pv.Mark.call(this);
+};
+pv.Mark.Anchor.prototype = pv.extend(pv.Mark);
+
+/**
+ * The anchor name. The set of supported anchor names is dependent on the
+ * concrete mark type; see the mark type for details. For example, bars support
+ * left, right, top and bottom anchors.
+ *
+ * <p>While anchor names are typically constants, the anchor name is a true
+ * property, which means you can specify a function to compute the anchor name
+ * dynamically. For instance, if you wanted to alternate top and bottom anchors,
+ * saying
+ *
+ * <pre>m.anchor(function() (this.index % 2) ? "top" : "bottom").add(pv.Dot);</pre>
+ *
+ * would have the desired effect.
+ *
+ * @type string
+ * @name pv.Mark.Anchor.prototype.name
+ */
+pv.Mark.Anchor.prototype.defineProperty("name");
+
+/**
+ * Returns an anchor with the specified name. While anchor names are typically
+ * constants, the anchor name is a true property, which means you can specify a
+ * function to compute the anchor name dynamically. See the
+ * {@link pv.Mark.Anchor#name} property for details.
+ *
+ * @param {string} name the anchor name; either a string or a property function.
+ * @returns {pv.Mark.Anchor} the new anchor.
+ */
+pv.Mark.prototype.anchor = function(name) {
+ var anchorType = this.type;
+ while (!anchorType.Anchor) {
+ anchorType = anchorType.defaults.proto.type;
+ }
+ var anchor = new anchorType.Anchor().extend(this).name(name);
+ anchor.parent = this.parent;
+ anchor.type = this.type;
+ return anchor;
+};
+
+/**
+ * Returns the anchor target of this mark, if it is derived from an anchor;
+ * otherwise returns null. For example, if a label is derived from a bar anchor,
+ *
+ * <pre>bar.anchor("top").add(pv.Label);</pre>
+ *
+ * then property functions on the label can refer to the bar via the
+ * <tt>anchorTarget</tt> method. This method is also useful for mark types
+ * defining properties on custom anchors.
+ *
+ * @returns {pv.Mark} the anchor target of this mark; possibly null.
+ */
+pv.Mark.prototype.anchorTarget = function() {
+ var target = this;
+ while (!(target instanceof pv.Mark.Anchor)) {
+ target = target.proto;
+ if (!target) return null;
+ }
+ return target.proto;
+};
+
+/**
+ * Returns the first instance of this mark in the scene graph. This method can
+ * only be called when the mark is bound to the scene graph (for example, from
+ * an event handler, or within a property function).
+ *
+ * @returns a node in the scene graph.
+ */
+pv.Mark.prototype.first = function() {
+ return this.scene[0];
+};
+
+/**
+ * Returns the last instance of this mark in the scene graph. This method can
+ * only be called when the mark is bound to the scene graph (for example, from
+ * an event handler, or within a property function). In addition, note that mark
+ * instances are built sequentially, so the last instance of this mark may not
+ * yet be constructed.
+ *
+ * @returns a node in the scene graph.
+ */
+pv.Mark.prototype.last = function() {
+ return this.scene[this.scene.length - 1];
+};
+
+/**
+ * Returns the previous instance of this mark in the scene graph, or null if
+ * this is the first instance.
+ *
+ * @returns a node in the scene graph, or null.
+ */
+pv.Mark.prototype.sibling = function() {
+ return (this.index == 0) ? null : this.scene[this.index - 1];
+};
+
+/**
+ * Returns the current instance in the scene graph of this mark, in the previous
+ * instance of the enclsoing parent panel. May return null if this instance
+ * could not be found.
+ *
+ * @returns a node in the scene graph, or null.
+ */
+pv.Mark.prototype.cousin = function() {
+ var p = this.parent, s = p && p.sibling();
+ return (s && s.children) ? s.children[this.childIndex][this.index] : null;
+};
+
+/**
+ * Renders this mark, including recursively rendering all child marks if this is
+ * a panel. Rendering consists of two phases: <b>build</b> and <b>update</b>. In
+ * the future, the update phase could conceivably be decoupled to allow
+ * different rendering engines. Similarly, future work is needed to allow
+ * dynamic rebuilding based on interaction. (For example, dynamic expansion of a
+ * tree visualization.)
+ *
+ * <p>In the build phase (see {@link #build}), all properties are evaluated, and
+ * the scene graph is generated. However, nothing is rendered.
+ *
+ * <p>In the update phase (see {@link #update}), the mark is rendered by
+ * creating and updating elements and attributes in the SVG image. No properties
+ * are evaluated during the update phase; instead the values computed previously
+ * in the build phase are simply translated into SVG.
+ */
+pv.Mark.prototype.render = function() {
+ this.build();
+ this.update();
+};
+
+/**
+ * Evaluates properties and computes implied properties. Properties are stored
+ * in the {@link #scene} array for each instance of this mark.
+ *
+ * <p>As marks are built recursively, the {@link #index} property is updated to
+ * match the current index into the data array for each mark. Note that the
+ * index property is only set for the mark currently being built and its
+ * enclosing parent panels. The index property for other marks is unset, but is
+ * inherited from the global <tt>Mark</tt> class prototype. This allows mark
+ * properties to refer to properties on other marks <i>in the same panel</i>
+ * conveniently; however, in general it is better to reference mark instances
+ * specifically through the scene graph rather than depending on the magical
+ * behavior of {@link #index}.
+ *
+ * <p>The root scene array has a special property, <tt>data</tt>, which stores
+ * the current data stack. The first element in this stack is the current datum,
+ * followed by the datum of the enclosing parent panel, and so on. The data
+ * stack should not be accessed directly; instead, property functions are passed
+ * the current data stack as arguments.
+ *
+ * <p>The evaluation of the <tt>data</tt> and <tt>visible</tt> properties is
+ * special. The <tt>data</tt> property is evaluated first; unlike the other
+ * properties, the data stack is from the parent panel, rather than the current
+ * mark, since the data is not defined until the data property is evaluated.
+ * The <tt>visible</tt> property is subsequently evaluated for each instance;
+ * only if true will the {@link #buildInstance} method be called, evaluating
+ * other properties and recursively building the scene graph.
+ *
+ * <p>If this mark is being re-built, any old instances of this mark that no
+ * longer exist (because the new data array contains fewer elements) will be
+ * cleared using {@link #clearInstance}.
+ *
+ * @param parent the instance of the parent panel from the scene graph.
+ */
+pv.Mark.prototype.build = function(parent) {
+ if (!this.scene) {
+ this.scene = [];
+ if (!this.parent) {
+ this.scene.data = [];
+ }
+ }
+
+ var data = this.get("data");
+ var stack = this.root.scene.data;
+ stack.unshift(null);
+ this.index = -1;
+
+ this.$$data = data; // XXX
+
+ for (var i = 0, d; i < data.length; i++) {
+ pv.Mark.prototype.index = ++this.index;
+ var s = {};
+
+ /*
+ * This is a bit confusing and could be cleaned up. This "scene" stores the
+ * previous scene graph; we want to reuse SVG elements that were created
+ * previously rather than recreating them, so we extract them. We also want
+ * to reuse SVG child elements as well.
+ */
+ if (this.scene[this.index]) {
+ s.svg = this.scene[this.index].svg;
+ s.children = this.scene[this.index].children;
+ }
+ this.scene[this.index] = s;
+
+ s.index = i;
+ s.data = stack[0] = data[i];
+ s.parent = parent;
+ s.visible = this.get("visible");
+ if (s.visible) {
+ this.buildInstance(s);
+ }
+ }
+ stack.shift();
+ delete this.index;
+ pv.Mark.prototype.index = -1;
+
+ /* Clear any old instances from the scene. */
+ for (var i = data.length; i < this.scene.length; i++) {
+ this.clearInstance(this.scene[i]);
+ }
+ this.scene.length = data.length;
+
+ return this;
+};
+
+/**
+ * Removes the specified mark instance from the SVG image. This method depends
+ * on the <tt>svg</tt> property of the scene graph node. If the specified mark
+ * instance was not present in the SVG image (for example, because it was not
+ * visible), this method has no effect.
+ *
+ * @param s a node in the scene graph; the instance of the mark to clear.
+ */
+pv.Mark.prototype.clearInstance = function(s) {
+ if (s.svg) {
+ s.parent.svg.removeChild(s.svg);
+ }
+};
+
+/**
+ * Evaluates all of the properties for this mark for the specified instance
+ * <tt>s</tt> in the scene graph. The set of properties to evaluate is retrieved
+ * from the {@link #properties} array for this mark type (see {@link #type}).
+ * After these properties are evaluated, any <b>implied</b> properties may be
+ * computed by the mark and set on the scene graph; see {@link #buildImplied}.
+ *
+ * <p>For panels, this method recursively builds the scene graph for all child
+ * marks as well. In general, this method should not need to be overridden by
+ * concrete mark types.
+ *
+ * @param s a node in the scene graph; the instance of the mark to build.
+ */
+pv.Mark.prototype.buildInstance = function(s) {
+ var p = this.type.prototype;
+ for (var i = 0; i < p.properties.length; i++) {
+ var name = p.properties[i];
+ if (!(name in s)) {
+ s[name] = this.get(name);
+ }
+ }
+ this.buildImplied(s);
+};
+
+/**
+ * Computes the implied properties for this mark for the specified instance
+ * <tt>s</tt> in the scene graph. Implied properties are those with dependencies
+ * on multiple other properties; for example, the width property may be implied
+ * if the left and right properties are set. This method can be overridden by
+ * concrete mark types to define new implied properties, if necessary.
+ *
+ * <p>The default implementation computes the implied CSS box model properties.
+ * The prioritization of redundant properties is as follows:<ol>
+ *
+ * <li>If the <tt>width</tt> property is not specified (i.e., null), its value is
+ * the width of the parent panel, minus this mark's left and right margins; the
+ * left and right margins are zero if not specified.
+ *
+ * <li>Otherwise, if the <tt>right</tt> margin is not specified, its value is the
+ * width of the parent panel, minus this mark's width and left margin; the left
+ * margin is zero if not specified.
+ *
+ * <li>Otherwise, if the <tt>left</tt> property is not specified, its value is
+ * the width of the parent panel, minus this mark's width and the right margin.
+ *
+ * </ol>This prioritization is then duplicated for the <tt>height</tt>,
+ * <tt>bottom</tt> and <tt>top</tt> properties, respectively.
+ *
+ * @param s a node in the scene graph; the instance of the mark to build.
+ */
+pv.Mark.prototype.buildImplied = function(s) {
+ var l = s.left;
+ var r = s.right;
+ var t = s.top;
+ var b = s.bottom;
+
+ /* Assume width and height are zero if not supported by this mark type. */
+ var p = this.type.prototype;
+ var w = p.width ? s.width : 0;
+ var h = p.height ? s.height : 0;
+
+ /* Compute implied width, right and left. */
+ var width = s.parent ? s.parent.width : 0;
+ if (w == null) {
+ w = width - (r = r || 0) - (l = l || 0);
+ } else if (r == null) {
+ r = width - w - (l = l || 0);
+ } else if (l == null) {
+ l = width - w - (r = r || 0);
+ }
+
+ /* Compute implied height, bottom and top. */
+ var height = s.parent ? s.parent.height : 0;
+ if (h == null) {
+ h = height - (t = t || 0) - (b = b || 0);
+ } else if (b == null) {
+ b = height - h - (t = t || 0);
+ } else if (t == null) {
+ t = height - h - (b = b || 0);
+ }
+
+ s.left = l;
+ s.right = r;
+ s.top = t;
+ s.bottom = b;
+
+ /* Only set width and height if they are supported by this mark type. */
+ if (p.width) s.width = w;
+ if (p.height) s.height = h;
+};
+
+var property; // XXX
+
+/**
+ * Evaluates the property function with the specified name for the current data
+ * stack. The data stack, <tt>this.root.scene.data</tt>, contains the current
+ * datum, followed by the datum for the enclosing panel, and so on.
+ *
+ * <p>This method first finds the implementing property function by querying the
+ * current mark. If the current mark does not define the property function, the
+ * prototype mark is queried, and so on. If none of the mark prototypes define a
+ * property function with the given name, the type default function is used. If
+ * no default function is provided, this method returns null.
+ *
+ * <p>The context of the property function is <tt>this</tt> instance (i.e., the
+ * leaf-level mark), rather than whatever mark defined the property function.
+ * Because of this behavior, a property function may be called on an object of a
+ * different "class" (e.g., a Dot inheriting the fill style from a Line). Also
+ * note that properties are not inherited statically; inheritance happens at the
+ * property function / mark level, not per property value / mark instance. Thus,
+ * even if a Dot extends from a Line, if the Line's fill style is defined using
+ * a function that generates a random color, the Dot may get a different color.
+ *
+ * @param {string} name the property name.
+ * @returns the evaluated property value.
+ */
+pv.Mark.prototype.get = function(name) {
+ var mark = this;
+ while (!mark["$" + name]) {
+ mark = mark.proto;
+ if (!mark) {
+ mark = this.type.defaults;
+ while (!mark["$" + name]) {
+ mark = mark.proto;
+ if (!mark) {
+ return null;
+ }
+ }
+ break;
+ }
+ }
+ property = name; // XXX
+ return mark["$" + name].apply(this, this.root.scene.data);
+};
+
+/**
+ * Updates the display, propagating property values computed in the build phase
+ * to the SVG image. This method is typically invoked by {@link #render}, but is
+ * also invoked after an event handler is triggered to update the display of a
+ * specific mark.
+ *
+ * @see #event
+ */
+pv.Mark.prototype.update = function() {
+ for (var i = 0; i < this.scene.length; i++) {
+ this.updateInstance(this.scene[i]);
+ }
+};
+
+/**
+ * Updates the display for the specified mark instance <tt>s</tt> in the scene
+ * graph. This implementation handles basic properties for all mark types, such
+ * as visibility, cursor and title tooltip. Concrete mark types should override
+ * this method to specify how marks are rendered.
+ *
+ * @param s a node in the scene graph; the instance of the mark to update.
+ */
+pv.Mark.prototype.updateInstance = function(s) {
+ var that = this, v = s.svg;
+
+ /* visible */
+ if (!s.visible) {
+ if (v) v.setAttribute("display", "none");
+ return;
+ }
+ v.removeAttribute("display");
+
+ /* cursor */
+ if (s.cursor) v.style.cursor = s.cursor;
+
+ /* title (Safari only supports xlink:title on anchor elements) */
+ var p = v.parentNode;
+ if (s.title) {
+ if (!v.$title) {
+ v.$title = document.createElementNS(pv.ns.svg, "a");
+ p.insertBefore(v.$title, v);
+ v.$title.appendChild(v);
+ }
+ v.$title.setAttributeNS(pv.ns.xlink, "title", s.title);
+ } else if (v.$title) {
+ p.insertBefore(v, v.$title);
+ p.removeChild(v.$title);
+ delete v.$title;
+ }
+
+ /* event */
+ function dispatch(type) {
+ return function(e) {
+ /* TODO set full scene stack. */
+ var data = [s.data], p = s;
+ while ((p = p.parent)) {
+ data.push(p.data);
+ }
+ that.index = s.index;
+ that.scene = s.parent.children[that.childIndex];
+ that.events[type].apply(that, data);
+ that.updateInstance(s); // XXX updateInstance, bah!
+ delete that.index;
+ delete that.scene;
+ e.preventDefault();
+ };
+ };
+
+ /* TODO inherit event handlers. */
+ if (!this.events)
+ return;
+ for (var type in this.events) {
+ v["on" + type] = dispatch(type);
+ }
+};
+
+/**
+ * Registers an event handler for the specified event type with this mark. When
+ * an event of the specified type is triggered, the specified handler will be
+ * invoked. The handler is invoked in a similar method to property functions:
+ * the context is <tt>this</tt> mark instance, and the arguments are the full
+ * data stack. Event handlers can use property methods to manipulate the display
+ * properties of the mark:
+ *
+ * <pre>m.event("click", function() this.fillStyle("red"));</pre>
+ *
+ * Alternatively, the external data can be manipulated and the visualization
+ * redrawn:
+ *
+ * <pre>m.event("click", function(d) {
+ * data = all.filter(function(k) k.name == d);
+ * vis.render();
+ * });</pre>
+ *
+ * TODO In the current event handler implementation, only the mark instance that
+ * triggered the event is updated, even if the event handler dirties the rest of
+ * the scene. While this can be ameliorated by explicitly re-rendering, it would
+ * be better and more efficient for the event dispatcher to handle dirtying and
+ * redraw automatically.
+ *
+ * <p>The complete set of event types is defined by SVG; see the reference
+ * below. The set of supported event types is:<ul>
+ *
+ * <li>click
+ * <li>mousedown
+ * <li>mouseup
+ * <li>mouseover
+ * <li>mousemove
+ * <li>mouseout
+ *
+ * </ul>Since Protovis does not specify any concept of focus, it does not
+ * support key events; these should be handled outside the visualization using
+ * standard JavaScript. In the future, support for interaction may be extended
+ * to support additional event types, particularly those most relevant to
+ * interactive visualization, such as selection.
+ *
+ * <p>TODO In the current implementation, event handlers are not inherited from
+ * prototype marks. They must be defined explicitly on each interactive mark. In
+ * addition, only one event handler for a given event type can be defined; when
+ * specifying multiple event handlers for the same type, only the last one will
+ * be used.
+ *
+ * @see <a href="http://www.w3.org/TR/SVGTiny12/interact.html#SVGEvents">SVG events</a>.
+ * @param {string} type the event type.
+ * @param {function} handler the event handler.
+ * @returns {pv.Mark} this.
+ */
+pv.Mark.prototype.event = function(type, handler) {
+ if (!this.events) this.events = {};
+ this.events[type] = handler;
+ return this;
+};
+/**
+ * Constructs a new area mark with default properties. Areas are not typically
+ * constructed directly, but by adding to a panel or an existing mark via
+ * {@link pv.Mark#add}.
+ *
+ * @class Represents an area mark: the solid area between two series of
+ * connected line segments. Unsurprisingly, areas are used most frequently for
+ * area charts.
+ *
+ * <p>Just as a line represents a polyline, the <tt>Area</tt> mark type
+ * represents a <i>polygon</i>. However, an area is not an arbitrary polygon;
+ * vertices are paired either horizontally or vertically into parallel
+ * <i>spans</i>, and each span corresponds to an associated datum. Either the
+ * width or the height must be specified, but not both; this determines whether
+ * the area is horizontally-oriented or vertically-oriented. Like lines, areas
+ * can be stroked and filled with arbitrary colors.
+ *
+ * <p>See also the <a href="../../api/Area.html">Area guide</a>.
+ *
+ * @extends pv.Mark
+ */
+pv.Area = function() {
+ pv.Mark.call(this);
+};
+pv.Area.prototype = pv.extend(pv.Mark);
+pv.Area.prototype.type = pv.Area;
+
+/**
+ * Returns "area".
+ *
+ * @returns {string} "area".
+ */
+pv.Area.toString = function() { return "area"; };
+
+/**
+ * The width of a given span, in pixels; used for horizontal spans. If the width
+ * is specified, the height property should be 0 (the default). Either the top
+ * or bottom property should be used to space the spans vertically, typically as
+ * a multiple of the index.
+ *
+ * @type number
+ * @name pv.Area.prototype.width
+ */
+pv.Area.prototype.defineProperty("width");
+
+/**
+ * The height of a given span, in pixels; used for vertical spans. If the height
+ * is specified, the width property should be 0 (the default). Either the left
+ * or right property should be used to space the spans horizontally, typically
+ * as a multiple of the index.
+ *
+ * @type number
+ * @name pv.Area.prototype.height
+ */
+pv.Area.prototype.defineProperty("height");
+
+/**
+ * The width of stroked lines, in pixels; used in conjunction with
+ * <tt>strokeStyle</tt> to stroke the perimeter of the area. Unlike the
+ * {@link Line} mark type, the entire perimeter is stroked, rather than just one
+ * edge. The default value of this property is 1.5, but since the default stroke
+ * style is null, area marks are not stroked by default.
+ *
+ * <p>This property is <i>fixed</i>. See {@link pv.Mark}.
+ *
+ * @type number
+ * @name pv.Area.prototype.lineWidth
+ */
+pv.Area.prototype.defineProperty("lineWidth");
+
+/**
+ * The style of stroked lines; used in conjunction with <tt>lineWidth</tt> to
+ * stroke the perimeter of the area. Unlike the {@link Line} mark type, the
+ * entire perimeter is stroked, rather than just one edge. The default value of
+ * this property is null, meaning areas are not stroked by default.
+ *
+ * <p>This property is <i>fixed</i>. See {@link pv.Mark}.
+ *
+ * @type string
+ * @name pv.Area.prototype.strokeStyle
+ * @see pv.color
+ */
+pv.Area.prototype.defineProperty("strokeStyle");
+
+/**
+ * The area fill style; if non-null, the interior of the polygon forming the
+ * area is filled with the specified color. The default value of this property
+ * is a categorical color.
+ *
+ * <p>This property is <i>fixed</i>. See {@link pv.Mark}.
+ *
+ * @type string
+ * @name pv.Area.prototype.fillStyle
+ * @see pv.color
+ */
+pv.Area.prototype.defineProperty("fillStyle");
+
+/**
+ * Default properties for areas. By default, there is no stroke and the fill
+ * style is a categorical color.
+ *
+ * @type pv.Area
+ */
+pv.Area.defaults = new pv.Area().extend(pv.Mark.defaults)
+ .lineWidth(1.5)
+ .fillStyle(pv.Colors.category20);
+
+/**
+ * Constructs a new area anchor with default properties.
+ *
+ * @class Represents an anchor for an area mark. Areas support five different
+ * anchors:<ul>
+ *
+ * <li>top
+ * <li>left
+ * <li>center
+ * <li>bottom
+ * <li>right
+ *
+ * </ul>In addition to positioning properties (left, right, top bottom), the
+ * anchors support text rendering properties (text-align, text-baseline). Text is
+ * rendered to appear inside the area polygon.
+ *
+ * <p>To facilitate stacking of areas, the anchors are defined in terms of their
+ * opposite edge. For example, the top anchor defines the bottom property, such
+ * that the area grows upwards; the bottom anchor instead defines the top
+ * property, such that the area grows downwards. Of course, in general it is
+ * more robust to use panels and the cousin accessor to define stacked area
+ * marks; see {@link pv.Mark#scene} for an example.
+ *
+ * @extends pv.Mark.Anchor
+ */
+pv.Area.Anchor = function() {
+ pv.Mark.Anchor.call(this);
+};
+pv.Area.Anchor.prototype = pv.extend(pv.Mark.Anchor);
+pv.Area.Anchor.prototype.type = pv.Area;
+
+/**
+ * The left property; null for "left" anchors, non-null otherwise.
+ *
+ * @type number
+ * @name pv.Area.Anchor.prototype.left
+ */ /** @private */
+pv.Area.Anchor.prototype.$left = function() {
+ var area = this.anchorTarget();
+ switch (this.get("name")) {
+ case "bottom":
+ case "top":
+ case "center": return area.left() + area.width() / 2;
+ case "right": return area.left() + area.width();
+ }
+ return null;
+};
+
+/**
+ * The right property; null for "right" anchors, non-null otherwise.
+ *
+ * @type number
+ * @name pv.Area.Anchor.prototype.right
+ */ /** @private */
+pv.Area.Anchor.prototype.$right = function() {
+ var area = this.anchorTarget();
+ switch (this.get("name")) {
+ case "bottom":
+ case "top":
+ case "center": return area.right() + area.width() / 2;
+ case "left": return area.right() + area.width();
+ }
+ return null;
+};
+
+/**
+ * The top property; null for "top" anchors, non-null otherwise.
+ *
+ * @type number
+ * @name pv.Area.Anchor.prototype.top
+ */ /** @private */
+pv.Area.Anchor.prototype.$top = function() {
+ var area = this.anchorTarget();
+ switch (this.get("name")) {
+ case "left":
+ case "right":
+ case "center": return area.top() + area.height() / 2;
+ case "bottom": return area.top() + area.height();
+ }
+ return null;
+};
+
+/**
+ * The bottom property; null for "bottom" anchors, non-null otherwise.
+ *
+ * @type number
+ * @name pv.Area.Anchor.prototype.bottom
+ */ /** @private */
+pv.Area.Anchor.prototype.$bottom = function() {
+ var area = this.anchorTarget();
+ switch (this.get("name")) {
+ case "left":
+ case "right":
+ case "center": return area.bottom() + area.height() / 2;
+ case "top": return area.bottom() + area.height();
+ }
+ return null;
+};
+
+/**
+ * The text-align property, for horizontal alignment inside the area.
+ *
+ * @type string
+ * @name pv.Area.Anchor.prototype.textAlign
+ */ /** @private */
+pv.Area.Anchor.prototype.$textAlign = function() {
+ switch (this.get("name")) {
+ case "left": return "left";
+ case "bottom":
+ case "top":
+ case "center": return "center";
+ case "right": return "right";
+ }
+ return null;
+};
+
+/**
+ * The text-baseline property, for vertical alignment inside the area.
+ *
+ * @type string
+ * @name pv.Area.Anchor.prototype.textBasline
+ */ /** @private */
+pv.Area.Anchor.prototype.$textBaseline = function() {
+ switch (this.get("name")) {
+ case "right":
+ case "left":
+ case "center": return "middle";
+ case "top": return "top";
+ case "bottom": return "bottom";
+ }
+ return null;
+};
+
+/**
+ * Overrides the default behavior of {@link pv.Mark#buildImplied} such that the
+ * width and height are set to zero if null.
+ *
+ * @param s a node in the scene graph; the instance of the mark to build.
+ */
+pv.Area.prototype.buildImplied = function(s) {
+ if (s.height == null) s.height = 0;
+ if (s.width == null) s.width = 0;
+ pv.Mark.prototype.buildImplied.call(this, s);
+};
+
+/**
+ * Override the default update implementation, since the area mark generates a
+ * single graphical element rather than multiple distinct elements.
+ */
+pv.Area.prototype.update = function() {
+ if (!this.scene.length) return;
+
+ var s = this.scene[0], v = s.svg;
+ if (s.visible) {
+
+ /* Create the <svg:polygon> element, if necessary. */
+ if (!v) {
+ v = s.svg = document.createElementNS(pv.ns.svg, "polygon");
+ s.parent.svg.appendChild(v);
+ }
+
+ /* points */
+ var p = "";
+ for (var i = 0; i < this.scene.length; i++) {
+ var si = this.scene[i];
+ p += si.left + "," + si.top + " ";
+ }
+ for (var i = this.scene.length - 1; i >= 0; i--) {
+ var si = this.scene[i];
+ p += (si.left + si.width) + "," + (si.top + si.height) + " ";
+ }
+ v.setAttribute("points", p);
+ }
+
+ this.updateInstance(s);
+};
+
+/**
+ * Updates the display for the (singleton) area instance. The area mark
+ * generates a single graphical element rather than multiple distinct elements.
+ *
+ * <p>TODO Recompute points? For efficiency, the points (the span positions) are
+ * not recomputed, and therefore cannot be updated automatically from event
+ * handlers without an explicit call to rebuild the area.
+ *
+ * @param s a node in the scene graph; the area to update.
+ */
+pv.Area.prototype.updateInstance = function(s) {
+ var v = s.svg;
+
+ pv.Mark.prototype.updateInstance.call(this, s);
+ if (!s.visible) return;
+
+ /* fill, stroke TODO gradient, patterns */
+ var fill = pv.color(s.fillStyle);
+ v.setAttribute("fill", fill.color);
+ v.setAttribute("fill-opacity", fill.opacity);
+ var stroke = pv.color(s.strokeStyle);
+ v.setAttribute("stroke", stroke.color);
+ v.setAttribute("stroke-opacity", stroke.opacity);
+ v.setAttribute("stroke-width", s.lineWidth);
+};
+/**
+ * Constructs a new bar mark with default properties. Bars are not typically
+ * constructed directly, but by adding to a panel or an existing mark via
+ * {@link pv.Mark#add}.
+ *
+ * @class Represents a bar: an axis-aligned rectangle that can be stroked and
+ * filled. Bars are used for many chart types, including bar charts, histograms
+ * and Gantt charts. Bars can also be used as decorations, for example to draw a
+ * frame border around a panel; in fact, a panel is a special type (a subclass)
+ * of bar.
+ *
+ * <p>Bars can be positioned in several ways. Most commonly, one of the four
+ * corners is fixed using two margins, and then the width and height properties
+ * determine the extent of the bar relative to this fixed location. For example,
+ * using the bottom and left properties fixes the bottom-left corner; the width
+ * then extends to the right, while the height extends to the top. As an
+ * alternative to the four corners, a bar can be positioned exclusively using
+ * margins; this is convenient as an inset from the containing panel, for
+ * example. See {@link pv.Mark#buildImplied} for details on the prioritization
+ * of redundant positioning properties.
+ *
+ * <p>See also the <a href="../../api/Bar.html">Bar guide</a>.
+ *
+ * @extends pv.Mark
+ */
+pv.Bar = function() {
+ pv.Mark.call(this);
+};
+pv.Bar.prototype = pv.extend(pv.Mark);
+pv.Bar.prototype.type = pv.Bar;
+
+/**
+ * Returns "bar".
+ *
+ * @returns {string} "bar".
+ */
+pv.Bar.toString = function() { return "bar"; };
+
+/**
+ * The width of the bar, in pixels. If the left position is specified, the bar
+ * extends rightward from the left edge; if the right position is specified, the
+ * bar extends leftward from the right edge.
+ *
+ * @type number
+ * @name pv.Bar.prototype.width
+ */
+pv.Bar.prototype.defineProperty("width");
+
+/**
+ * The height of the bar, in pixels. If the bottom position is specified, the
+ * bar extends upward from the bottom edge; if the top position is specified,
+ * the bar extends downward from the top edge.
+ *
+ * @type number
+ * @name pv.Bar.prototype.height
+ */
+pv.Bar.prototype.defineProperty("height");
+
+/**
+ * The width of stroked lines, in pixels; used in conjunction with
+ * <tt>strokeStyle</tt> to stroke the bar's border.
+ *
+ * @type number
+ * @name pv.Bar.prototype.lineWidth
+ */
+pv.Bar.prototype.defineProperty("lineWidth");
+
+/**
+ * The style of stroked lines; used in conjunction with <tt>lineWidth</tt> to
+ * stroke the bar's border. The default value of this property is null, meaning
+ * bars are not stroked by default.
+ *
+ * @type string
+ * @name pv.Bar.prototype.strokeStyle
+ * @see pv.color
+ */
+pv.Bar.prototype.defineProperty("strokeStyle");
+
+/**
+ * The bar fill style; if non-null, the interior of the bar is filled with the
+ * specified color. The default value of this property is a categorical color.
+ *
+ * @type string
+ * @name pv.Bar.prototype.fillStyle
+ * @see pv.color
+ */
+pv.Bar.prototype.defineProperty("fillStyle");
+
+/**
+ * Default properties for bars. By default, there is no stroke and the fill
+ * style is a categorical color.
+ *
+ * @type pv.Bar
+ */
+pv.Bar.defaults = new pv.Bar().extend(pv.Mark.defaults)
+ .lineWidth(1.5)
+ .fillStyle(pv.Colors.category20);
+
+/**
+ * Constructs a new bar anchor with default properties.
+ *
+ * @class Represents an anchor for a bar mark. Bars support five different
+ * anchors:<ul>
+ *
+ * <li>top
+ * <li>left
+ * <li>center
+ * <li>bottom
+ * <li>right
+ *
+ * </ul>In addition to positioning properties (left, right, top bottom), the
+ * anchors support text rendering properties (text-align, text-baseline). Text
+ * is rendered to appear inside the bar.
+ *
+ * <p>To facilitate stacking of bars, the anchors are defined in terms of their
+ * opposite edge. For example, the top anchor defines the bottom property, such
+ * that the bar grows upwards; the bottom anchor instead defines the top
+ * property, such that the bar grows downwards. Of course, in general it is more
+ * robust to use panels and the cousin accessor to define stacked bars; see
+ * {@link pv.Mark#scene} for an example.
+ *
+ * <p>Bar anchors also "smartly" specify position properties based on whether
+ * the derived mark type supports the width and height properties. If the
+ * derived mark type does not support these properties (e.g., dots), the
+ * position will be centered on the corresponding edge. Otherwise (e.g., bars),
+ * the position will be in the opposite side.
+ *
+ * @extends pv.Mark.Anchor
+ */
+pv.Bar.Anchor = function() {
+ pv.Mark.Anchor.call(this);
+};
+pv.Bar.Anchor.prototype = pv.extend(pv.Mark.Anchor);
+pv.Bar.Anchor.prototype.type = pv.Bar;
+
+/**
+ * The left property; null for "left" anchors, non-null otherwise.
+ *
+ * @type number
+ * @name pv.Bar.Anchor.prototype.left
+ */ /** @private */
+pv.Bar.Anchor.prototype.$left = function() {
+ var bar = this.anchorTarget();
+ switch (this.get("name")) {
+ case "bottom":
+ case "top":
+ case "center": return bar.left() + (this.type.prototype.width ? 0 : (bar.width() / 2));
+ case "right": return bar.left() + bar.width();
+ }
+ return null;
+};
+
+/**
+ * The right property; null for "right" anchors, non-null otherwise.
+ *
+ * @type number
+ * @name pv.Bar.Anchor.prototype.right
+ */ /** @private */
+pv.Bar.Anchor.prototype.$right = function() {
+ var bar = this.anchorTarget();
+ switch (this.get("name")) {
+ case "bottom":
+ case "top":
+ case "center": return bar.right() + (this.type.prototype.width ? 0 : (bar.width() / 2));
+ case "left": return bar.right() + bar.width();
+ }
+ return null;
+};
+
+/**
+ * The top property; null for "top" anchors, non-null otherwise.
+ *
+ * @type number
+ * @name pv.Bar.Anchor.prototype.top
+ */ /** @private */
+pv.Bar.Anchor.prototype.$top = function() {
+ var bar = this.anchorTarget();
+ switch (this.get("name")) {
+ case "left":
+ case "right":
+ case "center": return bar.top() + (this.type.prototype.height ? 0 : (bar.height() / 2));
+ case "bottom": return bar.top() + bar.height();
+ }
+ return null;
+};
+
+/**
+ * The bottom property; null for "bottom" anchors, non-null otherwise.
+ *
+ * @type number
+ * @name pv.Bar.Anchor.prototype.bottom
+ */ /** @private */
+pv.Bar.Anchor.prototype.$bottom = function() {
+ var bar = this.anchorTarget();
+ switch (this.get("name")) {
+ case "left":
+ case "right":
+ case "center": return bar.bottom() + (this.type.prototype.height ? 0 : (bar.height() / 2));
+ case "top": return bar.bottom() + bar.height();
+ }
+ return null;
+};
+
+/**
+ * The text-align property, for horizontal alignment inside the bar.
+ *
+ * @type string
+ * @name pv.Bar.Anchor.prototype.textAlign
+ */ /** @private */
+pv.Bar.Anchor.prototype.$textAlign = function() {
+ switch (this.get("name")) {
+ case "left": return "left";
+ case "bottom":
+ case "top":
+ case "center": return "center";
+ case "right": return "right";
+ }
+ return null;
+};
+
+/**
+ * The text-baseline property, for vertical alignment inside the bar.
+ *
+ * @type string
+ * @name pv.Bar.Anchor.prototype.textBaseline
+ */ /** @private */
+pv.Bar.Anchor.prototype.$textBaseline = function() {
+ switch (this.get("name")) {
+ case "right":
+ case "left":
+ case "center": return "middle";
+ case "top": return "top";
+ case "bottom": return "bottom";
+ }
+ return null;
+};
+
+/**
+ * Updates the display for the specified bar instance <tt>s</tt> in the scene
+ * graph. This implementation handles the fill and stroke style for the bar, as
+ * well as positional properties.
+ *
+ * @param s a node in the scene graph; the instance of the bar to update.
+ */
+pv.Bar.prototype.updateInstance = function(s) {
+ var v = s.svg;
+ if (s.visible && !v) {
+ v = s.svg = document.createElementNS(pv.ns.svg, "rect");
+ s.parent.svg.appendChild(v);
+ }
+
+ pv.Mark.prototype.updateInstance.call(this, s);
+ if (!s.visible) return;
+
+ /* left, top */
+ v.setAttribute("x", s.left);
+ v.setAttribute("y", s.top);
+
+ /* If width and height are exactly zero, the rect is not stroked! */
+ v.setAttribute("width", Math.max(1E-10, s.width));
+ v.setAttribute("height", Math.max(1E-10, s.height));
+
+ /* fill, stroke TODO gradient, patterns */
+ var fill = pv.color(s.fillStyle);
+ v.setAttribute("fill", fill.color);
+ v.setAttribute("fill-opacity", fill.opacity);
+ var stroke = pv.color(s.strokeStyle);
+ v.setAttribute("stroke", stroke.color);
+ v.setAttribute("stroke-opacity", stroke.opacity);
+ v.setAttribute("stroke-width", s.lineWidth);
+};
+/**
+ * Constructs a new dot mark with default properties. Dots are not typically
+ * constructed directly, but by adding to a panel or an existing mark via
+ * {@link pv.Mark#add}.
+ *
+ * @class Represents a dot; a dot is simply a sized glyph centered at a given
+ * point that can also be stroked and filled. The <tt>size</tt> property is
+ * proportional to the area of the rendered glyph to encourage meaningful visual
+ * encodings. Dots can visually encode up to eight dimensions of data, though
+ * this may be unwise due to integrality. See {@link pv.Mark#buildImplied} for
+ * details on the prioritization of redundant positioning properties.
+ *
+ * <p>See also the <a href="../../api/Dot.html">Dot guide</a>.
+ *
+ * @extends pv.Mark
+ */
+pv.Dot = function() {
+ pv.Mark.call(this);
+};
+pv.Dot.prototype = pv.extend(pv.Mark);
+pv.Dot.prototype.type = pv.Dot;
+
+/**
+ * Returns "dot".
+ *
+ * @returns {string} "dot".
+ */
+pv.Dot.toString = function() { return "dot"; };
+
+/**
+ * The size of the dot, in square pixels. Square pixels are used such that the
+ * area of the dot is linearly proportional to the value of the size property,
+ * facilitating representative encodings.
+ *
+ * @see #radius
+ * @type number
+ * @name pv.Dot.prototype.size
+ */
+pv.Dot.prototype.defineProperty("size");
+
+/**
+ * The shape name. Several shapes are supported:<ul>
+ *
+ * <li>cross
+ * <li>triangle
+ * <li>diamond
+ * <li>square
+ * <li>tick
+ * <li>circle
+ *
+ * </ul>These shapes can be further changed using the {@link #angle} property;
+ * for instance, a cross can be turned into a plus by rotating. Similarly, the
+ * tick, which is vertical by default, can be rotated horizontally. Note that
+ * some shapes (cross and tick) do not have interior areas, and thus do not
+ * support fill style meaningfully.
+ *
+ * <p>TODO It's probably better to use the Rule mark type rather than a
+ * tick-shaped Dot. However, the Rule mark doesn't support the width and height
+ * properties, so it's a bit clumsy to use. It should be possible to add support
+ * for width and height to rule, and then remove the tick shape.
+ *
+ * @type string
+ * @name pv.Dot.prototype.shape
+ */
+pv.Dot.prototype.defineProperty("shape");
+
+/**
+ * The rotation angle, in radians. Used to rotate shapes, such as to turn a
+ * cross into a plus.
+ *
+ * @type number
+ * @name pv.Dot.prototype.angle
+ */
+pv.Dot.prototype.defineProperty("angle");
+
+/**
+ * The width of stroked lines, in pixels; used in conjunction with
+ * <tt>strokeStyle</tt> to stroke the dot's shape.
+ *
+ * @type number
+ * @name pv.Dot.prototype.lineWidth
+ */
+pv.Dot.prototype.defineProperty("lineWidth");
+
+/**
+ * The style of stroked lines; used in conjunction with <tt>lineWidth</tt> to
+ * stroke the dot's shape. The default value of this property is a categorical
+ * color.
+ *
+ * @type string
+ * @name pv.Dot.prototype.strokeStyle
+ * @see pv.color
+ */
+pv.Dot.prototype.defineProperty("strokeStyle");
+
+/**
+ * The fill style; if non-null, the interior of the dot is filled with the
+ * specified color. The default value of this property is null, meaning dots are
+ * not filled by default.
+ *
+ * @type string
+ * @name pv.Dot.prototype.fillStyle
+ * @see pv.color
+ */
+pv.Dot.prototype.defineProperty("fillStyle");
+
+/**
+ * Default properties for dots. By default, there is no fill and the stroke
+ * style is a categorical color. The default shape is "circle" with size 20.
+ *
+ * @type pv.Dot
+ */
+pv.Dot.defaults = new pv.Dot().extend(pv.Mark.defaults)
+ .size(20)
+ .shape("circle")
+ .lineWidth(1.5)
+ .strokeStyle(pv.Colors.category10);
+
+/**
+ * Constructs a new dot anchor with default properties.
+ *
+ * @class Represents an anchor for a dot mark. Dots support five different
+ * anchors:<ul>
+ *
+ * <li>top
+ * <li>left
+ * <li>center
+ * <li>bottom
+ * <li>right
+ *
+ * </ul>In addition to positioning properties (left, right, top bottom), the
+ * anchors support text rendering properties (text-align, text-baseline). Text is
+ * rendered to appear outside the dot. Note that this behavior is different from
+ * other mark anchors, which default to rendering text <i>inside</i> the mark.
+ *
+ * <p>For consistency with the other mark types, the anchor positions are
+ * defined in terms of their opposite edge. For example, the top anchor defines
+ * the bottom property, such that a bar added to the top anchor grows upward.
+ *
+ * @extends pv.Mark.Anchor
+ */
+pv.Dot.Anchor = function() {
+ pv.Mark.Anchor.call(this);
+};
+pv.Dot.Anchor.prototype = pv.extend(pv.Mark.Anchor);
+pv.Dot.Anchor.prototype.type = pv.Dot;
+
+/**
+ * The left property; null for "left" anchors, non-null otherwise.
+ *
+ * @type number
+ * @name pv.Dot.Anchor.prototype.left
+ */ /** @private */
+pv.Dot.Anchor.prototype.$left = function(d) {
+ var dot = this.anchorTarget();
+ switch (this.get("name")) {
+ case "bottom":
+ case "top":
+ case "center": return dot.left();
+ case "right": return dot.left() + dot.radius();
+ }
+ return null;
+};
+
+/**
+ * The right property; null for "right" anchors, non-null otherwise.
+ *
+ * @type number
+ * @name pv.Dot.Anchor.prototype.right
+ */ /** @private */
+pv.Dot.Anchor.prototype.$right = function(d) {
+ var dot = this.anchorTarget();
+ switch (this.get("name")) {
+ case "bottom":
+ case "top":
+ case "center": return dot.right();
+ case "left": return dot.right() + dot.radius();
+ }
+ return null;
+};
+
+/**
+ * The top property; null for "top" anchors, non-null otherwise.
+ *
+ * @type number
+ * @name pv.Dot.Anchor.prototype.top
+ */ /** @private */
+pv.Dot.Anchor.prototype.$top = function(d) {
+ var dot = this.anchorTarget();
+ switch (this.get("name")) {
+ case "left":
+ case "right":
+ case "center": return dot.top();
+ case "bottom": return dot.top() + dot.radius();
+ }
+ return null;
+};
+
+/**
+ * The bottom property; null for "bottom" anchors, non-null otherwise.
+ *
+ * @type number
+ * @name pv.Dot.Anchor.prototype.bottom
+ */ /** @private */
+pv.Dot.Anchor.prototype.$bottom = function(d) {
+ var dot = this.anchorTarget();
+ switch (this.get("name")) {
+ case "left":
+ case "right":
+ case "center": return dot.bottom();
+ case "top": return dot.bottom() + dot.radius();
+ }
+ return null;
+};
+
+/**
+ * The text-align property, for horizontal alignment outside the dot.
+ *
+ * @type string
+ * @name pv.Dot.Anchor.prototype.textAlign
+ */ /** @private */
+pv.Dot.Anchor.prototype.$textAlign = function(d) {
+ switch (this.get("name")) {
+ case "left": return "right";
+ case "bottom":
+ case "top":
+ case "center": return "center";
+ case "right": return "left";
+ }
+ return null;
+};
+
+/**
+ * The text-baseline property, for vertical alignment outside the dot.
+ *
+ * @type string
+ * @name pv.Dot.Anchor.prototype.textBasline
+ */ /** @private */
+pv.Dot.Anchor.prototype.$textBaseline = function(d) {
+ switch (this.get("name")) {
+ case "right":
+ case "left":
+ case "center": return "middle";
+ case "top": return "bottom";
+ case "bottom": return "top";
+ }
+ return null;
+};
+
+/**
+ * Returns the radius of the dot, which is defined to be the square root of the
+ * {@link #size} property.
+ *
+ * @returns {number} the radius.
+ */
+pv.Dot.prototype.radius = function() {
+ return Math.sqrt(this.size());
+};
+
+/**
+ * Updates the display for the specified dot instance <tt>s</tt> in the scene
+ * graph. This implementation handles the fill and stroke style for the dot, as
+ * well as positional properties.
+ *
+ * @param s a node in the scene graph; the instance of the dot to update.
+ */
+pv.Dot.prototype.updateInstance = function(s) {
+ var v = s.svg;
+
+ /* Create the <svg:path> element, if necessary. */
+ if (s.visible && !v) {
+ v = s.svg = document.createElementNS(pv.ns.svg, "path");
+ s.parent.svg.appendChild(v);
+ }
+
+ /* visible, cursor, title, event, etc. */
+ pv.Mark.prototype.updateInstance.call(this, s);
+ if (!s.visible) return;
+
+ /* left, top */
+ v.setAttribute("transform", "translate(" + s.left + "," + s.top +")"
+ + (s.angle ? " rotate(" + 180 * s.angle / Math.PI + ")" : ""));
+
+ /* fill, stroke TODO gradient, patterns? */
+ var fill = pv.color(s.fillStyle);
+ v.setAttribute("fill", fill.color);
+ v.setAttribute("fill-opacity", fill.opacity);
+ var stroke = pv.color(s.strokeStyle);
+ v.setAttribute("stroke", stroke.color);
+ v.setAttribute("stroke-opacity", stroke.opacity);
+ v.setAttribute("stroke-width", s.lineWidth);
+
+ /* shape, size */
+ var radius = Math.sqrt(s.size);
+ var d;
+ switch (s.shape) {
+ case "cross": {
+ d = "M" + -radius + "," + -radius
+ + "L" + radius + "," + radius
+ + "M" + radius + "," + -radius
+ + "L" + -radius + "," + radius;
+ break;
+ }
+ case "triangle": {
+ var h = radius, w = radius * 2 / Math.sqrt(3);
+ d = "M0," + h
+ + "L" + w +"," + -h
+ + " " + -w + "," + -h
+ + "Z";
+ break;
+ }
+ case "diamond": {
+ radius *= Math.sqrt(2);
+ d = "M0," + -radius
+ + "L" + radius + ",0"
+ + " 0," + radius
+ + " " + -radius + ",0"
+ + "Z";
+ break;
+ }
+ case "square": {
+ d = "M" + -radius + "," + -radius
+ + "L" + radius + "," + -radius
+ + " " + radius + "," + radius
+ + " " + -radius + "," + radius
+ + "Z";
+ break;
+ }
+ case "tick": {
+ d = "M0,0L0," + -s.size;
+ break;
+ }
+ default: { // circle
+ d = "M0," + radius
+ + "A" + radius + "," + radius + " 0 1,1 0," + (-radius)
+ + "A" + radius + "," + radius + " 0 1,1 0," + radius
+ + "Z";
+ break;
+ }
+ }
+ v.setAttribute("d", d);
+};
+/**
+ * Constructs a new dot mark with default properties. Images are not typically
+ * constructed directly, but by adding to a panel or an existing mark via
+ * {@link pv.Mark#add}.
+ *
+ * @class Represents an image. Images share the same layout and style properties as
+ * bars, in conjunction with an external image such as PNG or JPEG. The image is
+ * specified via the {@link #url} property. The fill, if specified, appears
+ * beneath the image, while the optional stroke appears above the image.
+ *
+ * <p>TODO Restore support for dynamic images (such as heatmaps). These were
+ * supported in the canvas implementation using the pixel buffer API; although
+ * SVG does not support pixel manipulation, it is possible to embed a canvas
+ * element in SVG using foreign objects.
+ *
+ * <p>TODO Allow different modes of image placement: "scale" -- scale and
+ * preserve aspect ratio, "tile" -- repeat the image, "center" -- center the
+ * image, "fill" -- scale without preserving aspect ratio.
+ *
+ * <p>See {@link pv.Bar} for details on positioning properties.
+ *
+ * @extends pv.Bar
+ */
+pv.Image = function() {
+ pv.Bar.call(this);
+};
+pv.Image.prototype = pv.extend(pv.Bar);
+pv.Image.prototype.type = pv.Image;
+
+/**
+ * Returns "image".
+ *
+ * @returns {string} "image".
+ */
+pv.Image.toString = function() { return "image"; };
+
+/**
+ * The URL of the image to display. The set of supported image types is
+ * browser-dependent; PNG and JPEG are recommended.
+ *
+ * @type string
+ * @name pv.Image.prototype.url
+ */
+pv.Image.prototype.defineProperty("url");
+
+/**
+ * Default properties for images. By default, there is no stroke or fill style.
+ *
+ * @type pv.Image
+ */
+pv.Image.defaults = new pv.Image().extend(pv.Bar.defaults)
+ .fillStyle(null);
+
+/**
+ * Updates the display for the specified image instance <tt>s</tt> in the scene
+ * graph. This implementation handles the fill and stroke style for the image,
+ * as well as positional properties.
+ *
+ * <p>Image rendering is a bit more complicated than most marks because it can
+ * entail up to four SVG elements: three for the fill, image and stroke, and the
+ * fourth an anchor element for the title tooltip. The anchor element is placed
+ * around the stroke rect element, if present, and otherwise the image element.
+ * Similarly the event handlers and cursor style is placed on the stroke
+ * element, if present, and otherwise the image element. Note that since the
+ * stroke element is transparent, the <tt>pointer-events</tt> attribute is used
+ * to capture events.
+ *
+ * @param s a node in the scene graph; the instance of the image to update.
+ */
+pv.Image.prototype.updateInstance = function(s) {
+ var v = s.svg;
+
+ /* Create the svg:image element, if necessary. */
+ if (s.visible && !v) {
+ v = s.svg = document.createElementNS(pv.ns.svg, "image");
+ v.setAttribute("preserveAspectRatio", "none");
+ s.parent.svg.appendChild(v);
+ }
+
+ /*
+ * If no stroke is specified, then the event handlers and title anchor element
+ * can be placed on the image element. However, if there was previously a
+ * title anchor element around the stroke element, we must be careful to
+ * remove it. This logic could likely be simplified.
+ */
+ if (!s.strokeStyle) {
+ if (v.$stroke) {
+ v.parentNode.removeChild(v.$stroke.$title || v.$stroke);
+ delete v.$stroke;
+ }
+
+ /* cursor, title, events, etc. */
+ pv.Mark.prototype.updateInstance.call(this, s);
+ }
+
+ /* visible */
+ function display(v) {
+ s.visible ? v.removeAttribute("display") : v.setAttribute("display", "none");
+ }
+ if (v) {
+ display(v);
+ if (v.$stroke) display(v.$stroke);
+ if (v.$fill) display(v.$fill);
+ }
+ if (!s.visible) return;
+
+ /* left, top, width, height */
+ function position(v) {
+ v.setAttribute("x", s.left);
+ v.setAttribute("y", s.top);
+ v.setAttribute("width", s.width);
+ v.setAttribute("height", s.height);
+ }
+ position(v);
+
+ /* fill (via an underlaid svg:rect element) */
+ if (s.fillStyle) {
+ var f = v.$fill;
+ if (!f) {
+ f = v.$fill = document.createElementNS(pv.ns.svg, "rect");
+ (v.$title || v).parentNode.insertBefore(f, (v.$title || v));
+ }
+ position(f);
+ var fill = pv.color(s.fillStyle);
+ f.setAttribute("fill", fill.color);
+ f.setAttribute("fill-opacity", fill.opacity);
+ } else if (v.$fill) {
+ v.$fill.parentNode.removeChild(v.$fill);
+ delete v.$fill;
+ }
+
+ /* stroke (via an overlaid svg:rect element) */
+ if (s.strokeStyle) {
+ var f = v.$stroke;
+
+ /*
+ * If the $title attribute is set, that means the title anchor element was
+ * previously on the image element; now that the stroke style is set, we
+ * must delete the old title element to make room for the new one.
+ */
+ if (v.$title) {
+ var p = v.$title.parentNode;
+ p.insertBefore(v, v.$title);
+ p.removeChild(v.$title);
+ delete v.$title;
+ }
+
+ /* Create the stroke svg:rect element, if necessary. */
+ if (!f) {
+ f = v.$stroke = document.createElementNS(pv.ns.svg, "rect");
+ f.setAttribute("fill", "none");
+ f.setAttribute("pointer-events", "all");
+ v.parentNode.insertBefore(f, v.nextElementSibling);
+ }
+ position(f);
+ var stroke = pv.color(s.strokeStyle);
+ f.setAttribute("stroke", stroke.color);
+ f.setAttribute("stroke-opacity", stroke.opacity);
+ f.setAttribute("stroke-width", s.lineWidth);
+
+ /* cursor, title, events, etc. */
+ try {
+ s.svg = f;
+ pv.Mark.prototype.updateInstance.call(this, s);
+ } finally {
+ s.svg = v;
+ }
+ }
+
+ /* url */
+ v.setAttributeNS(pv.ns.xlink, "href", s.url);
+};
+/**
+ * Constructs a new label mark with default properties. Labels are not typically
+ * constructed directly, but by adding to a panel or an existing mark via
+ * {@link pv.Mark#add}.
+ *
+ * @class Represents a text label, allowing textual annotation of other marks or
+ * arbitrary text within the visualization. The character data must be plain
+ * text (unicode), though the text can be styled using the {@link #font}
+ * property. If rich text is needed, external HTML elements can be overlaid on
+ * the canvas by hand.
+ *
+ * <p>Labels are positioned using the box model, similarly to {@link Dot}. Thus,
+ * a label has no width or height, but merely a text anchor location. The text
+ * is positioned relative to this anchor location based on the
+ * {@link #textAlign}, {@link #textBaseline} and {@link #textMargin} properties.
+ * Furthermore, the text may be rotated using {@link #textAngle}.
+ *
+ * <p>Labels ignore events, so as to not interfere with event handlers on
+ * underlying marks, such as bars. In the future, we may support event handlers
+ * on labels.
+ *
+ * <p>See also the <a href="../../api/Label.html">Label guide</a>.
+ *
+ * @extends pv.Mark
+ */
+pv.Label = function() {
+ pv.Mark.call(this);
+};
+pv.Label.prototype = pv.extend(pv.Mark);
+pv.Label.prototype.type = pv.Label;
+
+/**
+ * Returns "label".
+ *
+ * @returns {string} "label".
+ */
+pv.Label.toString = function() { return "label"; };
+
+/**
+ * The character data to render; a string. The default value of the text
+ * property is the identity function, meaning the label's associated datum will
+ * be rendered using its <tt>toString</tt>.
+ *
+ * @type string
+ * @name pv.Label.prototype.text
+ */
+pv.Label.prototype.defineProperty("text");
+
+/**
+ * The font format, per the CSS Level 2 specification. The default font is "10px
+ * sans-serif", for consistency with the HTML 5 canvas element specification.
+ * Note that since text is not wrapped, any line-height property will be
+ * ignored. The other font-style, font-variant, font-weight, font-size and
+ * font-family properties are supported.
+ *
+ * @see <a href="http://www.w3.org/TR/CSS2/fonts.html#font-shorthand">CSS2 fonts</a>.
+ * @type string
+ * @name pv.Label.prototype.font
+ */
+pv.Label.prototype.defineProperty("font");
+
+/**
+ * The rotation angle, in radians. Text is rotated clockwise relative to the
+ * anchor location. For example, with the default left alignment, an angle of
+ * Math.PI / 2 causes text to proceed downwards. The default angle is zero.
+ *
+ * @type number
+ * @name pv.Label.prototype.textAngle
+ */
+pv.Label.prototype.defineProperty("textAngle");
+
+/**
+ * The text color. The name "textStyle" is used for consistency with "fillStyle"
+ * and "strokeStyle", although it might be better to rename this property (and
+ * perhaps use the same name as "strokeStyle"). The default color is black.
+ *
+ * @type string
+ * @name pv.Label.prototype.textStyle
+ * @see pv.color
+ */
+pv.Label.prototype.defineProperty("textStyle");
+
+/**
+ * The horizontal text alignment. One of:<ul>
+ *
+ * <li>left
+ * <li>center
+ * <li>right
+ *
+ * </ul>The default horizontal alignment is left.
+ *
+ * @type string
+ * @name pv.Label.prototype.textAlign
+ */
+pv.Label.prototype.defineProperty("textAlign");
+
+/**
+ * The vertical text alignment. One of:<ul>
+ *
+ * <li>top
+ * <li>middle
+ * <li>bottom
+ *
+ * </ul>The default vertical alignment is bottom.
+ *
+ * @type string
+ * @name pv.Label.prototype.textBaseline
+ */
+pv.Label.prototype.defineProperty("textBaseline");
+
+/**
+ * The text margin; may be specified in pixels, or in font-dependent units
+ * (e.g., ".1ex"). The margin can be used to pad text away from its anchor
+ * location, in a direction dependent on the horizontal and vertical alignment
+ * properties. For example, if the text is left- and middle-aligned, the margin
+ * shifts the text to the right. The default margin is 3 pixels.
+ *
+ * @type number
+ * @name pv.Label.prototype.textMargin
+ */
+pv.Label.prototype.defineProperty("textMargin");
+
+/**
+ * A list of shadow effects to be applied to text, per the CSS Text Level 3
+ * text-shadow property. An example specification is "0.1em 0.1em 0.1em
+ * rgba(0,0,0,.5)"; the first length is the horizontal offset, the second the
+ * vertical offset, and the third the blur radius.
+ *
+ * @see <a href="http://www.w3.org/TR/css3-text/#text-shadow">CSS3 text</a>.
+ * @type string
+ * @name pv.Label.prototype.textShadow
+ */
+pv.Label.prototype.defineProperty("textShadow");
+
+/**
+ * Default properties for labels. See the individual properties for the default
+ * values.
+ *
+ * @type pv.Label
+ */
+pv.Label.defaults = new pv.Label().extend(pv.Mark.defaults)
+ .text(pv.identity)
+ .font("10px sans-serif")
+ .textAngle(0)
+ .textStyle("black")
+ .textAlign("left")
+ .textBaseline("bottom")
+ .textMargin(3);
+
+/**
+ * Updates the display for the specified label instance <tt>s</tt> in the scene
+ * graph. This implementation handles the text formatting for the label, as well
+ * as positional properties.
+ *
+ * @param s a node in the scene graph; the instance of the dot to update.
+ */
+pv.Label.prototype.updateInstance = function(s) {
+ var v = s.svg;
+
+ /* Create the svg:text element, if necessary. */
+ if (s.visible && !v) {
+ v = s.svg = document.createElementNS(pv.ns.svg, "text");
+ v.$text = document.createTextNode("");
+ v.appendChild(v.$text);
+ s.parent.svg.appendChild(v);
+ }
+
+ /* cursor, title, events, visible, etc. */
+ pv.Mark.prototype.updateInstance.call(this, s);
+ if (!s.visible) return;
+
+ /* left, top, angle */
+ v.setAttribute("transform", "translate(" + s.left + "," + s.top + ")"
+ + (s.textAngle ? " rotate(" + 180 * s.textAngle / Math.PI + ")" : ""));
+
+ /* text-baseline */
+ switch (s.textBaseline) {
+ case "middle": {
+ v.removeAttribute("y");
+ v.setAttribute("dy", ".35em");
+ break;
+ }
+ case "top": {
+ v.setAttribute("y", s.textMargin);
+ v.setAttribute("dy", ".71em");
+ break;
+ }
+ case "bottom": {
+ v.setAttribute("y", "-" + s.textMargin);
+ v.removeAttribute("dy");
+ break;
+ }
+ }
+
+ /* text-align */
+ switch (s.textAlign) {
+ case "right": {
+ v.setAttribute("text-anchor", "end");
+ v.setAttribute("x", "-" + s.textMargin);
+ break;
+ }
+ case "center": {
+ v.setAttribute("text-anchor", "middle");
+ v.removeAttribute("x");
+ break;
+ }
+ case "left": {
+ v.setAttribute("text-anchor", "start");
+ v.setAttribute("x", s.textMargin);
+ break;
+ }
+ }
+
+ /* font, text-shadow TODO centralize font definition? */
+ v.$text.nodeValue = s.text;
+ var style = "font:" + s.font + ";";
+ if (s.textShadow) {
+ style += "text-shadow:" + s.textShadow +";";
+ }
+ v.setAttribute("style", style);
+
+ /* fill */
+ var fill = pv.color(s.textStyle);
+ v.setAttribute("fill", fill.color);
+ v.setAttribute("fill-opacity", fill.opacity);
+
+ /* TODO enable interaction on labels? centralize this definition? */
+ v.setAttribute("pointer-events", "none");
+};
+/**
+ * Constructs a new line mark with default properties. Lines are not typically
+ * constructed directly, but by adding to a panel or an existing mark via
+ * {@link pv.Mark#add}.
+ *
+ * @class Represents a series of connected line segments, or <i>polyline</i>,
+ * that can be stroked with a configurable color and thickness. Each
+ * articulation point in the line corresponds to a datum; for <i>n</i> points,
+ * <i>n</i>-1 connected line segments are drawn. The point is positioned using
+ * the box model. Arbitrary paths are also possible, allowing radar plots and
+ * other custom visualizations.
+ *
+ * <p>Like areas, lines can be stroked and filled with arbitrary colors. In most
+ * cases, lines are only stroked, but the fill style can be used to construct
+ * arbitrary polygons.
+ *
+ * <p>See also the <a href="../../api/Line.html">Line guide</a>.
+ *
+ * @extends pv.Mark
+ */
+pv.Line = function() {
+ pv.Mark.call(this);
+};
+pv.Line.prototype = pv.extend(pv.Mark);
+pv.Line.prototype.type = pv.Line;
+
+/**
+ * Returns "line".
+ *
+ * @returns {string} "line".
+ */
+pv.Line.toString = function() { return "line"; };
+
+/**
+ * The width of stroked lines, in pixels; used in conjunction with
+ * <tt>strokeStyle</tt> to stroke the line.
+ *
+ * @type number
+ * @name pv.Line.prototype.lineWidth
+ */
+pv.Line.prototype.defineProperty("lineWidth");
+
+/**
+ * The style of stroked lines; used in conjunction with <tt>lineWidth</tt> to
+ * stroke the line. The default value of this property is a categorical color.
+ *
+ * @type string
+ * @name pv.Line.prototype.strokeStyle
+ * @see pv.color
+ */
+pv.Line.prototype.defineProperty("strokeStyle");
+
+/**
+ * The line fill style; if non-null, the interior of the line is closed and
+ * filled with the specified color. The default value of this property is a
+ * null, meaning that lines are not filled by default.
+ *
+ * @type string
+ * @name pv.Line.prototype.fillStyle
+ * @see pv.color
+ */
+pv.Line.prototype.defineProperty("fillStyle");
+
+/**
+ * Default properties for lines. By default, there is no fill and the stroke
+ * style is a categorical color.
+ *
+ * @type pv.Line
+ */
+pv.Line.defaults = new pv.Line().extend(pv.Mark.defaults)
+ .lineWidth(1.5)
+ .strokeStyle(pv.Colors.category10);
+
+/**
+ * Override the default update implementation, since the line mark generates a
+ * single graphical element rather than multiple distinct elements.
+ */
+pv.Line.prototype.update = function() {
+ if (!this.scene.length) return;
+
+ /* visible */
+ var s = this.scene[0], v = s.svg;
+ if (s.visible) {
+
+ /* Create the svg:polyline element, if necessary. */
+ if (!v) {
+ v = s.svg = document.createElementNS(pv.ns.svg, "polyline");
+ s.parent.svg.appendChild(v);
+ }
+
+ /* left, top TODO allow points to be changed on events? */
+ var p = "";
+ for (var i = 0; i < this.scene.length; i++) {
+ var si = this.scene[i];
+ if (isNaN(si.left)) si.left = 0;
+ if (isNaN(si.top)) si.top = 0;
+ p += si.left + "," + si.top + " ";
+ }
+ v.setAttribute("points", p);
+
+ /* cursor, title, events, etc. */
+ this.updateInstance(s);
+ v.removeAttribute("display");
+ } else if (v) {
+ v.setAttribute("display", "none");
+ }
+};
+
+/**
+ * Updates the display for the (singleton) line instance. The line mark
+ * generates a single graphical element rather than multiple distinct elements.
+ *
+ * <p>TODO Recompute points? For efficiency, the points are not recomputed, and
+ * therefore cannot be updated automatically from event handlers without an
+ * explicit call to rebuild the line.
+ *
+ * @param s a node in the scene graph; the instance of the mark to update.
+ */
+pv.Line.prototype.updateInstance = function(s) {
+ var v = s.svg;
+
+ pv.Mark.prototype.updateInstance.call(this, s);
+ if (!s.visible) return;
+
+ /* fill, stroke TODO gradient, patterns */
+ var fill = pv.color(s.fillStyle);
+ v.setAttribute("fill", fill.color);
+ v.setAttribute("fill-opacity", fill.opacity);
+ var stroke = pv.color(s.strokeStyle);
+ v.setAttribute("stroke", stroke.color);
+ v.setAttribute("stroke-opacity", stroke.opacity);
+ v.setAttribute("stroke-width", s.lineWidth);
+};
+/**
+ * Constructs a new, empty panel with default properties. Panels, with the
+ * exception of the root panel, are not typically constructed directly; instead,
+ * they are added to an existing panel or mark via {@link pv.Mark#add}.
+ *
+ * @class Represents a container mark. Panels allow repeated or nested
+ * structures, commonly used in small multiple displays where a small
+ * visualization is tiled to facilitate comparison across one or more
+ * dimensions. Other types of visualizations may benefit from repeated and
+ * possibly overlapping structure as well, such as stacked area charts. Panels
+ * can also offset the position of marks to provide padding from surrounding
+ * content.
+ *
+ * <p>All Protovis displays have at least one panel; this is the root panel to
+ * which marks are rendered. The box model properties (four margins, width and
+ * height) are used to offset the positions of contained marks. The data
+ * property determines the panel count: a panel is generated once per associated
+ * datum. When nested panels are used, property functions can declare additional
+ * arguments to access the data associated with enclosing panels.
+ *
+ * <p>Panels can be rendered inline, facilitating the creation of sparklines.
+ * This allows designers to reuse browser layout features, such as text flow and
+ * tables; designers can also overlay HTML elements such as rich text and
+ * images.
+ *
+ * <p>All panels have a <tt>children</tt> array (possibly empty) containing the
+ * child marks in the order they were added. Panels also have a <tt>root</tt>
+ * field which points to the root (outermost) panel; the root panel's root field
+ * points to itself.
+ *
+ * <p>See also the <a href="../../api/">Protovis guide</a>.
+ *
+ * @extends pv.Bar
+ */
+pv.Panel = function() {
+ pv.Bar.call(this);
+
+ /**
+ * The child marks; zero or more {@link pv.Mark}s in the order they were
+ * added.
+ *
+ * @see #add
+ * @type pv.Mark[]
+ */
+ this.children = [];
+ this.root = this;
+
+ /**
+ * The internal $dom field is set by the Protovis loader; see lang/init.js. It
+ * refers to the script element that contains the Protovis specification, so
+ * that the panel knows where in the DOM to insert the generated SVG element.
+ *
+ * @private
+ */
+ this.$dom = pv.Panel.$dom;
+};
+pv.Panel.prototype = pv.extend(pv.Bar);
+pv.Panel.prototype.type = pv.Panel;
+
+/**
+ * Returns "panel".
+ *
+ * @returns {string} "panel".
+ */
+pv.Panel.toString = function() { return "panel"; };
+
+/**
+ * The canvas element; either the string ID of the canvas element in the current
+ * document, or a reference to the canvas element itself. If null, a canvas
+ * element will be created and inserted into the document at the location of the
+ * script element containing the current Protovis specification. This property
+ * only applies to root panels and is ignored on nested panels.
+ *
+ * <p>Note: the "canvas" element here refers to a <tt>div</tt> (or other suitable
+ * HTML container element), <i>not</i> a <tt>canvas</tt> element. The name of
+ * this property is a historical anachronism from the first implementation that
+ * used HTML 5 canvas, rather than SVG.
+ *
+ * @type string
+ * @name pv.Panel.prototype.canvas
+ */
+pv.Panel.prototype.defineProperty("canvas");
+
+/**
+ * The reverse property; a boolean determining whether child marks are ordered
+ * from front-to-back or back-to-front. SVG does not support explicit
+ * z-ordering; shapes are rendered in the order they appear. Thus, by default,
+ * child marks are rendered in the order they are added to the panel. Setting
+ * the reverse property to false reverses the order in which they are added to
+ * the SVG element; however, the properties are still evaluated (i.e., built) in
+ * forward order.
+ *
+ * @type boolean
+ * @name pv.Panel.prototype.reverse
+ */
+pv.Panel.prototype.defineProperty("reverse");
+
+/**
+ * Default properties for panels. By default, the margins are zero, the fill
+ * style is transparent, and the reverse property is false.
+ *
+ * @type pv.Panel
+ */
+pv.Panel.defaults = new pv.Panel().extend(pv.Bar.defaults)
+ .top(0).left(0).bottom(0).right(0)
+ .fillStyle(null)
+ .reverse(false);
+
+/**
+ * Adds a new mark of the specified type to this panel. Unlike the normal
+ * {@link Mark#add} behavior, adding a mark to a panel does not cause the mark
+ * to inherit from the panel. Since the contained marks are offset by the panel
+ * margins already, inheriting properties is generally undesirable; of course,
+ * it is always possible to change this behavior by calling {@link Mark#extend}
+ * explicitly.
+ *
+ * @param {function} type the type of the new mark to add.
+ * @returns {pv.Mark} the new mark.
+ */
+pv.Panel.prototype.add = function(type) {
+ var child = new type();
+ child.parent = this;
+ child.root = this.root;
+ child.childIndex = this.children.length;
+ this.children.push(child);
+ return child;
+};
+
+/**
+ * Creates a new canvas (SVG) element with the specified width and height, and
+ * inserts it into the current document. If the <tt>$dom</tt> field is set, as
+ * for text/javascript+protovis scripts, the SVG element is inserted into the
+ * DOM before the script element. Otherwise, the SVG element is inserted into
+ * the last child element of the document, as for text/javascript scripts.
+ *
+ * @param w the width of the canvas to create, in pixels.
+ * @param h the height of the canvas to create, in pixels.
+ * @return the new canvas (SVG) element.
+ */
+pv.Panel.prototype.createCanvas = function(w, h) {
+
+ /**
+ * Returns the last element in the current document's body. The canvas element
+ * is appended to this last element if another DOM element has not already
+ * been specified via the <tt>$dom</tt> field.
+ */
+ function lastElement() {
+ var node = document.body;
+ while (node.lastElementChild && node.lastElementChild.tagName) {
+ node = node.lastElementChild;
+ }
+ return (node == document.body) ? node : node.parentNode;
+ }
+
+ /* Create the SVG element. */
+ var c = document.createElementNS(pv.ns.svg, "svg");
+ c.setAttribute("width", w);
+ c.setAttribute("height", h);
+
+ /* Insert it into the DOM at the appropriate location. */
+ this.$dom // script element for text/javascript+protovis
+ ? this.$dom.parentNode.insertBefore(c, this.$dom)
+ : lastElement().appendChild(c);
+
+ return c;
+};
+
+/**
+ * Evaluates all of the properties for this panel for the specified instance
+ * <tt>s</tt> in the scene graph, including recursively building the scene graph
+ * for child marks.
+ *
+ * @param s a node in the scene graph; the instance of the panel to build.
+ * @see Mark#scene
+ */
+pv.Panel.prototype.buildInstance = function(s) {
+ pv.Bar.prototype.buildInstance.call(this, s);
+
+ /*
+ * Build each child, passing in the parent (this panel) scene graph node. The
+ * child mark's scene is initialized from the corresponding entry in the
+ * existing scene graph, such that properties from the previous build can be
+ * reused; this is largely to facilitate the recycling of SVG elements.
+ */
+ for (var i = 0; i < this.children.length; i++) {
+ this.children[i].scene = s.children[i] || [];
+ this.children[i].build(s);
+ }
+
+ /*
+ * Once the child marks have been built, the new scene graph nodes are removed
+ * from the child marks and placed into the scene graph. The nodes cannot
+ * remain on the child nodes because this panel (or a parent panel) may be
+ * instantiated multiple times!
+ */
+ for (var i = 0; i < this.children.length; i++) {
+ s.children[i] = this.children[i].scene;
+ delete this.children[i].scene;
+ }
+
+ /* Delete any expired child scenes, should child marks have been removed. */
+ s.children.length = this.children.length;
+};
+
+/**
+ * Computes the implied properties for this panel for the specified instance
+ * <tt>s</tt> in the scene graph. Panels have two implied properties:<ul>
+ *
+ * <li>The <tt>canvas</tt> property references the DOM element, typically a DIV,
+ * that contains the SVG element that is used to display the visualization. This
+ * property may be specified as a string, referring to the unique ID of the
+ * element in the DOM. The string is converted to a reference to the DOM
+ * element. The width and height of the SVG element is inferred from this DOM
+ * element. If no canvas property is specified, a new SVG element is created and
+ * inserted into the document, using the panel dimensions; see
+ * {@link #createCanvas}.
+ *
+ * <li>The <tt>children</tt> array, while not a property per se, contains the
+ * scene graph for each child mark. This array is initialized to be empty, and
+ * is populated above in {@link #buildInstance}.
+ *
+ * </ul>The current implementation creates the SVG element, if necessary, during
+ * the build phase; in the future, it may be preferable to move this to the
+ * update phase, although then the canvas property would be undefined. In
+ * addition, DOM inspection is necessary to define the implied width and height
+ * properties that may be inferred from the DOM.
+ *
+ * @param s a node in the scene graph; the instance of the panel to build.
+ */
+pv.Panel.prototype.buildImplied = function(s) {
+ if (!s.children) s.children = [];
+ if (!s.parent) {
+ var c = s.canvas;
+ if (c) {
+ var d = (typeof c == "string") ? document.getElementById(c) : c;
+
+ /* Clear the container if it's not already associated with this panel. */
+ if (!d.$panel || d.$panel != this) {
+ d.$panel = this;
+ delete d.$canvas;
+ while (d.lastChild) {
+ d.lastChild.remove();
+ }
+ }
+
+ /* Construct the canvas if not already present. */
+ if (!(c = d.$canvas)) {
+ d.$canvas = c = document.createElementNS(pv.ns.svg, "svg");
+ d.appendChild(c);
+ }
+
+ /** Returns the computed style for the given element and property. */
+ let css = function(e, p) {
+ return parseFloat(self.getComputedStyle(e, null).getPropertyValue(p));
+ };
+
+ /* If width and height weren't specified, inspect the container. */
+ var w, h;
+ if (s.width == null) {
+ w = css(d, "width");
+ s.width = w - s.left - s.right;
+ } else {
+ w = s.width + s.left + s.right;
+ }
+ if (s.height == null) {
+ h = css(d, "height");
+ s.height = h - s.top - s.bottom;
+ } else {
+ h = s.height + s.top + s.bottom;
+ }
+
+ c.setAttribute("width", w);
+ c.setAttribute("height", h);
+ s.canvas = c;
+ } else if (s.svg) {
+ s.canvas = s.svg.parentNode;
+ } else {
+ s.canvas = this.createCanvas(
+ s.width + s.left + s.right,
+ s.height + s.top + s.bottom);
+ }
+ }
+ pv.Bar.prototype.buildImplied.call(this, s);
+};
+
+/**
+ * Updates the display, propagating property values computed in the build phase
+ * to the SVG image. In addition to the SVG element that serves as the canvas,
+ * each panel instance has a corresponding <tt>g</tt> (container) element. The
+ * <tt>g</tt> element uses the <tt>transform</tt> attribute to offset the location
+ * of contained graphical elements.
+ */
+pv.Panel.prototype.update = function() {
+ var appends = [];
+ for (var i = 0; i < this.scene.length; i++) {
+ var s = this.scene[i];
+
+ /* Create the <svg:g> element, if necessary. */
+ var v = s.svg;
+ if (!v) {
+ v = s.svg = document.createElementNS(pv.ns.svg, "g");
+ appends.push(s);
+ }
+
+ /* Update this instance, recursively including child marks. */
+ this.updateInstance(s);
+ if (s.children) { // check visibility
+ for (var j = 0; j < this.children.length; j++) {
+ var c = this.children[j];
+ c.scene = s.children[j];
+ c.update();
+ delete c.scene;
+ }
+ }
+ }
+
+ /*
+ * WebKit appears has a bug where images are not rendered if the <g> element
+ * is appended before it contained any elements. Creating the child elements
+ * first and then appending them solves the problem and is likely more
+ * efficient. Also, it means we can reverse the order easily.
+ *
+ * TODO It would be nice to support arbitrary z-order here, at least within
+ * panel. Of course, the order of children may need to be updated not just on
+ * append.
+ */
+ if (appends.length) {
+ if (appends[0].reverse) appends.reverse();
+ for (var i = 0; i < appends.length; i++) {
+ var s = appends[i];
+ (s.parent ? s.parent.svg : s.canvas).appendChild(s.svg);
+ }
+ }
+};
+
+/**
+ * Updates the display for the specified panel instance <tt>s</tt> in the scene
+ * graph. This implementation handles the fill and stroke style for the panel,
+ * as well as any necessary transform to offset the location of contained marks.
+ *
+ * <p>TODO As a performance optimization, it may also be possible to assign
+ * constant property values (or even the most common value for each property) as
+ * attributes on the <g> element so they can be inherited.
+ *
+ * @param s a node in the scene graph; the instance of the panel to update.
+ */
+pv.Panel.prototype.updateInstance = function(s) {
+ var v = s.svg;
+
+ /* visible */
+ if (!s.visible) {
+ if (v) v.setAttribute("display", "none");
+ return;
+ }
+ v.removeAttribute("display");
+
+ /* fillStyle, strokeStyle */
+ var r = v.$rect;
+ if (s.fillStyle || s.strokeStyle) {
+ if (!r) {
+ r = v.$rect = document.createElementNS(pv.ns.svg, "rect");
+ v.insertBefore(r, v.firstElementChild);
+ }
+
+ /* If width and height are exactly zero, the rect is not stroked! */
+ r.setAttribute("width", Math.max(1E-10, s.width));
+ r.setAttribute("height", Math.max(1E-10, s.height));
+
+ /* fill, stroke TODO gradient, patterns */
+ var fill = pv.color(s.fillStyle);
+ r.setAttribute("fill", fill.color);
+ r.setAttribute("fill-opacity", fill.opacity);
+ var stroke = pv.color(s.strokeStyle);
+ r.setAttribute("stroke", stroke.color);
+ r.setAttribute("stroke-opacity", stroke.opacity);
+ r.setAttribute("stroke-width", s.lineWidth);
+ } else if (r) {
+ v.removeChild(r);
+ delete v.$rect;
+ r = null;
+ }
+
+ /* cursor, title, event, etc. */
+ if (r) {
+ try {
+ s.svg = r;
+ pv.Mark.prototype.updateInstance.call(this, s);
+ } finally {
+ s.svg = v;
+ }
+ }
+
+ /* left, top */
+ if (s.left || s.top) {
+ v.setAttribute("transform", "translate(" + s.left + "," + s.top +")");
+ } else {
+ v.removeAttribute("transform");
+ }
+};
+/**
+ * Constructs a new rule with default properties. Rules are not typically
+ * constructed directly, but by adding to a panel or an existing mark via
+ * {@link pv.Mark#add}.
+ *
+ * @class Represents a horizontal or vertical rule. Rules are frequently used
+ * for axes and grid lines. For example, specifying only the bottom property
+ * draws horizontal rules, while specifying only the left draws vertical
+ * rules. Rules can also be used as thin bars. The visual style is controlled in
+ * the same manner as lines.
+ *
+ * <p>Rules are positioned exclusively using the four margins. The following
+ * combinations of properties are supported:<ul>
+ *
+ * <li>left (vertical)
+ * <li>right (vertical)
+ * <li>left, bottom, top (vertical)
+ * <li>right, bottom, top (vertical)
+ * <li>top (horizontal)
+ * <li>bottom (horizontal)
+ * <li>top, left, right (horizontal)
+ * <li>bottom, left, right (horizontal)
+ *
+ * </ul>TODO If rules supported width (for horizontal) and height (for vertical)
+ * properties, it might be easier to place them. Small rules can be used as tick
+ * marks; alternatively, a {@link Dot} with the "tick" shape can be used.
+ *
+ * <p>See also the <a href="../../api/Rule.html">Rule guide</a>.
+ *
+ * @see pv.Line
+ * @extends pv.Mark
+ */
+pv.Rule = function() {
+ pv.Mark.call(this);
+};
+pv.Rule.prototype = pv.extend(pv.Mark);
+pv.Rule.prototype.type = pv.Rule;
+
+/**
+ * Returns "rule".
+ *
+ * @returns {string} "rule".
+ */
+pv.Rule.toString = function() { return "rule"; };
+
+/**
+ * The width of stroked lines, in pixels; used in conjunction with
+ * <tt>strokeStyle</tt> to stroke the rule. The default value is 1 pixel.
+ *
+ * @type number
+ * @name pv.Rule.prototype.lineWidth
+ */
+pv.Rule.prototype.defineProperty("lineWidth");
+
+/**
+ * The style of stroked lines; used in conjunction with <tt>lineWidth</tt> to
+ * stroke the rule. The default value of this property is black.
+ *
+ * @type string
+ * @name pv.Rule.prototype.strokeStyle
+ * @see pv.color
+ */
+pv.Rule.prototype.defineProperty("strokeStyle");
+
+/**
+ * Default properties for rules. By default, a single-pixel black line is
+ * stroked.
+ *
+ * @type pv.Rule
+ */
+pv.Rule.defaults = new pv.Rule().extend(pv.Mark.defaults)
+ .lineWidth(1)
+ .strokeStyle("black");
+
+/**
+ * Constructs a new rule anchor with default properties.
+ *
+ * @class Represents an anchor for a rule mark. Rules support five different
+ * anchors:<ul>
+ *
+ * <li>top
+ * <li>left
+ * <li>center
+ * <li>bottom
+ * <li>right
+ *
+ * </ul>In addition to positioning properties (left, right, top bottom), the
+ * anchors support text rendering properties (text-align, text-baseline). Text is
+ * rendered to appear outside the rule. Note that this behavior is different
+ * from other mark anchors, which default to rendering text <i>inside</i> the
+ * mark.
+ *
+ * <p>For consistency with the other mark types, the anchor positions are
+ * defined in terms of their opposite edge. For example, the top anchor defines
+ * the bottom property, such that a bar added to the top anchor grows upward.
+ *
+ * @extends pv.Bar.Anchor
+ */
+pv.Rule.Anchor = function() {
+ pv.Bar.Anchor.call(this);
+};
+pv.Rule.Anchor.prototype = pv.extend(pv.Bar.Anchor);
+pv.Rule.Anchor.prototype.type = pv.Rule;
+
+/**
+ * The text-align property, for horizontal alignment outside the rule.
+ *
+ * @type string
+ * @name pv.Rule.Anchor.prototype.textAlign
+ */ /** @private */
+pv.Rule.Anchor.prototype.$textAlign = function(d) {
+ switch (this.get("name")) {
+ case "left": return "right";
+ case "bottom":
+ case "top":
+ case "center": return "center";
+ case "right": return "left";
+ }
+ return null;
+};
+
+/**
+ * The text-baseline property, for vertical alignment outside the rule.
+ *
+ * @type string
+ * @name pv.Rule.Anchor.prototype.textBaseline
+ */ /** @private */
+pv.Rule.Anchor.prototype.$textBaseline = function(d) {
+ switch (this.get("name")) {
+ case "right":
+ case "left":
+ case "center": return "middle";
+ case "top": return "bottom";
+ case "bottom": return "top";
+ }
+ return null;
+};
+
+/**
+ * Returns the pseudo-width of the rule in pixels; read-only.
+ *
+ * @returns {number} the pseudo-width, in pixels.
+ */
+pv.Rule.prototype.width = function() {
+ return this.scene[this.index].width;
+};
+
+/**
+ * Returns the pseudo-height of the rule in pixels; read-only.
+ *
+ * @returns {number} the pseudo-height, in pixels.
+ */
+pv.Rule.prototype.height = function() {
+ return this.scene[this.index].height;
+};
+
+/**
+ * Overrides the default behavior of {@link Mark#buildImplied} to determine the
+ * orientation (vertical or horizontal) of the rule.
+ *
+ * @param s a node in the scene graph; the instance of the rule to build.
+ */
+pv.Rule.prototype.buildImplied = function(s) {
+ s.width = s.height = 0;
+
+ /* Determine horizontal or vertical orientation. */
+ var l = s.left, r = s.right, t = s.top, b = s.bottom;
+ if (((l == null) && (r == null)) || ((r != null) && (l != null))) {
+ s.width = s.parent.width - (l = l || 0) - (r = r || 0);
+ } else {
+ s.height = s.parent.height - (t = t || 0) - (b = b || 0);
+ }
+
+ s.left = l;
+ s.right = r;
+ s.top = t;
+ s.bottom = b;
+
+ pv.Mark.prototype.buildImplied.call(this, s);
+};
+
+/**
+ * Updates the display for the specified rule instance <tt>s</tt> in the scene
+ * graph. This implementation handles the stroke style for the rule, as well as
+ * positional properties.
+ *
+ * @param s a node in the scene graph; the instance of the rule to update.
+ */
+pv.Rule.prototype.updateInstance = function(s) {
+ var v = s.svg;
+
+ /* Create the svg:line element, if necessary. */
+ if (s.visible && !v) {
+ v = s.svg = document.createElementNS(pv.ns.svg, "line");
+ s.parent.svg.appendChild(v);
+ }
+
+ /* visible, cursor, title, events, etc. */
+ pv.Mark.prototype.updateInstance.call(this, s);
+ if (!s.visible) return;
+
+ /* left, top */
+ v.setAttribute("x1", s.left);
+ v.setAttribute("y1", s.top);
+ v.setAttribute("x2", s.left + s.width);
+ v.setAttribute("y2", s.top + s.height);
+
+ /* stroke TODO gradient, patterns, dashes */
+ var stroke = pv.color(s.strokeStyle);
+ v.setAttribute("stroke", stroke.color);
+ v.setAttribute("stroke-opacity", stroke.opacity);
+ v.setAttribute("stroke-width", s.lineWidth);
+};
+/**
+ * Constructs a new wedge with default properties. Wedges are not typically
+ * constructed directly, but by adding to a panel or an existing mark via
+ * {@link pv.Mark#add}.
+ *
+ * @class Represents a wedge, or pie slice. Specified in terms of start and end
+ * angle, inner and outer radius, wedges can be used to construct donut charts
+ * and polar bar charts as well. If the {@link #angle} property is used, the end
+ * angle is implied by adding this value to start angle. By default, the start
+ * angle is the previously-generated wedge's end angle. This design allows
+ * explicit control over the wedge placement if desired, while offering
+ * convenient defaults for the construction of radial graphs.
+ *
+ * <p>The center point of the circle is positioned using the standard box model.
+ * The wedge can be stroked and filled, similar to {link Bar}.
+ *
+ * <p>See also the <a href="../../api/Wedge.html">Wedge guide</a>.
+ *
+ * @extends pv.Mark
+ */
+pv.Wedge = function() {
+ pv.Mark.call(this);
+};
+pv.Wedge.prototype = pv.extend(pv.Mark);
+pv.Wedge.prototype.type = pv.Wedge;
+
+/**
+ * Returns "wedge".
+ *
+ * @returns {string} "wedge".
+ */
+pv.Wedge.toString = function() { return "wedge"; };
+
+/**
+ * The start angle of the wedge, in radians. The start angle is measured
+ * clockwise from the 3 o'clock position. The default value of this property is
+ * the end angle of the previous instance (the {@link Mark#sibling}), or -PI / 2
+ * for the first wedge; for pie and donut charts, typically only the
+ * {@link #angle} property needs to be specified.
+ *
+ * @type number
+ * @name pv.Wedge.prototype.startAngle
+ */
+pv.Wedge.prototype.defineProperty("startAngle");
+
+/**
+ * The end angle of the wedge, in radians. If not specified, the end angle is
+ * implied as the start angle plus the {@link #angle}.
+ *
+ * @type number
+ * @name pv.Wedge.prototype.endAngle
+ */
+pv.Wedge.prototype.defineProperty("endAngle");
+
+/**
+ * The angular span of the wedge, in radians. This property is used if end angle
+ * is not specified.
+ *
+ * @type number
+ * @name pv.Wedge.prototype.angle
+ */
+pv.Wedge.prototype.defineProperty("angle");
+
+/**
+ * The inner radius of the wedge, in pixels. The default value of this property
+ * is zero; a positive value will produce a donut slice rather than a pie slice.
+ * The inner radius can vary per-wedge.
+ *
+ * @type number
+ * @name pv.Wedge.prototype.innerRadius
+ */
+pv.Wedge.prototype.defineProperty("innerRadius");
+
+/**
+ * The outer radius of the wedge, in pixels. This property is required. For
+ * pies, only this radius is required; for donuts, the inner radius must be
+ * specified as well. The outer radius can vary per-wedge.
+ *
+ * @type number
+ * @name pv.Wedge.prototype.outerRadius
+ */
+pv.Wedge.prototype.defineProperty("outerRadius");
+
+/**
+ * The width of stroked lines, in pixels; used in conjunction with
+ * <tt>strokeStyle</tt> to stroke the wedge's border.
+ *
+ * @type number
+ * @name pv.Wedge.prototype.lineWidth
+ */
+pv.Wedge.prototype.defineProperty("lineWidth");
+
+/**
+ * The style of stroked lines; used in conjunction with <tt>lineWidth</tt> to
+ * stroke the wedge's border. The default value of this property is null,
+ * meaning wedges are not stroked by default.
+ *
+ * @type string
+ * @name pv.Wedge.prototype.strokeStyle
+ * @see pv.color
+ */
+pv.Wedge.prototype.defineProperty("strokeStyle");
+
+/**
+ * The wedge fill style; if non-null, the interior of the wedge is filled with
+ * the specified color. The default value of this property is a categorical
+ * color.
+ *
+ * @type string
+ * @name pv.Wedge.prototype.fillStyle
+ * @see pv.color
+ */
+pv.Wedge.prototype.defineProperty("fillStyle");
+
+/**
+ * Default properties for wedges. By default, there is no stroke and the fill
+ * style is a categorical color.
+ *
+ * @type pv.Wedge
+ */
+pv.Wedge.defaults = new pv.Wedge().extend(pv.Mark.defaults)
+ .startAngle(function() {
+ var s = this.sibling();
+ return s ? s.endAngle : -Math.PI / 2;
+ })
+ .innerRadius(0)
+ .lineWidth(1.5)
+ .strokeStyle(null)
+ .fillStyle(pv.Colors.category20.unique);
+
+/**
+ * Returns the mid-radius of the wedge, which is defined as half-way between the
+ * inner and outer radii.
+ *
+ * @see #innerRadius
+ * @see #outerRadius
+ * @returns {number} the mid-radius, in pixels.
+ */
+pv.Wedge.prototype.midRadius = function() {
+ return (this.innerRadius() + this.outerRadius()) / 2;
+};
+
+/**
+ * Returns the mid-angle of the wedge, which is defined as half-way between the
+ * start and end angles.
+ *
+ * @see #startAngle
+ * @see #endAngle
+ * @returns {number} the mid-angle, in radians.
+ */
+pv.Wedge.prototype.midAngle = function() {
+ return (this.startAngle() + this.endAngle()) / 2;
+};
+
+/**
+ * Constructs a new wedge anchor with default properties.
+ *
+ * @class Represents an anchor for a wedge mark. Wedges support five different
+ * anchors:<ul>
+ *
+ * <li>outer
+ * <li>inner
+ * <li>center
+ * <li>start
+ * <li>end
+ *
+ * </ul>In addition to positioning properties (left, right, top bottom), the
+ * anchors support text rendering properties (text-align, text-baseline,
+ * textAngle). Text is rendered to appear inside the wedge.
+ *
+ * @extends pv.Mark.Anchor
+ */
+pv.Wedge.Anchor = function() {
+ pv.Mark.Anchor.call(this);
+};
+pv.Wedge.Anchor.prototype = pv.extend(pv.Mark.Anchor);
+pv.Wedge.Anchor.prototype.type = pv.Wedge;
+
+/**
+ * The left property; non-null.
+ *
+ * @type number
+ * @name pv.Wedge.Anchor.prototype.left
+ */ /** @private */
+pv.Wedge.Anchor.prototype.$left = function() {
+ var w = this.anchorTarget();
+ switch (this.get("name")) {
+ case "outer": return w.left() + w.outerRadius() * Math.cos(w.midAngle());
+ case "inner": return w.left() + w.innerRadius() * Math.cos(w.midAngle());
+ case "start": return w.left() + w.midRadius() * Math.cos(w.startAngle());
+ case "center": return w.left() + w.midRadius() * Math.cos(w.midAngle());
+ case "end": return w.left() + w.midRadius() * Math.cos(w.endAngle());
+ }
+ return null;
+};
+
+/**
+ * The right property; non-null.
+ *
+ * @type number
+ * @name pv.Wedge.Anchor.prototype.right
+ */ /** @private */
+pv.Wedge.Anchor.prototype.$right = function() {
+ var w = this.anchorTarget();
+ switch (this.get("name")) {
+ case "outer": return w.right() + w.outerRadius() * Math.cos(w.midAngle());
+ case "inner": return w.right() + w.innerRadius() * Math.cos(w.midAngle());
+ case "start": return w.right() + w.midRadius() * Math.cos(w.startAngle());
+ case "center": return w.right() + w.midRadius() * Math.cos(w.midAngle());
+ case "end": return w.right() + w.midRadius() * Math.cos(w.endAngle());
+ }
+ return null;
+};
+
+/**
+ * The top property; non-null.
+ *
+ * @type number
+ * @name pv.Wedge.Anchor.prototype.top
+ */ /** @private */
+pv.Wedge.Anchor.prototype.$top = function() {
+ var w = this.anchorTarget();
+ switch (this.get("name")) {
+ case "outer": return w.top() + w.outerRadius() * Math.sin(w.midAngle());
+ case "inner": return w.top() + w.innerRadius() * Math.sin(w.midAngle());
+ case "start": return w.top() + w.midRadius() * Math.sin(w.startAngle());
+ case "center": return w.top() + w.midRadius() * Math.sin(w.midAngle());
+ case "end": return w.top() + w.midRadius() * Math.sin(w.endAngle());
+ }
+ return null;
+};
+
+/**
+ * The bottom property; non-null.
+ *
+ * @type number
+ * @name pv.Wedge.Anchor.prototype.bottom
+ */ /** @private */
+pv.Wedge.Anchor.prototype.$bottom = function() {
+ var w = this.anchorTarget();
+ switch (this.get("name")) {
+ case "outer": return w.bottom() + w.outerRadius() * Math.sin(w.midAngle());
+ case "inner": return w.bottom() + w.innerRadius() * Math.sin(w.midAngle());
+ case "start": return w.bottom() + w.midRadius() * Math.sin(w.startAngle());
+ case "center": return w.bottom() + w.midRadius() * Math.sin(w.midAngle());
+ case "end": return w.bottom() + w.midRadius() * Math.sin(w.endAngle());
+ }
+ return null;
+};
+
+/**
+ * The text-align property, for horizontal alignment inside the wedge.
+ *
+ * @type string
+ * @name pv.Wedge.Anchor.prototype.textAlign
+ */ /** @private */
+pv.Wedge.Anchor.prototype.$textAlign = function() {
+ var w = this.anchorTarget();
+ switch (this.get("name")) {
+ case "outer": return pv.Wedge.upright(w.midAngle()) ? "right" : "left";
+ case "inner": return pv.Wedge.upright(w.midAngle()) ? "left" : "right";
+ default: return "center";
+ }
+};
+
+/**
+ * The text-baseline property, for vertical alignment inside the wedge.
+ *
+ * @type string
+ * @name pv.Wedge.Anchor.prototype.textBaseline
+ */ /** @private */
+pv.Wedge.Anchor.prototype.$textBaseline = function() {
+ var w = this.anchorTarget();
+ switch (this.get("name")) {
+ case "start": return pv.Wedge.upright(w.startAngle()) ? "top" : "bottom";
+ case "end": return pv.Wedge.upright(w.endAngle()) ? "bottom" : "top";
+ default: return "middle";
+ }
+};
+
+/**
+ * The text-angle property, for text rotation inside the wedge.
+ *
+ * @type number
+ * @name pv.Wedge.Anchor.prototype.textAngle
+ */ /** @private */
+pv.Wedge.Anchor.prototype.$textAngle = function() {
+ var w = this.anchorTarget();
+ var a = 0;
+ switch (this.get("name")) {
+ case "center":
+ case "inner":
+ case "outer": a = w.midAngle(); break;
+ case "start": a = w.startAngle(); break;
+ case "end": a = w.endAngle(); break;
+ }
+ return pv.Wedge.upright(a) ? a : (a + Math.PI);
+};
+
+/**
+ * Returns true if the specified angle is considered "upright", as in, text
+ * rendered at that angle would appear upright. If the angle is not upright,
+ * text is rotated 180 degrees to be upright, and the text alignment properties
+ * are correspondingly changed.
+ *
+ * @param {number} angle an angle, in radius.
+ * @returns {boolean} true if the specified angle is upright.
+ */
+pv.Wedge.upright = function(angle) {
+ angle = angle % (2 * Math.PI);
+ angle = (angle < 0) ? (2 * Math.PI + angle) : angle;
+ return (angle < Math.PI / 2) || (angle > 3 * Math.PI / 2);
+};
+
+/**
+ * Overrides the default behavior of {@link Mark#buildImplied} such that the end
+ * angle is computed from the start angle and angle (angular span) if not
+ * specified.
+ *
+ * @param s a node in the scene graph; the instance of the wedge to build.
+ */
+pv.Wedge.prototype.buildImplied = function(s) {
+ pv.Mark.prototype.buildImplied.call(this, s);
+ if (s.endAngle == null) {
+ s.endAngle = s.startAngle + s.angle;
+ }
+};
+
+/**
+ * Updates the display for the specified wedge instance <tt>s</tt> in the scene
+ * graph. This implementation handles the fill and stroke style for the wedge,
+ * as well as positional properties.
+ *
+ * @param s a node in the scene graph; the instance of the bar to update.
+ */
+pv.Wedge.prototype.updateInstance = function(s) {
+ var v = s.svg;
+
+ /* Create the <svg:path> element, if necessary. */
+ if (s.visible && !v) {
+ v = s.svg = document.createElementNS(pv.ns.svg, "path");
+ v.setAttribute("fill-rule", "evenodd");
+ s.parent.svg.appendChild(v);
+ }
+
+ /* visible, cursor, title, events, etc. */
+ pv.Mark.prototype.updateInstance.call(this, s);
+ if (!s.visible) return;
+
+ /* left, top */
+ v.setAttribute("transform", "translate(" + s.left + "," + s.top +")");
+
+ /*
+ * TODO If the angle or endAngle is updated by an event handler, the implied
+ * properties won't recompute correctly, so this will lead to potentially
+ * buggy redraw. How to re-evaluate implied properties on update?
+ */
+
+ /* innerRadius, outerRadius, startAngle, endAngle */
+ var r1 = s.innerRadius, r2 = s.outerRadius;
+ if (s.angle >= 2 * Math.PI) {
+ if (r1) {
+ v.setAttribute("d", "M0," + r2
+ + "A" + r2 + "," + r2 + " 0 1,1 0," + (-r2)
+ + "A" + r2 + "," + r2 + " 0 1,1 0," + r2
+ + "M0," + r1
+ + "A" + r1 + "," + r1 + " 0 1,1 0," + (-r1)
+ + "A" + r1 + "," + r1 + " 0 1,1 0," + r1
+ + "Z");
+ } else {
+ v.setAttribute("d", "M0," + r2
+ + "A" + r2 + "," + r2 + " 0 1,1 0," + (-r2)
+ + "A" + r2 + "," + r2 + " 0 1,1 0," + r2
+ + "Z");
+ }
+ } else {
+ var c1 = Math.cos(s.startAngle), c2 = Math.cos(s.endAngle),
+ s1 = Math.sin(s.startAngle), s2 = Math.sin(s.endAngle);
+ if (r1) {
+ v.setAttribute("d", "M" + r2 * c1 + "," + r2 * s1
+ + "A" + r2 + "," + r2 + " 0 "
+ + ((s.angle < Math.PI) ? "0" : "1") + ",1 "
+ + r2 * c2 + "," + r2 * s2
+ + "L" + r1 * c2 + "," + r1 * s2
+ + "A" + r1 + "," + r1 + " 0 "
+ + ((s.angle < Math.PI) ? "0" : "1") + ",0 "
+ + r1 * c1 + "," + r1 * s1 + "Z");
+ } else {
+ v.setAttribute("d", "M" + r2 * c1 + "," + r2 * s1
+ + "A" + r2 + "," + r2 + " 0 "
+ + ((s.angle < Math.PI) ? "0" : "1") + ",1 "
+ + r2 * c2 + "," + r2 * s2 + "L0,0Z");
+ }
+ }
+
+ /* fill, stroke TODO gradient, patterns */
+ var fill = pv.color(s.fillStyle);
+ v.setAttribute("fill", fill.color);
+ v.setAttribute("fill-opacity", fill.opacity);
+ var stroke = pv.color(s.strokeStyle);
+ v.setAttribute("stroke", stroke.color);
+ v.setAttribute("stroke-opacity", stroke.opacity);
+ v.setAttribute("stroke-width", s.lineWidth);
+};
+pv.Scales = {};
+pv.Scales.epsilon = 1e-30;
+pv.Scales.defaultBase = 10;
+
+/**
+ * Scale is a base class for scale objects. Scale objects are used to scale the
+ * data to a given range. The Scale object initially scales the value to the
+ * interval [0, 1]. The values are then mapped to a given range by the range()
+ * method.
+ */
+pv.Scales.Scale = function() {
+ // Pixel coordinate minimum
+ this._rMin = 0;
+ // Pixel coordinate maximum
+ this._rMax = 100;
+ // Round value?
+ this._round = true;
+};
+
+/**
+ * Sets the range to map the data to.
+ */
+pv.Scales.Scale.prototype.range = function(a, b) {
+ if (a == undefined) {
+ // use default values
+ // TODO: [0, 100] may not be the best default values.
+ // Find better default values, which may be different for each scale type.
+ } else if (b == undefined) {
+ this._rMin = 0;
+ this._rMax = a;
+ } else {
+ this._rMin = a;
+ this._rMax = b;
+ }
+
+ return this;
+};
+
+// Accessor method for range min
+pv.Scales.Scale.prototype.rangeMin = function(x) {
+ if (x == undefined) {
+ return this._rMin;
+ } else {
+ this._rMin = x;
+ return this;
+ }
+};
+
+// Accessor method for range max
+pv.Scales.Scale.prototype.rangeMax = function(x) {
+ if (x == undefined) {
+ return this._rMax;
+ } else {
+ this._rMax = x;
+ return this;
+ }
+};
+
+// Accessor method for round
+pv.Scales.Scale.prototype.round = function(x) {
+ if (x == undefined) {
+ return this._round;
+ } else {
+ this._round = x;
+ return this;
+ }
+};
+
+//Scales the input to the set range
+pv.Scales.Scale.prototype.scale = function(x) {
+ var v = this._rMin + (this._rMax-this._rMin) * this.normalize(x);
+ return this._round ? Math.round(v) : v;
+};
+
+// Returns the inverse scaled value.
+pv.Scales.Scale.prototype.invert = function(y) {
+ var n = (y - this._rMin) / (this._rMax - this._rMin);
+ return this.unnormalize(n);
+};
+pv.Scale = {};
+
+pv.Scale.linear = function() {
+ var min, max, nice = false, s, f = pv.identity;
+
+ /* Property function. */
+ function scale() {
+ if (s == undefined) {
+ if (min == undefined) min = pv.min(this.$$data, f);
+ if (max == undefined) max = pv.max(this.$$data, f);
+ if (nice) { // TODO Only "nice" bounds set automatically.
+ var step = Math.pow(10, Math.round(Math.log(max - min) / Math.log(10)) - 1);
+ min = Math.floor(min / step) * step;
+ max = Math.ceil(max / step) * step;
+ }
+ s = range.call(this) / (max - min);
+ }
+ return (f.apply(this, arguments) - min) * s;
+ }
+
+ function range() {
+ switch (property) {
+ case "height":
+ case "top":
+ case "bottom": return this.parent.height();
+ case "width":
+ case "left":
+ case "right": return this.parent.width();
+ default: return 1;
+ }
+ }
+
+ scale.by = function(v) { f = v; return this; };
+ scale.min = function(v) { min = v; return this; };
+ scale.max = function(v) { max = v; return this; };
+
+ scale.nice = function(v) {
+ nice = (arguments.length == 0) ? true : v;
+ return this;
+ };
+
+ scale.range = function() {
+ if (arguments.length == 1) {
+ o = 0;
+ s = arguments[0];
+ } else {
+ o = arguments[0];
+ s = arguments[1] - arguments[0];
+ }
+ return this;
+ };
+
+ return scale;
+};
+/**
+ * QuantitativeScale is a base class for representing quantitative numerical data
+ * scales.
+ */
+pv.Scales.QuantitativeScale = function(min, max, base) {
+ pv.Scales.Scale.call(this);
+
+ this._min = min;
+ this._max = max;
+ this._base = base==undefined ? pv.Scales.defaultBase : base;
+};
+
+pv.Scales.QuantitativeScale.prototype = pv.extend(pv.Scales.Scale);
+
+// Accessor method for min
+pv.Scales.QuantitativeScale.prototype.min = function(x) {
+ if (x == undefined) {
+ return this._min;
+ } else {
+ this._min = x;
+ return this;
+ }
+};
+
+// Accessor method for max
+pv.Scales.QuantitativeScale.prototype.max = function(x) {
+ if (x == undefined) {
+ return this._max;
+ } else {
+ this._max = x;
+ return this;
+ }
+};
+
+// Accessor method for base
+pv.Scales.QuantitativeScale.prototype.base = function(x) {
+ if (x == undefined) {
+ return this._base;
+ } else {
+ this._base = x;
+ return this;
+ }
+};
+
+// Checks if the mapped interval contains x
+pv.Scales.QuantitativeScale.prototype.contains = function(x) {
+ return (x >= this._min && x <= this._max);
+};
+
+// Returns the step for the scale
+pv.Scales.QuantitativeScale.prototype.step = function(min, max, base) {
+ if (!base) base = pv.Scales.defaultBase;
+ var exp = Math.round(Math.log(max-min)/Math.log(base)) - 1;
+
+ return Math.pow(base, exp);
+};
+pv.Scales.dateTime = function(min, max) {
+ return new pv.Scales.DateTimeScale(min, max);
+}
+
+/**
+ * DateTimeScale DateTimeScale scales time data.
+ */
+pv.Scales.DateTimeScale = function(min, max) {
+ pv.Scales.Scale.call(this);
+
+ this._min = min;
+ this._max = max;
+};
+
+pv.Scales.DateTimeScale.prototype = pv.extend(pv.Scales.Scale);
+
+// Accessor method for min
+pv.Scales.DateTimeScale.prototype.min = function(x) {
+ if (x == undefined) {
+ return this._min;
+ } else {
+ this._min = x;
+ return this;
+ }
+};
+
+// Accessor method for max
+pv.Scales.DateTimeScale.prototype.max = function(x) {
+ if (x == undefined) {
+ return this._max;
+ } else {
+ this._max = x;
+ return this;
+ }
+};
+
+// Normalizes DateTimeScale value
+pv.Scales.DateTimeScale.prototype.normalize = function(x) {
+ var eps = pv.Scales.epsilon;
+ var range = this._max - this._min;
+
+ return (range < eps && range > -eps) ? 0 : (x - this._min) / range;
+};
+
+// Un-normalizes the value
+pv.Scales.DateTimeScale.prototype.unnormalize = function(n) {
+ return n * (this._max - this._min) + this._min;
+};
+
+// Checks if the mapped interval contains x
+pv.Scales.DateTimeScale.prototype.contains = function(x) {
+ var t = x.valueOf();
+ return (t >= this._min.valueOf() && t <= this._max.valueOf());
+};
+
+// Sets min/max values to "nice" values
+pv.Scales.DateTimeScale.prototype.nice = function() {
+ var span = this.span(this._min, this._max);
+ this._min = this.round(this._min, span, false);
+ this._max = this.round(this._max, span, true);
+};
+
+/**
+ * Calculate a list of rule values covering the time range spaced at a
+ * configurable span.
+ *
+ * @param [forceSpan] If you want to force rule-generation from a span other
+ * than the default calculated by span, pass the value here.
+ * @param [beNice] Round the min and max values based on the span in use. If
+ * you are passing a value for forceSpan, you may also want to pass true
+ * for this argument.
+ *
+ * @return a list of rule values
+ */
+pv.Scales.DateTimeScale.prototype.ruleValues = function(forceSpan, beNice) {
+ var min = this._min.valueOf(), max = this._max.valueOf();
+ var span = (forceSpan == null) ? this.span(this._min, this._max) : forceSpan;
+ // We need to boost the step in order to avoid an infinite loop in the first
+ // case where we round. DST can cause a case where just one step is not
+ // enough to push round far enough.
+ var step = Math.floor(this.step(this._min, this._max, span) * 1.5);
+ var list = [];
+
+ var d = this._min;
+ if (beNice) {
+ d = this.round(d, span, false);
+ max = this.round(this._max, span, true).valueOf();
+ }
+ if (span < pv.Scales.DateTimeScale.Span.MONTHS) {
+ while (d.valueOf() <= max) {
+ list.push(d);
+ // we need to round to compensate for daylight savings time...
+ d = this.round(new Date(d.valueOf()+step), span, false);
+ }
+ } else if (span == pv.Scales.DateTimeScale.Span.MONTHS) {
+ // TODO: Handle quarters
+ step = 1;
+ while (d.valueOf() <= max) {
+ list.push(d);
+ d = new Date(d);
+ d.setMonth(d.getMonth() + step);
+ }
+ } else { // Span.YEARS
+ step = 1;
+ while (d.valueOf() <= max) {
+ list.push(d);
+ d = new Date(d);
+ d.setFullYear(d.getFullYear() + step);
+ }
+ }
+
+ return list;
+};
+
+// Time Span Constants
+pv.Scales.DateTimeScale.Span = {};
+pv.Scales.DateTimeScale.Span.YEARS = 0;
+pv.Scales.DateTimeScale.Span.MONTHS = -1;
+pv.Scales.DateTimeScale.Span.DAYS = -2;
+pv.Scales.DateTimeScale.Span.HOURS = -3;
+pv.Scales.DateTimeScale.Span.MINUTES = -4;
+pv.Scales.DateTimeScale.Span.SECONDS = -5;
+pv.Scales.DateTimeScale.Span.MILLISECONDS = -6;
+pv.Scales.DateTimeScale.Span.WEEKS = -10;
+pv.Scales.DateTimeScale.Span.QUARTERS = -11;
+
+// Rounds the date
+pv.Scales.DateTimeScale.prototype.round = function(t, span, roundUp) {
+ var Span = pv.Scales.DateTimeScale.Span;
+ var d = t, bias = roundUp ? 1 : 0;
+
+ if (span >= Span.YEARS) {
+ d = new Date(t.getFullYear() + bias, 0);
+ } else if (span == Span.MONTHS) {
+ d = new Date(t.getFullYear(), t.getMonth() + bias);
+ } else if (span == Span.DAYS) {
+ d = new Date(t.getFullYear(), t.getMonth(), t.getDate() + bias);
+ } else if (span == Span.HOURS) {
+ d = new Date(t.getFullYear(), t.getMonth(), t.getDate(), t.getHours() + bias);
+ } else if (span == Span.MINUTES) {
+ d = new Date(t.getFullYear(), t.getMonth(), t.getDate(), t.getHours(), t.getMinutes() + bias);
+ } else if (span == Span.SECONDS) {
+ d = new Date(t.getFullYear(), t.getMonth(), t.getDate(), t.getHours(), t.getMinutes(), t.getSeconds() + bias);
+ } else if (span == Span.MILLISECONDS) {
+ d = new Date(d.time + (roundUp ? 1 : -1));
+ } else if (span == Span.WEEKS) {
+ bias = roundUp ? 7 - d.getDay() : -d.getDay();
+ d = new Date(t.getFullYear(), t.getMonth(), t.getDate() + bias);
+ }
+ return d;
+};
+
+// Returns the span of the given min/max values
+pv.Scales.DateTimeScale.prototype.span = function(min, max) {
+ var MS_MIN = 60*1000, MS_HOUR = 60*MS_MIN, MS_DAY = 24*MS_HOUR, MS_WEEK = 7*MS_DAY;
+ var Span = pv.Scales.DateTimeScale.Span;
+ var span = max.valueOf() - min.valueOf();
+ var days = span / MS_DAY;
+
+ // TODO: handle Weeks/Quarters
+ if (days >= 365*2) return (1 + max.getFullYear()-min.getFullYear());
+ else if (days >= 60) return Span.MONTHS;
+ else if (span/MS_WEEK > 1) return Span.WEEKS;
+ else if (span/MS_DAY > 1) return Span.DAYS;
+ else if (span/MS_HOUR > 1) return Span.HOURS;
+ else if (span/MS_MIN > 1) return Span.MINUTES;
+ else if (span/1000.0 > 1) return Span.SECONDS;
+ else return Span.MILLISECONDS;
+}
+
+// Returns the step for the scale
+pv.Scales.DateTimeScale.prototype.step = function(min, max, span) {
+ var Span = pv.Scales.DateTimeScale.Span;
+
+ if (span > Span.YEARS) {
+ var exp = Math.round(Math.log(Math.max(1,span-1)/Math.log(10))) - 1;
+ return Math.pow(10, exp);
+ } else if (span == Span.MONTHS) {
+ return 0;
+ } else if (span == Span.WEEKS) {
+ return 7*24*60*60*1000;
+ } else if (span == Span.DAYS) {
+ return 24*60*60*1000;
+ } else if (span == Span.HOURS) {
+ return 60*60*1000;
+ } else if (span == Span.MINUTES) {
+ return 60*1000;
+ } else if (span == Span.SECONDS) {
+ return 1000;
+ } else {
+ return 1;
+ }
+};
+pv.Scales.linear = function(min, max, base) {
+ return new pv.Scales.LinearScale(min, max, base);
+};
+
+pv.Scales.linear.fromData = function(data, f, base) {
+ return new pv.Scales.LinearScale(pv.min(data, f), pv.max(data, f), base);
+}
+
+/**
+ * LinearScale is a QuantativeScale that spaces values linearly along the scale
+ * range. This is the default scale for numeric types.
+ */
+pv.Scales.LinearScale = function(min, max, base) {
+ pv.Scales.QuantitativeScale.call(this, min, max, base);
+};
+
+pv.Scales.LinearScale.prototype = pv.extend(pv.Scales.QuantitativeScale);
+
+// Normalizes the value
+pv.Scales.LinearScale.prototype.normalize = function(x) {
+ var eps = pv.Scales.epsilon;
+ var range = this._max - this._min;
+
+ return (range < eps && range > -eps) ? 0 : (x - this._min) / range;
+};
+
+// Un-normalizes the value
+pv.Scales.LinearScale.prototype.unnormalize = function(n) {
+ return n * (this._max - this._min) + this._min;
+};
+
+// Sets min/max values to "nice numbers"
+pv.Scales.LinearScale.prototype.nice = function() {
+ var step = this.step(this._min, this._max, this._base);
+
+ this._min = Math.floor(this._min / step) * step;
+ this._max = Math.ceil(this._max / step) * step;
+
+ return this;
+};
+
+// Returns a list of rule values
+pv.Scales.LinearScale.prototype.ruleValues = function() {
+ var step = this.step(this._min, this._max, this._base);
+
+ var start = Math.floor(this._min / step) * step;
+ var end = Math.ceil(this._max / step) * step;
+
+ var list = pv.range(start, end+step, step);
+
+ // Remove precision problems
+ // TODO move to tick rendering, not scales
+ if (step < 1) {
+ var exp = Math.round(Math.log(step)/Math.log(this._base));
+
+ for (var i = 0; i < list.length; i++) {
+ list[i] = list[i].toFixed(-exp);
+ }
+ }
+
+ // check end points
+ if (list[0] < this._min) list.splice(0, 1);
+ if (list[list.length-1] > this._max) list.splice(list.length-1, 1);
+
+ return list;
+};
+pv.Scales.log = function(min, max, base) {
+ return new pv.Scales.LogScale(min, max, base);
+};
+
+pv.Scales.log.fromData = function(data, f, base) {
+ return new pv.Scales.LogScale(pv.min(data, f), pv.max(data, f), base);
+}
+
+/*
+ * LogScale is a QuantativeScale that performs a log transformation of the
+ * data. The base of the logarithm is determined by the base property.
+ */
+pv.Scales.LogScale = function(min, max, base) {
+ pv.Scales.QuantitativeScale.call(this, min, max, base);
+
+ this.update();
+};
+
+// Zero-symmetric log function
+pv.Scales.LogScale.log = function(x, b) {
+ return x==0 ? 0 : x>0 ? Math.log(x)/Math.log(b) : -Math.log(-x)/Math.log(b);
+};
+
+// Adjusted zero-symmetric log function
+pv.Scales.LogScale.zlog = function(x, b) {
+ var s = (x < 0) ? -1 : 1;
+ x = s*x;
+ if (x < b) x += (b-x)/b;
+ return s * Math.log(x) / Math.log(b);
+};
+
+pv.Scales.LogScale.prototype = pv.extend(pv.Scales.QuantitativeScale);
+
+// Accessor method for min
+pv.Scales.LogScale.prototype.min = function(x) {
+ var value = pv.Scales.QuantitativeScale.prototype.min.call(this, x);
+
+ if (x != undefined) this.update();
+ return value;
+};
+
+// Accessor method for max
+pv.Scales.LogScale.prototype.max = function(x) {
+ var value = pv.Scales.QuantitativeScale.prototype.max.call(this, x);
+
+ if (x != undefined) this.update();
+ return value;
+};
+
+// Accessor method for base
+pv.Scales.LogScale.prototype.base = function(x) {
+ var value = pv.Scales.QuantitativeScale.prototype.base.call(this, x);
+
+ if (x != undefined) this.update();
+ return value;
+};
+
+// Normalizes the value
+pv.Scales.LogScale.prototype.normalize = function(x) {
+ var eps = pv.Scales.epsilon;
+ var range = this._lmax - this._lmin;
+
+ return (range < eps && range > -eps) ? 0 : (this._log(x, this._base) - this._lmin) / range;
+};
+
+// Un-normalizes the value
+pv.Scales.LogScale.prototype.unnormalize = function(n) {
+ // TODO: handle case where _log = zlog
+ return Math.pow(this._base, n * (this._lmax - this._lmin) + this._lmin);
+};
+
+/**
+ * Sets min/max values to "nice numbers" For LogScale, we compute "nice" min/max
+ * values for the log scale(_lmin, _lmax) first, then calculate the data min/max
+ * values from the log min/max values.
+ */
+pv.Scales.LogScale.prototype.nice = function() {
+ var step = 1; //this.step(this._lmin, this._lmax);
+
+ this._lmin = Math.floor(this._lmin / step) * step;
+ this._lmax = Math.ceil(this._lmax / step) * step;
+
+ // TODO: handle case where _log = zlog
+ this._min = Math.pow(this._base, this._lmin);
+ this._max = Math.pow(this._base, this._lmax);
+
+ return this;
+};
+
+// Returns a list of rule values
+pv.Scales.LogScale.prototype.ruleValues = function() {
+ var step = this.step(this._lmin, this._lmax);
+ if (step < 1) step = 1; // bound to 1
+
+ var start = Math.floor(this._lmin);
+ var end = Math.ceil(this._lmax);
+
+ var list =[];
+ var i, j, b;
+ for (i = start; i < end; i++) { // for each step
+ // add each rule value
+ // TODO: handle case where _log = zlog
+ b = Math.pow(this._base, i);
+ for (j = 1; j < this._base; j++) {
+ if (i >= 0) list.push(b*j);
+ else list.push((b*j).toFixed(-i));
+ }
+ }
+ list.push(b*this._base); // add max value
+
+ // check end points
+ if (list[0] < this._min) list.splice(0, 1);
+ if (list[list.length-1] > this._max) list.splice(list.length-1, 1);
+
+ return list;
+};
+
+// Update log scale values
+pv.Scales.LogScale.prototype.update = function() {
+ this._log = (this._min < 0 && this._max > 0) ? pv.Scales.LogScale.zlog : pv.Scales.LogScale.log;
+ this._lmin = this._log(this._min, this._base);
+ this._lmax = this._log(this._max, this._base);
+};
+/**
+ * Returns a {@link pv.Nest} operator for the specified array. This is a
+ * convenience factory method, equivalent to <tt>new pv.Nest(array)</tt>.
+ *
+ * @see pv.Nest
+ * @param {array} array an array of elements to nest.
+ * @returns {pv.Nest} a nest operator for the specified array.
+ */
+pv.nest = function(array) {
+ return new pv.Nest(array);
+};
+
+/**
+ * Constructs a nest operator for the specified array.
+ *
+ * @class Represents a {@link Nest} operator for the specified array. Nesting
+ * allows elements in an array to be grouped into a hierarchical tree
+ * structure. The levels in the tree are specified by <i>key</i> functions. The
+ * leaf nodes of the tree can be sorted by value, while the internal nodes can
+ * be sorted by key. Finally, the tree can be returned either has a
+ * multidimensional array via {@link #entries}, or as a hierarchical map via
+ * {@link #map}. The {@link #rollup} routine similarly returns a map, collapsing
+ * the elements in each leaf node using a summary function.
+ *
+ * <p>For example, consider the following tabular data structure of Barley
+ * yields, from various sites in Minnesota during 1931-2:
+ *
+ * <pre>{ yield: 27.00, variety: "Manchuria", year: 1931, site: "University Farm" },
+ * { yield: 48.87, variety: "Manchuria", year: 1931, site: "Waseca" },
+ * { yield: 27.43, variety: "Manchuria", year: 1931, site: "Morris" }, ...</pre>
+ *
+ * To facilitate visualization, it may be useful to nest the elements first by
+ * year, and then by variety, as follows:
+ *
+ * <pre>var nest = pv.nest(yields)
+ * .key(function(d) d.year)
+ * .key(function(d) d.variety)
+ * .entries();</pre>
+ *
+ * This returns a nested array. Each element of the outer array is a key-values
+ * pair, listing the values for each distinct key:
+ *
+ * <pre>{ key: 1931, values: [
+ * { key: "Manchuria", values: [
+ * { yield: 27.00, variety: "Manchuria", year: 1931, site: "University Farm" },
+ * { yield: 48.87, variety: "Manchuria", year: 1931, site: "Waseca" },
+ * { yield: 27.43, variety: "Manchuria", year: 1931, site: "Morris" },
+ * ...
+ * ]},
+ * { key: "Glabron", values: [
+ * { yield: 43.07, variety: "Glabron", year: 1931, site: "University Farm" },
+ * { yield: 55.20, variety: "Glabron", year: 1931, site: "Waseca" },
+ * ...
+ * ]},
+ * ]},
+ * { key: 1932, values: ... }</pre>
+ *
+ * Further details, including sorting and rollup, is provided below on the
+ * corresponding methods.
+ *
+ * @param {array} array an array of elements to nest.
+ */
+pv.Nest = function(array) {
+ this.array = array;
+ this.keys = [];
+};
+
+/**
+ * Nests using the specified key function. Multiple keys may be added to the
+ * nest; the array elements will be nested in the order keys are specified.
+ *
+ * @param {function} key a key function; must return a string or suitable map
+ * key.
+ * @return {pv.Nest} this.
+ */
+pv.Nest.prototype.key = function(key) {
+ this.keys.push(key);
+ return this;
+};
+
+/**
+ * Sorts the previously-added keys. The natural sort order is used by default
+ * (see {@link pv.naturalOrder}); if an alternative order is desired,
+ * <tt>order</tt> should be a comparator function. If this method is not called
+ * (i.e., keys are <i>unsorted</i>), keys will appear in the order they appear
+ * in the underlying elements array. For example,
+ *
+ * <pre>pv.nest(yields)
+ * .key(function(d) d.year)
+ * .key(function(d) d.variety)
+ * .sortKeys()
+ * .entries()</pre>
+ *
+ * groups yield data by year, then variety, and sorts the variety groups
+ * lexicographically (since the variety attribute is a string).
+ *
+ * <p>Key sort order is only used in conjunction with {@link #entries}, which
+ * returns an array of key-values pairs. If the nest is used to construct a
+ * {@link #map} instead, keys are unsorted.
+ *
+ * @param {function} [order] an optional comparator function.
+ * @returns {pv.Nest} this.
+ */
+pv.Nest.prototype.sortKeys = function(order) {
+ this.keys[this.keys.length - 1].order = order || pv.naturalOrder;
+ return this;
+};
+
+/**
+ * Sorts the leaf values. The natural sort order is used by default (see
+ * {@link pv.naturalOrder}); if an alternative order is desired, <tt>order</tt>
+ * should be a comparator function. If this method is not called (i.e., values
+ * are <i>unsorted</i>), values will appear in the order they appear in the
+ * underlying elements array. For example,
+ *
+ * <pre>pv.nest(yields)
+ * .key(function(d) d.year)
+ * .key(function(d) d.variety)
+ * .sortValues(function(a, b) a.yield - b.yield)
+ * .entries()</pre>
+ *
+ * groups yield data by year, then variety, and sorts the values for each
+ * variety group by yield.
+ *
+ * <p>Value sort order, unlike keys, applies to both {@link #entries} and
+ * {@link #map}. It has no effect on {@link #rollup}.
+ *
+ * @param {function} [order] an optional comparator function.
+ * @return {pv.Nest} this.
+ */
+pv.Nest.prototype.sortValues = function(order) {
+ this.order = order || pv.naturalOrder;
+ return this;
+};
+
+/**
+ * Returns a hierarchical map of values. Each key adds one level to the
+ * hierarchy. With only a single key, the returned map will have a key for each
+ * distinct value of the key function; the correspond value with be an array of
+ * elements with that key value. If a second key is added, this will be a nested
+ * map. For example:
+ *
+ * <pre>pv.nest(yields)
+ * .key(function(d) d.variety)
+ * .key(function(d) d.site)
+ * .map()</pre>
+ *
+ * returns a map <tt>m</tt> such that <tt>m[variety][site]</tt> is an array, a subset of
+ * <tt>yields</tt>, with each element having the given variety and site.
+ *
+ * @returns a hierarchical map of values.
+ */
+pv.Nest.prototype.map = function() {
+ var map = {}, values = [];
+
+ /* Build the map. */
+ for (var i, j = 0; j < this.array.length; j++) {
+ var x = this.array[j];
+ var m = map;
+ for (i = 0; i < this.keys.length - 1; i++) {
+ var k = this.keys[i](x);
+ if (!m[k]) m[k] = {};
+ m = m[k];
+ }
+ k = this.keys[i](x);
+ if (!m[k]) {
+ var a = [];
+ values.push(a);
+ m[k] = a;
+ }
+ m[k].push(x);
+ }
+
+ /* Sort each leaf array. */
+ if (this.order) {
+ for (var i = 0; i < values.length; i++) {
+ values[i].sort(this.order);
+ }
+ }
+
+ return map;
+};
+
+/**
+ * Returns a hierarchical nested array. This method is similar to
+ * {@link pv#entries}, but works recursively on the entire hierarchy. Rather
+ * than returning a map like {@link #map}, this method returns a nested
+ * array. Each element of the array has a <tt>key</tt> and <tt>values</tt>
+ * field. For leaf nodes, the <tt>values</tt> array will be a subset of the
+ * underlying elements array; for non-leaf nodes, the <tt>values</tt> array will
+ * contain more key-values pairs.
+ *
+ * <p>For an example usage, see the {@link Nest} constructor.
+ *
+ * @returns a hierarchical nested array.
+ */
+pv.Nest.prototype.entries = function() {
+
+ /** Recursively extracts the entries for the given map. */
+ function entries(map) {
+ var array = [];
+ for (var k in map) {
+ var v = map[k];
+ array.push({ key: k, values: (v instanceof Array) ? v : entries(v) });
+ };
+ return array;
+ }
+
+ /** Recursively sorts the values for the given key-values array. */
+ function sort(array, i) {
+ var o = this.keys[i].order;
+ if (o) array.sort(function(a, b) { return o(a.key, b.key); });
+ if (++i < this.keys.length) {
+ for (var j = 0; j < array.length; j++) {
+ sort.call(this, array[j].values, i);
+ }
+ }
+ return array;
+ }
+
+ return sort.call(this, entries(this.map()), 0);
+};
+
+/**
+ * Returns a rollup map. The behavior of this method is the same as
+ * {@link #map}, except that the leaf values are replaced with the return value
+ * of the specified rollup function <tt>f</tt>. For example,
+ *
+ * <pre>pv.nest(yields)
+ * .key(function(d) d.site)
+ * .rollup(function(v) pv.median(v, function(d) d.yield))</pre>
+ *
+ * first groups yield data by site, and then returns a map from site to median
+ * yield for the given site.
+ *
+ * @see #map
+ * @param {function} f a rollup function.
+ * @returns a hierarhical map, with the leaf values computed by <tt>f</tt>.
+ */
+pv.Nest.prototype.rollup = function(f) {
+
+ /** Recursively descends to the leaf nodes (arrays) and does rollup. */
+ function rollup(map) {
+ for (var key in map) {
+ var value = map[key];
+ if (value instanceof Array) {
+ map[key] = f(value);
+ } else {
+ rollup(value);
+ }
+ }
+ return map;
+ }
+
+ return rollup(this.map());
+};
+pv.Scales.ordinal = function(ordinals) {
+ return new pv.Scales.OrdinalScale(ordinals);
+};
+
+/**
+ * OrdinalScale is a Scale for ordered sequential data. This supports both
+ * numeric and non-numeric data, and simply places each element in sequence
+ * using the ordering found in the input data array.
+ */
+pv.Scales.OrdinalScale = function(ordinals) {
+ pv.Scales.Scale.call(this);
+
+ /* Filter the specified ordinals to their unique values. */
+ var seen = {};
+ this._ordinals = [];
+ for (var i = 0; i < ordinals.length; i++) {
+ var o = ordinals[i];
+ if (seen[o] == undefined) {
+ seen[o] = true;
+ this._ordinals.push(o);
+ }
+ }
+
+ this._map = pv.numerate(this._ordinals);
+};
+
+pv.Scales.OrdinalScale.prototype = pv.extend(pv.Scales.Scale);
+
+// Accessor method for ordinals
+pv.Scales.OrdinalScale.prototype.ordinals = function(ordinals) {
+ if (ordinals == undefined) {
+ return this._ordinals;
+ } else {
+ this._ordinals = ordinals;
+ this._map = pv.numerate(ordinals);
+ return this;
+ }
+};
+
+// Normalizes the value
+pv.Scales.OrdinalScale.prototype.normalize = function(x) {
+ var i = this._map[x];
+
+ // if x not an ordinal value(assume x is an index value)
+ if (i == undefined) i = x;
+
+ // Not sure if the value should be shifted
+ return (i == undefined) ? -1 : (i + 0.5) / this._ordinals.length;
+};
+
+// Returns the ordinal values for i
+pv.Scales.OrdinalScale.prototype.unnormalize = function(n) {
+ var i = Math.floor(n * this._ordinals.length - 0.5);
+ return this._ordinals[i];
+};
+
+// Returns a list of rule values
+pv.Scales.OrdinalScale.prototype.ruleValues = function() {
+ return pv.range(0.5, this._ordinals.length-0.5);
+};
+
+// Returns the width between rules
+pv.Scales.OrdinalScale.prototype.ruleWidth = function() {
+ return this.scale(1/this._ordinals.length);
+};
+pv.Scales.root = function(min, max, base) {
+ return new pv.Scales.RootScale(min, max, base);
+};
+
+pv.Scales.root.fromData = function(data, f, base) {
+ return new pv.Scales.RootScale(pv.min(data, f), pv.max(data, f), base);
+}
+
+/**
+ * RootScale is a QuantativeScale that performs a root transformation of the
+ * data. This could be a square root or any arbitrary power. A root scale may
+ * be a many-to-one mapping where the reverse mapping will not be correct.
+ */
+pv.Scales.RootScale = function(min, max, base) {
+ if (min instanceof Array) {
+ if (max == undefined) max = 2; // default base for root is 2.
+ } else {
+ if (base == undefined) base = 2; // default base for root is 2.
+ }
+
+ pv.Scales.QuantitativeScale.call(this, min, max, base);
+
+ this.update();
+};
+
+// Returns the root value with base b
+pv.Scales.RootScale.root = function (x, b) {
+ var s = (x < 0) ? -1 : 1;
+ return s * Math.pow(s * x, 1 / b);
+};
+
+pv.Scales.RootScale.prototype = pv.extend(pv.Scales.QuantitativeScale);
+
+// Accessor method for min
+pv.Scales.RootScale.prototype.min = function(x) {
+ var value = pv.Scales.QuantitativeScale.prototype.min.call(this, x);
+ if (x != undefined) this.update();
+ return value;
+};
+
+// Accessor method for max
+pv.Scales.RootScale.prototype.max = function(x) {
+ var value = pv.Scales.QuantitativeScale.prototype.max.call(this, x);
+ if (x != undefined) this.update();
+ return value;
+};
+
+// Accessor method for base
+pv.Scales.RootScale.prototype.base = function(x) {
+ var value = pv.Scales.QuantitativeScale.prototype.base.call(this, x);
+ if (x != undefined) this.update();
+ return value;
+};
+
+// Normalizes the value
+pv.Scales.RootScale.prototype.normalize = function(x) {
+ var eps = pv.Scales.epsilon;
+ var range = this._rmax - this._rmin;
+
+ return (range < eps && range > -eps) ? 0
+ : (pv.Scales.RootScale.root(x, this._base) - this._rmin)
+ / (this._rmax - this._rmin);
+};
+
+// Un-normalizes the value
+pv.Scales.RootScale.prototype.unnormalize = function(n) {
+ return Math.pow(n * (this._rmax - this._rmin) + this._rmin, this._base);
+};
+
+// Sets min/max values to "nice numbers"
+pv.Scales.RootScale.prototype.nice = function() {
+ var step = this.step(this._rmin, this._rmax);
+
+ this._rmin = Math.floor(this._rmin / step) * step;
+ this._rmax = Math.ceil(this._rmax / step) * step;
+
+ this._min = Math.pow(this._rmin, this._base);
+ this._max = Math.pow(this._rmax, this._base);
+
+ return this;
+};
+
+// Returns a list of rule values
+// The rule values of a root scale should be the powers
+// of integers, e.g. 1, 4, 9, ... for base = 2
+// TODO: This function needs further testing
+pv.Scales.RootScale.prototype.ruleValues = function() {
+ var step = this.step(this._rmin, this._rmax);
+// if (step < 1) step = 1; // bound to 1
+ // TODO: handle decimal values
+
+ var s;
+ var list = pv.range(Math.floor(this._rmin), Math.ceil(this._rmax), step);
+ for (var i = 0; i < list.length; i++) {
+ s = (list[i] < 0) ? -1 : 1;
+ list[i] = s*Math.pow(list[i], this._base);
+ }
+
+ // check end points
+ if (list[0] < this._min) list.splice(0, 1);
+ if (list[list.length-1] > this._max) list.splice(list.length-1, 1);
+
+ return list;
+};
+
+// Update root scale values
+pv.Scales.RootScale.prototype.update = function() {
+ var rt = pv.Scales.RootScale.root;
+ this._rmin = rt(this._min, this._base);
+ this._rmax = rt(this._max, this._base);
+};
+ return pv;
+}();
diff --git a/comm/mail/base/content/quickFilterBar.inc.xhtml b/comm/mail/base/content/quickFilterBar.inc.xhtml
new file mode 100644
index 0000000000..d7ddee8ef6
--- /dev/null
+++ b/comm/mail/base/content/quickFilterBar.inc.xhtml
@@ -0,0 +1,118 @@
+# 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/.
+
+ <div id="quick-filter-bar" class="themeable-brighttext" hidden="hidden">
+ <div id="quickFilterBarContainer">
+ <button is="toggle-button" id="qfb-sticky"
+ class="button icon-button icon-only check-button"
+ data-l10n-id="quick-filter-bar-sticky">
+ </button>
+ <xul:search-textbox id="qfb-qs-textbox"
+ class="themeableSearchBox"
+ timeout="500"
+ maxlength="192"
+ data-l10n-id="quick-filter-bar-textbox"
+ data-l10n-attrs="placeholder" />
+ <button id="qfd-dropdown"
+ class="button button-flat icon-button icon-only"
+ data-l10n-id="quick-filter-bar-dropdown">
+ </button>
+ <div class="roving-group button-group quickFilterButtons">
+ <button is="toggle-button" id="qfb-unread"
+ class="button collapsible-button icon-button check-button"
+ data-l10n-id="quick-filter-bar-unread">
+ <span data-l10n-id="quick-filter-bar-unread-label"></span>
+ </button>
+ <button is="toggle-button" id="qfb-starred"
+ class="button collapsible-button icon-button check-button"
+ data-l10n-id="quick-filter-bar-starred">
+ <span data-l10n-id="quick-filter-bar-starred-label"></span>
+ </button>
+ <button is="toggle-button" id="qfb-inaddrbook"
+ class="button collapsible-button icon-button check-button"
+ data-l10n-id="quick-filter-bar-inaddrbook">
+ <span data-l10n-id="quick-filter-bar-inaddrbook-label"></span>
+ </button>
+ <button is="toggle-button" id="qfb-tags"
+ class="button collapsible-button icon-button check-button"
+ data-l10n-id="quick-filter-bar-tags">
+ <span data-l10n-id="quick-filter-bar-tags-label"></span>
+ </button>
+ <button is="toggle-button" id="qfb-attachment"
+ class="button collapsible-button icon-button check-button"
+ data-l10n-id="quick-filter-bar-attachment">
+ <span data-l10n-id="quick-filter-bar-attachment-label"></span>
+ </button>
+ </div>
+ <span id="qfb-results-label"></span>
+ </div>
+ <div id="quickFilterBarSecondFilters">
+ <div id="quick-filter-bar-filter-text-bar" hidden="hidden">
+ <span id="qfb-qs-label"
+ data-l10n-id="quick-filter-bar-text-filter-explanation"></span>
+ <div class="roving-group button-group">
+ <button is="toggle-button" id="qfb-qs-sender"
+ class="button check-button"
+ data-l10n-id="quick-filter-bar-text-filter-sender"></button>
+ <button is="toggle-button" id="qfb-qs-recipients"
+ class="button check-button"
+ data-l10n-id="quick-filter-bar-text-filter-recipients"></button>
+ <button is="toggle-button" id="qfb-qs-subject"
+ class="button check-button"
+ data-l10n-id="quick-filter-bar-text-filter-subject"></button>
+ <button is="toggle-button" id="qfb-qs-body"
+ class="button check-button"
+ data-l10n-id="quick-filter-bar-text-filter-body"></button>
+ </div>
+ </div>
+ <div id="quickFilterBarTagsContainer" hidden="hidden">
+ <xul:menulist id="qfb-boolean-mode" value="OR">
+ <xul:menupopup>
+ <xul:menuitem id="qfb-boolean-mode-or"
+ value="OR"
+ data-l10n-id="quick-filter-bar-boolean-mode-any"
+ default="default"/>
+ <xul:menuitem id="qfb-boolean-mode-and"
+ value="AND"
+ data-l10n-id="quick-filter-bar-boolean-mode-all"/>
+ </xul:menupopup>
+ </xul:menulist>
+ </div>
+ </div>
+ </div>
+ <xul:menupopup id="quickFilterButtonsContext"
+ class="no-accel-menupopup"
+ position="bottomleft topleft"
+ onpopupshowing="quickFilterBar.updateCheckedStateQuickFilterButtons();">
+ <xul:menuitem id="quickFilterButtonsContextUnreadToggle"
+ class="quick-filter-menuitem"
+ type="checkbox"
+ value="unread"
+ closemenu="none"
+ data-l10n-id="quick-filter-bar-dropdown-unread"/>
+ <xul:menuitem id="quickFilterButtonsContextStarredToggle"
+ class="quick-filter-menuitem"
+ type="checkbox"
+ value="starred"
+ closemenu="none"
+ data-l10n-id="quick-filter-bar-dropdown-starred"/>
+ <xul:menuitem id="quickFilterButtonsContextInaddrbookToggle"
+ class="quick-filter-menuitem"
+ type="checkbox"
+ value="addrBook"
+ closemenu="none"
+ data-l10n-id="quick-filter-bar-dropdown-inaddrbook"/>
+ <xul:menuitem id="quickFilterButtonsContextTagsToggle"
+ class="quick-filter-menuitem"
+ type="checkbox"
+ value="tags"
+ closemenu="none"
+ data-l10n-id="quick-filter-bar-dropdown-tags"/>
+ <xul:menuitem id="quickFilterButtonsContextAttachmentToggle"
+ class="quick-filter-menuitem"
+ type="checkbox"
+ value="attachment"
+ closemenu="none"
+ data-l10n-id="quick-filter-bar-dropdown-attachment"/>
+ </xul:menupopup>
diff --git a/comm/mail/base/content/quickFilterBar.js b/comm/mail/base/content/quickFilterBar.js
new file mode 100644
index 0000000000..e254b91416
--- /dev/null
+++ b/comm/mail/base/content/quickFilterBar.js
@@ -0,0 +1,603 @@
+/* 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 about3Pane.js */
+
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+XPCOMUtils.defineLazyModuleGetters(this, {
+ MessageTextFilter: "resource:///modules/QuickFilterManager.jsm",
+ SearchSpec: "resource:///modules/SearchSpec.jsm",
+ QuickFilterManager: "resource:///modules/QuickFilterManager.jsm",
+ QuickFilterSearchListener: "resource:///modules/QuickFilterManager.jsm",
+ QuickFilterState: "resource:///modules/QuickFilterManager.jsm",
+});
+
+class ToggleButton extends HTMLButtonElement {
+ constructor() {
+ super();
+ this.addEventListener("click", () => {
+ this.pressed = !this.pressed;
+ });
+ }
+
+ connectedCallback() {
+ this.setAttribute("is", "toggle-button");
+ if (!this.hasAttribute("aria-pressed")) {
+ this.pressed = false;
+ }
+ }
+
+ get pressed() {
+ return this.getAttribute("aria-pressed") === "true";
+ }
+
+ set pressed(value) {
+ this.setAttribute("aria-pressed", value ? "true" : "false");
+ }
+}
+customElements.define("toggle-button", ToggleButton, { extends: "button" });
+
+var quickFilterBar = {
+ _filterer: null,
+ activeTopLevelFilters: new Set(),
+ topLevelFilters: ["unread", "starred", "addrBook", "attachment"],
+
+ /**
+ * The UI element that last triggered a search. This can be used to avoid
+ * updating the element when a search returns - in particular the text box,
+ * which the user may still be typing into.
+ *
+ * @type {Element}
+ */
+ activeElement: null,
+
+ init() {
+ this._bindUI();
+ this.updateRovingTab();
+
+ // Enable any filters set by the user.
+ // If keep filters applied/sticky setting is enabled, enable sticky.
+ let xulStickyVal = Services.xulStore.getValue(
+ XULSTORE_URL,
+ "quickFilterBarSticky",
+ "enabled"
+ );
+ if (xulStickyVal) {
+ this.filterer.setFilterValue("sticky", xulStickyVal == "true");
+
+ // If sticky is set, show saved filters.
+ // Otherwise do not display saved filters on load.
+ if (xulStickyVal == "true") {
+ // If any filter settings are enabled, retrieve the enabled filters.
+ let enabledTopFiltersVal = Services.xulStore.getValue(
+ XULSTORE_URL,
+ "quickFilter",
+ "enabledTopFilters"
+ );
+
+ // Set any enabled filters to enabled in the UI.
+ if (enabledTopFiltersVal) {
+ let enabledTopFilters = JSON.parse(enabledTopFiltersVal);
+ for (let filterName of enabledTopFilters) {
+ this.activeTopLevelFilters.add(filterName);
+ this.filterer.setFilterValue(filterName, true);
+ }
+ }
+ }
+ }
+
+ // Hide the toolbar, unless it has been previously shown.
+ if (
+ Services.xulStore.getValue(
+ XULSTORE_URL,
+ "quickFilterBar",
+ "collapsed"
+ ) === "false"
+ ) {
+ this._showFilterBar(true, true);
+ } else {
+ this._showFilterBar(false, true);
+ }
+
+ commandController.registerCallback("cmd_showQuickFilterBar", () => {
+ if (!this.filterer.visible) {
+ this._showFilterBar(true);
+ }
+ document.getElementById(QuickFilterManager.textBoxDomId).select();
+ });
+ commandController.registerCallback("cmd_toggleQuickFilterBar", () => {
+ let show = !this.filterer.visible;
+ this._showFilterBar(show);
+ if (show) {
+ document.getElementById(QuickFilterManager.textBoxDomId).select();
+ }
+ });
+ window.addEventListener("keypress", event => {
+ if (event.keyCode != KeyEvent.DOM_VK_ESCAPE || !this.filterer.visible) {
+ // The filter bar isn't visible, do nothing.
+ return;
+ }
+ if (this.filterer.userHitEscape()) {
+ // User hit the escape key; do our undo-ish thing.
+ this.updateSearch();
+ this.reflectFiltererState();
+ } else {
+ // Close the filter since there was nothing left to relax.
+ this._showFilterBar(false);
+ }
+ });
+
+ document.getElementById("qfd-dropdown").addEventListener("click", event => {
+ document
+ .getElementById("quickFilterButtonsContext")
+ .openPopup(event.target, { triggerEvent: event });
+ });
+
+ for (let buttonGroup of this.rovingGroups) {
+ buttonGroup.addEventListener("keypress", event => {
+ this.triggerQFTRovingTab(event);
+ });
+ }
+
+ document.getElementById("qfb-sticky").addEventListener("click", event => {
+ let stickyValue = event.target.pressed ? "true" : "false";
+ Services.xulStore.setValue(
+ XULSTORE_URL,
+ "quickFilterBarSticky",
+ "enabled",
+ stickyValue
+ );
+ });
+ },
+
+ /**
+ * Get all button groups with the roving-group class.
+ *
+ * @returns {Array} An array of buttons.
+ */
+ get rovingGroups() {
+ return document.querySelectorAll("#quick-filter-bar .roving-group");
+ },
+
+ /**
+ * Update the `tabindex` attribute of the buttons.
+ */
+ updateRovingTab() {
+ for (let buttonGroup of this.rovingGroups) {
+ for (let button of buttonGroup.querySelectorAll("button")) {
+ button.tabIndex = -1;
+ }
+ // Allow focus on the first available button.
+ buttonGroup.querySelector("button").tabIndex = 0;
+ }
+ },
+
+ /**
+ * Handles the keypress event on the button group.
+ *
+ * @param {Event} event - The keypress DOMEvent.
+ */
+ triggerQFTRovingTab(event) {
+ if (!["ArrowRight", "ArrowLeft"].includes(event.key)) {
+ return;
+ }
+
+ let buttonGroup = [
+ ...event.target
+ .closest(".roving-group")
+ .querySelectorAll(`[is="toggle-button"]`),
+ ];
+ let focusableButton = buttonGroup.find(b => b.tabIndex != -1);
+ let elementIndex = buttonGroup.indexOf(focusableButton);
+
+ // Find the adjacent focusable element based on the pressed key.
+ let isRTL = document.dir == "rtl";
+ if (
+ (isRTL && event.key == "ArrowLeft") ||
+ (!isRTL && event.key == "ArrowRight")
+ ) {
+ elementIndex++;
+ if (elementIndex > buttonGroup.length - 1) {
+ elementIndex = 0;
+ }
+ } else if (
+ (!isRTL && event.key == "ArrowLeft") ||
+ (isRTL && event.key == "ArrowRight")
+ ) {
+ elementIndex--;
+ if (elementIndex == -1) {
+ elementIndex = buttonGroup.length - 1;
+ }
+ }
+
+ // Move the focus to a button and update the tabindex attribute.
+ let newFocusableButton = buttonGroup[elementIndex];
+ if (newFocusableButton) {
+ focusableButton.tabIndex = -1;
+ newFocusableButton.tabIndex = 0;
+ newFocusableButton.focus();
+ }
+ },
+
+ get filterer() {
+ if (!this._filterer) {
+ this._filterer = new QuickFilterState();
+ this._filterer.visible = false;
+ }
+ return this._filterer;
+ },
+
+ set filterer(value) {
+ this._filterer = value;
+ },
+
+ // ---------------------
+ // UI State Manipulation
+
+ /**
+ * Add appropriate event handlers to the DOM elements. We do this rather
+ * than requiring lots of boilerplate "oncommand" junk on the nodes.
+ *
+ * We hook up the following:
+ * - "command" event listener.
+ * - reflect filter state
+ */
+ _bindUI() {
+ for (let filterDef of QuickFilterManager.filterDefs) {
+ let domNode = document.getElementById(filterDef.domId);
+ let menuItemNode = document.getElementById(filterDef.menuItemID);
+
+ let handlerDomId, handlerMenuItems;
+
+ if (!("onCommand" in filterDef)) {
+ handlerDomId = event => {
+ try {
+ let postValue = domNode.pressed ? true : null;
+ this.filterer.setFilterValue(filterDef.name, postValue);
+ this.updateFiltersSettings(filterDef.name, postValue);
+ this.deferredUpdateSearch(domNode);
+ } catch (ex) {
+ console.error(ex);
+ }
+ };
+ handlerMenuItems = event => {
+ try {
+ let postValue = menuItemNode.hasAttribute("checked") ? true : null;
+ this.filterer.setFilterValue(filterDef.name, postValue);
+ this.updateFiltersSettings(filterDef.name, postValue);
+ this.deferredUpdateSearch();
+ } catch (ex) {
+ console.error(ex);
+ }
+ };
+ } else {
+ handlerDomId = event => {
+ if (filterDef.name == "tags") {
+ filterDef.callID = "button";
+ }
+ let filterValues = this.filterer.filterValues;
+ let preValue =
+ filterDef.name in filterValues
+ ? filterValues[filterDef.name]
+ : null;
+ let [postValue, update] = filterDef.onCommand(
+ preValue,
+ domNode,
+ event,
+ document
+ );
+ this.filterer.setFilterValue(filterDef.name, postValue, !update);
+ this.updateFiltersSettings(filterDef.name, postValue);
+ if (update) {
+ this.deferredUpdateSearch(domNode);
+ }
+ };
+ handlerMenuItems = event => {
+ if (filterDef.name == "tags") {
+ filterDef.callID = "menuItem";
+ }
+ let filterValues = this.filterer.filterValues;
+ let preValue =
+ filterDef.name in filterValues
+ ? filterValues[filterDef.name]
+ : null;
+ let [postValue, update] = filterDef.onCommand(
+ preValue,
+ menuItemNode,
+ event,
+ document
+ );
+ this.filterer.setFilterValue(filterDef.name, postValue, !update);
+ this.updateFiltersSettings(filterDef.name, postValue);
+ if (update) {
+ this.deferredUpdateSearch();
+ }
+ };
+ }
+
+ if (domNode.namespaceURI == document.documentElement.namespaceURI) {
+ domNode.addEventListener("click", handlerDomId);
+ } else {
+ domNode.addEventListener("command", handlerDomId);
+ }
+ if (menuItemNode !== null) {
+ menuItemNode.addEventListener("command", handlerMenuItems);
+ }
+
+ if ("domBindExtra" in filterDef) {
+ filterDef.domBindExtra(document, this, domNode);
+ }
+ }
+ },
+
+ /**
+ * Update enabled filters in XULStore.
+ */
+ updateFiltersSettings(filterName, filterValue) {
+ if (this.topLevelFilters.includes(filterName)) {
+ this.updateTopLevelFilters(filterName, filterValue);
+ }
+ },
+
+ /**
+ * Update enabled top level filters in XULStore.
+ */
+ updateTopLevelFilters(filterName, filterValue) {
+ if (filterValue) {
+ this.activeTopLevelFilters.add(filterName);
+ } else {
+ this.activeTopLevelFilters.delete(filterName);
+ }
+
+ // Save enabled filter settings to XULStore.
+ Services.xulStore.setValue(
+ XULSTORE_URL,
+ "quickFilter",
+ "enabledTopFilters",
+ JSON.stringify(Array.from(this.activeTopLevelFilters))
+ );
+ },
+
+ /**
+ * Ensure all the quick filter menuitems in the quick filter dropdown menu are
+ * checked to reflect their current state.
+ */
+ updateCheckedStateQuickFilterButtons() {
+ for (let item of document.querySelectorAll(".quick-filter-menuitem")) {
+ if (Object.hasOwn(this.filterer.filterValues, `${item.value}`)) {
+ item.setAttribute("checked", true);
+ continue;
+ }
+ item.removeAttribute("checked");
+ }
+ },
+
+ /**
+ * Update the UI to reflect the state of the filterer constraints.
+ *
+ * @param [aFilterName] If only a single filter needs to be updated, name it.
+ */
+ reflectFiltererState(aFilterName) {
+ // If we aren't visible then there is no need to update the widgets.
+ if (this.filterer.visible) {
+ let filterValues = this.filterer.filterValues;
+ for (let filterDef of QuickFilterManager.filterDefs) {
+ // If we only need to update one state, check and skip as appropriate.
+ if (aFilterName && filterDef.name != aFilterName) {
+ continue;
+ }
+
+ let domNode = document.getElementById(filterDef.domId);
+
+ let value =
+ filterDef.name in filterValues ? filterValues[filterDef.name] : null;
+ if (!("reflectInDOM" in filterDef)) {
+ domNode.pressed = value;
+ } else {
+ filterDef.reflectInDOM(domNode, value, document, this);
+ }
+ }
+ }
+
+ this.reflectFiltererResults();
+
+ this.domNode.hidden = !this.filterer.visible;
+ },
+
+ /**
+ * Update the UI to reflect the state of the folderDisplay in terms of
+ * filtering. This is expected to be called by |reflectFiltererState| and
+ * when something happens event-wise in terms of search.
+ *
+ * We can have one of two states:
+ * - No filter is active; no attributes exposed for CSS to do anything.
+ * - A filter is active and we are still searching; filterActive=searching.
+ */
+ reflectFiltererResults() {
+ let threadPane = document.getElementById("threadTree");
+
+ // bail early if the view is in the process of being created
+ if (!gDBView) {
+ return;
+ }
+
+ // no filter active
+ if (!gViewWrapper.search || !gViewWrapper.search.userTerms) {
+ threadPane.removeAttribute("filterActive");
+ this.domNode.removeAttribute("filterActive");
+ } else if (gViewWrapper.searching) {
+ // filter active, still searching
+ // Do not set this immediately; wait a bit and then only set this if we
+ // still are in this same state (and we are still the active tab...)
+ setTimeout(() => {
+ threadPane.setAttribute("filterActive", "searching");
+ this.domNode.setAttribute("filterActive", "searching");
+ }, 500);
+ }
+ },
+
+ // ----------------------
+ // Event Handling Support
+
+ /**
+ * Retrieve the current filter state value (presumably an object) for mutation
+ * purposes. This causes the filter to be the last touched filter for escape
+ * undo-ish purposes.
+ */
+ getFilterValueForMutation(aName) {
+ return this.filterer.getFilterValue(aName);
+ },
+
+ /**
+ * Set the filter state for the given named filter to the given value. This
+ * causes the filter to be the last touched filter for escape undo-ish
+ * purposes.
+ *
+ * @param aName Filter name.
+ * @param aValue The new filter state.
+ */
+ setFilterValue(aName, aValue) {
+ this.filterer.setFilterValue(aName, aValue);
+ },
+
+ /**
+ * For UI responsiveness purposes, defer the actual initiation of the search
+ * until after the button click handling has completed and had the ability
+ * to paint such.
+ *
+ * @param {Element} activeElement - The element that triggered a call to
+ * this function, if any.
+ */
+ deferredUpdateSearch(activeElement) {
+ setTimeout(() => this.updateSearch(activeElement), 10);
+ },
+
+ /**
+ * Update the user terms part of the search definition to reflect the active
+ * filterer's current state.
+ *
+ * @param {Element?} activeElement - The element that triggered a call to
+ * this function, if any.
+ */
+ updateSearch(activeElement) {
+ if (!this._filterer || !gViewWrapper?.search) {
+ return;
+ }
+
+ this.activeElement = activeElement;
+ this.filterer.displayedFolder = gFolder;
+
+ let [terms, listeners] = this.filterer.createSearchTerms(
+ gViewWrapper.search.session
+ );
+
+ for (let [listener, filterDef] of listeners) {
+ // it registers itself with the search session.
+ new QuickFilterSearchListener(
+ gViewWrapper,
+ this.filterer,
+ filterDef,
+ listener,
+ quickFilterBar
+ );
+ }
+
+ gViewWrapper.search.userTerms = terms;
+ // Uncomment to know what the search state is when we (try and) update it.
+ // dump(tab.folderDisplay.view.search.prettyString());
+ },
+
+ /**
+ * Shows and hides quick filter bar, and sets the XUL Store value for the
+ * quick filter bar status.
+ *
+ * @param {boolean} show - Filter Status.
+ * @param {boolean} [init=false] - Initial Function Call.
+ */
+ _showFilterBar(show, init = false) {
+ this.filterer.visible = show;
+ if (!show) {
+ this.filterer.clear();
+ this.updateSearch();
+ // Cannot call the below function when threadTree hasn't been initialized yet.
+ if (!init) {
+ threadTree.table.body.focus();
+ }
+ }
+ this.reflectFiltererState();
+ Services.xulStore.setValue(
+ XULSTORE_URL,
+ "quickFilterBar",
+ "collapsed",
+ !show
+ );
+
+ window.dispatchEvent(new Event("qfbtoggle"));
+ },
+
+ /**
+ * Called by the view wrapper so we can update the results count.
+ */
+ onMessagesChanged() {
+ let filtering = gViewWrapper.search?.userTerms != null;
+ let newCount = filtering ? gDBView.numMsgsInView : null;
+ this.filterer.setFilterValue("results", newCount, true);
+
+ // - postFilterProcess everyone who cares
+ // This may need to be converted into an asynchronous process at some point.
+ for (let filterDef of QuickFilterManager.filterDefs) {
+ if ("postFilterProcess" in filterDef) {
+ let preState =
+ filterDef.name in this.filterer.filterValues
+ ? this.filterer.filterValues[filterDef.name]
+ : null;
+ let [newState, update, treatAsUserAction] = filterDef.postFilterProcess(
+ preState,
+ gViewWrapper,
+ filtering
+ );
+ this.filterer.setFilterValue(
+ filterDef.name,
+ newState,
+ !treatAsUserAction
+ );
+ if (update) {
+ let domNode = document.getElementById(filterDef.domId);
+ // We are passing update as a super-secret data propagation channel
+ // exclusively for one-off cases like the text filter gloda upsell.
+ filterDef.reflectInDOM(domNode, newState, document, this, update);
+ }
+ }
+ }
+
+ // - Update match status.
+ this.reflectFiltererState();
+ },
+
+ /**
+ * The displayed folder changed. Reset or reapply the filter, depending on
+ * the sticky state.
+ */
+ onFolderChanged() {
+ this.filterer = new QuickFilterState(this.filterer);
+ this.reflectFiltererState();
+ if (this._filterer?.filterValues.sticky) {
+ this.updateSearch();
+ }
+ },
+
+ _testHelperResetFilterState() {
+ if (!this._filterer) {
+ return;
+ }
+ this._filterer = new QuickFilterState();
+ this.updateSearch();
+ this.reflectFiltererState();
+ },
+};
+XPCOMUtils.defineLazyGetter(quickFilterBar, "domNode", () =>
+ document.getElementById("quick-filter-bar")
+);
diff --git a/comm/mail/base/content/sanitize.js b/comm/mail/base/content/sanitize.js
new file mode 100644
index 0000000000..68510faeac
--- /dev/null
+++ b/comm/mail/base/content/sanitize.js
@@ -0,0 +1,241 @@
+/* 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/. */
+
+var { PlacesUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/PlacesUtils.sys.mjs"
+);
+var { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+
+function Sanitizer() {}
+Sanitizer.prototype = {
+ // warning to the caller: this one may raise an exception (e.g. bug #265028)
+ clearItem(aItemName) {
+ if (this.items[aItemName].canClear) {
+ this.items[aItemName].clear();
+ }
+ },
+
+ canClearItem(aItemName) {
+ return this.items[aItemName].canClear;
+ },
+
+ prefDomain: "",
+
+ getNameFromPreference(aPreferenceName) {
+ return aPreferenceName.substr(this.prefDomain.length);
+ },
+
+ /**
+ * Deletes privacy sensitive data in a batch, according to user preferences
+ *
+ * @returns null if everything's fine; an object in the form
+ * { itemName: error, ... } on (partial) failure
+ */
+ sanitize() {
+ var branch = Services.prefs.getBranch(this.prefDomain);
+ var errors = null;
+
+ // Cache the range of times to clear
+ if (this.ignoreTimespan) {
+ // If we ignore timespan, clear everything.
+ var range = null;
+ } else {
+ range = this.range || Sanitizer.getClearRange();
+ }
+
+ for (var itemName in this.items) {
+ var item = this.items[itemName];
+ item.range = range;
+ if ("clear" in item && item.canClear && branch.getBoolPref(itemName)) {
+ // Some of these clear() may raise exceptions (see bug #265028)
+ // to sanitize as much as possible, we catch and store them,
+ // rather than fail fast.
+ // Callers should check returned errors and give user feedback
+ // about items that could not be sanitized
+ try {
+ item.clear();
+ } catch (er) {
+ if (!errors) {
+ errors = {};
+ }
+ errors[itemName] = er;
+ dump("Error sanitizing " + itemName + ": " + er + "\n");
+ }
+ }
+ }
+ return errors;
+ },
+
+ // Time span only makes sense in certain cases. Consumers who want
+ // to only clear some private data can opt in by setting this to false,
+ // and can optionally specify a specific range. If timespan is not ignored,
+ // and range is not set, sanitize() will use the value of the timespan
+ // pref to determine a range
+ ignoreTimespan: true,
+ range: null,
+
+ items: {
+ cache: {
+ clear() {
+ try {
+ // Cache doesn't consult timespan, nor does it have the
+ // facility for timespan-based eviction. Wipe it.
+ Services.cache2.clear();
+ } catch (ex) {}
+ },
+
+ get canClear() {
+ return true;
+ },
+ },
+
+ cookies: {
+ clear() {
+ if (this.range) {
+ // Iterate through the cookies and delete any created after our cutoff.
+ for (let cookie of Services.cookies.cookies) {
+ if (cookie.creationTime > this.range[0]) {
+ // This cookie was created after our cutoff, clear it
+ Services.cookies.remove(
+ cookie.host,
+ cookie.name,
+ cookie.path,
+ cookie.originAttributes
+ );
+ }
+ }
+ } else {
+ // Remove everything
+ Services.cookies.removeAll();
+ }
+ },
+
+ get canClear() {
+ return true;
+ },
+ },
+
+ history: {
+ clear() {
+ if (this.range) {
+ PlacesUtils.history.removeVisitsByFilter({
+ beginDate: new Date(this.range[0]),
+ endDate: new Date(this.range[1]),
+ });
+ } else {
+ PlacesUtils.history.clear();
+ }
+
+ try {
+ Services.obs.notifyObservers(null, "browser:purge-session-history");
+ } catch (e) {}
+
+ try {
+ var predictor = Cc["@mozilla.org/network/predictor;1"].getService(
+ Ci.nsINetworkPredictor
+ );
+ predictor.reset();
+ } catch (e) {}
+ },
+
+ get canClear() {
+ // bug 347231: Always allow clearing history due to dependencies on
+ // the browser:purge-session-history notification. (like error console)
+ return true;
+ },
+ },
+ },
+};
+
+// "Static" members
+Sanitizer.prefDomain = "privacy.sanitize.";
+Sanitizer.prefShutdown = "sanitizeOnShutdown";
+Sanitizer.prefDidShutdown = "didShutdownSanitize";
+
+// Time span constants corresponding to values of the privacy.sanitize.timeSpan
+// pref. Used to determine how much history to clear, for various items
+Sanitizer.TIMESPAN_EVERYTHING = 0;
+Sanitizer.TIMESPAN_HOUR = 1;
+Sanitizer.TIMESPAN_2HOURS = 2;
+Sanitizer.TIMESPAN_4HOURS = 3;
+Sanitizer.TIMESPAN_TODAY = 4;
+
+// Return a 2 element array representing the start and end times,
+// in the uSec-since-epoch format that PRTime likes. If we should
+// clear everything, return null. Use ts if it is defined; otherwise
+// use the timeSpan pref.
+Sanitizer.getClearRange = function (ts) {
+ if (ts === undefined) {
+ ts = Sanitizer.prefs.getIntPref("timeSpan");
+ }
+ if (ts === Sanitizer.TIMESPAN_EVERYTHING) {
+ return null;
+ }
+
+ // PRTime is microseconds while JS time is milliseconds
+ var endDate = Date.now() * 1000;
+ switch (ts) {
+ case Sanitizer.TIMESPAN_HOUR:
+ var startDate = endDate - 3600000000; // 1*60*60*1000000
+ break;
+ case Sanitizer.TIMESPAN_2HOURS:
+ startDate = endDate - 7200000000; // 2*60*60*1000000
+ break;
+ case Sanitizer.TIMESPAN_4HOURS:
+ startDate = endDate - 14400000000; // 4*60*60*1000000
+ break;
+ case Sanitizer.TIMESPAN_TODAY:
+ var d = new Date(); // Start with today
+ d.setHours(0); // zero us back to midnight...
+ d.setMinutes(0);
+ d.setSeconds(0);
+ startDate = d.valueOf() * 1000; // convert to epoch usec
+ break;
+ default:
+ throw new Error("Invalid time span for clear private data: " + ts);
+ }
+ return [startDate, endDate];
+};
+
+Sanitizer._prefs = null;
+Sanitizer.__defineGetter__("prefs", function () {
+ return Sanitizer._prefs
+ ? Sanitizer._prefs
+ : (Sanitizer._prefs = Services.prefs.getBranch(Sanitizer.prefDomain));
+});
+
+// Shows sanitization UI
+Sanitizer.showUI = function (aParentWindow) {
+ Services.ww.openWindow(
+ AppConstants.platform == "macosx" ? null : aParentWindow,
+ "chrome://messenger/content/sanitize.xhtml",
+ "Sanitize",
+ "chrome,titlebar,dialog,centerscreen,modal",
+ null
+ );
+};
+
+/**
+ * Deletes privacy sensitive data in a batch, optionally showing the
+ * sanitize UI, according to user preferences
+ */
+Sanitizer.sanitize = function (aParentWindow) {
+ Sanitizer.showUI(aParentWindow);
+};
+
+// this is called on startup and shutdown, to perform pending sanitizations
+Sanitizer._checkAndSanitize = function () {
+ const prefs = Sanitizer.prefs;
+ if (
+ prefs.getBoolPref(Sanitizer.prefShutdown) &&
+ !prefs.prefHasUserValue(Sanitizer.prefDidShutdown)
+ ) {
+ // this is a shutdown or a startup after an unclean exit
+ var s = new Sanitizer();
+ s.prefDomain = "privacy.clearOnShutdown.";
+ s.sanitize() || prefs.setBoolPref(Sanitizer.prefDidShutdown, true); // sanitize() returns null on full success
+ }
+};
diff --git a/comm/mail/base/content/sanitize.xhtml b/comm/mail/base/content/sanitize.xhtml
new file mode 100644
index 0000000000..b9186841ab
--- /dev/null
+++ b/comm/mail/base/content/sanitize.xhtml
@@ -0,0 +1,92 @@
+<?xml version="1.0"?>
+
+<!-- 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/. -->
+
+<?xml-stylesheet href="chrome://global/skin/global.css"?>
+<?xml-stylesheet href="chrome://messenger/skin/variables.css"?>
+<?xml-stylesheet href="chrome://messenger/skin/shared/sanitizeDialog.css"?>
+<?xml-stylesheet href="chrome://messenger/skin/contextMenu.css"?>
+<?xml-stylesheet href="chrome://messenger/skin/colors.css"?>
+<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css"?>
+
+<!DOCTYPE window [
+ <!ENTITY % brandDTD SYSTEM "chrome://branding/locale/brand.dtd">
+ <!ENTITY % sanitizeDTD SYSTEM "chrome://messenger/locale/sanitize.dtd">
+ %brandDTD;
+ %sanitizeDTD;
+]>
+
+<window type="child"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ title="&sanitizeDialog2.title;"
+ noneverythingtitle="&sanitizeDialog2.title;"
+ style="min-width: &dialog.width;"
+ lightweightthemes="true"
+ onload="gSanitizePromptDialog.init();">
+<dialog id="SanitizeDialog"
+ dlgbuttons="accept,cancel">
+
+ <vbox id="SanitizeDialogPane">
+ <stringbundle id="bundleBrowser"
+ src="chrome://messenger/locale/messenger.properties"/>
+
+ <script src="chrome://messenger/content/sanitize.js"/>
+ <script src="chrome://messenger/content/sanitizeDialog.js"/>
+ <script src="chrome://messenger/content/dialogShadowDom.js"/>
+
+ <hbox id="SanitizeDurationBox" align="center">
+ <label value="&clearTimeDuration.label;"
+ accesskey="&clearTimeDuration.accesskey;"
+ control="sanitizeDurationChoice"
+ id="sanitizeDurationLabel"/>
+ <menulist id="sanitizeDurationChoice"
+ onselect="gSanitizePromptDialog.selectByTimespan();"
+ flex="1">
+ <menupopup id="sanitizeDurationPopup">
+ <menuitem label="&clearTimeDuration.lastHour;" value="1"/>
+ <menuitem label="&clearTimeDuration.last2Hours;" value="2"/>
+ <menuitem label="&clearTimeDuration.last4Hours;" value="3"/>
+ <menuitem label="&clearTimeDuration.today;" value="4"/>
+ <menuseparator/>
+ <menuitem label="&clearTimeDuration.everything;" value="0"/>
+ </menupopup>
+ </menulist>
+ <label id="sanitizeDurationSuffixLabel"
+ value="&clearTimeDuration.suffix;"/>
+ </hbox>
+
+ <vbox id="sanitizeEverythingWarningBox">
+ <spacer flex="1"/>
+ <hbox align="center">
+ <html:img id="sanitizeEverythingWarningIcon" alt="" />
+ <vbox id="sanitizeEverythingWarningDescBox" flex="1">
+ <description id="sanitizeEverythingWarning"/>
+ <description id="sanitizeEverythingUndoWarning">&sanitizeEverythingUndoWarning;</description>
+ </vbox>
+ </hbox>
+ <spacer flex="1"/>
+ </vbox>
+
+ <label id="historyGroupLabel" value="&historyGroup.label;"/>
+ <vbox id="historyGroup">
+ <checkbox label="&itemHistory.label;"
+ accesskey="&itemHistory.accesskey;"
+ preference="privacy.cpd.history"
+ oncommand="gSanitizePromptDialog.onReadGeneric();"/>
+ <checkbox label="&itemCookies.label;"
+ accesskey="&itemCookies.accesskey;"
+ preference="privacy.cpd.cookies"
+ oncommand="gSanitizePromptDialog.onReadGeneric();"/>
+ <checkbox label="&itemCache.label;"
+ accesskey="&itemCache.accesskey;"
+ preference="privacy.cpd.cache"
+ oncommand="gSanitizePromptDialog.onReadGeneric();"/>
+ </vbox>
+ <separator class="thin"/>
+
+ </vbox>
+</dialog>
+</window>
diff --git a/comm/mail/base/content/sanitizeDialog.js b/comm/mail/base/content/sanitizeDialog.js
new file mode 100644
index 0000000000..457cd4dc45
--- /dev/null
+++ b/comm/mail/base/content/sanitizeDialog.js
@@ -0,0 +1,206 @@
+/* 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 sanitize.js */
+
+var gSanitizePromptDialog = {
+ get bundleBrowser() {
+ if (!this._bundleBrowser) {
+ this._bundleBrowser = document.getElementById("bundleBrowser");
+ }
+ return this._bundleBrowser;
+ },
+
+ get selectedTimespan() {
+ var durList = document.getElementById("sanitizeDurationChoice");
+ return parseInt(durList.value);
+ },
+
+ get warningBox() {
+ return document.getElementById("sanitizeEverythingWarningBox");
+ },
+
+ init() {
+ // This is used by selectByTimespan() to determine if the window has loaded.
+ this._inited = true;
+
+ var s = new Sanitizer();
+ s.prefDomain = "privacy.cpd.";
+
+ document.getElementById("sanitizeDurationChoice").value =
+ Services.prefs.getIntPref("privacy.sanitize.timeSpan");
+
+ let sanitizeItemList = document.querySelectorAll(
+ "#historyGroup > [preference]"
+ );
+ for (let prefItem of sanitizeItemList) {
+ let name = s.getNameFromPreference(prefItem.getAttribute("preference"));
+ if (!s.canClearItem(name)) {
+ prefItem.preference = null;
+ prefItem.checked = false;
+ prefItem.disabled = true;
+ } else {
+ prefItem.checked = Services.prefs.getBoolPref(
+ prefItem.getAttribute("preference")
+ );
+ }
+ }
+
+ this.onReadGeneric();
+
+ document.querySelector("dialog").getButton("accept").label =
+ this.bundleBrowser.getString("sanitizeButtonOK");
+
+ let warningIcon = document.getElementById("sanitizeEverythingWarningIcon");
+ warningIcon.setAttribute(
+ "src",
+ "chrome://messenger/skin/icons/new/activity/warning.svg"
+ );
+
+ if (this.selectedTimespan === Sanitizer.TIMESPAN_EVERYTHING) {
+ this.prepareWarning();
+ this.warningBox.hidden = false;
+ document.title = this.bundleBrowser.getString(
+ "sanitizeDialog2.everything.title"
+ );
+ } else {
+ this.warningBox.hidden = true;
+ }
+ },
+
+ selectByTimespan() {
+ // This method is the onselect handler for the duration dropdown. As a
+ // result it's called a couple of times before onload calls init().
+ if (!this._inited) {
+ return;
+ }
+
+ var warningBox = this.warningBox;
+
+ // If clearing everything
+ if (this.selectedTimespan === Sanitizer.TIMESPAN_EVERYTHING) {
+ this.prepareWarning();
+ if (warningBox.hidden) {
+ warningBox.hidden = false;
+ }
+ window.sizeToContent();
+ window.document.title = this.bundleBrowser.getString(
+ "sanitizeDialog2.everything.title"
+ );
+ return;
+ }
+
+ // If clearing a specific time range
+ if (!warningBox.hidden) {
+ window.resizeBy(0, -warningBox.getBoundingClientRect().height);
+ warningBox.hidden = true;
+ }
+ window.document.title =
+ window.document.documentElement.getAttribute("noneverythingtitle");
+ },
+
+ sanitize() {
+ // Update pref values before handing off to the sanitizer (bug 453440)
+ this.updatePrefs();
+ var s = new Sanitizer();
+ s.prefDomain = "privacy.cpd.";
+
+ s.range = Sanitizer.getClearRange(this.selectedTimespan);
+ s.ignoreTimespan = !s.range;
+
+ try {
+ s.sanitize();
+ } catch (er) {
+ console.error("Exception during sanitize: " + er);
+ }
+ },
+
+ /**
+ * If the panel that displays a warning when the duration is "Everything" is
+ * not set up, sets it up. Otherwise does nothing.
+ */
+ prepareWarning() {
+ // If the date and time-aware locale warning string is ever used again,
+ // initialize it here. Currently we use the no-visits warning string,
+ // which does not include date and time. See bug 480169 comment 48.
+
+ var warningStringID;
+ if (this.hasNonSelectedItems()) {
+ warningStringID = "sanitizeSelectedWarning";
+ } else {
+ warningStringID = "sanitizeEverythingWarning2";
+ }
+
+ var warningDesc = document.getElementById("sanitizeEverythingWarning");
+ warningDesc.textContent = this.bundleBrowser.getString(warningStringID);
+ },
+
+ /**
+ * Called when the value of a preference element is synced from the actual
+ * pref. Enables or disables the OK button appropriately.
+ */
+ onReadGeneric() {
+ var found = false;
+
+ // Find any other pref that's checked and enabled.
+ let sanitizeItemList = document.querySelectorAll(
+ "#historyGroup > [preference]"
+ );
+ for (let prefItem of sanitizeItemList) {
+ found = !prefItem.disabled && prefItem.checked;
+ if (found) {
+ break;
+ }
+ }
+
+ try {
+ document.querySelector("dialog").getButton("accept").disabled = !found;
+ } catch (e) {}
+
+ // Update the warning prompt if needed
+ this.prepareWarning();
+
+ return undefined;
+ },
+
+ /**
+ * Sanitizer.prototype.sanitize() requires the prefs to be up-to-date.
+ * Because the type of this prefwindow is "child" -- and that's needed because
+ * without it the dialog has no OK and Cancel buttons -- the prefs are not
+ * updated on dialogaccept on platforms that don't support instant-apply
+ * (i.e., Windows). We must therefore manually set the prefs from their
+ * corresponding preference elements.
+ */
+ updatePrefs() {
+ Sanitizer.prefs.setIntPref("timeSpan", this.selectedTimespan);
+
+ // Now manually set the prefs from their corresponding preference elements.
+ let sanitizeItemList = document.querySelectorAll(
+ "#historyGroup > [preference]"
+ );
+ for (let prefItem of sanitizeItemList) {
+ let prefName = prefItem.getAttribute("preference");
+ Services.prefs.setBoolPref(prefName, prefItem.checked);
+ }
+ },
+
+ /**
+ * Check if all of the history items have been selected like the default status.
+ */
+ hasNonSelectedItems() {
+ let sanitizeItemList = document.querySelectorAll(
+ "#historyGroup > [preference]"
+ );
+ for (let prefItem of sanitizeItemList) {
+ if (!prefItem.checked) {
+ return true;
+ }
+ }
+ return false;
+ },
+};
+
+document.addEventListener("dialogaccept", () =>
+ gSanitizePromptDialog.sanitize()
+);
diff --git a/comm/mail/base/content/searchBar.js b/comm/mail/base/content/searchBar.js
new file mode 100644
index 0000000000..48e066a05b
--- /dev/null
+++ b/comm/mail/base/content/searchBar.js
@@ -0,0 +1,45 @@
+/* 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/. */
+
+var gStatusBar = document.getElementById("statusbar-icon");
+
+/**
+ * The glodasearch widget is a UI widget (the #searchInput textbox) which is
+ * outside of the mailTabType's display panel, but acts as though it were within
+ * it.. This means we need to use a tab monitor so that we can appropriately
+ * update the contents of the textbox.
+ *
+ * Every time a tab is changed, we save the state of the text box and restore
+ * its previous value for the tab we are switching to, as well as whether this
+ * value is a change to the currently-used value (if it is a faceted search) tab.
+ * The behaviour rationale for this is that the searchInput is like the
+ * URL bar. When you are on a glodaSearch tab, we need to show you your
+ * current value, including any "uncommitted" (you haven't hit enter yet)
+ * changes.
+ *
+ * In addition, we want to disable the quick-search modes when a tab is
+ * being displayed that lacks quick search abilities (but we'll leave the
+ * faceted search as it's always available).
+ */
+
+var GlodaSearchBoxTabMonitor = {
+ monitorName: "glodaSearchBox",
+
+ onTabSwitched(aTab, aOldTab) {},
+
+ onTabTitleChanged() {},
+
+ onTabOpened(aTab, aFirstTab, aOldTab) {
+ aTab._ext.glodaSearchBox = {
+ value: aTab.mode.name === "glodaFacet" ? aTab.searchString : "",
+ };
+
+ if (aTab.mode.name === "glodaFacet") {
+ let searchInput = aTab.panel.querySelector(".remote-gloda-search");
+ if (searchInput) {
+ searchInput.value = aTab.searchString;
+ }
+ }
+ },
+};
diff --git a/comm/mail/base/content/selectionsummaries.js b/comm/mail/base/content/selectionsummaries.js
new file mode 100644
index 0000000000..75461e3a54
--- /dev/null
+++ b/comm/mail/base/content/selectionsummaries.js
@@ -0,0 +1,103 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals gSummaryFrameManager */ // From messenger.js
+
+/**
+ * Summarize a set of selected messages. This can either be a single thread or
+ * multiple threads.
+ *
+ * @param aMessageDisplay The MessageDisplayWidget object responsible for
+ * showing messages.
+ */
+function summarizeSelection(aMessageDisplay) {
+ // Figure out if we're looking at one thread or more than one thread. We want
+ // the view's version of threading, not the database's version, in order to
+ // thread together cross-folder messages. XXX: This falls apart for group by
+ // sort; what we really want is a way to specify only the cross-folder view.
+ let folderDisplay = aMessageDisplay.folderDisplay;
+ let selectedIndices = folderDisplay.selectedIndices;
+ let dbView = folderDisplay.view.dbView;
+
+ let getThreadId = function (index) {
+ return dbView.getThreadContainingIndex(index).getRootHdr().messageKey;
+ };
+
+ let firstThreadId = getThreadId(selectedIndices[0]);
+ let oneThread = true;
+ for (let i = 1; i < selectedIndices.length; i++) {
+ if (getThreadId(selectedIndices[i]) != firstThreadId) {
+ oneThread = false;
+ break;
+ }
+ }
+
+ let selectedMessages = folderDisplay.selectedMessages;
+ if (oneThread) {
+ summarizeThread(selectedMessages, aMessageDisplay);
+ } else {
+ summarizeMultipleSelection(selectedMessages, aMessageDisplay);
+ }
+}
+
+/**
+ * Given an array of messages which are all in the same thread, summarize them.
+ *
+ * @param aSelectedMessages Array of message headers.
+ * @param aMessageDisplay The MessageDisplayWidget object responsible for
+ * showing messages.
+ */
+function summarizeThread(aSelectedMessages, aMessageDisplay) {
+ const kSummaryURL = "chrome://messenger/content/multimessageview.xhtml";
+
+ aMessageDisplay.singleMessageDisplay = false;
+ gSummaryFrameManager.loadAndCallback(kSummaryURL, function () {
+ let childWindow = gSummaryFrameManager.iframe.contentWindow;
+ try {
+ childWindow.gMessageSummary.summarize(
+ "thread",
+ aSelectedMessages,
+ aMessageDisplay.folderDisplay.view.dbView,
+ aMessageDisplay.folderDisplay.selectMessages.bind(
+ aMessageDisplay.folderDisplay
+ ),
+ aMessageDisplay
+ );
+ } catch (e) {
+ console.error(e);
+ throw e;
+ }
+ });
+}
+
+/**
+ * Given an array of message URIs, cause the message panel to display a summary
+ * of them.
+ *
+ * @param aSelectedMessages Array of message headers.
+ * @param aMessageDisplay The MessageDisplayWidget object responsible for
+ * showing messages.
+ */
+function summarizeMultipleSelection(aSelectedMessages, aMessageDisplay) {
+ const kSummaryURL = "chrome://messenger/content/multimessageview.xhtml";
+
+ aMessageDisplay.singleMessageDisplay = false;
+ gSummaryFrameManager.loadAndCallback(kSummaryURL, function () {
+ let childWindow = gSummaryFrameManager.iframe.contentWindow;
+ try {
+ childWindow.gMessageSummary.summarize(
+ "multipleselection",
+ aSelectedMessages,
+ aMessageDisplay.folderDisplay.view.dbView,
+ aMessageDisplay.folderDisplay.selectMessages.bind(
+ aMessageDisplay.folderDisplay
+ ),
+ aMessageDisplay
+ );
+ } catch (e) {
+ console.error(e);
+ throw e;
+ }
+ });
+}
diff --git a/comm/mail/base/content/shortcutsOverlay.js b/comm/mail/base/content/shortcutsOverlay.js
new file mode 100644
index 0000000000..85bf20b5ec
--- /dev/null
+++ b/comm/mail/base/content/shortcutsOverlay.js
@@ -0,0 +1,123 @@
+/* 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/. */
+
+"use strict";
+
+// Wrap in a block to prevent leaking to window scope.
+{
+ var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+ );
+
+ XPCOMUtils.defineLazyModuleGetters(this, {
+ ShortcutsManager: "resource:///modules/ShortcutsManager.jsm",
+ });
+
+ function setupShortcuts() {
+ // Set up all dedicated shortcuts.
+ setupSpacesShortcuts();
+
+ // Set up the event listener.
+ setupEventListener();
+ }
+
+ /**
+ * Use the ShortcutManager to set up all keyboard shortcuts for the spaces
+ * toolbar buttons.
+ */
+ async function setupSpacesShortcuts() {
+ // Set up all shortcut strings for the various spaces buttons.
+ let buttons = {
+ "space-toggle": ["collapseButton", "spacesToolbarReveal"],
+ "space-mail": ["mailButton"],
+ "space-addressbook": ["addressBookButton"],
+ "space-calendar": ["calendarButton"],
+ "space-tasks": ["tasksButton"],
+ "space-chat": ["chatButton"],
+ };
+ for (let [string, ids] of Object.entries(buttons)) {
+ let shortcut = await ShortcutsManager.getShortcutStrings(string);
+ if (!shortcut) {
+ continue;
+ }
+
+ for (let id of ids) {
+ let button = document.getElementById(id);
+ button.setAttribute("aria-label", button.title);
+ document.l10n.setAttributes(button, "button-shortcut-string", {
+ title: button.title,
+ shortcut: shortcut.localizedShortcut,
+ });
+ button.setAttribute("aria-keyshortcuts", shortcut.ariaKeyShortcuts);
+ }
+ }
+
+ // Set up all shortcut strings for the various spaces menuitems.
+ let menuitems = {
+ "space-toggle": ["spacesPopupButtonReveal"],
+ "space-mail": ["spacesPopupButtonMail"],
+ "space-addressbook": ["spacesPopupButtonAddressBook"],
+ "space-calendar": [
+ "spacesPopupButtonCalendar",
+ "calMenuSwitchToCalendar",
+ ],
+ "space-tasks": ["spacesPopupButtonTasks", "calMenuSwitchToTask"],
+ "space-chat": ["spacesPopupButtonChat", "menu_goChat"],
+ };
+ for (let [string, ids] of Object.entries(menuitems)) {
+ let shortcut = await ShortcutsManager.getShortcutStrings(string);
+ if (!shortcut) {
+ continue;
+ }
+
+ for (let id of ids) {
+ let menuitem = document.getElementById(id);
+ if (!menuitem.label) {
+ await document.l10n.translateElements([menuitem]);
+ }
+ document.l10n.setAttributes(menuitem, "menuitem-shortcut-string", {
+ label: menuitem.label,
+ shortcut: shortcut.localizedShortcut,
+ });
+ }
+ }
+ }
+
+ /**
+ * Set up the keydown event to intercept shortcuts.
+ */
+ function setupEventListener() {
+ let tabmail = document.getElementById("tabmail");
+
+ window.addEventListener("keydown", event => {
+ let shortcut = ShortcutsManager.matches(event);
+ // FIXME: Temporarily ignore numbers coming from the Numpad to prevent
+ // hijacking Alt characters typing in Windows. This can be removed once
+ // we implement customizable shortcuts.
+ if (!shortcut || event.location == 3) {
+ return;
+ }
+ event.preventDefault();
+ event.stopPropagation();
+
+ switch (shortcut.id) {
+ case "space-toggle":
+ window.gSpacesToolbar.toggleToolbar(!window.gSpacesToolbar.isHidden);
+ break;
+ case "space-mail":
+ case "space-addressbook":
+ case "space-calendar":
+ case "space-tasks":
+ case "space-chat":
+ let space = window.gSpacesToolbar.spaces.find(
+ space => space.name == shortcut.id.replace("space-", "")
+ );
+ window.gSpacesToolbar.openSpace(tabmail, space);
+ break;
+ }
+ });
+ }
+
+ window.addEventListener("load", setupShortcuts);
+}
diff --git a/comm/mail/base/content/spacesToolbar.inc.xhtml b/comm/mail/base/content/spacesToolbar.inc.xhtml
new file mode 100644
index 0000000000..440cfe9ee6
--- /dev/null
+++ b/comm/mail/base/content/spacesToolbar.inc.xhtml
@@ -0,0 +1,166 @@
+# 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/.
+
+<popupset id="spacesToolbarPopupSet">
+ <menupopup id="spacesContextMenu"
+ class="no-icon-menupopup no-accel-menupopup">
+ <menuitem id="spacesContextNewTabItem"
+ class="menuitem-iconic"
+ data-l10n-id="spaces-context-new-tab-item"/>
+ <menuitem id="spacesContextNewWindowItem"
+ class="menuitem-iconic"
+ data-l10n-id="spaces-context-new-window-item"/>
+ <menuseparator id="spacesContextMenuSeparator" hidden="true"/>
+ </menupopup>
+
+ <menupopup id="settingsContextMenu"
+ class="no-icon-menupopup no-accel-menupopup">
+ <menuitem id="settingsContextOpenSettingsItem"
+ class="menuitem-iconic"
+ data-l10n-id="settings-context-open-settings-item2"/>
+ <menuitem id="settingsContextOpenAccountSettingsItem"
+ class="menuitem-iconic"
+ data-l10n-id="settings-context-open-account-settings-item2"/>
+ <menuitem id="settingsContextOpenAddonsItem"
+ class="menuitem-iconic"
+ data-l10n-id="settings-context-open-addons-item2"/>
+ <menuseparator/>
+ <menuitem id="settingsContextOpenCustomizeItem"
+ class="menuitem-iconic"
+ data-l10n-id="menuitem-customize-label"/>
+ </menupopup>
+
+ <menupopup id="spacesToolbarContextMenu"
+ class="no-icon-menupopup no-accel-menupopup">
+ <menuitem id="spacesToolbarContextCustomize"
+ class="menuitem-iconic"
+ data-l10n-id="menuitem-customize-label"
+ oncommand="gSpacesToolbar.showCustomize();"/>
+ </menupopup>
+
+ <menupopup id="spacesToolbarAddonsPopup" type="arrow"
+ class="no-accel-menupopup"
+ onpopuphidden="gSpacesToolbar.spacesToolbarAddonsPopupClosed();">
+ </menupopup>
+</popupset>
+
+<html:div role="toolbar" id="spacesToolbar" xmlns="http://www.w3.org/1999/xhtml"
+ class="spaces-toolbar"
+ data-l10n-id="spaces-toolbar-element"
+ data-l10n-attrs="toolbarname"
+ aria-orientation="vertical"
+ hidden="hidden">
+ <!-- Hidden element used to fetch the style of an "active" button. -->
+ <span id="spacesAccentPlaceholder" hidden="hidden"></span>
+
+ <!-- Primary tabs. -->
+ <div class="spaces-toolbar-container spaces-toolbar-top-container">
+ <button id="mailButton"
+ data-l10n-id="spaces-toolbar-button-mail2"
+ class="spaces-toolbar-button"
+ tabindex="0">
+ <img src="" alt="" />
+ </button>
+ <button id="addressBookButton"
+ data-l10n-id="spaces-toolbar-button-address-book2"
+ class="spaces-toolbar-button"
+ tabindex="-1">
+ <img src="" alt="" />
+ </button>
+ <button id="calendarButton"
+ data-l10n-id="spaces-toolbar-button-calendar2"
+ class="spaces-toolbar-button"
+ tabindex="-1">
+ <img src="" alt="" />
+ </button>
+ <button id="tasksButton"
+ data-l10n-id="spaces-toolbar-button-tasks2"
+ class="spaces-toolbar-button"
+ tabindex="-1">
+ <img src="" alt="" />
+ </button>
+ <button id="chatButton"
+ data-l10n-id="spaces-toolbar-button-chat2"
+ class="spaces-toolbar-button"
+ tabindex="-1">
+ <span class="spaces-badge-container"></span>
+ <img src="" alt="" />
+ </button>
+ </div>
+
+ <div id="spacesToolbarAddonsContainer" class="spaces-toolbar-container">
+ <!-- Special container to allow add-ons to add buttons. -->
+ </div>
+ <button id="spacesToolbarAddonsOverflowButton"
+ data-l10n-id="spaces-toolbar-button-overflow"
+ class="spaces-toolbar-button"
+ aria-expanded="false"
+ aria-haspopup="menu"
+ aria-controls="spacesToolbarAddonsPopup"
+ hidden="hidden"
+ tabindex="-1">
+ <img src="" alt="" />
+ </button>
+
+ <div class="spaces-toolbar-container spaces-toolbar-bottom-container">
+ <!-- Settings button. -->
+ <button id="settingsButton"
+ data-l10n-id="spaces-toolbar-button-settings2"
+ class="spaces-toolbar-button"
+ tabindex="-1">
+ <img src="" alt="" />
+ </button>
+
+ <!-- Collapse button. -->
+ <button id="collapseButton"
+ data-l10n-id="spaces-toolbar-button-hide"
+ class="spaces-toolbar-button"
+ tabindex="-1">
+ <img src="" alt="" />
+ </button>
+ </div>
+</html:div>
+
+<panel id="spacesToolbarCustomizationPanel"
+ type="arrow"
+ orient="vertical"
+ class="cui-widget-panel popup-panel panel-no-padding"
+ noautohide="true"
+ onpopuphidden="gSpacesToolbar.onCustomizePopupHidden();">
+ <!-- Keep the noautohide attribute to true to prevent the panel from closing
+ when the color picker appears. -->
+ <html:div class="popup-panel-body" xmlns="http://www.w3.org/1999/xhtml">
+ <h3 data-l10n-id="spaces-customize-panel-title"></h3>
+
+ <div class="popup-panel-options-grid">
+ <label for="spacesBackgroundColor"
+ data-l10n-id="spaces-customize-background-color"></label>
+ <input type="color" id="spacesBackgroundColor" />
+
+ <label for="spacesIconsColor"
+ data-l10n-id="spaces-customize-icon-color"></label>
+ <input type="color" id="spacesIconsColor" />
+
+ <label for="spacesAccentBgColor"
+ data-l10n-id="spaces-customize-accent-background-color"></label>
+ <input type="color" id="spacesAccentBgColor" />
+
+ <label for="spacesAccentTextColor"
+ data-l10n-id="spaces-customize-accent-text-color"></label>
+ <input type="color" id="spacesAccentTextColor" />
+ </div>
+
+ <div class="popup-panel-buttons-container">
+ <button type="button"
+ data-l10n-id="spaces-customize-button-restore"
+ onclick="gSpacesToolbar.resetColorCustomization();">
+ </button>
+ <button type="button"
+ data-l10n-id="customize-panel-button-save"
+ onclick="gSpacesToolbar.closeCustomize();"
+ class="primary">
+ </button>
+ </div>
+ </html:div>
+</panel>
diff --git a/comm/mail/base/content/spacesToolbar.js b/comm/mail/base/content/spacesToolbar.js
new file mode 100644
index 0000000000..4155d7d0dc
--- /dev/null
+++ b/comm/mail/base/content/spacesToolbar.js
@@ -0,0 +1,1325 @@
+/* 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/. */
+
+"use strict";
+
+/* import-globals-from mailCore.js */
+/* import-globals-from utilityOverlay.js */
+
+/**
+ * Special vertical toolbar to organize all the buttons opening a tab.
+ */
+var gSpacesToolbar = {
+ SUPPORTED_BADGE_STYLES: ["--spaces-button-badge-bg-color"],
+ SUPPORTED_ICON_STYLES: [
+ "--webextension-toolbar-image",
+ "--webextension-toolbar-image-dark",
+ "--webextension-toolbar-image-light",
+ "--webextension-toolbar-image-2x",
+ "--webextension-toolbar-image-2x-dark",
+ "--webextension-toolbar-image-2x-light",
+ ],
+ docURL: "chrome://messenger/content/messenger.xhtml",
+ /**
+ * The spaces toolbar DOM element.
+ *
+ * @type {?HTMLElement}
+ */
+ element: null,
+ /**
+ * If the spaces toolbar has already been loaded.
+ *
+ * @type {boolean}
+ */
+ isLoaded: false,
+ /**
+ * If the spaces toolbar is hidden or visible.
+ *
+ * @type {boolean}
+ */
+ isHidden: false,
+ /**
+ * If the spaces toolbar is currently being customized.
+ *
+ * @type {boolean}
+ */
+ isCustomizing: false,
+ /**
+ * The DOM element panel collecting all customization options.
+ */
+ customizePanel: null,
+ /**
+ * The object storing all saved customization options:
+ * - background: The toolbar background color.
+ * - color: The default icon color of the buttons.
+ * - accentColor: The background color of an active/current button.
+ * - accentBackground: The icon color of an active/current button.
+ */
+ customizeData: {},
+
+ /**
+ * @callback TabInSpace
+ * @param {object} tabInfo - The tabInfo object (a member of tabmail.tabInfo)
+ * for the tab.
+ * @returns {0|1|2} - The relation between the tab and the space. 0 means it
+ * does not belong to the space. 1 means it is a primary tab of the space.
+ * 2 means it is a secondary tab of the space.
+ */
+ /**
+ * @callback OpenSpace
+ * @param {"tab"|"window"} where - Where to open the space: in a new tab or in
+ * a new window.
+ * @returns {?object | Window} - The tabInfo for the newly opened tab, or the
+ * newly opened messenger window, or null if neither was created.
+ */
+ /**
+ * Data and methods for a space.
+ *
+ * @typedef {object} SpaceInfo
+ * @property {string} name - The name for this space.
+ * @property {boolean} allowMultipleTabs - Whether to allow the user to open
+ * multiple tabs in this space.
+ * @property {HTMLButtonElement} button - The toolbar button for this space.
+ * @property {?XULMenuItem} menuitem - The menuitem for this space, if
+ * available.
+ * @property {TabInSpace} tabInSpace - A callback that determines whether an
+ * existing tab is considered outside this space, a primary tab of this
+ * space (a tab that is similar to the tab created by the open method) or
+ * a secondary tab of this space (a related tab that still belongs to this
+ * space).
+ * @property {OpenSpace} open - A callback to open this space.
+ */
+ /**
+ * The main spaces in this toolbar. This will be constructed on load.
+ *
+ * @type {?SpaceInfo[]}
+ */
+ spaces: null,
+ /**
+ * The current space the window is in, or undefined if it is not in any of the
+ * known spaces.
+ *
+ * @type {SpaceInfo|undefined}
+ */
+ currentSpace: undefined,
+ /**
+ * The number of buttons created by add-ons.
+ *
+ * @returns {integer}
+ */
+ get addonButtonCount() {
+ return document.querySelectorAll(".spaces-addon-button").length;
+ },
+ /**
+ * The number of pixel spacing to add to the add-ons button height calculation
+ * based on the current UI density.
+ *
+ * @type {integer}
+ */
+ densitySpacing: 0,
+ /**
+ * The button that can receive focus. Used for managing focus with a roving
+ * tabindex.
+ *
+ * @type {?HTMLElement}
+ */
+ focusButton: null,
+
+ tabMonitor: {
+ monitorName: "spacesToolbarMonitor",
+
+ onTabTitleChanged() {},
+ onTabOpened() {},
+ onTabPersist() {},
+ onTabRestored() {},
+ onTabClosing() {},
+
+ onTabSwitched(newTabInfo, oldTabInfo) {
+ // Bail out if for whatever reason something went wrong.
+ if (!newTabInfo) {
+ console.error(
+ "Spaces Toolbar: Missing new tab on monitored tab switching"
+ );
+ return;
+ }
+
+ let tabSpace = gSpacesToolbar.spaces.find(space =>
+ space.tabInSpace(newTabInfo)
+ );
+ if (gSpacesToolbar.currentSpace != tabSpace) {
+ gSpacesToolbar.currentSpace?.button.classList.remove("current");
+ gSpacesToolbar.currentSpace?.menuitem?.classList.remove("current");
+ gSpacesToolbar.currentSpace = tabSpace;
+ if (gSpacesToolbar.currentSpace) {
+ gSpacesToolbar.currentSpace.button.classList.add("current");
+ gSpacesToolbar.currentSpace.menuitem?.classList.add("current");
+ gSpacesToolbar.setFocusButton(gSpacesToolbar.currentSpace.button);
+ }
+
+ const spaceChangeEvent = new CustomEvent("spacechange", {
+ detail: tabSpace,
+ });
+ gSpacesToolbar.element.dispatchEvent(spaceChangeEvent);
+ }
+ },
+ },
+
+ /**
+ * Convert an rgb() string to an hexadecimal color string.
+ *
+ * @param {string} color - The RGBA color string that needs conversion.
+ * @returns {string} - The converted hexadecimal color.
+ */
+ _rgbToHex(color) {
+ let rgb = color.split("(")[1].split(")")[0].split(",");
+
+ // For each array element convert ot a base16 string and add zero if we get
+ // only one character.
+ let hash = rgb.map(x => parseInt(x).toString(16).padStart(2, "0"));
+
+ return `#${hash.join("")}`;
+ },
+
+ onLoad() {
+ if (this.isLoaded) {
+ return;
+ }
+
+ this.element = document.getElementById("spacesToolbar");
+ this.focusButton = document.getElementById("mailButton");
+ let tabmail = document.getElementById("tabmail");
+
+ this.spaces = [
+ {
+ name: "mail",
+ button: document.getElementById("mailButton"),
+ menuitem: document.getElementById("spacesPopupButtonMail"),
+ tabInSpace(tabInfo) {
+ switch (tabInfo.mode.name) {
+ case "folder":
+ case "mail3PaneTab":
+ case "mailMessageTab":
+ return 1;
+ default:
+ return 0;
+ }
+ },
+ open(where) {
+ // Prefer the current tab, else the earliest tab.
+ let existingTab = [tabmail.currentTabInfo, ...tabmail.tabInfo].find(
+ tabInfo => this.tabInSpace(tabInfo) == 1
+ );
+ let folderURI = null;
+ switch (existingTab?.mode.name) {
+ case "folder":
+ folderURI =
+ existingTab.folderDisplay.displayedFolder?.URI || null;
+ break;
+ case "mail3PaneTab":
+ folderURI = existingTab.folder.URI || null;
+ break;
+ }
+ if (where == "window") {
+ return window.openDialog(
+ "chrome://messenger/content/messenger.xhtml",
+ "_blank",
+ "chrome,dialog=no,all",
+ folderURI,
+ -1
+ );
+ }
+ return openTab("mail3PaneTab", { folderURI }, "tab");
+ },
+ allowMultipleTabs: true,
+ },
+ {
+ name: "addressbook",
+ button: document.getElementById("addressBookButton"),
+ menuitem: document.getElementById("spacesPopupButtonAddressBook"),
+ tabInSpace(tabInfo) {
+ if (tabInfo.mode.name == "addressBookTab") {
+ return 1;
+ }
+ return 0;
+ },
+ open(where) {
+ return openTab("addressBookTab", {}, where);
+ },
+ },
+ {
+ name: "calendar",
+ button: document.getElementById("calendarButton"),
+ menuitem: document.getElementById("spacesPopupButtonCalendar"),
+ tabInSpace(tabInfo) {
+ return tabInfo.mode.name == "calendar" ? 1 : 0;
+ },
+ open(where) {
+ return openTab("calendar", {}, where);
+ },
+ },
+ {
+ name: "tasks",
+ button: document.getElementById("tasksButton"),
+ menuitem: document.getElementById("spacesPopupButtonTasks"),
+ tabInSpace(tabInfo) {
+ return tabInfo.mode.name == "tasks" ? 1 : 0;
+ },
+ open(where) {
+ return openTab("tasks", {}, where);
+ },
+ },
+ {
+ name: "chat",
+ button: document.getElementById("chatButton"),
+ menuitem: document.getElementById("spacesPopupButtonChat"),
+ tabInSpace(tabInfo) {
+ return tabInfo.mode.name == "chat" ? 1 : 0;
+ },
+ open(where) {
+ return openTab("chat", {}, where);
+ },
+ },
+ {
+ name: "settings",
+ button: document.getElementById("settingsButton"),
+ menuitem: document.getElementById("spacesPopupButtonSettings"),
+ tabInSpace(tabInfo) {
+ switch (tabInfo.mode.name) {
+ case "preferencesTab":
+ // A primary tab that the open method creates.
+ return 1;
+ case "contentTab":
+ let url = tabInfo.urlbar?.value;
+ if (url == "about:accountsettings" || url == "about:addons") {
+ // A secondary tab, that is related to this space.
+ return 2;
+ }
+ }
+ return 0;
+ },
+ open(where) {
+ return openTab("preferencesTab", {}, where);
+ },
+ },
+ ];
+
+ this.setupEventListeners();
+ this.toggleToolbar(
+ Services.xulStore.getValue(this.docURL, "spacesToolbar", "hidden") ==
+ "true"
+ );
+
+ // The tab monitor will inform us when a different tab is selected.
+ tabmail.registerTabMonitor(this.tabMonitor);
+
+ this.customizePanel = document.getElementById(
+ "spacesToolbarCustomizationPanel"
+ );
+ this.loadCustomization();
+
+ this.isLoaded = true;
+ window.dispatchEvent(new CustomEvent("spaces-toolbar-ready"));
+ // Update the window UI after the spaces toolbar has been loaded.
+ this.updateUI();
+ },
+
+ setupEventListeners() {
+ this.element.addEventListener("contextmenu", event =>
+ this._showContextMenu(event)
+ );
+ this.element.addEventListener("keydown", event => {
+ this._onSpacesToolbarKeyDown(event);
+ });
+
+ // Prevent buttons from stealing the focus on click since the focus is
+ // handled when a specific tab is opened or switched to.
+ for (let button of document.querySelectorAll(".spaces-toolbar-button")) {
+ button.onmousedown = event => event.preventDefault();
+ }
+
+ let tabmail = document.getElementById("tabmail");
+ let contextMenu = document.getElementById("spacesContextMenu");
+ let newTabItem = document.getElementById("spacesContextNewTabItem");
+ let newWindowItem = document.getElementById("spacesContextNewWindowItem");
+ let separator = document.getElementById("spacesContextMenuSeparator");
+
+ // The space that we (last) opened the context menu for, which we share
+ // between methods.
+ let contextSpace;
+ newTabItem.addEventListener("command", () => contextSpace.open("tab"));
+ newWindowItem.addEventListener("command", () =>
+ contextSpace.open("window")
+ );
+
+ let settingsContextMenu = document.getElementById("settingsContextMenu");
+ document
+ .getElementById("settingsContextOpenSettingsItem")
+ .addEventListener("command", () => openTab("preferencesTab", {}));
+ document
+ .getElementById("settingsContextOpenAccountSettingsItem")
+ .addEventListener("command", () =>
+ openTab("contentTab", { url: "about:accountsettings" })
+ );
+ document
+ .getElementById("settingsContextOpenAddonsItem")
+ .addEventListener("command", () =>
+ openTab("contentTab", { url: "about:addons" })
+ );
+ document
+ .getElementById("settingsContextOpenCustomizeItem")
+ .addEventListener("command", () => this.showCustomize());
+
+ for (let space of this.spaces) {
+ this._addButtonClickListener(space.button, () => {
+ this.openSpace(tabmail, space);
+ });
+ space.menuitem?.addEventListener("command", () => {
+ this.openSpace(tabmail, space);
+ });
+ if (space.name == "settings") {
+ space.button.addEventListener("contextmenu", event => {
+ event.stopPropagation();
+ settingsContextMenu.openPopupAtScreen(
+ event.screenX,
+ event.screenY,
+ true,
+ event
+ );
+ });
+ continue;
+ }
+ space.button.addEventListener("contextmenu", event => {
+ event.stopPropagation();
+ contextSpace = space;
+ // Clean up old items.
+ for (let menuitem of contextMenu.querySelectorAll(".switch-to-tab")) {
+ menuitem.remove();
+ }
+
+ let existingTabs = tabmail.tabInfo.filter(space.tabInSpace);
+ // Show opening in new tab if no existing tabs or can open multiple.
+ // NOTE: We always show at least one item: either the switch to tab
+ // items, or the new tab item.
+ newTabItem.hidden = !!existingTabs.length && !space.allowMultipleTabs;
+ newWindowItem.hidden = !space.allowMultipleTabs;
+
+ for (let tabInfo of existingTabs) {
+ let menuitem = document.createXULElement("menuitem");
+ document.l10n.setAttributes(
+ menuitem,
+ "spaces-context-switch-tab-item",
+ { tabName: tabInfo.title }
+ );
+ menuitem.classList.add("switch-to-tab", "menuitem-iconic");
+ menuitem.addEventListener("command", () =>
+ tabmail.switchToTab(tabInfo)
+ );
+ contextMenu.appendChild(menuitem);
+ }
+ // The separator splits the "Open in new tab" and "Open in new window"
+ // items from the switch-to-tab items. Only show separator if there
+ // are non-hidden items on both sides.
+ separator.hidden = !existingTabs.length || !space.allowMultipleTabs;
+
+ contextMenu.openPopupAtScreen(
+ event.screenX,
+ event.screenY,
+ true,
+ event
+ );
+ });
+ }
+
+ this._addButtonClickListener(
+ document.getElementById("collapseButton"),
+ () => this.toggleToolbar(true)
+ );
+
+ document
+ .getElementById("spacesPopupButtonReveal")
+ .addEventListener("command", () => {
+ this.toggleToolbar(false);
+ });
+ this._addButtonClickListener(
+ document.getElementById("spacesToolbarAddonsOverflowButton"),
+ event => this.openSpacesToolbarAddonsPopup(event)
+ );
+
+ // Allow opening the pinned menu with Space or Enter keypress.
+ document
+ .getElementById("spacesPinnedButton")
+ .addEventListener("keypress", event => {
+ // Don't show the panel if the window is in customization mode.
+ if (
+ document.getElementById("toolbar-menubar").hasAttribute("customizing")
+ ) {
+ return;
+ }
+
+ if (event.key == " " || event.key == "Enter") {
+ let panel = document.getElementById("spacesButtonMenuPopup");
+ if (panel.state == "open") {
+ panel.hidePopup();
+ } else if (panel.state == "closed") {
+ panel.openPopup(event.target, "after_start");
+ }
+ }
+ });
+ },
+
+ /**
+ * Handle the keypress event on the spaces toolbar.
+ *
+ * @param {Event} event - The keypress DOMEvent.
+ */
+ _onSpacesToolbarKeyDown(event) {
+ if (
+ !["ArrowUp", "ArrowDown", "Home", "End", " ", "Enter"].includes(event.key)
+ ) {
+ return;
+ }
+
+ // NOTE: Normally a button click handler would cover Enter and Space key
+ // events, however we need to prevent the default behavior and explicitly
+ // trigger the button click because in some tabs XUL keys or Window event
+ // listeners are attached to this keys triggering specific actions.
+ // TODO: Remove once we have a properly mapped global shortcut object not
+ // relying on XUL keys.
+ if (event.key == " " || event.key == "Enter") {
+ event.preventDefault();
+ event.target.click();
+ return;
+ }
+
+ // Collect all currently visible buttons of the spaces toolbar.
+ let buttons = [
+ ...document.querySelectorAll(".spaces-toolbar-button:not([hidden])"),
+ ];
+ let elementIndex = buttons.indexOf(this.focusButton);
+
+ // Find the adjacent focusable element based on the pressed key.
+ switch (event.key) {
+ case "ArrowUp":
+ elementIndex--;
+ if (elementIndex == -1) {
+ elementIndex = buttons.length - 1;
+ }
+ break;
+
+ case "ArrowDown":
+ elementIndex++;
+ if (elementIndex > buttons.length - 1) {
+ elementIndex = 0;
+ }
+ break;
+
+ case "Home":
+ elementIndex = 0;
+ break;
+
+ case "End":
+ elementIndex = buttons.length - 1;
+ break;
+ }
+
+ this.setFocusButton(buttons[elementIndex], true);
+ },
+
+ /**
+ * Move the focus to a new toolbar button and update the tabindex attribute.
+ *
+ * @param {HTMLElement} buttonToFocus - The new button to receive focus.
+ * @param {boolean} [forceFocus=false] - Whether to force the focus to move
+ * onto the new button, otherwise focus will only move if the previous
+ * focusButton had focus.
+ */
+ setFocusButton(buttonToFocus, forceFocus = false) {
+ let prevHadFocus = false;
+ if (buttonToFocus != this.focusButton) {
+ prevHadFocus = document.activeElement == this.focusButton;
+ this.focusButton.tabIndex = -1;
+ this.focusButton = buttonToFocus;
+ buttonToFocus.tabIndex = 0;
+ }
+ // Only move the focus if the currently focused button was the active
+ // element.
+ if (forceFocus || prevHadFocus) {
+ buttonToFocus.focus();
+ }
+ },
+
+ /**
+ * Add a click event listener to a spaces toolbar button.
+ *
+ * This method will insert focus controls for when the button is clicked.
+ *
+ * @param {HTMLButtonElement} button - A button that belongs to the spaces
+ * toolbar.
+ * @param {Function} listener - An event listener to call when the button's
+ * click event is fired.
+ */
+ _addButtonClickListener(button, listener) {
+ button.addEventListener("click", event => {
+ // Since the button may have tabIndex = -1, we must manually move the
+ // focus into the button and set it as the focusButton.
+ // NOTE: We do *not* force the focus to move onto the button if it is not
+ // currently already within the spaces toolbar. This is mainly to avoid
+ // changing the document.activeElement before the tab's lastActiveElement
+ // is set in tabmail.js.
+ // NOTE: We do this before activating the button, which may move the focus
+ // elsewhere, such as into a space.
+ this.setFocusButton(button);
+ listener(event);
+ });
+ },
+
+ /**
+ * Open a space by creating a new tab or switching to an existing tab.
+ *
+ * @param {XULElement} tabmail - The tabmail element.
+ * @param {SpaceInfo} space - The space to open.
+ */
+ openSpace(tabmail, space) {
+ // Find the earliest primary tab that belongs to this space.
+ let existing = tabmail.tabInfo.find(
+ tabInfo => space.tabInSpace(tabInfo) == 1
+ );
+ if (!existing) {
+ return space.open("tab");
+ } else if (this.currentSpace != space) {
+ // Only switch to the tab if it is in a different space to the
+ // current one. In particular, if we are in a later tab we won't
+ // switch to the earliest tab.
+ tabmail.switchToTab(existing);
+ return existing;
+ }
+ return tabmail.currentTabInfo;
+ },
+
+ /**
+ * Open a popup context menu at the location of the right on the toolbar.
+ *
+ * @param {DOMEvent} event - The click event.
+ */
+ _showContextMenu(event) {
+ document
+ .getElementById("spacesToolbarContextMenu")
+ .openPopupAtScreen(event.screenX, event.screenY, true);
+ },
+
+ /**
+ * Load the saved customization from the xulStore, if we have any.
+ */
+ async loadCustomization() {
+ let xulStore = Services.xulStore;
+ if (xulStore.hasValue(this.docURL, "spacesToolbar", "colors")) {
+ this.customizeData = JSON.parse(
+ xulStore.getValue(this.docURL, "spacesToolbar", "colors")
+ );
+ this.updateCustomization();
+ }
+ },
+
+ /**
+ * Reset the colors shown on the button colors to the default state to remove
+ * any previously applied custom color.
+ */
+ _resetColorInputs() {
+ // Update colors with the current values. If we don't have any customization
+ // data, we fetch the current colors from the DOM elements.
+ // IMPORTANT! Always clear the onchange method before setting a new value
+ // since this method might be called after the popup is already opened.
+ let bgButton = document.getElementById("spacesBackgroundColor");
+ bgButton.onchange = null;
+ bgButton.value =
+ this.customizeData.background ||
+ this._rgbToHex(getComputedStyle(this.element).backgroundColor);
+ bgButton.onchange = event => {
+ this.customizeData.background = event.target.value;
+ this.updateCustomization();
+ };
+
+ let iconButton = document.getElementById("spacesIconsColor");
+ iconButton.onchange = null;
+ iconButton.value =
+ this.customizeData.color ||
+ this._rgbToHex(
+ getComputedStyle(
+ document.querySelector(".spaces-toolbar-button:not(.current)")
+ ).color
+ );
+ iconButton.onchange = event => {
+ this.customizeData.color = event.target.value;
+ this.updateCustomization();
+ };
+
+ let accentStyle = getComputedStyle(
+ document.getElementById("spacesAccentPlaceholder")
+ );
+ let accentBgButton = document.getElementById("spacesAccentBgColor");
+ accentBgButton.onchange = null;
+ accentBgButton.value =
+ this.customizeData.accentBackground ||
+ this._rgbToHex(accentStyle.backgroundColor);
+ accentBgButton.onchange = event => {
+ this.customizeData.accentBackground = event.target.value;
+ this.updateCustomization();
+ };
+
+ let accentFgButton = document.getElementById("spacesAccentTextColor");
+ accentFgButton.onchange = null;
+ accentFgButton.value =
+ this.customizeData.accentColor || this._rgbToHex(accentStyle.color);
+ accentFgButton.onchange = event => {
+ this.customizeData.accentColor = event.target.value;
+ this.updateCustomization();
+ };
+ },
+
+ /**
+ * Update the color buttons to reflect the current state of the toolbar UI,
+ * then open the customization panel.
+ */
+ showCustomize() {
+ this.isCustomizing = true;
+ // Reset the color inputs to be sure we're showing the correct colors.
+ this._resetColorInputs();
+
+ // Since we're forcing the panel to stay open with noautohide, we need to
+ // listen for the Escape keypress to maintain that usability exit point.
+ window.addEventListener("keypress", this.onWindowKeypress);
+ this.customizePanel.openPopup(
+ document.getElementById("collapseButton"),
+ "end_after",
+ 6,
+ 0,
+ false
+ );
+ },
+
+ /**
+ * Listen for the keypress event on the window after the customize panel was
+ * opened to enable the closing on Escape.
+ *
+ * @param {Event} event - The DOM Event.
+ */
+ onWindowKeypress(event) {
+ if (event.key == "Escape") {
+ gSpacesToolbar.customizePanel.hidePopup();
+ }
+ },
+
+ /**
+ * Close the customization panel.
+ */
+ closeCustomize() {
+ this.customizePanel.hidePopup();
+ },
+
+ /**
+ * Reset all event listeners and store the custom colors.
+ */
+ onCustomizePopupHidden() {
+ this.isCustomizing = false;
+ // Always remove the keypress event listener set on opening.
+ window.removeEventListener("keypress", this.onWindowKeypress);
+
+ // Save the custom colors, or delete it if we don't have any.
+ if (!Object.keys(this.customizeData).length) {
+ Services.xulStore.removeValue(this.docURL, "spacesToolbar", "colors");
+ return;
+ }
+
+ Services.xulStore.setValue(
+ this.docURL,
+ "spacesToolbar",
+ "colors",
+ JSON.stringify(this.customizeData)
+ );
+ },
+
+ /**
+ * Apply the customization to the CSS file.
+ */
+ updateCustomization() {
+ let data = this.customizeData;
+ let style = document.documentElement.style;
+
+ // Toolbar background color.
+ style.setProperty("--spaces-bg-color", data.background ?? null);
+ // Icons color.
+ style.setProperty("--spaces-button-text-color", data.color ?? null);
+ // Icons color for current/active buttons.
+ style.setProperty(
+ "--spaces-button-active-text-color",
+ data.accentColor ?? null
+ );
+ // Background color for current/active buttons.
+ style.setProperty(
+ "--spaces-button-active-bg-color",
+ data.accentBackground ?? null
+ );
+ },
+
+ /**
+ * Reset all color customizations to show the user the default UI.
+ */
+ resetColorCustomization() {
+ if (!matchMedia("(prefers-reduced-motion)").matches) {
+ // We set an event listener for the transition of any element inside the
+ // toolbar so we can reset the color for the buttons only after the
+ // toolbar and its elements reverted to their original colors.
+ this.element.addEventListener(
+ "transitionend",
+ () => {
+ this._resetColorInputs();
+ },
+ {
+ once: true,
+ }
+ );
+ }
+
+ this.customizeData = {};
+ this.updateCustomization();
+
+ // If the user required reduced motion, the transitionend listener will not
+ // work.
+ if (matchMedia("(prefers-reduced-motion)").matches) {
+ this._resetColorInputs();
+ }
+ },
+
+ /**
+ * Toggle the spaces toolbar and toolbar buttons visibility.
+ *
+ * @param {boolean} state - The visibility state to update the elements.
+ */
+ toggleToolbar(state) {
+ // Prevent the visibility change state of the spaces toolbar if we're
+ // currently customizing it, in order to avoid weird positioning outcomes
+ // with the customize popup panel.
+ if (this.isCustomizing) {
+ return;
+ }
+
+ this.isHidden = state;
+
+ // The focused element, prior to toggling.
+ let activeElement = document.activeElement;
+
+ let pinnedButton = document.getElementById("spacesPinnedButton");
+ pinnedButton.hidden = !state;
+ let revealButton = document.getElementById("spacesToolbarReveal");
+ revealButton.hidden = !state;
+ this.element.hidden = state;
+
+ if (state && this.element.contains(activeElement)) {
+ // If the toolbar is being hidden and one of its child element was
+ // focused, move the focus to the pinned button without changing the
+ // focusButton attribute of this object.
+ pinnedButton.focus();
+ } else if (
+ !state &&
+ (activeElement == pinnedButton || activeElement == revealButton)
+ ) {
+ // If the the toolbar is being shown and the focus is on the pinned or
+ // reveal button, move the focus to the previously focused button.
+ this.focusButton?.focus();
+ }
+
+ // Update the window UI after the visibility state of the spaces toolbar
+ // has changed.
+ this.updateUI();
+ },
+
+ /**
+ * Toggle the spaces toolbar from a menuitem.
+ */
+ toggleToolbarFromMenu() {
+ this.toggleToolbar(!this.isHidden);
+ },
+
+ /**
+ * Update the addons buttons and propagate toolbar visibility to a global
+ * attribute.
+ */
+ updateUI() {
+ // Interrupt if the spaces toolbar isn't loaded yet.
+ if (!this.isLoaded) {
+ return;
+ }
+
+ let density = Services.prefs.getIntPref("mail.uidensity", 1);
+ switch (density) {
+ case 0:
+ this.densitySpacing = 10;
+ break;
+ case 1:
+ this.densitySpacing = 15;
+ break;
+ case 2:
+ this.densitySpacing = 20;
+ break;
+ }
+
+ // Toggle the window attribute for those CSS selectors that need it.
+ if (this.isHidden) {
+ document.documentElement.removeAttribute("spacestoolbar");
+ } else {
+ document.documentElement.setAttribute("spacestoolbar", "true");
+ this.updateAddonButtonsUI();
+ }
+ },
+
+ /**
+ * Reset the inline style of the various titlebars and toolbars that interact
+ * with the spaces toolbar.
+ */
+ resetInlineStyle() {
+ document.getElementById("tabmail-tabs").removeAttribute("style");
+ },
+
+ /**
+ * Update the UI based on the window sizing.
+ */
+ onWindowResize() {
+ if (!this.isLoaded) {
+ return;
+ }
+
+ this.updateUImacOS();
+ this.updateAddonButtonsUI();
+ },
+
+ /**
+ * Update the location of buttons added by addons based on the space available
+ * in the toolbar. If the number of buttons is greater than the height of the
+ * visible container, move those buttons inside an overflow popup.
+ */
+ updateAddonButtonsUI() {
+ if (this.isHidden) {
+ return;
+ }
+
+ let overflowButton = document.getElementById(
+ "spacesToolbarAddonsOverflowButton"
+ );
+ let separator = document.getElementById("spacesPopupAddonsSeparator");
+ let popup = document.getElementById("spacesToolbarAddonsPopup");
+ // Bail out if we don't have any add-ons button.
+ if (!this.addonButtonCount) {
+ if (this.focusButton == overflowButton) {
+ this.setFocusButton(
+ this.element.querySelector(".spaces-toolbar-button:not([hidden])")
+ );
+ }
+ overflowButton.hidden = true;
+ separator.collapsed = true;
+ popup.hidePopup();
+ return;
+ }
+
+ separator.collapsed = false;
+ // Use the first available button's height as reference, and include the gap
+ // defined by the UIDensity pref.
+ let buttonHeight =
+ document.querySelector(".spaces-toolbar-button").getBoundingClientRect()
+ .height + this.densitySpacing;
+
+ let containerHeight = document
+ .getElementById("spacesToolbarAddonsContainer")
+ .getBoundingClientRect().height;
+
+ // Calculate the visible threshold of add-on buttons by:
+ // - Multiplying the space occupied by one button for the number of the
+ // add-on buttons currently present.
+ // - Subtracting the height of the add-ons container from the height
+ // occupied by all add-on buttons.
+ // - Dividing the returned value by the height of a single button.
+ // Doing so we will get an integer representing how many buttons might or
+ // might not fit in the available area.
+ let threshold = Math.ceil(
+ (buttonHeight * this.addonButtonCount - containerHeight) / buttonHeight
+ );
+
+ // Always reset the visibility of all buttons to avoid unnecessary
+ // calculations when needing to reveal hidden buttons.
+ for (let btn of document.querySelectorAll(".spaces-addon-button[hidden]")) {
+ btn.hidden = false;
+ }
+
+ // If we get a negative threshold, it means we have plenty of empty space
+ // so we don't need to do anything.
+ if (threshold <= 0) {
+ // If the overflow button was the currently focused button, move the focus
+ // to an arbitrary first available button.
+ if (this.focusButton == overflowButton) {
+ this.setFocusButton(
+ this.element.querySelector(".spaces-toolbar-button:not([hidden])")
+ );
+ }
+ overflowButton.hidden = true;
+ popup.hidePopup();
+ return;
+ }
+
+ overflowButton.hidden = false;
+ // Hide as many buttons as needed based on the threshold value.
+ for (let i = 0; i <= threshold; i++) {
+ let btn = document.querySelector(
+ `.spaces-addon-button:nth-last-child(${i})`
+ );
+ if (btn) {
+ // If one of the hidden add-on buttons was the focused one, move the
+ // focus to the overflow button.
+ if (btn == this.focusButton) {
+ this.setFocusButton(overflowButton);
+ }
+ btn.hidden = true;
+ }
+ }
+ },
+
+ /**
+ * Update the spacesToolbar UI and adjacent tabs exclusively for macOS. This
+ * is necessary mostly to tackle the changes when switching fullscreen mode.
+ */
+ updateUImacOS() {
+ // No need to to anything if we're not on macOS.
+ if (AppConstants.platform != "macosx") {
+ return;
+ }
+
+ // Add inline styling to the tabmail tabs only if we're on macOS and the
+ // app is in full screen mode.
+ if (window.fullScreen) {
+ let size = this.element.getBoundingClientRect().width;
+ let style = `margin-inline-start: ${size}px;`;
+ document.getElementById("tabmail-tabs").setAttribute("style", style);
+ return;
+ }
+
+ // Reset the style if we exited full screen mode.
+ this.resetInlineStyle();
+ },
+
+ /**
+ * @typedef NativeButtonProperties
+ * @property {string} title - The text of the button tooltip and menuitem value.
+ * @property {string} url - The URL of the content tab to open.
+ * @property {Map} iconStyles - The icon styles Map.
+ * @property {?string} badgeText - The optional badge text.
+ * @property {?Map} badgeStyles - The optional badge styles Map.
+ */
+
+ /**
+ * Helper function for extensions in order to add buttons to the spaces
+ * toolbar.
+ *
+ * @param {string} id - The ID of the newly created button.
+ * @param {NativeButtonProperties} properties - The properties of the new button.
+ *
+ * @returns {Promise} - A Promise that resolves when the button is created.
+ */
+ async createToolbarButton(id, properties = {}) {
+ return new Promise((resolve, reject) => {
+ if (!this.isLoaded) {
+ return reject("Unable to add spaces toolbar button! Toolbar not ready");
+ }
+ if (
+ !id ||
+ !properties.title ||
+ !properties.url ||
+ !properties.iconStyles
+ ) {
+ return reject(
+ "Unable to add spaces toolbar button! Missing ID, Title, IconStyles, or space URL"
+ );
+ }
+
+ // Create the button.
+ let button = document.createElement("button");
+ button.classList.add("spaces-toolbar-button", "spaces-addon-button");
+ button.id = id;
+ button.title = properties.title;
+ button.tabIndex = -1;
+
+ let badge = document.createElement("span");
+ badge.classList.add("spaces-badge-container");
+ button.appendChild(badge);
+
+ let img = document.createElement("img");
+ img.setAttribute("alt", "");
+ button.appendChild(img);
+ document
+ .getElementById("spacesToolbarAddonsContainer")
+ .appendChild(button);
+
+ // Create the menuitem.
+ let menuitem = document.createXULElement("menuitem");
+ menuitem.classList.add(
+ "spaces-addon-menuitem",
+ "menuitem-iconic",
+ "spaces-popup-menuitem"
+ );
+ menuitem.id = `${id}-menuitem`;
+ menuitem.label = properties.title;
+ document
+ .getElementById("spacesButtonMenuPopup")
+ .insertBefore(
+ menuitem,
+ document.getElementById("spacesPopupRevealSeparator")
+ );
+
+ // Set icons. The unified toolbar customization also relies on the CSS
+ // variables of the img.
+ for (let style of this.SUPPORTED_ICON_STYLES) {
+ if (properties.iconStyles.has(style)) {
+ img.style.setProperty(style, properties.iconStyles.get(style));
+ menuitem.style.setProperty(style, properties.iconStyles.get(style));
+ }
+ }
+
+ // Add space.
+ gSpacesToolbar.spaces.push({
+ name: id,
+ button,
+ menuitem,
+ url: properties.url,
+ isExtensionSpace: true,
+ tabInSpace(tabInfo) {
+ // TODO: Store the spaceButtonId in the XULStore (or somewhere), so the
+ // space is recognized after a restart. Or force closing of all spaces
+ // on shutdown.
+ return tabInfo.spaceButtonId == this.name ? 1 : 0;
+ },
+ open(where) {
+ // The check if we should switch to an existing tab in this space was
+ // done in openSpace() and this function here should always open a new
+ // tab and not switch to a tab which might have loaded the same url,
+ // but belongs to a different space.
+ let tab = openTab(
+ "contentTab",
+ { url: this.url, duplicate: true },
+ where
+ );
+ tab.spaceButtonId = this.name;
+ // TODO: Make sure the spaceButtonId is set during load, and not here,
+ // where it might be too late.
+ gSpacesToolbar.currentSpace = this;
+ button.classList.add("current");
+ return tab;
+ },
+ });
+
+ // Set click actions.
+ let tabmail = document.getElementById("tabmail");
+ this._addButtonClickListener(button, () => {
+ let space = gSpacesToolbar.spaces.find(space => space.name == id);
+ this.openSpace(tabmail, space);
+ });
+ menuitem.addEventListener("command", () => {
+ let space = gSpacesToolbar.spaces.find(space => space.name == id);
+ this.openSpace(tabmail, space);
+ });
+
+ // Set badge.
+ if (properties.badgeText) {
+ button.classList.add("has-badge");
+ badge.textContent = properties.badgeText;
+ }
+
+ if (properties.badgeStyles) {
+ for (let style of this.SUPPORTED_BADGE_STYLES) {
+ if (properties.badgeStyles.has(style)) {
+ badge.style.setProperty(style, properties.badgeStyles.get(style));
+ }
+ }
+ }
+
+ this.updateAddonButtonsUI();
+ return resolve();
+ });
+ },
+
+ /**
+ * Helper function for extensions in order to update buttons previously added
+ * to the spaces toolbar.
+ *
+ * @param {string} id - The ID of the button that needs to be updated.
+ * @param {NativeButtonProperties} properties - The new properties of the button.
+ * Not specifying the optional badgeText or badgeStyles will remove them.
+ *
+ * @returns {Promise} - A promise that resolves when the button is updated.
+ */
+ async updateToolbarButton(id, properties = {}) {
+ return new Promise((resolve, reject) => {
+ if (
+ !id ||
+ !properties.title ||
+ !properties.url ||
+ !properties.iconStyles
+ ) {
+ return reject(
+ "Unable to update spaces toolbar button! Missing ID, Title, IconsStyles, or space URL"
+ );
+ }
+
+ let button = document.getElementById(`${id}`);
+ let menuitem = document.getElementById(`${id}-menuitem`);
+ if (!button || !menuitem) {
+ return reject(
+ "Unable to update spaces toolbar button! Button or menuitem don't exist"
+ );
+ }
+
+ button.title = properties.title;
+ menuitem.label = properties.title;
+
+ // Update icons.
+ let img = button.querySelector("img");
+ for (let style of this.SUPPORTED_ICON_STYLES) {
+ let value = properties.iconStyles.get(style);
+ img.style.setProperty(style, value ?? null);
+ menuitem.style.setProperty(style, value ?? null);
+ }
+
+ // Update url.
+ let space = gSpacesToolbar.spaces.find(space => space.name == id);
+ if (space.url != properties.url) {
+ // TODO: Reload the space, when the url is changed (or close and re-open
+ // the tab).
+ space.url = properties.url;
+ }
+
+ // Update badge.
+ let badge = button.querySelector(".spaces-badge-container");
+ if (properties.badgeText) {
+ button.classList.add("has-badge");
+ badge.textContent = properties.badgeText;
+ } else {
+ button.classList.remove("has-badge");
+ badge.textContent = "";
+ }
+
+ for (let style of this.SUPPORTED_BADGE_STYLES) {
+ badge.style.setProperty(
+ style,
+ properties.badgeStyles?.get(style) ?? null
+ );
+ }
+
+ return resolve();
+ });
+ },
+
+ /**
+ * Helper function for extensions allowing the removal of previously created
+ * buttons.
+ *
+ * @param {string} id - The ID of the button that needs to be removed.
+ * @returns {Promise} - A promise that resolves when the button is removed.
+ */
+ async removeToolbarButton(id) {
+ return new Promise((resolve, reject) => {
+ if (!this.isLoaded) {
+ return reject(
+ "Unable to remove spaces toolbar button! Toolbar not ready"
+ );
+ }
+ if (!id) {
+ return reject("Unable to remove spaces toolbar button! Missing ID");
+ }
+
+ let button = document.getElementById(`${id}`);
+ // If the button being removed is the currently focused one, move the
+ // focus on an arbitrary first available spaces button.
+ if (this.focusButton == button) {
+ this.setFocusButton(
+ this.element.querySelector(".spaces-toolbar-button:not([hidden])")
+ );
+ }
+
+ button?.remove();
+ document.getElementById(`${id}-menuitem`)?.remove();
+
+ let space = gSpacesToolbar.spaces.find(space => space.name == id);
+ let tabmail = document.getElementById("tabmail");
+ let existing = tabmail.tabInfo.find(
+ tabInfo => space.tabInSpace(tabInfo) == 1
+ );
+ if (existing) {
+ tabmail.closeTab(existing);
+ }
+
+ gSpacesToolbar.spaces = gSpacesToolbar.spaces.filter(e => e.name != id);
+ this.updateAddonButtonsUI();
+
+ return resolve();
+ });
+ },
+
+ /**
+ * Populate the overflow container with a copy of all the currently hidden
+ * buttons generated by add-ons.
+ *
+ * @param {DOMEvent} event - The DOM click event.
+ */
+ openSpacesToolbarAddonsPopup(event) {
+ let popup = document.getElementById("spacesToolbarAddonsPopup");
+
+ for (let button of document.querySelectorAll(
+ ".spaces-addon-button[hidden]"
+ )) {
+ let menuitem = document.createXULElement("menuitem");
+ menuitem.classList.add("menuitem-iconic", "spaces-popup-menuitem");
+ menuitem.label = button.title;
+
+ let img = button.querySelector("img");
+ for (let style of this.SUPPORTED_ICON_STYLES) {
+ menuitem.style.setProperty(
+ style,
+ img.style.getPropertyValue(style) ?? null
+ );
+ }
+
+ menuitem.addEventListener("command", () => button.click());
+ popup.appendChild(menuitem);
+ }
+
+ popup.openPopup(event.target, "after_start", 0, 0);
+ },
+
+ /**
+ * Empty the overflow container.
+ */
+ spacesToolbarAddonsPopupClosed() {
+ document.getElementById("spacesToolbarAddonsPopup").replaceChildren();
+ },
+
+ /**
+ * Copy the badges from the contained menu items to the pinned button.
+ * Should be called whenever one of the menu item's badge state changes.
+ */
+ updatePinnedBadgeState() {
+ let hasBadge = Boolean(
+ document.querySelector("#spacesButtonMenuPopup .has-badge")
+ );
+ let spacesPinnedButton = document.getElementById("spacesPinnedButton");
+ spacesPinnedButton.classList.toggle("has-badge", hasBadge);
+ },
+
+ /**
+ * Save the preferred state when the app is closed.
+ */
+ onUnload() {
+ Services.xulStore.setValue(
+ this.docURL,
+ "spacesToolbar",
+ "hidden",
+ this.isHidden
+ );
+ },
+};
diff --git a/comm/mail/base/content/spacesToolbarPin.inc.xhtml b/comm/mail/base/content/spacesToolbarPin.inc.xhtml
new file mode 100644
index 0000000000..95b0389561
--- /dev/null
+++ b/comm/mail/base/content/spacesToolbarPin.inc.xhtml
@@ -0,0 +1,46 @@
+# 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/.
+
+<toolbarbutton id="spacesPinnedButton"
+ type="menu"
+ wantdropmarker="false"
+ class="button toolbar-button"
+ data-l10n-id="spaces-toolbar-pinned-tab-button"
+ hidden="true"
+ badged="true"
+ tabindex="0">
+ <menupopup id="spacesButtonMenuPopup">
+ <menuitem id="spacesPopupButtonMail"
+ data-l10n-id="spaces-pinned-button-menuitem-mail2"
+ data-l10n-attrs="acceltext"
+ class="menuitem-iconic spaces-popup-menuitem"/>
+ <menuitem id="spacesPopupButtonAddressBook"
+ data-l10n-id="spaces-pinned-button-menuitem-address-book2"
+ data-l10n-attrs="acceltext"
+ class="menuitem-iconic spaces-popup-menuitem"/>
+ <menuitem id="spacesPopupButtonCalendar"
+ data-l10n-id="spaces-pinned-button-menuitem-calendar2"
+ data-l10n-attrs="acceltext"
+ class="menuitem-iconic spaces-popup-menuitem"/>
+ <menuitem id="spacesPopupButtonTasks"
+ data-l10n-id="spaces-pinned-button-menuitem-tasks2"
+ data-l10n-attrs="acceltext"
+ class="menuitem-iconic spaces-popup-menuitem"/>
+ <menuitem id="spacesPopupButtonChat"
+ data-l10n-id="spaces-pinned-button-menuitem-chat2"
+ data-l10n-attrs="acceltext"
+ class="menuitem-iconic spaces-popup-menuitem"/>
+ <menuseparator id="spacesPopupSettingsSeparator"/>
+ <menuitem id="spacesPopupButtonSettings"
+ data-l10n-id="spaces-pinned-button-menuitem-settings2"
+ data-l10n-attrs="acceltext"
+ class="menuitem-iconic spaces-popup-menuitem"/>
+ <menuseparator id="spacesPopupAddonsSeparator" collapsed="true"/>
+ <menuseparator id="spacesPopupRevealSeparator"/>
+ <menuitem id="spacesPopupButtonReveal"
+ data-l10n-id="spaces-pinned-button-menuitem-show"
+ data-l10n-attrs="acceltext"
+ class="menuitem-iconic spaces-popup-menuitem"/>
+ </menupopup>
+</toolbarbutton>
diff --git a/comm/mail/base/content/specialTabs.js b/comm/mail/base/content/specialTabs.js
new file mode 100644
index 0000000000..15a163c981
--- /dev/null
+++ b/comm/mail/base/content/specialTabs.js
@@ -0,0 +1,1320 @@
+/* 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/. */
+
+/* global MozElements, openOptionsDialog */
+
+/* import-globals-from utilityOverlay.js */
+
+/* globals ZoomManager */ // From viewZoomOverlay.js
+/* globals PrintUtils */ // From printUtils.js
+
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+var { AddonManager } = ChromeUtils.importESModule(
+ "resource://gre/modules/AddonManager.sys.mjs"
+);
+var { ExtensionParent } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionParent.sys.mjs"
+);
+var { MailE10SUtils } = ChromeUtils.import(
+ "resource:///modules/MailE10SUtils.jsm"
+);
+
+function tabProgressListener(aTab, aStartsBlank) {
+ this.mTab = aTab;
+ this.mBrowser = aTab.browser;
+ this.mBlank = aStartsBlank;
+ this.mProgressListener = null;
+}
+
+tabProgressListener.prototype = {
+ mTab: null,
+ mBrowser: null,
+ mBlank: null,
+ mProgressListener: null,
+
+ // cache flags for correct status bar update after tab switching
+ mStateFlags: 0,
+ mStatus: 0,
+ mMessage: "",
+
+ // count of open requests (should always be 0 or 1)
+ mRequestCount: 0,
+
+ addProgressListener(aProgressListener) {
+ this.mProgressListener = aProgressListener;
+ },
+
+ onProgressChange(
+ aWebProgress,
+ aRequest,
+ aCurSelfProgress,
+ aMaxSelfProgress,
+ aCurTotalProgress,
+ aMaxTotalProgress
+ ) {
+ if (this.mProgressListener) {
+ this.mProgressListener.onProgressChange(
+ aWebProgress,
+ aRequest,
+ aCurSelfProgress,
+ aMaxSelfProgress,
+ aCurTotalProgress,
+ aMaxTotalProgress
+ );
+ }
+ },
+ onProgressChange64(
+ aWebProgress,
+ aRequest,
+ aCurSelfProgress,
+ aMaxSelfProgress,
+ aCurTotalProgress,
+ aMaxTotalProgress
+ ) {
+ if (this.mProgressListener) {
+ this.mProgressListener.onProgressChange64(
+ aWebProgress,
+ aRequest,
+ aCurSelfProgress,
+ aMaxSelfProgress,
+ aCurTotalProgress,
+ aMaxTotalProgress
+ );
+ }
+ },
+ onLocationChange(aWebProgress, aRequest, aLocationURI, aFlags) {
+ if (this.mProgressListener) {
+ this.mProgressListener.onLocationChange(
+ aWebProgress,
+ aRequest,
+ aLocationURI,
+ aFlags
+ );
+ }
+ // onLocationChange is called for both the top-level content
+ // and the subframes.
+ if (aWebProgress.isTopLevel) {
+ // Don't clear the favicon if this onLocationChange was triggered
+ // by a pushState or a replaceState. See bug 550565.
+ if (
+ aWebProgress.isLoadingDocument &&
+ !(aWebProgress.loadType & Ci.nsIDocShell.LOAD_CMD_PUSHSTATE) &&
+ !(aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT)
+ ) {
+ this.mTab.favIconUrl = null;
+ }
+
+ var location = aLocationURI ? aLocationURI.spec : "";
+ if (aLocationURI && !aLocationURI.schemeIs("about")) {
+ this.mTab.backButton.disabled = !this.mBrowser.canGoBack;
+ this.mTab.forwardButton.disabled = !this.mBrowser.canGoForward;
+ this.mTab.urlbar.value = location;
+ this.mTab.root.removeAttribute("collapsed");
+ } else {
+ this.mTab.root.setAttribute("collapsed", "false");
+ }
+
+ // Although we're unlikely to be loading about:blank, we'll check it
+ // anyway just in case. The second condition is for new tabs, otherwise
+ // the reload function is enabled until tab is refreshed.
+ this.mTab.reloadEnabled = !(
+ (location == "about:blank" && !this.mBrowser.browsingContext.opener) ||
+ location == ""
+ );
+ }
+ },
+ onStateChange(aWebProgress, aRequest, aStateFlags, aStatus) {
+ if (this.mProgressListener) {
+ this.mProgressListener.onStateChange(
+ aWebProgress,
+ aRequest,
+ aStateFlags,
+ aStatus
+ );
+ }
+
+ if (!aRequest) {
+ return;
+ }
+
+ let tabmail = document.getElementById("tabmail");
+
+ if (aStateFlags & Ci.nsIWebProgressListener.STATE_START) {
+ this.mRequestCount++;
+ } else if (aStateFlags & Ci.nsIWebProgressListener.STATE_STOP) {
+ // Since we (try to) only handle STATE_STOP of the last request,
+ // the count of open requests should now be 0.
+ this.mRequestCount = 0;
+ }
+
+ if (
+ aStateFlags & Ci.nsIWebProgressListener.STATE_START &&
+ aStateFlags & Ci.nsIWebProgressListener.STATE_IS_NETWORK
+ ) {
+ if (!this.mBlank) {
+ this.mTab.title = specialTabs.contentTabType.loadingTabString;
+ this.mTab.securityIcon.setLoading(true);
+ tabmail.setTabBusy(this.mTab, true);
+ tabmail.setTabTitle(this.mTab);
+ }
+
+ // Set our unit testing variables accordingly
+ this.mTab.pageLoading = true;
+ this.mTab.pageLoaded = false;
+ } else if (
+ aStateFlags & Ci.nsIWebProgressListener.STATE_STOP &&
+ aStateFlags & Ci.nsIWebProgressListener.STATE_IS_NETWORK
+ ) {
+ this.mBlank = false;
+ this.mTab.securityIcon.setLoading(false);
+ tabmail.setTabBusy(this.mTab, false);
+ this.mTab.title = this.mTab.browser.contentTitle;
+ tabmail.setTabTitle(this.mTab);
+
+ // Set our unit testing variables accordingly
+ this.mTab.pageLoading = false;
+ this.mTab.pageLoaded = true;
+
+ // If we've finished loading, and we've not had an icon loaded from a
+ // link element, then we try using the default icon for the site.
+ if (aWebProgress.isTopLevel && !this.mTab.favIconUrl) {
+ specialTabs.useDefaultFavIcon(this.mTab);
+ }
+ }
+ },
+ onStatusChange(aWebProgress, aRequest, aStatus, aMessage) {
+ if (this.mProgressListener) {
+ this.mProgressListener.onStatusChange(
+ aWebProgress,
+ aRequest,
+ aStatus,
+ aMessage
+ );
+ }
+ },
+ onSecurityChange(aWebProgress, aRequest, aState) {
+ if (this.mProgressListener) {
+ this.mProgressListener.onSecurityChange(aWebProgress, aRequest, aState);
+ }
+
+ const wpl = Ci.nsIWebProgressListener;
+ const wpl_security_bits =
+ wpl.STATE_IS_SECURE | wpl.STATE_IS_BROKEN | wpl.STATE_IS_INSECURE;
+ let level = "";
+ switch (aState & wpl_security_bits) {
+ case wpl.STATE_IS_SECURE:
+ level = "high";
+ break;
+ case wpl.STATE_IS_BROKEN:
+ level = "broken";
+ break;
+ }
+ this.mTab.securityIcon.setSecurityLevel(level);
+ },
+ onContentBlockingEvent(aWebProgress, aRequest, aEvent) {
+ if (this.mProgressListener) {
+ this.mProgressListener.onContentBlockingEvent(
+ aWebProgress,
+ aRequest,
+ aEvent
+ );
+ }
+ },
+ onRefreshAttempted(aWebProgress, aURI, aDelay, aSameURI) {
+ if (this.mProgressListener) {
+ return this.mProgressListener.onRefreshAttempted(
+ aWebProgress,
+ aURI,
+ aDelay,
+ aSameURI
+ );
+ }
+ return true;
+ },
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIWebProgressListener",
+ "nsIWebProgressListener2",
+ "nsISupportsWeakReference",
+ ]),
+};
+
+/**
+ * Handles tab icons for parent process browsers. The DOMLinkAdded event won't
+ * fire for child process browsers, that is handled by LinkHandlerParent.
+ */
+var DOMLinkHandler = {
+ handleEvent(event) {
+ switch (event.type) {
+ case "DOMLinkAdded":
+ case "DOMLinkChanged":
+ this.onLinkAdded(event);
+ break;
+ }
+ },
+ onLinkAdded(event) {
+ let link = event.target;
+ let rel = link.rel && link.rel.toLowerCase();
+ if (!link || !link.ownerDocument || !rel || !link.href) {
+ return;
+ }
+
+ if (rel.split(/\s+/).includes("icon")) {
+ if (!Services.prefs.getBoolPref("browser.chrome.site_icons")) {
+ return;
+ }
+
+ let targetDoc = link.ownerDocument;
+
+ let uri = Services.io.newURI(link.href, targetDoc.characterSet);
+
+ // Verify that the load of this icon is legal.
+ // Some error or special pages can load their favicon.
+ // To be on the safe side, only allow chrome:// favicons.
+ let isAllowedPage =
+ targetDoc.documentURI == "about:home" ||
+ ["about:neterror?", "about:blocked?", "about:certerror?"].some(
+ function (aStart) {
+ targetDoc.documentURI.startsWith(aStart);
+ }
+ );
+
+ if (!isAllowedPage || !uri.schemeIs("chrome")) {
+ // Be extra paraniod and just make sure we're not going to load
+ // something we shouldn't. Firefox does this, so we're doing the same.
+ try {
+ Services.scriptSecurityManager.checkLoadURIWithPrincipal(
+ targetDoc.nodePrincipal,
+ uri,
+ Ci.nsIScriptSecurityManager.DISALLOW_SCRIPT
+ );
+ } catch (ex) {
+ return;
+ }
+ }
+
+ try {
+ var contentPolicy = Cc[
+ "@mozilla.org/layout/content-policy;1"
+ ].getService(Ci.nsIContentPolicy);
+ } catch (e) {
+ // Refuse to load if we can't do a security check.
+ return;
+ }
+
+ // Security says okay, now ask content policy. This is probably trying to
+ // ensure that the image loaded always obeys the content policy. There
+ // may have been a chance that it was cached and we're trying to load it
+ // direct from the cache and not the normal route.
+ let { NetUtil } = ChromeUtils.import(
+ "resource://gre/modules/NetUtil.jsm"
+ );
+ let tmpChannel = NetUtil.newChannel({
+ uri,
+ loadingNode: targetDoc,
+ securityFlags: Ci.nsILoadInfo.SEC_ONLY_FOR_EXPLICIT_CONTENTSEC_CHECK,
+ contentPolicyType: Ci.nsIContentPolicy.TYPE_IMAGE,
+ });
+ let tmpLoadInfo = tmpChannel.loadInfo;
+ if (
+ contentPolicy.shouldLoad(uri, tmpLoadInfo, link.type) !=
+ Ci.nsIContentPolicy.ACCEPT
+ ) {
+ return;
+ }
+
+ let tab = document
+ .getElementById("tabmail")
+ .getBrowserForDocument(targetDoc.defaultView);
+
+ // If we don't have a browser/tab, then don't load the icon.
+ if (!tab) {
+ return;
+ }
+
+ // Just set the url on the browser and we'll display the actual icon
+ // when we finish loading the page.
+ specialTabs.setFavIcon(tab, link.href);
+ }
+ },
+};
+
+var contentTabBaseType = {
+ // List of URLs that will receive special treatment when opened in a tab.
+ // Note that about:preferences is loaded via a different mechanism.
+ inContentWhitelist: [
+ "about:addons",
+ "about:addressbook",
+ "about:blank",
+ "about:profiles",
+ "about:*",
+ ],
+
+ // Code to run if a particular document is loaded in a tab.
+ // The array members (functions) are for the respective document URLs
+ // as specified in inContentWhitelist.
+ inContentOverlays: [
+ // about:addons
+ function (aDocument, aTab) {
+ Services.scriptloader.loadSubScript(
+ "chrome://messenger/content/aboutAddonsExtra.js",
+ aDocument.defaultView
+ );
+ },
+
+ // about:addressbook provides its own context menu.
+ function (aDocument, aTab) {
+ aTab.browser.removeAttribute("context");
+ },
+
+ // Let's not mess with about:blank.
+ null,
+
+ // about:profiles
+ function (aDocument, aTab) {
+ let win = aDocument.defaultView;
+ // Need a timeout to let the script run to create the needed buttons.
+ win.setTimeout(() => {
+ win.MozXULElement.insertFTLIfNeeded("messenger/aboutProfilesExtra.ftl");
+ for (let button of aDocument.querySelectorAll(
+ `[data-l10n-id="profiles-launch-profile"]`
+ )) {
+ win.document.l10n.setAttributes(
+ button,
+ "profiles-launch-profile-plain"
+ );
+ }
+ }, 500);
+ },
+
+ // Other about:* pages.
+ function (aDocument, aTab) {
+ // Provide context menu for about:* pages.
+ aTab.browser.setAttribute("context", "aboutPagesContext");
+ },
+ ],
+
+ shouldSwitchTo({ url, duplicate }) {
+ if (duplicate) {
+ return -1;
+ }
+
+ let tabmail = document.getElementById("tabmail");
+ let tabInfo = tabmail.tabInfo;
+ let uri;
+
+ try {
+ uri = Services.io.newURI(url);
+ } catch (ex) {
+ return -1;
+ }
+
+ for (
+ let selectedIndex = 0;
+ selectedIndex < tabInfo.length;
+ ++selectedIndex
+ ) {
+ // Reuse the same tab, if only the anchors differ - especially for the
+ // about: pages, we just want to re-use the same tab.
+ if (
+ tabInfo[selectedIndex].mode.name == this.name &&
+ tabInfo[selectedIndex].browser.currentURI?.specIgnoringRef ==
+ uri.specIgnoringRef
+ ) {
+ // Go to the correct location on the page, but only if it's not the
+ // current location. This should NOT cause the page to reload.
+ if (tabInfo[selectedIndex].browser.currentURI.spec != uri.spec) {
+ MailE10SUtils.loadURI(tabInfo[selectedIndex].browser, uri.spec);
+ }
+ return selectedIndex;
+ }
+ }
+ return -1;
+ },
+
+ closeTab(aTab) {
+ aTab.browser.removeEventListener(
+ "pagetitlechanged",
+ aTab.titleListener,
+ true
+ );
+ aTab.browser.removeEventListener(
+ "DOMWindowClose",
+ aTab.closeListener,
+ true
+ );
+ aTab.browser.removeEventListener("DOMLinkAdded", DOMLinkHandler);
+ aTab.browser.removeEventListener("DOMLinkChanged", DOMLinkHandler);
+ aTab.browser.webProgress.removeProgressListener(aTab.filter);
+ aTab.filter.removeProgressListener(aTab.progressListener);
+ aTab.browser.destroy();
+ },
+
+ saveTabState(aTab) {
+ aTab.browser.setAttribute("type", "content");
+ aTab.browser.removeAttribute("primary");
+ },
+
+ showTab(aTab) {
+ aTab.browser.setAttribute("type", "content");
+ aTab.browser.setAttribute("primary", "true");
+ if (aTab.browser.currentURI.spec.startsWith("about:preferences")) {
+ aTab.browser.contentDocument.documentElement.focus();
+ }
+ },
+
+ getBrowser(aTab) {
+ return aTab.browser;
+ },
+
+ _setUpLoadListener(aTab) {
+ let self = this;
+
+ function onLoad(aEvent) {
+ let doc = aEvent.target;
+ let url = doc.defaultView.location.href;
+
+ // If this document has an overlay defined, run it now.
+ let ind = self.inContentWhitelist.indexOf(url);
+ if (ind < 0) {
+ // Try a wildcard.
+ ind = self.inContentWhitelist.indexOf(url.replace(/:.*/, ":*"));
+ }
+ if (ind >= 0) {
+ let overlayFunction = self.inContentOverlays[ind];
+ if (overlayFunction) {
+ overlayFunction(doc, aTab);
+ }
+ }
+ }
+
+ aTab.loadListener = onLoad;
+ aTab.browser.addEventListener("load", aTab.loadListener, true);
+ },
+
+ // Internal function used to set up the title listener on a content tab.
+ _setUpTitleListener(aTab) {
+ function onDOMTitleChanged(aEvent) {
+ aTab.title = aTab.browser.contentTitle;
+ document.getElementById("tabmail").setTabTitle(aTab);
+ }
+ // Save the function we'll use as listener so we can remove it later.
+ aTab.titleListener = onDOMTitleChanged;
+ // Add the listener.
+ aTab.browser.addEventListener("pagetitlechanged", aTab.titleListener, true);
+ },
+
+ /**
+ * Internal function used to set up the close window listener on a content
+ * tab.
+ */
+ _setUpCloseWindowListener(aTab) {
+ function onDOMWindowClose(aEvent) {
+ if (!aEvent.isTrusted) {
+ return;
+ }
+
+ // Redirect any window.close events to closing the tab. As a 3-pane tab
+ // must be open, we don't need to worry about being the last tab open.
+ document.getElementById("tabmail").closeTab(aTab);
+ aEvent.preventDefault();
+ }
+ // Save the function we'll use as listener so we can remove it later.
+ aTab.closeListener = onDOMWindowClose;
+ // Add the listener.
+ aTab.browser.addEventListener("DOMWindowClose", aTab.closeListener, true);
+ },
+
+ supportsCommand(aCommand, aTab) {
+ switch (aCommand) {
+ case "cmd_fullZoomReduce":
+ case "cmd_fullZoomEnlarge":
+ case "cmd_fullZoomReset":
+ case "cmd_fullZoomToggle":
+ case "cmd_find":
+ case "cmd_findAgain":
+ case "cmd_findPrevious":
+ case "cmd_print":
+ case "button_print":
+ case "cmd_stop":
+ case "cmd_reload":
+ case "Browser:Back":
+ case "Browser:Forward":
+ return true;
+ default:
+ return false;
+ }
+ },
+
+ isCommandEnabled(aCommand, aTab) {
+ switch (aCommand) {
+ case "cmd_fullZoomReduce":
+ case "cmd_fullZoomEnlarge":
+ case "cmd_fullZoomReset":
+ case "cmd_fullZoomToggle":
+ case "cmd_find":
+ case "cmd_findAgain":
+ case "cmd_findPrevious":
+ return true;
+ case "cmd_print":
+ case "button_print": {
+ let uri = aTab.browser?.currentURI;
+ if (!uri || !uri.schemeIs("about")) {
+ return true;
+ }
+ return [
+ "addressbook",
+ "certificate",
+ "crashes",
+ "credits",
+ "license",
+ "profiles",
+ "support",
+ "telemetry",
+ ].includes(uri.filePath);
+ }
+ case "cmd_reload":
+ return aTab.reloadEnabled;
+ case "cmd_stop":
+ return aTab.busy;
+ case "Browser:Back":
+ return aTab.browser?.canGoBack;
+ case "Browser:Forward":
+ return aTab.browser?.canGoForward;
+ default:
+ return false;
+ }
+ },
+
+ doCommand(aCommand, aTab) {
+ switch (aCommand) {
+ case "cmd_fullZoomReduce":
+ ZoomManager.reduce();
+ break;
+ case "cmd_fullZoomEnlarge":
+ ZoomManager.enlarge();
+ break;
+ case "cmd_fullZoomReset":
+ ZoomManager.reset();
+ break;
+ case "cmd_fullZoomToggle":
+ ZoomManager.toggleZoom();
+ break;
+ case "cmd_find":
+ aTab.findbar.onFindCommand();
+ break;
+ case "cmd_findAgain":
+ aTab.findbar.onFindAgainCommand(false);
+ break;
+ case "cmd_findPrevious":
+ aTab.findbar.onFindAgainCommand(true);
+ break;
+ case "cmd_print":
+ PrintUtils.startPrintWindow(this.getBrowser(aTab).browsingContext, {});
+ break;
+ case "cmd_stop":
+ aTab.browser.stop();
+ break;
+ case "cmd_reload":
+ aTab.browser.reload();
+ break;
+ case "Browser:Back":
+ specialTabs.browserBack();
+ break;
+ case "Browser:Forward":
+ specialTabs.browserForward();
+ break;
+ }
+ },
+};
+
+/**
+ * Class that wraps the content page loading/security icon.
+ */
+// Ideally, this could be moved into a sub-class for content tabs.
+class SecurityIcon {
+ constructor(icon) {
+ this.icon = icon;
+ this.loading = false;
+ this.securityLevel = "";
+ this.updateIcon();
+ }
+
+ /**
+ * Set whether the page is loading.
+ *
+ * @param {boolean} loading - Whether the page is loading.
+ */
+ setLoading(loading) {
+ if (this.loading !== loading) {
+ this.loading = loading;
+ this.updateIcon();
+ }
+ }
+
+ /**
+ * Set the security level of the page.
+ *
+ * @param {"high"|"broken"|""} - The security level for the page, or empty if
+ * it is to be ignored.
+ */
+ setSecurityLevel(securityLevel) {
+ if (this.securityLevel !== securityLevel) {
+ this.securityLevel = securityLevel;
+ this.updateIcon();
+ }
+ }
+
+ updateIcon() {
+ let src;
+ let srcSet;
+ let l10nId;
+ let secure = false;
+ if (this.loading) {
+ src = "chrome://global/skin/icons/loading.png";
+ srcSet = "chrome://global/skin/icons/loading@2x.png 2x";
+ l10nId = "content-tab-page-loading-icon";
+ } else {
+ switch (this.securityLevel) {
+ case "high":
+ secure = true;
+ src = "chrome://messenger/skin/icons/connection-secure.svg";
+ l10nId = "content-tab-security-high-icon";
+ break;
+ case "broken":
+ src = "chrome://messenger/skin/icons/connection-insecure.svg";
+ l10nId = "content-tab-security-broken-icon";
+ break;
+ }
+ }
+ if (srcSet) {
+ this.icon.setAttribute("srcset", srcSet);
+ } else {
+ this.icon.removeAttribute("srcset");
+ }
+ if (src) {
+ this.icon.setAttribute("src", src);
+ // Set alt.
+ document.l10n.setAttributes(this.icon, l10nId);
+ } else {
+ this.icon.removeAttribute("src");
+ this.icon.removeAttribute("data-l10n-id");
+ this.icon.removeAttribute("alt");
+ }
+ this.icon.classList.toggle("secure-connection-icon", secure);
+ }
+}
+
+var specialTabs = {
+ _kAboutRightsVersion: 1,
+ get _protocolSvc() {
+ delete this._protocolSvc;
+ return (this._protocolSvc = Cc[
+ "@mozilla.org/uriloader/external-protocol-service;1"
+ ].getService(Ci.nsIExternalProtocolService));
+ },
+
+ get msgNotificationBar() {
+ if (!this._notificationBox) {
+ this._notificationBox = new MozElements.NotificationBox(element => {
+ element.setAttribute("notificationside", "bottom");
+ document
+ .getElementById("messenger-notification-bottom")
+ .append(element);
+ });
+ }
+ return this._notificationBox;
+ },
+
+ // This will open any special tabs if necessary on startup.
+ openSpecialTabsOnStartup() {
+ let tabmail = document.getElementById("tabmail");
+
+ tabmail.registerTabType(this.contentTabType);
+
+ this.showWhatsNewPage();
+
+ // Show the about rights notification if we need to.
+ if (this.shouldShowAboutRightsNotification()) {
+ this.showAboutRightsNotification();
+ }
+ if (this.shouldShowPolicyNotification()) {
+ // Do it on a timeout to workaround that open in background do not work when called too early.
+ setTimeout(this.showPolicyNotification, 10000);
+ }
+ },
+
+ /**
+ * A tab to show content pages.
+ */
+ contentTabType: {
+ __proto__: contentTabBaseType,
+ name: "contentTab",
+ perTabPanel: "vbox",
+ lastBrowserId: 0,
+ get loadingTabString() {
+ delete this.loadingTabString;
+ return (this.loadingTabString = document
+ .getElementById("bundle_messenger")
+ .getString("loadingTab"));
+ },
+
+ modes: {
+ contentTab: {
+ type: "contentTab",
+ },
+ },
+
+ /**
+ * This is the internal function used by content tabs to open a new tab. To
+ * open a contentTab, use specialTabs.openTab("contentTab", aArgs)
+ *
+ * @param {object} aArgs - The options that content tabs accept.
+ * @param {string} aArgs.url - The URL that is to be opened
+ * @param {nsIOpenWindowInfo} [aArgs.openWindowInfo] - The opener window
+ * @param {"single-site"|"single-page"|null} [aArgs.linkHandler="single-site"]
+ * Restricts navigation in the browser to be opened:
+ * - "single-site" allows only URLs in the same domain as
+ * aArgs.url (including subdomains).
+ * - "single-page" allows only URLs matching aArgs.url.
+ * - `null` applies no such restrictions.
+ * All other links are sent to an external browser.
+ * @param {Function} [aArgs.onLoad] - A function that takes an Event and a
+ * DOMNode. It is called when the content page is done loading. The
+ * first argument is the load event, and the second argument is the
+ * xul:browser that holds the page. You can access the inner tab's
+ * window object by accessing the second parameter's contentWindow
+ * property.
+ */
+ openTab(aTab, aArgs) {
+ if (!("url" in aArgs)) {
+ throw new Error("url must be specified");
+ }
+
+ // First clone the page and set up the basics.
+ let clone = document
+ .getElementById("contentTab")
+ .firstElementChild.cloneNode(true);
+
+ clone.setAttribute("id", "contentTab" + this.lastBrowserId);
+ clone.setAttribute("collapsed", false);
+
+ let toolbox = clone.firstElementChild;
+ toolbox.setAttribute("id", "contentTabToolbox" + this.lastBrowserId);
+ toolbox.firstElementChild.setAttribute(
+ "id",
+ "contentTabToolbar" + this.lastBrowserId
+ );
+
+ aTab.linkedBrowser = aTab.browser = document.createXULElement("browser");
+ aTab.browser.setAttribute("id", "contentTabBrowser" + this.lastBrowserId);
+ aTab.browser.setAttribute("type", "content");
+ aTab.browser.setAttribute("flex", "1");
+ aTab.browser.setAttribute("autocompletepopup", "PopupAutoComplete");
+ aTab.browser.setAttribute("context", "browserContext");
+ aTab.browser.setAttribute("maychangeremoteness", "true");
+ aTab.browser.setAttribute("onclick", "return contentAreaClick(event);");
+ aTab.browser.openWindowInfo = aArgs.openWindowInfo || null;
+ clone.querySelector("stack").appendChild(aTab.browser);
+
+ if (aArgs.skipLoad) {
+ clone.querySelector("browser").setAttribute("nodefaultsrc", "true");
+ }
+ if (aArgs.userContextId) {
+ aTab.browser.setAttribute("usercontextid", aArgs.userContextId);
+ }
+ aTab.panel.setAttribute("id", "contentTabWrapper" + this.lastBrowserId);
+ aTab.panel.appendChild(clone);
+ aTab.root = clone;
+
+ ExtensionParent.apiManager.emit(
+ "extension-browser-inserted",
+ aTab.browser
+ );
+
+ // For pdf.js use the aboutPagesContext context menu.
+ if (aArgs.url.includes("type=application/pdf")) {
+ aTab.browser.setAttribute("context", "aboutPagesContext");
+ }
+
+ // Start setting up the browser.
+ aTab.toolbar = aTab.panel.querySelector(".contentTabToolbar");
+ aTab.backButton = aTab.toolbar.querySelector(".back-btn");
+ aTab.backButton.addEventListener("command", () => aTab.browser.goBack());
+ aTab.forwardButton = aTab.toolbar.querySelector(".forward-btn");
+ aTab.forwardButton.addEventListener("command", () =>
+ aTab.browser.goForward()
+ );
+ aTab.securityIcon = new SecurityIcon(
+ aTab.toolbar.querySelector(".contentTabSecurity")
+ );
+ aTab.urlbar = aTab.toolbar.querySelector(".contentTabUrlInput");
+ aTab.urlbar.value = aArgs.url;
+
+ // As we're opening this tab, showTab may not get called, so set
+ // the type according to if we're opening in background or not.
+ let background = "background" in aArgs && aArgs.background;
+ if (background) {
+ aTab.browser.removeAttribute("primary");
+ } else {
+ aTab.browser.setAttribute("primary", "true");
+ }
+
+ if (aArgs.linkHandler == "single-page") {
+ aTab.browser.setAttribute("messagemanagergroup", "single-page");
+ } else if (aArgs.linkHandler === null) {
+ aTab.browser.setAttribute("messagemanagergroup", "browsers");
+ } else {
+ aTab.browser.setAttribute("messagemanagergroup", "single-site");
+ }
+
+ aTab.browser.addEventListener("DOMLinkAdded", DOMLinkHandler);
+ aTab.browser.addEventListener("DOMLinkChanged", DOMLinkHandler);
+
+ // Now initialise the find bar.
+ aTab.findbar = document.createXULElement("findbar");
+ aTab.findbar.setAttribute(
+ "browserid",
+ "contentTabBrowser" + this.lastBrowserId
+ );
+ clone.appendChild(aTab.findbar);
+
+ // Default to reload being disabled.
+ aTab.reloadEnabled = false;
+
+ // Now set up the listeners.
+ this._setUpLoadListener(aTab);
+ this._setUpTitleListener(aTab);
+ this._setUpCloseWindowListener(aTab);
+
+ /**
+ * Override the browser custom element's version, which returns gBrowser.
+ */
+ aTab.browser.getTabBrowser = function () {
+ return document.getElementById("tabmail");
+ };
+
+ if ("onLoad" in aArgs) {
+ aTab.browser.addEventListener(
+ "load",
+ function _contentTab_onLoad(event) {
+ aArgs.onLoad(event, aTab.browser);
+ aTab.browser.removeEventListener("load", _contentTab_onLoad, true);
+ },
+ true
+ );
+ }
+
+ // Create a filter and hook it up to our browser
+ let filter = Cc[
+ "@mozilla.org/appshell/component/browser-status-filter;1"
+ ].createInstance(Ci.nsIWebProgress);
+ aTab.filter = filter;
+ aTab.browser.webProgress.addProgressListener(
+ filter,
+ Ci.nsIWebProgress.NOTIFY_ALL
+ );
+
+ // Wire up a progress listener to the filter for this browser
+ aTab.progressListener = new tabProgressListener(aTab, false);
+
+ filter.addProgressListener(
+ aTab.progressListener,
+ Ci.nsIWebProgress.NOTIFY_ALL
+ );
+
+ if ("onListener" in aArgs) {
+ aArgs.onListener(aTab.browser, aTab.progressListener);
+ }
+
+ // Initialize our unit testing variables.
+ aTab.pageLoading = false;
+ aTab.pageLoaded = false;
+
+ // Now start loading the content.
+ aTab.title = this.loadingTabString;
+
+ if (!aArgs.skipLoad) {
+ MailE10SUtils.loadURI(aTab.browser, aArgs.url, {
+ csp: aArgs.csp,
+ referrerInfo: aArgs.referrerInfo,
+ triggeringPrincipal: aArgs.triggeringPrincipal,
+ });
+ }
+
+ this.lastBrowserId++;
+ },
+ tryCloseTab(aTab) {
+ return aTab.browser.permitUnload();
+ },
+ persistTab(aTab) {
+ if (aTab.browser.currentURI.spec == "about:blank") {
+ return null;
+ }
+
+ // Extension pages of temporarily installed extensions cannot be restored.
+ if (
+ aTab.browser.currentURI.scheme == "moz-extension" &&
+ WebExtensionPolicy.getByHostname(aTab.browser.currentURI.host)
+ ?.temporarilyInstalled
+ ) {
+ return null;
+ }
+
+ return {
+ tabURI: aTab.browser.currentURI.spec,
+ linkHandler: aTab.browser.getAttribute("messagemanagergroup"),
+ userContextId: `${
+ aTab.browser.getAttribute("usercontextid") ||
+ Ci.nsIScriptSecurityManager.DEFAULT_USER_CONTEXT_ID
+ }`,
+ };
+ },
+ restoreTab(aTabmail, aPersistedState) {
+ let tab = aTabmail.openTab("contentTab", {
+ background: true,
+ duplicate: aPersistedState.duplicate,
+ linkHandler: aPersistedState.linkHandler,
+ url: aPersistedState.tabURI,
+ userContextId: aPersistedState.userContextId,
+ });
+
+ if (aPersistedState.tabURI == "about:addons") {
+ // Also in `openAddonsMgr` in mailCore.js.
+ tab.browser.droppedLinkHandler = event =>
+ tab.browser.contentWindow.gDragDrop.onDrop(event);
+ }
+ },
+ },
+
+ /**
+ * Shows the what's new page in the system browser if we should.
+ * Will update the mstone pref to a new version if needed.
+ *
+ * @see {BrowserContentHandler.needHomepageOverride}
+ */
+ showWhatsNewPage() {
+ let old_mstone = Services.prefs.getCharPref(
+ "mailnews.start_page_override.mstone",
+ ""
+ );
+
+ let mstone = Services.appinfo.version;
+ if (mstone != old_mstone) {
+ Services.prefs.setCharPref("mailnews.start_page_override.mstone", mstone);
+ }
+
+ if (AppConstants.MOZ_UPDATER) {
+ let update = Cc["@mozilla.org/updates/update-manager;1"].getService(
+ Ci.nsIUpdateManager
+ ).readyUpdate;
+
+ if (update && Services.vc.compare(update.appVersion, old_mstone) > 0) {
+ let overridePage = Services.urlFormatter.formatURLPref(
+ "mailnews.start_page.override_url"
+ );
+ overridePage = this.getPostUpdateOverridePage(update, overridePage);
+ overridePage = overridePage.replace("%OLD_VERSION%", old_mstone);
+ if (overridePage) {
+ openLinkExternally(overridePage);
+ }
+ }
+ }
+ },
+
+ /**
+ * Gets the override page for the first run after the application has been
+ * updated.
+ *
+ * @param {nsIUpdate} update - The nsIUpdate for the update that has been applied.
+ * @param {string} defaultOverridePage - The default override page.
+ * @returns {string} The override page.
+ */
+ getPostUpdateOverridePage(update, defaultOverridePage) {
+ update = update.QueryInterface(Ci.nsIWritablePropertyBag);
+ let actions = update.getProperty("actions");
+ // When the update doesn't specify actions fallback to the original behavior
+ // of displaying the default override page.
+ if (!actions) {
+ return defaultOverridePage;
+ }
+
+ // The existence of silent or the non-existence of showURL in the actions both
+ // mean that an override page should not be displayed.
+ if (actions.includes("silent") || !actions.includes("showURL")) {
+ return "";
+ }
+
+ // If a policy was set to not allow the update.xml-provided
+ // URL to be used, use the default fallback (which will also
+ // be provided by the policy).
+ if (!Services.policies.isAllowed("postUpdateCustomPage")) {
+ return defaultOverridePage;
+ }
+
+ return update.getProperty("openURL") || defaultOverridePage;
+ },
+
+ /**
+ * Looks at the existing prefs and determines if we should show the policy or not.
+ */
+ shouldShowPolicyNotification() {
+ let dataSubmissionEnabled = Services.prefs.getBoolPref(
+ "datareporting.policy.dataSubmissionEnabled",
+ true
+ );
+ let dataSubmissionPolicyBypassNotification = Services.prefs.getBoolPref(
+ "datareporting.policy.dataSubmissionPolicyBypassNotification",
+ false
+ );
+ let dataSubmissionPolicyAcceptedVersion = Services.prefs.getIntPref(
+ "datareporting.policy.dataSubmissionPolicyAcceptedVersion",
+ 0
+ );
+ let currentPolicyVersion = Services.prefs.getIntPref(
+ "datareporting.policy.currentPolicyVersion",
+ 1
+ );
+ if (
+ !AppConstants.MOZ_DATA_REPORTING ||
+ !dataSubmissionEnabled ||
+ dataSubmissionPolicyBypassNotification
+ ) {
+ return false;
+ }
+ if (dataSubmissionPolicyAcceptedVersion >= currentPolicyVersion) {
+ return false;
+ }
+ return true;
+ },
+
+ showPolicyNotification() {
+ try {
+ let firstRunURL = Services.prefs.getStringPref(
+ "datareporting.policy.firstRunURL"
+ );
+ document.getElementById("tabmail").openTab("contentTab", {
+ background: true,
+ url: firstRunURL,
+ });
+ } catch (e) {
+ // Show the infobar if it fails to show the privacy policy in the new tab.
+ this.showTelemetryNotification();
+ }
+ let currentPolicyVersion = Services.prefs.getIntPref(
+ "datareporting.policy.currentPolicyVersion",
+ 1
+ );
+ Services.prefs.setIntPref(
+ "datareporting.policy.dataSubmissionPolicyAcceptedVersion",
+ currentPolicyVersion
+ );
+ Services.prefs.setStringPref(
+ "datareporting.policy.dataSubmissionPolicyNotifiedTime",
+ new Date().getTime().toString()
+ );
+ },
+
+ showTelemetryNotification() {
+ let brandBundle = Services.strings.createBundle(
+ "chrome://branding/locale/brand.properties"
+ );
+ let telemetryBundle = Services.strings.createBundle(
+ "chrome://messenger/locale/telemetry.properties"
+ );
+
+ let productName = brandBundle.GetStringFromName("brandFullName");
+ let serverOwner = Services.prefs.getCharPref(
+ "toolkit.telemetry.server_owner"
+ );
+ let telemetryText = telemetryBundle.formatStringFromName("telemetryText", [
+ productName,
+ serverOwner,
+ ]);
+
+ // TODO: sync up this bar with Firefox:
+ // https://searchfox.org/mozilla-central/rev/227f22acef5c4865503bde9f835452bf38332c8e/browser/locales/en-US/chrome/browser/browser.properties#697-698
+ let buttons = [
+ {
+ label: telemetryBundle.GetStringFromName("telemetryLinkLabel"),
+ popup: null,
+ callback: () => {
+ openOptionsDialog("panePrivacy", "privacyDataCollectionCategory");
+ },
+ },
+ ];
+
+ let notification = this.msgNotificationBar.appendNotification(
+ "telemetry",
+ {
+ label: telemetryText,
+ priority: this.msgNotificationBar.PRIORITY_INFO_LOW,
+ },
+ buttons
+ );
+ // Arbitrary number, just so bar sticks around for a bit.
+ notification.persistence = 3;
+ },
+
+ /**
+ * Looks at the existing prefs and determines if we should show about:rights
+ * or not.
+ *
+ * This is controlled by two prefs:
+ *
+ * mail.rights.override
+ * If this pref is set to false, always show the about:rights
+ * notification.
+ * If this pref is set to true, never show the about:rights notification.
+ * If the pref doesn't exist, then we fallback to checking
+ * mail.rights.version.
+ *
+ * mail.rights.version
+ * If this pref isn't set or the value is less than the current version
+ * then we show the about:rights notification.
+ */
+ shouldShowAboutRightsNotification() {
+ try {
+ return !Services.prefs.getBoolPref("mail.rights.override");
+ } catch (e) {}
+
+ return (
+ Services.prefs.getIntPref("mail.rights.version") <
+ this._kAboutRightsVersion
+ );
+ },
+
+ async showAboutRightsNotification() {
+ var rightsBundle = Services.strings.createBundle(
+ "chrome://messenger/locale/aboutRights.properties"
+ );
+
+ var buttons = [
+ {
+ label: rightsBundle.GetStringFromName("buttonLabel"),
+ accessKey: rightsBundle.GetStringFromName("buttonAccessKey"),
+ popup: null,
+ callback(aNotificationBar, aButton) {
+ // Show the about:rights tab
+ document.getElementById("tabmail").openTab("contentTab", {
+ url: "about:rights",
+ });
+ },
+ },
+ ];
+
+ let notifyRightsText = await document.l10n.formatValue(
+ "about-rights-notification-text"
+ );
+ let notification = this.msgNotificationBar.appendNotification(
+ "about-rights",
+ {
+ label: notifyRightsText,
+ priority: this.msgNotificationBar.PRIORITY_INFO_LOW,
+ },
+ buttons
+ );
+ // Arbitrary number, just so bar sticks around for a bit.
+ notification.persistence = 3;
+
+ // Set the pref to say we've displayed the notification.
+ Services.prefs.setIntPref("mail.rights.version", this._kAboutRightsVersion);
+ },
+
+ /**
+ * Determine if we should load fav icons or not.
+ *
+ * @param aURI An nsIURI containing the current url.
+ */
+ _shouldLoadFavIcon(aURI) {
+ return (
+ aURI &&
+ Services.prefs.getBoolPref("browser.chrome.site_icons") &&
+ Services.prefs.getBoolPref("browser.chrome.favicons") &&
+ "schemeIs" in aURI &&
+ (aURI.schemeIs("http") || aURI.schemeIs("https"))
+ );
+ },
+
+ /**
+ * Tries to use the default favicon for a webpage for the specified tab.
+ * We'll use the site's favicon.ico if prefs allow us to.
+ */
+ useDefaultFavIcon(aTab) {
+ // Use documentURI in the check for shouldLoadFavIcon so that we do the
+ // right thing with about:-style error pages.
+ let docURIObject = aTab.browser.documentURI;
+ let icon = null;
+ if (this._shouldLoadFavIcon(docURIObject)) {
+ icon = docURIObject.prePath + "/favicon.ico";
+ }
+
+ this.setFavIcon(aTab, icon);
+ },
+
+ /**
+ * This sets the specified tab to load and display the given icon for the
+ * page shown in the browser. It is assumed that the preferences have already
+ * been checked before calling this function appropriately.
+ *
+ * @param aTab The tab to set the icon for.
+ * @param aIcon A string based URL of the icon to try and load.
+ */
+ setFavIcon(aTab, aIcon) {
+ if (aIcon) {
+ PlacesUtils.favicons.setAndFetchFaviconForPage(
+ aTab.browser.currentURI,
+ Services.io.newURI(aIcon),
+ false,
+ PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
+ null,
+ aTab.browser.contentPrincipal
+ );
+ }
+ document
+ .getElementById("tabmail")
+ .setTabFavIcon(
+ aTab,
+ aIcon,
+ "chrome://messenger/skin/icons/new/compact/draft.svg"
+ );
+ },
+
+ browserForward() {
+ let tabmail = document.getElementById("tabmail");
+ if (
+ !["contentTab", "mail3PaneTab"].includes(
+ tabmail?.currentTabInfo.mode.name
+ )
+ ) {
+ return;
+ }
+ let browser = tabmail.getBrowserForSelectedTab();
+ if (!browser) {
+ return;
+ }
+ if (browser.webNavigation) {
+ browser.webNavigation.goForward();
+ }
+ },
+
+ browserBack() {
+ let tabmail = document.getElementById("tabmail");
+ if (
+ !["contentTab", "mail3PaneTab"].includes(
+ tabmail?.currentTabInfo.mode.name
+ )
+ ) {
+ return;
+ }
+ let browser = tabmail.getBrowserForSelectedTab();
+ if (!browser) {
+ return;
+ }
+ if (browser.webNavigation) {
+ browser.webNavigation.goBack();
+ }
+ },
+};
diff --git a/comm/mail/base/content/sync.js b/comm/mail/base/content/sync.js
new file mode 100644
index 0000000000..2a53e10856
--- /dev/null
+++ b/comm/mail/base/content/sync.js
@@ -0,0 +1,157 @@
+/* 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/. */
+
+/**
+ * AppMenu UI for Sync. This file is only loaded if NIGHTLY_BUILD is set.
+ */
+
+/* import-globals-from utilityOverlay.js */
+
+ChromeUtils.defineESModuleGetters(this, {
+ EnsureFxAccountsWebChannel:
+ "resource://gre/modules/FxAccountsWebChannel.sys.mjs",
+ FxAccounts: "resource://gre/modules/FxAccounts.sys.mjs",
+ UIState: "resource://services-sync/UIState.sys.mjs",
+ Weave: "resource://services-sync/main.sys.mjs",
+});
+
+var gSync = {
+ handleEvent(event) {
+ if (event.type == "load") {
+ this.updateFxAPanel();
+ Services.obs.addObserver(this, UIState.ON_UPDATE);
+ window.addEventListener("unload", this, { once: true });
+ } else if (event.type == "unload") {
+ Services.obs.removeObserver(this, UIState.ON_UPDATE);
+ }
+ },
+
+ observe(subject, topic, data) {
+ this.updateFxAPanel();
+ },
+
+ /**
+ * Update the app menu items to match the current state.
+ */
+ updateFxAPanel() {
+ let state = UIState.get();
+ let isSignedIn = state.status == UIState.STATUS_SIGNED_IN;
+ document.getElementById("appmenu_signin").hidden = isSignedIn;
+ document.getElementById("appmenu_sync").hidden = !isSignedIn;
+ document.getElementById("syncSeparator").hidden = false;
+ document.querySelectorAll(".appmenu-sync-account-email").forEach(el => {
+ el.value = state.email;
+ el.removeAttribute("data-l10n-id");
+ });
+ let button = document.getElementById("appmenu-submenu-sync-now");
+ if (button) {
+ if (state.syncing) {
+ button.setAttribute("syncstatus", "active");
+ } else {
+ button.removeAttribute("syncstatus");
+ }
+ }
+ },
+
+ /**
+ * Opens the FxA log-in page in a tab.
+ *
+ * @param {string = ""} entryPoint
+ */
+ async initFxA() {
+ EnsureFxAccountsWebChannel();
+ let url = await FxAccounts.config.promiseConnectAccountURI("");
+ openContentTab(url);
+ },
+
+ /**
+ * Opens the FxA account management page in a tab.
+ *
+ * @param {string = ""} entryPoint
+ */
+ async openFxAManagePage(entryPoint = "") {
+ EnsureFxAccountsWebChannel();
+ const url = await FxAccounts.config.promiseManageURI(entryPoint);
+ openContentTab(url);
+ },
+
+ /**
+ * Opens the FxA avatar management page in a tab.
+ *
+ * @param {string = ""} entryPoint
+ */
+ async openFxAAvatarPage(entryPoint = "") {
+ EnsureFxAccountsWebChannel();
+ const url = await FxAccounts.config.promiseChangeAvatarURI(entryPoint);
+ openContentTab(url);
+ },
+
+ /**
+ * Disconnect from sync, and optionally disconnect from the FxA account.
+ *
+ * @param {boolean} confirm - Should the user be asked to confirm the
+ * disconnection?
+ * @param {boolean} disconnectAccount - If true, disconnect from FxA as well
+ * as Sync. If false, just disconnect from Sync.
+ * @returns {boolean} - true if the disconnection happened (ie, if the user
+ * didn't decline when asked to confirm)
+ */
+ async disconnect({ confirm = false, disconnectAccount = true }) {
+ if (confirm) {
+ let title, body, button;
+ if (disconnectAccount) {
+ [title, body, button] = await document.l10n.formatValues([
+ "fxa-signout-dialog-title",
+ "fxa-signout-dialog-body",
+ "fxa-signout-dialog-button",
+ ]);
+ } else {
+ [title, body, button] = await document.l10n.formatValues([
+ "sync-disconnect-dialog-title",
+ "sync-disconnect-dialog-body",
+ "sync-disconnect-dialog-button",
+ ]);
+ }
+
+ let flags =
+ Services.prompt.BUTTON_TITLE_IS_STRING * Services.prompt.BUTTON_POS_0 +
+ Services.prompt.BUTTON_TITLE_CANCEL * Services.prompt.BUTTON_POS_1;
+
+ // buttonPressed will be 0 for disconnect, 1 for cancel.
+ let buttonPressed = Services.prompt.confirmEx(
+ window,
+ title,
+ body,
+ flags,
+ button,
+ null,
+ null,
+ null,
+ {}
+ );
+ if (buttonPressed != 0) {
+ return false;
+ }
+ }
+
+ let fxAccounts = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccounts.sys.mjs"
+ ).getFxAccountsSingleton();
+
+ if (disconnectAccount) {
+ const { SyncDisconnect } = ChromeUtils.importESModule(
+ "resource://services-sync/SyncDisconnect.sys.mjs"
+ );
+ await fxAccounts.telemetry.recordDisconnection(null, "ui");
+ await SyncDisconnect.disconnect(false);
+ return true;
+ }
+
+ await fxAccounts.telemetry.recordDisconnection("sync", "ui");
+ await Weave.Service.promiseInitialized;
+ await Weave.Service.startOver();
+ return true;
+ },
+};
+window.addEventListener("load", gSync, { once: true });
diff --git a/comm/mail/base/content/systemIntegrationDialog.js b/comm/mail/base/content/systemIntegrationDialog.js
new file mode 100644
index 0000000000..41455b3db5
--- /dev/null
+++ b/comm/mail/base/content/systemIntegrationDialog.js
@@ -0,0 +1,188 @@
+/* -*- Mode: Javascript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*-
+ * 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/. */
+
+// This dialog can only be opened if we have a shell service.
+
+var { SearchIntegration } = ChromeUtils.import(
+ "resource:///modules/SearchIntegration.jsm"
+);
+
+var gSystemIntegrationDialog = {
+ _shellSvc: Cc["@mozilla.org/mail/shell-service;1"].getService(
+ Ci.nsIShellService
+ ),
+
+ _mailCheckbox: null,
+
+ _newsCheckbox: null,
+
+ _rssCheckbox: null,
+
+ _startupCheckbox: null,
+
+ _searchCheckbox: null,
+
+ onLoad() {
+ // initialize elements
+ this._mailCheckbox = document.getElementById("checkMail");
+ this._newsCheckbox = document.getElementById("checkNews");
+ this._rssCheckbox = document.getElementById("checkRSS");
+ this._calendarCheckbox = document.getElementById("checkCalendar");
+ this._startupCheckbox = document.getElementById("checkOnStartup");
+ this._searchCheckbox = document.getElementById("searchIntegration");
+
+ // Initialize the check boxes based on the default app states.
+ this._mailCheckbox.disabled = this._shellSvc.isDefaultClient(
+ false,
+ this._shellSvc.MAIL
+ );
+
+ let calledFromPrefs =
+ "arguments" in window && window.arguments[0] == "calledFromPrefs";
+
+ if (!calledFromPrefs) {
+ // As an optimization, if we aren't already the default mail client,
+ // then pre-check that option for the user. We'll leave News and RSS alone.
+ // Do this only if we are not called from the Preferences (Options) dialog.
+ // In that case, the user may want to just check what the current state is.
+ this._mailCheckbox.checked = true;
+ } else {
+ this._mailCheckbox.checked = this._mailCheckbox.disabled;
+
+ // If called from preferences, use only a simpler "Cancel" label on the
+ // cancel button.
+ document.querySelector("dialog").getButton("cancel").label = document
+ .querySelector("dialog")
+ .getAttribute("buttonlabelcancel2");
+ }
+
+ if (!this._mailCheckbox.disabled) {
+ this._mailCheckbox.removeAttribute("tooltiptext");
+ }
+
+ this._newsCheckbox.checked = this._newsCheckbox.disabled =
+ this._shellSvc.isDefaultClient(false, this._shellSvc.NEWS);
+ if (!this._newsCheckbox.disabled) {
+ this._newsCheckbox.removeAttribute("tooltiptext");
+ }
+
+ this._rssCheckbox.checked = this._rssCheckbox.disabled =
+ this._shellSvc.isDefaultClient(false, this._shellSvc.RSS);
+ if (!this._rssCheckbox.disabled) {
+ this._rssCheckbox.removeAttribute("tooltiptext");
+ }
+
+ this._calendarCheckbox.checked = this._calendarCheckbox.disabled =
+ this._shellSvc.isDefaultClient(false, this._shellSvc.CALENDAR);
+
+ // read the raw pref value and not shellSvc.shouldCheckDefaultMail
+ this._startupCheckbox.checked = Services.prefs.getBoolPref(
+ "mail.shell.checkDefaultClient"
+ );
+
+ // Search integration - check whether we should show/disable integration options
+ if (SearchIntegration) {
+ this._searchCheckbox.checked = SearchIntegration.prefEnabled;
+ // On Windows, do not offer the option on startup as it does not perform well.
+ if (
+ Services.appinfo.OS == "WINNT" &&
+ !calledFromPrefs &&
+ !this._searchCheckbox.checked
+ ) {
+ this._searchCheckbox.hidden = true;
+ // Even if the user wasn't presented the choice,
+ // we do not want to ask again automatically.
+ SearchIntegration.firstRunDone = true;
+ } else if (!SearchIntegration.osVersionTooLow) {
+ // Hide/disable the options if the OS does not support them.
+ this._searchCheckbox.hidden = false;
+ if (SearchIntegration.osComponentsNotRunning) {
+ this._searchCheckbox.checked = false;
+ this._searchCheckbox.disabled = true;
+ }
+ }
+ }
+ },
+
+ /**
+ * Called when the dialog is closed by any button.
+ *
+ * @param aSetAsDefault If true, set TB as the default application for the
+ * checked actions (mail/news/rss). Otherwise do nothing.
+ */
+ onDialogClose(aSetAsDefault) {
+ // In all cases, save the user's decision for "always check at startup".
+ this._shellSvc.shouldCheckDefaultClient = this._startupCheckbox.checked;
+
+ // If the search checkbox is exposed, the user had the chance to make his choice.
+ // So do not ask next time.
+ let searchIntegPossible = !this._searchCheckbox.hidden;
+ if (searchIntegPossible) {
+ SearchIntegration.firstRunDone = true;
+ }
+
+ // If the "skip integration" button was used do not set any defaults
+ // and close the dialog.
+ if (!aSetAsDefault) {
+ // Disable search integration in this case.
+ if (searchIntegPossible) {
+ SearchIntegration.prefEnabled = false;
+ }
+
+ return true;
+ }
+
+ // For each checked item, if we aren't already the default client,
+ // make us the default.
+ let appTypes = 0;
+
+ if (
+ this._mailCheckbox.checked &&
+ !this._shellSvc.isDefaultClient(false, this._shellSvc.MAIL)
+ ) {
+ appTypes |= this._shellSvc.MAIL;
+ }
+
+ if (
+ this._newsCheckbox.checked &&
+ !this._shellSvc.isDefaultClient(false, this._shellSvc.NEWS)
+ ) {
+ appTypes |= this._shellSvc.NEWS;
+ }
+
+ if (
+ this._rssCheckbox.checked &&
+ !this._shellSvc.isDefaultClient(false, this._shellSvc.RSS)
+ ) {
+ appTypes |= this._shellSvc.RSS;
+ }
+
+ if (
+ this._calendarCheckbox.checked &&
+ !this._shellSvc.isDefaultClient(false, this._shellSvc.CALENDAR)
+ ) {
+ appTypes |= this._shellSvc.CALENDAR;
+ }
+
+ if (appTypes) {
+ this._shellSvc.setDefaultClient(false, appTypes);
+ }
+
+ // Set the search integration pref if it is changed.
+ // The integration will handle the rest.
+ if (searchIntegPossible) {
+ SearchIntegration.prefEnabled = this._searchCheckbox.checked;
+ }
+
+ return true;
+ },
+};
+
+document.addEventListener("dialogaccept", () =>
+ gSystemIntegrationDialog.onDialogClose(true)
+);
+document.addEventListener("dialogcancel", () =>
+ gSystemIntegrationDialog.onDialogClose(false)
+);
diff --git a/comm/mail/base/content/systemIntegrationDialog.xhtml b/comm/mail/base/content/systemIntegrationDialog.xhtml
new file mode 100644
index 0000000000..6ce932aed5
--- /dev/null
+++ b/comm/mail/base/content/systemIntegrationDialog.xhtml
@@ -0,0 +1,53 @@
+<?xml version="1.0"?>
+<!-- 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/. -->
+
+<?xml-stylesheet href="chrome://global/skin/global.css"?>
+
+<!DOCTYPE window>
+
+<window
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ onload="gSystemIntegrationDialog.onLoad();"
+ data-l10n-id="system-integration-title"
+ style="min-width: 33em"
+>
+ <dialog
+ id="systemIntegrationDialog"
+ buttons="accept,cancel"
+ data-l10n-id="system-integration-dialog"
+ data-l10n-attrs="buttonlabelaccept, buttonlabelcancel, buttonlabelcancel2"
+ >
+ <script src="chrome://messenger/content/systemIntegrationDialog.js" />
+
+ <linkset>
+ <html:link rel="localization" href="branding/brand.ftl" />
+ <html:link
+ rel="localization"
+ href="messenger/preferences/system-integration.ftl"
+ />
+ </linkset>
+
+ <label control="defaultClientList" data-l10n-id="default-client-intro" />
+ <vbox id="defaultClientList" role="group">
+ <checkbox id="checkMail" data-l10n-id="checkbox-email-label" />
+ <checkbox id="checkNews" data-l10n-id="checkbox-newsgroups-label" />
+ <checkbox id="checkRSS" data-l10n-id="checkbox-feeds-label" />
+ <checkbox id="checkCalendar" data-l10n-id="checkbox-calendar-label" />
+ </vbox>
+
+ <separator class="groove" />
+
+ <checkbox
+ id="searchIntegration"
+ hidden="true"
+ data-l10n-id="system-search-integration-label"
+ />
+
+ <separator class="thin" />
+
+ <checkbox id="checkOnStartup" data-l10n-id="check-on-startup-label" />
+ </dialog>
+</window>
diff --git a/comm/mail/base/content/tabDialogs.inc.xhtml b/comm/mail/base/content/tabDialogs.inc.xhtml
new file mode 100644
index 0000000000..956db429a4
--- /dev/null
+++ b/comm/mail/base/content/tabDialogs.inc.xhtml
@@ -0,0 +1,23 @@
+# 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/.
+
+<html:template id="dialogStackTemplate">
+ <stack class="dialogStack tab-dialog-box" hidden="true">
+ <vbox class="dialogTemplate dialogOverlay" align="center" topmost="true" hidden="true">
+ <hbox class="dialogBox">
+ <browser class="dialogFrame"
+ autoscroll="false"
+ disablehistory="true"/>
+ </hbox>
+ </vbox>
+ </stack>
+</html:template>
+
+<html:template id="printPreviewStackTemplate">
+ <stack class="previewStack" rendering="true" flex="1" previewtype="primary">
+ <vbox class="previewRendering" flex="1">
+ <h1 class="print-pending-label" data-l10n-id="printui-loading"></h1>
+ </vbox>
+ </stack>
+</html:template>
diff --git a/comm/mail/base/content/tabmail.js b/comm/mail/base/content/tabmail.js
new file mode 100644
index 0000000000..c2ab652ef9
--- /dev/null
+++ b/comm/mail/base/content/tabmail.js
@@ -0,0 +1,2048 @@
+/* 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/. */
+
+"use strict"; // from mailWindow.js
+
+/* global MozElements, MozXULElement */
+
+/* import-globals-from mailCore.js */
+/* globals contentProgress, statusFeedback */
+
+var { UIFontSize } = ChromeUtils.import("resource:///modules/UIFontSize.jsm");
+
+// Wrap in a block to prevent leaking to window scope.
+{
+ /**
+ * The MozTabmailAlltabsMenuPopup widget is used as a menupopup to list all the
+ * currently opened tabs.
+ *
+ * @augments {MozElements.MozMenuPopup}
+ * @implements {EventListener}
+ */
+ class MozTabmailAlltabsMenuPopup extends MozElements.MozMenuPopup {
+ connectedCallback() {
+ if (this.delayConnectedCallback() || this.hasChildNodes()) {
+ return;
+ }
+
+ this.tabmail = document.getElementById("tabmail");
+
+ this._mutationObserver = new MutationObserver((records, observer) => {
+ records.forEach(mutation => {
+ let menuItem = mutation.target.mCorrespondingMenuitem;
+ if (menuItem) {
+ this._setMenuitemAttributes(menuItem, mutation.target);
+ }
+ });
+ });
+
+ this.addEventListener("popupshowing", event => {
+ // Set up the menu popup.
+ let tabcontainer = this.tabmail.tabContainer;
+ let tabs = tabcontainer.allTabs;
+
+ // Listen for changes in the tab bar.
+ this._mutationObserver.observe(tabcontainer, {
+ attributes: true,
+ subtree: true,
+ attributeFilter: ["label", "crop", "busy", "image", "selected"],
+ });
+
+ this.tabmail.addEventListener("TabOpen", this);
+ tabcontainer.arrowScrollbox.addEventListener("scroll", this);
+
+ // If an animation is in progress and the user
+ // clicks on the "all tabs" button, stop the animation.
+ tabcontainer._stopAnimation();
+
+ for (let i = 0; i < tabs.length; i++) {
+ this._createTabMenuItem(tabs[i]);
+ }
+ this._updateTabsVisibilityStatus();
+ });
+
+ this.addEventListener("popuphiding", event => {
+ // Clear out the menu popup and remove the listeners.
+ while (this.hasChildNodes()) {
+ let menuItem = this.lastElementChild;
+ menuItem.removeEventListener("command", this);
+ menuItem.tab.removeEventListener("TabClose", this);
+ menuItem.tab.mCorrespondingMenuitem = null;
+ menuItem.remove();
+ }
+ this._mutationObserver.disconnect();
+
+ this.tabmail.tabContainer.arrowScrollbox.removeEventListener(
+ "scroll",
+ this
+ );
+ this.tabmail.removeEventListener("TabOpen", this);
+ });
+ }
+
+ _menuItemOnCommand(aEvent) {
+ this.tabmail.tabContainer.selectedItem = aEvent.target.tab;
+ }
+
+ _tabOnTabClose(aEvent) {
+ let menuItem = aEvent.target.mCorrespondingMenuitem;
+ if (menuItem) {
+ menuItem.remove();
+ }
+ }
+
+ handleEvent(aEvent) {
+ if (!aEvent.isTrusted) {
+ return;
+ }
+
+ switch (aEvent.type) {
+ case "command":
+ this._menuItemOnCommand(aEvent);
+ break;
+ case "TabClose":
+ this._tabOnTabClose(aEvent);
+ break;
+ case "TabOpen":
+ this._createTabMenuItem(aEvent.target);
+ break;
+ case "scroll":
+ this._updateTabsVisibilityStatus();
+ break;
+ }
+ }
+
+ _updateTabsVisibilityStatus() {
+ let tabStrip = this.tabmail.tabContainer.arrowScrollbox;
+ // We don't want menu item decoration unless there is overflow.
+ if (tabStrip.getAttribute("overflow") != "true") {
+ return;
+ }
+
+ let tabStripBox = tabStrip.getBoundingClientRect();
+
+ for (let i = 0; i < this.children.length; i++) {
+ let currentTabBox = this.children[i].tab.getBoundingClientRect();
+
+ if (
+ currentTabBox.left >= tabStripBox.left &&
+ currentTabBox.right <= tabStripBox.right
+ ) {
+ this.children[i].setAttribute("tabIsVisible", "true");
+ } else {
+ this.children[i].removeAttribute("tabIsVisible");
+ }
+ }
+ }
+
+ _createTabMenuItem(aTab) {
+ let menuItem = document.createXULElement("menuitem");
+
+ menuItem.setAttribute(
+ "class",
+ "menuitem-iconic alltabs-item menuitem-with-favicon"
+ );
+
+ this._setMenuitemAttributes(menuItem, aTab);
+
+ // Keep some attributes of the menuitem in sync with its
+ // corresponding tab (e.g. the tab label).
+ aTab.mCorrespondingMenuitem = menuItem;
+ aTab.addEventListener("TabClose", this);
+ menuItem.tab = aTab;
+ menuItem.addEventListener("command", this);
+
+ this.appendChild(menuItem);
+ return menuItem;
+ }
+
+ _setMenuitemAttributes(aMenuitem, aTab) {
+ aMenuitem.setAttribute("label", aTab.label);
+ aMenuitem.setAttribute("crop", "end");
+
+ if (aTab.hasAttribute("busy")) {
+ aMenuitem.setAttribute("busy", aTab.getAttribute("busy"));
+ aMenuitem.removeAttribute("image");
+ } else {
+ aMenuitem.setAttribute("image", aTab.getAttribute("image"));
+ aMenuitem.removeAttribute("busy");
+ }
+
+ // Change the tab icon accordingly.
+ let style = window.getComputedStyle(aTab);
+ aMenuitem.style.listStyleImage = style.listStyleImage;
+ aMenuitem.style.MozImageRegion = style.MozImageRegion;
+
+ if (aTab.hasAttribute("pending")) {
+ aMenuitem.setAttribute("pending", aTab.getAttribute("pending"));
+ } else {
+ aMenuitem.removeAttribute("pending");
+ }
+
+ if (aTab.selected) {
+ aMenuitem.setAttribute("selected", "true");
+ } else {
+ aMenuitem.removeAttribute("selected");
+ }
+ }
+ }
+
+ customElements.define(
+ "tabmail-alltabs-menupopup",
+ MozTabmailAlltabsMenuPopup,
+ { extends: "menupopup" }
+ );
+
+ /**
+ * Thunderbird's tab UI mechanism.
+ *
+ * We expect to be instantiated with the following children:
+ * One "tabpanels" child element whose id must be placed in the
+ * "panelcontainer" attribute on the element we are being bound to. We do
+ * this because it is important to allow overlays to contribute panels.
+ * When we attempted to have the immediate children of the bound element
+ * be propagated through use of the "children" tag, we found that children
+ * contributed by overlays did not propagate.
+ * Any children you want added to the right side of the tab bar. This is
+ * primarily intended to allow for "open a BLANK tab" buttons, namely
+ * calendar and tasks. For reasons similar to the tabpanels case, we
+ * expect the instantiating element to provide a child hbox for overlays
+ * to contribute buttons to.
+ *
+ * From a javascript perspective, there are three types of code that we
+ * expect to interact with:
+ * 1) Code that wants to open new tabs.
+ * 2) Code that wants to contribute one or more varieties of tabs.
+ * 3) Code that wants to monitor to know when the active tab changes.
+ *
+ * Consumer code should use the following methods:
+ * openTab(aTabModeName, aArgs)
+ * Open a tab of the given "mode", passing the provided arguments as an
+ * object. The tab type author should tell you the modes they implement
+ * and the required/optional arguments.
+ *
+ * Each tab type can define the set of arguments that it expects, but
+ * there are also a few common ones that all should obey, including:
+ *
+ * "background": if this is true, the tab will be loaded in the
+ * background.
+ * "disregardOpener": if this is true, then the tab opener will not
+ * be switched to automatically by tabmail if the new tab is immediately
+ * closed.
+ *
+ * closeTab(aOptionalTabIndexInfoOrTabNode, aNoUndo):
+ * If no argument is provided, the current tab is closed. The first
+ * argument specifies a specific tab to be closed. It can be a tab index,
+ * a tab info object, or a tab's DOM element. In case the second
+ * argument is true, the closed tab can't be restored by calling
+ * undoCloseTab().
+ * Please note, some tabs cannot be closed. Trying to close such tab,
+ * will fail silently.
+ * undoCloseTab():
+ * Restores the most recent tab closed by the user.
+ * switchToTab(aTabIndexInfoOrTabNode):
+ * Switch to the tab by providing a tab index, tab info object, or tab
+ * node (tabmail-tab bound element.) Instead of calling this method,
+ * you can also just poke at tabmail.tabContainer and its selectedIndex
+ * and selectedItem properties.
+ * replaceTabWithWindow(aTab):
+ * Detaches a tab from this tabbar to new window. The argument "aTab" is
+ * required and can be a tab index, a tab info object or a tabs's
+ * DOM element. Calling this method works only for tabs implementing
+ * session restore.
+ * moveTabTo(aTab, aIndex):
+ * moves the given tab to the given Index. The first argument can be
+ * a tab index, a tab info object or a tab's DOM element. The second
+ * argument specifies the tabs new absolute position within the tabbar.
+ *
+ * Less-friendly consumer methods:
+ * * persistTab(tab):
+ * serializes a tab into an object, by passing a tab info object as
+ * argument. It is used for session restore and moving tabs between
+ * windows. Returns null in case persist fails.
+ * * removeCurrentTab():
+ * Close the current tab.
+ * * removeTabByNode(aTabElement):
+ * Close the tab whose tabmail-tab bound element is passed in.
+ * Changing the currently displayed tab is accomplished by changing
+ * tabmail.tabContainer's selectedIndex or selectedItem property.
+ *
+ * Code that lives in a tab should use the following methods:
+ * * setTabTitle([aOptionalTabInfo]): Tells us that the title of the current
+ * tab (if no argument is provided) or provided tab needs to be updated.
+ * This will result in a call to the tab mode's logic to update the title.
+ * In the event this is not for the current tab, the caller is responsible
+ * for ensuring that the underlying tab mode is capable of providing a tab
+ * title when it is in the background. (The is currently not the case for
+ * "folder" and "mail" modes because of their implementation.)
+ * * setTabBusy(aTabNode, aBusyState): Tells us that the tab in question
+ * is now busy or not busy. "Busy" means that it is occupied and
+ * will not be able to respond to you until it is no longer busy.
+ * This impacts the cursor display, as well as potentially
+ * providing tab display hints.
+ * * setTabThinking(aTabNode, aThinkingState): Tells us that the
+ * tab in question is now thinking or not thinking. "Thinking" means
+ * that the tab is involved in some ongoing process but you can still
+ * interact with the tab while it is thinking. A search would be an
+ * example of thinking. This impacts spinny-thing feedback as well as
+ * potential providing tab display hints. aThinkingState may be a
+ * boolean or a localized string explaining what you are thinking about.
+ *
+ * Tab contributing code should define a tab type object and register it
+ * with us by calling registerTabType. You can remove a registered tab
+ * type (eg when unloading a restartless addon) by calling unregisterTabType.
+ * Each tab type can provide multiple tab modes. The rationale behind this
+ * organization is that Thunderbird historically/currently uses a single
+ * 3-pane view to display both three-pane folder browsing and single message
+ * browsing across multiple tabs. Each tab type has the ability to use a
+ * single tab panel for all of its display needs. So Thunderbird's "mail"
+ * tab type covers both the "folder" (3-pane folder-based browsing) and
+ * "message" (just a single message) tab modes. Likewise, calendar/lightning
+ * currently displays both its calendar and tasks in the same panel. A tab
+ * type can also create a new tabpanel for each tab as it is created. In
+ * that case, the tab type should probably only have a single mode unless
+ * there are a number of similar modes that can gain from code sharing.
+ *
+ * If you're adding a new tab type, please update TabmailTab.type in
+ * mail/components/extensions/parent/ext-mail.js.
+ *
+ * The tab type definition should include the following attributes:
+ * * name: The name of the tab-type, mainly to aid in debugging.
+ * * panelId or perTabPanel: If using a single tab panel, the id of the
+ * panel must be provided in panelId. If using one tab panel per tab,
+ * perTabPanel should be either the XUL element name that should be
+ * created for each tab, or a helper function to create and return the
+ * element.
+ * * modes: An object whose attributes are mode names (which are
+ * automatically propagated to a 'name' attribute for debugging) and
+ * values are objects with the following attributes...
+ * * any of the openTab/closeTab/saveTabState/showTab/onTitleChanged
+ * functions as described on the mode definitions. These will only be
+ * called if the mode does not provide the functions. Note that because
+ * the 'this' variable passed to the functions will always reference the
+ * tab type definition (rather than the mode definition), the mode
+ * functions can defer to the tab type functions by calling
+ * this.functionName(). (This should prove convenient.)
+ * Mode definition attributes:
+ * * type: The "type" attribute to set on the displayed tab for CSS purposes.
+ * Generally, this would be the same as the mode name, but you can do as
+ * you please.
+ * * isDefault: This should only be present and should be true for the tab
+ * mode that is the tab displayed automatically on startup.
+ * * maxTabs: The maximum number of this mode that can be opened at a time.
+ * If this limit is reached, any additional calls to openTab for this
+ * mode will simply result in the first existing tab of this mode being
+ * displayed.
+ * * shouldSwitchTo(aArgs): Optional function. Called when openTab is called
+ * on the top-level tabmail binding. It is used to decide if the openTab
+ * function should switch to an existing tab or actually open a new tab.
+ * If the openTab function should switch to an existing tab, return the
+ * index of that tab; otherwise return -1.
+ * aArgs is a set of named parameters (the ones that are later passed to
+ * openTab).
+ * * openTab(aTab, aArgs): Called when a tab of the given mode is in the
+ * process of being opened. aTab will have its "mode" attribute
+ * set to the mode definition of the tab mode being opened. You should
+ * set the "title" attribute on it, and may set any other attributes
+ * you wish for your own use in subsequent functions. Note that 'this'
+ * points to the tab type definition, not the mode definition as you
+ * might expect. This allows you to place common logic code on the
+ * tab type for use by multiple modes and to defer to it. Any arguments
+ * provided to the caller of tabmail.openTab will be passed to your
+ * function as well, including background.
+ * * closeTab(aTab): Called when aTab is being closed. The tab need not be
+ * currently displayed. You are responsible for properly cleaning up
+ * any state you preserved in aTab.
+ * * saveTabState(aTab): Called when aTab is being switched away from so that
+ * you can preserve its state on aTab. This is primarily for single
+ * tab panel implementations; you may not have much state to save if your
+ * tab has its own tab panel.
+ * * showTab(aTab): Called when aTab is being displayed and you should
+ * restore its state (if required).
+ * * persistTab(aTab): Called when we want to persist the tab because we are
+ * saving the session state. You should return an object suitable for
+ * JSON serialization. The object will be provided to your restoreTab
+ * method when we attempt to restore the session. If your code is
+ * unable or unwilling to persist the tab (some of the time), you should
+ * return null in that case. If your code never wants to persist the tab
+ * you should not implement this method. You must implement restoreTab
+ * if you implement this method.
+ * * restoreTab(aTabmail, aPersistedState): Called when we are restoring a
+ * tab session and a tab with your mode was previously persisted via a
+ * call to your persistTab implementation. You are provided with a
+ * reference to this tabmail instance and the (deserialized) state object
+ * you returned from your persistTab implementation. It is your
+ * function's job to determine if you can restore the tab, and if so,
+ * you should invoke aTabmail.openTab to actually cause your tab to be
+ * opened. This may seem odd, but it should help keep your code simple
+ * while letting you do whatever you want. Since openTab is synchronous
+ * and returns the tabInfo structure built for the tab, you can perform
+ * any additional work you need after the call to openTab.
+ * * onTitleChanged(aTab): Called when someone calls tabmail.setTabTitle() to
+ * hint that the tab's title needs to be updated. This function should
+ * update aTab.title if it can.
+ * Mode definition functions to do with menu/toolbar commands:
+ * * supportsCommand(aCommand, aTab): Called when a menu or toolbar needs to
+ * be updated. Return true if you support that command in
+ * isCommandEnabled and doCommand, return false otherwise.
+ * * isCommandEnabled(aCommand, aTab): Called when a menu or toolbar needs
+ * to be updated. Return true if the command can be executed at the
+ * current time, false otherwise.
+ * * doCommand(aCommand, aTab): Called when a menu or toolbar command is to
+ * be executed. Perform the action appropriate to the command.
+ * * onEvent(aEvent, aTab): This can be used to handle different events on
+ * the window.
+ * * getBrowser(aTab): This function should return the browser element for
+ * your tab if there is one (return null or don't define this function
+ * otherwise). It is used for some toolkit functions that require a
+ * global "getBrowser" function, e.g. ZoomManager.
+ *
+ * Tab monitoring code is expected to be used for widgets on the screen
+ * outside of the tab box that need to update themselves as the active tab
+ * changes.
+ * Tab monitoring code (un)registers itself via (un)registerTabMonitor.
+ * The following attributes should be provided on the monitor object:
+ * * monitorName: A string value naming the tab monitor/extension. This is
+ * the canonical name for the tab monitor for all persistence purposes.
+ * If the tab monitor wants to store data in the tab info object and its
+ * name is FOO it should store it in 'tabInfo._ext.FOO'. This is the
+ * only place the tab monitor should store information on the tab info
+ * object. The FOO attribute will not be automatically created; it is
+ * up to the code. The _ext attribute will be there, reliably, however.
+ * The name is also used when persisting state, but the tab monitor
+ * does not need to do anything in that case; the name is automatically
+ * used in the course of wrapping the object.
+ * The following functions should be provided on the monitor object:
+ * * onTabTitleChanged(aTab): Called when the tab's title changes.
+ * * onTabSwitched(aTab, aOldTab): Called when a new tab is made active.
+ * Also called when the monitor is registered if one or more tabs exist.
+ * If this is the first call, aOldTab will be null, otherwise aOldTab
+ * will be the previously active tab.
+ * * onTabOpened(aTab, aIsFirstTab, aWasCurrentTab): Called when a new tab is
+ * opened. This method is invoked after the tab mode's openTab method
+ * is invoked. This method is invoked before the tab monitor
+ * onTabSwitched method in the case where it will be invoked. (It is
+ * not invoked if the tab is opened in the background.)
+ * * onTabClosing(aTab): Called when a tab is being closed. This method is
+ * is invoked before the call to the tab mode's closeTab function.
+ * * onTabPersist(aTab): Return a JSON-representable object to persist for
+ * the tab. Return null if you do not have anything to persist.
+ * * onTabRestored(aTab, aState, aIsFirstTab): Called when a tab is being
+ * restored and there is data previously persisted by the tab monitor.
+ * This method is called instead of invoking onTabOpened. This is done
+ * because the restoreTab method (potentially) uses the tabmail openTab
+ * API to effect restoration. (Note: the first opened tab is special;
+ * it will produce an onTabOpened notification potentially followed by
+ * an onTabRestored notification.)
+ * Tab monitor code is also allowed to hook into the command processing
+ * logic. We support the standard supportsCommand/isCommandEnabled/
+ * doCommand functions but with a twist to indicate when other tab monitors
+ * and the actual tab itself should get a chance to process: supportsCommand
+ * and isCommandEnabled should return null when they are not handling the
+ * case. doCommand should return true if it handled the case, null
+ * otherwise.
+ */
+
+ /**
+ * The MozTabmail widget handles the Tab UI mechanism.
+ *
+ * @augments {MozXULElement}
+ */
+ class MozTabmail extends MozXULElement {
+ /**
+ * Flag indicating that the UI is currently covered by an overlay.
+ *
+ * @type {boolean}
+ */
+ globalOverlay = false;
+
+ connectedCallback() {
+ if (this.delayConnectedCallback()) {
+ return;
+ }
+
+ this.tabbox = this.getElementsByTagName("tabbox").item(0);
+ this.currentTabInfo = null;
+
+ /**
+ * Temporary field that only has a non-null value during a call to
+ * openTab, and whose value is the currentTabInfo of the tab that was
+ * open when we received the call to openTab.
+ */
+ this._mostRecentTabInfo = null;
+ /**
+ * Tab id, incremented on each openTab() and set on the browser.
+ */
+ this.tabId = 0;
+ this.tabTypes = {};
+ this.tabModes = {};
+ this.defaultTabMode = null;
+ this.tabInfo = [];
+ this.tabContainer = document.getElementById(
+ this.getAttribute("tabcontainer")
+ );
+ this.panelContainer = document.getElementById(
+ this.getAttribute("panelcontainer")
+ );
+ this.tabMonitors = [];
+ this.recentlyClosedTabs = [];
+ this.mLastTabOpener = null;
+ this.unrestoredTabs = [];
+
+ // @implements {nsIController}
+ this.tabController = {
+ supportsCommand: aCommand => {
+ let tab = this.currentTabInfo;
+ // This can happen if we're starting up and haven't got a tab
+ // loaded yet.
+ if (!tab) {
+ return false;
+ }
+
+ for (let tabMonitor of this.tabMonitors) {
+ try {
+ if ("supportsCommand" in tabMonitor) {
+ let result = tabMonitor.supportsCommand(aCommand, tab);
+ if (result !== null) {
+ return result;
+ }
+ }
+ } catch (ex) {
+ console.error(ex);
+ }
+ }
+
+ let supportsCommandFunc =
+ tab.mode.supportsCommand || tab.mode.tabType.supportsCommand;
+ if (supportsCommandFunc) {
+ return supportsCommandFunc.call(tab.mode.tabType, aCommand, tab);
+ }
+
+ return false;
+ },
+
+ isCommandEnabled: aCommand => {
+ let tab = this.currentTabInfo;
+ // This can happen if we're starting up and haven't got a tab
+ // loaded yet.
+ if (!tab || this.globalOverlay) {
+ return false;
+ }
+
+ for (let tabMonitor of this.tabMonitors) {
+ try {
+ if ("isCommandEnabled" in tabMonitor) {
+ let result = tabMonitor.isCommandEnabled(aCommand, tab);
+ if (result !== null) {
+ return result;
+ }
+ }
+ } catch (ex) {
+ console.error(ex);
+ }
+ }
+
+ let isCommandEnabledFunc =
+ tab.mode.isCommandEnabled || tab.mode.tabType.isCommandEnabled;
+ if (isCommandEnabledFunc) {
+ return isCommandEnabledFunc.call(tab.mode.tabType, aCommand, tab);
+ }
+
+ return false;
+ },
+
+ doCommand: (aCommand, ...args) => {
+ let tab = this.currentTabInfo;
+ // This can happen if we're starting up and haven't got a tab
+ // loaded yet.
+ if (!tab) {
+ return;
+ }
+
+ for (let tabMonitor of this.tabMonitors) {
+ try {
+ if ("doCommand" in tabMonitor) {
+ let result = tabMonitor.doCommand(aCommand, tab);
+ if (result === true) {
+ return;
+ }
+ }
+ } catch (ex) {
+ console.error(ex);
+ }
+ }
+
+ let doCommandFunc = tab.mode.doCommand || tab.mode.tabType.doCommand;
+ if (doCommandFunc) {
+ doCommandFunc.call(tab.mode.tabType, aCommand, tab, ...args);
+ }
+ },
+
+ onEvent: aEvent => {
+ let tab = this.currentTabInfo;
+ // This can happen if we're starting up and haven't got a tab
+ // loaded yet.
+ if (!tab) {
+ return null;
+ }
+
+ let onEventFunc = tab.mode.onEvent || tab.mode.tabType.onEvent;
+ if (onEventFunc) {
+ return onEventFunc.call(tab.mode.tabType, aEvent, tab);
+ }
+
+ return false;
+ },
+
+ QueryInterface: ChromeUtils.generateQI(["nsIController"]),
+ };
+
+ // This is the second-highest priority controller. It's preceded by
+ // DefaultController and followed by calendarController, then whatever
+ // Gecko adds.
+ window.controllers.insertControllerAt(1, this.tabController);
+ this._restoringTabState = null;
+ }
+
+ set selectedTab(val) {
+ this.switchToTab(val);
+ }
+
+ get selectedTab() {
+ if (!this.currentTabInfo) {
+ this.currentTabInfo = this.tabInfo[0];
+ }
+
+ return this.currentTabInfo;
+ }
+
+ get tabs() {
+ return this.tabContainer.allTabs;
+ }
+
+ get selectedBrowser() {
+ return this.getBrowserForSelectedTab();
+ }
+
+ registerTabType(aTabType) {
+ if (aTabType.name in this.tabTypes) {
+ return;
+ }
+
+ this.tabTypes[aTabType.name] = aTabType;
+ for (let [modeName, modeDetails] of Object.entries(aTabType.modes)) {
+ modeDetails.name = modeName;
+ modeDetails.tabType = aTabType;
+ modeDetails.tabs = [];
+ this.tabModes[modeName] = modeDetails;
+ if (modeDetails.isDefault) {
+ this.defaultTabMode = modeDetails;
+ }
+ }
+
+ if (aTabType.panelId) {
+ aTabType.panel = document.getElementById(aTabType.panelId);
+ } else if (!aTabType.perTabPanel) {
+ throw new Error(
+ "Trying to register a tab type with neither panelId " +
+ "nor perTabPanel attributes."
+ );
+ }
+
+ setTimeout(() => {
+ for (let modeName of Object.keys(aTabType.modes)) {
+ let i = 0;
+ while (i < this.unrestoredTabs.length) {
+ let state = this.unrestoredTabs[i];
+ if (state.mode == modeName) {
+ this.restoreTab(state);
+ this.unrestoredTabs.splice(i, 1);
+ } else {
+ i++;
+ }
+ }
+ }
+ }, 0);
+ }
+
+ unregisterTabType(aTabType) {
+ // we can skip if the tab type was never registered...
+ if (!(aTabType.name in this.tabTypes)) {
+ return;
+ }
+
+ // ... if the tab type is still in use, we can not remove it without
+ // breaking the UI. So we throw an exception.
+ for (let modeName of Object.keys(aTabType.modes)) {
+ if (this.tabModes[modeName].tabs.length) {
+ throw new Error("Tab mode " + modeName + " still in use. Close tabs");
+ }
+ }
+ // ... finally get rid of the tab type
+ for (let modeName of Object.keys(aTabType.modes)) {
+ delete this.tabModes[modeName];
+ }
+
+ delete this.tabTypes[aTabType.name];
+ }
+
+ registerTabMonitor(aTabMonitor) {
+ if (!this.tabMonitors.includes(aTabMonitor)) {
+ this.tabMonitors.push(aTabMonitor);
+ if (this.tabInfo.length) {
+ aTabMonitor.onTabSwitched(this.currentTabInfo, null);
+ }
+ }
+ }
+
+ unregisterTabMonitor(aTabMonitor) {
+ if (this.tabMonitors.includes(aTabMonitor)) {
+ this.tabMonitors.splice(this.tabMonitors.indexOf(aTabMonitor), 1);
+ }
+ }
+
+ /**
+ * Given an index, tab node or tab info object, return a tuple of
+ * [iTab, tab info dictionary, tab DOM node]. If
+ * aTabIndexNodeOrInfo is not specified and aDefaultToCurrent is
+ * true, the current tab will be returned. Otherwise, an
+ * exception will be thrown.
+ */
+ _getTabContextForTabbyThing(aTabIndexNodeOrInfo, aDefaultToCurrent) {
+ let iTab;
+ let tab;
+ let tabNode;
+ if (aTabIndexNodeOrInfo == null) {
+ if (!aDefaultToCurrent) {
+ throw new Error("You need to specify a tab!");
+ }
+ iTab = this.tabContainer.selectedIndex;
+ return [iTab, this.tabInfo[iTab], this.tabContainer.allTabs[iTab]];
+ }
+ if (typeof aTabIndexNodeOrInfo == "number") {
+ iTab = aTabIndexNodeOrInfo;
+ tabNode = this.tabContainer.allTabs[iTab];
+ tab = this.tabInfo[iTab];
+ } else if (
+ aTabIndexNodeOrInfo.tagName &&
+ aTabIndexNodeOrInfo.tagName == "tab"
+ ) {
+ tabNode = aTabIndexNodeOrInfo;
+ iTab = this.tabContainer.getIndexOfItem(tabNode);
+ tab = this.tabInfo[iTab];
+ } else {
+ tab = aTabIndexNodeOrInfo;
+ iTab = this.tabInfo.indexOf(tab);
+ tabNode = iTab >= 0 ? this.tabContainer.allTabs[iTab] : null;
+ }
+ return [iTab, tab, tabNode];
+ }
+
+ openFirstTab() {
+ // From the moment of creation, our customElement already has a visible
+ // tab. We need to create a tab information structure for this tab.
+ // In the process we also generate a synthetic tab title changed
+ // event to ensure we have an accurate title. We assume the tab
+ // contents will set themselves up correctly.
+ if (this.tabInfo.length == 0) {
+ let tab = this.openTab("mail3PaneTab", { first: true });
+ this.tabs[0].linkedPanel = tab.panel.id;
+ }
+ }
+
+ // eslint-disable-next-line complexity
+ openTab(aTabModeName, aArgs = {}) {
+ try {
+ if (!(aTabModeName in this.tabModes)) {
+ throw new Error("No such tab mode: " + aTabModeName);
+ }
+
+ let tabMode = this.tabModes[aTabModeName];
+ // if we are already at our limit for this mode, show an existing one
+ if (tabMode.tabs.length == tabMode.maxTabs) {
+ let desiredTab = tabMode.tabs[0];
+ this.tabContainer.selectedIndex = this.tabInfo.indexOf(desiredTab);
+ return null;
+ }
+
+ // Do this so that we don't generate strict warnings
+ let background = aArgs.background;
+ // If the mode wants us to, we should switch to an existing tab
+ // rather than open a new one. We shouldn't switch to the tab if
+ // we're opening it in the background, though.
+ let shouldSwitchToFunc =
+ tabMode.shouldSwitchTo || tabMode.tabType.shouldSwitchTo;
+ if (shouldSwitchToFunc) {
+ let tabIndex = shouldSwitchToFunc.apply(tabMode.tabType, [aArgs]);
+ if (tabIndex >= 0) {
+ if (!background) {
+ this.selectTabByIndex(null, tabIndex);
+ }
+ return this.tabInfo[tabIndex];
+ }
+ }
+
+ if (!aArgs.first && !background) {
+ // we need to save the state before it gets corrupted
+ this.saveCurrentTabState();
+ }
+
+ let tab = {
+ first: !!aArgs.first,
+ mode: tabMode,
+ busy: false,
+ canClose: true,
+ thinking: false,
+ beforeTabOpen: true,
+ favIconUrl: null,
+ _ext: {},
+ };
+
+ tab.tabId = this.tabId++;
+ tabMode.tabs.push(tab);
+
+ let t;
+ if (aArgs.first) {
+ t = this.tabContainer.querySelector(`tab[is="tabmail-tab"]`);
+ } else {
+ t = document.createXULElement("tab", { is: "tabmail-tab" });
+ t.className = "tabmail-tab";
+ t.setAttribute("validate", "never");
+ this.tabContainer.appendChild(t);
+ }
+ tab.tabNode = t;
+
+ if (
+ this.tabContainer.mCollapseToolbar.collapsed &&
+ (!this.tabContainer.mAutoHide || this.tabContainer.allTabs.length > 1)
+ ) {
+ this.tabContainer.mCollapseToolbar.collapsed = false;
+ this.tabContainer._updateCloseButtons();
+ document.documentElement.removeAttribute("tabbarhidden");
+ }
+
+ let oldTab = (this._mostRecentTabInfo = this.currentTabInfo);
+ // If we're not disregarding the opening, hold a reference to opener
+ // so that if the new tab is closed without switching, we can switch
+ // back to the opener tab.
+ if (aArgs.disregardOpener) {
+ this.mLastTabOpener = null;
+ } else {
+ this.mLastTabOpener = oldTab;
+ }
+
+ // the order of the following statements is important
+ this.tabInfo[this.tabContainer.allTabs.length - 1] = tab;
+ if (!background) {
+ this.currentTabInfo = tab;
+ // this has a side effect of calling updateCurrentTab, but our
+ // setting currentTabInfo above will cause it to take no action.
+ this.tabContainer.selectedIndex =
+ this.tabContainer.allTabs.length - 1;
+ }
+
+ // make sure we are on the right panel
+ if (tab.mode.tabType.perTabPanel) {
+ // should we create the element for them, or will they do it?
+ if (typeof tab.mode.tabType.perTabPanel == "string") {
+ tab.panel = document.createXULElement(tab.mode.tabType.perTabPanel);
+ } else {
+ tab.panel = tab.mode.tabType.perTabPanel(tab);
+ }
+
+ this.panelContainer.appendChild(tab.panel);
+
+ if (!background) {
+ this.panelContainer.selectedPanel = tab.panel;
+ }
+ } else {
+ if (!background) {
+ this.panelContainer.selectedPanel = tab.mode.tabType.panel;
+ }
+ t.linkedPanel = tab.mode.tabType.panelId;
+ }
+
+ // Make sure the new panel is marked selected.
+ let oldPanel = [...this.panelContainer.children].find(p =>
+ p.hasAttribute("selected")
+ );
+ // Blur the currently focused element only if we're actually switching
+ // to the newly opened tab.
+ if (oldPanel && !background) {
+ this.rememberLastActiveElement(oldTab);
+ oldPanel.removeAttribute("selected");
+ if (oldTab.chromeBrowser) {
+ oldTab.chromeBrowser.docShellIsActive = false;
+ }
+ }
+
+ this.panelContainer.selectedPanel.setAttribute("selected", "true");
+ let tabOpenFunc = tab.mode.openTab || tab.mode.tabType.openTab;
+ tabOpenFunc.apply(tab.mode.tabType, [tab, aArgs]);
+ if (tab.chromeBrowser) {
+ tab.chromeBrowser.docShellIsActive = !background;
+ }
+
+ if (!t.linkedPanel) {
+ if (!tab.panel.id) {
+ // No id set. Create our own.
+ tab.panel.id = "unnamedTab" + Math.random().toString().substring(2);
+ console.warn(`Tab mode ${aTabModeName} should set an id
+ on the first argument of openTab.`);
+ }
+ t.linkedPanel = tab.panel.id;
+ }
+
+ // Set the tabId after defining a <browser> and before notifications.
+ let browser = this.getBrowserForTab(tab);
+ if (browser && !tab.browser) {
+ tab.browser = browser;
+ if (!tab.linkedBrowser) {
+ tab.linkedBrowser = browser;
+ }
+ }
+
+ let restoreState = this._restoringTabState;
+ for (let tabMonitor of this.tabMonitors) {
+ try {
+ if (
+ "onTabRestored" in tabMonitor &&
+ restoreState &&
+ tabMonitor.monitorName in restoreState.ext
+ ) {
+ tabMonitor.onTabRestored(
+ tab,
+ restoreState.ext[tabMonitor.monitorName],
+ false
+ );
+ } else if ("onTabOpened" in tabMonitor) {
+ tabMonitor.onTabOpened(tab, false, oldTab);
+ }
+ if (!background) {
+ tabMonitor.onTabSwitched(tab, oldTab);
+ }
+ } catch (ex) {
+ console.error(ex);
+ }
+ }
+
+ // clear _mostRecentTabInfo; we only needed it during the call to
+ // openTab.
+ this._mostRecentTabInfo = null;
+ t.setAttribute("label", tab.title);
+ // For styling purposes, apply the type to the tab.
+ t.setAttribute("type", tab.mode.type);
+
+ if (!background) {
+ this.setDocumentTitle(tab);
+ // Move the focus on the newly selected tab.
+ this.panelContainer.selectedPanel.focus();
+ }
+
+ let moving = restoreState ? restoreState.moving : null;
+ // Dispatch tab opening event
+ let evt = new CustomEvent("TabOpen", {
+ bubbles: true,
+ detail: { tabInfo: tab, moving },
+ });
+ t.dispatchEvent(evt);
+ delete tab.beforeTabOpen;
+
+ contentProgress.addProgressListenerToBrowser(browser);
+
+ return tab;
+ } catch (e) {
+ console.error(e);
+ return null;
+ }
+ }
+
+ selectTabByMode(aTabModeName) {
+ let tabMode = this.tabModes[aTabModeName];
+ if (tabMode.tabs.length) {
+ let desiredTab = tabMode.tabs[0];
+ this.tabContainer.selectedIndex = this.tabInfo.indexOf(desiredTab);
+ }
+ }
+
+ selectTabByIndex(aEvent, aIndex) {
+ // count backwards for aIndex < 0
+ if (aIndex < 0) {
+ aIndex += this.tabInfo.length;
+ }
+
+ if (
+ aIndex >= 0 &&
+ aIndex < this.tabInfo.length &&
+ aIndex != this.tabContainer.selectedIndex
+ ) {
+ this.tabContainer.selectedIndex = aIndex;
+ }
+
+ if (aEvent) {
+ aEvent.preventDefault();
+ aEvent.stopPropagation();
+ }
+ }
+
+ /**
+ * If the current/most recent tab is of mode aTabModeName, return its
+ * tab info, otherwise return the tab info for the first tab of the
+ * given mode.
+ * You would want to use this method when you would like to mimic the
+ * settings of an existing instance of your mode. In such a case,
+ * it is reasonable to assume that if the 'current' tab was of the
+ * same mode that its settings should be used. Otherwise, we must
+ * fall back to another tab. We currently choose the first tab of
+ * the instance, because for the "folder" tab, it is the canonical tab.
+ * In other cases, having an MRU order and choosing the MRU tab might
+ * be more appropriate.
+ *
+ * @returns the tab info object for the tab meeting the above criteria,
+ * or null if no such tab exists.
+ */
+ getTabInfoForCurrentOrFirstModeInstance(aTabMode) {
+ // If we're in the middle of opening a new tab
+ // (this._mostRecentTabInfo is non-null), we shouldn't consider the
+ // current tab
+ let tabToConsider = this._mostRecentTabInfo || this.currentTabInfo;
+ if (tabToConsider && tabToConsider.mode == aTabMode) {
+ return tabToConsider;
+ } else if (aTabMode.tabs.length) {
+ return aTabMode.tabs[0];
+ }
+
+ return null;
+ }
+
+ undoCloseTab(aIdx) {
+ if (!this.recentlyClosedTabs.length) {
+ return;
+ }
+ if (aIdx >= this.recentlyClosedTabs.length) {
+ aIdx = this.recentlyClosedTabs.length - 1;
+ }
+ // splice always returns an array
+ let history = this.recentlyClosedTabs.splice(aIdx, 1)[0];
+ if (!history.tab) {
+ return;
+ }
+
+ if (!this.restoreTab(JSON.parse(history.tab))) {
+ return;
+ }
+
+ let idx = Math.min(history.idx, this.tabInfo.length);
+ let tab = this.tabContainer.allTabs[this.tabInfo.length - 1];
+ this.moveTabTo(tab, idx);
+ this.switchToTab(tab);
+ }
+
+ closeTab(aOptTabIndexNodeOrInfo, aNoUndo) {
+ let [iTab, tab, tabNode] = this._getTabContextForTabbyThing(
+ aOptTabIndexNodeOrInfo,
+ true
+ );
+ if (!tab.canClose) {
+ return;
+ }
+
+ // Give the tab type a chance to make its own decisions about
+ // whether its tabs can be closed or not. For instance, contentTabs
+ // and chromeTabs run onbeforeunload event handlers that may
+ // exercise their right to prompt the user for confirmation before
+ // closing.
+ let tryCloseFunc = tab.mode.tryCloseTab || tab.mode.tabType.tryCloseTab;
+ if (tryCloseFunc && !tryCloseFunc.call(tab.mode.tabType, tab)) {
+ return;
+ }
+
+ let evt = new CustomEvent("TabClose", {
+ bubbles: true,
+ detail: { tabInfo: tab, moving: tab.moving },
+ });
+
+ tabNode.dispatchEvent(evt);
+ for (let tabMonitor of this.tabMonitors) {
+ try {
+ if ("onTabClosing" in tabMonitor) {
+ tabMonitor.onTabClosing(tab);
+ }
+ } catch (ex) {
+ console.error(ex);
+ }
+ }
+
+ if (!aNoUndo) {
+ // Allow user to undo accidentally closed tabs
+ let session = this.persistTab(tab);
+ if (session) {
+ this.recentlyClosedTabs.unshift({
+ tab: JSON.stringify(session),
+ idx: iTab,
+ title: tab.title,
+ });
+ if (this.recentlyClosedTabs.length > 10) {
+ this.recentlyClosedTabs.pop();
+ }
+ }
+ }
+
+ tab.closed = true;
+ let closeFunc = tab.mode.closeTab || tab.mode.tabType.closeTab;
+ closeFunc.call(tab.mode.tabType, tab);
+ this.tabInfo.splice(iTab, 1);
+ tab.mode.tabs.splice(tab.mode.tabs.indexOf(tab), 1);
+ tabNode.remove();
+
+ if (this.tabContainer.selectedIndex == -1) {
+ if (this.mLastTabOpener && this.tabInfo.includes(this.mLastTabOpener)) {
+ this.tabContainer.selectedIndex = this.tabInfo.indexOf(
+ this.mLastTabOpener
+ );
+ } else {
+ this.tabContainer.selectedIndex =
+ iTab == this.tabContainer.allTabs.length ? iTab - 1 : iTab;
+ }
+ }
+
+ // Clear the last tab opener - we don't need this anymore.
+ this.mLastTabOpener = null;
+ if (this.currentTabInfo == tab) {
+ this.updateCurrentTab();
+ }
+
+ if (tab.panel) {
+ tab.panel.remove();
+ delete tab.panel;
+ // Ensure current tab is still selected and displayed in the
+ // panelContainer.
+ this.panelContainer.selectedPanel =
+ this.currentTabInfo.panel || this.currentTabInfo.mode.tabType.panel;
+ }
+
+ if (
+ this.tabContainer.allTabs.length == 1 &&
+ this.tabContainer.mAutoHide
+ ) {
+ this.tabContainer.mCollapseToolbar.collapsed = true;
+ document.documentElement.setAttribute("tabbarhidden", "true");
+ }
+ }
+
+ removeTabByNode(aTabNode) {
+ this.closeTab(aTabNode);
+ }
+
+ /**
+ * Given a tabNode (or tabby thing), close all of the other tabs
+ * that are closeable.
+ */
+ closeOtherTabs(aTabNode, aNoUndo) {
+ let [, thisTab] = this._getTabContextForTabbyThing(aTabNode, false);
+ // closeTab mutates the tabInfo array, so start from the end.
+ for (let i = this.tabInfo.length - 1; i >= 0; i--) {
+ let tab = this.tabInfo[i];
+ if (tab != thisTab && tab.canClose) {
+ this.closeTab(tab, aNoUndo);
+ }
+ }
+ }
+
+ replaceTabWithWindow(aTab, aTargetWindow, aTargetPosition) {
+ if (this.tabInfo.length <= 1) {
+ return null;
+ }
+
+ let tab = this._getTabContextForTabbyThing(aTab, false)[1];
+ if (!tab.canClose) {
+ return null;
+ }
+
+ // We use JSON and session restore transfer the tab to the new window.
+ tab = this.persistTab(tab);
+ if (!tab) {
+ return null;
+ }
+
+ // Converting to JSON and back again creates clean javascript
+ // object with absolutely no references to our current window.
+ tab = JSON.parse(JSON.stringify(tab));
+ // Set up an identifier for the move, consumers may want to correlate TabClose and
+ // TabOpen events.
+ let moveSession = Services.uuid.generateUUID().toString();
+ tab.moving = moveSession;
+ aTab.moving = moveSession;
+ this.closeTab(aTab, true);
+
+ if (aTargetWindow && aTargetWindow !== "popup") {
+ let targetTabmail = aTargetWindow.document.getElementById("tabmail");
+ targetTabmail.restoreTab(tab);
+ if (aTargetPosition) {
+ let droppedTab =
+ targetTabmail.tabInfo[targetTabmail.tabInfo.length - 1];
+ targetTabmail.moveTabTo(droppedTab, aTargetPosition);
+ }
+ return aTargetWindow;
+ }
+
+ let features = ["chrome"];
+ if (aTargetWindow === "popup") {
+ features.push(
+ "dialog",
+ "resizable",
+ "minimizable",
+ "centerscreen",
+ "titlebar",
+ "close"
+ );
+ } else {
+ features.push("dialog=no", "all", "status", "toolbar");
+ }
+
+ return window
+ .openDialog(
+ "chrome://messenger/content/messenger.xhtml",
+ "_blank",
+ features.join(","),
+ null,
+ {
+ action: "restore",
+ tabs: [tab],
+ }
+ )
+ .focus();
+ }
+
+ moveTabTo(aTabIndexNodeOrInfo, aIndex) {
+ let [oldIdx, tab, tabNode] = this._getTabContextForTabbyThing(
+ aTabIndexNodeOrInfo,
+ false
+ );
+ if (
+ !tab ||
+ !tabNode ||
+ tabNode.tagName != "tab" ||
+ oldIdx < 0 ||
+ oldIdx == aIndex
+ ) {
+ return -1;
+ }
+
+ // remove the entries from tabInfo, tabMode and the tabContainer
+ this.tabInfo.splice(oldIdx, 1);
+ tab.mode.tabs.splice(tab.mode.tabs.indexOf(tab), 1);
+ tabNode.remove();
+ // as we removed items, we might need to update indices
+ if (oldIdx < aIndex) {
+ aIndex--;
+ }
+
+ // Read it into tabInfo and the tabContainer
+ this.tabInfo.splice(aIndex, 0, tab);
+ this.tabContainer.insertBefore(
+ tabNode,
+ this.tabContainer.allTabs[aIndex]
+ );
+ // Now it's getting a bit ugly, as tabModes stores redundant
+ // information we need to get it in sync with tabInfo.
+ //
+ // As tabModes.tabs is a subset of tabInfo, every tab can be mapped
+ // to a tabInfo index. So we check for each tab in tabModes if it is
+ // directly in front of our moved tab. We do this by looking up the
+ // index in tabInfo and compare it with the moved tab's index. If we
+ // found our tab, we insert the moved tab directly behind into tabModes
+ // In case find no tab we simply append it
+ let modeIdx = tab.mode.tabs.length + 1;
+ for (let i = 0; i < tab.mode.tabs.length; i++) {
+ if (this.tabInfo.indexOf(tab.mode.tabs[i]) < aIndex) {
+ continue;
+ }
+ modeIdx = i;
+ break;
+ }
+
+ tab.mode.tabs.splice(modeIdx, 0, tab);
+ let evt = new CustomEvent("TabMove", {
+ bubbles: true,
+ view: window,
+ detail: { idx: oldIdx, tabInfo: tab },
+ });
+ tabNode.dispatchEvent(evt);
+
+ return aIndex;
+ }
+
+ // Returns null in case persist fails.
+ persistTab(tab) {
+ let persistFunc = tab.mode.persistTab || tab.mode.tabType.persistTab;
+ // if we can't restore the tab we can't move it
+ if (!persistFunc) {
+ return null;
+ }
+
+ // If there is a non-null tab-state, then persisting succeeded and
+ // we should store it. We store the tab's persisted state in its
+ // own distinct object rather than mixing things up in a dictionary
+ // to avoid bugs and because we may eventually let extensions store
+ // per-tab information in the persisted state.
+ let tabState;
+ // Wrap this in an exception handler so that if the persistence
+ // logic fails, things like tab closure still run to completion.
+ try {
+ tabState = persistFunc.call(tab.mode.tabType, tab);
+ } catch (ex) {
+ // Report this so that our unit testing framework sees this
+ // error and (extension) developers likewise can see when their
+ // extensions are ill-behaved.
+ console.error(ex);
+ }
+
+ if (!tabState) {
+ return null;
+ }
+
+ let ext = {};
+ for (let tabMonitor of this.tabMonitors) {
+ try {
+ if ("onTabPersist" in tabMonitor) {
+ let monState = tabMonitor.onTabPersist(tab);
+ if (monState !== null) {
+ ext[tabMonitor.monitorName] = monState;
+ }
+ }
+ } catch (ex) {
+ console.error(ex);
+ }
+ }
+
+ return { mode: tab.mode.name, state: tabState, ext };
+ }
+
+ /**
+ * Persist the state of all tab modes implementing persistTab methods
+ * to a JSON-serializable object representation and return it. Call
+ * restoreTabs with the result to restore the tab state.
+ * Calling this method should have no side effects; tabs will not be
+ * closed, displays will not change, etc. This means the method is
+ * safe to use in an auto-save style so that if we crash we can
+ * restore the (approximate) state at the time of the crash.
+ *
+ * @returns {object} The persisted tab states.
+ */
+ persistTabs() {
+ let state = {
+ // Explicitly specify a revision so we don't wish we had later.
+ rev: 0,
+ // If our currently selected tab gets persisted, we will update this
+ selectedIndex: null,
+ };
+
+ let tabs = (state.tabs = []);
+ for (let [iTab, tab] of this.tabInfo.entries()) {
+ let persistTab = this.persistTab(tab);
+ if (!persistTab) {
+ continue;
+ }
+ tabs.push(persistTab);
+ // Mark this persisted tab as selected
+ if (iTab == this.tabContainer.selectedIndex) {
+ state.selectedIndex = tabs.length - 1;
+ }
+ }
+
+ return state;
+ }
+
+ restoreTab(aState) {
+ // Migrate old mail tabs to new mail tabs. This can be removed after ESR 115.
+ if (aState.mode == "folder") {
+ aState.mode = "mail3PaneTab";
+ } else if (aState.mode == "message") {
+ aState.mode = "mailMessageTab";
+ }
+
+ // if we no longer know about the mode, we can't restore the tab
+ let mode = this.tabModes[aState.mode];
+ if (!mode) {
+ this.unrestoredTabs.push(aState);
+ return false;
+ }
+
+ let restoreFunc = mode.restoreTab || mode.tabType.restoreTab;
+ if (!restoreFunc) {
+ return false;
+ }
+
+ // normalize the state to have an ext attribute if it does not.
+ if (!("ext" in aState)) {
+ aState.ext = {};
+ }
+
+ this._restoringTabState = aState;
+ restoreFunc.call(mode.tabType, this, aState.state);
+ this._restoringTabState = null;
+
+ return true;
+ }
+
+ /**
+ * Attempts to restore tabs persisted from a prior call to
+ * |persistTabs|. This is currently a synchronous operation, but in
+ * the future this may kick off an asynchronous mechanism to restore
+ * the tabs one-by-one.
+ */
+ restoreTabs(aPersistedState, aDontRestoreFirstTab) {
+ let tabs = aPersistedState.tabs;
+ let indexToSelect = null;
+
+ for (let [iTab, tabState] of tabs.entries()) {
+ if (tabState.state.firstTab && aDontRestoreFirstTab) {
+ tabState.state.dontRestoreFirstTab = aDontRestoreFirstTab;
+ }
+
+ if (!this.restoreTab(tabState)) {
+ continue;
+ }
+
+ // If this persisted tab was the selected one, then mark the newest
+ // tab as the guy to select.
+ if (iTab == aPersistedState.selectedIndex) {
+ indexToSelect = this.tabInfo.length - 1;
+ }
+ }
+
+ if (indexToSelect != null && !aDontRestoreFirstTab) {
+ this.tabContainer.selectedIndex = indexToSelect;
+ } else {
+ this.tabContainer.selectedIndex = 0;
+ }
+
+ if (
+ this.tabContainer.allTabs.length == 1 &&
+ this.tabContainer.mAutoHide
+ ) {
+ this.tabContainer.mCollapseToolbar.collapsed = true;
+ document.documentElement.setAttribute("tabbarhidden", "true");
+ }
+ }
+
+ clearRecentlyClosedTabs() {
+ this.recentlyClosedTabs.length = 0;
+ }
+ /**
+ * Called when the window is being unloaded, this calls the close
+ * function for every tab.
+ */
+ _teardown() {
+ for (var i = 0; i < this.tabInfo.length; i++) {
+ let tab = this.tabInfo[i];
+ let tabCloseFunc = tab.mode.closeTab || tab.mode.tabType.closeTab;
+ tabCloseFunc.call(tab.mode.tabType, tab);
+ }
+ }
+
+ /**
+ * The content window of the current tab, if it is a 3-pane tab.
+ *
+ * @type {?Window}
+ */
+ get currentAbout3Pane() {
+ if (this.currentTabInfo.mode.name == "mail3PaneTab") {
+ return this.currentTabInfo.chromeBrowser.contentWindow;
+ }
+ return null;
+ }
+
+ /**
+ * The content window of the current tab, if it is a message tab, OR if it
+ * is a 3-pane tab, the content window of the message browser within.
+ *
+ * @type {?Window}
+ */
+ get currentAboutMessage() {
+ switch (this.currentTabInfo.mode.name) {
+ case "mail3PaneTab": {
+ let messageBrowser = this.currentAbout3Pane.messageBrowser;
+ return messageBrowser && !messageBrowser.hidden
+ ? messageBrowser.contentWindow
+ : null;
+ }
+ case "mailMessageTab":
+ return this.currentTabInfo.chromeBrowser.contentWindow;
+ default:
+ return null;
+ }
+ }
+
+ /**
+ * getBrowserForSelectedTab is required as some toolkit functions
+ * require a getBrowser() function.
+ */
+ getBrowserForSelectedTab() {
+ if (!this.tabInfo) {
+ return null;
+ }
+
+ if (!this.currentTabInfo) {
+ this.currentTabInfo = this.tabInfo[0];
+ }
+
+ if (this.currentTabInfo) {
+ return this.getBrowserForTab(this.currentTabInfo);
+ }
+
+ return null;
+ }
+
+ getBrowserForTab(aTab) {
+ let browserFunc = aTab
+ ? aTab.mode.getBrowser || aTab.mode.tabType.getBrowser
+ : null;
+ return browserFunc ? browserFunc.call(aTab.mode.tabType, aTab) : null;
+ }
+
+ /**
+ * getBrowserForDocument is used to find the browser for a specific
+ * document that's been loaded
+ */
+ getBrowserForDocument(aDocument) {
+ for (let i = 0; i < this.tabInfo.length; ++i) {
+ let browserFunc =
+ this.tabInfo[i].mode.getBrowser ||
+ this.tabInfo[i].mode.tabType.getBrowser;
+
+ if (browserFunc) {
+ let possBrowser = browserFunc.call(
+ this.tabInfo[i].mode.tabType,
+ this.tabInfo[i]
+ );
+ if (possBrowser && possBrowser.contentWindow == aDocument) {
+ return this.tabInfo[i];
+ }
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * getBrowserForDocumentId is used to find the browser for a specific
+ * document via its id attribute.
+ */
+ getBrowserForDocumentId(aDocumentId) {
+ for (let i = 0; i < this.tabInfo.length; ++i) {
+ let browserFunc =
+ this.tabInfo[i].mode.getBrowser ||
+ this.tabInfo[i].mode.tabType.getBrowser;
+ if (browserFunc) {
+ let possBrowser = browserFunc.call(
+ this.tabInfo[i].mode.tabType,
+ this.tabInfo[i]
+ );
+ if (
+ possBrowser &&
+ possBrowser.contentDocument.documentElement.id == aDocumentId
+ ) {
+ return this.tabInfo[i];
+ }
+ }
+ }
+
+ return null;
+ }
+
+ getTabForBrowser(aBrowser) {
+ // Check the selected browser first, since that's the most likely.
+ if (this.getBrowserForSelectedTab() == aBrowser) {
+ return this.currentTabInfo;
+ }
+ for (let tabInfo of this.tabInfo) {
+ if (this.getBrowserForTab(tabInfo) == aBrowser) {
+ return tabInfo;
+ }
+ }
+ return null;
+ }
+
+ removeCurrentTab() {
+ this.removeTabByNode(
+ this.tabContainer.allTabs[this.tabContainer.selectedIndex]
+ );
+ }
+
+ switchToTab(aTabIndexNodeOrInfo) {
+ let [iTab] = this._getTabContextForTabbyThing(aTabIndexNodeOrInfo, false);
+ this.tabContainer.selectedIndex = iTab;
+ }
+
+ /**
+ * Finds the active element and stores it on `tabInfo` for restoring focus
+ * when this tab next becomes active.
+ *
+ * @param {object} tabInfo
+ */
+ rememberLastActiveElement(tabInfo) {
+ // Check for anything inside tabmail-container rather than the panel
+ // because focus could be in the Today Pane.
+ let activeElement = document.activeElement;
+ let container = document.getElementById("tabmail-container");
+ if (container.contains(activeElement)) {
+ while (activeElement.localName == "browser") {
+ let next = activeElement.contentDocument?.activeElement;
+ if (!next || next.localName == "body") {
+ break;
+ }
+ activeElement = next;
+ }
+ // If the active element is inside a container, store the container
+ // instead of the element, so that `.focus()` returns focus to the
+ // right place.
+ tabInfo.lastActiveElement =
+ activeElement.closest("[aria-activedescendant]") ?? activeElement;
+ Services.focus.clearFocus(window);
+ } else {
+ delete tabInfo.lastActiveElement;
+ }
+ }
+
+ /**
+ * UpdateCurrentTab - called in response to changing the current tab.
+ */
+ updateCurrentTab() {
+ if (
+ this.currentTabInfo != this.tabInfo[this.tabContainer.selectedIndex]
+ ) {
+ if (this.currentTabInfo) {
+ this.saveCurrentTabState();
+ }
+
+ let oldTab = this.currentTabInfo;
+ let oldPanel = [...this.panelContainer.children].find(p =>
+ p.hasAttribute("selected")
+ );
+ let tab = (this.currentTabInfo =
+ this.tabInfo[this.tabContainer.selectedIndex]);
+ // Update the selected attribute on the current and old tab panel.
+ if (oldPanel) {
+ this.rememberLastActiveElement(oldTab);
+ oldPanel.removeAttribute("selected");
+ if (oldTab.chromeBrowser) {
+ oldTab.chromeBrowser.docShellIsActive = false;
+ }
+ }
+
+ this.panelContainer.selectedPanel.setAttribute("selected", "true");
+ let showTabFunc = tab.mode.showTab || tab.mode.tabType.showTab;
+ showTabFunc.call(tab.mode.tabType, tab);
+ if (tab.chromeBrowser) {
+ tab.chromeBrowser.docShellIsActive = true;
+ }
+
+ let browser = this.getBrowserForTab(tab);
+ if (browser && !tab.browser) {
+ tab.browser = browser;
+ if (!tab.linkedBrowser) {
+ tab.linkedBrowser = browser;
+ }
+ }
+
+ for (let tabMonitor of this.tabMonitors) {
+ try {
+ tabMonitor.onTabSwitched(tab, oldTab);
+ } catch (ex) {
+ console.error(ex);
+ }
+ }
+
+ // always update the cursor status when we switch tabs
+ SetBusyCursor(window, tab.busy);
+ // active tabs should not have the wasBusy attribute
+ this.tabContainer.selectedItem.removeAttribute("wasBusy");
+ // update the thinking status when we switch tabs
+ this._setActiveThinkingState(tab.thinking);
+ // active tabs should not have the wasThinking attribute
+ this.tabContainer.selectedItem.removeAttribute("wasThinking");
+ this.setDocumentTitle(tab);
+
+ // We switched tabs, so we don't need to know the last tab
+ // opener anymore.
+ this.mLastTabOpener = null;
+
+ // Try to set focus where it was when the tab was last selected.
+ this.panelContainer.selectedPanel.focus();
+ if (tab.lastActiveElement) {
+ tab.lastActiveElement.focus();
+ delete tab.lastActiveElement;
+ }
+
+ let evt = new CustomEvent("TabSelect", {
+ bubbles: true,
+ detail: {
+ tabInfo: tab,
+ previousTabInfo: oldTab,
+ },
+ });
+ this.tabContainer.selectedItem.dispatchEvent(evt);
+ }
+ }
+
+ saveCurrentTabState() {
+ if (!this.currentTabInfo) {
+ this.currentTabInfo = this.tabInfo[0];
+ }
+
+ let tab = this.currentTabInfo;
+ // save the old tab state before we change the current tab
+ let saveTabFunc = tab.mode.saveTabState || tab.mode.tabType.saveTabState;
+ saveTabFunc.call(tab.mode.tabType, tab);
+ }
+
+ setTabTitle(aTabNodeOrInfo) {
+ let [iTab, tab] = this._getTabContextForTabbyThing(aTabNodeOrInfo, true);
+ if (tab) {
+ let tabNode = this.tabContainer.allTabs[iTab];
+ let titleChangeFunc =
+ tab.mode.onTitleChanged || tab.mode.tabType.onTitleChanged;
+ if (titleChangeFunc) {
+ titleChangeFunc.call(tab.mode.tabType, tab, tabNode);
+ }
+
+ let defaultTabTitle =
+ document.documentElement.getAttribute("defaultTabTitle");
+ let oldLabel = tabNode.getAttribute("label");
+ let newLabel = aTabNodeOrInfo ? tab.title : defaultTabTitle;
+ if (oldLabel == newLabel) {
+ return;
+ }
+
+ for (let tabMonitor of this.tabMonitors) {
+ try {
+ tabMonitor.onTabTitleChanged(tab);
+ } catch (ex) {
+ console.error(ex);
+ }
+ }
+
+ // If the displayed tab is the one at the moment of creation
+ // (aTabNodeOrInfo is null), set the default title as its title.
+ tabNode.setAttribute("label", newLabel);
+ // Update the window title if we're the displayed tab.
+ if (iTab == this.tabContainer.selectedIndex) {
+ this.setDocumentTitle(tab);
+ }
+
+ // Notify tab title change
+ if (!tab.beforeTabOpen) {
+ let evt = new CustomEvent("TabAttrModified", {
+ bubbles: true,
+ cancelable: false,
+ detail: { changed: ["label"], tabInfo: tab },
+ });
+ tabNode.dispatchEvent(evt);
+ }
+ }
+ }
+
+ /**
+ * Set the favIconUrl for the given tab and display it as the tab's icon.
+ * If the given favicon is missing or loads with an error, a fallback icon
+ * will be displayed instead.
+ *
+ * Note that the new favIconUrl is reported to the extension API's
+ * tabs.onUpdated.
+ *
+ * @param {object} tabInfo - The tabInfo object for the tab.
+ * @param {string|null} favIconUrl - The favIconUrl to set for the given
+ * tab.
+ * @param {string} fallbackSrc - The fallback icon src to display in case
+ * of missing or broken favicons.
+ */
+ setTabFavIcon(tabInfo, favIconUrl, fallbackSrc) {
+ let prevUrl = tabInfo.favIconUrl;
+ // The favIconUrl value is used by the TabmailTab _favIconUrl getter,
+ // which is used by the tab wrapper in the TabAttrModified callback.
+ tabInfo.favIconUrl = favIconUrl;
+ // NOTE: we always report the given favIconUrl, rather than the icon that
+ // is used in the tab. In particular, if the favIconUrl is null, we pass
+ // null rather than the fallbackIcon that is displayed.
+ if (favIconUrl != prevUrl && !tabInfo.beforeTabOpen) {
+ let evt = new CustomEvent("TabAttrModified", {
+ bubbles: true,
+ cancelable: false,
+ detail: { changed: ["favIconUrl"], tabInfo },
+ });
+ tabInfo.tabNode.dispatchEvent(evt);
+ }
+
+ tabInfo.tabNode.setIcon(favIconUrl, fallbackSrc);
+ }
+
+ /**
+ * Updates the global state to reflect the active tab's thinking
+ * state (which the caller provides).
+ */
+ _setActiveThinkingState(aThinkingState) {
+ if (aThinkingState) {
+ statusFeedback.showProgress(0);
+ if (typeof aThinkingState == "string") {
+ statusFeedback.showStatusString(aThinkingState);
+ }
+ } else {
+ statusFeedback.showProgress(0);
+ }
+ }
+
+ setTabThinking(aTabNodeOrInfo, aThinking) {
+ let [iTab, tab, tabNode] = this._getTabContextForTabbyThing(
+ aTabNodeOrInfo,
+ false
+ );
+ let isSelected = iTab == this.tabContainer.selectedIndex;
+ // if we are the current tab, update the cursor
+ if (isSelected) {
+ this._setActiveThinkingState(aThinking);
+ }
+
+ // if we are busy, hint our tab
+ if (aThinking) {
+ tabNode.setAttribute("thinking", "true");
+ } else {
+ // if we were thinking and are not selected, set the
+ // "wasThinking" attribute.
+ if (tab.thinking && !isSelected) {
+ tabNode.setAttribute("wasThinking", "true");
+ }
+ tabNode.removeAttribute("thinking");
+ }
+
+ // update the tab info to store the busy state.
+ tab.thinking = aThinking;
+ }
+
+ setTabBusy(aTabNodeOrInfo, aBusy) {
+ let [iTab, tab, tabNode] = this._getTabContextForTabbyThing(
+ aTabNodeOrInfo,
+ false
+ );
+ let isSelected = iTab == this.tabContainer.selectedIndex;
+
+ // if we are the current tab, update the cursor
+ if (isSelected) {
+ SetBusyCursor(window, aBusy);
+ }
+
+ // if we are busy, hint our tab
+ if (aBusy) {
+ tabNode.setAttribute("busy", "true");
+ } else {
+ // if we were busy and are not selected, set the
+ // "wasBusy" attribute.
+ if (tab.busy && !isSelected) {
+ tabNode.setAttribute("wasBusy", "true");
+ }
+ tabNode.removeAttribute("busy");
+ }
+
+ // update the tab info to store the busy state.
+ tab.busy = aBusy;
+ }
+
+ /**
+ * Set the document title based on the tab title
+ */
+ setDocumentTitle(aTab = this.selectedTab) {
+ let docTitle = aTab.title ? aTab.title.trim() : "";
+ let docElement = document.documentElement;
+ // If the document title is blank, add the default title.
+ if (!docTitle) {
+ docTitle = docElement.getAttribute("defaultTabTitle");
+ }
+
+ if (docElement.hasAttribute("titlepreface")) {
+ docTitle = docElement.getAttribute("titlepreface") + docTitle;
+ }
+
+ // If we're on Mac, don't display the separator and the modifier.
+ if (AppConstants.platform != "macosx") {
+ docTitle +=
+ docElement.getAttribute("titlemenuseparator") +
+ docElement.getAttribute("titlemodifier");
+ }
+
+ document.title = docTitle;
+ }
+
+ // Called by <browser>, unused by tabmail.
+ finishBrowserRemotenessChange(browser, loadSwitchId) {}
+
+ /**
+ * Returns the find bar for a tab.
+ */
+ getCachedFindBar(tab = this.selectedTab) {
+ return tab.findbar ?? null;
+ }
+
+ /**
+ * Implementation of gBrowser's lazy-loaded find bar. We don't lazily load
+ * the find bar, and some of our tabs don't have a find bar.
+ */
+ async getFindBar(tab = this.selectedTab) {
+ return tab.findbar ?? null;
+ }
+
+ disconnectedCallback() {
+ window.controllers.removeController(this.tabController);
+ }
+ }
+
+ customElements.define("tabmail", MozTabmail);
+}
+
+/**
+ * Refresh the contents of the recently closed tags popup menu/panel.
+ * Used for example for appmenu/Go/Recently_Closed_Tabs panel.
+ *
+ * @param {Element} parent - Parent element that will contain the menu items.
+ * @param {string} [elementName] - Type of menu item, e.g. "menuitem", "toolbarbutton".
+ * @param {string} [classes] - Classes to set on the menu items.
+ * @param {string} [separatorName] - Type of separator, e.g. "menuseparator", "toolbarseparator".
+ */
+function InitRecentlyClosedTabsPopup(
+ parent,
+ elementName = "menuitem",
+ classes,
+ separatorName = "menuseparator"
+) {
+ const tabs = document.getElementById("tabmail").recentlyClosedTabs;
+
+ // Show Popup only when there are restorable tabs.
+ if (!tabs.length) {
+ return false;
+ }
+
+ // Clear the list.
+ while (parent.hasChildNodes()) {
+ parent.lastChild.remove();
+ }
+
+ // Insert menu items to rebuild the recently closed tab list.
+ tabs.forEach((tab, index) => {
+ const item = document.createXULElement(elementName);
+ item.setAttribute("label", tab.title);
+ item.setAttribute(
+ "oncommand",
+ `document.getElementById("tabmail").undoCloseTab(${index});`
+ );
+ if (classes) {
+ item.setAttribute("class", classes);
+ }
+
+ if (index == 0) {
+ item.setAttribute("key", "key_undoCloseTab");
+ }
+ parent.appendChild(item);
+ });
+
+ // Only show "Restore All Tabs" if there is more than one tab to restore.
+ if (tabs.length > 1) {
+ parent.appendChild(document.createXULElement(separatorName));
+
+ const item = document.createXULElement(elementName);
+ item.setAttribute(
+ "label",
+ document.getElementById("bundle_messenger").getString("restoreAllTabs")
+ );
+
+ item.addEventListener("command", () => {
+ let tabmail = document.getElementById("tabmail");
+ let len = tabmail.recentlyClosedTabs.length;
+ while (len--) {
+ document.getElementById("tabmail").undoCloseTab();
+ }
+ });
+
+ if (classes) {
+ item.setAttribute("class", classes);
+ }
+ parent.appendChild(item);
+ }
+
+ return true;
+}
+
+// Set up the tabContextMenu, which is used as the context menu for all tabmail
+// tabs.
+window.addEventListener(
+ "DOMContentLoaded",
+ () => {
+ let tabmail = document.getElementById("tabmail");
+ let tabMenu = document.getElementById("tabContextMenu");
+
+ let openInWindowItem = document.getElementById(
+ "tabContextMenuOpenInWindow"
+ );
+ let closeOtherTabsItem = document.getElementById(
+ "tabContextMenuCloseOtherTabs"
+ );
+ let recentlyClosedMenu = document.getElementById(
+ "tabContextMenuRecentlyClosed"
+ );
+ let closeItem = document.getElementById("tabContextMenuClose");
+
+ // Shared variable: the tabNode that was activated to open the context menu.
+ let currentTabInfo = null;
+
+ tabMenu.addEventListener("popupshowing", () => {
+ let tabNode = tabMenu.triggerNode?.closest("tab");
+
+ // this happens when the user did not actually-click on a tab but
+ // instead on the strip behind it.
+ if (!tabNode) {
+ currentTabInfo = null;
+ return false;
+ }
+
+ currentTabInfo = tabmail.tabInfo.find(info => info.tabNode == tabNode);
+ openInWindowItem.setAttribute(
+ "disabled",
+ currentTabInfo.canClose && tabmail.persistTab(currentTabInfo)
+ );
+ closeOtherTabsItem.setAttribute(
+ "disabled",
+ tabmail.tabInfo.every(info => info == currentTabInfo || !info.canClose)
+ );
+ recentlyClosedMenu.setAttribute(
+ "disabled",
+ !tabmail.recentlyClosedTabs.length
+ );
+ closeItem.setAttribute("disabled", !currentTabInfo.canClose);
+ return true;
+ });
+
+ // Tidy up.
+ tabMenu.addEventListener("popuphidden", () => {
+ currentTabInfo = null;
+ });
+
+ openInWindowItem.addEventListener("command", () => {
+ tabmail.replaceTabWithWindow(currentTabInfo);
+ });
+ closeOtherTabsItem.addEventListener("command", () => {
+ tabmail.closeOtherTabs(currentTabInfo);
+ });
+ closeItem.addEventListener("command", () => {
+ tabmail.closeTab(currentTabInfo);
+ });
+
+ let recentlyClosedPopup = recentlyClosedMenu.querySelector("menupopup");
+ recentlyClosedPopup.addEventListener("popupshowing", () =>
+ InitRecentlyClosedTabsPopup(recentlyClosedPopup)
+ );
+
+ // Register the tabmail window font size only after everything else loaded.
+ UIFontSize.registerWindow(window);
+ },
+ { once: true }
+);
diff --git a/comm/mail/base/content/tagDialog.inc.xhtml b/comm/mail/base/content/tagDialog.inc.xhtml
new file mode 100644
index 0000000000..100efefe60
--- /dev/null
+++ b/comm/mail/base/content/tagDialog.inc.xhtml
@@ -0,0 +1,27 @@
+# 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/.
+
+ <linkset>
+ <html:link rel="localization" href="messenger/preferences/new-tag.ftl"/>
+ </linkset>
+
+ <box style="display: grid; grid-template-columns: auto 1fr; align-items: center;">
+ <label id="nameLabel"
+ data-l10n-id="tag-name-label"
+ control="name"/>
+ <hbox class="input-container">
+ <html:input id="name"
+ type="text"
+ oninput="doEnabling();"
+ class="input-inline"
+ aria-labelledby="nameLabel"/>
+ </hbox>
+ <hbox align="center">
+ <label id="colorLabel"
+ data-l10n-id="tag-color-label"
+ control="tagColorPicker"/>
+ </hbox>
+ <html:input type="color" id="tagColorPicker"/>
+ </box>
+ <separator/>
diff --git a/comm/mail/base/content/threadPane.js b/comm/mail/base/content/threadPane.js
new file mode 100644
index 0000000000..88af9c28b0
--- /dev/null
+++ b/comm/mail/base/content/threadPane.js
@@ -0,0 +1,825 @@
+/* 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/. */
+
+/* TODO: Now used exclusively in SearchDialog.xhtml. Needs dead code removal. */
+
+/* import-globals-from folderDisplay.js */
+/* import-globals-from SearchDialog.js */
+
+/* globals validateFileName */ // From utilityOverlay.js
+/* globals messageFlavorDataProvider */ // From messenger.js
+
+ChromeUtils.defineESModuleGetters(this, {
+ TreeSelection: "chrome://messenger/content/tree-selection.mjs",
+});
+
+var gLastMessageUriToLoad = null;
+var gThreadPaneCommandUpdater = null;
+/**
+ * Tracks whether the right mouse button changed the selection or not. If the
+ * user right clicks on the selection, it stays the same. If they click outside
+ * of it, we alter the selection (but not the current index) to be the row they
+ * clicked on.
+ *
+ * The value of this variable is an object with "view" and "selection" keys
+ * and values. The view value is the view whose selection we saved off, and
+ * the selection value is the selection object we saved off.
+ */
+var gRightMouseButtonSavedSelection = null;
+
+/**
+ * When right-clicks happen, we do not want to corrupt the underlying
+ * selection. The right-click is a transient selection. So, unless the
+ * user is right-clicking on the current selection, we create a new
+ * selection object (thanks to TreeSelection) and set that as the
+ * current/transient selection.
+ *
+ * @param aSingleSelect Should the selection we create be a single selection?
+ * This is relevant if the row being clicked on is already part of the
+ * selection. If it is part of the selection and !aSingleSelect, then we
+ * leave the selection as is. If it is part of the selection and
+ * aSingleSelect then we create a transient single-row selection.
+ */
+function ChangeSelectionWithoutContentLoad(event, tree, aSingleSelect) {
+ var treeSelection = tree.view.selection;
+
+ var row = tree.getRowAt(event.clientX, event.clientY);
+ // Only do something if:
+ // - the row is valid
+ // - it's not already selected (or we want a single selection)
+ if (row >= 0 && (aSingleSelect || !treeSelection.isSelected(row))) {
+ // Check if the row is exactly the existing selection. In that case
+ // there is no need to create a bogus selection.
+ if (treeSelection.count == 1) {
+ let minObj = {};
+ treeSelection.getRangeAt(0, minObj, {});
+ if (minObj.value == row) {
+ event.stopPropagation();
+ return;
+ }
+ }
+
+ let transientSelection = new TreeSelection(tree);
+ transientSelection.logAdjustSelectionForReplay();
+
+ gRightMouseButtonSavedSelection = {
+ // Need to clear out this reference later.
+ view: tree.view,
+ realSelection: treeSelection,
+ transientSelection,
+ };
+
+ var saveCurrentIndex = treeSelection.currentIndex;
+
+ // tell it to log calls to adjustSelection
+ // attach it to the view
+ tree.view.selection = transientSelection;
+ // Don't generate any selection events! (we never set this to false, because
+ // that would generate an event, and we never need one of those from this
+ // selection object.
+ transientSelection.selectEventsSuppressed = true;
+ transientSelection.select(row);
+ transientSelection.currentIndex = saveCurrentIndex;
+ tree.ensureRowIsVisible(row);
+ }
+ event.stopPropagation();
+}
+
+function ThreadPaneOnDragStart(aEvent) {
+ if (aEvent.target.localName != "treechildren") {
+ return;
+ }
+
+ let messageUris = gFolderDisplay.selectedMessageUris;
+ if (!messageUris) {
+ return;
+ }
+
+ gFolderDisplay.hintAboutToDeleteMessages();
+ let messengerBundle = document.getElementById("bundle_messenger");
+ let noSubjectString = messengerBundle.getString(
+ "defaultSaveMessageAsFileName"
+ );
+ if (noSubjectString.endsWith(".eml")) {
+ noSubjectString = noSubjectString.slice(0, -4);
+ }
+ let longSubjectTruncator = messengerBundle.getString(
+ "longMsgSubjectTruncator"
+ );
+ // Clip the subject string to 124 chars to avoid problems on Windows,
+ // see NS_MAX_FILEDESCRIPTOR in m-c/widget/windows/nsDataObj.cpp .
+ const maxUncutNameLength = 124;
+ let maxCutNameLength = maxUncutNameLength - longSubjectTruncator.length;
+ let messages = new Map();
+ for (let [index, msgUri] of messageUris.entries()) {
+ let msgService = MailServices.messageServiceFromURI(msgUri);
+ let msgHdr = msgService.messageURIToMsgHdr(msgUri);
+ let subject = msgHdr.mime2DecodedSubject || "";
+ if (msgHdr.flags & Ci.nsMsgMessageFlags.HasRe) {
+ subject = "Re: " + subject;
+ }
+
+ let uniqueFileName;
+ // If there is no subject, use a default name.
+ // If subject needs to be truncated, add a truncation character to indicate it.
+ if (!subject) {
+ uniqueFileName = noSubjectString;
+ } else {
+ uniqueFileName =
+ subject.length <= maxUncutNameLength
+ ? subject
+ : subject.substr(0, maxCutNameLength) + longSubjectTruncator;
+ }
+ let msgFileName = validateFileName(uniqueFileName);
+ let msgFileNameLowerCase = msgFileName.toLocaleLowerCase();
+
+ while (true) {
+ if (!messages[msgFileNameLowerCase]) {
+ messages[msgFileNameLowerCase] = 1;
+ break;
+ } else {
+ let postfix = "-" + messages[msgFileNameLowerCase];
+ messages[msgFileNameLowerCase]++;
+ msgFileName = msgFileName + postfix;
+ msgFileNameLowerCase = msgFileNameLowerCase + postfix;
+ }
+ }
+
+ msgFileName = msgFileName + ".eml";
+
+ let msgUrl = msgService.getUrlForUri(msgUri);
+ let separator = msgUrl.spec.includes("?") ? "&" : "?";
+
+ aEvent.dataTransfer.mozSetDataAt("text/x-moz-message", msgUri, index);
+ aEvent.dataTransfer.mozSetDataAt("text/x-moz-url", msgUrl.spec, index);
+ aEvent.dataTransfer.mozSetDataAt(
+ "application/x-moz-file-promise-url",
+ msgUrl.spec + separator + "fileName=" + encodeURIComponent(msgFileName),
+ index
+ );
+ aEvent.dataTransfer.mozSetDataAt(
+ "application/x-moz-file-promise",
+ new messageFlavorDataProvider(),
+ index
+ );
+ aEvent.dataTransfer.mozSetDataAt(
+ "application/x-moz-file-promise-dest-filename",
+ msgFileName.replace(/(.{74}).*(.{10})$/u, "$1...$2"),
+ index
+ );
+ }
+ aEvent.dataTransfer.effectAllowed = "copyMove";
+ aEvent.dataTransfer.addElement(aEvent.target);
+}
+
+function ThreadPaneOnDragOver(aEvent) {
+ let ds = Cc["@mozilla.org/widget/dragservice;1"]
+ .getService(Ci.nsIDragService)
+ .getCurrentSession();
+ ds.canDrop = false;
+ if (!gFolderDisplay.displayedFolder.canFileMessages) {
+ return;
+ }
+
+ let dt = aEvent.dataTransfer;
+ if (Array.from(dt.mozTypesAt(0)).includes("application/x-moz-file")) {
+ let extFile = dt.mozGetDataAt("application/x-moz-file", 0);
+ if (!extFile) {
+ return;
+ }
+
+ extFile = extFile.QueryInterface(Ci.nsIFile);
+ if (extFile.isFile()) {
+ let len = extFile.leafName.length;
+ if (len > 4 && extFile.leafName.toLowerCase().endsWith(".eml")) {
+ ds.canDrop = true;
+ }
+ }
+ }
+}
+
+function ThreadPaneOnDrop(aEvent) {
+ let dt = aEvent.dataTransfer;
+ for (let i = 0; i < dt.mozItemCount; i++) {
+ let extFile = dt.mozGetDataAt("application/x-moz-file", i);
+ if (!extFile) {
+ continue;
+ }
+
+ extFile = extFile.QueryInterface(Ci.nsIFile);
+ if (extFile.isFile()) {
+ let len = extFile.leafName.length;
+ if (len > 4 && extFile.leafName.toLowerCase().endsWith(".eml")) {
+ MailServices.copy.copyFileMessage(
+ extFile,
+ gFolderDisplay.displayedFolder,
+ null,
+ false,
+ 1,
+ "",
+ null,
+ msgWindow
+ );
+ }
+ }
+ }
+}
+
+function TreeOnMouseDown(event) {
+ // Detect right mouse click and change the highlight to the row
+ // where the click happened without loading the message headers in
+ // the Folder or Thread Pane.
+ // Same for middle click, which will open the folder/message in a tab.
+ if (event.button == 2 || event.button == 1) {
+ // We want a single selection if this is a middle-click (button 1)
+ ChangeSelectionWithoutContentLoad(
+ event,
+ event.target.parentNode,
+ event.button == 1
+ );
+ }
+}
+
+function ThreadPaneOnClick(event) {
+ // We only care about button 0 (left click) events.
+ if (event.button != 0) {
+ event.stopPropagation();
+ return;
+ }
+
+ // We already handle marking as read/flagged/junk cyclers in nsMsgDBView.cpp
+ // so all we need to worry about here is doubleclicks and column header. We
+ // get here for clicks on the "treecol" (headers) and the "scrollbarbutton"
+ // (scrollbar buttons) and don't want those events to cause a doubleclick.
+
+ let t = event.target;
+ if (t.localName == "treecol") {
+ HandleColumnClick(t.id);
+ return;
+ }
+
+ if (t.localName != "treechildren") {
+ return;
+ }
+
+ let tree = GetThreadTree();
+ // Figure out what cell the click was in.
+ let treeCellInfo = tree.getCellAt(event.clientX, event.clientY);
+ if (treeCellInfo.row == -1) {
+ return;
+ }
+
+ if (treeCellInfo.col.id == "selectCol") {
+ HandleSelectColClick(event, treeCellInfo.row);
+ return;
+ }
+
+ if (treeCellInfo.col.id == "deleteCol") {
+ handleDeleteColClick(event);
+ return;
+ }
+
+ // Grouped By Sort dummy header row non cycler column doubleclick toggles the
+ // thread's open/closed state; tree.js handles it. Cyclers are not currently
+ // implemented in group header rows, a click/doubleclick there should
+ // select/toggle thread state.
+ if (gFolderDisplay.view.isGroupedByHeaderAtIndex(treeCellInfo.row)) {
+ if (!treeCellInfo.col.cycler) {
+ return;
+ }
+ if (event.detail == 1) {
+ gFolderDisplay.selectViewIndex(treeCellInfo.row);
+ }
+ if (event.detail == 2) {
+ gFolderDisplay.view.dbView.toggleOpenState(treeCellInfo.row);
+ }
+ event.stopPropagation();
+ return;
+ }
+
+ // Cyclers and twisties respond to single clicks, not double clicks.
+ if (
+ event.detail == 2 &&
+ !treeCellInfo.col.cycler &&
+ treeCellInfo.childElt != "twisty"
+ ) {
+ ThreadPaneDoubleClick();
+ } else if (
+ treeCellInfo.col.id == "threadCol" &&
+ !event.shiftKey &&
+ (event.ctrlKey || event.metaKey)
+ ) {
+ gDBView.ExpandAndSelectThreadByIndex(treeCellInfo.row, true);
+ event.stopPropagation();
+ }
+}
+
+function HandleColumnClick(columnID) {
+ if (columnID == "selectCol") {
+ let treeView = gFolderDisplay.tree.view;
+ let selection = treeView.selection;
+ if (!selection) {
+ return;
+ }
+ if (selection.count > 0) {
+ selection.clearSelection();
+ } else {
+ selection.selectAll();
+ }
+ return;
+ }
+
+ if (gFolderDisplay.COLUMNS_MAP_NOSORT.has(columnID)) {
+ return;
+ }
+
+ let sortType = gFolderDisplay.COLUMNS_MAP.get(columnID);
+ let curCustomColumn = gDBView.curCustomColumn;
+ if (!sortType) {
+ // If the column isn't in the map, check if it's a custom column.
+ try {
+ // Test for the columnHandler (an error is thrown if it does not exist).
+ gDBView.getColumnHandler(columnID);
+
+ // Handler is registered - set column to be the current custom column.
+ gDBView.curCustomColumn = columnID;
+ sortType = "byCustom";
+ } catch (ex) {
+ dump(
+ "HandleColumnClick: No custom column handler registered for " +
+ "columnID: " +
+ columnID +
+ " - " +
+ ex +
+ "\n"
+ );
+ return;
+ }
+ }
+
+ let viewWrapper = gFolderDisplay.view;
+ let simpleColumns = false;
+ try {
+ simpleColumns = !Services.prefs.getBoolPref(
+ "mailnews.thread_pane_column_unthreads"
+ );
+ } catch (ex) {}
+
+ if (sortType == "byThread") {
+ if (simpleColumns) {
+ MsgToggleThreaded();
+ } else if (viewWrapper.showThreaded) {
+ MsgReverseSortThreadPane();
+ } else {
+ MsgSortByThread();
+ }
+
+ return;
+ }
+
+ if (!simpleColumns && viewWrapper.showThreaded) {
+ viewWrapper.showUnthreaded = true;
+ MsgSortThreadPane(sortType);
+ return;
+ }
+
+ if (
+ viewWrapper.primarySortType == Ci.nsMsgViewSortType[sortType] &&
+ (viewWrapper.primarySortType != Ci.nsMsgViewSortType.byCustom ||
+ curCustomColumn == columnID)
+ ) {
+ MsgReverseSortThreadPane();
+ } else {
+ MsgSortThreadPane(sortType);
+ }
+}
+
+function HandleSelectColClick(event, row) {
+ // User wants to multiselect using the old way.
+ if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) {
+ return;
+ }
+ let tree = gFolderDisplay.tree;
+ let selection = tree.view.selection;
+ if (event.detail == 1) {
+ selection.toggleSelect(row);
+ }
+
+ // In the selectCol, we want a double click on a thread parent to select
+ // and deselect all children, in threaded and grouped views.
+ if (
+ event.detail == 2 &&
+ tree.view.isContainerOpen(row) &&
+ !tree.view.isContainerEmpty(row)
+ ) {
+ // On doubleclick of an open thread, select/deselect all the children.
+ let startRow = row + 1;
+ let endRow = startRow;
+ while (endRow < tree.view.rowCount && tree.view.getLevel(endRow) > 0) {
+ endRow++;
+ }
+ endRow--;
+ if (selection.isSelected(row)) {
+ selection.rangedSelect(startRow, endRow, true);
+ } else {
+ selection.clearRange(startRow, endRow);
+ ThreadPaneSelectionChanged();
+ }
+ }
+
+ // There is no longer any selection, clean up for correct state of things.
+ if (selection.count == 0) {
+ if (gFolderDisplay.displayedFolder) {
+ gFolderDisplay.displayedFolder.lastMessageLoaded = nsMsgKey_None;
+ }
+ gFolderDisplay._mostRecentSelectionCounts[1] = 0;
+ }
+}
+
+/**
+ * Delete a message without selecting it or loading its content.
+ *
+ * @param {DOMEvent} event - The DOM Event.
+ */
+function handleDeleteColClick(event) {
+ // Prevent deletion if any of the modifier keys was pressed.
+ if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) {
+ return;
+ }
+
+ // Simulate a right click on the message row to inherit all the validations
+ // and alerts coming from the "cmd_delete" command.
+ ChangeSelectionWithoutContentLoad(
+ event,
+ event.target.parentNode,
+ event.button == 1
+ );
+
+ // Trigger the message deletion.
+ goDoCommand("cmd_delete");
+}
+
+function ThreadPaneDoubleClick() {
+ MsgOpenSelectedMessages();
+}
+
+function ThreadPaneKeyDown(event) {
+ if (event.keyCode != KeyEvent.DOM_VK_RETURN) {
+ return;
+ }
+
+ // Grouped By Sort dummy header row <enter> toggles the thread's open/closed
+ // state. Let tree.js handle it.
+ if (
+ gFolderDisplay.view.showGroupedBySort &&
+ gFolderDisplay.treeSelection &&
+ gFolderDisplay.treeSelection.count == 1 &&
+ gFolderDisplay.view.isGroupedByHeaderAtIndex(
+ gFolderDisplay.treeSelection.currentIndex
+ )
+ ) {
+ return;
+ }
+
+ // Prevent any thread that happens to be last selected (currentIndex) in a
+ // single or multi selection from toggling in tree.js.
+ event.stopImmediatePropagation();
+
+ ThreadPaneDoubleClick();
+}
+
+function MsgSortByThread() {
+ gFolderDisplay.view.showThreaded = true;
+ MsgSortThreadPane("byDate");
+}
+
+function MsgSortThreadPane(sortName) {
+ let sortType = Ci.nsMsgViewSortType[sortName];
+ let grouped = gFolderDisplay.view.showGroupedBySort;
+ gFolderDisplay.view._threadExpandAll = Boolean(
+ gFolderDisplay.view._viewFlags & Ci.nsMsgViewFlagsType.kExpandAll
+ );
+
+ if (!grouped) {
+ gFolderDisplay.view.sort(sortType, Ci.nsMsgViewSortOrder.ascending);
+ // Respect user's last expandAll/collapseAll choice, post sort direction change.
+ gFolderDisplay.restoreThreadState();
+ return;
+ }
+
+ // legacy behavior dictates we un-group-by-sort if we were. this probably
+ // deserves a UX call...
+
+ // For non virtual folders, do not ungroup (which sorts by the going away
+ // sort) and then sort, as it's a double sort.
+ // For virtual folders, which are rebuilt in the backend in a grouped
+ // change, create a new view upfront rather than applying viewFlags. There
+ // are oddities just applying viewFlags, for example changing out of a
+ // custom column grouped xfvf view with the threads collapsed works (doesn't)
+ // differently than other variations.
+ // So, first set the desired sortType and sortOrder, then set viewFlags in
+ // batch mode, then apply it all (open a new view) with endViewUpdate().
+ gFolderDisplay.view.beginViewUpdate();
+ gFolderDisplay.view._sort = [[sortType, Ci.nsMsgViewSortOrder.ascending]];
+ gFolderDisplay.view.showGroupedBySort = false;
+ gFolderDisplay.view.endViewUpdate();
+
+ // Virtual folders don't persist viewFlags well in the back end,
+ // due to a virtual folder being either 'real' or synthetic, so make
+ // sure it's done here.
+ if (gFolderDisplay.view.isVirtual) {
+ gFolderDisplay.view.dbView.viewFlags = gFolderDisplay.view.viewFlags;
+ }
+}
+
+function MsgReverseSortThreadPane() {
+ let grouped = gFolderDisplay.view.showGroupedBySort;
+ gFolderDisplay.view._threadExpandAll = Boolean(
+ gFolderDisplay.view._viewFlags & Ci.nsMsgViewFlagsType.kExpandAll
+ );
+
+ // Grouped By view is special for column click sort direction changes.
+ if (grouped) {
+ if (gDBView.selection.count) {
+ gFolderDisplay._saveSelection();
+ }
+
+ if (gFolderDisplay.view.isSingleFolder) {
+ if (gFolderDisplay.view.isVirtual) {
+ gFolderDisplay.view.showGroupedBySort = false;
+ } else {
+ // Must ensure rows are collapsed and kExpandAll is unset.
+ gFolderDisplay.doCommand(Ci.nsMsgViewCommandType.collapseAll);
+ }
+ }
+ }
+
+ if (gFolderDisplay.view.isSortedAscending) {
+ gFolderDisplay.view.sortDescending();
+ } else {
+ gFolderDisplay.view.sortAscending();
+ }
+
+ // Restore Grouped By state post sort direction change.
+ if (grouped) {
+ if (gFolderDisplay.view.isVirtual && gFolderDisplay.view.isSingleFolder) {
+ MsgGroupBySort();
+ }
+
+ // Restore Grouped By selection post sort direction change.
+ gFolderDisplay._restoreSelection();
+ }
+
+ // Respect user's last expandAll/collapseAll choice, for both threaded and grouped
+ // views, post sort direction change.
+ gFolderDisplay.restoreThreadState();
+}
+
+function MsgToggleThreaded() {
+ if (gFolderDisplay.view.showThreaded) {
+ gFolderDisplay.view.showUnthreaded = true;
+ } else {
+ gFolderDisplay.view.showThreaded = true;
+ }
+}
+
+function MsgSortThreaded() {
+ gFolderDisplay.view.showThreaded = true;
+}
+
+function MsgGroupBySort() {
+ gFolderDisplay.view.showGroupedBySort = true;
+}
+
+function MsgSortUnthreaded() {
+ gFolderDisplay.view.showUnthreaded = true;
+}
+
+function MsgSortAscending() {
+ if (
+ gFolderDisplay.view.showGroupedBySort &&
+ gFolderDisplay.view.isSingleFolder
+ ) {
+ if (gFolderDisplay.view.isSortedDescending) {
+ MsgReverseSortThreadPane();
+ }
+
+ return;
+ }
+
+ gFolderDisplay.view.sortAscending();
+}
+
+function MsgSortDescending() {
+ if (
+ gFolderDisplay.view.showGroupedBySort &&
+ gFolderDisplay.view.isSingleFolder
+ ) {
+ if (gFolderDisplay.view.isSortedAscending) {
+ MsgReverseSortThreadPane();
+ }
+
+ return;
+ }
+
+ gFolderDisplay.view.sortDescending();
+}
+
+// XXX this should probably migrate into FolderDisplayWidget, or whatever
+// FolderDisplayWidget ends up using if it refactors column management out.
+function UpdateSortIndicators(sortType, sortOrder) {
+ // Remove the sort indicator from all the columns
+ let treeColumns = document.getElementById("threadCols").children;
+ for (let i = 0; i < treeColumns.length; i++) {
+ treeColumns[i].removeAttribute("sortDirection");
+ }
+
+ // show the twisties if the view is threaded
+ let threadCol = document.getElementById("threadCol");
+ let subjectCol = document.getElementById("subjectCol");
+ let sortedColumn;
+ // set the sort indicator on the column we are sorted by
+ let colID = ConvertSortTypeToColumnID(sortType);
+ if (colID) {
+ sortedColumn = document.getElementById(colID);
+ }
+
+ let viewWrapper = gFolderDisplay.view;
+
+ // the thread column is not visible when we are grouped by sort
+ threadCol.collapsed = viewWrapper.showGroupedBySort;
+
+ // show twisties only when grouping or threading
+ if (viewWrapper.showGroupedBySort || viewWrapper.showThreaded) {
+ subjectCol.setAttribute("primary", "true");
+ } else {
+ subjectCol.removeAttribute("primary");
+ }
+
+ if (sortedColumn) {
+ sortedColumn.setAttribute(
+ "sortDirection",
+ sortOrder == Ci.nsMsgViewSortOrder.ascending ? "ascending" : "descending"
+ );
+ }
+
+ // Prevent threadCol from showing the sort direction chevron.
+ if (viewWrapper.showThreaded) {
+ threadCol.removeAttribute("sortDirection");
+ }
+}
+
+function GetThreadTree() {
+ return document.getElementById("threadTree");
+}
+
+function ThreadPaneOnLoad() {
+ var tree = GetThreadTree();
+ // We won't have the tree if we're in a message window, so exit silently
+ if (!tree) {
+ return;
+ }
+ tree.addEventListener("click", ThreadPaneOnClick, true);
+ tree.addEventListener(
+ "dblclick",
+ event => {
+ // The tree.js dblclick event handler is handling editing and toggling
+ // open state of the cell. We don't use editing, and we want to handle
+ // the toggling through the click handler (also for double click), so
+ // capture the dblclick event before it bubbles up and causes the
+ // tree.js dblclick handler to toggle open state.
+ event.stopPropagation();
+ },
+ true
+ );
+
+ // The mousedown event listener below should only be added in the thread
+ // pane of the mailnews 3pane window, not in the advanced search window.
+ if (tree.parentNode.id == "searchResultListBox") {
+ return;
+ }
+
+ tree.addEventListener("mousedown", TreeOnMouseDown, true);
+ let delay = Services.prefs.getIntPref("mailnews.threadpane_select_delay");
+ document.getElementById("threadTree")._selectDelay = delay;
+}
+
+function ThreadPaneSelectionChanged() {
+ GetThreadTree().view.selectionChanged();
+ UpdateSelectCol();
+ UpdateMailSearch();
+}
+
+function UpdateSelectCol() {
+ let selectCol = document.getElementById("selectCol");
+ if (!selectCol) {
+ return;
+ }
+ let treeView = gFolderDisplay.tree.view;
+ let selection = treeView.selection;
+ if (selection && selection.count > 0) {
+ if (treeView.rowCount == selection.count) {
+ selectCol.classList.remove("someselected");
+ selectCol.classList.add("allselected");
+ } else {
+ selectCol.classList.remove("allselected");
+ selectCol.classList.add("someselected");
+ }
+ } else {
+ selectCol.classList.remove("allselected");
+ selectCol.classList.remove("someselected");
+ }
+}
+
+function ConvertSortTypeToColumnID(sortKey) {
+ var columnID;
+
+ // Hack to turn this into an integer, if it was a string.
+ // It would be a string if it came from XULStore.json.
+ sortKey = sortKey - 0;
+
+ switch (sortKey) {
+ // In the case of None, we default to the date column
+ // This appears to be the case in such instances as
+ // Global search, so don't complain about it.
+ case Ci.nsMsgViewSortType.byNone:
+ case Ci.nsMsgViewSortType.byDate:
+ columnID = "dateCol";
+ break;
+ case Ci.nsMsgViewSortType.byReceived:
+ columnID = "receivedCol";
+ break;
+ case Ci.nsMsgViewSortType.byAuthor:
+ columnID = "senderCol";
+ break;
+ case Ci.nsMsgViewSortType.byRecipient:
+ columnID = "recipientCol";
+ break;
+ case Ci.nsMsgViewSortType.bySubject:
+ columnID = "subjectCol";
+ break;
+ case Ci.nsMsgViewSortType.byLocation:
+ columnID = "locationCol";
+ break;
+ case Ci.nsMsgViewSortType.byAccount:
+ columnID = "accountCol";
+ break;
+ case Ci.nsMsgViewSortType.byUnread:
+ columnID = "unreadButtonColHeader";
+ break;
+ case Ci.nsMsgViewSortType.byStatus:
+ columnID = "statusCol";
+ break;
+ case Ci.nsMsgViewSortType.byTags:
+ columnID = "tagsCol";
+ break;
+ case Ci.nsMsgViewSortType.bySize:
+ columnID = "sizeCol";
+ break;
+ case Ci.nsMsgViewSortType.byPriority:
+ columnID = "priorityCol";
+ break;
+ case Ci.nsMsgViewSortType.byFlagged:
+ columnID = "flaggedCol";
+ break;
+ case Ci.nsMsgViewSortType.byThread:
+ columnID = "threadCol";
+ break;
+ case Ci.nsMsgViewSortType.byId:
+ columnID = "idCol";
+ break;
+ case Ci.nsMsgViewSortType.byJunkStatus:
+ columnID = "junkStatusCol";
+ break;
+ case Ci.nsMsgViewSortType.byAttachments:
+ columnID = "attachmentCol";
+ break;
+ case Ci.nsMsgViewSortType.byCustom:
+ // TODO: either change try() catch to if (property exists) or restore the getColumnHandler() check
+ try {
+ // getColumnHandler throws an error when the ID is not handled
+ columnID = window.gDBView.curCustomColumn;
+ } catch (err) {
+ // error - means no handler
+ dump(
+ "ConvertSortTypeToColumnID: custom sort key but no handler for column '" +
+ columnID +
+ "'\n"
+ );
+ columnID = "dateCol";
+ }
+
+ break;
+ case Ci.nsMsgViewSortType.byCorrespondent:
+ columnID = "correspondentCol";
+ break;
+ default:
+ dump("unsupported sort key: " + sortKey + "\n");
+ columnID = "dateCol";
+ break;
+ }
+ return columnID;
+}
+
+addEventListener("load", ThreadPaneOnLoad, true);
diff --git a/comm/mail/base/content/threadTree.inc.xhtml b/comm/mail/base/content/threadTree.inc.xhtml
new file mode 100644
index 0000000000..6151847887
--- /dev/null
+++ b/comm/mail/base/content/threadTree.inc.xhtml
@@ -0,0 +1,230 @@
+# 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/.
+
+ <!-- The threadTree is shared with messenger.xhtml (MAIN_WINDOW)
+ and SearchDialog.xhtml (SEARCH_WINDOW). -->
+ <tree id="threadTree"
+ class="plain"
+ persist="lastfoldersent width"
+ treelines="true"
+ enableColumnDrag="true"
+ _selectDelay="250"
+ lastfoldersent="false"
+ keepcurrentinview="true"
+ disableKeyNavigation="true"
+ onkeydown="ThreadPaneKeyDown(event);"
+ onselect="ThreadPaneSelectionChanged();">
+#ifdef MAIN_WINDOW
+ <treecols is="thread-pane-treecols" id="threadCols"
+#else
+ <treecols id="threadCols"
+#endif
+ pickertooltiptext="&columnChooser2.tooltip;">
+
+ <!--
+ The below code may suggest that 'ordinal' is still a supported XUL
+ XUL attribute. It is not. This is a crutch so that we can
+ continue persisting the CSS -moz-box-ordinal-group attribute,
+ which is the appropriate replacement for the ordinal attribute
+ but cannot yet be easily persisted. The code that synchronizes
+ the attribute with the CSS lives in
+ toolkit/content/widget/tree.js and is specific to tree elements.
+ -->
+ <treecol is="treecol-image" id="selectCol"
+ class="thread-tree-icon-header selectColumnHeader"
+ persist="hidden ordinal"
+ fixed="true"
+ cycler="true"
+ currentView="unthreaded"
+ hidden="true"
+ closemenu="none"
+ src="chrome://messenger/skin/icons/new/compact/checkbox.svg"
+ label="&selectColumn.label;"
+ tooltiptext="&selectColumn.tooltip;"/>
+ <splitter class="tree-splitter"/>
+ <treecol is="treecol-image" id="threadCol"
+ class="thread-tree-icon-header threadColumnHeader"
+ persist="hidden ordinal"
+ fixed="true"
+ cycler="true"
+ currentView="unthreaded"
+#ifdef SEARCH_WINDOW
+ ignoreincolumnpicker="true"
+ hidden="true"
+#endif
+ closemenu="none"
+ src="chrome://messenger/skin/icons/new/thread-sm.svg"
+ label="&threadColumn.label;"
+ tooltiptext="&threadColumn2.tooltip;"/>
+ <splitter class="tree-splitter"/>
+ <treecol is="treecol-image" id="flaggedCol"
+ class="thread-tree-icon-header flagColumnHeader"
+ persist="hidden ordinal sortDirection"
+ fixed="true"
+ cycler="true"
+ closemenu="none"
+ src="chrome://messenger/skin/icons/new/star-sm.svg"
+ label="&starredColumn.label;"
+ tooltiptext="&starredColumn2.tooltip;"/>
+ <splitter class="tree-splitter"/>
+ <treecol is="treecol-image" id="attachmentCol"
+ class="thread-tree-icon-header attachmentColumnHeader"
+ persist="hidden ordinal sortDirection"
+ fixed="true"
+ closemenu="none"
+ src="chrome://messenger/skin/icons/new/attachment-sm.svg"
+ label="&attachmentColumn.label;"
+ tooltiptext="&attachmentColumn2.tooltip;"/>
+ <splitter class="tree-splitter"/>
+ <treecol id="subjectCol"
+ persist="ordinal width sortDirection"
+ ignoreincolumnpicker="true"
+ closemenu="none"
+ label="&subjectColumn.label;"
+ tooltiptext="&subjectColumn2.tooltip;"/>
+ <splitter class="tree-splitter"/>
+ <treecol is="treecol-image" id="unreadButtonColHeader"
+ class="thread-tree-icon-header readColumnHeader"
+ persist="hidden ordinal sortDirection"
+ fixed="true"
+ cycler="true"
+ closemenu="none"
+ src="chrome://messenger/skin/icons/new/unread-sm.svg"
+ label="&readColumn.label;"
+ tooltiptext="&readColumn2.tooltip;"/>
+ <splitter class="tree-splitter"/>
+ <treecol id="senderCol"
+ persist="hidden ordinal sortDirection width"
+ hidden="true"
+ closemenu="none"
+ label="&fromColumn.label;"
+ tooltiptext="&fromColumn2.tooltip;"/>
+ <splitter class="tree-splitter"/>
+ <treecol id="recipientCol"
+ persist="hidden ordinal sortDirection width"
+ hidden="true"
+ closemenu="none"
+ label="&recipientColumn.label;"
+ tooltiptext="&recipientColumn2.tooltip;"/>
+ <splitter class="tree-splitter"/>
+ <treecol id="correspondentCol"
+ persist="hidden ordinal sortDirection width"
+ closemenu="none"
+ label="&correspondentColumn.label;"
+ tooltiptext="&correspondentColumn2.tooltip;"/>
+ <splitter class="tree-splitter"/>
+ <treecol is="treecol-image" id="junkStatusCol"
+ class="thread-tree-icon-header junkStatusHeader"
+ persist="hidden ordinal sortDirection"
+ fixed="true"
+ cycler="true"
+ closemenu="none"
+ src="chrome://messenger/skin/icons/new/spam-sm.svg"
+ label="&junkStatusColumn.label;"
+ tooltiptext="&junkStatusColumn2.tooltip;"/>
+ <splitter class="tree-splitter"/>
+ <treecol id="receivedCol"
+ persist="hidden ordinal sortDirection width"
+ hidden="true"
+ closemenu="none"
+ label="&receivedColumn.label;"
+ tooltiptext="&receivedColumn2.tooltip;"/>
+ <splitter class="tree-splitter"/>
+ <treecol id="dateCol"
+ persist="hidden ordinal sortDirection width"
+ closemenu="none"
+ label="&dateColumn.label;"
+ tooltiptext="&dateColumn2.tooltip;"/>
+ <splitter class="tree-splitter"/>
+ <treecol id="statusCol"
+ persist="hidden ordinal sortDirection width"
+ style="flex: 1 auto"
+ hidden="true"
+ closemenu="none"
+ label="&statusColumn.label;"
+ tooltiptext="&statusColumn2.tooltip;"/>
+ <splitter class="tree-splitter"/>
+ <treecol id="sizeCol"
+ persist="hidden ordinal sortDirection width"
+ style="flex: 1 auto"
+ hidden="true"
+ closemenu="none"
+ label="&sizeColumn.label;"
+ tooltiptext="&sizeColumn2.tooltip;"/>
+ <splitter class="tree-splitter"/>
+ <treecol id="tagsCol"
+ persist="hidden ordinal sortDirection width"
+ style="flex: 1 auto"
+ hidden="true"
+ closemenu="none"
+ label="&tagsColumn.label;"
+ tooltiptext="&tagsColumn2.tooltip;"/>
+ <splitter class="tree-splitter"/>
+ <treecol id="accountCol"
+ persist="hidden ordinal sortDirection width"
+ style="flex: 1 auto"
+ hidden="true"
+ closemenu="none"
+ label="&accountColumn.label;"
+ tooltiptext="&accountColumn2.tooltip;"/>
+ <splitter class="tree-splitter"/>
+ <treecol id="priorityCol"
+ persist="hidden ordinal sortDirection width"
+ style="flex: 1 auto"
+ hidden="true"
+ closemenu="none"
+ label="&priorityColumn.label;"
+ tooltiptext="&priorityColumn2.tooltip;"/>
+ <splitter class="tree-splitter"/>
+ <treecol id="unreadCol"
+ persist="hidden ordinal sortDirection width"
+ style="flex: 1 auto"
+ hidden="true"
+ closemenu="none"
+ label="&unreadColumn.label;"
+ tooltiptext="&unreadColumn2.tooltip;"/>
+ <splitter class="tree-splitter"/>
+ <treecol id="totalCol"
+ persist="hidden ordinal sortDirection width"
+ style="flex: 1 auto"
+ hidden="true"
+ closemenu="none"
+ label="&totalColumn.label;"
+ tooltiptext="&totalColumn2.tooltip;"/>
+ <splitter class="tree-splitter"/>
+ <treecol id="locationCol"
+ persist="hidden ordinal sortDirection width"
+ style="flex: 1 auto"
+ closemenu="none"
+ label="&locationColumn.label;"
+ tooltiptext="&locationColumn2.tooltip;"/>
+ <splitter class="tree-splitter"/>
+ <treecol id="idCol"
+ persist="hidden ordinal sortDirection width"
+ style="flex: 1 auto"
+ hidden="true"
+ closemenu="none"
+ label="&idColumn.label;"
+ tooltiptext="&idColumn2.tooltip;"/>
+ <splitter class="tree-splitter"/>
+ <treecol is="treecol-image" id="deleteCol"
+ class="thread-tree-icon-header deleteColumnHeader"
+ persist="hidden ordinal"
+ fixed="true"
+ cycler="true"
+ currentView="unthreaded"
+ hidden="true"
+ closemenu="none"
+ src="chrome://messenger/skin/icons/new/trash-sm.svg"
+ label="&deleteColumn.label;"
+ tooltiptext="&deleteColumn.tooltip;"/>
+ </treecols>
+#ifdef MAIN_WINDOW
+ <treechildren ondragstart="ThreadPaneOnDragStart(event);"
+ ondragover="ThreadPaneOnDragOver(event);"
+ ondrop="ThreadPaneOnDrop(event);"/>
+#else
+ <treechildren ondragstart="ThreadPaneOnDragStart(event);"/>
+#endif
+ </tree>
diff --git a/comm/mail/base/content/toolbarIconColor.js b/comm/mail/base/content/toolbarIconColor.js
new file mode 100644
index 0000000000..591c86096d
--- /dev/null
+++ b/comm/mail/base/content/toolbarIconColor.js
@@ -0,0 +1,166 @@
+/**
+ * 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/. */
+
+var { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+
+var ToolbarIconColor = {
+ _windowState: {
+ active: false,
+ fullscreen: false,
+ tabsintitlebar: false,
+ },
+
+ init() {
+ this._initialized = true;
+
+ window.addEventListener("activate", this);
+ window.addEventListener("deactivate", this);
+ window.addEventListener("toolbarvisibilitychange", this);
+ window.addEventListener("windowlwthemeupdate", this);
+
+ // If the window isn't active now, we assume that it has never been active
+ // before and will soon become active such that inferFromText will be
+ // called from the initial activate event.
+ if (Services.focus.activeWindow == window) {
+ this.inferFromText("activate");
+ }
+ },
+
+ uninit() {
+ this._initialized = false;
+
+ window.removeEventListener("activate", this);
+ window.removeEventListener("deactivate", this);
+ window.removeEventListener("toolbarvisibilitychange", this);
+ window.removeEventListener("windowlwthemeupdate", this);
+ },
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "activate":
+ case "deactivate":
+ case "windowlwthemeupdate":
+ this.inferFromText(event.type);
+ break;
+ case "toolbarvisibilitychange":
+ this.inferFromText(event.type, event.visible);
+ break;
+ }
+ },
+
+ // A cache of luminance values for each toolbar to avoid unnecessary calls to
+ // getComputedStyle().
+ _toolbarLuminanceCache: new Map(),
+
+ // A cache of the current sidebar color to avoid unnecessary conditions and
+ // luminance calculations.
+ _sidebarColorCache: null,
+
+ inferFromText(reason, reasonValue) {
+ if (!this._initialized) {
+ return;
+ }
+
+ function parseRGB(aColorString) {
+ let rgb = aColorString.match(/^rgba?\((\d+), (\d+), (\d+)/);
+ rgb.shift();
+ return rgb.map(x => parseInt(x));
+ }
+
+ switch (reason) {
+ case "activate": // falls through.
+ case "deactivate":
+ this._windowState.active = reason === "activate";
+ break;
+ case "fullscreen":
+ this._windowState.fullscreen = reasonValue;
+ break;
+ case "windowlwthemeupdate":
+ // Theme change, we'll need to recalculate all color values.
+ this._toolbarLuminanceCache.clear();
+ this._sidebarColorCache = null;
+ break;
+ case "toolbarvisibilitychange":
+ // Toolbar changes dont require reset of the cached color values.
+ break;
+ case "tabsintitlebar":
+ this._windowState.tabsintitlebar = reasonValue;
+ break;
+ }
+
+ let toolbarSelector = "toolbox > toolbar:not([collapsed=true])";
+ if (AppConstants.platform == "macosx") {
+ toolbarSelector += ":not([type=menubar])";
+ }
+ toolbarSelector += ", .toolbar";
+
+ // The getComputedStyle calls and setting the brighttext are separated in
+ // two loops to avoid flushing layout and making it dirty repeatedly.
+ let cachedLuminances = this._toolbarLuminanceCache;
+ let luminances = new Map();
+ for (let toolbar of document.querySelectorAll(toolbarSelector)) {
+ // Toolbars *should* all have ids, but guard anyway to avoid blowing up.
+ let cacheKey =
+ toolbar.id && toolbar.id + JSON.stringify(this._windowState);
+ // Lookup cached luminance value for this toolbar in this window state.
+ let luminance = cacheKey && cachedLuminances.get(cacheKey);
+ if (isNaN(luminance)) {
+ let [r, g, b] = parseRGB(getComputedStyle(toolbar).color);
+ luminance = 0.2125 * r + 0.7154 * g + 0.0721 * b;
+ if (cacheKey) {
+ cachedLuminances.set(cacheKey, luminance);
+ }
+ }
+ luminances.set(toolbar, luminance);
+ }
+
+ const luminanceThreshold = 127; // In between 0 and 255
+ for (let [toolbar, luminance] of luminances) {
+ if (luminance <= luminanceThreshold) {
+ toolbar.removeAttribute("brighttext");
+ } else {
+ toolbar.setAttribute("brighttext", "true");
+ }
+ }
+
+ // On Linux, we need to detect if the OS theme caused a text color change in
+ // the sidebar icons and properly update the brighttext attribute.
+ if (
+ reason == "activate" &&
+ AppConstants.platform == "linux" &&
+ Services.prefs.getCharPref("extensions.activeThemeID", "") ==
+ "default-theme@mozilla.org"
+ ) {
+ let folderTree = document.getElementById("folderTree");
+ if (!folderTree) {
+ return;
+ }
+
+ let sidebarColor = getComputedStyle(folderTree).color;
+ // Interrupt if the sidebar color didn't change.
+ if (sidebarColor == this._sidebarColorCache) {
+ return;
+ }
+
+ this._sidebarColorCache = sidebarColor;
+
+ let mainWindow = document.getElementById("messengerWindow");
+ if (!mainWindow) {
+ return;
+ }
+
+ let [r, g, b] = parseRGB(sidebarColor);
+ let luminance = 0.2125 * r + 0.7154 * g + 0.0721 * b;
+
+ if (luminance <= 110) {
+ mainWindow.removeAttribute("lwt-tree-brighttext");
+ } else {
+ mainWindow.setAttribute("lwt-tree-brighttext", "true");
+ }
+ }
+ },
+};
diff --git a/comm/mail/base/content/troubleshootMode.js b/comm/mail/base/content/troubleshootMode.js
new file mode 100644
index 0000000000..79343f3004
--- /dev/null
+++ b/comm/mail/base/content/troubleshootMode.js
@@ -0,0 +1,74 @@
+/* -*- Mode: JavaScript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* 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/. */
+
+var { XPIDatabase } = ChromeUtils.import(
+ "resource://gre/modules/addons/XPIDatabase.jsm"
+);
+
+function restartApp() {
+ Services.startup.quit(
+ Services.startup.eForceQuit | Services.startup.eRestart
+ );
+}
+
+function deleteLocalstore() {
+ // Delete the xulstore file.
+ let xulstoreFile = Services.dirsvc.get("ProfD", Ci.nsIFile);
+ xulstoreFile.append("xulstore.json");
+ if (xulstoreFile.exists()) {
+ xulstoreFile.remove(false);
+ }
+}
+
+async function disableAddons() {
+ XPIDatabase.syncLoadDB(false);
+ let addons = XPIDatabase.getAddons();
+ for (let addon of addons) {
+ if (addon.type == "theme") {
+ // Setting userDisabled to false on the default theme activates it,
+ // disables all other themes and deactivates the applied persona, if
+ // any.
+ const DEFAULT_THEME_ID = "default-theme@mozilla.org";
+ if (addon.id == DEFAULT_THEME_ID) {
+ await XPIDatabase.updateAddonDisabledState(addon, {
+ userDisabled: false,
+ });
+ }
+ } else {
+ await XPIDatabase.updateAddonDisabledState(addon, { userDisabled: true });
+ }
+ }
+}
+
+async function onOK(event) {
+ event.preventDefault();
+ if (document.getElementById("resetToolbars").checked) {
+ deleteLocalstore();
+ }
+ if (document.getElementById("disableAddons").checked) {
+ await disableAddons();
+ }
+ restartApp();
+}
+
+function onCancel() {
+ Services.startup.quit(Services.startup.eForceQuit);
+}
+
+function onLoad() {
+ document
+ .getElementById("tasks")
+ .addEventListener("CheckboxStateChange", updateOKButtonState);
+
+ document.addEventListener("dialogaccept", onOK);
+ document.addEventListener("dialogcancel", onCancel);
+ document.addEventListener("dialogextra1", () => window.close());
+}
+
+function updateOKButtonState() {
+ document.querySelector("dialog").getButton("accept").disabled =
+ !document.getElementById("resetToolbars").checked &&
+ !document.getElementById("disableAddons").checked;
+}
diff --git a/comm/mail/base/content/troubleshootMode.xhtml b/comm/mail/base/content/troubleshootMode.xhtml
new file mode 100644
index 0000000000..767adced2c
--- /dev/null
+++ b/comm/mail/base/content/troubleshootMode.xhtml
@@ -0,0 +1,54 @@
+<?xml version="1.0"?>
+<!-- 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/. -->
+
+<!DOCTYPE window [ <!ENTITY % utilityDTD SYSTEM "chrome://communicator/locale/utilityOverlay.dtd">
+%utilityDTD; ]>
+
+<?xml-stylesheet href="chrome://global/skin/global.css"?>
+
+<window
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ data-l10n-id="troubleshoot-mode-window"
+ data-l10n-attrs="title,style"
+ onload="onLoad();"
+>
+ <dialog
+ id="safeModeDialog"
+ style="width: inherit"
+ buttons="accept,cancel,extra1"
+ buttonidaccept="troubleshoot-mode-change-and-restart"
+ buttonidcancel="troubleshoot-mode-quit"
+ buttonidextra1="troubleshoot-mode-continue"
+ buttondisabledaccept="true"
+ >
+ <script src="chrome://messenger/content/troubleshootMode.js" />
+
+ <linkset>
+ <html:link rel="localization" href="branding/brand.ftl" />
+ <html:link rel="localization" href="messenger/troubleshootMode.ftl" />
+ </linkset>
+
+ <vbox>
+ <description data-l10n-id="troubleshoot-mode-description" />
+
+ <separator class="thin" />
+
+ <label data-l10n-id="troubleshoot-mode-description2" />
+ <vbox id="tasks">
+ <checkbox
+ id="disableAddons"
+ data-l10n-id="troubleshoot-mode-disable-addons"
+ />
+ <checkbox
+ id="resetToolbars"
+ data-l10n-id="troubleshoot-mode-reset-toolbars"
+ />
+ </vbox>
+ </vbox>
+
+ <separator class="thin" />
+ </dialog>
+</window>
diff --git a/comm/mail/base/content/utilityOverlay.js b/comm/mail/base/content/utilityOverlay.js
new file mode 100644
index 0000000000..b0593647e1
--- /dev/null
+++ b/comm/mail/base/content/utilityOverlay.js
@@ -0,0 +1,514 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals goUpdateCommand */ // From globalOverlay.js
+
+var { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+var { PlacesUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/PlacesUtils.sys.mjs"
+);
+
+var gShowBiDi = false;
+
+function getBrowserURL() {
+ return AppConstants.BROWSER_CHROME_URL;
+}
+
+// update menu items that rely on focus
+function goUpdateGlobalEditMenuItems() {
+ goUpdateCommand("cmd_undo");
+ goUpdateCommand("cmd_redo");
+ goUpdateCommand("cmd_cut");
+ goUpdateCommand("cmd_copy");
+ goUpdateCommand("cmd_paste");
+ goUpdateCommand("cmd_selectAll");
+ goUpdateCommand("cmd_delete");
+ if (gShowBiDi) {
+ goUpdateCommand("cmd_switchTextDirection");
+ }
+}
+
+// update menu items that rely on the current selection
+function goUpdateSelectEditMenuItems() {
+ goUpdateCommand("cmd_cut");
+ goUpdateCommand("cmd_copy");
+ goUpdateCommand("cmd_delete");
+ goUpdateCommand("cmd_selectAll");
+}
+
+// update menu items that relate to undo/redo
+function goUpdateUndoEditMenuItems() {
+ goUpdateCommand("cmd_undo");
+ goUpdateCommand("cmd_redo");
+}
+
+// update menu items that depend on clipboard contents
+function goUpdatePasteMenuItems() {
+ goUpdateCommand("cmd_paste");
+}
+
+// update Find As You Type menu items, they rely on focus
+function goUpdateFindTypeMenuItems() {
+ goUpdateCommand("cmd_findTypeText");
+ goUpdateCommand("cmd_findTypeLinks");
+}
+
+/**
+ * Gather all descendent text under given node.
+ *
+ * @param {Node} root - The root node to gather text from.
+ * @returns {string} The text data under the node.
+ */
+function gatherTextUnder(root) {
+ var text = "";
+ var node = root.firstChild;
+ var depth = 1;
+ while (node && depth > 0) {
+ // See if this node is text.
+ if (node.nodeType == Node.TEXT_NODE) {
+ // Add this text to our collection.
+ text += " " + node.data;
+ } else if (HTMLImageElement.isInstance(node)) {
+ // If it has an alt= attribute, add that.
+ var altText = node.getAttribute("alt");
+ if (altText && altText != "") {
+ text += " " + altText;
+ }
+ }
+ // Find next node to test.
+ if (node.firstChild) {
+ // If it has children, go to first child.
+ node = node.firstChild;
+ depth++;
+ } else if (node.nextSibling) {
+ // No children, try next sibling.
+ node = node.nextSibling;
+ } else {
+ // Last resort is a sibling of an ancestor.
+ while (node && depth > 0) {
+ node = node.parentNode;
+ depth--;
+ if (node.nextSibling) {
+ node = node.nextSibling;
+ break;
+ }
+ }
+ }
+ }
+ // Strip leading and trailing whitespace.
+ text = text.trim();
+ // Compress remaining whitespace.
+ text = text.replace(/\s+/g, " ");
+ return text;
+}
+
+function GenerateValidFilename(filename, extension) {
+ if (filename) {
+ // we have a title; let's see if it's usable
+ // clean up the filename to make it usable and
+ // then trim whitespace from beginning and end
+ filename = validateFileName(filename).trim();
+ if (filename.length > 0) {
+ return filename + extension;
+ }
+ }
+ return null;
+}
+
+function validateFileName(aFileName) {
+ var re = /[\/]+/g;
+ if (navigator.appVersion.includes("Windows")) {
+ re = /[\\\/\|]+/g;
+ aFileName = aFileName.replace(/[\"]+/g, "'");
+ aFileName = aFileName.replace(/[\*\:\?]+/g, " ");
+ aFileName = aFileName.replace(/[\<]+/g, "(");
+ aFileName = aFileName.replace(/[\>]+/g, ")");
+ } else if (navigator.appVersion.includes("Macintosh")) {
+ re = /[\:\/]+/g;
+ }
+
+ if (
+ Services.prefs.getBoolPref("mail.save_msg_filename_underscores_for_space")
+ ) {
+ aFileName = aFileName.replace(/ /g, "_");
+ }
+
+ return aFileName.replace(re, "_");
+}
+
+function goToggleToolbar(id, elementID) {
+ var toolbar = document.getElementById(id);
+ var element = document.getElementById(elementID);
+ if (toolbar) {
+ const isHidden = toolbar.getAttribute("hidden") === "true";
+ toolbar.setAttribute("hidden", !isHidden);
+ Services.xulStore.persist(toolbar, "hidden");
+ if (element) {
+ element.setAttribute("checked", isHidden);
+ Services.xulStore.persist(element, "checked");
+ }
+ }
+}
+
+/**
+ * Toggle a splitter to show or hide some piece of UI (e.g. the message preview
+ * pane).
+ *
+ * @param splitterId the splliter that should be toggled
+ */
+function togglePaneSplitter(splitterId) {
+ var splitter = document.getElementById(splitterId);
+ var state = splitter.getAttribute("state");
+ if (state == "collapsed") {
+ splitter.setAttribute("state", "open");
+ } else {
+ splitter.setAttribute("state", "collapsed");
+ }
+}
+
+// openUILink handles clicks on UI elements that cause URLs to load.
+// We currently only react to left click in Thunderbird.
+function openUILink(url, event) {
+ if (!event.button) {
+ PlacesUtils.history
+ .insert({
+ url,
+ visits: [
+ {
+ date: new Date(),
+ },
+ ],
+ })
+ .catch(console.error);
+ Cc["@mozilla.org/uriloader/external-protocol-service;1"]
+ .getService(Ci.nsIExternalProtocolService)
+ .loadURI(Services.io.newURI(url));
+ }
+}
+
+function openLinkText(event, what) {
+ switch (what) {
+ case "getInvolvedURL":
+ openUILink("https://www.thunderbird.net/get-involved/", event);
+ break;
+ case "keyboardShortcutsURL":
+ openUILink("https://support.mozilla.org/kb/keyboard-shortcuts/", event);
+ break;
+ case "donateURL":
+ openUILink(
+ "https://give.thunderbird.net/?utm_source=thunderbird-client&utm_medium=referral&utm_content=help-menu",
+ event
+ );
+ break;
+ case "tourURL":
+ openUILink("https://www.thunderbird.net/features/", event);
+ break;
+ case "feedbackURL":
+ openUILink("https://connect.mozilla.org/", event);
+ break;
+ }
+}
+
+/**
+ * Open a web search in the default browser for a given query.
+ *
+ * @param query the string to search for
+ * @param engine (optional) the search engine to use
+ */
+function openWebSearch(query, engine) {
+ return Services.search.init().then(async () => {
+ if (!engine) {
+ engine = await Services.search.getDefault();
+ openLinkExternally(engine.getSubmission(query).uri.spec);
+
+ Services.telemetry.keyedScalarAdd(
+ "tb.websearch.usage",
+ engine.name.toLowerCase(),
+ 1
+ );
+ }
+ });
+}
+
+/**
+ * Open the specified tab type (possibly in a new window)
+ *
+ * @param tabType the tab type to open (e.g. "contentTab")
+ * @param tabParams the parameters to pass to the tab
+ * @param where 'tab' to open in a new tab (default) or 'window' to open in a
+ * new window
+ */
+function openTab(tabType, tabParams, where) {
+ if (where != "window") {
+ let tabmail = document.getElementById("tabmail");
+ if (!tabmail) {
+ // Try opening new tabs in an existing 3pane window
+ let mail3PaneWindow = Services.wm.getMostRecentWindow("mail:3pane");
+ if (mail3PaneWindow) {
+ tabmail = mail3PaneWindow.document.getElementById("tabmail");
+ mail3PaneWindow.focus();
+ }
+ }
+
+ if (tabmail) {
+ return tabmail.openTab(tabType, tabParams);
+ }
+ }
+
+ // Either we explicitly wanted to open in a new window, or we fell through to
+ // here because there's no 3pane.
+ return window.openDialog(
+ "chrome://messenger/content/messenger.xhtml",
+ "_blank",
+ "chrome,dialog=no,all",
+ null,
+ {
+ tabType,
+ tabParams,
+ }
+ );
+}
+
+/**
+ * Open the specified URL as a content tab (or window)
+ *
+ * @param {string} url - The location to open.
+ * @param {string} [where="tab"] - 'tab' to open in a new tab or 'window' to
+ * open in a new window
+ * @param {string} [linkHandler] - See specialTabs.contentTabType.openTab.
+ */
+function openContentTab(url, where, linkHandler) {
+ return openTab("contentTab", { url, linkHandler }, where);
+}
+
+/**
+ * Open the preferences page for the specified query in a new tab.
+ *
+ * @param paneID ID of prefpane to select automatically.
+ * @param scrollPaneTo ID of the element to scroll into view.
+ * @param otherArgs other prefpane specific arguments.
+ */
+function openPreferencesTab(paneID, scrollPaneTo, otherArgs) {
+ openTab("preferencesTab", {
+ paneID,
+ scrollPaneTo,
+ otherArgs,
+ onLoad(aEvent, aBrowser) {
+ aBrowser.contentWindow.selectPrefPane(paneID, scrollPaneTo, otherArgs);
+ },
+ });
+}
+
+/**
+ * Open the dictionary list in a new content tab, if possible in an available
+ * mail:3pane window, otherwise by opening a new mail:3pane.
+ *
+ * @param where the context to open the dictionary list in (e.g. 'tab',
+ * 'window'). See openContentTab for more details.
+ */
+function openDictionaryList(where) {
+ let dictUrl = Services.urlFormatter.formatURLPref(
+ "spellchecker.dictionaries.download.url"
+ );
+
+ openContentTab(dictUrl, where);
+}
+
+/**
+ * Open the privacy policy in a new content tab, if possible in an available
+ * mail:3pane window, otherwise by opening a new mail:3pane.
+ *
+ * @param where the context to open the privacy policy in (e.g. 'tab',
+ * 'window'). See openContentTab for more details.
+ */
+function openPrivacyPolicy(where) {
+ const kTelemetryInfoUrl = "toolkit.telemetry.infoURL";
+ let url = Services.prefs.getCharPref(kTelemetryInfoUrl);
+ openContentTab(url, where);
+}
+
+/**
+ * Used by the developer tools (in the toolbox process) and a few toolkit pages
+ * for opening URLs.
+ *
+ * Thunderbird code should avoid using this function.
+ *
+ * This is similar, but not identical, to the same function in Firefox.
+ *
+ * @param {string} url - The URL to load.
+ * @param {string} [where] - Ignored, only here for compatibility.
+ * @param {object} [openParams] - Optional parameters for changing behaviour.
+ */
+function openTrustedLinkIn(url, where, params = {}) {
+ if (!params.triggeringPrincipal) {
+ params.triggeringPrincipal =
+ Services.scriptSecurityManager.getSystemPrincipal();
+ }
+
+ openLinkIn(url, where, params);
+}
+
+/**
+ * Used by the developer tools (in the toolbox process) for opening URLs.
+ * MDN URLs get send to a browser, all others are displayed in a new window.
+ *
+ * Thunderbird code should avoid using this function.
+ *
+ * This is similar, but not identical, to the same function in Firefox.
+ *
+ * @param {string} url - The URL to load.
+ * @param {string} [where] - Ignored, only here for compatibility.
+ * @param {object} [openParams] - Optional parameters for changing behaviour.
+ */
+function openWebLinkIn(url, where, params = {}) {
+ if (url.startsWith("https://developer.mozilla.org/")) {
+ openLinkExternally(url);
+ return;
+ }
+
+ if (!params.triggeringPrincipal) {
+ params.triggeringPrincipal =
+ Services.scriptSecurityManager.createNullPrincipal({});
+ }
+ if (params.triggeringPrincipal.isSystemPrincipal) {
+ throw new Error(
+ "System principal should never be passed into openWebLinkIn()"
+ );
+ }
+
+ openLinkIn(url, where, params);
+}
+
+// Thunderbird itself is not using this function. It is however called for the
+// "contribute" button for add-ons in the add-on manager. We ignore all additional
+// parameters including "where" and always open the link externally. We don't
+// want to open donation pages in a tab due to their complexity, and we don't
+// want to handle them inside Thunderbird.
+function openUILinkIn(
+ url,
+ where,
+ aAllowThirdPartyFixup,
+ aPostData,
+ aReferrerInfo
+) {
+ openLinkExternally(url);
+}
+
+/**
+ * Loads a URL in Thunderbird. If this is a mail:3pane window, the URL opens
+ * in a content tab, otherwise a new window is opened.
+ *
+ * This is similar, but not identical, to the same function in Firefox.
+ *
+ * @param {string} url - The URL to load.
+ * @param {string} [where] - Ignored, only here for compatibility.
+ * @param {object} [openParams] - Optional parameters for changing behaviour.
+ */
+function openLinkIn(url, where, openParams) {
+ if (!url) {
+ return;
+ }
+
+ if ("switchToTabHavingURI" in window) {
+ window.switchToTabHavingURI(url, true);
+ return;
+ }
+
+ // If we get here, this isn't a mail:3pane window, which means it's probably
+ // the developer tools window and therefore a completely separate program
+ // from the rest of Thunderbird. Be careful what you do here.
+
+ let args = Cc["@mozilla.org/array;1"].createInstance(Ci.nsIMutableArray);
+ let uri = Cc["@mozilla.org/supports-string;1"].createInstance(
+ Ci.nsISupportsString
+ );
+ uri.data = url;
+ args.appendElement(uri);
+
+ let win = Services.ww.openWindow(
+ window,
+ AppConstants.BROWSER_CHROME_URL,
+ null,
+ "chrome,dialog=no,all",
+ args
+ );
+
+ if (openParams.resolveOnContentBrowserCreated) {
+ win.addEventListener("load", () =>
+ openParams.resolveOnContentBrowserCreated(win.gBrowser.selectedBrowser)
+ );
+ }
+}
+
+/**
+ * Forces a url to open in an external application according to the protocol
+ * service settings.
+ *
+ * @param url A url string or an nsIURI containing the url to open.
+ */
+function openLinkExternally(url) {
+ let uri = url;
+ if (!(uri instanceof Ci.nsIURI)) {
+ uri = Services.io.newURI(url);
+ }
+
+ // This can fail if there is a problem with the places database.
+ PlacesUtils.history
+ .insert({
+ url, // accepts both string and nsIURI
+ visits: [
+ {
+ date: new Date(),
+ },
+ ],
+ })
+ .catch(console.error);
+
+ Cc["@mozilla.org/uriloader/external-protocol-service;1"]
+ .getService(Ci.nsIExternalProtocolService)
+ .loadURI(uri);
+}
+
+/**
+ * Moved from toolkit/content/globalOverlay.js.
+ * For details see bug 1422720 and bug 1422721.
+ */
+function goSetMenuValue(aCommand, aLabelAttribute) {
+ var commandNode = top.document.getElementById(aCommand);
+ if (commandNode) {
+ var label = commandNode.getAttribute(aLabelAttribute);
+ if (label) {
+ commandNode.setAttribute("label", label);
+ }
+ }
+}
+
+function goSetAccessKey(aCommand, aAccessKeyAttribute) {
+ var commandNode = top.document.getElementById(aCommand);
+ if (commandNode) {
+ var value = commandNode.getAttribute(aAccessKeyAttribute);
+ if (value) {
+ commandNode.setAttribute("accesskey", value);
+ }
+ }
+}
+
+function buildHelpMenu() {
+ let helpTroubleshootModeItem = document.getElementById(
+ "helpTroubleshootMode"
+ );
+ if (helpTroubleshootModeItem) {
+ helpTroubleshootModeItem.disabled =
+ !Services.policies.isAllowed("safeMode");
+ }
+ let appmenu_troubleshootModeItem = document.getElementById(
+ "appmenu_troubleshootMode"
+ );
+ if (appmenu_troubleshootModeItem) {
+ appmenu_troubleshootModeItem.disabled =
+ !Services.policies.isAllowed("safeMode");
+ }
+}
diff --git a/comm/mail/base/content/viewSource.js b/comm/mail/base/content/viewSource.js
new file mode 100644
index 0000000000..8fd3a9ccde
--- /dev/null
+++ b/comm/mail/base/content/viewSource.js
@@ -0,0 +1,168 @@
+// -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
+
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals gViewSourceUtils, internalSave, ZoomManager */
+
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+XPCOMUtils.defineLazyScriptGetter(
+ this,
+ "PrintUtils",
+ "chrome://messenger/content/printUtils.js"
+);
+
+// Needed for printing.
+window.browserDOMWindow = window.opener.browserDOMWindow;
+
+var gBrowser;
+addEventListener("load", () => {
+ gBrowser = document.getElementById("content");
+ gBrowser.getTabForBrowser = () => {
+ return null;
+ };
+ gBrowser.addEventListener("pagetitlechanged", () => {
+ document.title =
+ document.documentElement.getAttribute("titlepreface") +
+ gBrowser.contentTitle +
+ document.documentElement.getAttribute("titlemenuseparator") +
+ document.documentElement.getAttribute("titlemodifier");
+ });
+
+ if (Services.prefs.getBoolPref("view_source.wrap_long_lines", false)) {
+ document
+ .getElementById("cmd_wrapLongLines")
+ .setAttribute("checked", "true");
+ }
+
+ gViewSourceUtils.viewSourceInBrowser({
+ ...window.arguments[0],
+ viewSourceBrowser: gBrowser,
+ });
+ gBrowser.contentWindow.focus();
+
+ document
+ .getElementById("repair-text-encoding")
+ .setAttribute("disabled", !gBrowser.mayEnableCharacterEncodingMenu);
+ gBrowser.addEventListener(
+ "load",
+ () => {
+ document
+ .getElementById("repair-text-encoding")
+ .setAttribute("disabled", !gBrowser.mayEnableCharacterEncodingMenu);
+ },
+ true
+ );
+
+ gBrowser.addEventListener(
+ "DoZoomEnlargeBy10",
+ () => {
+ ZoomManager.scrollZoomEnlarge(gBrowser);
+ },
+ true
+ );
+ gBrowser.addEventListener(
+ "DoZoomReduceBy10",
+ () => {
+ ZoomManager.scrollReduceEnlarge(gBrowser);
+ },
+ true
+ );
+});
+
+var viewSourceChrome = {
+ promptAndGoToLine() {
+ let actor = gViewSourceUtils.getViewSourceActor(gBrowser.browsingContext);
+ actor.manager.getActor("ViewSourcePage").promptAndGoToLine();
+ },
+
+ toggleWrapping() {
+ let state = gBrowser.contentDocument.body.classList.toggle("wrap");
+ if (state) {
+ document
+ .getElementById("cmd_wrapLongLines")
+ .setAttribute("checked", "true");
+ } else {
+ document.getElementById("cmd_wrapLongLines").removeAttribute("checked");
+ }
+ Services.prefs.setBoolPref("view_source.wrap_long_lines", state);
+ },
+
+ /**
+ * Called by clicks on a menuitem to force the character set detection.
+ */
+ onForceCharacterSet() {
+ gBrowser.forceEncodingDetection();
+ gBrowser.reloadWithFlags(Ci.nsIWebNavigation.LOAD_FLAGS_CHARSET_CHANGE);
+ },
+
+ /**
+ * Reloads the browser, bypassing the network cache.
+ */
+ reload() {
+ gBrowser.reloadWithFlags(
+ Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_PROXY |
+ Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_CACHE
+ );
+ },
+};
+
+// viewZoomOverlay.js uses this
+function getBrowser() {
+ return gBrowser;
+}
+
+// Strips the |view-source:| for internalSave()
+function ViewSourceSavePage() {
+ internalSave(
+ gBrowser.currentURI.spec.replace(/^view-source:/i, ""),
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ "SaveLinkTitle",
+ null,
+ null,
+ gBrowser.cookieJarSettings,
+ gBrowser.contentDocument,
+ null,
+ gBrowser.webNavigation.QueryInterface(Ci.nsIWebPageDescriptor),
+ null,
+ Services.scriptSecurityManager.getSystemPrincipal()
+ );
+}
+
+/** Called by ContextMenuParent.sys.mjs */
+function openContextMenu({ data }, browser, actor) {
+ let popup = browser.ownerDocument.getElementById("viewSourceContextMenu");
+
+ let newEvent = document.createEvent("MouseEvent");
+ let screenX = data.context.screenXDevPx / window.devicePixelRatio;
+ let screenY = data.context.screenYDevPx / window.devicePixelRatio;
+ newEvent.initNSMouseEvent(
+ "contextmenu",
+ true,
+ true,
+ null,
+ 0,
+ screenX,
+ screenY,
+ 0,
+ 0,
+ false,
+ false,
+ false,
+ false,
+ 2,
+ null,
+ 0,
+ data.context.mozInputSource
+ );
+ popup.openPopupAtScreen(newEvent.screenX, newEvent.screenY, true, newEvent);
+}
diff --git a/comm/mail/base/content/viewSource.xhtml b/comm/mail/base/content/viewSource.xhtml
new file mode 100644
index 0000000000..046ad41937
--- /dev/null
+++ b/comm/mail/base/content/viewSource.xhtml
@@ -0,0 +1,245 @@
+<?xml version="1.0"?>
+# -*- Mode: HTML -*-
+# 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/.
+
+<?xml-stylesheet href="chrome://messenger/skin/messenger.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/contextMenu.css" type="text/css"?>
+
+<!DOCTYPE window [
+<!ENTITY % brandDTD SYSTEM "chrome://branding/locale/brand.dtd" >
+%brandDTD;
+<!ENTITY % baseMenuOverlayDTD SYSTEM "chrome://messenger/locale/baseMenuOverlay.dtd">
+%baseMenuOverlayDTD;
+<!ENTITY % sourceDTD SYSTEM "chrome://messenger/locale/viewSource.dtd" >
+%sourceDTD;
+]>
+
+<window id="viewSource"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ contenttitlesetting="true"
+ title="&mainWindow.title;"
+ titlemodifier="&mainWindow.titlemodifier;"
+ titlepreface="&mainWindow.preface;"
+ titlemenuseparator ="&mainWindow.titlemodifierseparator;"
+ windowtype="navigator:view-source"
+ width="640" height="480"
+ screenX="10" screenY="10"
+ persist="screenX screenY width height sizemode">
+
+<linkset>
+ <html:link rel="localization" href="branding/brand.ftl"/>
+ <html:link rel="localization" href="messenger/messenger.ftl"/>
+ <html:link rel="localization" href="messenger/menubar.ftl"/>
+ <html:link rel="localization" href="messenger/appmenu.ftl"/>
+ <html:link rel="localization" href="messenger/viewSource.ftl"/>
+ <html:link rel="localization" href="toolkit/global/textActions.ftl"/>
+ <html:link rel="localization" href="toolkit/printing/printUI.ftl" />
+</linkset>
+
+ <script src="chrome://messenger/content/globalOverlay.js"/>
+ <script src="chrome://global/content/contentAreaUtils.js"/>
+ <script src="chrome://messenger/content/mailCore.js"/>
+ <script src="chrome://messenger/content/viewSource.js"/>
+ <script src="chrome://messenger/content/viewZoomOverlay.js"/>
+ <script src="chrome://global/content/editMenuOverlay.js"/>
+
+ <stringbundle id="viewSourceBundle" src="chrome://messenger/locale/viewSource.properties"/>
+
+ <command id="cmd_savePage" oncommand="ViewSourceSavePage();"/>
+ <command id="cmd_print" oncommand="PrintUtils.startPrintWindow(gBrowser.browsingContext, {});"/>
+ <command id="cmd_close" oncommand="window.close();"/>
+ <command id="cmd_find"
+ oncommand="document.getElementById('FindToolbar').onFindCommand();"/>
+ <command id="cmd_findAgain"
+ oncommand="document.getElementById('FindToolbar').onFindAgainCommand(false);"/>
+ <command id="cmd_findPrevious"
+ oncommand="document.getElementById('FindToolbar').onFindAgainCommand(true);"/>
+#ifdef XP_MACOSX
+ <command id="cmd_findSelection"
+ oncommand="document.getElementById('FindToolbar').onFindSelectionCommand();"/>
+#endif
+ <command id="cmd_reload" oncommand="viewSourceChrome.reload();"/>
+ <command id="cmd_goToLine" oncommand="viewSourceChrome.promptAndGoToLine();"/>
+ <command id="cmd_wrapLongLines" oncommand="viewSourceChrome.toggleWrapping();"/>
+ <command id="cmd_textZoomReduce" oncommand="ZoomManager.reduce();"/>
+ <command id="cmd_textZoomEnlarge" oncommand="ZoomManager.enlarge();"/>
+ <command id="cmd_textZoomReset" oncommand="ZoomManager.reset();"/>
+
+ <keyset id="viewSourceKeys">
+ <key id="key_savePage" key="&savePageCmd.commandkey;" modifiers="accel" command="cmd_savePage"/>
+ <key id="key_print" key="&printCmd.commandkey;" modifiers="accel" command="cmd_print"/>
+ <key id="key_close" key="&closeCmd.commandkey;" modifiers="accel" command="cmd_close"/>
+ <key id="key_goToLine" key="&goToLineCmd.commandkey;" command="cmd_goToLine" modifiers="accel"/>
+
+ <key id="key_textZoomEnlarge" key="&textEnlarge.commandkey;" command="cmd_textZoomEnlarge" modifiers="accel"/>
+ <key id="key_textZoomEnlarge2" key="&textEnlarge.commandkey2;" command="cmd_textZoomEnlarge" modifiers="accel"/>
+ <key id="key_textZoomEnlarge3" key="&textEnlarge.commandkey3;" command="cmd_textZoomEnlarge" modifiers="accel"/>
+ <key id="key_textZoomReduce" key="&textReduce.commandkey;" command="cmd_textZoomReduce" modifiers="accel"/>
+ <key id="key_textZoomReduce2" key="&textReduce.commandkey2;" command="cmd_textZoomReduce" modifiers="accel"/>
+ <key id="key_textZoomReset" key="&textReset.commandkey;" command="cmd_textZoomReset" modifiers="accel"/>
+ <key id="key_textZoomReset2" key="&textReset.commandkey2;" command="cmd_textZoomReset" modifiers="accel"/>
+
+ <key id="key_reload" key="&reloadCmd.commandkey;" command="cmd_reload" modifiers="accel"/>
+ <key key="&reloadCmd.commandkey;" command="cmd_reload" modifiers="accel,shift"/>
+ <key keycode="VK_F5" command="cmd_reload"/>
+ <key keycode="VK_F5" command="cmd_reload" modifiers="accel"/>
+
+ <key id="key_copy" data-l10n-id="text-action-copy-shortcut" modifiers="accel" command="cmd_copy"/>
+ <key id="key_selectAll" data-l10n-id="text-action-select-all-shortcut" modifiers="accel" command="cmd_selectAll"/>
+ <key id="key_find" key="&findOnCmd.commandkey;" command="cmd_find" modifiers="accel"/>
+ <key id="key_findAgain" key="&findAgainCmd.commandkey;" command="cmd_findAgain" modifiers="accel"/>
+ <key id="key_findPrevious" key="&findAgainCmd.commandkey;" command="cmd_findPrevious" modifiers="accel,shift"/>
+#ifdef XP_MACOSX
+ <key id="key_findSelection" key="&findSelectionCmd.commandkey;" command="cmd_findSelection" modifiers="accel"/>
+#endif
+ <key keycode="&findAgainCmd.commandkey2;" command="cmd_findAgain"/>
+ <key keycode="&findAgainCmd.commandkey2;" command="cmd_findPrevious" modifiers="shift"/>
+
+ <key keycode="VK_BACK" command="Browser:Back"/>
+ <key keycode="VK_BACK" command="Browser:Forward" modifiers="shift"/>
+#ifndef XP_MACOSX
+ <key id="goBackKb" keycode="VK_LEFT" command="Browser:Back" modifiers="alt"/>
+ <key id="goForwardKb" keycode="VK_RIGHT" command="Browser:Forward" modifiers="alt"/>
+#else
+ <key id="goBackKb" keycode="VK_LEFT" command="Browser:Back" modifiers="accel" />
+ <key id="goForwardKb" keycode="VK_RIGHT" command="Browser:Forward" modifiers="accel" />
+#endif
+#ifdef XP_UNIX
+ <key id="goBackKb2" key="&goBackCmd.commandKey;" command="Browser:Back" modifiers="accel"/>
+ <key id="goForwardKb2" key="&goForwardCmd.commandKey;" command="Browser:Forward" modifiers="accel"/>
+#endif
+ <key id="key_openHelp"
+ oncommand="openSupportURL();"
+#ifdef XP_MACOSX
+ key="&productHelpMac.commandkey;"
+ modifiers="&productHelpMac.modifiers;"/>
+#else
+ keycode="&productHelp.commandkey;"/>
+#endif
+ </keyset>
+
+ <tooltip id="aHTMLTooltip" page="true"/>
+
+ <menupopup id="viewSourceContextMenu">
+ <menuitem id="cMenu_copy"
+ data-l10n-id="text-action-copy"
+ command="cmd_copy"/>
+ <menuseparator/>
+ <menuitem id="cMenu_selectAll"
+ data-l10n-id="text-action-select-all"
+ command="cmd_selectAll"/>
+ <menuseparator/>
+ <menuitem id="cMenu_find"
+ data-l10n-id="context-text-action-find"
+ command="cmd_find"/>
+ <menuitem id="cMenu_findAgain"
+ data-l10n-id="context-text-action-find-again"
+ command="cmd_findAgain"/>
+ </menupopup>
+
+ <!-- Menu -->
+ <toolbox id="viewSource-toolbox">
+ <toolbar type="menubar">
+ <menubar id="viewSource-main-menubar">
+
+ <menu id="menu_file" label="&fileMenu.label;" accesskey="&fileMenu.accesskey;">
+ <menupopup id="menu_FilePopup">
+ <menuitem key="key_savePage" command="cmd_savePage" id="menu_savePage"
+ label="&savePageCmd.label;" accesskey="&savePageCmd.accesskey;"/>
+ <menuseparator/>
+ <menuitem key="key_print" command="cmd_print" id="menu_print"
+ label="&printCmd.label;" accesskey="&printCmd.accesskey;"/>
+ <menuseparator/>
+ <menuitem key="key_close" command="cmd_close" id="menu_close"
+ label="&closeCmd.label;" accesskey="&closeCmd.accesskey;"/>
+ </menupopup>
+ </menu>
+
+ <menu id="menu_edit" label="&editMenu.label;"
+ accesskey="&editMenu.accesskey;">
+ <menupopup id="editmenu-popup">
+ <menuitem id="menu_copy"
+ data-l10n-id="text-action-copy"
+ key="key_copy"
+ command="cmd_copy"/>
+ <menuseparator/>
+ <menuitem id="menu_selectAll"
+ data-l10n-id="text-action-select-all"
+ key="key_selectAll"
+ command="cmd_selectAll"/>
+ <menuseparator/>
+ <menuitem id="menu_find"
+ data-l10n-id="text-action-find"
+ key="key_find"
+ command="cmd_find"/>
+ <menuitem id="menu_findAgain"
+ data-l10n-id="text-action-find-again"
+ key="key_findAgain"
+ command="cmd_findAgain"/>
+ <menuseparator/>
+ <menuitem id="menu_goToLine" key="key_goToLine" command="cmd_goToLine"
+ label="&goToLineCmd.label;" accesskey="&goToLineCmd.accesskey;"/>
+ </menupopup>
+ </menu>
+
+ <menu id="menu_view" label="&viewMenu.label;" accesskey="&viewMenu.accesskey;">
+ <menupopup id="viewmenu-popup">
+ <menuitem id="menu_reload" command="cmd_reload" accesskey="&reloadCmd.accesskey;"
+ label="&reloadCmd.label;" key="key_reload"/>
+ <menuseparator />
+ <menu id="viewTextZoomMenu" label="&menu_textSize.label;" accesskey="&menu_textSize.accesskey;">
+ <menupopup>
+ <menuitem id="menu_textEnlarge" command="cmd_textZoomEnlarge"
+ label="&menu_textEnlarge.label;" accesskey="&menu_textEnlarge.accesskey;"
+ key="key_textZoomEnlarge"/>
+ <menuitem id="menu_textReduce" command="cmd_textZoomReduce"
+ label="&menu_textReduce.label;" accesskey="&menu_textReduce.accesskey;"
+ key="key_textZoomReduce"/>
+ <menuseparator/>
+ <menuitem id="menu_textReset" command="cmd_textZoomReset"
+ label="&menu_textReset.label;" accesskey="&menu_textReset.accesskey;"
+ key="key_textZoomReset"/>
+ </menupopup>
+ </menu>
+
+ <!-- Charset Menu -->
+ <menuitem id="repair-text-encoding"
+ data-l10n-id="menu-view-repair-text-encoding"
+ oncommand="viewSourceChrome.onForceCharacterSet();"/>
+ <menuseparator/>
+ <menuitem id="menu_wrapLongLines" type="checkbox" command="cmd_wrapLongLines"
+ label="&menu_wrapLongLines.title;" accesskey="&menu_wrapLongLines.accesskey;"/>
+ </menupopup>
+ </menu>
+ <menu id="helpMenu"
+ data-l10n-id="menu-help-help-title">
+ <menupopup id="menu_HelpPopup">
+ <menuitem id="menu_openHelp"
+ data-l10n-id="appmenu-help-get-help"
+ key="key_openHelp"
+ oncommand="openSupportURL();"/>
+ </menupopup>
+ </menu>
+ </menubar>
+ </toolbar>
+ </toolbox>
+ <vbox class="printPreviewStack" flex="1">
+ <browser id="content"
+ type="content"
+ name="content"
+ src="about:blank"
+ flex="1"
+ primary="true"
+ disableglobalhistory="true"
+ showcaret="true"
+ tooltip="aHTMLTooltip"
+ maychangeremoteness="true"
+ messagemanagergroup="browsers"/>
+ <findbar id="FindToolbar" browserid="content"/>
+ </vbox>
+
+#include tabDialogs.inc.xhtml
+</window>
diff --git a/comm/mail/base/content/viewZoomOverlay.js b/comm/mail/base/content/viewZoomOverlay.js
new file mode 100644
index 0000000000..523386f82e
--- /dev/null
+++ b/comm/mail/base/content/viewZoomOverlay.js
@@ -0,0 +1,153 @@
+// -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
+
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals getBrowser */
+
+/** Document Zoom Management Code
+ *
+ * Forked from M-C since we don't provide a global gBrowser variable.
+ *
+ * TODO: Move to dedicated js module - see bug 1841768.
+ */
+
+var ZoomManager = {
+ get MIN() {
+ delete this.MIN;
+ return (this.MIN = Services.prefs.getIntPref("zoom.minPercent") / 100);
+ },
+
+ get MAX() {
+ delete this.MAX;
+ return (this.MAX = Services.prefs.getIntPref("zoom.maxPercent") / 100);
+ },
+
+ get useFullZoom() {
+ return Services.prefs.getBoolPref("browser.zoom.full");
+ },
+
+ set useFullZoom(aVal) {
+ Services.prefs.setBoolPref("browser.zoom.full", aVal);
+ },
+
+ get zoom() {
+ return this.getZoomForBrowser(getBrowser());
+ },
+
+ useFullZoomForBrowser(aBrowser) {
+ return this.useFullZoom || aBrowser.isSyntheticDocument;
+ },
+
+ getFullZoomForBrowser(aBrowser) {
+ if (!this.useFullZoomForBrowser(aBrowser)) {
+ return 1.0;
+ }
+ return this.getZoomForBrowser(aBrowser);
+ },
+
+ getZoomForBrowser(aBrowser) {
+ let zoom = this.useFullZoomForBrowser(aBrowser)
+ ? aBrowser.fullZoom
+ : aBrowser.textZoom;
+ // Round to remove any floating-point error.
+ return Number(zoom ? zoom.toFixed(2) : 1);
+ },
+
+ set zoom(aVal) {
+ this.setZoomForBrowser(getBrowser(), aVal);
+ },
+
+ setZoomForBrowser(browser, val) {
+ if (val < this.MIN || val > this.MAX) {
+ throw Components.Exception(
+ `invalid zoom value: ${val}`,
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ let fullZoom = this.useFullZoomForBrowser(browser);
+ browser.textZoom = fullZoom ? 1 : val;
+ browser.fullZoom = fullZoom ? val : 1;
+ },
+
+ get zoomValues() {
+ var zoomValues = Services.prefs
+ .getCharPref("toolkit.zoomManager.zoomValues")
+ .split(",")
+ .map(parseFloat);
+ zoomValues.sort((a, b) => a - b);
+
+ while (zoomValues[0] < this.MIN) {
+ zoomValues.shift();
+ }
+
+ while (zoomValues[zoomValues.length - 1] > this.MAX) {
+ zoomValues.pop();
+ }
+
+ delete this.zoomValues;
+ return (this.zoomValues = zoomValues);
+ },
+
+ enlarge(browser = getBrowser()) {
+ const i =
+ this.zoomValues.indexOf(this.snap(this.getZoomForBrowser(browser))) + 1;
+ if (i < this.zoomValues.length) {
+ this.setZoomForBrowser(browser, this.zoomValues[i]);
+ }
+ },
+
+ reduce(browser = getBrowser()) {
+ const i =
+ this.zoomValues.indexOf(this.snap(this.getZoomForBrowser(browser))) - 1;
+ if (i >= 0) {
+ this.setZoomForBrowser(browser, this.zoomValues[i]);
+ }
+ },
+
+ reset(browser = getBrowser()) {
+ this.setZoomForBrowser(browser, 1);
+ },
+
+ toggleZoom(browser = getBrowser()) {
+ const zoomLevel = this.getZoomForBrowser();
+
+ this.useFullZoom = !this.useFullZoom;
+ this.setZoomForBrowser(browser, zoomLevel);
+ },
+
+ snap(aVal) {
+ var values = this.zoomValues;
+ for (var i = 0; i < values.length; i++) {
+ if (values[i] >= aVal) {
+ if (i > 0 && aVal - values[i - 1] < values[i] - aVal) {
+ i--;
+ }
+ return values[i];
+ }
+ }
+ return values[i - 1];
+ },
+
+ scrollZoomEnlarge(messagePaneBrowser) {
+ let zoom = messagePaneBrowser.fullZoom;
+ zoom += 0.1;
+ let zoomMax = Services.prefs.getIntPref("zoom.maxPercent") / 100;
+ if (zoom > zoomMax) {
+ zoom = zoomMax;
+ }
+ messagePaneBrowser.fullZoom = zoom;
+ },
+
+ scrollReduceEnlarge(messagePaneBrowser) {
+ let zoom = messagePaneBrowser.fullZoom;
+ zoom -= 0.1;
+ let zoomMin = Services.prefs.getIntPref("zoom.minPercent") / 100;
+ if (zoom < zoomMin) {
+ zoom = zoomMin;
+ }
+ messagePaneBrowser.fullZoom = zoom;
+ },
+};
diff --git a/comm/mail/base/content/webextensions.css b/comm/mail/base/content/webextensions.css
new file mode 100644
index 0000000000..8245fb5183
--- /dev/null
+++ b/comm/mail/base/content/webextensions.css
@@ -0,0 +1,106 @@
+/* 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/. */
+
+@namespace xul url("http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul");
+@namespace html url("http://www.w3.org/1999/xhtml");
+
+/* Rules to help integrate WebExtension buttons */
+
+.webextension-action > .toolbarbutton-badge-stack > .toolbarbutton-icon {
+ height: 18px;
+ width: 18px;
+}
+
+@media not all and (min-resolution: 1.1dppx) {
+ /* for browserAction, composeAction and messageAction */
+ :root .spaces-addon-menuitem,
+ .webextension-action {
+ list-style-image: var(--webextension-toolbar-image, inherit);
+ }
+
+ /* for buttons in sidebar or sidebar menu */
+ :root .spaces-addon-button img {
+ content: var(--webextension-toolbar-image, inherit);
+ }
+
+ :root .spaces-addon-menuitem:-moz-lwtheme,
+ .webextension-action:-moz-lwtheme {
+ list-style-image: var(--webextension-toolbar-image-dark, inherit);
+ }
+
+ :root .spaces-addon-button:-moz-lwtheme img {
+ content: var(--webextension-toolbar-image-dark, inherit);
+ }
+
+ @media (prefers-color-scheme: dark) {
+ :root .spaces-addon-menuitem,
+ .webextension-action {
+ list-style-image: var(--webextension-toolbar-image-light, inherit) !important;
+ }
+
+ :root .spaces-addon-button img {
+ content: var(--webextension-toolbar-image-light, inherit) !important;
+ }
+ }
+
+ .webextension-action[cui-areatype="menu-panel"] {
+ list-style-image: var(--webextension-menupanel-image, inherit);
+ }
+ :root[lwt-popup-brighttext] .webextension-action[cui-areatype="menu-panel"] {
+ list-style-image: var(--webextension-menupanel-image-light, inherit);
+ }
+ :root:not([lwt-popup-brighttext]) .webextension-action[cui-areatype="menu-panel"]:-moz-lwtheme {
+ list-style-image: var(--webextension-menupanel-image-dark, inherit);
+ }
+
+ .webextension-menuitem {
+ list-style-image: var(--webextension-menuitem-image, inherit) !important;
+ }
+}
+
+/* for displays, like Retina > 1.1dppx */
+@media (min-resolution: 1.1dppx) {
+ :root .spaces-addon-menuitem,
+ .webextension-action {
+ list-style-image: var(--webextension-toolbar-image-2x, inherit);
+ }
+
+ :root .spaces-addon-button img {
+ content: var(--webextension-toolbar-image-2x, inherit);
+ }
+
+ :root .spaces-addon-menuitem:-moz-lwtheme,
+ .webextension-action:-moz-lwtheme {
+ list-style-image: var(--webextension-toolbar-image-2x-dark, inherit);
+ }
+
+ :root .spaces-addon-button:-moz-lwtheme img {
+ content: var(--webextension-toolbar-image-2x-dark, inherit);
+ }
+
+ @media (prefers-color-scheme: dark) {
+ :root .spaces-addon-menuitem,
+ .webextension-action {
+ list-style-image: var(--webextension-toolbar-image-2x-light, inherit) !important;
+ }
+
+ :root .spaces-addon-button img {
+ content: var(--webextension-toolbar-image-2x-light, inherit) !important;
+ }
+ }
+
+ .webextension-action[cui-areatype="menu-panel"] {
+ list-style-image: var(--webextension-menupanel-image-2x, inherit);
+ }
+ :root[lwt-popup-brighttext] .webextension-action[cui-areatype="menu-panel"] {
+ list-style-image: var(--webextension-menupanel-image-2x-light, inherit);
+ }
+ :root:not([lwt-popup-brighttext]) .webextension-action[cui-areatype="menu-panel"]:-moz-lwtheme {
+ list-style-image: var(--webextension-menupanel-image-2x-dark, inherit);
+ }
+
+ .webextension-menuitem {
+ list-style-image: var(--webextension-menuitem-image-2x, inherit) !important;
+ }
+}
diff --git a/comm/mail/base/content/widgets/browserPopups.inc.xhtml b/comm/mail/base/content/widgets/browserPopups.inc.xhtml
new file mode 100644
index 0000000000..468c2eb3eb
--- /dev/null
+++ b/comm/mail/base/content/widgets/browserPopups.inc.xhtml
@@ -0,0 +1,192 @@
+# 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/.
+
+#ifndef NO_BROWSERCONTEXT
+ <menupopup id="browserContext"
+ onpopupshowing="return browserContextOnShowing(event);"
+ onpopuphiding="browserContextOnHiding(event);">
+ <!-- Browser navigation -->
+#ifdef XP_MACOSX
+ <menuitem id="browserContext-back"
+ data-l10n-id="content-tab-menu-back-mac"
+ command="Browser:Back"/>
+ <menuitem id="browserContext-forward"
+ data-l10n-id="content-tab-menu-forward-mac"
+ command="Browser:Forward"/>
+ <menuitem id="browserContext-reload"
+ tooltip="dynamic-shortcut-tooltip"
+ data-l10n-id="content-tab-menu-reload-mac"
+ command="cmd_reload"/>
+ <menuitem id="browserContext-stop"
+ tooltip="dynamic-shortcut-tooltip"
+ data-l10n-id="content-tab-menu-stop-mac"
+ command="cmd_stop"/>
+#else
+ <menugroup id="context-navigation">
+ <menuitem id="browserContext-back"
+ data-l10n-id="content-tab-menu-back"
+ data-l10n-args='{"shortcut":""}'
+ class="menuitem-iconic"
+ command="Browser:Back"/>
+ <menuitem id="browserContext-forward"
+ data-l10n-id="content-tab-menu-forward"
+ data-l10n-args='{"shortcut":""}'
+ class="menuitem-iconic"
+ command="Browser:Forward"/>
+ <menuitem id="browserContext-reload"
+ class="menuitem-iconic"
+ tooltip="dynamic-shortcut-tooltip"
+ data-l10n-id="content-tab-menu-reload"
+ command="cmd_reload"/>
+ <menuitem id="browserContext-stop"
+ class="menuitem-iconic"
+ tooltip="dynamic-shortcut-tooltip"
+ data-l10n-id="content-tab-menu-stop"
+ command="cmd_stop"/>
+ </menugroup>
+#endif
+ <menuseparator id="browserContext-sep-navigation"/>
+ <!-- Spellchecking suggestions -->
+ <menuitem id="browserContext-spell-no-suggestions"
+ disabled="true"
+ data-l10n-id="text-action-spell-no-suggestions"/>
+ <menuitem id="browserContext-spell-add-to-dictionary"
+ data-l10n-id="text-action-spell-add-to-dictionary"
+ oncommand="gSpellChecker.addToDictionary();"/>
+ <menuitem id="browserContext-spell-undo-add-to-dictionary"
+ data-l10n-id="text-action-spell-undo-add-to-dictionary"
+ oncommand="gSpellChecker.undoAddToDictionary();" />
+ <menuseparator id="browserContext-spell-suggestions-separator"/>
+
+ <menuitem id="browserContext-openInBrowser"
+ label="&openInBrowser.label;"
+ accesskey="&openInBrowser.accesskey;"
+ oncommand="gContextMenu.openInBrowser();"/>
+ <menuitem id="browserContext-openLinkInBrowser"
+ label="&openLinkInBrowser.label;"
+ accesskey="&openLinkInBrowser.accesskey;"
+ oncommand="gContextMenu.openLinkInBrowser();"/>
+ <menuseparator id="browserContext-sep-open-browser"/>
+ <menuitem id="browserContext-undo"
+ label="&undoDefaultCmd.label;"
+ accesskey="&undoDefaultCmd.accesskey;"
+ command="cmd_undo"/>
+ <menuseparator id="browserContext-sep-undo"/>
+ <menuitem id="browserContext-cut"
+ data-l10n-id="text-action-cut"
+ command="cmd_copy"/>
+ <menuitem id="browserContext-copy"
+ data-l10n-id="text-action-copy"
+ command="cmd_copy"/>
+ <menuitem id="browserContext-paste"
+ data-l10n-id="text-action-paste"
+ command="cmd_paste"/>
+ <menuitem id="browserContext-selectall"
+ data-l10n-id="text-action-select-all"
+ command="cmd_selectAll"/>
+ <menuseparator id="browserContext-sep-clipboard"/>
+
+ <menuitem id="browserContext-searchTheWeb"
+ label="[glodaComplete.webSearch1.label]"
+ oncommand="openWebSearch(event.target.value)"/>
+
+ <!-- Spellchecking general menu items (enable, add dictionaries...) -->
+ <menuseparator id="browserContext-spell-separator"/>
+ <menuitem id="browserContext-spell-check-enabled"
+ data-l10n-id="text-action-spell-check-toggle"
+ type="checkbox"
+ oncommand="gSpellChecker.toggleEnabled();"/>
+ <menuitem id="browserContext-spell-add-dictionaries-main"
+ label="&spellAddDictionaries.label;"
+ accesskey="&spellAddDictionaries.accesskey;"
+ oncommand="gContextMenu.addDictionaries();"/>
+ <menu id="browserContext-spell-dictionaries"
+ data-l10n-id="text-action-spell-dictionaries">
+ <menupopup id="browserContext-spell-dictionaries-menu">
+ <menuseparator id="browserContext-spell-language-separator"/>
+ <menuitem id="browserContext-spell-add-dictionaries"
+ label="&spellAddDictionaries.label;"
+ accesskey="&spellAddDictionaries.accesskey;"
+ oncommand="gContextMenu.addDictionaries();"/>
+ </menupopup>
+ </menu>
+
+ <menuitem id="browserContext-media-play"
+ label="&contextPlay.label;"
+ accesskey="&contextPlay.accesskey;"
+ oncommand="gContextMenu.mediaCommand('play');"/>
+ <menuitem id="browserContext-media-pause"
+ label="&contextPause.label;"
+ accesskey="&contextPause.accesskey;"
+ oncommand="gContextMenu.mediaCommand('pause');"/>
+ <menuitem id="browserContext-media-mute"
+ label="&contextMute.label;"
+ accesskey="&contextMute.accesskey;"
+ oncommand="gContextMenu.mediaCommand('mute');"/>
+ <menuitem id="browserContext-media-unmute"
+ label="&contextUnmute.label;"
+ accesskey="&contextUnmute.accesskey;"
+ oncommand="gContextMenu.mediaCommand('unmute');"/>
+ <menuseparator id="browserContext-sep-edit"/>
+ <menuitem id="browserContext-copylink"
+ label="&copyLinkCmd.label;"
+ accesskey="&copyLinkCmd.accesskey;"
+ command="cmd_copyLink"/>
+ <menuitem id="browserContext-copyimage"
+ label="&copyImageAllCmd.label;"
+ accesskey="&copyImageAllCmd.accesskey;"
+ command="cmd_copyImage"/>
+ <menuitem id="browserContext-addemail"
+ label="&AddToAddressBook.label;"
+ accesskey="&AddToAddressBook.accesskey;"
+ oncommand="addEmail(gContextMenu.linkURL);"/>
+ <menuitem id="browserContext-composeemailto"
+ label="&SendMessageTo.label;"
+ accesskey="&SendMessageTo.accesskey;"
+ oncommand="composeEmailTo(gContextMenu.linkURL);"/>
+ <menuitem id="browserContext-copyemail"
+ label="&copyEmailCmd.label;"
+ accesskey="&copyEmailCmd.accesskey;"
+ oncommand="gContextMenu.copyEmail();"/>
+ <menuseparator id="browserContext-sep-copy"/>
+ <menuitem id="browserContext-savelink"
+ label="&saveLinkAsCmd.label;"
+ accesskey="&saveLinkAsCmd.accesskey;"
+ oncommand="gContextMenu.saveLink();"/>
+ <menuitem id="browserContext-saveimage"
+ label="&saveImageAsCmd.label;"
+ accesskey="&saveImageAsCmd.accesskey;"
+ oncommand="gContextMenu.saveImage();"/>
+ </menupopup>
+#endif
+ <panel id="DateTimePickerPanel"
+ type="arrow"
+ orient="vertical"
+ noautofocus="true"
+ norolluponanchor="true"
+ consumeoutsideclicks="never"
+ level="top"
+ tabspecific="true">
+ </panel>
+
+ <!-- For select dropdowns. The menupopup is what shows the list of options,
+ and the popuponly menulist makes things like the menuactive attributes
+ work correctly on the menupopup. ContentSelectDropdown expects the
+ popuponly menulist to be its immediate parent. -->
+ <menulist popuponly="true" id="ContentSelectDropdown" hidden="true">
+ <menupopup rolluponmousewheel="true"
+ activateontab="true" position="after_start"
+ level="parent"
+#ifdef XP_WIN
+ consumeoutsideclicks="false" ignorekeys="shortcuts"
+#endif
+ />
+ </menulist>
+
+ <panel is="autocomplete-richlistbox-popup" id="PopupAutoComplete"
+ type="autocomplete"
+ role="group"
+ noautofocus="true"/>
+
+ <tooltip id="remoteBrowserTooltip"/>
diff --git a/comm/mail/base/content/widgets/browserPopups.js b/comm/mail/base/content/widgets/browserPopups.js
new file mode 100644
index 0000000000..f6d2a2139f
--- /dev/null
+++ b/comm/mail/base/content/widgets/browserPopups.js
@@ -0,0 +1,991 @@
+/* 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 ../utilityOverlay.js */
+
+/* globals saveURL */ // From contentAreaUtils.js
+/* globals goUpdateCommand */ // From globalOverlay.js
+
+var { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+var { InlineSpellChecker, SpellCheckHelper } = ChromeUtils.importESModule(
+ "resource://gre/modules/InlineSpellChecker.sys.mjs"
+);
+var { PlacesUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/PlacesUtils.sys.mjs"
+);
+var { ShortcutUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/ShortcutUtils.sys.mjs"
+);
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "MailUtils",
+ "resource:///modules/MailUtils.jsm"
+);
+var { E10SUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/E10SUtils.sys.mjs"
+);
+
+var gContextMenu;
+var gSpellChecker = new InlineSpellChecker();
+
+/** Called by ContextMenuParent.sys.mjs */
+function openContextMenu({ data }, browser, actor) {
+ if (!browser.hasAttribute("context")) {
+ return;
+ }
+
+ let wgp = actor.manager;
+
+ if (!wgp.isCurrentGlobal) {
+ // Don't display context menus for unloaded documents
+ return;
+ }
+
+ // NOTE: We don't use `wgp.documentURI` here as we want to use the failed
+ // channel URI in the case we have loaded an error page.
+ let documentURIObject = wgp.browsingContext.currentURI;
+
+ let frameReferrerInfo = data.frameReferrerInfo;
+ if (frameReferrerInfo) {
+ frameReferrerInfo = E10SUtils.deserializeReferrerInfo(frameReferrerInfo);
+ }
+
+ let linkReferrerInfo = data.linkReferrerInfo;
+ if (linkReferrerInfo) {
+ linkReferrerInfo = E10SUtils.deserializeReferrerInfo(linkReferrerInfo);
+ }
+
+ let frameID = nsContextMenu.WebNavigationFrames.getFrameId(
+ wgp.browsingContext
+ );
+
+ nsContextMenu.contentData = {
+ context: data.context,
+ browser,
+ actor,
+ editFlags: data.editFlags,
+ spellInfo: data.spellInfo,
+ principal: wgp.documentPrincipal,
+ storagePrincipal: wgp.documentStoragePrincipal,
+ documentURIObject,
+ docLocation: data.docLocation,
+ charSet: data.charSet,
+ referrerInfo: E10SUtils.deserializeReferrerInfo(data.referrerInfo),
+ frameReferrerInfo,
+ linkReferrerInfo,
+ contentType: data.contentType,
+ contentDisposition: data.contentDisposition,
+ frameID,
+ frameOuterWindowID: frameID,
+ frameBrowsingContext: wgp.browsingContext,
+ selectionInfo: data.selectionInfo,
+ disableSetDesktopBackground: data.disableSetDesktopBackground,
+ loginFillInfo: data.loginFillInfo,
+ parentAllowsMixedContent: data.parentAllowsMixedContent,
+ userContextId: wgp.browsingContext.originAttributes.userContextId,
+ webExtContextData: data.webExtContextData,
+ cookieJarSettings: wgp.cookieJarSettings,
+ };
+
+ // Note: `popup` must be in `document`, but `browser` might be in a
+ // different document, such as about:3pane.
+ let popup = document.getElementById(browser.getAttribute("context"));
+ let context = nsContextMenu.contentData.context;
+
+ // Fill in some values in the context from the WindowGlobalParent actor.
+ context.principal = wgp.documentPrincipal;
+ context.storagePrincipal = wgp.documentStoragePrincipal;
+ context.frameID = frameID;
+ context.frameOuterWindowID = wgp.outerWindowId;
+ context.frameBrowsingContextID = wgp.browsingContext.id;
+
+ // We don't have access to the original event here, as that happened in
+ // another process. Therefore we synthesize a new MouseEvent to propagate the
+ // inputSource to the subsequently triggered popupshowing event.
+ let newEvent = document.createEvent("MouseEvent");
+ let screenX = context.screenXDevPx / window.devicePixelRatio;
+ let screenY = context.screenYDevPx / window.devicePixelRatio;
+ newEvent.initNSMouseEvent(
+ "contextmenu",
+ true,
+ true,
+ null,
+ 0,
+ screenX,
+ screenY,
+ 0,
+ 0,
+ false,
+ false,
+ false,
+ false,
+ 2,
+ null,
+ 0,
+ context.mozInputSource
+ );
+ popup.openPopupAtScreen(newEvent.screenX, newEvent.screenY, true, newEvent);
+}
+
+/**
+ * Function to set the global nsContextMenu. Called by popupshowing on browserContext.
+ *
+ * @param {Event} event - The onpopupshowing event.
+ * @returns {boolean}
+ */
+function browserContextOnShowing(event) {
+ if (event.target.id != "browserContext") {
+ return true;
+ }
+
+ gContextMenu = new nsContextMenu(event.target, event.shiftKey);
+ return gContextMenu.shouldDisplay;
+}
+
+/**
+ * Function to clear out the global nsContextMenu.
+ *
+ * @param {Event} event - The onpopuphiding event.
+ */
+function browserContextOnHiding(event) {
+ if (event.target.id != "browserContext") {
+ return;
+ }
+
+ gContextMenu.hiding();
+ gContextMenu = null;
+}
+
+class nsContextMenu {
+ constructor(aXulMenu, aIsShift) {
+ this.xulMenu = aXulMenu;
+
+ // Get contextual info.
+ this.setContext();
+
+ if (!this.shouldDisplay) {
+ return;
+ }
+
+ this.isContentSelected =
+ !this.selectionInfo || !this.selectionInfo.docSelectionIsCollapsed;
+
+ if (!aIsShift) {
+ // The rest of this block sends menu information to WebExtensions.
+ let subject = {
+ menu: aXulMenu,
+ tab: document.getElementById("tabmail")
+ ? document.getElementById("tabmail").currentTabInfo
+ : window,
+ timeStamp: this.timeStamp,
+ isContentSelected: this.isContentSelected,
+ inFrame: this.inFrame,
+ isTextSelected: this.isTextSelected,
+ onTextInput: this.onTextInput,
+ onLink: this.onLink,
+ onImage: this.onImage,
+ onVideo: this.onVideo,
+ onAudio: this.onAudio,
+ onCanvas: this.onCanvas,
+ onEditable: this.onEditable,
+ onSpellcheckable: this.onSpellcheckable,
+ onPassword: this.onPassword,
+ srcUrl: this.mediaURL,
+ frameUrl: this.contentData ? this.contentData.docLocation : undefined,
+ pageUrl: this.browser ? this.browser.currentURI.spec : undefined,
+ linkText: this.linkTextStr,
+ linkUrl: this.linkURL,
+ selectionText: this.isTextSelected
+ ? this.selectionInfo.fullText
+ : undefined,
+ frameId: this.frameID,
+ webExtBrowserType: this.webExtBrowserType,
+ webExtContextData: this.contentData
+ ? this.contentData.webExtContextData
+ : undefined,
+ };
+
+ subject.wrappedJSObject = subject;
+ Services.obs.notifyObservers(subject, "on-build-contextmenu");
+ }
+
+ // Reset after "on-build-contextmenu" notification in case selection was
+ // changed during the notification.
+ this.isContentSelected =
+ !this.selectionInfo || !this.selectionInfo.docSelectionIsCollapsed;
+ this.initItems();
+
+ // If all items in the menu are hidden, set this.shouldDisplay to false
+ // so that the callers know to not even display the empty menu.
+ let contextPopup = document.getElementById("browserContext");
+ for (let item of contextPopup.children) {
+ if (!item.hidden) {
+ return;
+ }
+ }
+
+ // All items must have been hidden.
+ this.shouldDisplay = false;
+ }
+
+ setContext() {
+ let context = Object.create(null);
+
+ if (nsContextMenu.contentData) {
+ this.contentData = nsContextMenu.contentData;
+ context = this.contentData.context;
+ nsContextMenu.contentData = null;
+ }
+
+ this.shouldDisplay = !this.contentData || context.shouldDisplay;
+ this.timeStamp = context.timeStamp;
+
+ // Assign what's _possibly_ needed from `context` sent by ContextMenuChild.sys.mjs
+ // Keep this consistent with the similar code in ContextMenu's _setContext
+ this.bgImageURL = context.bgImageURL;
+ this.imageDescURL = context.imageDescURL;
+ this.imageInfo = context.imageInfo;
+ this.mediaURL = context.mediaURL;
+
+ this.canSpellCheck = context.canSpellCheck;
+ this.hasBGImage = context.hasBGImage;
+ this.hasMultipleBGImages = context.hasMultipleBGImages;
+ this.isDesignMode = context.isDesignMode;
+ this.inFrame = context.inFrame;
+ this.inPDFViewer = context.inPDFViewer;
+ this.inSrcdocFrame = context.inSrcdocFrame;
+ this.inSyntheticDoc = context.inSyntheticDoc;
+
+ this.link = context.link;
+ this.linkDownload = context.linkDownload;
+ this.linkProtocol = context.linkProtocol;
+ this.linkTextStr = context.linkTextStr;
+ this.linkURL = context.linkURL;
+ this.linkURI = this.getLinkURI(); // can't send; regenerate
+
+ this.onAudio = context.onAudio;
+ this.onCanvas = context.onCanvas;
+ this.onCompletedImage = context.onCompletedImage;
+ this.onDRMMedia = context.onDRMMedia;
+ this.onPiPVideo = context.onPiPVideo;
+ this.onEditable = context.onEditable;
+ this.onImage = context.onImage;
+ this.onKeywordField = context.onKeywordField;
+ this.onLink = context.onLink;
+ this.onLoadedImage = context.onLoadedImage;
+ this.onMailtoLink = context.onMailtoLink;
+ this.onMozExtLink = context.onMozExtLink;
+ this.onNumeric = context.onNumeric;
+ this.onPassword = context.onPassword;
+ this.onSaveableLink = context.onSaveableLink;
+ this.onSpellcheckable = context.onSpellcheckable;
+ this.onTextInput = context.onTextInput;
+ this.onVideo = context.onVideo;
+
+ this.target = context.target;
+ this.targetIdentifier = context.targetIdentifier;
+
+ this.principal = context.principal;
+ this.storagePrincipal = context.storagePrincipal;
+ this.frameID = context.frameID;
+ this.frameOuterWindowID = context.frameOuterWindowID;
+ this.frameBrowsingContext = BrowsingContext.get(
+ context.frameBrowsingContextID
+ );
+
+ this.inSyntheticDoc = context.inSyntheticDoc;
+ this.inAboutDevtoolsToolbox = context.inAboutDevtoolsToolbox;
+
+ // Everything after this isn't sent directly from ContextMenu
+ if (this.target) {
+ this.ownerDoc = this.target.ownerDocument;
+ }
+
+ this.csp = E10SUtils.deserializeCSP(context.csp);
+
+ if (!this.contentData) {
+ return;
+ }
+
+ this.browser = this.contentData.browser;
+ if (this.browser && this.browser.currentURI.spec == "about:blank") {
+ this.shouldDisplay = false;
+ return;
+ }
+ this.selectionInfo = this.contentData.selectionInfo;
+ this.actor = this.contentData.actor;
+
+ this.textSelected = this.selectionInfo?.text;
+ this.isTextSelected = !!this.textSelected?.length;
+
+ this.webExtBrowserType = this.browser.getAttribute(
+ "webextension-view-type"
+ );
+
+ if (context.shouldInitInlineSpellCheckerUINoChildren) {
+ gSpellChecker.initFromRemote(
+ this.contentData.spellInfo,
+ this.actor.manager
+ );
+ }
+
+ if (this.contentData.spellInfo) {
+ this.spellSuggestions = this.contentData.spellInfo.spellSuggestions;
+ }
+
+ if (context.shouldInitInlineSpellCheckerUIWithChildren) {
+ gSpellChecker.initFromRemote(
+ this.contentData.spellInfo,
+ this.actor.manager
+ );
+ let canSpell = gSpellChecker.canSpellCheck && this.canSpellCheck;
+ this.showItem("browserContext-spell-check-enabled", canSpell);
+ this.showItem("browserContext-spell-separator", canSpell);
+ }
+ }
+
+ hiding() {
+ if (this.actor) {
+ this.actor.hiding();
+ }
+
+ this.contentData = null;
+ gSpellChecker.clearSuggestionsFromMenu();
+ gSpellChecker.clearDictionaryListFromMenu();
+ gSpellChecker.uninit();
+ }
+
+ initItems() {
+ this.initSaveItems();
+ this.initClipboardItems();
+ this.initMediaPlayerItems();
+ this.initBrowserItems();
+ this.initSpellingItems();
+ this.initSeparators();
+ }
+ addDictionaries() {
+ openDictionaryList();
+ }
+ initSpellingItems() {
+ let canSpell =
+ gSpellChecker.canSpellCheck &&
+ !gSpellChecker.initialSpellCheckPending &&
+ this.canSpellCheck;
+ let showDictionaries = canSpell && gSpellChecker.enabled;
+ let onMisspelling = gSpellChecker.overMisspelling;
+ let showUndo = canSpell && gSpellChecker.canUndo();
+ this.showItem("browserContext-spell-check-enabled", canSpell);
+ this.showItem("browserContext-spell-separator", canSpell);
+ document
+ .getElementById("browserContext-spell-check-enabled")
+ .setAttribute("checked", canSpell && gSpellChecker.enabled);
+
+ this.showItem("browserContext-spell-add-to-dictionary", onMisspelling);
+ this.showItem("browserContext-spell-undo-add-to-dictionary", showUndo);
+
+ // suggestion list
+ this.showItem(
+ "browserContext-spell-suggestions-separator",
+ onMisspelling || showUndo
+ );
+ if (onMisspelling) {
+ let addMenuItem = document.getElementById(
+ "browserContext-spell-add-to-dictionary"
+ );
+ let suggestionCount = gSpellChecker.addSuggestionsToMenu(
+ addMenuItem.parentNode,
+ addMenuItem,
+ this.spellSuggestions
+ );
+ this.showItem(
+ "browserContext-spell-no-suggestions",
+ suggestionCount == 0
+ );
+ } else {
+ this.showItem("browserContext-spell-no-suggestions", false);
+ }
+
+ // dictionary list
+ this.showItem("browserContext-spell-dictionaries", showDictionaries);
+ if (canSpell) {
+ let dictMenu = document.getElementById(
+ "browserContext-spell-dictionaries-menu"
+ );
+ let dictSep = document.getElementById(
+ "browserContext-spell-language-separator"
+ );
+ let count = gSpellChecker.addDictionaryListToMenu(dictMenu, dictSep);
+ this.showItem(dictSep, count > 0);
+ this.showItem("browserContext-spell-add-dictionaries-main", false);
+ } else if (this.onSpellcheckable) {
+ // when there is no spellchecker but we might be able to spellcheck
+ // add the add to dictionaries item. This will ensure that people
+ // with no dictionaries will be able to download them
+ this.showItem(
+ "browserContext-spell-language-separator",
+ showDictionaries
+ );
+ this.showItem(
+ "browserContext-spell-add-dictionaries-main",
+ showDictionaries
+ );
+ } else {
+ this.showItem("browserContext-spell-add-dictionaries-main", false);
+ }
+ }
+ initSaveItems() {
+ this.showItem("browserContext-savelink", this.onSaveableLink);
+ this.showItem("browserContext-saveimage", this.onLoadedImage);
+ }
+ initClipboardItems() {
+ // Copy depends on whether there is selected text.
+ // Enabling this context menu item is now done through the global
+ // command updating system.
+
+ goUpdateGlobalEditMenuItems();
+
+ this.showItem("browserContext-cut", this.onTextInput);
+ this.showItem(
+ "browserContext-copy",
+ !this.onPlayableMedia && (this.isContentSelected || this.onTextInput)
+ );
+ this.showItem("browserContext-paste", this.onTextInput);
+
+ this.showItem("browserContext-undo", this.onTextInput);
+ // Select all not available in the thread pane or on playable media.
+ this.showItem("browserContext-selectall", !this.onPlayableMedia);
+ this.showItem("browserContext-copyemail", this.onMailtoLink);
+ this.showItem("browserContext-copylink", this.onLink && !this.onMailtoLink);
+ this.showItem("browserContext-copyimage", this.onImage);
+
+ this.showItem("browserContext-composeemailto", this.onMailtoLink);
+ this.showItem("browserContext-addemail", this.onMailtoLink);
+
+ let searchTheWeb = document.getElementById("browserContext-searchTheWeb");
+ this.showItem(
+ searchTheWeb,
+ !this.onPlayableMedia && this.isContentSelected
+ );
+
+ if (!searchTheWeb.hidden) {
+ let selection = this.textSelected;
+
+ let bundle = document.getElementById("bundle_messenger");
+ let key = "openSearch.label";
+ let abbrSelection;
+ if (selection.length > 15) {
+ key += ".truncated";
+ abbrSelection = selection.slice(0, 15);
+ } else {
+ abbrSelection = selection;
+ }
+
+ searchTheWeb.label = bundle.getFormattedString(key, [
+ Services.search.defaultEngine.name,
+ abbrSelection,
+ ]);
+ searchTheWeb.value = selection;
+ }
+ }
+ initMediaPlayerItems() {
+ let onMedia = this.onVideo || this.onAudio;
+ // Several mutually exclusive items.... play/pause, mute/unmute, show/hide
+ this.showItem("browserContext-media-play", onMedia && this.target.paused);
+ this.showItem("browserContext-media-pause", onMedia && !this.target.paused);
+ this.showItem("browserContext-media-mute", onMedia && !this.target.muted);
+ this.showItem("browserContext-media-unmute", onMedia && this.target.muted);
+ if (onMedia) {
+ let hasError =
+ this.target.error != null ||
+ this.target.networkState == this.target.NETWORK_NO_SOURCE;
+ this.setItemAttr("browserContext-media-play", "disabled", hasError);
+ this.setItemAttr("browserContext-media-pause", "disabled", hasError);
+ this.setItemAttr("browserContext-media-mute", "disabled", hasError);
+ this.setItemAttr("browserContext-media-unmute", "disabled", hasError);
+ }
+ }
+ initBackForwardMenuItemTooltip(menuItemId, l10nId, shortcutId) {
+ // On macOS regular menuitems are used and the shortcut isn't added.
+ if (AppConstants.platform == "macosx") {
+ return;
+ }
+
+ let shortcut = document.getElementById(shortcutId);
+ if (shortcut) {
+ shortcut = ShortcutUtils.prettifyShortcut(shortcut);
+ } else {
+ // Sidebar doesn't have navigation buttons or shortcuts, but we still
+ // want to format the menu item tooltip to remove "$shortcut" string.
+ shortcut = "";
+ }
+ let menuItem = document.getElementById(menuItemId);
+ document.l10n.setAttributes(menuItem, l10nId, { shortcut });
+ }
+ initBrowserItems() {
+ // Work out if we are a context menu on a special item e.g. an image, link
+ // etc.
+ let onSpecialItem =
+ this.isContentSelected ||
+ this.onCanvas ||
+ this.onLink ||
+ this.onImage ||
+ this.onAudio ||
+ this.onVideo ||
+ this.onTextInput;
+
+ // Internal about:* pages should not show nav items.
+ let shouldShowNavItems =
+ !onSpecialItem && this.browser.currentURI.scheme != "about";
+
+ // Ensure these commands are updated with their current status.
+ if (shouldShowNavItems) {
+ goUpdateCommand("Browser:Back");
+ goUpdateCommand("Browser:Forward");
+ goUpdateCommand("cmd_stop");
+ goUpdateCommand("cmd_reload");
+ }
+
+ let stopped = document.getElementById("cmd_stop").hasAttribute("disabled");
+ this.showItem("browserContext-reload", shouldShowNavItems && stopped);
+ this.showItem("browserContext-stop", shouldShowNavItems && !stopped);
+ this.showItem("browserContext-sep-navigation", shouldShowNavItems);
+
+ if (AppConstants.platform == "macosx") {
+ this.showItem("browserContext-back", shouldShowNavItems);
+ this.showItem("browserContext-forward", shouldShowNavItems);
+ } else {
+ this.showItem("context-navigation", shouldShowNavItems);
+
+ this.initBackForwardMenuItemTooltip(
+ "browserContext-back",
+ "content-tab-menu-back",
+ "key_goBackKb"
+ );
+ this.initBackForwardMenuItemTooltip(
+ "browserContext-forward",
+ "content-tab-menu-forward",
+ "key_goForwardKb"
+ );
+ }
+
+ // Only show open in browser if we're not on a special item and we're not
+ // on an about: or chrome: protocol - for these protocols the browser is
+ // unlikely to show the same thing as we do (if at all), so therefore don't
+ // offer the option.
+ this.showItem(
+ "browserContext-openInBrowser",
+ !onSpecialItem &&
+ ["http", "https"].includes(this.contentData?.documentURIObject?.scheme)
+ );
+
+ // Only show browserContext-openLinkInBrowser if we're on a link and it isn't
+ // a mailto link.
+ this.showItem(
+ "browserContext-openLinkInBrowser",
+ this.onLink && ["http", "https"].includes(this.linkProtocol)
+ );
+ }
+ initSeparators() {
+ let separators = Array.from(
+ this.xulMenu.querySelectorAll(":scope > menuseparator")
+ );
+ let lastShownSeparator = null;
+ for (let separator of separators) {
+ let shouldShow = this.shouldShowSeparator(separator);
+ if (
+ !shouldShow &&
+ lastShownSeparator &&
+ separator.classList.contains("webextension-group-separator")
+ ) {
+ // The separator for the WebExtension elements group must be shown, hide
+ // the last shown menu separator instead.
+ lastShownSeparator.hidden = true;
+ shouldShow = true;
+ }
+ if (shouldShow) {
+ lastShownSeparator = separator;
+ }
+ separator.hidden = !shouldShow;
+ }
+ this.checkLastSeparator(this.xulMenu);
+ }
+
+ /**
+ * Get a computed style property for an element.
+ *
+ * @param aElem
+ * A DOM node
+ * @param aProp
+ * The desired CSS property
+ * @returns the value of the property
+ */
+ getComputedStyle(aElem, aProp) {
+ return aElem.ownerGlobal.getComputedStyle(aElem).getPropertyValue(aProp);
+ }
+
+ /**
+ * Determine whether the clicked-on link can be saved, and whether it
+ * may be saved according to the ScriptSecurityManager.
+ *
+ * @returns true if the protocol can be persisted and if the target has
+ * permission to link to the URL, false if not
+ */
+ isLinkSaveable() {
+ try {
+ Services.scriptSecurityManager.checkLoadURIWithPrincipal(
+ this.target.nodePrincipal,
+ this.linkURI,
+ Ci.nsIScriptSecurityManager.STANDARD
+ );
+ } catch (e) {
+ // Don't save things we can't link to.
+ return false;
+ }
+
+ // We don't do the Right Thing for news/snews yet, so turn them off
+ // until we do.
+ return (
+ this.linkProtocol &&
+ !(
+ this.linkProtocol == "mailto" ||
+ this.linkProtocol == "javascript" ||
+ this.linkProtocol == "news" ||
+ this.linkProtocol == "snews"
+ )
+ );
+ }
+
+ /**
+ * Save URL of clicked-on link.
+ */
+ saveLink() {
+ saveURL(
+ this.linkURL,
+ null,
+ this.linkTextStr,
+ null,
+ true,
+ null,
+ null,
+ null,
+ document
+ );
+ }
+
+ /**
+ * Save a clicked-on image.
+ */
+ saveImage() {
+ saveURL(
+ this.imageInfo.currentSrc,
+ null,
+ null,
+ "SaveImageTitle",
+ false,
+ null,
+ null,
+ null,
+ document
+ );
+ }
+
+ /**
+ * Extract email addresses from a mailto: link and put them on the
+ * clipboard.
+ */
+ copyEmail() {
+ // Copy the comma-separated list of email addresses only.
+ // There are other ways of embedding email addresses in a mailto:
+ // link, but such complex parsing is beyond us.
+
+ const kMailToLength = 7; // length of "mailto:"
+
+ var url = this.linkURL;
+ var qmark = url.indexOf("?");
+ var addresses;
+
+ if (qmark > kMailToLength) {
+ addresses = url.substring(kMailToLength, qmark);
+ } else {
+ addresses = url.substr(kMailToLength);
+ }
+
+ // Let's try to unescape it using a character set.
+ try {
+ addresses = Services.textToSubURI.unEscapeURIForUI(addresses);
+ } catch (ex) {
+ // Do nothing.
+ }
+
+ var clipboard = Cc["@mozilla.org/widget/clipboardhelper;1"].getService(
+ Ci.nsIClipboardHelper
+ );
+ clipboard.copyString(addresses);
+ }
+
+ // ---------
+ // Utilities
+
+ /**
+ * Set a DOM node's hidden property by passing in the node's id or the
+ * element itself.
+ *
+ * @param aItemOrId
+ * a DOM node or the id of a DOM node
+ * @param aShow
+ * true to show, false to hide
+ */
+ showItem(aItemOrId, aShow) {
+ var item =
+ aItemOrId.constructor == String
+ ? document.getElementById(aItemOrId)
+ : aItemOrId;
+ if (item) {
+ item.hidden = !aShow;
+ }
+ }
+
+ /**
+ * Set a DOM node's disabled property by passing in the node's id or the
+ * element itself.
+ *
+ * @param aItemOrId A DOM node or the id of a DOM node
+ * @param aEnabled True to enable the element, false to disable.
+ */
+ enableItem(aItemOrId, aEnabled) {
+ var item =
+ aItemOrId.constructor == String
+ ? document.getElementById(aItemOrId)
+ : aItemOrId;
+ item.disabled = !aEnabled;
+ }
+
+ /**
+ * Set given attribute of specified context-menu item. If the
+ * value is null, then it removes the attribute (which works
+ * nicely for the disabled attribute).
+ *
+ * @param aId
+ * The id of an element
+ * @param aAttr
+ * The attribute name
+ * @param aVal
+ * The value to set the attribute to, or null to remove the attribute
+ */
+ setItemAttr(aId, aAttr, aVal) {
+ var elem = document.getElementById(aId);
+ if (elem) {
+ if (aVal == null) {
+ // null indicates attr should be removed.
+ elem.removeAttribute(aAttr);
+ } else {
+ // Set attr=val.
+ elem.setAttribute(aAttr, aVal);
+ }
+ }
+ }
+
+ /**
+ * Get an absolute URL for clicked-on link, from the href property or by
+ * resolving an XLink URL by hand.
+ *
+ * @returns the string absolute URL for the clicked-on link
+ */
+ getLinkURL() {
+ if (this.link.href) {
+ return this.link.href;
+ }
+ var href = this.link.getAttributeNS("http://www.w3.org/1999/xlink", "href");
+ if (!href || href.trim() == "") {
+ // Without this we try to save as the current doc,
+ // for example, HTML case also throws if empty.
+ throw new Error("Empty href");
+ }
+ href = this.makeURLAbsolute(this.link.baseURI, href);
+ return href;
+ }
+
+ /**
+ * Generate a URI object from the linkURL spec
+ *
+ * @returns an nsIURI if possible, or null if not
+ */
+ getLinkURI() {
+ try {
+ return Services.io.newURI(this.linkURL);
+ } catch (ex) {
+ // e.g. empty URL string
+ }
+ return null;
+ }
+
+ /**
+ * Get the scheme for the clicked-on linkURI, if present.
+ *
+ * @returns a scheme, possibly undefined, or null if there's no linkURI
+ */
+ getLinkProtocol() {
+ if (this.linkURI) {
+ return this.linkURI.scheme; // Can be |undefined|.
+ }
+
+ return null;
+ }
+
+ /**
+ * Get the text of the clicked-on link.
+ *
+ * @returns {string}
+ */
+ linkText() {
+ return this.linkTextStr;
+ }
+
+ /**
+ * Determines whether the focused window has something selected.
+ *
+ * @returns true if there is a selection, false if not
+ */
+ isContentSelection() {
+ return !document.commandDispatcher.focusedWindow.getSelection().isCollapsed;
+ }
+
+ /**
+ * Convert relative URL to absolute, using a provided <base>.
+ *
+ * @param aBase
+ * The URL string to use as the base
+ * @param aUrl
+ * The possibly-relative URL string
+ * @returns The string absolute URL
+ */
+ makeURLAbsolute(aBase, aUrl) {
+ // Construct nsIURL.
+ var baseURI = Services.io.newURI(aBase);
+
+ return Services.io.newURI(baseURI.resolve(aUrl)).spec;
+ }
+
+ /**
+ * Determine whether a DOM node is a text or password input, or a textarea.
+ *
+ * @param aNode
+ * The DOM node to check
+ * @returns true for textboxes, false for other elements
+ */
+ isTargetATextBox(aNode) {
+ if (HTMLInputElement.isInstance(aNode)) {
+ return aNode.type == "text" || aNode.type == "password";
+ }
+
+ return HTMLTextAreaElement.isInstance(aNode);
+ }
+
+ /**
+ * Determine whether a separator should be shown based on whether
+ * there are any non-hidden items between it and the previous separator.
+ *
+ * @param {DomElement} element - The separator element.
+ * @returns {boolean} True if the separator should be shown, false if not.
+ */
+ shouldShowSeparator(element) {
+ if (element) {
+ let sibling = element.previousElementSibling;
+ while (sibling && sibling.localName != "menuseparator") {
+ if (!sibling.hidden) {
+ return true;
+ }
+ sibling = sibling.previousElementSibling;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Ensures that there isn't a separator shown at the bottom of the menu.
+ *
+ * @param aPopup The menu to check.
+ */
+ checkLastSeparator(aPopup) {
+ let sibling = aPopup.lastElementChild;
+ while (sibling) {
+ if (!sibling.hidden) {
+ if (sibling.localName == "menuseparator") {
+ // If we got here then the item is a menuseparator and everything
+ // below it hidden.
+ sibling.setAttribute("hidden", true);
+ return;
+ }
+ return;
+ }
+ sibling = sibling.previousElementSibling;
+ }
+ }
+
+ openInBrowser() {
+ let url = this.contentData?.documentURIObject?.spec;
+ if (!url) {
+ return;
+ }
+ PlacesUtils.history
+ .insert({
+ url,
+ visits: [
+ {
+ date: new Date(),
+ },
+ ],
+ })
+ .catch(console.error);
+ Cc["@mozilla.org/uriloader/external-protocol-service;1"]
+ .getService(Ci.nsIExternalProtocolService)
+ .loadURI(Services.io.newURI(url));
+ }
+
+ openLinkInBrowser() {
+ PlacesUtils.history
+ .insert({
+ url: this.linkURL,
+ visits: [
+ {
+ date: new Date(),
+ },
+ ],
+ })
+ .catch(console.error);
+ Cc["@mozilla.org/uriloader/external-protocol-service;1"]
+ .getService(Ci.nsIExternalProtocolService)
+ .loadURI(this.linkURI);
+ }
+
+ mediaCommand(command) {
+ var media = this.target;
+
+ switch (command) {
+ case "play":
+ media.play();
+ break;
+ case "pause":
+ media.pause();
+ break;
+ case "mute":
+ media.muted = true;
+ break;
+ case "unmute":
+ media.muted = false;
+ break;
+ // XXX hide controls & show controls don't work in emails as Javascript is
+ // disabled. May want to consider later for RSS feeds.
+ }
+ }
+}
+
+ChromeUtils.defineESModuleGetters(nsContextMenu, {
+ WebNavigationFrames: "resource://gre/modules/WebNavigationFrames.sys.mjs",
+});
diff --git a/comm/mail/base/content/widgets/customizable-toolbar.js b/comm/mail/base/content/widgets/customizable-toolbar.js
new file mode 100644
index 0000000000..350e814716
--- /dev/null
+++ b/comm/mail/base/content/widgets/customizable-toolbar.js
@@ -0,0 +1,319 @@
+/* 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/. */
+
+"use strict";
+
+/* globals MozXULElement */
+
+// Wrap in a block to prevent leaking to window scope.
+{
+ /**
+ * Extends the built-in `toolbar` element to allow it to be customized.
+ *
+ * @augments {MozXULElement}
+ */
+ class CustomizableToolbar extends MozXULElement {
+ connectedCallback() {
+ if (this.delayConnectedCallback() || this._hasConnected) {
+ return;
+ }
+ this._hasConnected = true;
+
+ this._toolbox = null;
+ this._newElementCount = 0;
+
+ // Search for the toolbox palette in the toolbar binding because
+ // toolbars are constructed first.
+ let toolbox = this.toolbox;
+ if (!toolbox) {
+ return;
+ }
+
+ if (!toolbox.palette) {
+ // Look to see if there is a toolbarpalette.
+ let node = toolbox.firstElementChild;
+ while (node) {
+ if (node.localName == "toolbarpalette") {
+ break;
+ }
+ node = node.nextElementSibling;
+ }
+
+ if (!node) {
+ return;
+ }
+
+ // Hold on to the palette but remove it from the document.
+ toolbox.palette = node;
+ toolbox.removeChild(node);
+ }
+
+ // Build up our contents from the palette.
+ let currentSet =
+ this.getAttribute("currentset") || this.getAttribute("defaultset");
+
+ if (currentSet) {
+ this.currentSet = currentSet;
+ }
+ }
+
+ /**
+ * Get the toolbox element connected to this toolbar.
+ *
+ * @returns {Element?} The toolbox element or null.
+ */
+ get toolbox() {
+ if (this._toolbox) {
+ return this._toolbox;
+ }
+
+ let toolboxId = this.getAttribute("toolboxid");
+ if (toolboxId) {
+ let toolbox = document.getElementById(toolboxId);
+ if (!toolbox) {
+ let tbName = this.hasAttribute("toolbarname")
+ ? ` (${this.getAttribute("toolbarname")})`
+ : "";
+
+ throw new Error(
+ `toolbar ID ${this.id}${tbName}: toolboxid attribute '${toolboxId}' points to a toolbox that doesn't exist`
+ );
+ }
+ this._toolbox = toolbox;
+ return this._toolbox;
+ }
+
+ this._toolbox =
+ this.parentNode && this.parentNode.localName == "toolbox"
+ ? this.parentNode
+ : null;
+
+ return this._toolbox;
+ }
+
+ /**
+ * Sets the current set of items in the toolbar.
+ *
+ * @param {string} val - Comma-separated list of IDs or "__empty".
+ * @returns {string} Comma-separated list of IDs or "__empty".
+ */
+ set currentSet(val) {
+ if (val == this.currentSet) {
+ return;
+ }
+
+ // Build a cache of items in the toolbarpalette.
+ let palette = this.toolbox ? this.toolbox.palette : null;
+ let paletteChildren = palette ? palette.children : [];
+
+ let paletteItems = {};
+
+ for (let item of paletteChildren) {
+ paletteItems[item.id] = item;
+ }
+
+ let ids = val == "__empty" ? [] : val.split(",");
+ let children = this.children;
+ let nodeidx = 0;
+ let added = {};
+
+ // Iterate over the ids to use on the toolbar.
+ for (let id of ids) {
+ // Iterate over the existing nodes on the toolbar. nodeidx is the
+ // spot where we want to insert items.
+ let found = false;
+ for (let i = nodeidx; i < children.length; i++) {
+ let curNode = children[i];
+ if (this._idFromNode(curNode) == id) {
+ // The node already exists. If i equals nodeidx, we haven't
+ // iterated yet, so the item is already in the right position.
+ // Otherwise, insert it here.
+ if (i != nodeidx) {
+ this.insertBefore(curNode, children[nodeidx]);
+ }
+
+ added[curNode.id] = true;
+ nodeidx++;
+ found = true;
+ break;
+ }
+ }
+ if (found) {
+ // Move on to the next id.
+ continue;
+ }
+
+ // The node isn't already on the toolbar, so add a new one.
+ let nodeToAdd = paletteItems[id] || this._getToolbarItem(id);
+ if (nodeToAdd && !(nodeToAdd.id in added)) {
+ added[nodeToAdd.id] = true;
+ this.insertBefore(nodeToAdd, children[nodeidx] || null);
+ nodeToAdd.setAttribute("removable", "true");
+ nodeidx++;
+ }
+ }
+
+ // Remove any leftover removable nodes.
+ for (let i = children.length - 1; i >= nodeidx; i--) {
+ let curNode = children[i];
+
+ let curNodeId = this._idFromNode(curNode);
+ // Skip over fixed items.
+ if (curNodeId && curNode.getAttribute("removable") == "true") {
+ if (palette) {
+ palette.appendChild(curNode);
+ } else {
+ this.removeChild(curNode);
+ }
+ }
+ }
+ }
+
+ /**
+ * Gets the current set of items in the toolbar.
+ *
+ * @returns {string} Comma-separated list of IDs or "__empty".
+ */
+ get currentSet() {
+ let node = this.firstElementChild;
+ let currentSet = [];
+ while (node) {
+ let id = this._idFromNode(node);
+ if (id) {
+ currentSet.push(id);
+ }
+ node = node.nextElementSibling;
+ }
+
+ return currentSet.join(",") || "__empty";
+ }
+
+ /**
+ * Return the ID for a given toolbar item node, with special handling for
+ * some cases.
+ *
+ * @param {Element} node - Return the ID of this node.
+ * @returns {string} The ID of the node.
+ */
+ _idFromNode(node) {
+ if (node.getAttribute("skipintoolbarset") == "true") {
+ return "";
+ }
+ const specialItems = {
+ toolbarseparator: "separator",
+ toolbarspring: "spring",
+ toolbarspacer: "spacer",
+ };
+ return specialItems[node.localName] || node.id;
+ }
+
+ /**
+ * Returns a toolbar item based on the given ID.
+ *
+ * @param {string} id - The ID for the new toolbar item.
+ * @returns {Element?} The toolbar item corresponding to the ID, or null.
+ */
+ _getToolbarItem(id) {
+ // Handle special cases.
+ if (["separator", "spring", "spacer"].includes(id)) {
+ let newItem = document.createXULElement("toolbar" + id);
+ // Due to timers resolution Date.now() can be the same for
+ // elements created in small timeframes. So ids are
+ // differentiated through a unique count suffix.
+ newItem.id = id + Date.now() + ++this._newElementCount;
+ if (id == "spring") {
+ newItem.flex = 1;
+ }
+ return newItem;
+ }
+
+ let toolbox = this.toolbox;
+ if (!toolbox) {
+ return null;
+ }
+
+ // Look for an item with the same id, as the item may be
+ // in a different toolbar.
+ let item = document.getElementById(id);
+ if (
+ item &&
+ item.parentNode &&
+ item.parentNode.localName == "toolbar" &&
+ item.parentNode.toolbox == toolbox
+ ) {
+ return item;
+ }
+
+ if (toolbox.palette) {
+ // Attempt to locate an item with a matching ID within the palette.
+ let paletteItem = toolbox.palette.firstElementChild;
+ while (paletteItem) {
+ if (paletteItem.id == id) {
+ return paletteItem;
+ }
+ paletteItem = paletteItem.nextElementSibling;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Insert an item into the toolbar.
+ *
+ * @param {string} id - The ID of the item to insert.
+ * @param {Element?} beforeElt - Optional element to insert the item before.
+ * @param {Element?} wrapper - Optional wrapper element.
+ * @returns {Element} The inserted item.
+ */
+ insertItem(id, beforeElt, wrapper) {
+ let newItem = this._getToolbarItem(id);
+ if (!newItem) {
+ return null;
+ }
+
+ let insertItem = newItem;
+ // Make sure added items are removable.
+ newItem.setAttribute("removable", "true");
+
+ // Wrap the item in another node if so inclined.
+ if (wrapper) {
+ wrapper.appendChild(newItem);
+ insertItem = wrapper;
+ }
+
+ // Insert the palette item into the toolbar.
+ if (beforeElt) {
+ this.insertBefore(insertItem, beforeElt);
+ } else {
+ this.appendChild(insertItem);
+ }
+ return newItem;
+ }
+
+ /**
+ * Determine whether the current set of toolbar items has custom
+ * interactive items or not.
+ *
+ * @param {string} currentSet - Comma-separated list of IDs or "__empty".
+ * @returns {boolean} Whether the current set has custom interactive items.
+ */
+ hasCustomInteractiveItems(currentSet) {
+ if (currentSet == "__empty") {
+ return false;
+ }
+
+ let defaultOrNoninteractive = (this.getAttribute("defaultset") || "")
+ .split(",")
+ .concat(["separator", "spacer", "spring"]);
+
+ return currentSet
+ .split(",")
+ .some(item => !defaultOrNoninteractive.includes(item));
+ }
+ }
+
+ customElements.define("customizable-toolbar", CustomizableToolbar, {
+ extends: "toolbar",
+ });
+}
diff --git a/comm/mail/base/content/widgets/foldersummary.js b/comm/mail/base/content/widgets/foldersummary.js
new file mode 100644
index 0000000000..48bcb34d26
--- /dev/null
+++ b/comm/mail/base/content/widgets/foldersummary.js
@@ -0,0 +1,295 @@
+/**
+ * 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/. */
+
+/* global MozElements */
+/* global MozXULElement */
+/* import-globals-from ../../../../mailnews/base/content/newmailalert.js */
+
+// Wrap in a block to prevent leaking to window scope.
+{
+ const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+ );
+
+ /**
+ * MozFolderSummary displays a listing of NEW mails for the folder in question.
+ * For each mail the subject, sender and a message preview can be included.
+ *
+ * @augments {MozXULElement}
+ */
+ class MozFolderSummary extends MozXULElement {
+ constructor() {
+ super();
+ this.maxMsgHdrsInPopup = 8;
+
+ this.showSubject = Services.prefs.getBoolPref(
+ "mail.biff.alert.show_subject"
+ );
+ this.showSender = Services.prefs.getBoolPref(
+ "mail.biff.alert.show_sender"
+ );
+ this.showPreview = Services.prefs.getBoolPref(
+ "mail.biff.alert.show_preview"
+ );
+ this.messengerBundle = Services.strings.createBundle(
+ "chrome://messenger/locale/messenger.properties"
+ );
+
+ ChromeUtils.defineModuleGetter(
+ this,
+ "MailUtils",
+ "resource:///modules/MailUtils.jsm"
+ );
+ }
+
+ hasMessages() {
+ return this.lastElementChild;
+ }
+
+ static createFolderSummaryMessage() {
+ let vbox = document.createXULElement("vbox");
+ vbox.setAttribute("class", "folderSummaryMessage");
+
+ let hbox = document.createXULElement("hbox");
+ hbox.setAttribute("class", "folderSummary-message-row");
+
+ let subject = document.createXULElement("label");
+ subject.setAttribute("class", "folderSummary-subject");
+
+ let sender = document.createXULElement("label");
+ sender.setAttribute("class", "folderSummary-sender");
+ sender.setAttribute("crop", "end");
+
+ hbox.appendChild(subject);
+ hbox.appendChild(sender);
+
+ let preview = document.createXULElement("description");
+ preview.setAttribute(
+ "class",
+ "folderSummary-message-row folderSummary-previewText"
+ );
+ preview.setAttribute("crop", "end");
+
+ vbox.appendChild(hbox);
+ vbox.appendChild(preview);
+ return vbox;
+ }
+
+ /**
+ * Check the given folder for NEW messages.
+ *
+ * @param {nsIMsgFolder} folder - The folder to examine.
+ * @param {nsIUrlListener} urlListener - Listener to notify if we run urls
+ * to fetch msgs.
+ * @param Object outAsync - Object with value property set to true if there
+ * are async fetches pending (a message preview will be available later).
+ * @returns true if the folder knows about messages that should be shown.
+ */
+ parseFolder(folder, urlListener, outAsync) {
+ // Skip servers, Trash, Junk folders and newsgroups.
+ if (
+ !folder ||
+ folder.isServer ||
+ !folder.hasNewMessages ||
+ folder.getFlag(Ci.nsMsgFolderFlags.Junk) ||
+ folder.getFlag(Ci.nsMsgFolderFlags.Trash) ||
+ folder.server instanceof Ci.nsINntpIncomingServer
+ ) {
+ return false;
+ }
+
+ let folderArray = [];
+ let msgDatabase;
+ try {
+ msgDatabase = folder.msgDatabase;
+ } catch (e) {
+ // The database for this folder may be missing (e.g. outdated/missing .msf),
+ // so just skip this folder.
+ return false;
+ }
+
+ if (folder.flags & Ci.nsMsgFolderFlags.Virtual) {
+ let srchFolderUri =
+ msgDatabase.dBFolderInfo.getCharProperty("searchFolderUri");
+ let folderUris = srchFolderUri.split("|");
+ for (let uri of folderUris) {
+ let realFolder = this.MailUtils.getOrCreateFolder(uri);
+ if (!realFolder.isServer) {
+ folderArray.push(realFolder);
+ }
+ }
+ } else {
+ folderArray.push(folder);
+ }
+
+ let haveMsgsToShow = false;
+ for (let folder of folderArray) {
+ // now get the database
+ try {
+ msgDatabase = folder.msgDatabase;
+ } catch (e) {
+ // The database for this folder may be missing (e.g. outdated/missing .msf),
+ // then just skip this folder.
+ continue;
+ }
+
+ folder.msgDatabase = null;
+ let msgKeys = msgDatabase.getNewList();
+
+ let numNewMessages = folder.getNumNewMessages(false);
+ if (!numNewMessages) {
+ continue;
+ }
+ // NOTE: getNewlist returns all nsMsgMessageFlagType::New messages,
+ // while getNumNewMessages returns count of new messages since the last
+ // biff. Only show newly received messages since last biff in
+ // notification.
+ msgKeys = msgKeys.slice(-numNewMessages);
+ if (!msgKeys.length) {
+ continue;
+ }
+
+ if (this.showPreview) {
+ // fetchMsgPreviewText forces the previewText property to get generated
+ // for each of the message keys.
+ try {
+ outAsync.value = folder.fetchMsgPreviewText(msgKeys, urlListener);
+ folder.msgDatabase = null;
+ } catch (ex) {
+ // fetchMsgPreviewText throws an error when we call it on a news
+ // folder
+ folder.msgDatabase = null;
+ continue;
+ }
+ }
+
+ // If fetching the preview text is going to be an asynch operation and the
+ // caller is set up to handle that fact, then don't bother filling in any
+ // of the fields since we'll have to do this all over again when the fetch
+ // for the preview text completes.
+ // We don't expect to get called with a urlListener if we're doing a
+ // virtual folder.
+ if (outAsync.value && urlListener) {
+ return false;
+ }
+
+ // In the case of async fetching for more than one folder, we may
+ // already have got enough to show (added by another urllistener).
+ let curHdrsInPopup = this.children.length;
+ if (curHdrsInPopup >= this.maxMsgHdrsInPopup) {
+ return false;
+ }
+
+ for (
+ let i = 0;
+ i + curHdrsInPopup < this.maxMsgHdrsInPopup && i < msgKeys.length;
+ i++
+ ) {
+ let msgBox = MozFolderSummary.createFolderSummaryMessage();
+ let msgHdr = msgDatabase.getMsgHdrForKey(msgKeys[i]);
+ msgBox.addEventListener("click", event => {
+ if (event.button !== 0) {
+ return;
+ }
+ this.MailUtils.displayMessageInFolderTab(msgHdr, true);
+ });
+
+ if (this.showSubject) {
+ let msgSubject = msgHdr.mime2DecodedSubject;
+ const kMsgFlagHasRe = 0x0010; // MSG_FLAG_HAS_RE
+ if (msgHdr.flags & kMsgFlagHasRe) {
+ msgSubject = msgSubject ? "Re: " + msgSubject : "Re: ";
+ }
+ msgBox.querySelector(".folderSummary-subject").textContent =
+ msgSubject;
+ }
+
+ if (this.showSender) {
+ let addrs = MailServices.headerParser.parseEncodedHeader(
+ msgHdr.author,
+ msgHdr.effectiveCharset,
+ false
+ );
+ let folderSummarySender = msgBox.querySelector(
+ ".folderSummary-sender"
+ );
+ // Set the label value instead of textContent to avoid wrapping.
+ folderSummarySender.value =
+ addrs.length > 0 ? addrs[0].name || addrs[0].email : "";
+ if (addrs.length > 1) {
+ let andOthersStr =
+ this.messengerBundle.GetStringFromName("andOthers");
+ folderSummarySender.value += " " + andOthersStr;
+ }
+ }
+
+ if (this.showPreview) {
+ msgBox.querySelector(".folderSummary-previewText").textContent =
+ msgHdr.getStringProperty("preview") || "";
+ }
+ this.appendChild(msgBox);
+ haveMsgsToShow = true;
+ }
+ }
+ return haveMsgsToShow;
+ }
+
+ /**
+ * Render NEW messages in a folder.
+ *
+ * @param {nsIMsgFolder} folder - A real folder containing new messages.
+ * @param {number[]} msgKeys - The keys of new messages.
+ */
+ render(folder, msgKeys) {
+ let msgDatabase = folder.msgDatabase;
+ for (let msgKey of msgKeys.slice(0, this.maxMsgHdrsInPopup)) {
+ let msgBox = MozFolderSummary.createFolderSummaryMessage();
+ let msgHdr = msgDatabase.getMsgHdrForKey(msgKey);
+ msgBox.addEventListener("click", event => {
+ if (event.button !== 0) {
+ return;
+ }
+ this.MailUtils.displayMessageInFolderTab(msgHdr, true);
+ });
+
+ if (this.showSubject) {
+ let msgSubject = msgHdr.mime2DecodedSubject;
+ const kMsgFlagHasRe = 0x0010; // MSG_FLAG_HAS_RE
+ if (msgHdr.flags & kMsgFlagHasRe) {
+ msgSubject = msgSubject ? "Re: " + msgSubject : "Re: ";
+ }
+ msgBox.querySelector(".folderSummary-subject").textContent =
+ msgSubject;
+ }
+
+ if (this.showSender) {
+ let addrs = MailServices.headerParser.parseEncodedHeader(
+ msgHdr.author,
+ msgHdr.effectiveCharset,
+ false
+ );
+ let folderSummarySender = msgBox.querySelector(
+ ".folderSummary-sender"
+ );
+ // Set the label value instead of textContent to avoid wrapping.
+ folderSummarySender.value =
+ addrs.length > 0 ? addrs[0].name || addrs[0].email : "";
+ if (addrs.length > 1) {
+ let andOthersStr =
+ this.messengerBundle.GetStringFromName("andOthers");
+ folderSummarySender.value += " " + andOthersStr;
+ }
+ }
+
+ if (this.showPreview) {
+ msgBox.querySelector(".folderSummary-previewText").textContent =
+ msgHdr.getStringProperty("preview") || "";
+ }
+ this.appendChild(msgBox);
+ }
+ }
+ }
+ customElements.define("folder-summary", MozFolderSummary);
+}
diff --git a/comm/mail/base/content/widgets/gloda-autocomplete-input.js b/comm/mail/base/content/widgets/gloda-autocomplete-input.js
new file mode 100644
index 0000000000..59f71ba6ae
--- /dev/null
+++ b/comm/mail/base/content/widgets/gloda-autocomplete-input.js
@@ -0,0 +1,243 @@
+/**
+ * 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/. */
+
+/* global MozXULElement */
+
+"use strict";
+
+// The autocomplete CE is defined lazily. Create one now to get
+// autocomplete-input defined, allowing us to inherit from it.
+if (!customElements.get("autocomplete-input")) {
+ delete document.createXULElement("input", { is: "autocomplete-input" });
+}
+
+customElements.whenDefined("autocomplete-input").then(() => {
+ const { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+ );
+ const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+ );
+
+ const lazy = {};
+ ChromeUtils.defineESModuleGetters(lazy, {
+ GlodaIMSearcher: "resource:///modules/GlodaIMSearcher.sys.mjs",
+ });
+ ChromeUtils.defineModuleGetter(
+ lazy,
+ "Gloda",
+ "resource:///modules/gloda/GlodaPublic.jsm"
+ );
+ ChromeUtils.defineModuleGetter(
+ lazy,
+ "GlodaMsgSearcher",
+ "resource:///modules/gloda/GlodaMsgSearcher.jsm"
+ );
+ ChromeUtils.defineModuleGetter(
+ lazy,
+ "GlodaConstants",
+ "resource:///modules/gloda/GlodaConstants.jsm"
+ );
+
+ XPCOMUtils.defineLazyGetter(
+ lazy,
+ "glodaCompleter",
+ () =>
+ Cc["@mozilla.org/autocomplete/search;1?name=gloda"].getService(
+ Ci.nsIAutoCompleteSearch
+ ).wrappedJSObject
+ );
+
+ /**
+ * The MozGlodaAutocompleteInput widget is used to display the autocomplete search bar.
+ *
+ * @augments {AutocompleteInput}
+ */
+ class MozGlodaAutocompleteInput extends customElements.get(
+ "autocomplete-input"
+ ) {
+ constructor() {
+ super();
+
+ this.addEventListener(
+ "drop",
+ event => {
+ this.searchInputDNDObserver.onDrop(event);
+ },
+ true
+ );
+
+ this.addEventListener("keypress", event => {
+ if (event.keyCode == KeyEvent.DOM_VK_RETURN) {
+ // Trigger the click event if a popup result is currently selected.
+ if (this.popup.richlistbox.selectedIndex != -1) {
+ this.popup.onPopupClick(event);
+ } else {
+ this.doSearch();
+ }
+ event.preventDefault();
+ event.stopPropagation();
+ }
+
+ if (event.keyCode == KeyEvent.DOM_VK_ESCAPE) {
+ this.clearSearch();
+ event.preventDefault();
+ event.stopPropagation();
+ }
+ });
+ }
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+
+ this.hasConnected = true;
+ super.connectedCallback();
+
+ this.setAttribute("is", "gloda-autocomplete-input");
+
+ // @implements {nsIObserver}
+ this.searchInputDNDObserver = {
+ onDrop: event => {
+ if (event.dataTransfer.types.includes("text/x-moz-address")) {
+ this.focus();
+ this.value = event.dataTransfer.getData("text/plain");
+ // XXX for some reason the input field is _cleared_ even though
+ // the search works.
+ this.doSearch();
+ }
+ event.stopPropagation();
+ },
+ };
+
+ // @implements {nsIObserver}
+ this.textObserver = {
+ observe: (subject, topic, data) => {
+ try {
+ // Some autocomplete controllers throw NS_ERROR_NOT_IMPLEMENTED.
+ subject.popupElement;
+ } catch (ex) {
+ return;
+ }
+ if (
+ topic == "autocomplete-did-enter-text" &&
+ document.activeElement == this
+ ) {
+ let selectedIndex = this.popup.selectedIndex;
+ let curResult = lazy.glodaCompleter.curResult;
+ if (!curResult) {
+ // autocomplete didn't even finish.
+ return;
+ }
+ let row = curResult.getObjectAt(selectedIndex);
+ if (row == null) {
+ return;
+ }
+ if (row.fullText) {
+ // The autocomplete-did-enter-text notification is synchronously
+ // generated by nsAutoCompleteController which will attempt to
+ // call ClosePopup after we return and then tell the searchbox
+ // about the text entered. Since doSearch may close the current
+ // tab (and thus destroy the XUL document that owns the popup and
+ // the input field), the search box may no longer have its
+ // binding attached when we return and telling it about the
+ // entered text could fail.
+ // To avoid this, we defer the doSearch call to the next turn of
+ // the event loop by using setTimeout.
+ setTimeout(this.doSearch.bind(this), 0);
+ } else if (row.nounDef) {
+ let theQuery = lazy.Gloda.newQuery(
+ lazy.GlodaConstants.NOUN_MESSAGE
+ );
+ if (row.nounDef.name == "tag") {
+ theQuery = theQuery.tags(row.item);
+ } else if (row.nounDef.name == "identity") {
+ theQuery = theQuery.involves(row.item);
+ }
+ theQuery.orderBy("-date");
+ document.getElementById("tabmail").openTab("glodaFacet", {
+ query: theQuery,
+ });
+ }
+ }
+ },
+ };
+
+ let keyLabel =
+ AppConstants.platform == "macosx" ? "keyLabelMac" : "keyLabelNonMac";
+ let placeholder = this.getAttribute("emptytextbase").replace(
+ "#1",
+ this.getAttribute(keyLabel)
+ );
+
+ this.setAttribute("placeholder", placeholder);
+
+ Services.obs.addObserver(
+ this.textObserver,
+ "autocomplete-did-enter-text"
+ );
+
+ // make sure we set our emptytext here from the get-go
+ if (this.hasAttribute("placeholder")) {
+ this.placeholder = this.getAttribute("placeholder");
+ }
+ }
+
+ set state(val) {
+ this.value = val.string;
+ }
+
+ get state() {
+ return { string: this.value };
+ }
+
+ doSearch() {
+ if (this.value) {
+ let tabmail = document.getElementById("tabmail");
+ // If the current tab is a gloda search tab, reset the value
+ // to the initial search value. Otherwise, clear it. This
+ // is the value that is going to be saved with the current
+ // tab when we switch back to it next.
+ let searchString = this.value;
+
+ if (tabmail.currentTabInfo.mode.name == "glodaFacet") {
+ // We'd rather reuse the existing tab (and somehow do something
+ // smart with any preexisting facet choices, but that's a
+ // bit hard right now, so doing the cheap thing and closing
+ // this tab and starting over.
+ tabmail.closeTab();
+ }
+ this.value = ""; // clear our value, to avoid persistence
+ let args = {
+ searcher: new lazy.GlodaMsgSearcher(null, searchString),
+ };
+ if (Services.prefs.getBoolPref("mail.chat.enabled")) {
+ args.IMSearcher = new lazy.GlodaIMSearcher(null, searchString);
+ }
+ tabmail.openTab("glodaFacet", args);
+ }
+ }
+
+ clearSearch() {
+ this.value = "";
+ }
+
+ disconnectedCallback() {
+ Services.obs.removeObserver(
+ this.textObserver,
+ "autocomplete-did-enter-text"
+ );
+ this.hasConnected = false;
+ }
+ }
+
+ MozXULElement.implementCustomInterface(MozGlodaAutocompleteInput, [
+ Ci.nsIObserver,
+ ]);
+ customElements.define("gloda-autocomplete-input", MozGlodaAutocompleteInput, {
+ extends: "input",
+ });
+});
diff --git a/comm/mail/base/content/widgets/glodaFacet.js b/comm/mail/base/content/widgets/glodaFacet.js
new file mode 100644
index 0000000000..c8d1e78dd8
--- /dev/null
+++ b/comm/mail/base/content/widgets/glodaFacet.js
@@ -0,0 +1,1823 @@
+/* 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/. */
+
+/* global DateFacetVis, FacetContext */
+
+// Wrap in a block to prevent leaking to window scope.
+{
+ const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+ );
+ const { TagUtils } = ChromeUtils.import("resource:///modules/TagUtils.jsm");
+ const { FacetUtils } = ChromeUtils.import(
+ "resource:///modules/gloda/Facet.jsm"
+ );
+ const { PluralForm } = ChromeUtils.importESModule(
+ "resource://gre/modules/PluralForm.sys.mjs"
+ );
+ const { Gloda } = ChromeUtils.import("resource:///modules/gloda/Gloda.jsm");
+
+ var glodaFacetStrings = Services.strings.createBundle(
+ "chrome://messenger/locale/glodaFacetView.properties"
+ );
+
+ class MozFacetDate extends HTMLElement {
+ get build() {
+ return this.buildFunc;
+ }
+
+ get brushItems() {
+ return items => this.vis.hoverItems(items);
+ }
+
+ get clearBrushedItems() {
+ return () => this.vis.clearHover();
+ }
+
+ connectedCallback() {
+ const wrapper = document.createElement("div");
+ wrapper.classList.add("facet", "date-wrapper");
+
+ const h2 = document.createElement("h2");
+
+ const canvas = document.createElement("div");
+ canvas.classList.add("date-vis-frame");
+
+ const zoomOut = document.createElement("div");
+ zoomOut.classList.add("facet-date-zoom-out");
+ zoomOut.setAttribute("role", "image");
+ zoomOut.addEventListener("click", () => FacetContext.zoomOut());
+
+ wrapper.appendChild(h2);
+ wrapper.appendChild(canvas);
+ wrapper.appendChild(zoomOut);
+ this.appendChild(wrapper);
+
+ this.canUpdate = true;
+ this.canvasNode = canvas;
+ this.vis = null;
+ if ("faceter" in this) {
+ this.buildFunc(true);
+ }
+ }
+
+ buildFunc(aDoSize) {
+ if (!this.vis) {
+ this.vis = new DateFacetVis(this, this.canvasNode);
+ this.vis.build();
+ } else {
+ while (this.canvasNode.hasChildNodes()) {
+ this.canvasNode.lastChild.remove();
+ }
+ if (aDoSize) {
+ this.vis.build();
+ } else {
+ this.vis.rebuild();
+ }
+ }
+ }
+ }
+
+ customElements.define("facet-date", MozFacetDate);
+
+ /**
+ * MozFacetResultsMessage shows the search results for the string entered in gloda-searchbox.
+ *
+ * @augments {HTMLElement}
+ */
+ class MozFacetResultsMessage extends HTMLElement {
+ connectedCallback() {
+ const header = document.createElement("div");
+ header.classList.add("results-message-header");
+
+ this.countNode = document.createElement("h2");
+ this.countNode.classList.add("results-message-count");
+
+ this.toggleTimeline = document.createElement("button");
+ this.toggleTimeline.setAttribute("id", "date-toggle");
+ this.toggleTimeline.setAttribute("tabindex", 0);
+ this.toggleTimeline.classList.add("gloda-timeline-button");
+ this.toggleTimeline.addEventListener("click", () => {
+ FacetContext.toggleTimeline();
+ });
+
+ const timelineImage = document.createElement("img");
+ timelineImage.setAttribute(
+ "src",
+ "chrome://messenger/skin/icons/popular.svg"
+ );
+ timelineImage.setAttribute("alt", "");
+ this.toggleTimeline.appendChild(timelineImage);
+
+ this.toggleText = document.createElement("span");
+ this.toggleTimeline.appendChild(this.toggleText);
+
+ const sortDiv = document.createElement("div");
+ sortDiv.classList.add("results-message-sort-bar");
+
+ this.sortSelect = document.createElement("select");
+ this.sortSelect.setAttribute("id", "sortby");
+ let sortByPref = Services.prefs.getIntPref("gloda.facetview.sortby");
+
+ let relevanceItem = document.createElement("option");
+ relevanceItem.textContent = glodaFacetStrings.GetStringFromName(
+ "glodaFacetView.results.message.sort.relevance2"
+ );
+ relevanceItem.setAttribute("value", "-dascore");
+ relevanceItem.toggleAttribute(
+ "selected",
+ sortByPref <= 0 || sortByPref == 2 || sortByPref > 3
+ );
+ this.sortSelect.appendChild(relevanceItem);
+
+ let dateItem = document.createElement("option");
+ dateItem.textContent = glodaFacetStrings.GetStringFromName(
+ "glodaFacetView.results.message.sort.date2"
+ );
+ dateItem.setAttribute("value", "-date");
+ dateItem.toggleAttribute("selected", sortByPref == 1 || sortByPref == 3);
+ this.sortSelect.appendChild(dateItem);
+
+ this.messagesNode = document.createElement("div");
+ this.messagesNode.classList.add("messages");
+
+ header.appendChild(this.countNode);
+ header.appendChild(this.toggleTimeline);
+ header.appendChild(sortDiv);
+
+ sortDiv.appendChild(this.sortSelect);
+
+ this.appendChild(header);
+ this.appendChild(this.messagesNode);
+ }
+
+ setMessages(messages) {
+ let topMessagesPluralFormat = glodaFacetStrings.GetStringFromName(
+ "glodaFacetView.results.header.countLabel.NMessages"
+ );
+ let outOfPluralFormat = glodaFacetStrings.GetStringFromName(
+ "glodaFacetView.results.header.countLabel.ofN"
+ );
+ let groupingFormat = glodaFacetStrings.GetStringFromName(
+ "glodaFacetView.results.header.countLabel.grouping"
+ );
+
+ let displayCount = messages.length;
+ let totalCount = FacetContext.activeSet.length;
+
+ // set the count so CSS selectors can know what the results look like
+ this.setAttribute("state", totalCount <= 0 ? "empty" : "some");
+
+ let topMessagesStr = PluralForm.get(
+ displayCount,
+ topMessagesPluralFormat
+ ).replace("#1", displayCount.toLocaleString());
+ let outOfStr = PluralForm.get(totalCount, outOfPluralFormat).replace(
+ "#1",
+ totalCount.toLocaleString()
+ );
+
+ this.countNode.textContent = groupingFormat
+ .replace("#1", topMessagesStr)
+ .replace("#2", outOfStr);
+
+ this.toggleText.textContent = glodaFacetStrings.GetStringFromName(
+ "glodaFacetView.results.message.timeline.label"
+ );
+
+ let sortByPref = Services.prefs.getIntPref("gloda.facetview.sortby");
+ this.sortSelect.addEventListener("change", () => {
+ if (sortByPref >= 2) {
+ Services.prefs.setIntPref(
+ "gloda.facetview.sortby",
+ this.sortSelect.value == "-dascore" ? 2 : 3
+ );
+ }
+
+ FacetContext.sortBy = this.sortSelect.value;
+ });
+
+ while (this.messagesNode.hasChildNodes()) {
+ this.messagesNode.lastChild.remove();
+ }
+ try {
+ // -- Messages
+ for (let message of messages) {
+ let msgNode = document.createElement("facet-result-message");
+ msgNode.message = message;
+ msgNode.setAttribute("class", "message");
+ this.messagesNode.appendChild(msgNode);
+ }
+ } catch (e) {
+ console.error(e);
+ }
+ }
+ }
+
+ customElements.define("facet-results-message", MozFacetResultsMessage);
+
+ class MozFacetBoolean extends HTMLElement {
+ constructor() {
+ super();
+
+ this.addEventListener("mouseover", event => {
+ FacetContext.hoverFacet(
+ this.faceter,
+ this.faceter.attrDef,
+ true,
+ this.trueValues
+ );
+ });
+
+ this.addEventListener("mouseout", event => {
+ FacetContext.unhoverFacet(
+ this.faceter,
+ this.faceter.attrDef,
+ true,
+ this.trueValues
+ );
+ });
+ }
+
+ connectedCallback() {
+ this.addChildren();
+
+ this.canUpdate = true;
+ this.bubble.addEventListener("click", event => {
+ return this.bubbleClicked(event);
+ });
+
+ if ("faceter" in this) {
+ this.build(true);
+ }
+ }
+
+ addChildren() {
+ this.bubble = document.createElement("span");
+ this.bubble.classList.add("facet-checkbox-bubble");
+
+ this.checkbox = document.createElement("input");
+ this.checkbox.setAttribute("type", "checkbox");
+
+ this.labelNode = document.createElement("span");
+ this.labelNode.classList.add("facet-checkbox-label");
+
+ this.countNode = document.createElement("span");
+ this.countNode.classList.add("facet-checkbox-count");
+
+ this.bubble.appendChild(this.checkbox);
+ this.bubble.appendChild(this.labelNode);
+ this.bubble.appendChild(this.countNode);
+
+ this.appendChild(this.bubble);
+ }
+
+ set disabled(val) {
+ if (val) {
+ this.setAttribute("disabled", "true");
+ this.checkbox.setAttribute("disabled", "true");
+ } else {
+ this.removeAttribute("disabled");
+ this.checkbox.removeAttribute("disabled");
+ }
+ }
+
+ get disabled() {
+ return this.getAttribute("disabled") == "true";
+ }
+
+ set checked(val) {
+ if (this.checked == val) {
+ return;
+ }
+ this.checkbox.checked = val;
+ if (val) {
+ this.setAttribute("checked", "true");
+ if (!this.disabled) {
+ FacetContext.addFacetConstraint(this.faceter, true, this.trueGroups);
+ }
+ } else {
+ this.removeAttribute("checked");
+ this.checkbox.removeAttribute("checked");
+ if (!this.disabled) {
+ FacetContext.removeFacetConstraint(
+ this.faceter,
+ true,
+ this.trueGroups
+ );
+ }
+ }
+ this.checkStateChanged();
+ }
+
+ get checked() {
+ return this.getAttribute("checked") == "true";
+ }
+
+ extraSetup() {}
+
+ checkStateChanged() {}
+
+ brushItems() {}
+
+ clearBrushedItems() {}
+
+ build(firstTime) {
+ if (firstTime) {
+ this.labelNode.textContent = this.facetDef.strings.facetNameLabel;
+ this.checkbox.setAttribute(
+ "aria-label",
+ this.facetDef.strings.facetNameLabel
+ );
+ this.trueValues = [];
+ }
+
+ // If we do not currently have a constraint applied and there is only
+ // one (or no) group, then: disable us, but reflect the underlying
+ // state of the data (checked or non-checked)
+ if (!this.faceter.constraint && this.orderedGroups.length <= 1) {
+ this.disabled = true;
+ let count = 0;
+ if (this.orderedGroups.length) {
+ // true case?
+ if (this.orderedGroups[0][0]) {
+ count = this.orderedGroups[0][1].length;
+ this.checked = true;
+ } else {
+ this.checked = false;
+ }
+ }
+ this.countNode.textContent = count.toLocaleString();
+ return;
+ }
+ // if we were disabled checked before, clear ourselves out
+ if (this.disabled && this.checked) {
+ this.checked = false;
+ }
+ this.disabled = false;
+
+ // if we are here, we have our 2 groups, find true...
+ // (note: it is possible to get jerked around by null values
+ // currently, so leave a reasonable failure case)
+ this.trueValues = [];
+ this.trueGroups = [true];
+ for (let groupPair of this.orderedGroups) {
+ if (groupPair[0]) {
+ this.trueValues = groupPair[1];
+ }
+ }
+
+ this.countNode.textContent = this.trueValues.length.toLocaleString();
+ }
+
+ bubbleClicked(event) {
+ if (!this.disabled) {
+ this.checked = !this.checked;
+ }
+ event.stopPropagation();
+ }
+ }
+
+ customElements.define("facet-boolean", MozFacetBoolean);
+
+ class MozFacetBooleanFiltered extends MozFacetBoolean {
+ static get observedAttributes() {
+ return ["checked", "disabled"];
+ }
+
+ connectedCallback() {
+ super.addChildren();
+
+ this.filterNode = document.createElement("select");
+ this.filterNode.classList.add("facet-filter-list");
+ this.appendChild(this.filterNode);
+
+ this.canUpdate = true;
+ this.bubble.addEventListener("click", event => {
+ return super.bubbleClicked(event);
+ });
+
+ this.extraSetup();
+
+ if ("faceter" in this) {
+ this.build(true);
+ }
+
+ this._updateAttributes();
+ }
+
+ attributeChangedCallback() {
+ this._updateAttributes();
+ }
+
+ _updateAttributes() {
+ if (!this.checkbox) {
+ return;
+ }
+
+ if (this.hasAttribute("checked")) {
+ this.checkbox.setAttribute("checked", this.getAttribute("checked"));
+ } else {
+ this.checkbox.removeAttribute("checked");
+ }
+
+ if (this.hasAttribute("disabled")) {
+ this.checkbox.setAttribute("disabled", this.getAttribute("disabled"));
+ } else {
+ this.checkbox.removeAttribute("disabled");
+ }
+ }
+
+ extraSetup() {
+ this.groupDisplayProperty = this.getAttribute("groupDisplayProperty");
+
+ this.filterNode.addEventListener("change", event =>
+ this.filterChanged(event)
+ );
+
+ this.selectedValue = "all";
+ }
+
+ build(firstTime) {
+ if (firstTime) {
+ this.labelNode.textContent = this.facetDef.strings.facetNameLabel;
+ this.checkbox.setAttribute(
+ "aria-label",
+ this.facetDef.strings.facetNameLabel
+ );
+ this.trueValues = [];
+ }
+
+ // Only update count if anything other than "all" is selected.
+ // Otherwise we lose the set of attachment types in our select box,
+ // and that makes us sad. We do want to update on "all" though
+ // because other facets may further reduce the number of attachments
+ // we see. (Or if this is not just being used for attachments, it
+ // still holds.)
+ if (this.selectedValue != "all") {
+ let count = 0;
+ for (let groupPair of this.orderedGroups) {
+ if (groupPair[0] != null) {
+ count += groupPair[1].length;
+ }
+ }
+ this.countNode.textContent = count.toLocaleString();
+ return;
+ }
+
+ while (this.filterNode.hasChildNodes()) {
+ this.filterNode.lastChild.remove();
+ }
+
+ let allNode = document.createElement("option");
+ allNode.textContent = glodaFacetStrings.GetStringFromName(
+ "glodaFacetView.facets.filter." +
+ this.attrDef.attributeName +
+ ".allLabel"
+ );
+ allNode.setAttribute("value", "all");
+ if (this.selectedValue == "all") {
+ allNode.setAttribute("selected", "selected");
+ }
+ this.filterNode.appendChild(allNode);
+
+ // if we are here, we have our 2 groups, find true...
+ // (note: it is possible to get jerked around by null values
+ // currently, so leave a reasonable failure case)
+ // empty true groups is for the checkbox
+ this.trueGroups = [];
+ // the real true groups is the actual true values for our explicit
+ // filtering
+ this.realTrueGroups = [];
+ this.trueValues = [];
+ this.falseValues = [];
+ let selectNodes = [];
+ for (let groupPair of this.orderedGroups) {
+ if (groupPair[0] === null) {
+ this.falseValues.push.apply(this.falseValues, groupPair[1]);
+ } else {
+ this.trueValues.push.apply(this.trueValues, groupPair[1]);
+
+ let groupValue = groupPair[0];
+ let selNode = document.createElement("option");
+ selNode.textContent = groupValue[this.groupDisplayProperty];
+ selNode.setAttribute("value", this.realTrueGroups.length);
+ if (this.selectedValue == groupValue.category) {
+ selNode.setAttribute("selected", "selected");
+ }
+ selectNodes.push(selNode);
+
+ this.realTrueGroups.push(groupValue);
+ }
+ }
+ selectNodes.sort((a, b) => {
+ return a.textContent.localeCompare(b.textContent);
+ });
+ selectNodes.forEach(selNode => {
+ this.filterNode.appendChild(selNode);
+ });
+
+ this.disabled = !this.trueValues.length;
+
+ this.countNode.textContent = this.trueValues.length.toLocaleString();
+ }
+
+ checkStateChanged() {
+ // if they un-check us, revert our value to all.
+ if (!this.checked) {
+ this.selectedValue = "all";
+ }
+ }
+
+ filterChanged(event) {
+ if (!this.checked) {
+ return;
+ }
+ if (this.filterNode.value == "all") {
+ this.selectedValue = "all";
+ FacetContext.addFacetConstraint(
+ this.faceter,
+ true,
+ this.trueGroups,
+ false,
+ true
+ );
+ } else {
+ let groupValue = this.realTrueGroups[parseInt(this.filterNode.value)];
+ this.selectedValue = groupValue.category;
+ FacetContext.addFacetConstraint(
+ this.faceter,
+ true,
+ [groupValue],
+ false,
+ true
+ );
+ }
+ }
+ }
+
+ customElements.define("facet-boolean-filtered", MozFacetBooleanFiltered);
+
+ class MozFacetDiscrete extends HTMLElement {
+ constructor() {
+ super();
+
+ this.addEventListener("click", event => {
+ this.showPopup(event);
+ });
+
+ this.addEventListener("keypress", event => {
+ if (event.keyCode != KeyEvent.DOM_VK_RETURN) {
+ return;
+ }
+ this.showPopup(event);
+ });
+
+ this.addEventListener("keypress", event => {
+ this.activateLink(event);
+ });
+
+ this.addEventListener("mouseover", event => {
+ // we dispatch based on the class of the thing we clicked on.
+ // there are other ways we could accomplish this, but they all sorta suck.
+ if (
+ event.target.hasAttribute("class") &&
+ event.target.classList.contains("bar-link")
+ ) {
+ this.barHovered(event.target.parentNode, true);
+ }
+ });
+
+ this.addEventListener("mouseout", event => {
+ // we dispatch based on the class of the thing we clicked on.
+ // there are other ways we could accomplish this, but they all sorta suck.
+ if (
+ event.target.hasAttribute("class") &&
+ event.target.classList.contains("bar-link")
+ ) {
+ this.barHoverGone(event.target.parentNode, true);
+ }
+ });
+ }
+
+ connectedCallback() {
+ const facet = document.createElement("div");
+ facet.classList.add("facet");
+
+ this.nameNode = document.createElement("h2");
+
+ this.contentBox = document.createElement("div");
+ this.contentBox.classList.add("facet-content");
+
+ this.includeLabel = document.createElement("h3");
+ this.includeLabel.classList.add("facet-included-header");
+
+ this.includeList = document.createElement("ul");
+ this.includeList.classList.add("facet-included", "barry");
+
+ this.remainderLabel = document.createElement("h3");
+ this.remainderLabel.classList.add("facet-remaindered-header");
+
+ this.remainderList = document.createElement("ul");
+ this.remainderList.classList.add("facet-remaindered", "barry");
+
+ this.excludeLabel = document.createElement("h3");
+ this.excludeLabel.classList.add("facet-excluded-header");
+
+ this.excludeList = document.createElement("ul");
+ this.excludeList.classList.add("facet-excluded", "barry");
+
+ this.moreButton = document.createElement("button");
+ this.moreButton.classList.add("facet-more");
+ this.moreButton.setAttribute("needed", "false");
+ this.moreButton.setAttribute("tabindex", "0");
+
+ this.contentBox.appendChild(this.includeLabel);
+ this.contentBox.appendChild(this.includeList);
+ this.contentBox.appendChild(this.remainderLabel);
+ this.contentBox.appendChild(this.remainderList);
+ this.contentBox.appendChild(this.excludeLabel);
+ this.contentBox.appendChild(this.excludeList);
+ this.contentBox.appendChild(this.moreButton);
+
+ facet.appendChild(this.nameNode);
+ facet.appendChild(this.contentBox);
+
+ this.appendChild(facet);
+
+ this.canUpdate = false;
+
+ if ("faceter" in this) {
+ this.build(true);
+ }
+ }
+
+ build(firstTime) {
+ // -- Header Building
+ this.nameNode.textContent = this.facetDef.strings.facetNameLabel;
+
+ // - include
+ // setup the include label
+ if ("includeLabel" in this.facetDef.strings) {
+ this.includeLabel.textContent = this.facetDef.strings.includeLabel;
+ } else {
+ this.includeLabel.textContent = glodaFacetStrings.GetStringFromName(
+ "glodaFacetView.facets.included.fallbackLabel"
+ );
+ }
+ this.includeLabel.setAttribute("state", "empty");
+
+ // - exclude
+ // setup the exclude label
+ if ("excludeLabel" in this.facetDef.strings) {
+ this.excludeLabel.textContent = this.facetDef.strings.excludeLabel;
+ } else {
+ this.excludeLabel.textContent = glodaFacetStrings.GetStringFromName(
+ "glodaFacetView.facets.excluded.fallbackLabel"
+ );
+ }
+ this.excludeLabel.setAttribute("state", "empty");
+
+ // - remainder
+ // setup the remainder label
+ if ("remainderLabel" in this.facetDef.strings) {
+ this.remainderLabel.textContent = this.facetDef.strings.remainderLabel;
+ } else {
+ this.remainderLabel.textContent = glodaFacetStrings.GetStringFromName(
+ "glodaFacetView.facets.remainder.fallbackLabel"
+ );
+ }
+
+ // -- House-cleaning
+ // -- All/Top mode decision
+ this.modes = ["all"];
+ if (this.maxDisplayRows >= this.orderedGroups.length) {
+ this.mode = "all";
+ } else {
+ // top mode must be used
+ this.modes.push("top");
+ this.mode = "top";
+ this.topGroups = FacetUtils.makeTopGroups(
+ this.attrDef,
+ this.orderedGroups,
+ this.maxDisplayRows
+ );
+ // setup the more button string
+ let groupCount = this.orderedGroups.length;
+ this.moreButton.textContent = PluralForm.get(
+ groupCount,
+ glodaFacetStrings.GetStringFromName(
+ "glodaFacetView.facets.mode.top.listAllLabel"
+ )
+ ).replace("#1", groupCount);
+ }
+
+ // -- Row Building
+ this.buildRows();
+ }
+
+ changeMode(newMode) {
+ this.mode = newMode;
+ this.setAttribute("mode", newMode);
+ this.buildRows();
+ }
+
+ buildRows() {
+ let nounDef = this.nounDef;
+ let useGroups = this.mode == "all" ? this.orderedGroups : this.topGroups;
+
+ // should we just rely on automatic string coercion?
+ this.moreButton.setAttribute(
+ "needed",
+ this.mode == "top" ? "true" : "false"
+ );
+
+ let constraint = this.faceter.constraint;
+
+ // -- empty all of our display buckets...
+ let remainderList = this.remainderList;
+ while (remainderList.hasChildNodes()) {
+ remainderList.lastChild.remove();
+ }
+ let includeList = this.includeList;
+ let excludeList = this.excludeList;
+ while (includeList.hasChildNodes()) {
+ includeList.lastChild.remove();
+ }
+ while (excludeList.hasChildNodes()) {
+ excludeList.lastChild.remove();
+ }
+
+ // -- first pass, check for ambiguous labels
+ // It's possible that multiple groups are identified by the same short
+ // string, in which case we want to use the longer string to
+ // disambiguate. For example, un-merged contacts can result in
+ // multiple identities having contacts with the same name. In that
+ // case we want to display both the contact name and the identity
+ // name.
+ // This is generically addressed by using the userVisibleString function
+ // defined on the noun type if it is defined. It takes an argument
+ // indicating whether it should be a short string or a long string.
+ // Our algorithm is somewhat dumb. We get the short strings, put them
+ // in a dictionary that maps to whether they are ambiguous or not. We
+ // do not attempt to map based on their id, so then when it comes time
+ // to actually build the labels, we must build the short string and
+ // then re-call for the long name. We could be smarter by building
+ // a list of the input values that resulted in the output string and
+ // then using that to back-update the id map, but it's more compelx and
+ // the performance difference is unlikely to be meaningful.
+ let ambiguousKeyValues;
+ if ("userVisibleString" in nounDef) {
+ ambiguousKeyValues = {};
+ for (let groupPair of useGroups) {
+ let [groupValue] = groupPair;
+
+ // skip null values, they are handled by the none special-case
+ if (groupValue == null) {
+ continue;
+ }
+
+ let groupStr = nounDef.userVisibleString(groupValue, false);
+ // We use hasOwnProperty because it is possible that groupStr could
+ // be the same as the name of one of the attributes on
+ // Object.prototype.
+ if (ambiguousKeyValues.hasOwnProperty(groupStr)) {
+ ambiguousKeyValues[groupStr] = true;
+ } else {
+ ambiguousKeyValues[groupStr] = false;
+ }
+ }
+ }
+
+ // -- create the items, assigning them to the right list based on
+ // existing constraint values
+ for (let groupPair of useGroups) {
+ let [groupValue, groupItems] = groupPair;
+ let li = document.createElement("li");
+ li.setAttribute("class", "bar");
+ li.setAttribute("tabindex", "0");
+ li.setAttribute("role", "link");
+ li.setAttribute("aria-haspopup", "true");
+ li.groupValue = groupValue;
+ li.setAttribute("groupValue", groupValue);
+ li.groupItems = groupItems;
+
+ let countSpan = document.createElement("span");
+ countSpan.setAttribute("class", "bar-count");
+ countSpan.textContent = groupItems.length.toLocaleString();
+ li.appendChild(countSpan);
+
+ let label = document.createElement("span");
+ label.setAttribute("class", "bar-link");
+
+ // The null value is a special indicator for 'none'
+ if (groupValue == null) {
+ if ("noneLabel" in this.facetDef.strings) {
+ label.textContent = this.facetDef.strings.noneLabel;
+ } else {
+ label.textContent = glodaFacetStrings.GetStringFromName(
+ "glodaFacetView.facets.noneLabel"
+ );
+ }
+ } else {
+ // Otherwise stringify the group object
+ let labelStr;
+ if (ambiguousKeyValues) {
+ labelStr = nounDef.userVisibleString(groupValue, false);
+ if (ambiguousKeyValues[labelStr]) {
+ labelStr = nounDef.userVisibleString(groupValue, true);
+ }
+ } else if ("labelFunc" in this.facetDef) {
+ labelStr = this.facetDef.labelFunc(groupValue);
+ } else {
+ labelStr = groupValue.toLocaleString().substring(0, 80);
+ }
+ label.textContent = labelStr;
+ label.setAttribute("title", labelStr);
+ }
+ li.appendChild(label);
+
+ // root it under the appropriate list
+ if (constraint) {
+ if (constraint.isIncludedGroup(groupValue)) {
+ li.setAttribute("variety", "include");
+ includeList.appendChild(li);
+ } else if (constraint.isExcludedGroup(groupValue)) {
+ li.setAttribute("variety", "exclude");
+ excludeList.appendChild(li);
+ } else {
+ li.setAttribute("variety", "remainder");
+ remainderList.appendChild(li);
+ }
+ } else {
+ li.setAttribute("variety", "remainder");
+ remainderList.appendChild(li);
+ }
+ }
+
+ this.updateHeaderStates();
+ }
+
+ /**
+ * - Mark the include/exclude headers as "some" if there is anything in their
+ * - lists, mark the remainder header as "needed" if either of include /
+ * - exclude exist so we need that label.
+ */
+ updateHeaderStates(items) {
+ this.includeLabel.setAttribute(
+ "state",
+ this.includeList.childElementCount ? "some" : "empty"
+ );
+ this.excludeLabel.setAttribute(
+ "state",
+ this.excludeList.childElementCount ? "some" : "empty"
+ );
+ this.remainderLabel.setAttribute(
+ "needed",
+ (this.includeList.childElementCount ||
+ this.excludeList.childElementCount) &&
+ this.remainderList.childElementCount
+ ? "true"
+ : "false"
+ );
+
+ // nuke the style attributes.
+ this.includeLabel.removeAttribute("style");
+ this.excludeLabel.removeAttribute("style");
+ this.remainderLabel.removeAttribute("style");
+ }
+
+ brushItems(items) {}
+
+ clearBrushedItems() {}
+
+ afterListVisible(variety, callback) {
+ let labelNode = this[variety + "Label"];
+ let listNode = this[variety + "List"];
+
+ // if there are already things displayed, no need
+ if (listNode.childElementCount) {
+ callback();
+ return;
+ }
+
+ let remListVisible = this.remainderLabel.getAttribute("needed") == "true";
+ let remListShouldBeVisible = this.remainderList.childElementCount > 1;
+
+ labelNode.setAttribute("state", "some");
+
+ let showNodes = [labelNode];
+ if (remListVisible != remListShouldBeVisible) {
+ showNodes = [labelNode, this.remainderLabel];
+ }
+
+ showNodes.forEach(node => (node.style.display = "block"));
+
+ callback();
+ }
+
+ _flyBarAway(barNode, variety, callback) {
+ function getRect(aElement) {
+ let box = aElement.getBoundingClientRect();
+ let documentElement = aElement.ownerDocument.documentElement;
+ return {
+ top: box.top + window.pageYOffset - documentElement.clientTop,
+ left: box.left + window.pageXOffset - documentElement.clientLeft,
+ width: box.width,
+ height: box.height,
+ };
+ }
+ // figure out our origin location prior to adding the target or it
+ // will shift us down.
+ let origin = getRect(barNode);
+
+ // clone the node into its target location
+ let targetNode = barNode.cloneNode(true);
+ targetNode.groupValue = barNode.groupValue;
+ targetNode.groupItems = barNode.groupItems;
+ targetNode.setAttribute("variety", variety);
+
+ let targetParent = this[variety + "List"];
+ targetParent.appendChild(targetNode);
+
+ // create a flying clone
+ let flyingNode = barNode.cloneNode(true);
+
+ let dest = getRect(targetNode);
+
+ // if the flying box wants to go higher than the content box goes, just
+ // send it to the top of the content box instead.
+ let contentRect = getRect(this.contentBox);
+ if (dest.top < contentRect.top) {
+ dest.top = contentRect.top;
+ }
+
+ // likewise if it wants to go further south than the content box, stop
+ // that
+ if (dest.top > contentRect.top + contentRect.height) {
+ dest.top = contentRect.top + contentRect.height - dest.height;
+ }
+
+ flyingNode.style.position = "absolute";
+ flyingNode.style.width = origin.width + "px";
+ flyingNode.style.height = origin.height + "px";
+ flyingNode.style.top = origin.top + "px";
+ flyingNode.style.left = origin.left + "px";
+ flyingNode.style.zIndex = 1000;
+
+ flyingNode.style.transitionDuration =
+ Math.abs(dest.top - origin.top) * 2 + "ms";
+ flyingNode.style.transitionProperty = "top, left";
+
+ flyingNode.addEventListener("transitionend", () => {
+ barNode.remove();
+ targetNode.style.display = "block";
+ flyingNode.remove();
+
+ if (callback) {
+ setTimeout(callback, 50);
+ }
+ });
+
+ document.body.appendChild(flyingNode);
+
+ // Adding setTimeout to improve the facet-discrete animation.
+ // See Bug 1439323 for more detail.
+ setTimeout(() => {
+ // animate the flying clone... flying!
+ window.requestAnimationFrame(() => {
+ flyingNode.style.top = dest.top + "px";
+ flyingNode.style.left = dest.left + "px";
+ });
+
+ // hide the target (cloned) node
+ targetNode.style.display = "none";
+
+ // hide the original node and remove its JS properties
+ barNode.style.visibility = "hidden";
+ delete barNode.groupValue;
+ delete barNode.groupItems;
+ }, 100);
+ }
+
+ barClicked(barNode, variety) {
+ let groupValue = barNode.groupValue;
+ // These determine what goAnimate actually does.
+ // flyAway allows us to cancel flying in the case the constraint is
+ // being fully dropped and so the facet is just going to get rebuilt
+ let flyAway = true;
+
+ const goAnimate = () => {
+ setTimeout(() => {
+ if (flyAway) {
+ this.afterListVisible(variety, () => {
+ this._flyBarAway(barNode, variety, () => {
+ this.updateHeaderStates();
+ });
+ });
+ }
+ }, 0);
+ };
+
+ // Immediately apply the facet change, triggering the animation after
+ // the faceting completes.
+ if (variety == "remainder") {
+ let currentVariety = barNode.getAttribute("variety");
+ let constraintGone = FacetContext.removeFacetConstraint(
+ this.faceter,
+ currentVariety == "include",
+ [groupValue],
+ goAnimate
+ );
+
+ // we will automatically rebuild if the constraint is gone, so
+ // just make the animation a no-op.
+ if (constraintGone) {
+ flyAway = false;
+ }
+ } else {
+ // include/exclude
+ let revalidate = FacetContext.addFacetConstraint(
+ this.faceter,
+ variety == "include",
+ [groupValue],
+ false,
+ false,
+ goAnimate
+ );
+
+ // revalidate means we need to blow away the other dudes, in which
+ // case it makes the most sense to just trigger a rebuild of ourself
+ if (revalidate) {
+ flyAway = false;
+ this.build(false);
+ }
+ }
+ }
+
+ barHovered(barNode, aInclude) {
+ let groupValue = barNode.groupValue;
+ let groupItems = barNode.groupItems;
+
+ FacetContext.hoverFacet(
+ this.faceter,
+ this.attrDef,
+ groupValue,
+ groupItems
+ );
+ }
+
+ /**
+ * HoverGone! HoverGone!
+ * We know it's gone, but where has it gone?
+ */
+ barHoverGone(barNode, include) {
+ let groupValue = barNode.groupValue;
+ let groupItems = barNode.groupItems;
+
+ FacetContext.unhoverFacet(
+ this.faceter,
+ this.attrDef,
+ groupValue,
+ groupItems
+ );
+ }
+
+ includeFacet(node) {
+ this.barClicked(
+ node,
+ node.getAttribute("variety") == "remainder" ? "include" : "remainder"
+ );
+ }
+
+ undoFacet(node) {
+ this.barClicked(
+ node,
+ node.getAttribute("variety") == "remainder" ? "include" : "remainder"
+ );
+ }
+
+ excludeFacet(node) {
+ this.barClicked(node, "exclude");
+ }
+
+ showPopup(event) {
+ try {
+ // event.target could be the <li> node, or a span inside
+ // of it, or perhaps the facet-more button, or maybe something
+ // else that we'll handle in the next version. We walk up its
+ // parent chain until we get to the right level of the DOM
+ // hierarchy, or the facet-content which seems to be the root.
+ if (this.currentNode) {
+ this.currentNode.removeAttribute("selected");
+ }
+
+ let node = event.target;
+
+ while (
+ !(node && node.hasAttribute && node.hasAttribute("class")) ||
+ (!node.classList.contains("bar") &&
+ !node.classList.contains("facet-more") &&
+ !node.classList.contains("facet-content"))
+ ) {
+ node = node.parentNode;
+ }
+
+ if (!(node && node.hasAttribute && node.hasAttribute("class"))) {
+ return false;
+ }
+
+ this.currentNode = node;
+ node.setAttribute("selected", "true");
+
+ if (node.classList.contains("bar")) {
+ document.querySelector("facet-popup-menu").show(event, this, node);
+ } else if (node.classList.contains("facet-more")) {
+ this.changeMode("all");
+ }
+
+ return false;
+ } catch (e) {
+ return console.error(e);
+ }
+ }
+
+ activateLink(event) {
+ try {
+ let node = event.target;
+
+ while (
+ !node.hasAttribute("class") ||
+ (!node.classList.contains("facet-more") &&
+ !node.classList.contains("facet-content"))
+ ) {
+ node = node.parentNode;
+ }
+
+ if (node.classList.contains("facet-more")) {
+ this.changeMode("all");
+ }
+
+ return false;
+ } catch (e) {
+ return console.error(e);
+ }
+ }
+ }
+
+ customElements.define("facet-discrete", MozFacetDiscrete);
+
+ class MozFacetPopupMenu extends HTMLElement {
+ constructor() {
+ super();
+
+ this.addEventListener("keypress", event => {
+ switch (event.keyCode) {
+ case KeyEvent.DOM_VK_ESCAPE:
+ this.hide();
+ break;
+
+ case KeyEvent.DOM_VK_DOWN:
+ this.moveFocus(event, 1);
+ break;
+
+ case KeyEvent.DOM_VK_TAB:
+ if (event.shiftKey) {
+ this.moveFocus(event, -1);
+ break;
+ }
+
+ this.moveFocus(event, 1);
+ break;
+
+ case KeyEvent.DOM_VK_UP:
+ this.moveFocus(event, -1);
+ break;
+
+ default:
+ break;
+ }
+ });
+ }
+
+ connectedCallback() {
+ const parentDiv = document.createElement("div");
+ parentDiv.classList.add("parent");
+ parentDiv.setAttribute("tabIndex", "0");
+
+ this.includeNode = document.createElement("div");
+ this.includeNode.classList.add("popup-menuitem", "top");
+ this.includeNode.setAttribute("tabindex", "0");
+ this.includeNode.onmouseover = () => {
+ this.focus();
+ };
+ this.includeNode.onkeypress = event => {
+ if (event.keyCode == event.DOM_VK_RETURN) {
+ this.doInclude();
+ }
+ };
+ this.includeNode.onmouseup = () => {
+ this.doInclude();
+ };
+
+ this.excludeNode = document.createElement("div");
+ this.excludeNode.classList.add("popup-menuitem", "bottom");
+ this.excludeNode.setAttribute("tabindex", "0");
+ this.excludeNode.onmouseover = () => {
+ this.focus();
+ };
+ this.excludeNode.onkeypress = event => {
+ if (event.keyCode == event.DOM_VK_RETURN) {
+ this.doExclude();
+ }
+ };
+ this.excludeNode.onmouseup = () => {
+ this.doExclude();
+ };
+
+ this.undoNode = document.createElement("div");
+ this.undoNode.classList.add("popup-menuitem", "undo");
+ this.undoNode.setAttribute("tabindex", "0");
+ this.undoNode.onmouseover = () => {
+ this.focus();
+ };
+ this.undoNode.onkeypress = event => {
+ if (event.keyCode == event.DOM_VK_RETURN) {
+ this.doUndo();
+ }
+ };
+ this.undoNode.onmouseup = () => {
+ this.doUndo();
+ };
+
+ parentDiv.appendChild(this.includeNode);
+ parentDiv.appendChild(this.excludeNode);
+ parentDiv.appendChild(this.undoNode);
+
+ this.appendChild(parentDiv);
+ }
+
+ _getLabel(facetDef, facetValue, groupValue, stringName) {
+ let labelFormat;
+ if (stringName in facetDef.strings) {
+ labelFormat = facetDef.strings[stringName];
+ } else {
+ labelFormat = glodaFacetStrings.GetStringFromName(
+ `glodaFacetView.facets.${stringName}.fallbackLabel`
+ );
+ }
+
+ if (!labelFormat.includes("#1")) {
+ return labelFormat;
+ }
+
+ return labelFormat.replace("#1", facetValue);
+ }
+
+ build(facetDef, facetValue, groupValue) {
+ try {
+ if (groupValue) {
+ this.includeNode.textContent = this._getLabel(
+ facetDef,
+ facetValue,
+ groupValue,
+ "mustMatchLabel"
+ );
+ this.excludeNode.textContent = this._getLabel(
+ facetDef,
+ facetValue,
+ groupValue,
+ "cantMatchLabel"
+ );
+ this.undoNode.textContent = this._getLabel(
+ facetDef,
+ facetValue,
+ groupValue,
+ "mayMatchLabel"
+ );
+ } else {
+ this.includeNode.textContent = this._getLabel(
+ facetDef,
+ facetValue,
+ groupValue,
+ "mustMatchNoneLabel"
+ );
+ this.excludeNode.textContent = this._getLabel(
+ facetDef,
+ facetValue,
+ groupValue,
+ "mustMatchSomeLabel"
+ );
+ this.undoNode.textContent = this._getLabel(
+ facetDef,
+ facetValue,
+ groupValue,
+ "mayMatchAnyLabel"
+ );
+ }
+ } catch (e) {
+ console.error(e);
+ }
+ }
+
+ moveFocus(event, delta) {
+ try {
+ // We probably want something quite generic in the long term, but that
+ // is way too much for now (needs to skip over invisible items, etc)
+ let focused = document.activeElement;
+ if (focused == this.includeNode) {
+ this.excludeNode.focus();
+ } else if (focused == this.excludeNode) {
+ this.includeNode.focus();
+ }
+ event.preventDefault();
+ event.stopPropagation();
+ } catch (e) {
+ console.error(e);
+ }
+ }
+
+ selectItem(event) {
+ try {
+ let focused = document.activeElement;
+ if (focused == this.includeNode) {
+ this.doInclude();
+ } else if (focused == this.excludeNode) {
+ this.doExclude();
+ } else {
+ this.doUndo();
+ }
+ } catch (e) {
+ console.error(e);
+ }
+ }
+
+ show(event, facetNode, barNode) {
+ try {
+ this.node = barNode;
+ this.facetNode = facetNode;
+ let facetDef = facetNode.facetDef;
+ let groupValue = barNode.groupValue;
+ let variety = barNode.getAttribute("variety");
+ let label = barNode.querySelector(".bar-link").textContent;
+ this.build(facetDef, label, groupValue);
+ this.node.setAttribute("selected", "true");
+ const rtl = window.getComputedStyle(this).direction == "rtl";
+ /* We show different menus if we're on an "unselected" facet value,
+ or if we're on a preselected facet value, whether included or
+ excluded. The variety attribute handles that through CSS */
+ this.setAttribute("variety", variety);
+ let rect = barNode.getBoundingClientRect();
+ let x, y;
+ if (event.type == "click") {
+ // center the menu on the mouse click
+ if (rtl) {
+ x = event.pageX + 10;
+ } else {
+ x = event.pageX - 10;
+ }
+ y = Math.max(20, event.pageY - 15);
+ } else {
+ if (rtl) {
+ x = rect.left + rect.width / 2 + 20;
+ } else {
+ x = rect.left + rect.width / 2 - 20;
+ }
+ y = rect.top - 10;
+ }
+ if (rtl) {
+ this.style.left = x - this.getBoundingClientRect().width + "px";
+ } else {
+ this.style.left = x + "px";
+ }
+ this.style.top = y + "px";
+
+ if (variety == "remainder") {
+ // include
+ this.includeNode.focus();
+ } else {
+ // undo
+ this.undoNode.focus();
+ }
+ } catch (e) {
+ console.error(e);
+ }
+ }
+
+ hide() {
+ try {
+ this.setAttribute("variety", "invisible");
+ if (this.node) {
+ this.node.removeAttribute("selected");
+ this.node.focus();
+ }
+ } catch (e) {
+ console.error(e);
+ }
+ }
+
+ doInclude() {
+ try {
+ this.facetNode.includeFacet(this.node);
+ this.hide();
+ } catch (e) {
+ console.error(e);
+ }
+ }
+
+ doExclude() {
+ this.facetNode.excludeFacet(this.node);
+ this.hide();
+ }
+
+ doUndo() {
+ this.facetNode.undoFacet(this.node);
+ this.hide();
+ }
+ }
+
+ customElements.define("facet-popup-menu", MozFacetPopupMenu);
+
+ /**
+ * MozResultMessage displays an excerpt of a message. Typically these are used in the gloda
+ * results listing, showing the messages that matched.
+ */
+ class MozFacetResultMessage extends HTMLElement {
+ constructor() {
+ super();
+
+ this.addEventListener("mouseover", event => {
+ FacetContext.hoverFacet(
+ FacetContext.fakeResultFaceter,
+ FacetContext.fakeResultAttr,
+ this.message,
+ [this.message]
+ );
+ });
+
+ this.addEventListener("mouseout", event => {
+ FacetContext.unhoverFacet(
+ FacetContext.fakeResultFaceter,
+ FacetContext.fakeResultAttr,
+ this.message,
+ [this.message]
+ );
+ });
+ }
+
+ connectedCallback() {
+ const messageHeader = document.createElement("div");
+
+ const messageLine = document.createElement("div");
+ messageLine.classList.add("message-line");
+
+ const messageMeta = document.createElement("div");
+ messageMeta.classList.add("message-meta");
+
+ this.addressesGroup = document.createElement("div");
+ this.addressesGroup.classList.add("message-addresses-group");
+
+ this.authorGroup = document.createElement("div");
+ this.authorGroup.classList.add("message-author-group");
+
+ this.author = document.createElement("span");
+ this.author.classList.add("message-author");
+
+ this.date = document.createElement("div");
+ this.date.classList.add("message-date");
+
+ this.authorGroup.appendChild(this.author);
+ this.authorGroup.appendChild(this.date);
+ this.addressesGroup.appendChild(this.authorGroup);
+ messageMeta.appendChild(this.addressesGroup);
+ messageLine.appendChild(messageMeta);
+
+ const messageSubjectGroup = document.createElement("div");
+ messageSubjectGroup.classList.add("message-subject-group");
+
+ this.star = document.createElement("span");
+ this.star.classList.add("message-star");
+
+ this.subject = document.createElement("span");
+ this.subject.classList.add("message-subject");
+ this.subject.setAttribute("tabindex", "0");
+ this.subject.setAttribute("role", "link");
+
+ this.tags = document.createElement("span");
+ this.tags.classList.add("message-tags");
+
+ this.recipientsGroup = document.createElement("div");
+ this.recipientsGroup.classList.add("message-recipients-group");
+
+ this.to = document.createElement("span");
+ this.to.classList.add("message-to-label");
+
+ this.recipients = document.createElement("div");
+ this.recipients.classList.add("message-recipients");
+
+ this.recipientsGroup.appendChild(this.to);
+ this.recipientsGroup.appendChild(this.recipients);
+ messageSubjectGroup.appendChild(this.star);
+ messageSubjectGroup.appendChild(this.subject);
+ messageSubjectGroup.appendChild(this.tags);
+ messageSubjectGroup.appendChild(this.recipientsGroup);
+ messageLine.appendChild(messageSubjectGroup);
+ messageHeader.appendChild(messageLine);
+ this.appendChild(messageHeader);
+
+ this.snippet = document.createElement("pre");
+ this.snippet.classList.add("message-body");
+
+ this.attachments = document.createElement("div");
+ this.attachments.classList.add("message-attachments");
+
+ this.appendChild(this.snippet);
+ this.appendChild(this.attachments);
+
+ this.build();
+ }
+
+ /* eslint-disable complexity */
+ build() {
+ let message = this.message;
+
+ let subject = this.subject;
+ // -- eventify
+ subject.onclick = event => {
+ FacetContext.showConversationInTab(this, event.button == 1);
+ };
+ subject.onkeypress = event => {
+ if (Event.keyCode == event.DOM_VK_RETURN) {
+ FacetContext.showConversationInTab(this, event.shiftKey);
+ }
+ };
+
+ // -- Content Poking
+ if (message.subject.trim() == "") {
+ subject.textContent = glodaFacetStrings.GetStringFromName(
+ "glodaFacetView.result.message.noSubject"
+ );
+ } else {
+ subject.textContent = message.subject;
+ }
+ let authorNode = this.author;
+ authorNode.setAttribute("title", message.from.value);
+ authorNode.textContent = message.from.contact.name;
+ let toNode = this.to;
+ toNode.textContent = glodaFacetStrings.GetStringFromName(
+ "glodaFacetView.result.message.toLabel"
+ );
+
+ // this.author.textContent = ;
+ let { makeFriendlyDateAgo } = ChromeUtils.import(
+ "resource:///modules/TemplateUtils.jsm"
+ );
+ this.date.textContent = makeFriendlyDateAgo(message.date);
+
+ // - Recipients
+ try {
+ let recipientsNode = this.recipients;
+ if (message.recipients) {
+ let recipientCount = 0;
+ const MAX_RECIPIENTS = 3;
+ let totalRecipientCount = message.recipients.length;
+ let recipientSeparator = glodaFacetStrings.GetStringFromName(
+ "glodaFacetView.results.message.recipientSeparator"
+ );
+ for (let index in message.recipients) {
+ let recipNode = document.createElement("span");
+ recipNode.setAttribute("class", "message-recipient");
+ recipNode.textContent = message.recipients[index].contact.name;
+ recipientsNode.appendChild(recipNode);
+ recipientCount++;
+ if (recipientCount == MAX_RECIPIENTS) {
+ break;
+ }
+ if (index != totalRecipientCount - 1) {
+ // add separators (usually commas)
+ let sepNode = document.createElement("span");
+ sepNode.setAttribute("class", "message-recipient-separator");
+ sepNode.textContent = recipientSeparator;
+ recipientsNode.appendChild(sepNode);
+ }
+ }
+ if (totalRecipientCount > MAX_RECIPIENTS) {
+ let nOthers = totalRecipientCount - recipientCount;
+ let andNOthers = document.createElement("span");
+ andNOthers.setAttribute("class", "message-recipients-andothers");
+
+ let andOthersLabel = PluralForm.get(
+ nOthers,
+ glodaFacetStrings.GetStringFromName(
+ "glodaFacetView.results.message.andOthers"
+ )
+ ).replace("#1", nOthers);
+
+ andNOthers.textContent = andOthersLabel;
+ recipientsNode.appendChild(andNOthers);
+ }
+ }
+ } catch (e) {
+ console.error(e);
+ }
+
+ // - Starred
+ let starNode = this.star;
+ if (message.starred) {
+ starNode.setAttribute("starred", "true");
+ }
+
+ // - Attachments
+ if (message.attachmentNames) {
+ let attachmentsNode = this.attachments;
+ let imgNode = document.createElement("div");
+ imgNode.setAttribute("class", "message-attachment-icon");
+ attachmentsNode.appendChild(imgNode);
+ for (let attach of message.attachmentNames) {
+ let attachNode = document.createElement("div");
+ attachNode.setAttribute("class", "message-attachment");
+ if (attach.length >= 28) {
+ attach = attach.substring(0, 24) + "…";
+ }
+ attachNode.textContent = attach;
+ attachmentsNode.appendChild(attachNode);
+ }
+ }
+
+ // - Tags
+ let tagsNode = this.tags;
+ if ("tags" in message && message.tags.length) {
+ for (let tag of message.tags) {
+ let tagNode = document.createElement("span");
+ tagNode.setAttribute("class", "message-tag");
+ let color = MailServices.tags.getColorForKey(tag.key);
+ if (color) {
+ let textColor = !TagUtils.isColorContrastEnough(color)
+ ? "white"
+ : "black";
+ tagNode.setAttribute(
+ "style",
+ "color: " + textColor + "; background-color: " + color + ";"
+ );
+ }
+ tagNode.textContent = tag.tag;
+ tagsNode.appendChild(tagNode);
+ }
+ }
+
+ // - Body
+ if (message.indexedBodyText) {
+ let bodyText = message.indexedBodyText;
+
+ let matches = [];
+ if ("stashedColumns" in FacetContext.collection) {
+ let collection;
+ if (
+ "IMCollection" in FacetContext &&
+ message instanceof Gloda.lookupNounDef("im-conversation").clazz
+ ) {
+ collection = FacetContext.IMCollection;
+ } else {
+ collection = FacetContext.collection;
+ }
+ let offsets = collection.stashedColumns[message.id][0];
+ let offsetNums = offsets.split(" ").map(x => parseInt(x));
+ for (let i = 0; i < offsetNums.length; i += 4) {
+ // i is the column index. The indexedBodyText is in the column 0.
+ // Ignore matches for other columns.
+ if (offsetNums[i] != 0) {
+ continue;
+ }
+
+ // i+1 is the term index, indicating which queried term was found.
+ // We can ignore for now...
+
+ // i+2 is the *byte* offset at which the term is in the string.
+ // i+3 is the term's length.
+ matches.push([offsetNums[i + 2], offsetNums[i + 3]]);
+ }
+
+ // Sort the matches by index, just to be sure.
+ // They are probably already sorted, but if they aren't it could
+ // mess things up at the next step.
+ matches.sort((a, b) => a[0] - b[0]);
+
+ // Convert the byte offsets and lengths into character indexes.
+ let charCodeToByteCount = c => {
+ // UTF-8 stores:
+ // - code points below U+0080 on 1 byte,
+ // - code points below U+0800 on 2 bytes,
+ // - code points U+D800 through U+DFFF are UTF-16 surrogate halves
+ // (they indicate that JS has split a 4 bytes UTF-8 character
+ // in two halves of 2 bytes each),
+ // - other code points on 3 bytes.
+ if (c < 0x80) {
+ return 1;
+ }
+ if (c < 0x800 || (c >= 0xd800 && c <= 0xdfff)) {
+ return 2;
+ }
+ return 3;
+ };
+ let byteOffset = 0;
+ let offset = 0;
+ for (let match of matches) {
+ while (byteOffset < match[0]) {
+ byteOffset += charCodeToByteCount(bodyText.charCodeAt(offset++));
+ }
+ match[0] = offset;
+ for (let i = offset; i < offset + match[1]; ++i) {
+ let size = charCodeToByteCount(bodyText.charCodeAt(i));
+ if (size > 1) {
+ match[1] -= size - 1;
+ }
+ }
+ }
+ }
+
+ // how many lines of context we want before the first match:
+ const kContextLines = 2;
+
+ let startIndex = 0;
+ if (matches.length > 0) {
+ // Find where the snippet should begin to show at least the
+ // first match and kContextLines of context before the match.
+ startIndex = matches[0][0];
+ for (let context = kContextLines; context >= 0; --context) {
+ startIndex = bodyText.lastIndexOf("\n", startIndex - 1);
+ if (startIndex == -1) {
+ startIndex = 0;
+ break;
+ }
+ }
+ }
+
+ // start assuming it's just one line that we want to show
+ let idxNewline = -1;
+ let ellipses = "…";
+
+ let maxLineCount = 5;
+ if (startIndex != 0) {
+ // Avoid displaying an ellipses followed by an empty line.
+ while (bodyText[startIndex + 1] == "\n") {
+ ++startIndex;
+ }
+ bodyText = ellipses + bodyText.substring(startIndex);
+ // The first line will only contain the ellipsis as the character
+ // at startIndex is always \n, so we show an additional line.
+ ++maxLineCount;
+ }
+
+ for (
+ let newlineCount = 0;
+ newlineCount < maxLineCount;
+ newlineCount++
+ ) {
+ idxNewline = bodyText.indexOf("\n", idxNewline + 1);
+ if (idxNewline == -1) {
+ ellipses = "";
+ break;
+ }
+ }
+ let snippet = "";
+ if (idxNewline > -1) {
+ snippet = bodyText.substring(0, idxNewline);
+ } else {
+ snippet = bodyText;
+ }
+ if (ellipses) {
+ snippet = snippet.trimRight() + ellipses;
+ }
+
+ let parent = this.snippet;
+ let node = document.createTextNode(snippet);
+ parent.appendChild(node);
+
+ let offset = startIndex ? startIndex - 1 : 0; // The ellipsis takes 1 character.
+ for (let match of matches) {
+ if (idxNewline > -1 && match[0] > startIndex + idxNewline) {
+ break;
+ }
+ let secondNode = node.splitText(match[0] - offset);
+ node = secondNode.splitText(match[1]);
+ offset += match[0] + match[1] - offset;
+ let span = document.createElement("span");
+ span.textContent = secondNode.data;
+ if (!this.firstMatchText) {
+ this.firstMatchText = secondNode.data;
+ }
+ span.setAttribute("class", "message-body-fulltext-match");
+ parent.replaceChild(span, secondNode);
+ }
+ }
+
+ // - Misc attributes
+ if (!message.read) {
+ this.setAttribute("unread", "true");
+ }
+ }
+ }
+
+ customElements.define("facet-result-message", MozFacetResultMessage);
+}
diff --git a/comm/mail/base/content/widgets/header-fields.js b/comm/mail/base/content/widgets/header-fields.js
new file mode 100644
index 0000000000..10ec83b45c
--- /dev/null
+++ b/comm/mail/base/content/widgets/header-fields.js
@@ -0,0 +1,973 @@
+/* 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/. */
+
+/* global gMessageHeader, gShowCondensedEmailAddresses, openUILink */
+
+{
+ const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+ );
+
+ const lazy = {};
+ ChromeUtils.defineModuleGetter(
+ lazy,
+ "DisplayNameUtils",
+ "resource:///modules/DisplayNameUtils.jsm"
+ );
+ ChromeUtils.defineModuleGetter(
+ lazy,
+ "TagUtils",
+ "resource:///modules/TagUtils.jsm"
+ );
+
+ class MultiRecipientRow extends HTMLDivElement {
+ /**
+ * The number of lines of recipients to display before adding a <more>
+ * indicator to the widget. This can be increased using the preference
+ * mailnews.headers.show_n_lines_before_more.
+ *
+ * @type {integer}
+ */
+ #maxLinesBeforeMore = 1;
+
+ /**
+ * The array of all the recipients that need to be shown in this widget.
+ *
+ * @type {Array<object>}
+ */
+ #recipients = [];
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+
+ this.setAttribute("is", "multi-recipient-row");
+ this.classList.add("multi-recipient-row");
+
+ this.heading = document.createElement("span");
+ this.heading.id = `${this.dataset.headerName}Heading`;
+ this.heading.classList.add("row-heading");
+ // message-header-to-list-name
+ // message-header-from-list-name
+ // message-header-cc-list-name
+ // message-header-bcc-list-name
+ // message-header-sender-list-name
+ // message-header-reply-to-list-name
+ document.l10n.setAttributes(
+ this.heading,
+ `message-header-${this.dataset.headerName}-list-name`
+ );
+ this.appendChild(this.heading);
+
+ this.recipientsList = document.createElement("ol");
+ this.recipientsList.classList.add("recipients-list");
+ this.recipientsList.setAttribute("aria-labelledby", this.heading.id);
+ this.appendChild(this.recipientsList);
+
+ this.moreButton = document.createElement("button");
+ this.moreButton.setAttribute("type", "button");
+ this.moreButton.classList.add("show-more-recipients", "plain");
+ this.moreButton.addEventListener(
+ "mousedown",
+ // Prevent focus being transferred to the button before it is removed.
+ event => event.preventDefault()
+ );
+ this.moreButton.addEventListener("click", () => this.showAllRecipients());
+
+ document.l10n.setAttributes(
+ this.moreButton,
+ "message-header-field-show-more"
+ );
+
+ // @implements {nsIObserver}
+ this.ABObserver = {
+ /**
+ * Array list of all observable notifications.
+ *
+ * @type {Array<string>}
+ */
+ _notifications: [
+ "addrbook-directory-created",
+ "addrbook-directory-deleted",
+ "addrbook-contact-created",
+ "addrbook-contact-updated",
+ "addrbook-contact-deleted",
+ ],
+
+ addObservers() {
+ for (let topic of this._notifications) {
+ Services.obs.addObserver(this, topic);
+ }
+ this._added = true;
+ window.addEventListener("unload", this);
+ },
+
+ removeObservers() {
+ if (!this._added) {
+ return;
+ }
+ for (let topic of this._notifications) {
+ Services.obs.removeObserver(this, topic);
+ }
+ this._added = false;
+ window.removeEventListener("unload", this);
+ },
+
+ handleEvent() {
+ this.removeObservers();
+ },
+
+ observe: (subject, topic, data) => {
+ switch (topic) {
+ case "addrbook-directory-created":
+ case "addrbook-directory-deleted":
+ subject.QueryInterface(Ci.nsIAbDirectory);
+ this.directoryChanged(subject);
+ break;
+ case "addrbook-contact-created":
+ case "addrbook-contact-updated":
+ case "addrbook-contact-deleted":
+ subject.QueryInterface(Ci.nsIAbCard);
+ this.contactUpdated(subject);
+ break;
+ }
+ },
+ };
+
+ this.ABObserver.addObservers();
+ }
+
+ /**
+ * Clear things out when the element is removed from the DOM.
+ */
+ disconnectedCallback() {
+ this.ABObserver.removeObservers();
+ }
+
+ /**
+ * Loop through all available recipients and check if any of those belonged
+ * to the created or removed address book.
+ *
+ * @param {nsIAbDirectory} subject - The created or removed Address Book.
+ */
+ directoryChanged(subject) {
+ if (!(subject instanceof Ci.nsIAbDirectory)) {
+ return;
+ }
+
+ for (let recipient of [...this.recipientsList.childNodes].filter(
+ r => r.cardDetails?.book?.dirPrefId == subject.dirPrefId
+ )) {
+ recipient.updateRecipient();
+ }
+ }
+
+ /**
+ * Loop through all available recipients and update the UI to reflect if
+ * they were saved, updated, or removed as contacts in an address book.
+ *
+ * @param {nsIAbCard} subject - The changed contact card.
+ */
+ contactUpdated(subject) {
+ if (!(subject instanceof Ci.nsIAbCard)) {
+ // Bail out if this is not a valid Address Book Card object.
+ return;
+ }
+
+ if (!subject.isMailList && !subject.emailAddresses.length) {
+ // Bail out if we don't have any addresses to match against.
+ return;
+ }
+
+ let addresses = subject.emailAddresses;
+ for (let recipient of [...this.recipientsList.childNodes].filter(
+ r => r.emailAddress && addresses.includes(r.emailAddress)
+ )) {
+ recipient.updateRecipient();
+ }
+ }
+
+ /**
+ * Add a recipient to be shown in this widget. The recipient won't be shown
+ * until the row view is built.
+ *
+ * @param {object} recipient - The recipient element.
+ * @param {string} recipient.displayName - The recipient display name.
+ * @param {string} [recipient.emailAddress] - The recipient email address.
+ * @param {string} [recipient.fullAddress] - The recipient full address.
+ */
+ addRecipient(recipient) {
+ this.#recipients.push(recipient);
+ }
+
+ buildView() {
+ this.#maxLinesBeforeMore = Services.prefs.getIntPref(
+ "mailnews.headers.show_n_lines_before_more"
+ );
+ let showAllHeaders =
+ this.#maxLinesBeforeMore < 1 ||
+ Services.prefs.getIntPref("mail.show_headers") ==
+ Ci.nsMimeHeaderDisplayTypes.AllHeaders ||
+ this.dataset.showAll == "true";
+ this.buildRecipients(showAllHeaders);
+ }
+
+ buildRecipients(showAllHeaders) {
+ // Determine focus before clearing the children.
+ let focusIndex = [...this.recipientsList.childNodes].findIndex(node =>
+ node.contains(document.activeElement)
+ );
+ this.recipientsList.replaceChildren();
+ gMessageHeader.toggleScrollableHeader(showAllHeaders);
+
+ // Store the available width of the entire row.
+ // FIXME! The size of the rows can variate depending on when adjacent
+ // elements are generated (e.g.: TO row + date row), therefore this size
+ // is not always accurate when viewing the first email. We should defer
+ // the generation of the multi recipient rows only after all the other
+ // headers have been populated.
+ let availableWidth = !showAllHeaders
+ ? this.recipientsList.getBoundingClientRect().width
+ : 0;
+
+ // Track the space occupied by recipients per row. Every time we exceed
+ // the available space of a single row, we reset this value.
+ let currentRowWidth = 0;
+ // Track how many rows are being populated by recipients.
+ let rows = 1;
+ for (let [count, recipient] of this.#recipients.entries()) {
+ let li = document.createElement("li", { is: "header-recipient" });
+ // Set an id before connected callback is called on the element.
+ li.id = `${this.dataset.headerName}Recipient${count}`;
+ // Append the element to the DOM to trigger the connectedCallback.
+ this.recipientsList.appendChild(li);
+ li.dataset.headerName = this.dataset.headerName;
+ li.recipient = recipient;
+
+ // Bail out if we need to show all elements.
+ if (showAllHeaders) {
+ continue;
+ }
+
+ // Keep track of how much space our recipients are occupying.
+ let width = li.getBoundingClientRect().width;
+ // FIXME! If we have more than one recipient, we add a comma as pseudo
+ // element after the previous element. Account for that by adding an
+ // arbitrary 30px size to simulate extra characters space. This is a bit
+ // of an extreme sizing as it's almost as large as the more button, but
+ // it's necessary to make sure we never encounter that scenario.
+ if (count > 0) {
+ width += 30;
+ }
+ currentRowWidth += width;
+
+ if (currentRowWidth <= availableWidth) {
+ continue;
+ }
+
+ // If the recipients available in the current row exceed the
+ // available space, increase the row count and set the value of the
+ // last added list item to the next row width counter.
+ if (rows < this.#maxLinesBeforeMore) {
+ rows++;
+ currentRowWidth = width;
+ continue;
+ }
+
+ // Append the "more" button inside a list item to be properly handled
+ // as an inline element of the recipients list UI.
+ let buttonLi = document.createElement("li");
+ buttonLi.appendChild(this.moreButton);
+ this.recipientsList.appendChild(buttonLi);
+ currentRowWidth += buttonLi.getBoundingClientRect().width;
+
+ // Reverse loop through the added list item and remove them until
+ // they all fit in the current row alongside the "more" button.
+ for (; count && currentRowWidth > availableWidth; count--) {
+ let toRemove = this.recipientsList.childNodes[count];
+ currentRowWidth -= toRemove.getBoundingClientRect().width;
+ toRemove.remove();
+ }
+
+ // Skip the "more" button, which is present if we reached this stage.
+ let lastRecipientIndex = this.recipientsList.childNodes.length - 2;
+ // Add a unique class to the last visible recipient to remove the
+ // comma separator added via pseudo element.
+ this.recipientsList.childNodes[lastRecipientIndex].classList.add(
+ "last-before-button"
+ );
+
+ break;
+ }
+
+ if (focusIndex >= 0) {
+ // If we had focus before, restore focus to the same index, or the last node.
+ let focusNode =
+ this.recipientsList.childNodes[
+ Math.min(focusIndex, this.recipientsList.childNodes.length - 1)
+ ];
+ if (focusNode.contains(this.moreButton)) {
+ // The button is focusable.
+ this.moreButton.focus();
+ } else {
+ // The item is focusable.
+ focusNode.focus();
+ }
+ }
+ }
+
+ /**
+ * Show all recipients available in this widget.
+ */
+ showAllRecipients() {
+ this.buildRecipients(true);
+ }
+
+ /**
+ * Empty the widget.
+ */
+ clear() {
+ this.#recipients = [];
+ this.recipientsList.replaceChildren();
+ }
+ }
+ customElements.define("multi-recipient-row", MultiRecipientRow, {
+ extends: "div",
+ });
+
+ class HeaderRecipient extends HTMLLIElement {
+ /**
+ * The object holding the recipient information.
+ *
+ * @type {object}
+ * @property {string} displayName - The recipient display name.
+ * @property {string} [emailAddress] - The recipient email address.
+ * @property {string} [fullAddress] - The recipient full address.
+ */
+ #recipient = {};
+
+ /**
+ * The Card object if the recipients is saved in the address book.
+ *
+ * @type {object}
+ * @property {?object} book - The address book in which the contact is
+ * saved, if we have a card.
+ * @property {?object} card - The saved contact card, if present.
+ */
+ cardDetails = {};
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+
+ this.setAttribute("is", "header-recipient");
+ this.classList.add("header-recipient");
+ this.tabIndex = 0;
+
+ this.avatar = document.createElement("div");
+ this.avatar.classList.add("recipient-avatar");
+ this.appendChild(this.avatar);
+
+ this.email = document.createElement("span");
+ this.email.classList.add("recipient-single-line");
+ this.email.id = `${this.id}Display`;
+ this.appendChild(this.email);
+
+ this.multiLine = document.createElement("span");
+ this.multiLine.classList.add("recipient-multi-line");
+
+ this.nameLine = document.createElement("span");
+ this.nameLine.classList.add("recipient-multi-line-name");
+ this.multiLine.appendChild(this.nameLine);
+
+ this.addressLine = document.createElement("span");
+ this.addressLine.classList.add("recipient-multi-line-address");
+ this.multiLine.appendChild(this.addressLine);
+
+ this.appendChild(this.multiLine);
+
+ this.abIndicator = document.createElement("button");
+ this.abIndicator.classList.add(
+ "recipient-address-book-button",
+ "plain-button"
+ );
+ // We make the button non-focusable since its functionality is equivalent
+ // to the first item in the popup menu, so we can save a tab-stop.
+ this.abIndicator.tabIndex = -1;
+ this.abIndicator.addEventListener("click", event => {
+ event.stopPropagation();
+ if (this.cardDetails.card) {
+ gMessageHeader.editContact(this);
+ return;
+ }
+
+ this.addToAddressBook();
+ });
+
+ let img = document.createElement("img");
+ img.id = `${this.id}AbIcon`;
+ img.src = "chrome://messenger/skin/icons/new/address-book-indicator.svg";
+ document.l10n.setAttributes(
+ img,
+ "message-header-address-not-in-address-book-icon2"
+ );
+
+ this.abIndicator.appendChild(img);
+ this.appendChild(this.abIndicator);
+
+ // Use the email and icon as the accessible name. We do this to stop the
+ // button title from contributing to the accessible name.
+ // TODO: If the button or its title is removed, or the title replaces the
+ // image alt text, then remove this aria-labelledby attribute. The id's
+ // will no longer be necessary either.
+ this.setAttribute("aria-labelledby", `${this.email.id} ${img.id}`);
+
+ this.addEventListener("contextmenu", event => {
+ gMessageHeader.openEmailAddressPopup(event, this);
+ });
+ this.addEventListener("click", event => {
+ gMessageHeader.openEmailAddressPopup(event, this);
+ });
+ this.addEventListener("keypress", event => {
+ if (event.key == "Enter") {
+ gMessageHeader.openEmailAddressPopup(event, this);
+ }
+ });
+ }
+
+ set recipient(recipient) {
+ this.#recipient = recipient;
+ this.updateRecipient();
+ }
+
+ get displayName() {
+ return this.#recipient.displayName;
+ }
+
+ get emailAddress() {
+ return this.#recipient.emailAddress;
+ }
+
+ get fullAddress() {
+ return this.#recipient.fullAddress;
+ }
+
+ updateRecipient() {
+ if (!this.emailAddress) {
+ this.abIndicator.hidden = true;
+ this.email.textContent = this.displayName;
+ if (this.dataset.headerName == "from") {
+ this.nameLine.textContent = this.displayName;
+ this.addressLine.textContent = "";
+ this.avatar.replaceChildren();
+ this.avatar.classList.remove("has-avatar");
+ }
+ this.cardDetails = {};
+ return;
+ }
+
+ this.abIndicator.hidden = false;
+ let card = MailServices.ab.cardForEmailAddress(
+ this.#recipient.emailAddress
+ );
+ this.cardDetails = {
+ card,
+ book: card
+ ? MailServices.ab.getDirectoryFromUID(card.directoryUID)
+ : null,
+ };
+
+ let displayName = lazy.DisplayNameUtils.formatDisplayName(
+ this.emailAddress,
+ this.displayName,
+ this.dataset.headerName,
+ this.cardDetails.card
+ );
+
+ // Show only the display name if we have a valid card and the user wants
+ // to show a condensed header (without the full email address) for saved
+ // contacts.
+ if (gShowCondensedEmailAddresses && displayName) {
+ this.email.textContent = displayName;
+ this.email.setAttribute("title", this.#recipient.fullAddress);
+ } else {
+ this.email.textContent = this.#recipient.fullAddress;
+ this.email.removeAttribute("title");
+ }
+
+ if (this.dataset.headerName == "from") {
+ if (gShowCondensedEmailAddresses) {
+ this.nameLine.textContent =
+ displayName || this.displayName || this.fullAddress;
+ } else {
+ this.nameLine.textContent = this.fullAddress;
+ }
+ this.addressLine.textContent = this.emailAddress;
+ }
+
+ let hasCard = this.cardDetails.card;
+ // Update the style of the indicator button.
+ this.abIndicator.classList.toggle("in-address-book", hasCard);
+ document.l10n.setAttributes(
+ this.abIndicator,
+ hasCard
+ ? "message-header-address-in-address-book-button"
+ : "message-header-address-not-in-address-book-button"
+ );
+ document.l10n.setAttributes(
+ this.abIndicator.querySelector("img"),
+ hasCard
+ ? "message-header-address-in-address-book-icon2"
+ : "message-header-address-not-in-address-book-icon2"
+ );
+
+ if (this.dataset.headerName == "from") {
+ this._updateAvatar();
+ }
+ }
+
+ _updateAvatar() {
+ this.avatar.replaceChildren();
+
+ if (!this.cardDetails.card) {
+ this._createAvatarPlaceholder();
+ return;
+ }
+
+ // We have a card, so let's try to fetch the image.
+ let card = this.cardDetails.card;
+ let photoURL = card.photoURL;
+ if (photoURL) {
+ let img = document.createElement("img");
+ document.l10n.setAttributes(img, "message-header-recipient-avatar", {
+ address: this.emailAddress,
+ });
+ // TODO: We should fetch a dynamically generated smaller version of the
+ // uploaded picture to avoid loading large images that will only be used
+ // in smaller format.
+ img.src = photoURL;
+ this.avatar.appendChild(img);
+ this.avatar.classList.add("has-avatar");
+ } else {
+ this._createAvatarPlaceholder();
+ }
+ }
+
+ _createAvatarPlaceholder() {
+ let letter = document.createElement("span");
+ letter.textContent = Array.from(
+ this.nameLine.textContent || this.displayName || this.fullAddress
+ )[0]?.toUpperCase();
+ letter.setAttribute("aria-hidden", "true");
+ this.avatar.appendChild(letter);
+ this.avatar.classList.remove("has-avatar");
+ }
+
+ addToAddressBook() {
+ let card = Cc["@mozilla.org/addressbook/cardproperty;1"].createInstance(
+ Ci.nsIAbCard
+ );
+ card.displayName = this.#recipient.displayName;
+ card.primaryEmail = this.#recipient.emailAddress;
+
+ let addressBook = MailServices.ab.getDirectory(
+ "jsaddrbook://abook.sqlite"
+ );
+ addressBook.addCard(card);
+ }
+ }
+ customElements.define("header-recipient", HeaderRecipient, {
+ extends: "li",
+ });
+
+ class SimpleHeaderRow extends HTMLDivElement {
+ constructor() {
+ super();
+
+ this.addEventListener("contextmenu", event => {
+ gMessageHeader.openCopyPopup(event, this);
+ });
+ }
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+
+ this.setAttribute("is", "simple-header-row");
+ this.heading = document.createElement("span");
+ this.heading.id = `${this.dataset.headerName}Heading`;
+ this.heading.classList.add("row-heading");
+ let sep = document.createElement("span");
+ sep.classList.add("screen-reader-only");
+ sep.setAttribute("data-l10n-name", "field-separator");
+ this.heading.appendChild(sep);
+
+ if (
+ ["organization", "subject", "date", "user-agent"].includes(
+ this.dataset.headerName
+ )
+ ) {
+ // message-header-organization-field
+ // message-header-subject-field
+ // message-header-date-field
+ // message-header-user-agent-field
+ document.l10n.setAttributes(
+ this.heading,
+ `message-header-${this.dataset.headerName}-field`
+ );
+ } else {
+ // If this simple row is used by an autogenerated custom header,
+ // use directly that header value as label.
+ document.l10n.setAttributes(
+ this.heading,
+ "message-header-custom-field",
+ {
+ fieldName: this.dataset.prettyHeaderName,
+ }
+ );
+ }
+ this.appendChild(this.heading);
+
+ this.classList.add("header-row");
+ this.tabIndex = 0;
+
+ this.value = document.createElement("span");
+ this.appendChild(this.value);
+ }
+
+ /**
+ * Set the text content for this row.
+ *
+ * @param {string} val - The content string to be added to this row.
+ */
+ set headerValue(val) {
+ this.value.textContent = val;
+ // NOTE: In principle, we could use aria-labelledby and point to the
+ // heading and value elements. However, for some reason the expected
+ // accessible name is not read out when focused whilst using Orca screen
+ // reader. Instead, only the content of the value element is read out.
+ // This may be because this element has no proper ARIA role since we are
+ // extending a div, which is not a best approach, so we can't expect
+ // proper support.
+ // TODO: This area needs some proper semantics to associate the fieldname
+ // with the field value, whilst being focusable to allow the user to open
+ // a context menu on the row.
+ this.setAttribute(
+ "aria-label",
+ `${this.heading.textContent} ${this.value.textContent}`
+ );
+ }
+ }
+ customElements.define("simple-header-row", SimpleHeaderRow, {
+ extends: "div",
+ });
+
+ class UrlHeaderRow extends SimpleHeaderRow {
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+ super.connectedCallback();
+
+ this.setAttribute("is", "url-header-row");
+ document.l10n.setAttributes(this.heading, "message-header-website-field");
+
+ this.value.classList.add("text-link");
+ this.addEventListener("click", event => {
+ if (event.button != 2) {
+ openUILink(encodeURI(this.value.textContent), event);
+ }
+ });
+ this.addEventListener("keydown", event => {
+ if (event.key == "Enter") {
+ openUILink(encodeURI(this.value.textContent), event);
+ }
+ });
+ }
+ }
+ customElements.define("url-header-row", UrlHeaderRow, {
+ extends: "div",
+ });
+
+ class HeaderNewsgroupsRow extends HTMLDivElement {
+ /**
+ * The array of all the newsgroups that need to be shown in this row.
+ *
+ * @type {Array<object>}
+ */
+ #newsgroups = [];
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+
+ this.setAttribute("is", "header-newsgroups-row");
+ this.classList.add("header-newsgroups-row");
+
+ this.heading = document.createElement("span");
+ this.heading.id = `${this.dataset.headerName}Heading`;
+ this.heading.classList.add("row-heading");
+ // message-header-newsgroups-list-name
+ // message-header-followup-to-list-name
+ document.l10n.setAttributes(
+ this.heading,
+ `message-header-${this.dataset.headerName}-list-name`
+ );
+ this.appendChild(this.heading);
+
+ this.newsgroupsList = document.createElement("ol");
+ this.newsgroupsList.classList.add("newsgroups-list");
+ this.newsgroupsList.setAttribute("aria-labelledby", this.heading.id);
+ this.appendChild(this.newsgroupsList);
+ }
+
+ addNewsgroup(newsgroup) {
+ this.#newsgroups.push(newsgroup);
+ }
+
+ buildView() {
+ this.newsgroupsList.replaceChildren();
+ for (let newsgroup of this.#newsgroups) {
+ let li = document.createElement("li", { is: "header-newsgroup" });
+ this.newsgroupsList.appendChild(li);
+ li.textContent = newsgroup;
+ }
+ }
+
+ clear() {
+ this.#newsgroups = [];
+ this.newsgroupsList.replaceChildren();
+ }
+ }
+ customElements.define("header-newsgroups-row", HeaderNewsgroupsRow, {
+ extends: "div",
+ });
+
+ class HeaderNewsgroup extends HTMLLIElement {
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+
+ this.setAttribute("is", "header-newsgroup");
+ this.classList.add("header-newsgroup");
+ this.tabIndex = 0;
+
+ this.addEventListener("contextmenu", event => {
+ gMessageHeader.openNewsgroupPopup(event, this);
+ });
+ this.addEventListener("click", event => {
+ gMessageHeader.openNewsgroupPopup(event, this);
+ });
+ this.addEventListener("keypress", event => {
+ if (event.key == "Enter") {
+ gMessageHeader.openNewsgroupPopup(event, this);
+ }
+ });
+ }
+ }
+ customElements.define("header-newsgroup", HeaderNewsgroup, {
+ extends: "li",
+ });
+
+ class HeaderTagsRow extends HTMLDivElement {
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+
+ this.setAttribute("is", "header-tags-row");
+ this.classList.add("header-tags-row");
+
+ this.heading = document.createElement("span");
+ this.heading.id = `${this.dataset.headerName}Heading`;
+ this.heading.classList.add("row-heading");
+ document.l10n.setAttributes(
+ this.heading,
+ "message-header-tags-list-name"
+ );
+ this.appendChild(this.heading);
+
+ this.tagsList = document.createElement("ol");
+ this.tagsList.classList.add("tags-list");
+ this.tagsList.setAttribute("aria-labelledby", this.heading.id);
+ this.appendChild(this.tagsList);
+ }
+
+ buildTags(tags) {
+ // Clear old tags.
+ this.tagsList.replaceChildren();
+
+ for (let tag of tags) {
+ // For each tag, create a label, give it the font color that corresponds to the
+ // color of the tag and append it.
+ let tagName;
+ try {
+ // if we got a bad tag name, getTagForKey will throw an exception, skip it
+ // and go to the next one.
+ tagName = MailServices.tags.getTagForKey(tag);
+ } catch (ex) {
+ continue;
+ }
+
+ // Create a label for the tag name and set the color.
+ let li = document.createElement("li");
+ li.tabIndex = 0;
+ li.classList.add("tag");
+ li.textContent = tagName;
+
+ let color = MailServices.tags.getColorForKey(tag);
+ if (color) {
+ let textColor = !lazy.TagUtils.isColorContrastEnough(color)
+ ? "white"
+ : "black";
+ li.setAttribute(
+ "style",
+ `color: ${textColor}; background-color: ${color};`
+ );
+ }
+
+ this.tagsList.appendChild(li);
+ }
+ }
+
+ clear() {
+ this.tagsList.replaceChildren();
+ }
+ }
+ customElements.define("header-tags-row", HeaderTagsRow, {
+ extends: "div",
+ });
+
+ class MultiMessageIdsRow extends HTMLDivElement {
+ /**
+ * The array of all the IDs that need to be shown in this row.
+ *
+ * @type {Array<object>}
+ */
+ #ids = [];
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+
+ this.setAttribute("is", "multi-message-ids-row");
+ this.classList.add("multi-message-ids-row");
+
+ this.heading = document.createElement("span");
+ this.heading.id = `${this.dataset.headerName}Heading`;
+ this.heading.classList.add("row-heading");
+ let sep = document.createElement("span");
+ sep.classList.add("screen-reader-only");
+ sep.setAttribute("data-l10n-name", "field-separator");
+ this.heading.appendChild(sep);
+
+ // message-header-references-field
+ // message-header-message-id-field
+ // message-header-in-reply-to-field
+ document.l10n.setAttributes(
+ this.heading,
+ `message-header-${this.dataset.headerName}-field`
+ );
+ this.appendChild(this.heading);
+
+ this.idsList = document.createElement("ol");
+ this.idsList.classList.add("ids-list");
+ this.appendChild(this.idsList);
+
+ this.toggleButton = document.createElement("button");
+ this.toggleButton.setAttribute("type", "button");
+ this.toggleButton.classList.add("show-more-ids", "plain");
+ this.toggleButton.addEventListener(
+ "mousedown",
+ // Prevent focus being transferred to the button before it is removed.
+ event => event.preventDefault()
+ );
+ this.toggleButton.addEventListener("click", () => this.buildView(true));
+
+ document.l10n.setAttributes(
+ this.toggleButton,
+ "message-ids-field-show-all"
+ );
+ }
+
+ addId(id) {
+ this.#ids.push(id);
+ }
+
+ buildView(showAll = false) {
+ this.idsList.replaceChildren();
+ for (let [count, id] of this.#ids.entries()) {
+ let li = document.createElement("li", { is: "header-message-id" });
+ li.id = id;
+ this.idsList.appendChild(li);
+ if (!showAll && count < this.#ids.length - 1 && this.#ids.length > 1) {
+ li.messageId.textContent = count + 1;
+ li.messageId.title = id;
+ } else {
+ li.messageId.textContent = id;
+ }
+ }
+
+ if (!showAll && this.#ids.length > 1) {
+ this.idsList.lastElementChild.classList.add("last-before-button");
+ let liButton = document.createElement("li");
+ liButton.appendChild(this.toggleButton);
+ this.idsList.appendChild(liButton);
+ }
+ }
+
+ clear() {
+ this.#ids = [];
+ this.idsList.replaceChildren();
+ }
+ }
+ customElements.define("multi-message-ids-row", MultiMessageIdsRow, {
+ extends: "div",
+ });
+
+ class HeaderMessageId extends HTMLLIElement {
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+
+ this.setAttribute("is", "header-message-id");
+ this.classList.add("header-message-id");
+
+ this.messageId = document.createElement("span");
+ this.messageId.classList.add("text-link");
+ this.messageId.tabIndex = 0;
+ this.appendChild(this.messageId);
+
+ this.messageId.addEventListener("contextmenu", event => {
+ gMessageHeader.openMessageIdPopup(event, this);
+ });
+ this.messageId.addEventListener("click", event => {
+ gMessageHeader.onMessageIdClick(event);
+ });
+ this.messageId.addEventListener("keypress", event => {
+ if (event.key == "Enter") {
+ gMessageHeader.onMessageIdClick(event);
+ }
+ });
+ }
+ }
+ customElements.define("header-message-id", HeaderMessageId, {
+ extends: "li",
+ });
+}
diff --git a/comm/mail/base/content/widgets/mailWidgets.js b/comm/mail/base/content/widgets/mailWidgets.js
new file mode 100644
index 0000000000..6ad566b742
--- /dev/null
+++ b/comm/mail/base/content/widgets/mailWidgets.js
@@ -0,0 +1,2477 @@
+/**
+ * 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 ../../../components/compose/content/addressingWidgetOverlay.js */
+/* import-globals-from ../../../components/compose/content/MsgComposeCommands.js */
+
+/* global MozElements */
+/* global MozXULElement */
+/* global gFolderDisplay */
+/* global PluralForm */
+/* global onRecipientsChanged */
+
+// Wrap in a block to prevent leaking to window scope.
+{
+ const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+ );
+ const LazyModules = {};
+
+ ChromeUtils.defineModuleGetter(
+ LazyModules,
+ "DBViewWrapper",
+ "resource:///modules/DBViewWrapper.jsm"
+ );
+ ChromeUtils.defineModuleGetter(
+ LazyModules,
+ "MailUtils",
+ "resource:///modules/MailUtils.jsm"
+ );
+ ChromeUtils.defineModuleGetter(
+ LazyModules,
+ "MimeParser",
+ "resource:///modules/mimeParser.jsm"
+ );
+ ChromeUtils.defineModuleGetter(
+ LazyModules,
+ "TagUtils",
+ "resource:///modules/TagUtils.jsm"
+ );
+
+ // NOTE: Icon column headers should have their "label" attribute set to
+ // describe the icon for the accessibility tree.
+ //
+ // NOTE: Ideally we could listen for the "alt" attribute and pass it on to the
+ // contained <img>, but the accessibility tree only seems to read the "label"
+ // for a <treecol>, and ignores the alt text.
+ class MozTreecolImage extends customElements.get("treecol") {
+ static get observedAttributes() {
+ return ["src"];
+ }
+
+ connectedCallback() {
+ if (this.hasChildNodes() || this.delayConnectedCallback()) {
+ return;
+ }
+ this.image = document.createElement("img");
+ this.image.classList.add("treecol-icon");
+
+ this.appendChild(this.image);
+ this._updateAttributes();
+ }
+
+ attributeChangedCallback() {
+ this._updateAttributes();
+ }
+
+ _updateAttributes() {
+ if (!this.image) {
+ return;
+ }
+
+ const src = this.getAttribute("src");
+
+ if (src != null) {
+ this.image.setAttribute("src", src);
+ } else {
+ this.image.removeAttribute("src");
+ }
+ }
+ }
+ customElements.define("treecol-image", MozTreecolImage, {
+ extends: "treecol",
+ });
+
+ /**
+ * Class extending treecols. This features a customized treecolpicker that
+ * features a menupopup with more items than the standard one.
+ *
+ * @augments {MozTreecols}
+ */
+ class MozThreadPaneTreecols extends customElements.get("treecols") {
+ connectedCallback() {
+ if (this.delayConnectedCallback()) {
+ return;
+ }
+ let treecolpicker = this.querySelector("treecolpicker:not([is]");
+
+ // Can't change the super treecolpicker by setting
+ // is="thread-pane-treecolpicker" since that needs to be there at the
+ // parsing stage to take effect.
+ // So, remove the existing treecolpicker, and add a new one.
+ if (treecolpicker) {
+ treecolpicker.remove();
+ }
+ if (!this.querySelector("treecolpicker[is=thread-pane-treecolpicker]")) {
+ this.appendChild(
+ MozXULElement.parseXULToFragment(
+ `
+ <treecolpicker is="thread-pane-treecolpicker"
+ class="thread-tree-col-picker"
+ tooltiptext="&columnChooser2.tooltip;"
+ fixed="true">
+ </treecolpicker>
+ `,
+ ["chrome://messenger/locale/messenger.dtd"]
+ )
+ );
+ }
+ // Exceptionally apply super late, so we get the other goodness from there
+ // now that the treecolpicker is corrected.
+ super.connectedCallback();
+ }
+ }
+ customElements.define("thread-pane-treecols", MozThreadPaneTreecols, {
+ extends: "treecols",
+ });
+
+ /**
+ * Class extending treecolpicker. This implements UI to apply column settings
+ * of the current thread pane to other mail folders too.
+ *
+ * @augments {MozTreecolPicker}
+ */
+ class MozThreadPaneTreeColpicker extends customElements.get("treecolpicker") {
+ connectedCallback() {
+ super.connectedCallback();
+ if (this.delayConnectedCallback()) {
+ return;
+ }
+ MozXULElement.insertFTLIfNeeded("messenger/mailWidgets.ftl");
+ let popup = this.querySelector(`menupopup[anonid="popup"]`);
+
+ // We'll add an "Apply columns to..." menu
+ popup.appendChild(
+ MozXULElement.parseXULToFragment(
+ `
+ <menu class="applyTo-menu" label="&columnPicker.applyTo.label;">
+ <menupopup>
+ <menu class="applyToFolder-menu"
+ label="&columnPicker.applyToFolder.label;">
+ <menupopup is="folder-menupopup"
+ class="applyToFolder"
+ showFileHereLabel="true"
+ position="start_before"></menupopup>
+ </menu>
+ <menu class="applyToFolderAndChildren-menu"
+ label="&columnPicker.applyToFolderAndChildren.label;">
+ <menupopup is="folder-menupopup"
+ class="applyToFolderAndChildren"
+ showFileHereLabel="true"
+ showAccountsFileHere="true"
+ position="start_before"></menupopup>
+ </menu>
+ </menupopup>
+ </menu>
+ <menu class="applyViewTo-menu" data-l10n-id="apply-current-view-to-menu">
+ <menupopup>
+ <menu class="applyViewToFolder-menu"
+ label="&columnPicker.applyToFolder.label;">
+ <menupopup is="folder-menupopup"
+ class="applyViewToFolder"
+ showFileHereLabel="true"
+ position="start_before"></menupopup>
+ </menu>
+ <menu class="applyViewToFolderAndChildren-menu"
+ label="&columnPicker.applyToFolderAndChildren.label;">
+ <menupopup is="folder-menupopup"
+ class="applyViewToFolderAndChildren"
+ showFileHereLabel="true"
+ showAccountsFileHere="true"
+ position="start_before"></menupopup>
+ </menu>
+ </menupopup>
+ </menu>
+ `,
+ ["chrome://messenger/locale/messenger.dtd"]
+ )
+ );
+
+ let confirmApplyCols = (destFolder, useChildren) => {
+ // Confirm the action with the user.
+ let bundle = document.getElementById("bundle_messenger");
+ let title = useChildren
+ ? "threadPane.columnPicker.confirmFolder.withChildren.title"
+ : "threadPane.columnPicker.confirmFolder.noChildren.title";
+ let message = useChildren
+ ? "threadPane.columnPicker.confirmFolder.withChildren.message"
+ : "threadPane.columnPicker.confirmFolder.noChildren.message";
+ let confirmed = Services.prompt.confirm(
+ null,
+ bundle.getString(title),
+ bundle.getFormattedString(message, [destFolder.prettyName])
+ );
+ if (confirmed) {
+ this._applyColumns(destFolder, useChildren);
+ }
+ };
+
+ this.querySelector(".applyToFolder-menu").addEventListener(
+ "command",
+ event => {
+ confirmApplyCols(event.target._folder, false);
+ }
+ );
+
+ this.querySelector(".applyToFolderAndChildren-menu").addEventListener(
+ "command",
+ event => {
+ confirmApplyCols(event.target._folder, true);
+ }
+ );
+
+ let confirmApplyView = async (destFolder, useChildren) => {
+ let msgId = useChildren
+ ? "threadpane-apply-changes-prompt-with-children-text"
+ : "threadpane-apply-changes-prompt-no-children-text";
+ let [title, message] = await document.l10n.formatValues([
+ { id: "threadpane-apply-changes-prompt-title" },
+ { id: msgId, args: { name: destFolder.prettyName } },
+ ]);
+ if (Services.prompt.confirm(null, title, message)) {
+ this._applyView(destFolder, useChildren);
+ }
+ };
+
+ this.querySelector(".applyViewToFolder-menu").addEventListener(
+ "command",
+ event => {
+ confirmApplyView(event.target._folder, false);
+ }
+ );
+
+ this.querySelector(".applyViewToFolderAndChildren-menu").addEventListener(
+ "command",
+ event => {
+ confirmApplyView(event.target._folder, true);
+ }
+ );
+ }
+
+ _applyColumns(destFolder, useChildren) {
+ // Get the current folder's column state, plus the "swapped" column
+ // state, which swaps "From" and "Recipient" if only one is shown.
+ // This is useful for copying an incoming folder's columns to an
+ // outgoing folder, or vice versa.
+ let colState = gFolderDisplay.getColumnStates();
+
+ let myColStateString = JSON.stringify(colState);
+ let swappedColStateString;
+ if (colState.senderCol.visible != colState.recipientCol.visible) {
+ let tmp = colState.senderCol;
+ colState.senderCol = colState.recipientCol;
+ colState.recipientCol = tmp;
+ swappedColStateString = JSON.stringify(colState);
+ } else {
+ swappedColStateString = myColStateString;
+ }
+
+ let isOutgoing = function (folder) {
+ return folder.isSpecialFolder(
+ LazyModules.DBViewWrapper.prototype.OUTGOING_FOLDER_FLAGS,
+ true
+ );
+ };
+
+ let amIOutgoing = isOutgoing(gFolderDisplay.displayedFolder);
+
+ let colStateString = function (folder) {
+ return isOutgoing(folder) == amIOutgoing
+ ? myColStateString
+ : swappedColStateString;
+ };
+
+ // Now propagate appropriately...
+ const propName = gFolderDisplay.PERSISTED_COLUMN_PROPERTY_NAME;
+ if (useChildren) {
+ LazyModules.MailUtils.takeActionOnFolderAndDescendents(
+ destFolder,
+ folder => {
+ folder.setStringProperty(propName, colStateString(folder));
+ // Force the reference to be forgotten.
+ folder.msgDatabase = null;
+ }
+ ).then(() => {
+ Services.obs.notifyObservers(
+ gFolderDisplay.displayedFolder,
+ "msg-folder-columns-propagated"
+ );
+ });
+ } else {
+ destFolder.setStringProperty(propName, colStateString(destFolder));
+ // null out to avoid memory bloat.
+ destFolder.msgDatabase = null;
+ }
+ }
+
+ _applyView(destFolder, useChildren) {
+ let viewFlags =
+ gFolderDisplay.displayedFolder.msgDatabase.dBFolderInfo.viewFlags;
+ let sortType =
+ gFolderDisplay.displayedFolder.msgDatabase.dBFolderInfo.sortType;
+ let sortOrder =
+ gFolderDisplay.displayedFolder.msgDatabase.dBFolderInfo.sortOrder;
+ if (useChildren) {
+ LazyModules.MailUtils.takeActionOnFolderAndDescendents(
+ destFolder,
+ folder => {
+ folder.msgDatabase.dBFolderInfo.viewFlags = viewFlags;
+ folder.msgDatabase.dBFolderInfo.sortType = sortType;
+ folder.msgDatabase.dBFolderInfo.sortOrder = sortOrder;
+ folder.msgDatabase = null;
+ }
+ ).then(() => {
+ Services.obs.notifyObservers(
+ gFolderDisplay.displayedFolder,
+ "msg-folder-views-propagated"
+ );
+ });
+ } else {
+ destFolder.msgDatabase.dBFolderInfo.viewFlags = viewFlags;
+ destFolder.msgDatabase.dBFolderInfo.sortType = sortType;
+ destFolder.msgDatabase.dBFolderInfo.sortOrder = sortOrder;
+ // null out to avoid memory bloat
+ destFolder.msgDatabase = null;
+ }
+ }
+ }
+ customElements.define(
+ "thread-pane-treecolpicker",
+ MozThreadPaneTreeColpicker,
+ { extends: "treecolpicker" }
+ );
+
+ // The menulist CE is defined lazily. Create one now to get menulist defined,
+ // allowing us to inherit from it.
+ if (!customElements.get("menulist")) {
+ delete document.createXULElement("menulist");
+ }
+ {
+ /**
+ * MozMenulistEditable is a menulist widget that can be made editable by setting editable="true".
+ * With an additional type="description" the list also contains an additional label that can hold
+ * for instance, a description of a menu item.
+ * It is typically used e.g. for the "Custom From Address..." feature to let the user chose and
+ * edit the address to send from.
+ *
+ * @augments {MozMenuList}
+ */
+ class MozMenulistEditable extends customElements.get("menulist") {
+ static get markup() {
+ // Accessibility information of these nodes will be
+ // presented on XULComboboxAccessible generated from <menulist>;
+ // hide these nodes from the accessibility tree.
+ return `
+ <html:link rel="stylesheet" href="chrome://global/skin/menulist.css"/>
+ <html:input part="text-input" type="text" allowevents="true"/>
+ <hbox id="label-box" part="label-box" flex="1" role="none">
+ <label id="label" part="label" crop="end" flex="1" role="none"/>
+ <label id="highlightable-label" part="label" crop="end" flex="1" role="none"/>
+ </hbox>
+ <dropmarker part="dropmarker" exportparts="icon: dropmarker-icon" type="menu" role="none"/>
+ <html:slot/>
+ `;
+ }
+
+ connectedCallback() {
+ if (this.delayConnectedCallback()) {
+ return;
+ }
+
+ this.shadowRoot.appendChild(this.constructor.fragment);
+ this._inputField = this.shadowRoot.querySelector("input");
+ this._labelBox = this.shadowRoot.getElementById("label-box");
+ this._dropmarker = this.shadowRoot.querySelector("dropmarker");
+
+ if (this.getAttribute("type") == "description") {
+ this._description = document.createXULElement("label");
+ this._description.id = this._description.part = "description";
+ this._description.setAttribute("crop", "end");
+ this._description.setAttribute("role", "none");
+ this.shadowRoot.getElementById("label").after(this._description);
+ }
+
+ this.initializeAttributeInheritance();
+
+ this.mSelectedInternal = null;
+ this.setInitialSelection();
+
+ this._handleMutation = mutations => {
+ this.editable = this.getAttribute("editable") == "true";
+ };
+ this.mAttributeObserver = new MutationObserver(this._handleMutation);
+ this.mAttributeObserver.observe(this, {
+ attributes: true,
+ attributeFilter: ["editable"],
+ });
+
+ this._keypress = event => {
+ if (event.key == "ArrowDown") {
+ this.open = true;
+ }
+ };
+ this._inputField.addEventListener("keypress", this._keypress);
+ this._change = event => {
+ event.stopPropagation();
+ this.selectedItem = null;
+ this.setAttribute("value", this._inputField.value);
+ // Start the event again, but this time with the menulist as target.
+ this.dispatchEvent(new CustomEvent("change", { bubbles: true }));
+ };
+ this._inputField.addEventListener("change", this._change);
+
+ this._popupHiding = event => {
+ // layerX is 0 if the user clicked outside the popup.
+ if (this.editable && event.layerX > 0) {
+ this._inputField.select();
+ }
+ };
+ if (!this.menupopup) {
+ this.appendChild(MozXULElement.parseXULToFragment(`<menupopup />`));
+ }
+ this.menupopup.addEventListener("popuphiding", this._popupHiding);
+ }
+
+ disconnectedCallback() {
+ super.disconnectedCallback();
+
+ this.mAttributeObserver.disconnect();
+ this._inputField.removeEventListener("keypress", this._keypress);
+ this._inputField.removeEventListener("change", this._change);
+ this.menupopup.removeEventListener("popuphiding", this._popupHiding);
+
+ for (let prop of [
+ "_inputField",
+ "_labelBox",
+ "_dropmarker",
+ "_description",
+ ]) {
+ if (this[prop]) {
+ this[prop].remove();
+ this[prop] = null;
+ }
+ }
+ }
+
+ static get inheritedAttributes() {
+ let attrs = super.inheritedAttributes;
+ attrs.input = "value,disabled";
+ attrs["#description"] = "value=description";
+ return attrs;
+ }
+
+ set editable(val) {
+ if (val == this.editable) {
+ return;
+ }
+
+ if (!val) {
+ // If we were focused and transition from editable to not editable,
+ // focus the parent menulist so that the focus does not get stuck.
+ if (this._inputField == document.activeElement) {
+ window.setTimeout(() => this.focus(), 0);
+ }
+ }
+
+ this.setAttribute("editable", val);
+ }
+
+ get editable() {
+ return this.getAttribute("editable") == "true";
+ }
+
+ set value(val) {
+ this._inputField.value = val;
+ this.setAttribute("value", val);
+ this.setAttribute("label", val);
+ }
+
+ get value() {
+ if (this.editable) {
+ return this._inputField.value;
+ }
+ return super.value;
+ }
+
+ get label() {
+ if (this.editable) {
+ return this._inputField.value;
+ }
+ return super.label;
+ }
+
+ set placeholder(val) {
+ this._inputField.placeholder = val;
+ }
+
+ get placeholder() {
+ return this._inputField.placeholder;
+ }
+
+ set selectedItem(val) {
+ if (val) {
+ this._inputField.value = val.getAttribute("value");
+ }
+ super.selectedItem = val;
+ }
+
+ get selectedItem() {
+ return super.selectedItem;
+ }
+
+ focus() {
+ if (this.editable) {
+ this._inputField.focus();
+ } else {
+ super.focus();
+ }
+ }
+
+ select() {
+ if (this.editable) {
+ this._inputField.select();
+ }
+ }
+ }
+
+ const MenuBaseControl = MozElements.BaseControlMixin(
+ MozElements.MozElementMixin(XULMenuElement)
+ );
+ MenuBaseControl.implementCustomInterface(MozMenulistEditable, [
+ Ci.nsIDOMXULMenuListElement,
+ Ci.nsIDOMXULSelectControlElement,
+ ]);
+
+ customElements.define("menulist-editable", MozMenulistEditable, {
+ extends: "menulist",
+ });
+ }
+
+ /**
+ * The MozAttachmentlist widget lists attachments for a mail. This is typically used to show
+ * attachments while writing a new mail as well as when reading mails.
+ *
+ * @augments {MozElements.RichListBox}
+ */
+ class MozAttachmentlist extends MozElements.RichListBox {
+ constructor() {
+ super();
+
+ this.messenger = Cc["@mozilla.org/messenger;1"].createInstance(
+ Ci.nsIMessenger
+ );
+
+ this.addEventListener("keypress", event => {
+ switch (event.key) {
+ case " ":
+ // Allow plain spacebar to select the focused item.
+ if (!event.shiftKey && !event.ctrlKey) {
+ this.addItemToSelection(this.currentItem);
+ }
+ // Prevent inbuilt scrolling.
+ event.preventDefault();
+ break;
+
+ case "Enter":
+ if (this.currentItem && !event.ctrlKey && !event.shiftKey) {
+ this.addItemToSelection(this.currentItem);
+ let evt = document.createEvent("XULCommandEvent");
+ evt.initCommandEvent(
+ "command",
+ true,
+ true,
+ window,
+ 0,
+ event.ctrlKey,
+ event.altKey,
+ event.shiftKey,
+ event.metaKey,
+ null
+ );
+ this.currentItem.dispatchEvent(evt);
+ }
+ break;
+ }
+ });
+
+ // Make sure we keep the focus.
+ this.addEventListener("mousedown", event => {
+ if (event.button != 0) {
+ return;
+ }
+
+ if (document.commandDispatcher.focusedElement != this) {
+ this.focus();
+ }
+ });
+ }
+
+ connectedCallback() {
+ super.connectedCallback();
+ if (this.delayConnectedCallback()) {
+ return;
+ }
+
+ let children = Array.from(this._childNodes);
+
+ children
+ .filter(child => child.getAttribute("selected") == "true")
+ .forEach(this.selectedItems.append, this.selectedItems);
+
+ children
+ .filter(child => !child.hasAttribute("context"))
+ .forEach(child =>
+ child.setAttribute("context", this.getAttribute("itemcontext"))
+ );
+ }
+
+ get itemCount() {
+ return this._childNodes.length;
+ }
+
+ /**
+ * Get the preferred height (the height that would allow us to fit
+ * everything without scrollbars) of the attachmentlist's bounding
+ * rectangle. Add 3px to account for item's margin.
+ */
+ get preferredHeight() {
+ return this.scrollHeight + this.getBoundingClientRect().height + 3;
+ }
+
+ get _childNodes() {
+ return this.querySelectorAll("richlistitem.attachmentItem");
+ }
+
+ getIndexOfItem(item) {
+ for (let i = 0; i < this._childNodes.length; i++) {
+ if (this._childNodes[i] === item) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ getItemAtIndex(index) {
+ if (index >= 0 && index < this._childNodes.length) {
+ return this._childNodes[index];
+ }
+ return null;
+ }
+
+ getRowCount() {
+ return this._childNodes.length;
+ }
+
+ getIndexOfFirstVisibleRow() {
+ if (this._childNodes.length == 0) {
+ return -1;
+ }
+
+ // First try to estimate which row is visible, assuming they're all the same height.
+ let box = this;
+ let estimatedRow = Math.floor(
+ box.scrollTop / this._childNodes[0].getBoundingClientRect().height
+ );
+ let estimatedIndex = estimatedRow * this._itemsPerRow();
+ let offset = this._childNodes[estimatedIndex].screenY - box.screenY;
+
+ if (offset > 0) {
+ // We went too far! Go back until we find an item totally off-screen, then return the one
+ // after that.
+ for (let i = estimatedIndex - 1; i >= 0; i--) {
+ let childBoxObj = this._childNodes[i].getBoundingClientRect();
+ if (childBoxObj.screenY + childBoxObj.height <= box.screenY) {
+ return i + 1;
+ }
+ }
+
+ // If we get here, we must have gone back to the beginning of the list, so just return 0.
+ return 0;
+ }
+
+ // We didn't go far enough! Keep going until we find an item at least partially on-screen.
+ for (let i = estimatedIndex; i < this._childNodes.length; i++) {
+ let childBoxObj = this._childNodes[i].getBoundingClientRect();
+ if (childBoxObj.screenY + childBoxObj.height > box.screenY > 0) {
+ return i;
+ }
+ }
+
+ return null;
+ }
+
+ ensureIndexIsVisible(index) {
+ this.ensureElementIsVisible(this.getItemAtIndex(index));
+ }
+
+ ensureElementIsVisible(item) {
+ let box = this;
+
+ // Are we too far down?
+ if (item.screenY < box.screenY) {
+ box.scrollTop =
+ item.getBoundingClientRect().y - box.getBoundingClientRect().y;
+ } else if (
+ item.screenY + item.getBoundingClientRect().height >
+ box.screenY + box.getBoundingClientRect().height
+ ) {
+ // ... or not far enough?
+ box.scrollTop =
+ item.getBoundingClientRect().y +
+ item.getBoundingClientRect().height -
+ box.getBoundingClientRect().y -
+ box.getBoundingClientRect().height;
+ }
+ }
+
+ scrollToIndex(index) {
+ let box = this;
+ let item = this.getItemAtIndex(index);
+ if (!item) {
+ return;
+ }
+ box.scrollTop =
+ item.getBoundingClientRect().y - box.getBoundingClientRect().y;
+ }
+
+ appendItem(attachment, name) {
+ // -1 appends due to the way getItemAtIndex is implemented.
+ return this.insertItemAt(-1, attachment, name);
+ }
+
+ insertItemAt(index, attachment, name) {
+ let item = this.ownerDocument.createXULElement("richlistitem");
+ item.classList.add("attachmentItem");
+ item.setAttribute("role", "option");
+
+ item.addEventListener("dblclick", event => {
+ let evt = document.createEvent("XULCommandEvent");
+ evt.initCommandEvent(
+ "command",
+ true,
+ true,
+ window,
+ 0,
+ event.ctrlKey,
+ event.altKey,
+ event.shiftKey,
+ event.metaKey,
+ null
+ );
+ item.dispatchEvent(evt);
+ });
+
+ let makeDropIndicator = placementClass => {
+ let img = document.createElement("img");
+ img.setAttribute(
+ "src",
+ "chrome://messenger/skin/icons/tab-drag-indicator.svg"
+ );
+ img.setAttribute("alt", "");
+ img.classList.add("attach-drop-indicator", placementClass);
+ return img;
+ };
+
+ item.appendChild(makeDropIndicator("before"));
+
+ let icon = this.ownerDocument.createElement("img");
+ icon.setAttribute("alt", "");
+ icon.setAttribute("draggable", "false");
+ // Allow the src to be invalid.
+ icon.classList.add("attachmentcell-icon", "invisible-on-broken");
+ item.appendChild(icon);
+
+ let textLabel = this.ownerDocument.createElement("span");
+ textLabel.classList.add("attachmentcell-name");
+ item.appendChild(textLabel);
+
+ let extensionLabel = this.ownerDocument.createElement("span");
+ extensionLabel.classList.add("attachmentcell-extension");
+ item.appendChild(extensionLabel);
+
+ let sizeLabel = this.ownerDocument.createElement("span");
+ sizeLabel.setAttribute("role", "note");
+ sizeLabel.classList.add("attachmentcell-size");
+ item.appendChild(sizeLabel);
+
+ item.appendChild(makeDropIndicator("after"));
+
+ item.setAttribute("context", this.getAttribute("itemcontext"));
+
+ item.attachment = attachment;
+ this.invalidateItem(item, name);
+ this.insertBefore(item, this.getItemAtIndex(index));
+ return item;
+ }
+
+ /**
+ * Set the attachment icon source.
+ *
+ * @param {MozRichlistitem} item - The attachment item to set the icon of.
+ * @param {string|null} src - The src to set.
+ */
+ setAttachmentIconSrc(item, src) {
+ let icon = item.querySelector(".attachmentcell-icon");
+ icon.setAttribute("src", src);
+ }
+
+ /**
+ * Refresh the attachment icon using the attachment details.
+ *
+ * @param {MozRichlistitem} item - The attachment item to refresh the icon
+ * for.
+ */
+ refreshAttachmentIcon(item) {
+ let src;
+ let attachment = item.attachment;
+ let type = attachment.contentType;
+ if (type == "text/x-moz-deleted") {
+ src = "chrome://messenger/skin/icons/attachment-deleted.svg";
+ } else if (!item.loaded || item.uploading) {
+ src = "chrome://global/skin/icons/loading.png";
+ } else if (item.cloudIcon) {
+ src = item.cloudIcon;
+ } else {
+ let iconName = attachment.name;
+ if (iconName.toLowerCase().endsWith(".eml")) {
+ // Discard file names derived from subject headers with special
+ // characters.
+ iconName = "message.eml";
+ } else if (attachment.url) {
+ // For local file urls, we are better off using the full file url
+ // because moz-icon will actually resolve the file url and get the
+ // right icon from the file url. All other urls, we should try to
+ // extract the file name from them. This fixes issues where an icon
+ // wasn't showing up if you dragged a web url that had a query or
+ // reference string after the file name and for mailnews urls where
+ // the filename is hidden in the url as a &filename= part.
+ let url = Services.io.newURI(attachment.url);
+ if (
+ url instanceof Ci.nsIURL &&
+ url.fileName &&
+ !url.schemeIs("file")
+ ) {
+ iconName = url.fileName;
+ }
+ }
+ src = `moz-icon://${iconName}?size=16&contentType=${type}`;
+ }
+
+ this.setAttachmentIconSrc(item, src);
+ }
+
+ /**
+ * Get whether the attachment list is fully loaded.
+ *
+ * @returns {boolean} - Whether all the attachments in the list are fully
+ * loaded.
+ */
+ isLoaded() {
+ // Not loaded if at least one loading.
+ for (let item of this.querySelectorAll(".attachmentItem")) {
+ if (!item.loaded) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Set the attachment item's loaded state.
+ *
+ * @param {MozRichlistitem} item - The attachment item.
+ * @param {boolean} loaded - Whether the attachment is fully loaded.
+ */
+ setAttachmentLoaded(item, loaded) {
+ item.loaded = loaded;
+ this.refreshAttachmentIcon(item);
+ }
+
+ /**
+ * Set the attachment item's cloud icon, if any.
+ *
+ * @param {MozRichlistitem} item - The attachment item.
+ * @param {?string} cloudIcon - The icon of the cloud provider where the
+ * attachment was uploaded. Will be used as file type icon in the list of
+ * attachments, if specified.
+ */
+ setCloudIcon(item, cloudIcon) {
+ item.cloudIcon = cloudIcon;
+ this.refreshAttachmentIcon(item);
+ }
+
+ /**
+ * Set the attachment item's displayed name.
+ *
+ * @param {MozRichlistitem} item - The attachment item.
+ * @param {string} name - The name to display for the attachment.
+ */
+ setAttachmentName(item, name) {
+ item.setAttribute("name", name);
+ // Extract what looks like the file extension so we can always show it,
+ // even if the full name would overflow.
+ // NOTE: This is a convenience feature rather than a security feature
+ // since the content type of an attachment need not match the extension.
+ let found = name.match(/^(.+)(\.[a-zA-Z0-9_#$!~+-]{1,16})$/);
+ item.querySelector(".attachmentcell-name").textContent =
+ found?.[1] || name;
+ item.querySelector(".attachmentcell-extension").textContent =
+ found?.[2] || "";
+ }
+
+ /**
+ * Set the attachment item's displayed size.
+ *
+ * @param {MozRichlistitem} item - The attachment item.
+ * @param {string} size - The size to display for the attachment.
+ */
+ setAttachmentSize(item, size) {
+ item.setAttribute("size", size);
+ let sizeEl = item.querySelector(".attachmentcell-size");
+ sizeEl.textContent = size;
+ sizeEl.hidden = !size;
+ }
+
+ invalidateItem(item, name) {
+ let attachment = item.attachment;
+
+ this.setAttachmentName(item, name || attachment.name);
+ let size =
+ attachment.size == null || attachment.size == -1
+ ? ""
+ : this.messenger.formatFileSize(attachment.size);
+ if (size && item.cloudHtmlFileSize > 0) {
+ size = `${this.messenger.formatFileSize(
+ item.cloudHtmlFileSize
+ )} (${size})`;
+ }
+ this.setAttachmentSize(item, size);
+
+ // By default, items are considered loaded.
+ item.loaded = true;
+ this.refreshAttachmentIcon(item);
+ return item;
+ }
+
+ /**
+ * Find the attachmentitem node for the specified nsIMsgAttachment.
+ */
+ findItemForAttachment(aAttachment) {
+ for (let i = 0; i < this.itemCount; i++) {
+ let item = this.getItemAtIndex(i);
+ if (item.attachment == aAttachment) {
+ return item;
+ }
+ }
+ return null;
+ }
+
+ _fireOnSelect() {
+ if (!this._suppressOnSelect && !this.suppressOnSelect) {
+ this.dispatchEvent(
+ new Event("select", { bubbles: false, cancelable: true })
+ );
+ }
+ }
+
+ _itemsPerRow() {
+ // For 0 or 1 children, we can assume that they all fit in one row.
+ if (this._childNodes.length < 2) {
+ return this._childNodes.length;
+ }
+
+ let itemWidth =
+ this._childNodes[1].getBoundingClientRect().x -
+ this._childNodes[0].getBoundingClientRect().x;
+
+ // Each item takes up a full row
+ if (itemWidth == 0) {
+ return 1;
+ }
+ return Math.floor(this.clientWidth / itemWidth);
+ }
+
+ _itemsPerCol(aItemsPerRow) {
+ let itemsPerRow = aItemsPerRow || this._itemsPerRow();
+
+ if (this._childNodes.length == 0) {
+ return 0;
+ }
+
+ if (this._childNodes.length <= itemsPerRow) {
+ return 1;
+ }
+
+ let itemHeight =
+ this._childNodes[itemsPerRow].getBoundingClientRect().y -
+ this._childNodes[0].getBoundingClientRect().y;
+
+ return Math.floor(this.clientHeight / itemHeight);
+ }
+
+ /**
+ * Set the width of each child to the largest width child to create a
+ * grid-like effect for the flex-wrapped attachment list.
+ */
+ setOptimumWidth() {
+ if (this._childNodes.length == 0) {
+ return;
+ }
+
+ let width = 0;
+ for (let child of this._childNodes) {
+ // Unset the width, then the child will expand or shrink to its
+ // "natural" size in the flex-wrapped container. I.e. its preferred
+ // width bounded by the width of the container's content space.
+ child.style.width = null;
+ width = Math.max(width, child.getBoundingClientRect().width);
+ }
+ for (let child of this._childNodes) {
+ child.style.width = `${width}px`;
+ }
+ }
+ }
+
+ customElements.define("attachment-list", MozAttachmentlist, {
+ extends: "richlistbox",
+ });
+
+ /**
+ * The MailAddressPill widget is used to display the email addresses in the
+ * messengercompose.xhtml window.
+ *
+ * @augments {MozXULElement}
+ */
+ class MailAddressPill extends MozXULElement {
+ static get inheritedAttributes() {
+ return {
+ ".pill-label": "crop,value=label",
+ };
+ }
+
+ /**
+ * Indicates whether the address of this pill is for a mail list.
+ *
+ * @type {boolean}
+ */
+ isMailList = false;
+
+ /**
+ * If this pill is for a mail list, this provides the URI.
+ *
+ * @type {?string}
+ */
+ listURI = null;
+
+ /**
+ * If this pill is for a mail list, this provides the total count of
+ * its addresses.
+ *
+ * @type {number}
+ */
+ listAddressCount = 0;
+
+ connectedCallback() {
+ if (this.hasChildNodes() || this.delayConnectedCallback()) {
+ return;
+ }
+
+ this.classList.add("address-pill");
+ this.setAttribute("context", "emailAddressPillPopup");
+ this.setAttribute("allowevents", "true");
+
+ this.labelView = document.createXULElement("hbox");
+ this.labelView.setAttribute("flex", "1");
+
+ this.pillLabel = document.createXULElement("label");
+ this.pillLabel.classList.add("pill-label");
+ this.pillLabel.setAttribute("crop", "center");
+
+ this.pillIndicator = document.createElement("img");
+ this.pillIndicator.setAttribute(
+ "src",
+ "chrome://messenger/skin/icons/pill-indicator.svg"
+ );
+ this.pillIndicator.setAttribute("alt", "");
+ this.pillIndicator.classList.add("pill-indicator");
+ this.pillIndicator.hidden = true;
+
+ this.labelView.appendChild(this.pillLabel);
+ this.labelView.appendChild(this.pillIndicator);
+
+ this.appendChild(this.labelView);
+ this._setupEmailInput();
+
+ this._setupEventListeners();
+ this.initializeAttributeInheritance();
+
+ // @implements {nsIObserver}
+ this.inputObserver = {
+ observe: (subject, topic, data) => {
+ if (topic == "autocomplete-did-enter-text" && this.isEditing) {
+ this.updatePill();
+ }
+ },
+ };
+
+ Services.obs.addObserver(
+ this.inputObserver,
+ "autocomplete-did-enter-text"
+ );
+
+ // Remove the observer on window unload as the disconnectedCallback()
+ // will never be called when closing a window, so we might therefore
+ // leak if XPCOM isn't smart enough.
+ window.addEventListener(
+ "unload",
+ () => {
+ this.removeObserver();
+ },
+ { once: true }
+ );
+ }
+
+ get emailAddress() {
+ return this.getAttribute("emailAddress");
+ }
+
+ set emailAddress(val) {
+ this.setAttribute("emailAddress", val);
+ }
+
+ get label() {
+ return this.getAttribute("label");
+ }
+
+ set label(val) {
+ this.setAttribute("label", val);
+ }
+
+ get fullAddress() {
+ return this.getAttribute("fullAddress");
+ }
+
+ set fullAddress(val) {
+ this.setAttribute("fullAddress", val);
+ }
+
+ get displayName() {
+ return this.getAttribute("displayName");
+ }
+
+ set displayName(val) {
+ this.setAttribute("displayName", val);
+ }
+
+ get emailInput() {
+ return this.querySelector(`input[is="autocomplete-input"]`);
+ }
+
+ /**
+ * Get the main addressing input field the pill belongs to.
+ */
+ get rowInput() {
+ return this.closest(".address-container").querySelector(
+ ".address-row-input"
+ );
+ }
+
+ /**
+ * Check if the pill is currently in "Edit Mode", meaning the label is
+ * hidden and the html:input field is visible.
+ *
+ * @returns {boolean} true if the pill is currently being edited.
+ */
+ get isEditing() {
+ return !this.emailInput.hasAttribute("hidden");
+ }
+
+ get fragment() {
+ if (!this.constructor.hasOwnProperty("_fragment")) {
+ this.constructor._fragment = MozXULElement.parseXULToFragment(`
+ <html:input is="autocomplete-input"
+ type="text"
+ class="input-pill"
+ disableonsend="true"
+ autocompletesearch="mydomain addrbook ldap news"
+ autocompletesearchparam="{}"
+ timeout="200"
+ maxrows="6"
+ completedefaultindex="true"
+ forcecomplete="true"
+ completeselectedindex="true"
+ minresultsforpopup="2"
+ ignoreblurwhilesearching="true"
+ hidden="hidden"/>
+ `);
+ }
+ return document.importNode(this.constructor._fragment, true);
+ }
+
+ _setupEmailInput() {
+ this.appendChild(this.fragment);
+ this.emailInput.value = this.fullAddress;
+ }
+
+ _setupEventListeners() {
+ this.addEventListener("blur", event => {
+ // Prevent deselecting a pill on blur if:
+ // - The related target is null (context menu was opened, bug 1729741).
+ // - The related target is another pill (multi selection and deslection
+ // are handled by the click event listener added on pill creation).
+ if (
+ !event.relatedTarget ||
+ event.relatedTarget.tagName == "mail-address-pill"
+ ) {
+ return;
+ }
+
+ this.closest("mail-recipients-area").deselectAllPills();
+ });
+
+ this.emailInput.addEventListener("keypress", event => {
+ if (this.hasAttribute("disabled")) {
+ return;
+ }
+ this.onEmailInputKeyPress(event);
+ });
+
+ // Disable the inbuilt autocomplete on blur as we handle it here.
+ this.emailInput._dontBlur = true;
+
+ this.emailInput.addEventListener("blur", () => {
+ // If the input is still the active element after blur (when switching
+ // to another window), return to prevent autocompletion and
+ // pillification and let the user continue editing the address later.
+ if (document.activeElement == this.emailInput) {
+ return;
+ }
+
+ if (
+ this.emailInput.forceComplete &&
+ this.emailInput.mController.matchCount >= 1
+ ) {
+ // If input.forceComplete is true and there are autocomplete matches,
+ // we need to call the inbuilt Enter handler to force the input text
+ // to the best autocomplete match because we've set input._dontBlur.
+ this.emailInput.mController.handleEnter(true);
+ return;
+ }
+
+ this.updatePill();
+ });
+ }
+
+ /**
+ * Simple email address validation.
+ *
+ * @param {string} address - An email address.
+ */
+ isValidAddress(address) {
+ return /^[^\s@]+@[^\s@]+$/.test(address);
+ }
+
+ /**
+ * Convert the pill into "Edit Mode" by hiding the label and showing the
+ * html:input element.
+ */
+ startEditing() {
+ // Record the intention of editing a pill as a change in the recipient
+ // even if the text is not actually changed in order to prevent accidental
+ // data loss.
+ onRecipientsChanged();
+
+ // We need to set the min and max width before hiding and showing the
+ // child nodes in order to prevent unwanted jumps in the resizing of the
+ // edited pill. Both properties are necessary to handle flexbox.
+ this.style.setProperty("max-width", `${this.clientWidth}px`);
+ this.style.setProperty("min-width", `${this.clientWidth}px`);
+
+ this.classList.add("editing");
+ this.labelView.setAttribute("hidden", "true");
+ this.emailInput.removeAttribute("hidden");
+ this.emailInput.focus();
+
+ // Account for pill padding.
+ let inputWidth = this.emailInput.clientWidth + 15;
+
+ // In case the original address is shorter than the input field child node
+ // force resize the pill container to prevent overflows.
+ if (inputWidth > this.clientWidth) {
+ this.style.setProperty("max-width", `${inputWidth}px`);
+ this.style.setProperty("min-width", `${inputWidth}px`);
+ }
+ }
+
+ /**
+ * Revert the pill UI to a regular selectable element, meaning the label is
+ * visible and the html:input field is hidden.
+ *
+ * @param {Event} event - The DOM Event.
+ */
+ onEmailInputKeyPress(event) {
+ switch (event.key) {
+ case "Escape":
+ this.emailInput.value = this.fullAddress;
+ this.resetPill();
+ break;
+ case "Delete":
+ case "Backspace":
+ if (!this.emailInput.value.trim() && !event.repeat) {
+ this.rowInput.focus();
+ this.remove();
+ }
+ break;
+ }
+ }
+
+ async updatePill() {
+ let addresses = MailServices.headerParser.makeFromDisplayAddress(
+ this.emailInput.value
+ );
+ let row = this.closest(".address-row");
+
+ if (!addresses[0]) {
+ this.rowInput.focus();
+ this.remove();
+ // Update aria labels of all pills in the row, as pill count changed.
+ updateAriaLabelsOfAddressRow(row);
+ onRecipientsChanged();
+ return;
+ }
+
+ this.label = addresses[0].toString();
+ this.emailAddress = addresses[0].email || "";
+ this.fullAddress = addresses[0].toString();
+ this.displayName = addresses[0].name || "";
+ // We need to detach the autocomplete Controller to prevent the input
+ // to be filled with the previously selected address when the "blur"
+ // event gets triggered.
+ this.emailInput.detachController();
+ // Attach it again to enable autocomplete.
+ this.emailInput.attachController();
+
+ this.resetPill();
+
+ // Update the aria label of edited pill only, as pill count didn't change.
+ // Unfortunately, we still need to get the row's pills for counting once.
+ let pills = row.querySelectorAll("mail-address-pill");
+ this.setAttribute(
+ "aria-label",
+ await document.l10n.formatValue("pill-aria-label", {
+ email: this.fullAddress,
+ count: pills.length,
+ })
+ );
+
+ onRecipientsChanged();
+ }
+
+ resetPill() {
+ this.updatePillStatus();
+ this.style.removeProperty("max-width");
+ this.style.removeProperty("min-width");
+ this.classList.remove("editing");
+ this.labelView.removeAttribute("hidden");
+ this.emailInput.setAttribute("hidden", "hidden");
+ let textLength = this.emailInput.value.length;
+ this.emailInput.setSelectionRange(textLength, textLength);
+ this.rowInput.focus();
+ }
+
+ /**
+ * Check if an address is valid or it exists in the address book and update
+ * the helper icons accordingly.
+ */
+ async updatePillStatus() {
+ let isValid = this.isValidAddress(this.emailAddress);
+ let listNames = LazyModules.MimeParser.parseHeaderField(
+ this.fullAddress,
+ LazyModules.MimeParser.HEADER_ADDRESS
+ );
+
+ if (listNames.length > 0) {
+ let mailList = MailServices.ab.getMailListFromName(listNames[0].name);
+ this.isMailList = !!mailList;
+ if (this.isMailList) {
+ this.listURI = mailList.URI;
+ this.listAddressCount = mailList.childCards.length;
+ } else {
+ this.listURI = "";
+ this.listAddressCount = 0;
+ }
+ }
+
+ let isNewsgroup = this.emailInput.classList.contains("news-input");
+
+ if (!isValid && !this.isMailList && !isNewsgroup) {
+ this.classList.add("invalid-address");
+ this.setAttribute(
+ "tooltiptext",
+ await document.l10n.formatValue("pill-tooltip-invalid-address", {
+ email: this.fullAddress,
+ })
+ );
+ this.pillIndicator.hidden = true;
+
+ // Interrupt if the address is not valid as we don't need to check for
+ // other conditions.
+ return;
+ }
+
+ this.classList.remove("invalid-address");
+ this.removeAttribute("tooltiptext");
+ this.pillIndicator.hidden = true;
+
+ // Check if the address is not in the Address Book only if it's not a
+ // mail list or a newsgroup.
+ if (
+ !isNewsgroup &&
+ !this.isMailList &&
+ !MailServices.ab.cardForEmailAddress(this.emailAddress)
+ ) {
+ this.setAttribute(
+ "tooltiptext",
+ await document.l10n.formatValue("pill-tooltip-not-in-address-book", {
+ email: this.fullAddress,
+ })
+ );
+ this.pillIndicator.hidden = false;
+ }
+ }
+
+ /**
+ * Get the nearest sibling pill which is not selected.
+ *
+ * @param {("next"|"previous")} [siblingsType="next"] - Iterate next or
+ * previous siblings.
+ * @returns {HTMLElement} - The nearest unselected sibling element, or null.
+ */
+ getUnselectedSiblingPill(siblingsType = "next") {
+ if (siblingsType == "next") {
+ // Check for next siblings.
+ let element = this.nextElementSibling;
+ while (element) {
+ if (!element.hasAttribute("selected")) {
+ return element;
+ }
+ element = element.nextElementSibling;
+ }
+
+ return null;
+ }
+
+ // Check for previous siblings.
+ let element = this.previousElementSibling;
+ while (element) {
+ if (!element.hasAttribute("selected")) {
+ return element;
+ }
+ element = element.previousElementSibling;
+ }
+
+ return null;
+ }
+
+ removeObserver() {
+ Services.obs.removeObserver(
+ this.inputObserver,
+ "autocomplete-did-enter-text"
+ );
+ }
+ }
+
+ customElements.define("mail-address-pill", MailAddressPill);
+
+ /**
+ * The MailRecipientsArea widget is used to display the recipient rows in the
+ * header area of the messengercompose.xul window.
+ *
+ * @augments {MozXULElement}
+ */
+ class MailRecipientsArea extends MozXULElement {
+ connectedCallback() {
+ if (this.delayConnectedCallback() || this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+
+ for (let input of this.querySelectorAll(".mail-input,.news-input")) {
+ // Disable inbuilt autocomplete on blur to handle it with our handlers.
+ input._dontBlur = true;
+
+ setupAutocompleteInput(input);
+
+ input.addEventListener("keypress", event => {
+ // Ctrl+Shift+Tab is handled by moveFocusToNeighbouringArea.
+ if (event.key != "Tab" || !event.shiftKey || event.ctrlKey) {
+ return;
+ }
+ event.preventDefault();
+ this.moveFocusToPreviousElement(input);
+ });
+
+ input.addEventListener("input", event => {
+ addressInputOnInput(event, false);
+ });
+ }
+
+ // Force the focus on the first available input field if Tab is
+ // pressed on the extraAddressRowsMenuButton label.
+ document
+ .getElementById("extraAddressRowsMenuButton")
+ .addEventListener("keypress", event => {
+ if (event.key == "Tab" && !event.shiftKey) {
+ event.preventDefault();
+ let row = this.querySelector(".address-row:not(.hidden)");
+ let removeFieldButton = row.querySelector(".remove-field-button");
+ // If the close button is hidden, focus on the input field.
+ if (removeFieldButton.hidden) {
+ row.querySelector(".address-row-input").focus();
+ return;
+ }
+ // Focus on the close button.
+ removeFieldButton.focus();
+ }
+ });
+
+ this.addEventListener("dragstart", event => {
+ // Check if we're dragging a pill, as the drag target might be another
+ // element like row or pill <input> when dragging selected plain text.
+ let targetPill = event.target.closest(
+ "mail-address-pill:not(.editing)"
+ );
+ if (!targetPill) {
+ return;
+ }
+ if (!targetPill.hasAttribute("selected")) {
+ // If the drag action starts from a non-selected pill,
+ // deselect all selected pills and select only the target pill.
+ for (let pill of this.getAllSelectedPills()) {
+ pill.removeAttribute("selected");
+ }
+ targetPill.toggleAttribute("selected");
+ }
+ event.dataTransfer.effectAllowed = "move";
+ event.dataTransfer.dropEffect = "move";
+ event.dataTransfer.setData("text/pills", "pills");
+ event.dataTransfer.setDragImage(targetPill, 50, 12);
+ });
+
+ this.addEventListener("dragover", event => {
+ event.preventDefault();
+ });
+
+ this.addEventListener("dragenter", event => {
+ if (!event.dataTransfer.getData("text/pills")) {
+ return;
+ }
+
+ // If the current drop target is a pill, add drop indicator style to it.
+ event.target
+ .closest("mail-address-pill")
+ ?.classList.add("drop-indicator");
+
+ // If the current drop target is inside an address row, add the
+ // indicator style for the row's address container.
+ event.target
+ .closest(".address-row")
+ ?.querySelector(".address-container")
+ .classList.add("drag-address-container");
+ });
+
+ this.addEventListener("dragleave", event => {
+ if (!event.dataTransfer.getData("text/pills")) {
+ return;
+ }
+ // If dragleave from pill, remove its drop indicator style.
+ event.target
+ .closest("mail-address-pill")
+ ?.classList.remove("drop-indicator");
+
+ // If dragleave from address row, remove the indicator style of its
+ // address container.
+ event.target
+ .closest(".address-row")
+ ?.querySelector(".address-container")
+ .classList.remove("drag-address-container");
+ });
+
+ this.addEventListener("drop", event => {
+ // First handle cases where the dropped data is not pills.
+ if (!event.dataTransfer.getData("text/pills")) {
+ // Bail out if the dropped data comes from the contacts sidebar.
+ // Those addresses will be added immediately as pills without going
+ // through the input field as plain text.
+ if (event.dataTransfer.types.includes("moz/abcard")) {
+ return;
+ }
+
+ // Dropped data should be plain text (images are handled elsewhere).
+ // We currently only support dropping text directly into the row input
+ // (Bug 1706187), which is inbuilt: no further handling required here.
+ // Input element resizing is automatically handled by its input event.
+ return;
+ }
+
+ // Pills have been dropped ("text/pills").
+ let targetAddressRow = event.target.closest(".address-row");
+ // Return if pills have been dropped outside an address row.
+ if (
+ !targetAddressRow ||
+ targetAddressRow.classList.contains("address-row-raw")
+ ) {
+ return;
+ }
+
+ // Pills have been dropped somewhere inside an address row.
+ // If they have been dropped directly on an address container, use that.
+ // Otherwise ensure having an addressContainer for drop targets inside
+ // the row, but outside the address container (e.g. the row label).
+ let targetAddressContainer = event.target.closest(".address-container");
+ let addressContainer =
+ targetAddressContainer ||
+ targetAddressRow.querySelector(".address-container");
+
+ // Recreate pills in the target address container.
+ // If dropped on a pill, append pills before that pill. Otherwise if
+ // dropped into an address container, append pills after existing pills.
+ // Otherwise if dropped elsewhere on the row (e.g. on the row label),
+ // append pills before existing pills.
+ let targetPill = event.target.closest("mail-address-pill");
+ this.createDNDPills(
+ addressContainer,
+ targetPill || !targetAddressContainer,
+ targetPill ? targetPill.fullAddress : null
+ );
+ addressContainer.classList.remove("drag-address-container");
+ });
+ }
+
+ /**
+ * Check if the current size of the recipient input field doesn't exceed its
+ * container width. This might happen if the user pastes a very long string
+ * with multiple addresses when pills are already present.
+ *
+ * @param {Element} input - The HTML input field.
+ * @param {integer} length - The amount of characters in the input field.
+ */
+ resizeInputField(input, length) {
+ // Set a minimum size of 1 in case no characters were written in the field
+ // in order to force the smallest size possible and avoid blank rows when
+ // multiple pills fill the entire recipient row.
+ input.setAttribute("size", length || 1);
+
+ // If the previously set size causes the input field to grow beyond 80% of
+ // its parent container, we remove the size attribute to let the CSS flex
+ // attribute let it grow naturally to fill the available space.
+ if (
+ input.clientWidth >
+ input.closest(".address-container").clientWidth * 0.8
+ ) {
+ input.removeAttribute("size");
+ }
+ }
+
+ /**
+ * Move the dragged pills to another address row.
+ *
+ * @param {string} addressContainer - The address container on which pills
+ * have been dropped.
+ * @param {boolean} [appendStart] - If the selected addresses should be
+ * appended at the start or at the end of existing addresses.
+ * Specifying targetAddress will override this.
+ * @param {string} [targetAddress] - The existing address before which all
+ * selected addresses should be appended.
+ */
+ createDNDPills(addressContainer, appendStart, targetAddress) {
+ let existingPills =
+ addressContainer.querySelectorAll("mail-address-pill");
+ let existingAddresses = [...existingPills].map(pill => pill.fullAddress);
+ let selectedAddresses = [...this.getAllSelectedPills()].map(
+ pill => pill.fullAddress
+ );
+ let originalTargetIndex = existingAddresses.indexOf(targetAddress);
+
+ // Remove all the duplicate existing addresses.
+ for (let address of selectedAddresses) {
+ let index = existingAddresses.indexOf(address);
+ if (index > -1) {
+ existingAddresses.splice(index, 1);
+ }
+ }
+
+ let combinedAddresses;
+ // If selected pills have been dropped on another pill, they should be
+ // inserted before that pill, otherwise use appendStart.
+ if (targetAddress) {
+ // Merge the two arrays in the right order. If the target address has
+ // been removed by deduplication above, use its original index.
+ existingAddresses.splice(
+ existingAddresses.includes(targetAddress)
+ ? existingAddresses.indexOf(targetAddress)
+ : originalTargetIndex,
+ 0,
+ ...selectedAddresses
+ );
+ combinedAddresses = existingAddresses;
+ } else {
+ combinedAddresses = appendStart
+ ? selectedAddresses.concat(existingAddresses)
+ : existingAddresses.concat(selectedAddresses);
+ }
+
+ // Remove all selected pills.
+ for (let pill of this.getAllSelectedPills()) {
+ pill.remove();
+ }
+
+ // Existing pills are removed before creating new ones in the right order.
+ for (let pill of existingPills) {
+ pill.remove();
+ }
+
+ // Create pills for all the combined addresses.
+ let row = addressContainer.closest(".address-row");
+ for (let address of combinedAddresses) {
+ addressRowAddRecipientsArray(
+ row,
+ [address],
+ selectedAddresses.includes(address)
+ );
+ }
+
+ // Move the focus to the first selected pill.
+ this.getAllSelectedPills()[0].focus();
+ }
+
+ /**
+ * Create a new address row and a menuitem for revealing it.
+ *
+ * @param {object} recipient - An object for various element attributes.
+ * @param {boolean} rawInput - A flag to disable pills and autocompletion.
+ * @returns {object} - The newly created elements.
+ * @property {Element} row - The address row.
+ * @property {Element} showRowMenuItem - The menu item that shows the row.
+ */
+ // NOTE: This is currently never called with rawInput = false, so it may be
+ // out of date if used.
+ buildRecipientRow(recipient, rawInput = false) {
+ let row = document.createXULElement("hbox");
+ row.setAttribute("id", recipient.rowId);
+ row.classList.add("address-row");
+ row.dataset.recipienttype = recipient.type;
+
+ let firstCol = document.createXULElement("hbox");
+ firstCol.classList.add("aw-firstColBox");
+
+ row.classList.add("hidden");
+
+ let closeButton = document.createElement("button");
+ closeButton.classList.add("remove-field-button", "plain-button");
+ document.l10n.setAttributes(closeButton, "remove-address-row-button", {
+ type: recipient.type,
+ });
+ let closeIcon = document.createElement("img");
+ closeIcon.setAttribute("src", "chrome://global/skin/icons/close.svg");
+ // Button's title is the accessible name.
+ closeIcon.setAttribute("alt", "");
+ closeButton.appendChild(closeIcon);
+
+ closeButton.addEventListener("click", event => {
+ closeLabelOnClick(event);
+ });
+ firstCol.appendChild(closeButton);
+ row.appendChild(firstCol);
+
+ let labelContainer = document.createXULElement("hbox");
+ labelContainer.setAttribute("align", "top");
+ labelContainer.setAttribute("pack", "end");
+ labelContainer.setAttribute("flex", 1);
+ labelContainer.classList.add("address-label-container");
+ labelContainer.setAttribute(
+ "style",
+ getComposeBundle().getString("headersSpaceStyle")
+ );
+
+ let label = document.createXULElement("label");
+ label.setAttribute("id", recipient.labelId);
+ label.setAttribute("value", recipient.type);
+ label.setAttribute("control", recipient.inputId);
+ label.setAttribute("flex", 1);
+ label.setAttribute("crop", "end");
+ labelContainer.appendChild(label);
+ row.appendChild(labelContainer);
+
+ let inputContainer = document.createXULElement("hbox");
+ inputContainer.setAttribute("id", recipient.containerId);
+ inputContainer.setAttribute("flex", 1);
+ inputContainer.setAttribute("align", "center");
+ inputContainer.classList.add(
+ "input-container",
+ "wrap-container",
+ "address-container"
+ );
+ inputContainer.addEventListener("click", focusAddressInputOnClick);
+
+ // Set up the row input for the row.
+ let input = document.createElement(
+ "input",
+ rawInput
+ ? undefined
+ : {
+ is: "autocomplete-input",
+ }
+ );
+ input.setAttribute("id", recipient.inputId);
+ input.setAttribute("size", 1);
+ input.setAttribute("type", "text");
+ input.setAttribute("disableonsend", true);
+ input.classList.add("plain", "address-input", "address-row-input");
+
+ if (!rawInput) {
+ // Regular autocomplete address input, not other header with raw input.
+ // Set various attributes for autocomplete.
+ input.setAttribute("autocompletesearch", "mydomain addrbook ldap news");
+ input.setAttribute("autocompletesearchparam", "{}");
+ input.setAttribute("timeout", 200);
+ input.setAttribute("maxrows", 6);
+ input.setAttribute("completedefaultindex", true);
+ input.setAttribute("forcecomplete", true);
+ input.setAttribute("completeselectedindex", true);
+ input.setAttribute("minresultsforpopup", 2);
+ input.setAttribute("ignoreblurwhilesearching", true);
+ // Disable the inbuilt autocomplete on blur as we handle it below.
+ input._dontBlur = true;
+
+ setupAutocompleteInput(input);
+
+ // Handle keydown event in autocomplete address input of row with pills.
+ // input.onBeforeHandleKeyDown() gets called by the toolkit autocomplete
+ // before going into autocompletion.
+ input.onBeforeHandleKeyDown = event => {
+ addressInputOnBeforeHandleKeyDown(event);
+ };
+ } else {
+ // Handle keydown event in other header input (rawInput), which does not
+ // have autocomplete and its associated keydown handling.
+ row.classList.add("address-row-raw");
+ input.addEventListener("keydown", otherHeaderInputOnKeyDown);
+ input.addEventListener("input", event => {
+ addressInputOnInput(event, true);
+ });
+ }
+
+ input.addEventListener("blur", () => {
+ addressInputOnBlur(input);
+ });
+ input.addEventListener("focus", () => {
+ addressInputOnFocus(input);
+ });
+
+ inputContainer.appendChild(input);
+ row.appendChild(inputContainer);
+
+ // Create the menuitem that shows the row on selection.
+ let showRowMenuItem = document.createXULElement("menuitem");
+ showRowMenuItem.classList.add("subviewbutton", "menuitem-iconic");
+ showRowMenuItem.setAttribute("id", recipient.showRowMenuItemId);
+ showRowMenuItem.setAttribute("disableonsend", true);
+ showRowMenuItem.setAttribute("label", recipient.type);
+
+ showRowMenuItem.addEventListener("command", () =>
+ showAndFocusAddressRow(row.id)
+ );
+
+ row.dataset.showSelfMenuitem = showRowMenuItem.id;
+
+ return { row, showRowMenuItem };
+ }
+
+ /**
+ * Create a new recipient pill.
+ *
+ * @param {HTMLElement} element - The original autocomplete input that
+ * generated the pill.
+ * @param {Array} address - The array containing the recipient's info.
+ * @returns {Element} The newly created pill.
+ */
+ createRecipientPill(element, address) {
+ let pill = document.createXULElement("mail-address-pill");
+
+ pill.label = address.toString();
+ pill.emailAddress = address.email || "";
+ pill.fullAddress = address.toString();
+ pill.displayName = address.name || "";
+
+ pill.addEventListener("click", event => {
+ if (pill.hasAttribute("disabled")) {
+ return;
+ }
+ // Remove pills on middle mouse button click, but not with selection
+ // modifier keys.
+ if (
+ event.button == 1 &&
+ !event.ctrlKey &&
+ !event.metaKey &&
+ !event.shiftKey
+ ) {
+ if (!pill.hasAttribute("selected")) {
+ this.deselectAllPills();
+ pill.setAttribute("selected", "selected");
+ }
+ this.removeSelectedPills();
+ return;
+ }
+
+ // Edit pill on unmodified single left-click on single selected pill,
+ // which also fires for unmodified double-click ("dblclick") on a pill.
+ if (
+ event.button == 0 &&
+ !event.ctrlKey &&
+ !event.metaKey &&
+ !event.shiftKey &&
+ !pill.isEditing &&
+ pill.hasAttribute("selected") &&
+ this.getAllSelectedPills().length == 1
+ ) {
+ this.startEditing(pill, event);
+ return;
+ }
+
+ // Handle selection, especially with Ctrl/Cmd and/or Shift modifiers.
+ this.checkSelected(pill, event);
+ });
+
+ pill.addEventListener("keydown", event => {
+ if (!pill.isEditing || pill.hasAttribute("disabled")) {
+ return;
+ }
+ this.handleKeyDown(pill, event);
+ });
+
+ pill.addEventListener("keypress", event => {
+ if (pill.hasAttribute("disabled")) {
+ return;
+ }
+ this.handleKeyPress(pill, event);
+ });
+
+ element.closest(".address-container").insertBefore(pill, element);
+
+ // The emailInput attribute is accessible only after the pill has been
+ // appended to the DOM.
+ let excludedClasses = [
+ "mail-primary-input",
+ "news-primary-input",
+ "address-row-input",
+ ];
+ for (let cssClass of element.classList) {
+ if (excludedClasses.includes(cssClass)) {
+ continue;
+ }
+ pill.emailInput.classList.add(cssClass);
+ }
+ pill.emailInput.setAttribute(
+ "aria-labelledby",
+ element.getAttribute("aria-labelledby")
+ );
+ element.removeAttribute("aria-labelledby");
+
+ let params = JSON.parse(
+ pill.emailInput.getAttribute("autocompletesearchparam")
+ );
+ params.type = element.closest(".address-row").dataset.recipienttype;
+ pill.emailInput.setAttribute(
+ "autocompletesearchparam",
+ JSON.stringify(params)
+ );
+
+ pill.updatePillStatus();
+
+ return pill;
+ }
+
+ /**
+ * Handle keydown event on a pill in the mail-recipients-area.
+ *
+ * @param {Element} pill - The mail-address-pill element where Event fired.
+ * @param {Event} event - The DOM Event.
+ */
+ handleKeyDown(pill, event) {
+ switch (event.key) {
+ case " ":
+ case ",":
+ // Behaviour consistent with row input:
+ // If keydown would normally replace all of the current trimmed input,
+ // including if the current input is empty, then suppress the key and
+ // clear the input instead.
+ let input = pill.emailInput;
+ let selection = input.value.substring(
+ input.selectionStart,
+ input.selectionEnd
+ );
+ if (selection.includes(input.value.trim())) {
+ event.preventDefault();
+ input.value = "";
+ }
+ break;
+ }
+ }
+
+ /**
+ * Handle keypress event on a pill in the mail-recipients-area.
+ *
+ * @param {Element} pill - The mail-address-pill element where Event fired.
+ * @param {Event} event - The DOM Event.
+ */
+ handleKeyPress(pill, event) {
+ if (pill.isEditing) {
+ return;
+ }
+
+ switch (event.key) {
+ case "Enter":
+ case "F2": // For Windows users
+ this.startEditing(pill, event);
+ break;
+
+ case "Delete":
+ case "Backspace":
+ // We must never delete a focused pill which is not selected.
+ // If no pills selected, just select the focused pill.
+ // For rapid repeated deletions (esp. from holding BACKSPACE),
+ // stop before selecting another focused pill for deletion.
+ if (!this.hasSelectedPills() && !event.repeat) {
+ pill.setAttribute("selected", "selected");
+ break;
+ }
+ // Delete selected pills, handle focus and select another pill
+ // where applicable.
+ let focusType = event.key == "Delete" ? "next" : "previous";
+ this.removeSelectedPills(focusType, true);
+ break;
+
+ case "ArrowLeft":
+ if (pill.previousElementSibling) {
+ this.checkKeyboardSelected(event, pill.previousElementSibling);
+ }
+ break;
+
+ case "ArrowRight":
+ this.checkKeyboardSelected(event, pill.nextElementSibling);
+ break;
+
+ case " ":
+ this.checkSelected(pill, event);
+ break;
+
+ case "Home":
+ let firstPill = pill
+ .closest(".address-container")
+ .querySelector("mail-address-pill");
+ if (!event.ctrlKey) {
+ // Unmodified navigation: select only first pill and focus it below.
+ // ### Todo: We can't handle Shift+Home yet, so it ends up here.
+ this.deselectAllPills();
+ firstPill.setAttribute("selected", "selected");
+ }
+ firstPill.focus();
+ break;
+
+ case "End":
+ if (!event.ctrlKey) {
+ // Unmodified navigation: focus row input.
+ // ### Todo: We can't handle Shift+End yet, so it ends up here.
+ pill.rowInput.focus();
+ break;
+ }
+ // Navigation with Ctrl modifier key: focus last pill.
+ pill
+ .closest(".address-container")
+ .querySelector("mail-address-pill:last-of-type")
+ .focus();
+ break;
+
+ case "Tab":
+ for (let item of this.getSiblingPills(pill)) {
+ item.removeAttribute("selected");
+ }
+ // Ctrl+Tab is handled by moveFocusToNeighbouringArea.
+ if (event.ctrlKey) {
+ return;
+ }
+ event.preventDefault();
+ if (event.shiftKey) {
+ this.moveFocusToPreviousElement(pill);
+ return;
+ }
+ pill.rowInput.focus();
+ break;
+
+ case "a":
+ if (
+ !(event.ctrlKey || event.metaKey) ||
+ event.repeat ||
+ event.shiftKey
+ ) {
+ // Bail out if it's not Ctrl+A or Cmd+A, if the Shift key is
+ // pressed, or if repeated keypress.
+ break;
+ }
+ if (
+ pill
+ .closest(".address-container")
+ .querySelector("mail-address-pill:not([selected])")
+ ) {
+ // For non-repeated Ctrl+A, if there's at least one unselected pill,
+ // first select all pills of the same .address-container.
+ this.selectSiblingPills(pill);
+ break;
+ }
+ // For non-repeated Ctrl+A, if pills in same container are already
+ // selected, select all pills of the entire <mail-recipients-area>.
+ this.selectAllPills();
+ break;
+
+ case "c":
+ if (event.ctrlKey || event.metaKey) {
+ this.copySelectedPills();
+ }
+ break;
+
+ case "x":
+ if (event.ctrlKey || event.metaKey) {
+ this.cutSelectedPills();
+ }
+ break;
+ }
+ }
+
+ /**
+ * Handle the selection and focus of recipient pill elements on mouse click
+ * and spacebar keypress events.
+ *
+ * @param {HTMLElement} pill - The <mail-address-pill> element, event target.
+ * @param {Event} event - A DOM click or keypress Event.
+ */
+ checkSelected(pill, event) {
+ // Interrupt if the pill is in edit mode or a right click was detected.
+ // Selecting pills on right click will be handled by the opening of the
+ // context menu.
+ if (pill.isEditing || event.button == 2) {
+ return;
+ }
+
+ if (!event.ctrlKey && !event.metaKey && event.key != " ") {
+ this.deselectAllPills();
+ }
+
+ pill.toggleAttribute("selected");
+
+ // We need to force the focus on a pill that receives a click event
+ // (or a spacebar keypress), as macOS doesn't automatically move the focus
+ // on this custom element (bug 1645643, bug 1645916).
+ pill.focus();
+ }
+
+ /**
+ * Handle the selection and focus of the pill elements on keyboard
+ * navigation.
+ *
+ * @param {Event} event - A DOM keyboard event.
+ * @param {HTMLElement} targetElement - A mail-address-pill or address input
+ * element navigated to.
+ */
+ checkKeyboardSelected(event, targetElement) {
+ let sourcePill =
+ event.target.tagName == "mail-address-pill" ? event.target : null;
+ let targetPill =
+ targetElement.tagName == "mail-address-pill" ? targetElement : null;
+
+ if (event.shiftKey) {
+ if (sourcePill) {
+ sourcePill.setAttribute("selected", "selected");
+ }
+ if (event.key == "Home" && !sourcePill) {
+ // Shift+Home from address input.
+ this.selectSiblingPills(targetPill);
+ }
+ if (targetPill) {
+ targetPill.setAttribute("selected", "selected");
+ }
+ } else if (!event.ctrlKey) {
+ // Non-modified navigation keys must select the target pill and deselect
+ // all others. Also some other keys like Backspace from rowInput.
+ this.deselectAllPills();
+ if (targetPill) {
+ targetPill.setAttribute("selected", "selected");
+ } else {
+ // Focus the input navigated to.
+ targetElement.focus();
+ }
+ }
+
+ // If targetElement is a pill, focus it.
+ if (targetPill) {
+ targetPill.focus();
+ }
+ }
+
+ /**
+ * Trigger the pill.startEditing() method.
+ *
+ * @param {XULElement} pill - The mail-address-pill element.
+ * @param {Event} event - The DOM Event.
+ */
+ startEditing(pill, event) {
+ if (pill.isEditing) {
+ event.stopPropagation();
+ return;
+ }
+
+ pill.startEditing();
+ }
+
+ /**
+ * Copy the selected pills to clipboard.
+ */
+ copySelectedPills() {
+ let selectedAddresses = [
+ ...document.getElementById("recipientsContainer").getAllSelectedPills(),
+ ].map(pill => pill.fullAddress);
+
+ let clipboard = Cc["@mozilla.org/widget/clipboardhelper;1"].getService(
+ Ci.nsIClipboardHelper
+ );
+ clipboard.copyString(selectedAddresses.join(", "));
+ }
+
+ /**
+ * Cut the selected pills to clipboard.
+ */
+ cutSelectedPills() {
+ this.copySelectedPills();
+ this.removeSelectedPills();
+ }
+
+ /**
+ * Move the selected email address pills to another address row.
+ *
+ * @param {Element} row - The address row to move the pills to.
+ */
+ moveSelectedPills(row) {
+ // Store all the selected addresses inside an array.
+ let selectedAddresses = [...this.getAllSelectedPills()].map(
+ pill => pill.fullAddress
+ );
+
+ // Return if no pills selected.
+ if (!selectedAddresses.length) {
+ return;
+ }
+
+ // Remove the selected pills.
+ this.removeSelectedPills("next", false, true);
+
+ // Create new address pills inside the target address row and
+ // maintain the current selection.
+ addressRowAddRecipientsArray(row, selectedAddresses, true);
+
+ // Move focus to the last selected pill.
+ let selectedPills = this.getAllSelectedPills();
+ selectedPills[selectedPills.length - 1].focus();
+ }
+
+ /**
+ * Delete all selected pills and handle focus and selection smartly as needed.
+ *
+ * @param {("next"|"previous")} [focusType="next"] - How to move focus after
+ * removing pills: try to focus one of the next siblings (for DEL etc.)
+ * or one of the previous siblings (for BACKSPACE).
+ * @param {boolean} [select=false] - After deletion, whether to select the
+ * focused pill where applicable.
+ * @param {boolean} [moved=false] - Whether the method was originally called
+ * from moveSelectedPills().
+ */
+ removeSelectedPills(focusType = "next", select = false, moved = false) {
+ // Return if no pills selected.
+ let firstSelectedPill = this.querySelector("mail-address-pill[selected]");
+ if (!firstSelectedPill) {
+ return;
+ }
+ // Get the pill which has focus before we start removing selected pills,
+ // which may or may not include the focused pill. If no pill has focus,
+ // consider the first selected pill as focused pill for our purposes.
+ let pill =
+ this.querySelector("mail-address-pill:focus") || firstSelectedPill;
+
+ // We'll look hard for an appropriate element to focus after the removal.
+ let focusElement = null;
+ // Get addressContainer and rowInput now as pill might be deleted later.
+ let addressContainer = pill.closest(".address-container");
+ let rowInput = pill.rowInput;
+ let unselectedSourcePill = false;
+
+ if (pill.hasAttribute("selected")) {
+ // Find focus (1): Focused pill is selected and will be deleted;
+ // try nearest sibling, observing focusType direction.
+ focusElement = pill.getUnselectedSiblingPill(focusType);
+ } else {
+ // The source pill isn't selected; keep it focused ("satellite focus").
+ unselectedSourcePill = true;
+ focusElement = pill;
+ }
+
+ // Remove selected pills.
+ let selectedPills = this.getAllSelectedPills();
+ for (let sPill of selectedPills) {
+ sPill.remove();
+ }
+
+ // Find focus (2): When deleting backwards, if no previous sibling found,
+ // this means that the first pill was deleted. Try the first remaining pill,
+ // but don't auto-select it because it's in the opposite direction.
+ if (!focusElement && focusType == "previous") {
+ focusElement = addressContainer.querySelector("mail-address-pill");
+ } else if (
+ select &&
+ focusElement &&
+ selectedPills.length == 1 &&
+ !unselectedSourcePill
+ ) {
+ // If select = true (DEL or BACKSPACE), and we found a pill to focus in
+ // round (1), and we have removed a single pill only, and it's not a
+ // case of "satellite focus" (see above):
+ // Conveniently select the nearest pill for rapid consecutive deletions.
+ focusElement.setAttribute("selected", "selected");
+ }
+ // Find focus (3): If all else fails (no pills left in addressContainer,
+ // or last pill deleted forwards): Focus rowInput.
+ if (!focusElement) {
+ focusElement = rowInput;
+ }
+ focusElement.focus();
+
+ // Update aria labels for all rows as we allow cross-row pill removal.
+ // This may not yet be micro-performance optimized; see bug 1671261.
+ updateAriaLabelsAndTooltipsOfAllAddressRows();
+
+ // Don't trigger some methods if the pills were removed automatically
+ // during the move to another addressing widget.
+ if (!moved) {
+ onRecipientsChanged();
+ }
+ }
+
+ /**
+ * Select all pills of the same address row (.address-container).
+ *
+ * @param {Element} pill - A <mail-address-pill> element. All pills in the
+ * same .address-container will be selected.
+ */
+ selectSiblingPills(pill) {
+ for (let sPill of this.getSiblingPills(pill)) {
+ sPill.setAttribute("selected", "selected");
+ }
+ }
+
+ /**
+ * Select all pills of the <mail-recipients-area> element.
+ */
+ selectAllPills() {
+ for (let pill of this.getAllPills()) {
+ pill.setAttribute("selected", "selected");
+ }
+ }
+
+ /**
+ * Deselect all the pills of the <mail-recipients-area> element.
+ */
+ deselectAllPills() {
+ for (let pill of this.querySelectorAll(`mail-address-pill[selected]`)) {
+ pill.removeAttribute("selected");
+ }
+ }
+
+ /**
+ * Return all pills of the same address row (.address-container).
+ *
+ * @param {Element} pill - A <mail-address-pill> element. All pills in the
+ * same .address-container will be returned.
+ * @returns {NodeList} NodeList of <mail-address-pill> elements in same field.
+ */
+ getSiblingPills(pill) {
+ return pill
+ .closest(".address-container")
+ .querySelectorAll("mail-address-pill");
+ }
+
+ /**
+ * Return all pills of the <mail-recipients-area> element.
+ *
+ * @returns {NodeList} NodeList of all <mail-address-pill> elements.
+ */
+ getAllPills() {
+ return this.querySelectorAll("mail-address-pill");
+ }
+
+ /**
+ * Return all currently selected pills in the <mail-recipients-area>.
+ *
+ * @returns {NodeList} NodeList of all selected <mail-address-pill> elements.
+ */
+ getAllSelectedPills() {
+ return this.querySelectorAll("mail-address-pill[selected]");
+ }
+
+ /**
+ * Check if any pill in the <mail-recipients-area> is selected.
+ *
+ * @returns {boolean} true if any pill is selected.
+ */
+ hasSelectedPills() {
+ return Boolean(this.querySelector("mail-address-pill[selected]"));
+ }
+
+ /**
+ * Move the focus to the previous focusable element.
+ *
+ * @param {Element} element - The element where the event was triggered.
+ */
+ moveFocusToPreviousElement(element) {
+ let row = element.closest(".address-row");
+ // Move focus on the close label if not collapsed.
+ if (!row.querySelector(".remove-field-button").hidden) {
+ row.querySelector(".remove-field-button").focus();
+ return;
+ }
+ // If a previous address row is available and not hidden,
+ // focus on the autocomplete input field.
+ let previousRow = row.previousElementSibling;
+ while (previousRow) {
+ if (!previousRow.classList.contains("hidden")) {
+ previousRow.querySelector(".address-row-input").focus();
+ return;
+ }
+ previousRow = previousRow.previousElementSibling;
+ }
+ // Move the focus on the previous button: either the
+ // extraAddressRowsMenuButton, or one of "<type>ShowAddressRowButton".
+ let buttons = document.querySelectorAll(
+ "#extraAddressRowsArea button:not([hidden])"
+ );
+ if (buttons.length) {
+ // Select the last available label.
+ buttons[buttons.length - 1].focus();
+ return;
+ }
+ // Move the focus on the msgIdentity if no extra recipients are available.
+ document.getElementById("msgIdentity").focus();
+ }
+ }
+
+ customElements.define("mail-recipients-area", MailRecipientsArea);
+}
diff --git a/comm/mail/base/content/widgets/pane-splitter.js b/comm/mail/base/content/widgets/pane-splitter.js
new file mode 100644
index 0000000000..d201f3286f
--- /dev/null
+++ b/comm/mail/base/content/widgets/pane-splitter.js
@@ -0,0 +1,562 @@
+/* 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/. */
+
+{
+ /**
+ * A widget for adjusting the size of its {@link PaneSplitter#resizeElement}.
+ * By default, the splitter will resize the height of the resizeElement, but
+ * this can be changed using the "resize-direction" attribute.
+ *
+ * If dragged, the splitter will set a CSS variable on the parent element,
+ * which is named from the id of the element plus "width" or "height" as
+ * appropriate (e.g. --splitter-width). The variable should be used to set the
+ * border-area width or height of the resizeElement.
+ *
+ * Often, you will want to naturally limit the size of the resizeElement to
+ * prevent it exceeding its min or max size bounds, and to remain within the
+ * available space of its container. One way to do this is to use a grid
+ * layout on the container and size the resizeElement's row with
+ * "minmax(auto, --splitter-height)", or similar for the column when adjusting
+ * the width.
+ *
+ * This splitter element fires a "splitter-resizing" event as dragging begins,
+ * and "splitter-resized" when it ends.
+ *
+ * The resizeElement can be collapsed and expanded. Whilst collapsed, the
+ * "collapsed-by-splitter" class will be added to the resizeElement and the
+ * "--<id>-width" or "--<id>-height" CSS variable, will be be set to "0px".
+ * The "splitter-collapsed" and "splitter-expanded" events are fired as
+ * appropriate. If the splitter has a "collapse-width" or "collapse-height"
+ * attribute, collapsing and expanding happens automatically when below the
+ * given size.
+ */
+ class PaneSplitter extends HTMLHRElement {
+ static observedAttributes = ["resize-direction", "resize-id", "id"];
+
+ connectedCallback() {
+ this.addEventListener("mousedown", this);
+ // Try and find the _resizeElement from the resize-id attribute.
+ this._updateResizeElement();
+ this._updateStyling();
+ }
+
+ attributeChangedCallback(name, oldValue, newValue) {
+ switch (name) {
+ case "resize-direction":
+ this._updateResizeDirection();
+ break;
+ case "resize-id":
+ this._updateResizeElement();
+ break;
+ case "id":
+ this._updateStyling();
+ break;
+ }
+ }
+
+ /**
+ * The direction the splitter resizes the controlled element. Resizing
+ * horizontally changes its width, whilst resizing vertically changes its
+ * height.
+ *
+ * This corresponds to the "resize-direction" attribute and defaults to
+ * "vertical" when none is given.
+ *
+ * @type {"vertical"|"horizontal"}
+ */
+ get resizeDirection() {
+ return this.getAttribute("resize-direction") ?? "vertical";
+ }
+
+ set resizeDirection(val) {
+ this.setAttribute("resize-direction", val);
+ }
+
+ _updateResizeDirection() {
+ // The resize direction has changed. To be safe, make sure we're no longer
+ // resizing.
+ this.endResize();
+ this._updateStyling();
+ }
+
+ _resizeElement = null;
+
+ /**
+ * The element that is being sized by the splitter. It must have a set id.
+ *
+ * If the "resize-id" attribute is set, it will be used to choose this
+ * element by its id.
+ *
+ * @type {?HTMLElement}
+ */
+ get resizeElement() {
+ // Make sure the resizeElement is up to date.
+ this._updateResizeElement();
+ return this._resizeElement;
+ }
+
+ set resizeElement(element) {
+ if (!element?.id) {
+ element = null;
+ }
+ this._updateResizeElement(element);
+ // Set the resize-id attribute.
+ // NOTE: This will trigger a second call to _updateResizeElement, but it
+ // should end early because the resize-id matches the just set
+ // _resizeElement.
+ if (element) {
+ this.setAttribute("resize-id", element.id);
+ } else {
+ this.removeAttribute("resize-id");
+ }
+ }
+
+ /**
+ * Update the _resizeElement property.
+ *
+ * @param {?HTMLElement} [element] - The resizeElement to set, or leave
+ * undefined to use the resize-id attribute to find the element.
+ */
+ _updateResizeElement(element) {
+ if (element == undefined) {
+ // Use the resize-id to find the element.
+ let resizeId = this.getAttribute("resize-id");
+ if (resizeId) {
+ if (this._resizeElement?.id == resizeId) {
+ // Avoid looking up the element since we already have it.
+ return;
+ }
+ // Try and find the element.
+ // NOTE: If we don't find the element now, then we still keep the same
+ // resize-id attribute and we'll try again the next time this method
+ // is called.
+ element = this.ownerDocument.getElementById(resizeId);
+ } else {
+ element = null;
+ }
+ }
+ if (element == this._resizeElement) {
+ return;
+ }
+
+ // Make sure we stop resizing the current _resizeElement.
+ this.endResize();
+ if (this._resizeElement) {
+ // Clean up previous element.
+ this._resizeElement.classList.remove("collapsed-by-splitter");
+ }
+ this._resizeElement = element;
+ this._beforeElement =
+ element &&
+ !!(
+ this.compareDocumentPosition(element) &
+ Node.DOCUMENT_POSITION_FOLLOWING
+ );
+ // Are we already collapsed?
+ this._isCollapsed = this._resizeElement?.classList.contains(
+ "collapsed-by-splitter"
+ );
+ this._updateStyling();
+ }
+
+ _width = null;
+
+ /**
+ * The desired width of the resizeElement. This is used to set the
+ * --<id>-width CSS variable on the parent when the resizeDirection is
+ * "horizontal" and the resizeElement is not collapsed. If its value is
+ * null, the same CSS variable is removed from the parent instead.
+ *
+ * Note, this value is persistent across collapse states, so the width
+ * before collapsing can be returned to on expansion.
+ *
+ * Use this value in persistent storage.
+ *
+ * @type {?number}
+ */
+ get width() {
+ return this._width;
+ }
+
+ set width(width) {
+ if (width == this._width) {
+ return;
+ }
+ this._width = width;
+ this._updateStyling();
+ }
+
+ _height = null;
+
+ /**
+ * The desired height of the resizeElement. This is used to set the
+ * -<id>-height CSS variable on the parent when the resizeDirection is
+ * "vertical" and the resizeElement is not collapsed. If its value is null,
+ * the same CSS variable is removed from the parent instead.
+ *
+ * Note, this value is persistent across collapse states, so the height
+ * before collapsing can be returned to on expansion.
+ *
+ * Use this value in persistent storage.
+ *
+ * @type {?number}
+ */
+ get height() {
+ return this._height;
+ }
+
+ set height(height) {
+ if (height == this._height) {
+ return;
+ }
+ this._height = height;
+ this._updateStyling();
+ }
+
+ /**
+ * Update the width or height of the splitter, depending on its
+ * resizeDirection.
+ *
+ * If a trySize is given, the width or height of the splitter will be set to
+ * the given value, before being set to the actual size of the
+ * resizeElement. This acts as an automatic bounding process, without
+ * knowing the details of the layout and its constraints.
+ *
+ * If no trySize is given, then the width and height will be set to the
+ * actual size of the resizeElement.
+ *
+ * @param {?number} [trySize] - The size to try and achieve.
+ */
+ _updateSize(trySize) {
+ let vertical = this.resizeDirection == "vertical";
+ if (trySize != undefined) {
+ if (vertical) {
+ this.height = trySize;
+ } else {
+ this.width = trySize;
+ }
+ }
+ // Now that the width and height are updated, we fetch the size the
+ // element actually took.
+ let actual = this._getActualResizeSize();
+ if (vertical) {
+ this.height = actual;
+ } else {
+ this.width = actual;
+ }
+ }
+
+ /**
+ * Get the actual size of the resizeElement, regardless of the current
+ * width or height property values. This causes a reflow, and it gets
+ * called on every mousemove event while dragging, so it's very expensive
+ * but practically unavoidable.
+ *
+ * @returns {number} - The border area size of the resizeElement.
+ */
+ _getActualResizeSize() {
+ let resizeRect = this.resizeElement.getBoundingClientRect();
+ if (this.resizeDirection == "vertical") {
+ return resizeRect.height;
+ }
+ return resizeRect.width;
+ }
+
+ /**
+ * Collapses the controlled pane. A collapsed pane does not affect the
+ * `width` or `height` properties. Fires a "splitter-collapsed" event.
+ */
+ collapse() {
+ if (this._isCollapsed) {
+ return;
+ }
+ this._isCollapsed = true;
+ this._updateStyling();
+ this._updateDragCursor();
+ this.dispatchEvent(
+ new CustomEvent("splitter-collapsed", { bubbles: true })
+ );
+ }
+
+ /**
+ * Expands the controlled pane. It returns to the width or height it had
+ * when collapsed. Fires a "splitter-expanded" event.
+ */
+ expand() {
+ if (!this._isCollapsed) {
+ return;
+ }
+ this._isCollapsed = false;
+ this._updateStyling();
+ this._updateDragCursor();
+ this.dispatchEvent(
+ new CustomEvent("splitter-expanded", { bubbles: true })
+ );
+ }
+
+ _isCollapsed = false;
+
+ /**
+ * If the controlled pane is collapsed.
+ *
+ * @type {boolean}
+ */
+ get isCollapsed() {
+ return this._isCollapsed;
+ }
+
+ set isCollapsed(collapsed) {
+ if (collapsed) {
+ this.collapse();
+ } else {
+ this.expand();
+ }
+ }
+
+ /**
+ * Collapse the splitter if it is expanded, or expand it if collapsed.
+ */
+ toggleCollapsed() {
+ this.isCollapsed = !this._isCollapsed;
+ }
+
+ /**
+ * If the splitter is disabled.
+ *
+ * @type {boolean}
+ */
+ get isDisabled() {
+ return this.hasAttribute("disabled");
+ }
+
+ set isDisabled(disabled) {
+ if (disabled) {
+ this.setAttribute("disabled", true);
+ return;
+ }
+ this.removeAttribute("disabled");
+ }
+
+ /**
+ * Update styling to reflect the current state.
+ */
+ _updateStyling() {
+ if (!this.resizeElement || !this.parentNode || !this.id) {
+ // Wait until we have a resizeElement, a parent and an id.
+ return;
+ }
+
+ if (this.id != this._cssName?.basis) {
+ // Clear the old names.
+ if (this._cssName) {
+ this.parentNode.style.removeProperty(this._cssName.width);
+ this.parentNode.style.removeProperty(this._cssName.height);
+ }
+ this._cssName = {
+ basis: this.id,
+ height: `--${this.id}-height`,
+ width: `--${this.id}-width`,
+ };
+ }
+
+ let vertical = this.resizeDirection == "vertical";
+ let height = this.isCollapsed ? 0 : this.height;
+ if (!vertical || height == null) {
+ // If we are resizing horizontally or the "height" property is set to
+ // null, we remove the CSS height variable. The height of the element
+ // is left to be determined by the CSS stylesheet rules.
+ this.parentNode.style.removeProperty(this._cssName.height);
+ } else {
+ this.parentNode.style.setProperty(this._cssName.height, `${height}px`);
+ }
+ let width = this.isCollapsed ? 0 : this.width;
+ if (vertical || width == null) {
+ // If we are resizing vertically or the "width" property is set to
+ // null, we remove the CSS width variable. The width of the element
+ // is left to be determined by the CSS stylesheet rules.
+ this.parentNode.style.removeProperty(this._cssName.width);
+ } else {
+ this.parentNode.style.setProperty(this._cssName.width, `${width}px`);
+ }
+ this.resizeElement.classList.toggle(
+ "collapsed-by-splitter",
+ this.isCollapsed
+ );
+ this.classList.toggle("splitter-collapsed", this.isCollapsed);
+ this.classList.toggle("splitter-before", this._beforeElement);
+ }
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "mousedown":
+ this._onMouseDown(event);
+ break;
+ case "mousemove":
+ this._onMouseMove(event);
+ break;
+ case "mouseup":
+ this._onMouseUp(event);
+ break;
+ }
+ }
+
+ _onMouseDown(event) {
+ if (!this.resizeElement || this.isDisabled) {
+ return;
+ }
+ if (event.buttons != 1) {
+ return;
+ }
+
+ let vertical = this.resizeDirection == "vertical";
+ let collapseSize =
+ Number(
+ this.getAttribute(vertical ? "collapse-height" : "collapse-width")
+ ) || 0;
+ let ltrDir = this.parentNode.matches(":dir(ltr)");
+
+ this._dragStartInfo = {
+ wasCollapsed: this.isCollapsed,
+ // Whether this will resize vertically.
+ vertical,
+ pos: vertical ? event.clientY : event.clientX,
+ // Whether decreasing X/Y should increase the size.
+ negative: vertical
+ ? this._beforeElement
+ : this._beforeElement == ltrDir,
+ size: this._getActualResizeSize(),
+ collapseSize,
+ };
+
+ event.preventDefault();
+ window.addEventListener("mousemove", this);
+ window.addEventListener("mouseup", this);
+ // Block all other pointer events whilst resizing. This ensures we don't
+ // trigger any styling or other effects whilst resizing. This also ensures
+ // that the MouseEvent's clientX and clientY will always be relative to
+ // the current window, rather than some ancestor xul:browser's window.
+ document.documentElement.style.pointerEvents = "none";
+ this._updateDragCursor();
+ this.classList.add("splitter-resizing");
+ }
+
+ _updateDragCursor() {
+ if (!this._dragStartInfo) {
+ return;
+ }
+ let cursor;
+ let { vertical, negative } = this._dragStartInfo;
+ if (this.isCollapsed) {
+ if (vertical) {
+ cursor = negative ? "n-resize" : "s-resize";
+ } else {
+ cursor = negative ? "w-resize" : "e-resize";
+ }
+ } else {
+ cursor = vertical ? "ns-resize" : "ew-resize";
+ }
+ document.documentElement.style.cursor = cursor;
+ }
+
+ /**
+ * If `mousemove` events will be ignored because the screen hasn't been
+ * updated since the last one.
+ *
+ * @type {boolean}
+ */
+ _mouseMoveBlocked = false;
+
+ _onMouseMove(event) {
+ if (event.buttons != 1) {
+ // The button was released and we didn't get a mouseup event (e.g.
+ // releasing the mouse above a disabled html:button), or the
+ // button(s) pressed changed. Either way, stop dragging.
+ this.endResize();
+ return;
+ }
+
+ event.preventDefault();
+
+ // Ensure the expensive part of this function runs no more than once
+ // per frame. Doing it more frequently is just wasting CPU time.
+ if (this._mouseMoveBlocked) {
+ return;
+ }
+ this._mouseMoveBlocked = true;
+ requestAnimationFrame(() => (this._mouseMoveBlocked = false));
+
+ let { wasCollapsed, vertical, negative, pos, size, collapseSize } =
+ this._dragStartInfo;
+
+ let delta = (vertical ? event.clientY : event.clientX) - pos;
+ if (negative) {
+ delta *= -1;
+ }
+
+ if (!this._started) {
+ if (Math.abs(delta) < 3) {
+ return;
+ }
+ this._started = true;
+ this.dispatchEvent(
+ new CustomEvent("splitter-resizing", { bubbles: true })
+ );
+ }
+
+ size += delta;
+ if (collapseSize) {
+ let pastCollapseThreshold = size < collapseSize - 20;
+ if (wasCollapsed) {
+ if (!pastCollapseThreshold) {
+ this._dragStartInfo.wasCollapsed = false;
+ }
+ pastCollapseThreshold = size < 20;
+ }
+
+ if (pastCollapseThreshold) {
+ this.collapse();
+ return;
+ }
+
+ this.expand();
+ size = Math.max(size, collapseSize);
+ }
+ this._updateSize(Math.max(0, size));
+ }
+
+ _onMouseUp(event) {
+ event.preventDefault();
+ this.endResize();
+ }
+
+ /**
+ * Stop the resizing operation if it is currently active.
+ */
+ endResize() {
+ if (!this._dragStartInfo) {
+ return;
+ }
+ let didStart = this._started;
+
+ delete this._dragStartInfo;
+ delete this._started;
+
+ window.removeEventListener("mousemove", this);
+ window.removeEventListener("mouseup", this);
+ document.documentElement.style.pointerEvents = null;
+ document.documentElement.style.cursor = null;
+ this.classList.remove("splitter-resizing");
+
+ // Make sure our property corresponds to the actual final size.
+ this._updateSize();
+
+ if (didStart) {
+ this.dispatchEvent(
+ new CustomEvent("splitter-resized", { bubbles: true })
+ );
+ }
+ }
+ }
+ customElements.define("pane-splitter", PaneSplitter, { extends: "hr" });
+}
diff --git a/comm/mail/base/content/widgets/statuspanel.js b/comm/mail/base/content/widgets/statuspanel.js
new file mode 100644
index 0000000000..8d30ea4697
--- /dev/null
+++ b/comm/mail/base/content/widgets/statuspanel.js
@@ -0,0 +1,78 @@
+/**
+ * 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/. */
+
+/* global MozXULElement */
+
+// Wrap in a block to prevent leaking to window scope.
+{
+ class MozStatuspanel extends MozXULElement {
+ static get observedAttributes() {
+ return ["label", "mirror"];
+ }
+
+ connectedCallback() {
+ const hbox = document.createXULElement("hbox");
+ hbox.classList.add("statuspanel-inner");
+
+ const label = document.createXULElement("label");
+ label.classList.add("statuspanel-label");
+ label.setAttribute("flex", "1");
+ label.setAttribute("crop", "end");
+
+ hbox.appendChild(label);
+ this.appendChild(hbox);
+
+ this._labelElement = label;
+
+ this._updateAttributes();
+ this._setupEventListeners();
+ }
+
+ attributeChangedCallback() {
+ this._updateAttributes();
+ }
+
+ set label(val) {
+ if (!this.label) {
+ this.removeAttribute("mirror");
+ }
+ this.setAttribute("label", val);
+ }
+
+ get label() {
+ return this.getAttribute("label");
+ }
+
+ _updateAttributes() {
+ if (!this._labelElement) {
+ return;
+ }
+
+ if (this.hasAttribute("label")) {
+ this._labelElement.setAttribute("value", this.getAttribute("label"));
+ } else {
+ this._labelElement.removeAttribute("value");
+ }
+
+ if (this.hasAttribute("mirror")) {
+ this._labelElement.setAttribute("mirror", this.getAttribute("mirror"));
+ } else {
+ this._labelElement.removeAttribute("mirror");
+ }
+ }
+
+ _setupEventListeners() {
+ this.addEventListener("mouseover", event => {
+ if (this.hasAttribute("mirror")) {
+ this.removeAttribute("mirror");
+ } else {
+ this.setAttribute("mirror", "true");
+ }
+ });
+ }
+ }
+
+ customElements.define("statuspanel", MozStatuspanel);
+}
diff --git a/comm/mail/base/content/widgets/tabmail-tab.js b/comm/mail/base/content/widgets/tabmail-tab.js
new file mode 100644
index 0000000000..7de115149b
--- /dev/null
+++ b/comm/mail/base/content/widgets/tabmail-tab.js
@@ -0,0 +1,179 @@
+/* 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/. */
+
+"use strict";
+
+/* global MozElements, MozXULElement */
+
+// Wrap in a block to prevent leaking to window scope.
+{
+ /**
+ * The MozTabmailTab widget behaves as a tab in the messenger window.
+ * It is used to navigate between different views. It displays information
+ * about the view: i.e. name and icon.
+ *
+ * @augments {MozElements.MozTab}
+ */
+ class MozTabmailTab extends MozElements.MozTab {
+ static get inheritedAttributes() {
+ return {
+ ".tab-background": "pinned,selected,titlechanged",
+ ".tab-line": "selected=visuallyselected",
+ ".tab-content": "pinned,selected,titlechanged,title=label",
+ ".tab-throbber": "fadein,pinned,busy,progress,selected",
+ ".tab-icon-image": "fadein,pinned,selected",
+ ".tab-label-container": "pinned,selected=visuallyselected",
+ ".tab-text": "text=label,accesskey,fadein,pinned,selected",
+ ".tab-close-button": "fadein,pinned,selected=visuallyselected",
+ };
+ }
+
+ connectedCallback() {
+ if (this.delayConnectedCallback() || this.hasChildNodes()) {
+ return;
+ }
+
+ this.setAttribute("is", "tabmail-tab");
+ this.appendChild(
+ MozXULElement.parseXULToFragment(
+ `
+ <stack class="tab-stack" flex="1">
+ <vbox class="tab-background">
+ <hbox class="tab-line"></hbox>
+ </vbox>
+ <html:div class="tab-content">
+ <hbox class="tab-throbber" role="presentation"></hbox>
+ <html:img class="tab-icon-image" alt="" role="presentation" />
+ <hbox class="tab-label-container"
+ onoverflow="this.setAttribute('textoverflow', 'true');"
+ onunderflow="this.removeAttribute('textoverflow');"
+ flex="1">
+ <label class="tab-text tab-label" role="presentation"></label>
+ </hbox>
+ <!-- We make the button non-focusable, otherwise each close
+ - button creates a new tab stop. See bug 1754097 -->
+ <html:button class="plain-button tab-close-button"
+ tabindex="-1"
+ title="&closeTab.label;">
+ <!-- Button title should provide the accessible context. -->
+ <html:img class="tab-close-icon" alt=""
+ src="chrome://global/skin/icons/close.svg" />
+ </html:button>
+ </html:div>
+ </stack>
+ `,
+ ["chrome://messenger/locale/tabmail.dtd"]
+ )
+ );
+
+ this.addEventListener(
+ "dragstart",
+ event => {
+ document.dragTab = this;
+ },
+ true
+ );
+
+ this.addEventListener(
+ "dragover",
+ event => {
+ document.dragTab = null;
+ },
+ true
+ );
+
+ let closeButton = this.querySelector(".tab-close-button");
+
+ // Prevent switching to the tab before closing it by stopping the
+ // mousedown event.
+ closeButton.addEventListener("mousedown", event => {
+ if (event.button != 0) {
+ return;
+ }
+ event.stopPropagation();
+ });
+
+ closeButton.addEventListener("click", () =>
+ document.getElementById("tabmail").removeTabByNode(this)
+ );
+
+ // Middle mouse button click on the tab also closes it.
+ this.addEventListener("click", event => {
+ if (event.button != 1) {
+ return;
+ }
+ document.getElementById("tabmail").removeTabByNode(this);
+ });
+
+ this.setAttribute("context", "tabContextMenu");
+
+ this.mCorrespondingMenuitem = null;
+
+ this.initializeAttributeInheritance();
+ }
+
+ get linkedBrowser() {
+ let tabmail = document.getElementById("tabmail");
+ let tab = tabmail._getTabContextForTabbyThing(this, false)[1];
+ return tabmail.getBrowserForTab(tab);
+ }
+
+ get mode() {
+ let tabmail = document.getElementById("tabmail");
+ let tab = tabmail._getTabContextForTabbyThing(this, false)[1];
+ return tab.mode;
+ }
+
+ /**
+ * Set the displayed icon for the tab.
+ *
+ * If a fallback source if given, it will be used instead if the given icon
+ * source is missing or loads with an error.
+ *
+ * If both sources are null, then the icon will become invisible.
+ *
+ * @param {string|null} iconSrc - The icon source to display in the tab, or
+ * null to just use the fallback source.
+ * @param {?string} [fallbackSrc] - The fallback source to display if the
+ * iconSrc is missing or broken.
+ */
+ setIcon(iconSrc, fallbackSrc) {
+ let icon = this.querySelector(".tab-icon-image");
+ if (!fallbackSrc) {
+ if (iconSrc) {
+ icon.setAttribute("src", iconSrc);
+ } else {
+ icon.removeAttribute("src");
+ }
+ return;
+ }
+ if (!iconSrc) {
+ icon.setAttribute("src", fallbackSrc);
+ return;
+ }
+ if (iconSrc == icon.getAttribute("src")) {
+ return;
+ }
+
+ // Set the tab image, and use the fallback if an error occurs.
+ // Set up a one time listener for either error or load.
+ let listener = event => {
+ icon.removeEventListener("error", listener);
+ icon.removeEventListener("load", listener);
+ if (event.type == "error") {
+ icon.setAttribute("src", fallbackSrc);
+ }
+ };
+ icon.addEventListener("error", listener);
+ icon.addEventListener("load", listener);
+ icon.setAttribute("src", iconSrc);
+ }
+ }
+
+ MozXULElement.implementCustomInterface(MozTabmailTab, [
+ Ci.nsIDOMXULSelectControlItemElement,
+ ]);
+
+ customElements.define("tabmail-tab", MozTabmailTab, { extends: "tab" });
+}
diff --git a/comm/mail/base/content/widgets/tabmail-tabs.js b/comm/mail/base/content/widgets/tabmail-tabs.js
new file mode 100644
index 0000000000..004a60122d
--- /dev/null
+++ b/comm/mail/base/content/widgets/tabmail-tabs.js
@@ -0,0 +1,723 @@
+/* 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/. */
+
+"use strict";
+
+/* global MozElements, MozXULElement */
+
+// Wrap in a block to prevent leaking to window scope.
+{
+ const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+ );
+
+ /**
+ * The MozTabs widget holds all the tabs for the main tab UI.
+ *
+ * @augments {MozTabs}
+ */
+ class MozTabmailTabs extends customElements.get("tabs") {
+ constructor() {
+ super();
+
+ this.addEventListener("dragstart", event => {
+ let draggedTab = this._getDragTargetTab(event);
+
+ if (!draggedTab) {
+ return;
+ }
+
+ let tab = this.tabmail.selectedTab;
+
+ if (!tab || !tab.canClose) {
+ return;
+ }
+
+ let dt = event.dataTransfer;
+
+ // If we drag within the same window, we use the tab directly
+ dt.mozSetDataAt("application/x-moz-tabmail-tab", draggedTab, 0);
+
+ // Otherwise we use session restore & JSON to migrate the tab.
+ let uri = this.tabmail.persistTab(tab);
+
+ // In case the tab implements session restore, we use JSON to convert
+ // it into a string.
+ //
+ // If a tab does not support session restore it returns null. We can't
+ // moved such tabs to a new window. However moving them within the same
+ // window works perfectly fine.
+ if (uri) {
+ uri = JSON.stringify(uri);
+ }
+
+ dt.mozSetDataAt("application/x-moz-tabmail-json", uri, 0);
+
+ dt.mozCursor = "default";
+
+ // Create Drag Image.
+ let panel = document.getElementById("tabpanelcontainer");
+
+ let thumbnail = document.createElementNS(
+ "http://www.w3.org/1999/xhtml",
+ "canvas"
+ );
+ thumbnail.width = Math.ceil(screen.availWidth / 5.75);
+ thumbnail.height = Math.round(thumbnail.width * 0.5625);
+
+ let snippetWidth = panel.getBoundingClientRect().width * 0.6;
+ let scale = thumbnail.width / snippetWidth;
+
+ let ctx = thumbnail.getContext("2d");
+
+ ctx.scale(scale, scale);
+
+ ctx.drawWindow(
+ window,
+ panel.screenX - window.mozInnerScreenX,
+ panel.screenY - window.mozInnerScreenY,
+ snippetWidth,
+ snippetWidth * 0.5625,
+ "rgb(255,255,255)"
+ );
+
+ dt = event.dataTransfer;
+ dt.setDragImage(thumbnail, 0, 0);
+
+ event.stopPropagation();
+ });
+
+ this.addEventListener("dragover", event => {
+ let dt = event.dataTransfer;
+
+ if (dt.mozItemCount == 0) {
+ return;
+ }
+
+ // Bug 516247:
+ // in case the user is dragging something else than a tab, and
+ // keeps hovering over a tab, we assume he wants to switch to this tab.
+ if (
+ dt.mozTypesAt(0)[0] != "application/x-moz-tabmail-tab" &&
+ dt.mozTypesAt(0)[1] != "application/x-moz-tabmail-json"
+ ) {
+ let tab = this._getDragTargetTab(event);
+
+ if (!tab) {
+ return;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+
+ if (!this._dragTime) {
+ this._dragTime = Date.now();
+ return;
+ }
+
+ if (Date.now() <= this._dragTime + this._dragOverDelay) {
+ return;
+ }
+
+ if (this.tabmail.tabContainer.selectedItem == tab) {
+ return;
+ }
+
+ this.tabmail.tabContainer.selectedItem = tab;
+
+ return;
+ }
+
+ // As some tabs do not support session restore they can't be
+ // moved to a different or new window. We should not show
+ // a dropmarker in such a case.
+ if (!dt.mozGetDataAt("application/x-moz-tabmail-json", 0)) {
+ let draggedTab = dt.mozGetDataAt("application/x-moz-tabmail-tab", 0);
+
+ if (!draggedTab) {
+ return;
+ }
+
+ if (this.tabmail.tabContainer.getIndexOfItem(draggedTab) == -1) {
+ return;
+ }
+ }
+
+ dt.effectAllowed = "copyMove";
+
+ event.preventDefault();
+ event.stopPropagation();
+
+ let ltr = window.getComputedStyle(this).direction == "ltr";
+ let ind = this._tabDropIndicator;
+ let arrowScrollbox = this.arrowScrollbox;
+
+ // Let's scroll
+ let pixelsToScroll = 0;
+ if (arrowScrollbox.getAttribute("overflow") == "true") {
+ switch (event.target) {
+ case arrowScrollbox._scrollButtonDown:
+ pixelsToScroll = arrowScrollbox.scrollIncrement * -1;
+ break;
+ case arrowScrollbox._scrollButtonUp:
+ pixelsToScroll = arrowScrollbox.scrollIncrement;
+ break;
+ }
+
+ if (ltr) {
+ pixelsToScroll = pixelsToScroll * -1;
+ }
+
+ if (pixelsToScroll) {
+ // Hide Indicator while Scrolling
+ ind.hidden = true;
+ arrowScrollbox.scrollByPixels(pixelsToScroll);
+ return;
+ }
+ }
+
+ let newIndex = this._getDropIndex(event);
+
+ // Fix the DropIndex in case it points to tab that can't be closed.
+ let tabInfo = this.tabmail.tabInfo;
+
+ while (newIndex < tabInfo.length && !tabInfo[newIndex].canClose) {
+ newIndex++;
+ }
+
+ let scrollRect = this.arrowScrollbox.scrollClientRect;
+ let rect = this.getBoundingClientRect();
+ let minMargin = scrollRect.left - rect.left;
+ let maxMargin = Math.min(
+ minMargin + scrollRect.width,
+ scrollRect.right
+ );
+
+ if (!ltr) {
+ [minMargin, maxMargin] = [
+ this.clientWidth - maxMargin,
+ this.clientWidth - minMargin,
+ ];
+ }
+
+ let newMargin;
+ let tabs = this.allTabs;
+
+ if (newIndex == tabs.length) {
+ let tabRect = tabs[newIndex - 1].getBoundingClientRect();
+
+ if (ltr) {
+ newMargin = tabRect.right - rect.left;
+ } else {
+ newMargin = rect.right - tabRect.left;
+ }
+ } else {
+ let tabRect = tabs[newIndex].getBoundingClientRect();
+
+ if (ltr) {
+ newMargin = tabRect.left - rect.left;
+ } else {
+ newMargin = rect.right - tabRect.right;
+ }
+ }
+
+ ind.hidden = false;
+
+ newMargin -= ind.clientWidth / 2;
+
+ ind.style.insetInlineStart = `${Math.round(newMargin)}px`;
+ });
+
+ this.addEventListener("drop", event => {
+ let dt = event.dataTransfer;
+
+ if (dt.mozItemCount != 1) {
+ return;
+ }
+
+ let draggedTab = dt.mozGetDataAt("application/x-moz-tabmail-tab", 0);
+
+ if (!draggedTab) {
+ return;
+ }
+
+ event.stopPropagation();
+ this._tabDropIndicator.hidden = true;
+
+ // Is the tab one of our children?
+ if (this.tabmail.tabContainer.getIndexOfItem(draggedTab) == -1) {
+ // It's a tab from an other window, so we have to trigger session
+ // restore to get our tab
+
+ let tabmail2 = draggedTab.ownerDocument.getElementById("tabmail");
+ if (!tabmail2) {
+ return;
+ }
+
+ let draggedJson = dt.mozGetDataAt(
+ "application/x-moz-tabmail-json",
+ 0
+ );
+ if (!draggedJson) {
+ return;
+ }
+
+ draggedJson = JSON.parse(draggedJson);
+
+ // Some tab exist only once, so we have to gamble a bit. We close
+ // the tab and try to reopen it. If something fails the tab is gone.
+
+ tabmail2.closeTab(draggedTab, true);
+
+ if (!this.tabmail.restoreTab(draggedJson)) {
+ return;
+ }
+
+ draggedTab =
+ this.tabmail.tabContainer.allTabs[
+ this.tabmail.tabContainer.allTabs.length - 1
+ ];
+ }
+
+ let idx = this._getDropIndex(event);
+
+ // Fix the DropIndex in case it points to tab that can't be closed
+ let tabInfo = this.tabmail.tabInfo;
+ while (idx < tabInfo.length && !tabInfo[idx].canClose) {
+ idx++;
+ }
+
+ this.tabmail.moveTabTo(draggedTab, idx);
+
+ this.tabmail.switchToTab(draggedTab);
+ this.tabmail.updateCurrentTab();
+ });
+
+ this.addEventListener("dragend", event => {
+ // Note: while this case is correctly handled here, this event
+ // isn't dispatched when the tab is moved within the tabstrip,
+ // see bug 460801.
+
+ // The user pressed ESC to cancel the drag, or the drag succeeded.
+ let dt = event.dataTransfer;
+ if (dt.mozUserCancelled || dt.dropEffect != "none") {
+ return;
+ }
+
+ // Disable detach within the browser toolbox.
+ let eX = event.screenX;
+ let wX = window.screenX;
+
+ // Check if the drop point is horizontally within the window.
+ if (eX > wX && eX < wX + window.outerWidth) {
+ let bo = this.arrowScrollbox;
+ // Also avoid detaching if the the tab was dropped too close to
+ // the tabbar (half a tab).
+ let endScreenY = bo.screenY + 1.5 * bo.getBoundingClientRect().height;
+ let eY = event.screenY;
+
+ if (eY < endScreenY && eY > window.screenY) {
+ return;
+ }
+ }
+
+ // User wants to deatach tab from window...
+ if (dt.mozItemCount != 1) {
+ return;
+ }
+
+ let draggedTab = dt.mozGetDataAt("application/x-moz-tabmail-tab", 0);
+
+ if (!draggedTab) {
+ return;
+ }
+
+ this.tabmail.replaceTabWithWindow(draggedTab);
+ });
+
+ this.addEventListener("dragleave", event => {
+ this._dragTime = 0;
+
+ this._tabDropIndicator.hidden = true;
+ event.stopPropagation();
+ });
+ }
+
+ connectedCallback() {
+ if (this.delayConnectedCallback()) {
+ return;
+ }
+ super.connectedCallback();
+
+ this.tabmail = document.getElementById("tabmail");
+
+ this.arrowScrollboxWidth = 0;
+
+ this.arrowScrollbox = this.querySelector("arrowscrollbox");
+
+ this.mCollapseToolbar = document.getElementById(
+ this.getAttribute("collapsetoolbar")
+ );
+
+ // @implements {nsIObserver}
+ this._prefObserver = (subject, topic, data) => {
+ if (topic == "nsPref:changed") {
+ subject.QueryInterface(Ci.nsIPrefBranch);
+ if (data == "mail.tabs.autoHide") {
+ this.mAutoHide = subject.getBoolPref("mail.tabs.autoHide");
+ }
+ }
+ };
+
+ this._tabDropIndicator = this.querySelector(".tab-drop-indicator");
+
+ this._dragOverDelay = 350;
+
+ this._dragTime = 0;
+
+ this._mAutoHide = false;
+
+ this.mAllTabsButton = document.getElementById(
+ this.getAttribute("alltabsbutton")
+ );
+ this.mAllTabsPopup = this.mAllTabsButton.menu;
+
+ this.mDownBoxAnimate = this.arrowScrollbox;
+
+ this._animateTimer = null;
+
+ this._animateStep = -1;
+
+ this._animateDelay = 25;
+
+ this._animatePercents = [
+ 1.0, 0.85, 0.8, 0.75, 0.71, 0.68, 0.65, 0.62, 0.59, 0.57, 0.54, 0.52,
+ 0.5, 0.47, 0.45, 0.44, 0.42, 0.4, 0.38, 0.37, 0.35, 0.34, 0.32, 0.31,
+ 0.3, 0.29, 0.28, 0.27, 0.26, 0.25, 0.24, 0.23, 0.23, 0.22, 0.22, 0.21,
+ 0.21, 0.21, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2, 0.19, 0.19, 0.19,
+ 0.18, 0.18, 0.17, 0.17, 0.16, 0.15, 0.14, 0.13, 0.11, 0.09, 0.06,
+ ];
+
+ this.mTabMinWidth = Services.prefs.getIntPref("mail.tabs.tabMinWidth");
+ this.mTabMaxWidth = Services.prefs.getIntPref("mail.tabs.tabMaxWidth");
+ this.mTabClipWidth = Services.prefs.getIntPref("mail.tabs.tabClipWidth");
+ this.mAutoHide = Services.prefs.getBoolPref("mail.tabs.autoHide");
+
+ if (this.mAutoHide) {
+ this.mCollapseToolbar.collapsed = true;
+ document.documentElement.setAttribute("tabbarhidden", "true");
+ }
+
+ this._updateCloseButtons();
+
+ Services.prefs.addObserver("mail.tabs.", this._prefObserver);
+
+ window.addEventListener("resize", this);
+
+ // Listen to overflow/underflow events on the tabstrip,
+ // we cannot put these as xbl handlers on the entire binding because
+ // they would also get called for the all-tabs popup scrollbox.
+ // Also, we can't rely on event.target because these are all
+ // anonymous nodes.
+ this.arrowScrollbox.shadowRoot.addEventListener("overflow", this);
+ this.arrowScrollbox.shadowRoot.addEventListener("underflow", this);
+
+ this.addEventListener("select", event => {
+ this._handleTabSelect();
+
+ if (
+ !("updateCurrentTab" in this.tabmail) ||
+ event.target.localName != "tabs"
+ ) {
+ return;
+ }
+
+ this.tabmail.updateCurrentTab();
+ });
+
+ this.addEventListener("TabSelect", event => {
+ this._handleTabSelect();
+ });
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "_tabMinWidthPref",
+ "mail.tabs.tabMinWidth",
+ null,
+ (pref, prevValue, newValue) => (this._tabMinWidth = newValue),
+ newValue => {
+ const LIMIT = 50;
+ return Math.max(newValue, LIMIT);
+ }
+ );
+ this._tabMinWidth = this._tabMinWidthPref;
+
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "_tabMaxWidthPref",
+ "mail.tabs.tabMaxWidth",
+ null,
+ (pref, prevValue, newValue) => (this._tabMaxWidth = newValue)
+ );
+ this._tabMaxWidth = this._tabMaxWidthPref;
+ }
+
+ get tabbox() {
+ return document.getElementById("tabmail-tabbox");
+ }
+
+ // Accessor for tabs.
+ get allTabs() {
+ if (!this.arrowScrollbox) {
+ return [];
+ }
+
+ return Array.from(this.arrowScrollbox.children);
+ }
+
+ appendChild(tab) {
+ return this.insertBefore(tab, null);
+ }
+
+ insertBefore(tab, node) {
+ if (!this.arrowScrollbox) {
+ return;
+ }
+
+ if (node == null) {
+ this.arrowScrollbox.appendChild(tab);
+ return;
+ }
+
+ this.arrowScrollbox.insertBefore(tab, node);
+ }
+
+ set mAutoHide(val) {
+ if (val != this._mAutoHide) {
+ if (this.allTabs.length == 1) {
+ this.mCollapseToolbar.collapsed = val;
+ }
+ this._mAutoHide = val;
+ }
+ }
+
+ get mAutoHide() {
+ return this._mAutoHide;
+ }
+
+ set selectedIndex(val) {
+ let tab = this.getItemAtIndex(val);
+ let alreadySelected = tab && tab.selected;
+
+ this.__proto__.__proto__
+ .__lookupSetter__("selectedIndex")
+ .call(this, val);
+
+ if (!alreadySelected) {
+ // Fire an onselect event for the tabs element.
+ let event = document.createEvent("Events");
+ event.initEvent("select", true, true);
+ this.dispatchEvent(event);
+ }
+ }
+
+ get selectedIndex() {
+ return this.__proto__.__proto__
+ .__lookupGetter__("selectedIndex")
+ .call(this);
+ }
+
+ _updateCloseButtons() {
+ let width =
+ this.arrowScrollbox.firstElementChild.getBoundingClientRect().width;
+ // 0 width is an invalid value and indicates
+ // an item without display, so ignore.
+ if (width > this.mTabClipWidth || width == 0) {
+ this.setAttribute("closebuttons", "alltabs");
+ } else {
+ this.setAttribute("closebuttons", "activetab");
+ }
+ }
+
+ _handleTabSelect() {
+ this.arrowScrollbox.ensureElementIsVisible(this.selectedItem);
+ }
+
+ handleEvent(aEvent) {
+ let alltabsButton = document.getElementById("alltabs-button");
+
+ switch (aEvent.type) {
+ case "overflow":
+ this.arrowScrollbox.ensureElementIsVisible(this.selectedItem);
+
+ // filter overflow events which were dispatched on nested scrollboxes
+ // and ignore vertical events.
+ if (
+ aEvent.target != this.arrowScrollbox.scrollbox ||
+ aEvent.detail == 0
+ ) {
+ return;
+ }
+
+ this.arrowScrollbox.setAttribute("overflow", "true");
+ alltabsButton.removeAttribute("hidden");
+ break;
+ case "underflow":
+ // filter underflow events which were dispatched on nested scrollboxes
+ // and ignore vertical events.
+ if (
+ aEvent.target != this.arrowScrollbox.scrollbox ||
+ aEvent.detail == 0
+ ) {
+ return;
+ }
+
+ this.arrowScrollbox.removeAttribute("overflow");
+ alltabsButton.setAttribute("hidden", "true");
+ break;
+ case "resize":
+ let width = this.arrowScrollbox.getBoundingClientRect().width;
+ if (width != this.arrowScrollboxWidth) {
+ this._updateCloseButtons();
+ // XXX without this line the tab bar won't budge
+ this.arrowScrollbox.scrollByPixels(1);
+ this._handleTabSelect();
+ this.arrowScrollboxWidth = width;
+ }
+ break;
+ }
+ }
+
+ _stopAnimation() {
+ if (this._animateStep != -1) {
+ if (this._animateTimer) {
+ this._animateTimer.cancel();
+ }
+
+ this._animateStep = -1;
+ this.mAllTabsBoxAnimate.style.opacity = 0.0;
+ this.mDownBoxAnimate.style.opacity = 0.0;
+ }
+ }
+
+ _notifyBackgroundTab(aTab) {
+ let tsbo = this.arrowScrollbox;
+ let tsboStart = tsbo.screenX;
+ let tsboEnd = tsboStart + tsbo.getBoundingClientRect().width;
+
+ let ctboStart = aTab.screenX;
+ let ctboEnd = ctboStart + aTab.getBoundingClientRect().width;
+
+ // only start the flash timer if the new tab (which was loaded in
+ // the background) is not completely visible
+ if (tsboStart > ctboStart || ctboEnd > tsboEnd) {
+ this._animateStep = 0;
+
+ if (!this._animateTimer) {
+ this._animateTimer = Cc["@mozilla.org/timer;1"].createInstance(
+ Ci.nsITimer
+ );
+ } else {
+ this._animateTimer.cancel();
+ }
+
+ this._animateTimer.initWithCallback(
+ this,
+ this._animateDelay,
+ Ci.nsITimer.TYPE_REPEATING_SLACK
+ );
+ }
+ }
+
+ notify(aTimer) {
+ if (!document) {
+ aTimer.cancel();
+ }
+
+ let percent = this._animatePercents[this._animateStep];
+ this.mAllTabsBoxAnimate.style.opacity = percent;
+ this.mDownBoxAnimate.style.opacity = percent;
+
+ if (this._animateStep < this._animatePercents.length - 1) {
+ this._animateStep++;
+ } else {
+ this._stopAnimation();
+ }
+ }
+
+ _getDragTargetTab(event) {
+ let tab = event.target;
+ while (tab && tab.localName != "tab") {
+ tab = tab.parentNode;
+ }
+
+ if (!tab) {
+ return null;
+ }
+
+ if (event.type != "drop" && event.type != "dragover") {
+ return tab;
+ }
+
+ let tabRect = tab.getBoundingClientRect();
+ if (event.screenX < tab.screenX + tabRect.width * 0.25) {
+ return null;
+ }
+
+ if (event.screenX > tab.screenX + tabRect.width * 0.75) {
+ return null;
+ }
+
+ return tab;
+ }
+
+ _getDropIndex(event) {
+ let tabs = this.allTabs;
+
+ if (window.getComputedStyle(this).direction == "ltr") {
+ for (let i = 0; i < tabs.length; i++) {
+ if (
+ event.screenX <
+ tabs[i].screenX + tabs[i].getBoundingClientRect().width / 2
+ ) {
+ return i;
+ }
+ }
+ } else {
+ for (let i = 0; i < tabs.length; i++) {
+ if (
+ event.screenX >
+ tabs[i].screenX + tabs[i].getBoundingClientRect().width / 2
+ ) {
+ return i;
+ }
+ }
+ }
+
+ return tabs.length;
+ }
+
+ set _tabMinWidth(val) {
+ this.arrowScrollbox.style.setProperty("--tab-min-width", `${val}px`);
+ }
+ set _tabMaxWidth(val) {
+ this.arrowScrollbox.style.setProperty("--tab-max-width", `${val}px`);
+ }
+
+ disconnectedCallback() {
+ Services.prefs.removeObserver("mail.tabs.", this._prefObserver);
+
+ // Release timer to avoid reference cycles.
+ if (this._animateTimer) {
+ this._animateTimer.cancel();
+ this._animateTimer = null;
+ }
+
+ this.arrowScrollbox.shadowRoot.removeEventListener("overflow", this);
+ this.arrowScrollbox.shadowRoot.removeEventListener("underflow", this);
+ }
+ }
+
+ MozXULElement.implementCustomInterface(MozTabmailTabs, [Ci.nsITimerCallback]);
+ customElements.define("tabmail-tabs", MozTabmailTabs, { extends: "tabs" });
+}
diff --git a/comm/mail/base/content/widgets/toolbarContext.inc.xhtml b/comm/mail/base/content/widgets/toolbarContext.inc.xhtml
new file mode 100644
index 0000000000..c6a1c415a8
--- /dev/null
+++ b/comm/mail/base/content/widgets/toolbarContext.inc.xhtml
@@ -0,0 +1,19 @@
+# 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/.
+
+<menupopup id="toolbar-context-menu"
+ onpopupshowing="calendarOnToolbarsPopupShowing(event); ToolbarContextMenu.updateExtension(this);">
+ <menuseparator id="customizeMailToolbarMenuSeparator"/>
+ <menuitem id="CustomizeMailToolbar"
+ command="cmd_CustomizeMailToolbar"
+ label="&customizeToolbar.label;"
+ accesskey="&customizeToolbar.accesskey;"/>
+ <menuseparator id="extensionsMailToolbarMenuSeparator"/>
+ <menuitem oncommand="ToolbarContextMenu.openAboutAddonsForContextAction(this.parentElement)"
+ data-l10n-id="toolbar-context-menu-manage-extension"
+ class="customize-context-manageExtension"/>
+ <menuitem oncommand="ToolbarContextMenu.removeExtensionForContextAction(this.parentElement)"
+ data-l10n-id="toolbar-context-menu-remove-extension"
+ class="customize-context-removeExtension"/>
+</menupopup>
diff --git a/comm/mail/base/content/widgets/toolbarbutton-menu-button.js b/comm/mail/base/content/widgets/toolbarbutton-menu-button.js
new file mode 100644
index 0000000000..c514aa7357
--- /dev/null
+++ b/comm/mail/base/content/widgets/toolbarbutton-menu-button.js
@@ -0,0 +1,80 @@
+/* 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/. */
+
+"use strict";
+
+/* global MozXULElement */
+
+// Wrap in a block to prevent leaking to window scope.
+{
+ /**
+ * The MozToolbarButtonMenuButton widget is a toolbarbutton with
+ * type="menu". Place a menupopup element inside the button to create
+ * the menu popup. When the dropmarker in the toobarbutton is pressed the
+ * menupopup will open. When clicking the main area of the button it works
+ * like a normal toolbarbutton.
+ *
+ * @augments MozToolbarbutton
+ */
+ class MozToolbarButtonMenuButton extends customElements.get("toolbarbutton") {
+ static get inheritedAttributes() {
+ return {
+ ...super.inheritedAttributes,
+ ".toolbarbutton-menubutton-button":
+ "command,hidden,disabled,align,dir,pack,orient,label,wrap,tooltiptext=buttontooltiptext",
+ ".toolbarbutton-menubutton-dropmarker": "open,disabled",
+ };
+ }
+ static get menubuttonFragment() {
+ let frag = document.importNode(
+ MozXULElement.parseXULToFragment(`
+ <toolbarbutton class="box-inherit toolbarbutton-menubutton-button"
+ flex="1"
+ allowevents="true"></toolbarbutton>
+ <dropmarker type="menu"
+ class="toolbarbutton-menubutton-dropmarker"></dropmarker>
+ `),
+ true
+ );
+ Object.defineProperty(this, "menubuttonFragment", { value: frag });
+ return frag;
+ }
+
+ /** @override */
+ get _hasConnected() {
+ return (
+ this.querySelector(":scope > toolbarbutton > .toolbarbutton-text") !=
+ null
+ );
+ }
+
+ /** @override */
+ render() {
+ this.appendChild(this.constructor.menubuttonFragment.cloneNode(true));
+ this.initializeAttributeInheritance();
+ }
+
+ connectedCallback() {
+ if (this.delayConnectedCallback() || this._hasConnected) {
+ return;
+ }
+
+ // Defer creating DOM elements for content inside popups.
+ // These will be added in the popupshown handler above.
+ let panel = this.closest("panel");
+ if (panel && !panel.hasAttribute("hasbeenopened")) {
+ return;
+ }
+ this.setAttribute("is", "toolbarbutton-menu-button");
+ this.setAttribute("type", "menu");
+
+ this.render();
+ }
+ }
+ customElements.define(
+ "toolbarbutton-menu-button",
+ MozToolbarButtonMenuButton,
+ { extends: "toolbarbutton" }
+ );
+}
diff --git a/comm/mail/base/content/widgets/tree-listbox.js b/comm/mail/base/content/widgets/tree-listbox.js
new file mode 100644
index 0000000000..81d42ca72b
--- /dev/null
+++ b/comm/mail/base/content/widgets/tree-listbox.js
@@ -0,0 +1,914 @@
+/* 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/. */
+
+{
+ // Animation variables for expanding and collapsing child lists.
+ const ANIMATION_DURATION_MS = 200;
+ const ANIMATION_EASING = "ease";
+ let reducedMotionMedia = matchMedia("(prefers-reduced-motion)");
+
+ /**
+ * Provides keyboard and mouse interaction to a (possibly nested) list.
+ * It is intended for lists with a small number (up to 1000?) of items.
+ * Only one item can be selected at a time. Maintenance of the items in the
+ * list is not managed here. Styling of the list is not managed here.
+ *
+ * The following class names apply to list items:
+ * - selected: Indicates the currently selected list item.
+ * - children: If the list item has descendants.
+ * - collapsed: If the list item's descendants are hidden.
+ *
+ * List items can provide their own twisty element, which will operate when
+ * clicked on if given the class name "twisty".
+ *
+ * This class fires "collapsed", "expanded" and "select" events.
+ */
+ let TreeListboxMixin = Base =>
+ class extends Base {
+ /**
+ * The selected and focused item, or null if there is none.
+ *
+ * @type {?HTMLLIElement}
+ */
+ _selectedRow = null;
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+
+ this.setAttribute("is", "tree-listbox");
+ switch (this.getAttribute("role")) {
+ case "tree":
+ this.isTree = true;
+ break;
+ case "listbox":
+ this.isTree = false;
+ break;
+ default:
+ throw new RangeError(
+ `Unsupported role ${this.getAttribute("role")}`
+ );
+ }
+ this.tabIndex = 0;
+
+ this.domChanged();
+ this._initRows();
+ let rows = this.rows;
+ if (!this.selectedRow && rows.length) {
+ // TODO: This should only really happen on "focus".
+ this.selectedRow = rows[0];
+ }
+
+ this.addEventListener("click", this);
+ this.addEventListener("keydown", this);
+ this._mutationObserver.observe(this, {
+ subtree: true,
+ childList: true,
+ });
+ }
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "click":
+ this._onClick(event);
+ break;
+ case "keydown":
+ this._onKeyDown(event);
+ break;
+ }
+ }
+
+ _onClick(event) {
+ if (event.button !== 0) {
+ return;
+ }
+
+ let row = event.target.closest("li:not(.unselectable)");
+ if (!row) {
+ return;
+ }
+
+ if (
+ row.classList.contains("children") &&
+ (event.target.closest(".twisty") || event.detail == 2)
+ ) {
+ if (row.classList.contains("collapsed")) {
+ this.expandRow(row);
+ } else {
+ this.collapseRow(row);
+ }
+ return;
+ }
+
+ this.selectedRow = row;
+ if (document.activeElement != this) {
+ // Overflowing elements with tabindex=-1 steal focus. Grab it back.
+ this.focus();
+ }
+ }
+
+ _onKeyDown(event) {
+ if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) {
+ return;
+ }
+
+ switch (event.key) {
+ case "ArrowUp":
+ this.selectedIndex = this._clampIndex(this.selectedIndex - 1);
+ break;
+ case "ArrowDown":
+ this.selectedIndex = this._clampIndex(this.selectedIndex + 1);
+ break;
+ case "Home":
+ this.selectedIndex = 0;
+ break;
+ case "End":
+ this.selectedIndex = this.rowCount - 1;
+ break;
+ case "PageUp": {
+ if (!this.selectedRow) {
+ break;
+ }
+ // Get the top of the selected row, and remove the page height.
+ let selectedBox = this.selectedRow.getBoundingClientRect();
+ let y = selectedBox.top - this.clientHeight;
+
+ // Find the last row below there.
+ let rows = this.rows;
+ let i = this.selectedIndex - 1;
+ while (i > 0 && rows[i].getBoundingClientRect().top >= y) {
+ i--;
+ }
+ this.selectedIndex = i;
+ break;
+ }
+ case "PageDown": {
+ if (!this.selectedRow) {
+ break;
+ }
+ // Get the top of the selected row, and add the page height.
+ let selectedBox = this.selectedRow.getBoundingClientRect();
+ let y = selectedBox.top + this.clientHeight;
+
+ // Find the last row below there.
+ let rows = this.rows;
+ let i = rows.length - 1;
+ while (
+ i > this.selectedIndex &&
+ rows[i].getBoundingClientRect().top >= y
+ ) {
+ i--;
+ }
+ this.selectedIndex = i;
+ break;
+ }
+ case "ArrowLeft":
+ case "ArrowRight": {
+ let selected = this.selectedRow;
+ if (!selected) {
+ break;
+ }
+
+ let isArrowRight = event.key == "ArrowRight";
+ let isRTL = this.matches(":dir(rtl)");
+ if (isArrowRight == isRTL) {
+ let parent = selected.parentNode.closest(
+ ".children:not(.unselectable)"
+ );
+ if (
+ parent &&
+ (!selected.classList.contains("children") ||
+ selected.classList.contains("collapsed"))
+ ) {
+ this.selectedRow = parent;
+ break;
+ }
+ if (selected.classList.contains("children")) {
+ this.collapseRow(selected);
+ }
+ } else if (selected.classList.contains("children")) {
+ if (selected.classList.contains("collapsed")) {
+ this.expandRow(selected);
+ } else {
+ this.selectedRow = selected.querySelector("li");
+ }
+ }
+ break;
+ }
+ case "Enter": {
+ const selected = this.selectedRow;
+ if (!selected?.classList.contains("children")) {
+ return;
+ }
+ if (selected.classList.contains("collapsed")) {
+ this.expandRow(selected);
+ } else {
+ this.collapseRow(selected);
+ }
+ break;
+ }
+ default:
+ return;
+ }
+
+ event.preventDefault();
+ }
+
+ /**
+ * Data for the rows in the DOM.
+ *
+ * @typedef {object} TreeRowData
+ * @property {HTMLLIElement} row - The row item.
+ * @property {HTMLLIElement[]} ancestors - The ancestors of the row,
+ * ordered closest to furthest away.
+ */
+
+ /**
+ * Data for all items beneath this node, including collapsed items,
+ * ordered as they are in the DOM.
+ *
+ * @type {TreeRowData[]}
+ */
+ _rowsData = [];
+
+ /**
+ * Call whenever the tree nodes or ordering changes. This should only be
+ * called externally if the mutation observer has been dis-connected and
+ * re-connected.
+ */
+ domChanged() {
+ this._rowsData = Array.from(this.querySelectorAll("li"), row => {
+ let ancestors = [];
+ for (
+ let parentRow = row.parentNode.closest("li");
+ this.contains(parentRow);
+ parentRow = parentRow.parentNode.closest("li")
+ ) {
+ ancestors.push(parentRow);
+ }
+ return { row, ancestors };
+ });
+ }
+
+ _mutationObserver = new MutationObserver(mutations => {
+ for (let mutation of mutations) {
+ for (let node of mutation.addedNodes) {
+ if (node.nodeType != Node.ELEMENT_NODE || !node.matches("li")) {
+ continue;
+ }
+ // No item can already be selected on addition.
+ node.classList.remove("selected");
+ }
+ }
+ let oldRowsData = this._rowsData;
+ this.domChanged();
+ this._initRows();
+ let newRows = this.rows;
+ if (!newRows.length) {
+ this.selectedRow = null;
+ return;
+ }
+ if (!this.selectedRow) {
+ // TODO: This should only really happen on "focus".
+ this.selectedRow = newRows[0];
+ return;
+ }
+ if (newRows.includes(this.selectedRow)) {
+ // Selected row is still visible.
+ return;
+ }
+ let oldSelectedIndex = oldRowsData.findIndex(
+ entry => entry.row == this.selectedRow
+ );
+ if (oldSelectedIndex < 0) {
+ // Unexpected, the selectedRow was not in our _rowsData list.
+ this.selectedRow = newRows[0];
+ return;
+ }
+ // Find the closest ancestor that is still shown.
+ let existingAncestor = oldRowsData[oldSelectedIndex].ancestors.find(
+ row => newRows.includes(row)
+ );
+ if (existingAncestor) {
+ // We search as if the existingAncestor is the full list. This keeps
+ // the selection within the ancestor, or moves it to the ancestor if
+ // no child is found.
+ // NOTE: Includes existingAncestor itself, so should be non-empty.
+ newRows = newRows.filter(row => existingAncestor.contains(row));
+ }
+ // We have lost the selectedRow, so we select a new row. We want to try
+ // and find the element that exists both in the new rows and in the old
+ // rows, that directly preceded the previously selected row. We then
+ // want to select the next visible row that follows this found element
+ // in the new rows.
+ // If rows were replaced with new rows, this will select the first of
+ // the new rows.
+ // If rows were simply removed, this will select the next row that was
+ // not removed.
+ let beforeIndex = -1;
+ for (let i = oldSelectedIndex; i >= 0; i--) {
+ beforeIndex = this._rowsData.findIndex(
+ entry => entry.row == oldRowsData[i].row
+ );
+ if (beforeIndex >= 0) {
+ break;
+ }
+ }
+ // Start from just after the found item, or 0 if none were found
+ // (beforeIndex == -1), find the next visible item. Otherwise we default
+ // to selecting the last row.
+ let selectRow = newRows[newRows.length - 1];
+ for (let i = beforeIndex + 1; i < this._rowsData.length; i++) {
+ if (newRows.includes(this._rowsData[i].row)) {
+ selectRow = this._rowsData[i].row;
+ break;
+ }
+ }
+ this.selectedRow = selectRow;
+ });
+
+ /**
+ * Set the role attribute and classes for all descendants of the widget.
+ */
+ _initRows() {
+ let descendantItems = this.querySelectorAll("li");
+ let descendantLists = this.querySelectorAll("ol, ul");
+
+ for (let i = 0; i < descendantItems.length; i++) {
+ let row = descendantItems[i];
+ row.setAttribute("role", this.isTree ? "treeitem" : "option");
+ if (
+ i + 1 < descendantItems.length &&
+ row.contains(descendantItems[i + 1])
+ ) {
+ row.classList.add("children");
+ if (this.isTree) {
+ row.setAttribute(
+ "aria-expanded",
+ !row.classList.contains("collapsed")
+ );
+ }
+ } else {
+ row.classList.remove("children");
+ row.classList.remove("collapsed");
+ row.removeAttribute("aria-expanded");
+ }
+ row.setAttribute("aria-selected", row.classList.contains("selected"));
+ }
+
+ if (this.isTree) {
+ for (let list of descendantLists) {
+ list.setAttribute("role", "group");
+ }
+ }
+
+ for (let childList of this.querySelectorAll(
+ "li.collapsed > :is(ol, ul)"
+ )) {
+ childList.style.height = "0";
+ }
+ }
+
+ /**
+ * Every visible row. Rows with collapsed ancestors are not included.
+ *
+ * @type {HTMLLIElement[]}
+ */
+ get rows() {
+ return [...this.querySelectorAll("li:not(.unselectable)")].filter(
+ row => {
+ let collapsed = row.parentNode.closest("li.collapsed");
+ if (collapsed && this.contains(collapsed)) {
+ return false;
+ }
+ return true;
+ }
+ );
+ }
+
+ /**
+ * The number of visible rows.
+ *
+ * @type {integer}
+ */
+ get rowCount() {
+ return this.rows.length;
+ }
+
+ /**
+ * Clamps `index` to a value between 0 and `rowCount - 1`.
+ *
+ * @param {integer} index
+ * @returns {integer}
+ */
+ _clampIndex(index) {
+ if (index >= this.rowCount) {
+ return this.rowCount - 1;
+ }
+ if (index < 0) {
+ return 0;
+ }
+ return index;
+ }
+
+ /**
+ * Ensures that the row at `index` is on the screen.
+ *
+ * @param {integer} index
+ */
+ scrollToIndex(index) {
+ this.getRowAtIndex(index)?.scrollIntoView({ block: "nearest" });
+ }
+
+ /**
+ * Returns the row element at `index` or null if `index` is out of range.
+ *
+ * @param {integer} index
+ * @returns {HTMLLIElement?}
+ */
+ getRowAtIndex(index) {
+ return this.rows[index];
+ }
+
+ /**
+ * The index of the selected row. If there are no rows, the value is -1.
+ * Otherwise, should always have a value between 0 and `rowCount - 1`.
+ * It is set to 0 in `connectedCallback` if there are rows.
+ *
+ * @type {integer}
+ */
+ get selectedIndex() {
+ return this.rows.findIndex(row => row == this.selectedRow);
+ }
+
+ set selectedIndex(index) {
+ index = this._clampIndex(index);
+ this.selectedRow = this.getRowAtIndex(index);
+ }
+
+ /**
+ * The selected and focused item, or null if there is none.
+ *
+ * @type {?HTMLLIElement}
+ */
+ get selectedRow() {
+ return this._selectedRow;
+ }
+
+ set selectedRow(row) {
+ if (row == this._selectedRow) {
+ return;
+ }
+
+ if (this._selectedRow) {
+ this._selectedRow.classList.remove("selected");
+ this._selectedRow.setAttribute("aria-selected", "false");
+ }
+
+ this._selectedRow = row ?? null;
+ if (row) {
+ row.classList.add("selected");
+ row.setAttribute("aria-selected", "true");
+ this.setAttribute("aria-activedescendant", row.id);
+ row.firstElementChild.scrollIntoView({ block: "nearest" });
+ } else {
+ this.removeAttribute("aria-activedescendant");
+ }
+
+ this.dispatchEvent(new CustomEvent("select"));
+ }
+
+ /**
+ * Collapses the row at `index` if it can be collapsed. If the selected
+ * row is a descendant of the collapsing row, selection is moved to the
+ * collapsing row.
+ *
+ * @param {integer} index
+ */
+ collapseRowAtIndex(index) {
+ this.collapseRow(this.getRowAtIndex(index));
+ }
+
+ /**
+ * Expands the row at `index` if it can be expanded.
+ *
+ * @param {integer} index
+ */
+ expandRowAtIndex(index) {
+ this.expandRow(this.getRowAtIndex(index));
+ }
+
+ /**
+ * Collapses the row if it can be collapsed. If the selected row is a
+ * descendant of the collapsing row, selection is moved to the collapsing
+ * row.
+ *
+ * @param {HTMLLIElement} row - The row to collapse.
+ */
+ collapseRow(row) {
+ if (
+ row.classList.contains("children") &&
+ !row.classList.contains("collapsed")
+ ) {
+ if (row.contains(this.selectedRow)) {
+ this.selectedRow = row;
+ }
+ row.classList.add("collapsed");
+ if (this.isTree) {
+ row.setAttribute("aria-expanded", "false");
+ }
+ row.dispatchEvent(new CustomEvent("collapsed", { bubbles: true }));
+ this._animateCollapseRow(row);
+ }
+ }
+
+ /**
+ * Expands the row if it can be expanded.
+ *
+ * @param {HTMLLIElement} row - The row to expand.
+ */
+ expandRow(row) {
+ if (
+ row.classList.contains("children") &&
+ row.classList.contains("collapsed")
+ ) {
+ row.classList.remove("collapsed");
+ if (this.isTree) {
+ row.setAttribute("aria-expanded", "true");
+ }
+ row.dispatchEvent(new CustomEvent("expanded", { bubbles: true }));
+ this._animateExpandRow(row);
+ }
+ }
+
+ /**
+ * Animate the collapsing of a row containing child items.
+ *
+ * @param {HTMLLIElement} row - The parent row element.
+ */
+ _animateCollapseRow(row) {
+ let childList = row.querySelector("ol, ul");
+
+ if (reducedMotionMedia.matches) {
+ if (childList) {
+ childList.style.height = "0";
+ }
+ return;
+ }
+
+ let childListHeight = childList.scrollHeight;
+
+ let animation = childList.animate(
+ [{ height: `${childListHeight}px` }, { height: "0" }],
+ {
+ duration: ANIMATION_DURATION_MS,
+ easing: ANIMATION_EASING,
+ fill: "both",
+ }
+ );
+ animation.onfinish = () => {
+ childList.style.height = "0";
+ animation.cancel();
+ };
+ }
+
+ /**
+ * Animate the revealing of a row containing child items.
+ *
+ * @param {HTMLLIElement} row - The parent row element.
+ */
+ _animateExpandRow(row) {
+ let childList = row.querySelector("ol, ul");
+
+ if (reducedMotionMedia.matches) {
+ if (childList) {
+ childList.style.height = null;
+ }
+ return;
+ }
+
+ let childListHeight = childList.scrollHeight;
+
+ let animation = childList.animate(
+ [{ height: "0" }, { height: `${childListHeight}px` }],
+ {
+ duration: ANIMATION_DURATION_MS,
+ easing: ANIMATION_EASING,
+ fill: "both",
+ }
+ );
+ animation.onfinish = () => {
+ childList.style.height = null;
+ animation.cancel();
+ };
+ }
+ };
+
+ /**
+ * An unordered list with the functionality of TreeListboxMixin.
+ */
+ class TreeListbox extends TreeListboxMixin(HTMLUListElement) {}
+ customElements.define("tree-listbox", TreeListbox, { extends: "ul" });
+
+ /**
+ * An ordered list with the functionality of TreeListboxMixin, plus the
+ * ability to re-order the top-level list by drag-and-drop/Alt+Up/Alt+Down.
+ *
+ * This class fires an "ordered" event when the list is re-ordered.
+ *
+ * @note All children of this element should be HTML. If there are XUL
+ * elements, you're gonna have a bad time.
+ */
+ class OrderableTreeListbox extends TreeListboxMixin(HTMLOListElement) {
+ connectedCallback() {
+ super.connectedCallback();
+ this.setAttribute("is", "orderable-tree-listbox");
+
+ this.addEventListener("dragstart", this);
+ window.addEventListener("dragover", this);
+ window.addEventListener("drop", this);
+ window.addEventListener("dragend", this);
+ }
+
+ handleEvent(event) {
+ super.handleEvent(event);
+
+ switch (event.type) {
+ case "dragstart":
+ this._onDragStart(event);
+ break;
+ case "dragover":
+ this._onDragOver(event);
+ break;
+ case "drop":
+ this._onDrop(event);
+ break;
+ case "dragend":
+ this._onDragEnd(event);
+ break;
+ }
+ }
+
+ /**
+ * An array of all top-level rows that can be reordered. Override this
+ * getter to prevent reordering of one or more rows.
+ *
+ * @note So far this has only been used to prevent the last row being
+ * moved. Any other use is untested. It likely also works for rows at
+ * the top of the list.
+ *
+ * @returns {HTMLLIElement[]}
+ */
+ get _orderableChildren() {
+ return [...this.children];
+ }
+
+ _onKeyDown(event) {
+ super._onKeyDown(event);
+
+ if (
+ !event.altKey ||
+ event.ctrlKey ||
+ event.metaKey ||
+ event.shiftKey ||
+ !["ArrowUp", "ArrowDown"].includes(event.key)
+ ) {
+ return;
+ }
+
+ let row = this.selectedRow;
+ if (!row || row.parentElement != this) {
+ return;
+ }
+
+ let otherRow;
+ if (event.key == "ArrowUp") {
+ otherRow = row.previousElementSibling;
+ } else {
+ otherRow = row.nextElementSibling;
+ }
+ if (!otherRow) {
+ return;
+ }
+
+ // Check we can move these rows.
+ let orderable = this._orderableChildren;
+ if (!orderable.includes(row) || !orderable.includes(otherRow)) {
+ return;
+ }
+
+ let reducedMotion = reducedMotionMedia.matches;
+
+ this.scrollToIndex(this.rows.indexOf(otherRow));
+
+ // Temporarily disconnect the mutation observer to stop it changing things.
+ this._mutationObserver.disconnect();
+ if (event.key == "ArrowUp") {
+ if (!reducedMotion) {
+ let { top: otherTop } = otherRow.getBoundingClientRect();
+ let { top: rowTop, height: rowHeight } = row.getBoundingClientRect();
+ OrderableTreeListbox._animateTranslation(otherRow, 0 - rowHeight);
+ OrderableTreeListbox._animateTranslation(row, rowTop - otherTop);
+ }
+ this.insertBefore(row, otherRow);
+ } else {
+ if (!reducedMotion) {
+ let { top: otherTop, height: otherHeight } =
+ otherRow.getBoundingClientRect();
+ let { top: rowTop, height: rowHeight } = row.getBoundingClientRect();
+ OrderableTreeListbox._animateTranslation(otherRow, rowHeight);
+ OrderableTreeListbox._animateTranslation(
+ row,
+ rowTop - otherTop - otherHeight + rowHeight
+ );
+ }
+ this.insertBefore(row, otherRow.nextElementSibling);
+ }
+ this._mutationObserver.observe(this, { subtree: true, childList: true });
+
+ // Rows moved.
+ this.domChanged();
+ this.dispatchEvent(new CustomEvent("ordered", { detail: row }));
+ }
+
+ _onDragStart(event) {
+ if (!event.target.closest("[draggable]")) {
+ // This shouldn't be necessary, but is?!
+ event.preventDefault();
+ return;
+ }
+
+ let orderable = this._orderableChildren;
+ if (orderable.length < 2) {
+ return;
+ }
+
+ for (let topLevelRow of orderable) {
+ if (topLevelRow.contains(event.target)) {
+ let rect = topLevelRow.getBoundingClientRect();
+ this._dragInfo = {
+ row: topLevelRow,
+ // How far can we move `topLevelRow` upwards?
+ min: orderable[0].getBoundingClientRect().top - rect.top,
+ // How far can we move `topLevelRow` downwards?
+ max:
+ orderable[orderable.length - 1].getBoundingClientRect().bottom -
+ rect.bottom,
+ // Where is the pointer relative to the scroll box of the list?
+ // (Not quite, the Y position of `this` is not removed, but we'd
+ // only have to do the same where this value is used.)
+ scrollY: event.clientY + this.scrollTop,
+ // Where is the pointer relative to `topLevelRow`?
+ offsetY: event.clientY - rect.top,
+ };
+ topLevelRow.classList.add("dragging");
+
+ // Prevent `topLevelRow` being used as the drag image. We don't
+ // really want any drag image, but there's no way to not have one.
+ event.dataTransfer.setDragImage(document.createElement("img"), 0, 0);
+ return;
+ }
+ }
+ }
+
+ _onDragOver(event) {
+ if (!this._dragInfo) {
+ return;
+ }
+
+ let { row, min, max, scrollY, offsetY } = this._dragInfo;
+
+ // Move `row` with the mouse pointer.
+ let dragY = Math.min(
+ max,
+ Math.max(min, event.clientY + this.scrollTop - scrollY)
+ );
+ row.style.transform = `translateY(${dragY}px)`;
+
+ let thisRect = this.getBoundingClientRect();
+ // How much space is there above `row`? We'll see how many rows fit in
+ // the space and put `row` in after them.
+ let spaceAbove = Math.max(
+ 0,
+ event.clientY + this.scrollTop - offsetY - thisRect.top
+ );
+ // The height of all rows seen in the loop so far.
+ let totalHeight = 0;
+ // If we've looped past the row being dragged.
+ let afterDraggedRow = false;
+ // The row before where a drop would take place. If null, drop would
+ // happen at the start of the list.
+ let targetRow = null;
+
+ for (let topLevelRow of this._orderableChildren) {
+ if (topLevelRow == row) {
+ afterDraggedRow = true;
+ continue;
+ }
+
+ let rect = topLevelRow.getBoundingClientRect();
+ let enoughSpace = spaceAbove > totalHeight + rect.height / 2;
+
+ let multiplier = 0;
+ if (enoughSpace) {
+ if (afterDraggedRow) {
+ multiplier = -1;
+ }
+ targetRow = topLevelRow;
+ } else if (!afterDraggedRow) {
+ multiplier = 1;
+ }
+ OrderableTreeListbox._transitionTranslation(
+ topLevelRow,
+ multiplier * row.clientHeight
+ );
+
+ totalHeight += rect.height;
+ }
+
+ this._dragInfo.dropTarget = targetRow;
+ event.preventDefault();
+ }
+
+ _onDrop(event) {
+ if (!this._dragInfo) {
+ return;
+ }
+
+ let { row, dropTarget } = this._dragInfo;
+
+ let targetRow;
+ if (dropTarget) {
+ targetRow = dropTarget.nextElementSibling;
+ } else {
+ targetRow = this.firstElementChild;
+ }
+
+ event.preventDefault();
+ // Temporarily disconnect the mutation observer to stop it changing things.
+ this._mutationObserver.disconnect();
+ this.insertBefore(row, targetRow);
+ this._mutationObserver.observe(this, { subtree: true, childList: true });
+ // Rows moved.
+ this.domChanged();
+ this.dispatchEvent(new CustomEvent("ordered", { detail: row }));
+ }
+
+ _onDragEnd(event) {
+ if (!this._dragInfo) {
+ return;
+ }
+
+ this._dragInfo.row.classList.remove("dragging");
+ delete this._dragInfo;
+
+ for (let topLevelRow of this.children) {
+ topLevelRow.style.transition = null;
+ topLevelRow.style.transform = null;
+ }
+ }
+
+ /**
+ * Used to animate a real change in the order. The element is moved in the
+ * DOM, then the animation makes it appear to move from the original
+ * position to the new position
+ *
+ * @param {HTMLLIElement} element - The row to animate.
+ * @param {number} from - Original Y position of the element relative to
+ * its current position.
+ */
+ static _animateTranslation(element, from) {
+ let animation = element.animate(
+ [
+ { transform: `translateY(${from}px)` },
+ { transform: "translateY(0px)" },
+ ],
+ {
+ duration: ANIMATION_DURATION_MS,
+ fill: "both",
+ }
+ );
+ animation.onfinish = () => animation.cancel();
+ }
+
+ /**
+ * Used to simulate a change in the order. The element remains in the same
+ * DOM position.
+ *
+ * @param {HTMLLIElement} element - The row to animate.
+ * @param {number} to - The new Y position of the element after animation.
+ */
+ static _transitionTranslation(element, to) {
+ if (!reducedMotionMedia.matches) {
+ element.style.transition = `transform ${ANIMATION_DURATION_MS}ms`;
+ }
+ element.style.transform = to ? `translateY(${to}px)` : null;
+ }
+ }
+ customElements.define("orderable-tree-listbox", OrderableTreeListbox, {
+ extends: "ol",
+ });
+}
diff --git a/comm/mail/base/content/widgets/tree-selection.mjs b/comm/mail/base/content/widgets/tree-selection.mjs
new file mode 100644
index 0000000000..022af7316e
--- /dev/null
+++ b/comm/mail/base/content/widgets/tree-selection.mjs
@@ -0,0 +1,744 @@
+/* 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/. */
+
+/**
+ * This implementation attempts to mimic the behavior of nsTreeSelection. In
+ * a few cases, this leads to potentially confusing actions. I attempt to note
+ * when we are doing this and why we do it.
+ *
+ * Unit test is in mail/base/test/unit/test_treeSelection.js
+ */
+export class TreeSelection {
+ QueryInterface = ChromeUtils.generateQI(["nsITreeSelection"]);
+
+ /**
+ * The current XULTreeElement, appropriately QueryInterfaced. May be null.
+ */
+ _tree;
+
+ /**
+ * Where the focus rectangle (that little dotted thing) shows up. Just
+ * because something is focused does not mean it is actually selected.
+ */
+ _currentIndex;
+ /**
+ * The view index where the shift is anchored when it is not (conceptually)
+ * the same as _currentIndex. This only happens when you perform a ranged
+ * selection. In that case, the start index of the ranged selection becomes
+ * the shift pivot (and the _currentIndex becomes the end of the ranged
+ * selection.)
+ * It gets cleared whenever the selection changes and it's not the result of
+ * a call to rangedSelect.
+ */
+ _shiftSelectPivot;
+ /**
+ * A list of [lowIndexInclusive, highIndexInclusive] non-overlapping,
+ * non-adjacent 'tuples' sort in ascending order.
+ */
+ _ranges;
+ /**
+ * The number of currently selected rows.
+ */
+ _count;
+
+ // In the case of the stand-alone message window, there's no tree, but
+ // there's a view.
+ _view;
+
+ /**
+ * A set of indices we think is invalid.
+ */
+ _invalidIndices;
+
+ constructor(tree) {
+ this._tree = tree;
+
+ this._currentIndex = null;
+ this._shiftSelectPivot = null;
+ this._ranges = [];
+ this._count = 0;
+ this._invalidIndices = new Set();
+
+ this._selectEventsSuppressed = false;
+ }
+
+ /**
+ * Mark the currently selected rows as invalid.
+ */
+ _invalidateSelection() {
+ for (let [low, high] of this._ranges) {
+ for (let i = low; i <= high; i++) {
+ this._invalidIndices.add(i);
+ }
+ }
+ }
+
+ /**
+ * Call `invalidateRow` on the tree for each row we think is invalid.
+ */
+ _doInvalidateRows() {
+ if (this.selectEventsSuppressed) {
+ return;
+ }
+ if (this._tree) {
+ for (let i of this._invalidIndices) {
+ this._tree.invalidateRow(i);
+ }
+ }
+ this._invalidIndices.clear();
+ }
+
+ /**
+ * Call `invalidateRange` on the tree.
+ *
+ * @param {number} startIndex - The first index to invalidate.
+ * @param {number?} endIndex - The last index to invalidate. If not given,
+ * defaults to the index of the last row.
+ */
+ _doInvalidateRange(startIndex, endIndex) {
+ let noEndIndex = endIndex === undefined;
+ if (noEndIndex) {
+ if (!this._view || this.view.rowCount == 0) {
+ this._doInvalidateAll();
+ return;
+ }
+ endIndex = this._view.rowCount - 1;
+ }
+ if (this._tree) {
+ this._tree.invalidateRange(startIndex, endIndex);
+ }
+ for (let i of this._invalidIndices) {
+ if (i >= startIndex && (noEndIndex || i <= endIndex)) {
+ this._invalidIndices.delete(i);
+ }
+ }
+ }
+
+ /**
+ * Call `invalidate` on the tree.
+ */
+ _doInvalidateAll() {
+ if (this._tree) {
+ this._tree.invalidate();
+ }
+ this._invalidIndices.clear();
+ }
+
+ get tree() {
+ return this._tree;
+ }
+ set tree(tree) {
+ this._tree = tree;
+ }
+
+ get view() {
+ return this._view;
+ }
+ set view(view) {
+ this._view = view;
+ }
+ /**
+ * Although the nsITreeSelection documentation doesn't say, what this method
+ * is supposed to do is check if the seltype attribute on the XUL tree is any
+ * of the following: "single" (only a single row may be selected at a time,
+ * "cell" (a single cell may be selected), or "text" (the row gets selected
+ * but only the primary column shows up as selected.)
+ *
+ * @returns false because we don't support single-selection.
+ */
+ get single() {
+ return false;
+ }
+
+ _updateCount() {
+ this._count = 0;
+ for (let [low, high] of this._ranges) {
+ this._count += high - low + 1;
+ }
+ }
+
+ get count() {
+ return this._count;
+ }
+
+ isSelected(viewIndex) {
+ for (let [low, high] of this._ranges) {
+ if (viewIndex >= low && viewIndex <= high) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Select the given row. It does nothing if that row was already selected.
+ */
+ select(viewIndex) {
+ this._invalidateSelection();
+ // current index will provide our effective shift pivot
+ this._shiftSelectPivot = null;
+ this._currentIndex = viewIndex != -1 ? viewIndex : null;
+
+ if (this._count == 1 && this._ranges[0][0] == viewIndex) {
+ return;
+ }
+
+ if (viewIndex >= 0) {
+ this._count = 1;
+ this._ranges = [[viewIndex, viewIndex]];
+ this._invalidIndices.add(viewIndex);
+ } else {
+ this._count = 0;
+ this._ranges = [];
+ }
+
+ this._doInvalidateRows();
+ this._fireSelectionChanged();
+ }
+
+ timedSelect(index, delay) {
+ throw new Error("We do not implement timed selection.");
+ }
+
+ toggleSelect(index) {
+ this._currentIndex = index;
+ // If nothing's selected, select index
+ if (this._count == 0) {
+ this._count = 1;
+ this._ranges = [[index, index]];
+ } else {
+ let added = false;
+ for (let [iTupe, [low, high]] of this._ranges.entries()) {
+ // below the range? add it to the existing range or create a new one
+ if (index < low) {
+ this._count++;
+ // is it just below an existing range? (range fusion only happens in the
+ // high case, not here.)
+ if (index == low - 1) {
+ this._ranges[iTupe][0] = index;
+ added = true;
+ break;
+ }
+ // then it gets its own range
+ this._ranges.splice(iTupe, 0, [index, index]);
+ added = true;
+ break;
+ }
+ // in the range? will need to either nuke, shrink, or split the range to
+ // remove it
+ if (index >= low && index <= high) {
+ this._count--;
+ if (index == low && index == high) {
+ // nuke
+ this._ranges.splice(iTupe, 1);
+ } else if (index == low) {
+ // lower shrink
+ this._ranges[iTupe][0] = index + 1;
+ } else if (index == high) {
+ // upper shrink
+ this._ranges[iTupe][1] = index - 1;
+ } else {
+ // split
+ this._ranges.splice(iTupe, 1, [low, index - 1], [index + 1, high]);
+ }
+ added = true;
+ break;
+ }
+ // just above the range? fuse into the range, and possibly the next
+ // range up.
+ if (index == high + 1) {
+ this._count++;
+ // see if there is another range and there was just a gap of one between
+ // the two ranges.
+ if (
+ iTupe + 1 < this._ranges.length &&
+ this._ranges[iTupe + 1][0] == index + 1
+ ) {
+ // yes, merge the ranges
+ this._ranges.splice(iTupe, 2, [low, this._ranges[iTupe + 1][1]]);
+ added = true;
+ break;
+ }
+ // nope, no merge required, just update the range
+ this._ranges[iTupe][1] = index;
+ added = true;
+ break;
+ }
+ // otherwise we need to keep going
+ }
+
+ if (!added) {
+ this._count++;
+ this._ranges.push([index, index]);
+ }
+ }
+
+ this._invalidIndices.add(index);
+ this._doInvalidateRows();
+ this._fireSelectionChanged();
+ }
+
+ /**
+ * @param rangeStart If omitted, it implies a shift-selection is happening,
+ * in which case we use _shiftSelectPivot as the start if we have it,
+ * _currentIndex if we don't, and if we somehow didn't have a
+ * _currentIndex, we use the range end.
+ * @param rangeEnd Just the inclusive end of the range.
+ * @param augment Does this set a new selection or should it be merged with
+ * the existing selection?
+ */
+ rangedSelect(rangeStart, rangeEnd, augment) {
+ if (rangeStart == -1) {
+ if (this._shiftSelectPivot != null) {
+ rangeStart = this._shiftSelectPivot;
+ } else if (this._currentIndex != null) {
+ rangeStart = this._currentIndex;
+ } else {
+ rangeStart = rangeEnd;
+ }
+ }
+
+ this._shiftSelectPivot = rangeStart;
+ this._currentIndex = rangeEnd;
+
+ // enforce our ordering constraint for our ranges
+ if (rangeStart > rangeEnd) {
+ [rangeStart, rangeEnd] = [rangeEnd, rangeStart];
+ }
+
+ // if we're not augmenting, then this is really easy.
+ if (!augment) {
+ this._invalidateSelection();
+
+ this._count = rangeEnd - rangeStart + 1;
+ this._ranges = [[rangeStart, rangeEnd]];
+
+ for (let i = rangeStart; i <= rangeEnd; i++) {
+ this._invalidIndices.add(i);
+ }
+
+ this._doInvalidateRows();
+ this._fireSelectionChanged();
+ return;
+ }
+
+ // Iterate over our existing set of ranges, finding the 'range' of ranges
+ // that our new range overlaps or simply obviates.
+ // Overlap variables track blocks we need to keep some part of, Nuke
+ // variables are for blocks that get spliced out. For our purposes, all
+ // overlap blocks are also nuke blocks.
+ let lowOverlap, lowNuke, highNuke, highOverlap;
+ // in case there is no overlap, also figure an insertionPoint
+ let insertionPoint = this._ranges.length; // default to the end
+ for (let [iTupe, [low, high]] of this._ranges.entries()) {
+ // If it's completely include the range, it should be nuked
+ if (rangeStart <= low && rangeEnd >= high) {
+ if (lowNuke == null) {
+ // only the first one we see is the low one
+ lowNuke = iTupe;
+ }
+ highNuke = iTupe;
+ }
+ // If our new range start is inside a range or is adjacent, it's overlap
+ if (
+ rangeStart >= low - 1 &&
+ rangeStart <= high + 1 &&
+ lowOverlap == null
+ ) {
+ lowOverlap = lowNuke = highNuke = iTupe;
+ }
+ // If our new range ends inside a range or is adjacent, it's overlap
+ if (rangeEnd >= low - 1 && rangeEnd <= high + 1) {
+ highOverlap = highNuke = iTupe;
+ if (lowNuke == null) {
+ lowNuke = iTupe;
+ }
+ }
+
+ // we're done when no more overlap is possible
+ if (rangeEnd < low) {
+ insertionPoint = iTupe;
+ break;
+ }
+ }
+
+ if (lowOverlap != null) {
+ rangeStart = Math.min(rangeStart, this._ranges[lowOverlap][0]);
+ }
+ if (highOverlap != null) {
+ rangeEnd = Math.max(rangeEnd, this._ranges[highOverlap][1]);
+ }
+ if (lowNuke != null) {
+ this._ranges.splice(lowNuke, highNuke - lowNuke + 1, [
+ rangeStart,
+ rangeEnd,
+ ]);
+ } else {
+ this._ranges.splice(insertionPoint, 0, [rangeStart, rangeEnd]);
+ }
+ for (let i = rangeStart; i <= rangeEnd; i++) {
+ this._invalidIndices.add(i);
+ }
+
+ this._updateCount();
+ this._doInvalidateRows();
+ this._fireSelectionChanged();
+ }
+
+ /**
+ * This is basically RangedSelect but without insertion of a new range and we
+ * don't need to worry about adjacency.
+ * Oddly, nsTreeSelection doesn't fire a selection changed event here...
+ */
+ clearRange(rangeStart, rangeEnd) {
+ // Iterate over our existing set of ranges, finding the 'range' of ranges
+ // that our clear range overlaps or simply obviates.
+ // Overlap variables track blocks we need to keep some part of, Nuke
+ // variables are for blocks that get spliced out. For our purposes, all
+ // overlap blocks are also nuke blocks.
+ let lowOverlap, lowNuke, highNuke, highOverlap;
+ for (let [iTupe, [low, high]] of this._ranges.entries()) {
+ // If we completely include the range, it should be nuked
+ if (rangeStart <= low && rangeEnd >= high) {
+ if (lowNuke == null) {
+ // only the first one we see is the low one
+ lowNuke = iTupe;
+ }
+ highNuke = iTupe;
+ }
+ // If our new range start is inside a range, it's nuke and maybe overlap
+ if (rangeStart >= low && rangeStart <= high && lowNuke == null) {
+ lowNuke = highNuke = iTupe;
+ // it's only overlap if we don't match at the low end
+ if (rangeStart > low) {
+ lowOverlap = iTupe;
+ }
+ }
+ // If our new range ends inside a range, it's nuke and maybe overlap
+ if (rangeEnd >= low && rangeEnd <= high) {
+ highNuke = iTupe;
+ // it's only overlap if we don't match at the high end
+ if (rangeEnd < high) {
+ highOverlap = iTupe;
+ }
+ if (lowNuke == null) {
+ lowNuke = iTupe;
+ }
+ }
+
+ // we're done when no more overlap is possible
+ if (rangeEnd < low) {
+ break;
+ }
+ }
+ // nothing to do since there's nothing to nuke
+ if (lowNuke == null) {
+ return;
+ }
+ let args = [lowNuke, highNuke - lowNuke + 1];
+ if (lowOverlap != null) {
+ args.push([this._ranges[lowOverlap][0], rangeStart - 1]);
+ }
+ if (highOverlap != null) {
+ args.push([rangeEnd + 1, this._ranges[highOverlap][1]]);
+ }
+ this._ranges.splice.apply(this._ranges, args);
+
+ for (let i = rangeStart; i <= rangeEnd; i++) {
+ this._invalidIndices.add(i);
+ }
+
+ this._updateCount();
+ this._doInvalidateRows();
+ // note! nsTreeSelection doesn't fire a selection changed event, so neither
+ // do we, but it seems like we should
+ }
+
+ /**
+ * nsTreeSelection always fires a select notification when the range is
+ * cleared, even if there is no effective chance in selection.
+ */
+ clearSelection() {
+ this._invalidateSelection();
+ this._shiftSelectPivot = null;
+ this._count = 0;
+ this._ranges = [];
+
+ this._doInvalidateRows();
+ this._fireSelectionChanged();
+ }
+
+ /**
+ * Select all with no rows is a no-op, otherwise we select all and notify.
+ */
+ selectAll() {
+ if (!this._view) {
+ return;
+ }
+
+ let view = this._view;
+ let rowCount = view.rowCount;
+
+ // no-ops-ville
+ if (!rowCount) {
+ return;
+ }
+
+ this._count = rowCount;
+ this._ranges = [[0, rowCount - 1]];
+
+ this._doInvalidateAll();
+ this._fireSelectionChanged();
+ }
+
+ getRangeCount() {
+ return this._ranges.length;
+ }
+ getRangeAt(rangeIndex, minObj, maxObj) {
+ if (rangeIndex < 0 || rangeIndex >= this._ranges.length) {
+ throw new Error("Try a real range index next time.");
+ }
+ [minObj.value, maxObj.value] = this._ranges[rangeIndex];
+ }
+
+ /**
+ * Helper method to adjust points in the face of row additions/removal.
+ *
+ * @param point The point, null if there isn't one, or an index otherwise.
+ * @param deltaAt The row at which the change is happening.
+ * @param delta The number of rows added if positive, or the (negative)
+ * number of rows removed.
+ */
+ _adjustPoint(point, deltaAt, delta) {
+ // if there is no point, no change
+ if (point == null) {
+ return point;
+ }
+ // if the point is before the change, no change
+ if (point < deltaAt) {
+ return point;
+ }
+ // if it's a deletion and it includes the point, clear it
+ if (delta < 0 && point >= deltaAt && point + delta < deltaAt) {
+ return null;
+ }
+ // (else) the point is at/after the change, compensate
+ return point + delta;
+ }
+ /**
+ * Find the index of the range, if any, that contains the given index, and
+ * the index at which to insert a range if one does not exist.
+ *
+ * @returns A tuple containing: 1) the index if there is one, null otherwise,
+ * 2) the index at which to insert a range that would contain the point.
+ */
+ _findRangeContainingRow(index) {
+ for (let [iTupe, [low, high]] of this._ranges.entries()) {
+ if (index >= low && index <= high) {
+ return [iTupe, iTupe];
+ }
+ if (index < low) {
+ return [null, iTupe];
+ }
+ }
+ return [null, this._ranges.length];
+ }
+
+ /**
+ * When present, a list of calls made to adjustSelection. See
+ * |logAdjustSelectionForReplay| and |replayAdjustSelectionLog|.
+ */
+ _adjustSelectionLog = null;
+ /**
+ * Start logging calls to adjustSelection made against this instance. You
+ * would do this because you are replacing an existing selection object
+ * with this instance for the purposes of creating a transient selection.
+ * Of course, you want the original selection object to be up-to-date when
+ * you go to put it back, so then you can call replayAdjustSelectionLog
+ * with that selection object and everything will be peachy.
+ */
+ logAdjustSelectionForReplay() {
+ this._adjustSelectionLog = [];
+ }
+ /**
+ * Stop logging calls to adjustSelection and replay the existing log against
+ * selection.
+ *
+ * @param selection {nsITreeSelection}.
+ */
+ replayAdjustSelectionLog(selection) {
+ if (this._adjustSelectionLog.length) {
+ // Temporarily disable selection events because adjustSelection is going
+ // to generate an event each time otherwise, and better 1 event than
+ // many.
+ selection.selectEventsSuppressed = true;
+ for (let [index, count] of this._adjustSelectionLog) {
+ selection.adjustSelection(index, count);
+ }
+ selection.selectEventsSuppressed = false;
+ }
+ this._adjustSelectionLog = null;
+ }
+
+ adjustSelection(index, count) {
+ // nothing to do if there is no actual change
+ if (!count) {
+ return;
+ }
+
+ if (this._adjustSelectionLog) {
+ this._adjustSelectionLog.push([index, count]);
+ }
+
+ // adjust our points
+ this._shiftSelectPivot = this._adjustPoint(
+ this._shiftSelectPivot,
+ index,
+ count
+ );
+ this._currentIndex = this._adjustPoint(this._currentIndex, index, count);
+
+ // If we are adding rows, we want to split any range at index and then
+ // translate all of the ranges above that point up.
+ if (count > 0) {
+ let [iContain, iInsert] = this._findRangeContainingRow(index);
+ if (iContain != null) {
+ let [low, high] = this._ranges[iContain];
+ // if it is the low value, we just want to shift the range entirely, so
+ // do nothing (and keep iInsert pointing at it for translation)
+ // if it is not the low value, then there must be at least two values so
+ // we should split it and only translate the new/upper block
+ if (index != low) {
+ this._ranges.splice(iContain, 1, [low, index - 1], [index, high]);
+ iInsert++;
+ }
+ }
+ // now translate everything from iInsert on up
+ for (let iTrans = iInsert; iTrans < this._ranges.length; iTrans++) {
+ let [low, high] = this._ranges[iTrans];
+ this._ranges[iTrans] = [low + count, high + count];
+ }
+ // invalidate and fire selection change notice
+ this._doInvalidateRange(index);
+ this._fireSelectionChanged();
+ return;
+ }
+
+ // If we are removing rows, we are basically clearing the range that is
+ // getting deleted and translating everyone above the remaining point
+ // downwards. The one trick is we may have to merge the lowest translated
+ // block.
+ let saveSuppress = this.selectEventsSuppressed;
+ this.selectEventsSuppressed = true;
+ this.clearRange(index, index - count - 1);
+ // translate
+ let iTrans = this._findRangeContainingRow(index)[1];
+ for (; iTrans < this._ranges.length; iTrans++) {
+ let [low, high] = this._ranges[iTrans];
+ // for the first range, low may be below the index, in which case it
+ // should not get translated
+ this._ranges[iTrans] = [low >= index ? low + count : low, high + count];
+ }
+ // we may have to merge the lowest translated block because it may now be
+ // adjacent to the previous block
+ if (
+ iTrans > 0 &&
+ iTrans < this._ranges.length &&
+ this._ranges[iTrans - 1][1] == this._ranges[iTrans][0]
+ ) {
+ this._ranges[iTrans - 1][1] = this._ranges[iTrans][1];
+ this._ranges.splice(iTrans, 1);
+ }
+
+ this._doInvalidateRange(index);
+ this.selectEventsSuppressed = saveSuppress;
+ }
+
+ get selectEventsSuppressed() {
+ return this._selectEventsSuppressed;
+ }
+ /**
+ * Control whether selection events are suppressed. For consistency with
+ * nsTreeSelection, we always generate a selection event when a value of
+ * false is assigned, even if the value was already false.
+ */
+ set selectEventsSuppressed(suppress) {
+ if (this._selectEventsSuppressed == suppress) {
+ return;
+ }
+
+ this._selectEventsSuppressed = suppress;
+ if (!suppress) {
+ this._fireSelectionChanged();
+ }
+ }
+
+ /**
+ * Note that we bypass any XUL "onselect" handler that may exist and go
+ * straight to the view. If you have a tree, you shouldn't be using us,
+ * so this seems aboot right.
+ */
+ _fireSelectionChanged() {
+ // don't fire if we are suppressed; we will fire when un-suppressed
+ if (this.selectEventsSuppressed) {
+ return;
+ }
+ let view = this._tree?.view ?? this._view;
+
+ // We might not have a view if we're in the middle of setting up things
+ view?.selectionChanged();
+ }
+
+ get currentIndex() {
+ if (this._currentIndex == null) {
+ return -1;
+ }
+ return this._currentIndex;
+ }
+ /**
+ * Sets the current index. Other than updating the variable, this just
+ * invalidates the tree row if we have a tree.
+ * The real selection object would send a DOM event we don't care about.
+ */
+ set currentIndex(index) {
+ if (index == this.currentIndex) {
+ return;
+ }
+
+ this._invalidateSelection();
+ this._currentIndex = index != -1 ? index : null;
+ this._invalidIndices.add(index);
+ this._doInvalidateRows();
+ }
+
+ get shiftSelectPivot() {
+ return this._shiftSelectPivot != null ? this._shiftSelectPivot : -1;
+ }
+
+ /*
+ * Functions after this aren't part of the nsITreeSelection interface.
+ */
+
+ /**
+ * Duplicate this selection on another nsITreeSelection. This is useful
+ * when you would like to discard this selection for a real tree selection.
+ * We assume that both selections are for the same tree.
+ *
+ * @note We don't transfer the correct shiftSelectPivot over.
+ * @note This will fire a selectionChanged event on the tree view.
+ *
+ * @param selection an nsITreeSelection to duplicate this selection onto
+ */
+ duplicateSelection(selection) {
+ selection.selectEventsSuppressed = true;
+ selection.clearSelection();
+ for (let [iTupe, [low, high]] of this._ranges.entries()) {
+ selection.rangedSelect(low, high, iTupe > 0);
+ }
+
+ selection.currentIndex = this.currentIndex;
+ // This will fire a selectionChanged event
+ selection.selectEventsSuppressed = false;
+ }
+}
diff --git a/comm/mail/base/content/widgets/tree-view.mjs b/comm/mail/base/content/widgets/tree-view.mjs
new file mode 100644
index 0000000000..aef622fa27
--- /dev/null
+++ b/comm/mail/base/content/widgets/tree-view.mjs
@@ -0,0 +1,2633 @@
+/* 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 { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+import { TreeSelection } from "chrome://messenger/content/tree-selection.mjs";
+
+// Account for the mac OS accelerator key variation.
+// Use these strings to check keyboard event properties.
+const accelKeyName = AppConstants.platform == "macosx" ? "metaKey" : "ctrlKey";
+const otherKeyName = AppConstants.platform == "macosx" ? "ctrlKey" : "metaKey";
+
+const ANIMATION_DURATION_MS = 200;
+const reducedMotionMedia = matchMedia("(prefers-reduced-motion)");
+
+/**
+ * Main tree view container that takes care of generating the main scrollable
+ * DIV and the tree table.
+ */
+class TreeView extends HTMLElement {
+ static observedAttributes = ["rows"];
+
+ /**
+ * The number of rows on either side to keep of the visible area to keep in
+ * memory in order to avoid visible blank spaces while the user scrolls.
+ *
+ * This member is visible for testing and should not be used outside of this
+ * class in production code.
+ *
+ * @type {integer}
+ */
+ _toleranceSize = 0;
+
+ /**
+ * Set the size of the tolerance buffer based on the number of rows which can
+ * be visible at once.
+ */
+ #calculateToleranceBufferSize() {
+ this._toleranceSize = this.#calculateVisibleRowCount() * 2;
+ }
+
+ /**
+ * Index of the first row that exists in the DOM. Includes rows in the
+ * tolerance buffer if they have been added.
+ *
+ * @type {integer}
+ */
+ #firstBufferRowIndex = 0;
+
+ /**
+ * Index of the last row that exists in the DOM. Includes rows in the
+ * tolerance buffer if they have been added.
+ *
+ * @type {integer}
+ */
+ #lastBufferRowIndex = 0;
+
+ /**
+ * Index of the first visible row.
+ *
+ * @type {integer}
+ */
+ #firstVisibleRowIndex = 0;
+
+ /**
+ * Index of the last visible row.
+ *
+ * @type {integer}
+ */
+ #lastVisibleRowIndex = 0;
+
+ /**
+ * Row indices mapped to the row elements that exist in the DOM.
+ *
+ * @type {Map<integer, HTMLTableRowElement>}
+ */
+ _rows = new Map();
+
+ /**
+ * The current view.
+ *
+ * @type {nsITreeView}
+ */
+ _view = null;
+
+ /**
+ * The current selection.
+ *
+ * @type {nsITreeSelection}
+ */
+ _selection = null;
+
+ /**
+ * The function storing the timeout callback for the delayed select feature in
+ * order to clear it when not needed.
+ *
+ * @type {integer}
+ */
+ _selectTimeout = null;
+
+ /**
+ * A handle to the callback to fill the buffer when we aren't busy painting.
+ *
+ * @type {number}
+ */
+ #bufferFillIdleCallbackHandle = null;
+
+ /**
+ * The virtualized table containing our rows.
+ *
+ * @type {TreeViewTable}
+ */
+ table = null;
+
+ /**
+ * An event to fire to indicate the work of filling the buffer is complete.
+ * This will fire once both visible and tolerance rows are ready. It will also
+ * fire if no change to the buffer is required.
+ *
+ * This member is visible in order to provide a reliable indicator to tests
+ * that all expected rows should be in place. It should not be used in
+ * production code.
+ *
+ * @type {Event}
+ */
+ _rowBufferReadyEvent = null;
+
+ /**
+ * Fire the provided event, if any, in order to indicate that any necessary
+ * buffer modification work is complete, including if no work is necessary.
+ */
+ #dispatchRowBufferReadyEvent() {
+ // Don't fire if we're currently waiting on buffer fills; let the callback
+ // do that when it's finished.
+ if (this._rowBufferReadyEvent && !this.#bufferFillIdleCallbackHandle) {
+ this.dispatchEvent(this._rowBufferReadyEvent);
+ }
+ }
+
+ /**
+ * Determine the height of the visible row area, excluding any chrome which
+ * covers elements.
+ *
+ * WARNING: This may cause synchronous reflow if used after modifying the DOM.
+ *
+ * @returns {integer} - The height of the area into which visible rows are
+ * rendered.
+ */
+ #calculateVisibleHeight() {
+ // Account for the table header height in a sticky position above the body.
+ return this.clientHeight - this.table.header.clientHeight;
+ }
+
+ /**
+ * Determine how many rows are visible in the client presently.
+ *
+ * WARNING: This may cause synchronous reflow if used after modifying the DOM.
+ *
+ * @returns {integer} - The number of visible or partly-visible rows.
+ */
+ #calculateVisibleRowCount() {
+ return Math.ceil(
+ this.#calculateVisibleHeight() / this._rowElementClass.ROW_HEIGHT
+ );
+ }
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+
+ // Prevent this element from being part of the roving tab focus since we
+ // handle that independently for the TreeViewTableBody and we don't want any
+ // interference from this.
+ this.tabIndex = -1;
+ this.classList.add("tree-view-scrollable-container");
+
+ this.table = document.createElement("table", { is: "tree-view-table" });
+ this.appendChild(this.table);
+
+ this.placeholder = this.querySelector(`slot[name="placeholders"]`);
+
+ this.addEventListener("scroll", this);
+
+ let lastHeight = 0;
+ this.resizeObserver = new ResizeObserver(entries => {
+ // The width of the table isn't important to virtualizing the table. Skip
+ // updating if the height hasn't changed.
+ if (this.clientHeight == lastHeight) {
+ this.#dispatchRowBufferReadyEvent();
+ return;
+ }
+
+ if (!this._rowElementClass) {
+ this.#dispatchRowBufferReadyEvent();
+ return;
+ }
+
+ // The number of rows in the tolerance buffer is based on the number of
+ // rows which can be visible. Update it.
+ this.#calculateToleranceBufferSize();
+
+ // There's not much point in reducing the number of rows on resize. Scroll
+ // height remains the same and we can retain the extra rows in the buffer.
+ if (this.clientHeight > lastHeight) {
+ this._ensureVisibleRowsAreDisplayed();
+ } else {
+ this.#dispatchRowBufferReadyEvent();
+ }
+
+ lastHeight = this.clientHeight;
+ });
+ this.resizeObserver.observe(this);
+ }
+
+ disconnectedCallback() {
+ this.#resetRowBuffer();
+ this.resizeObserver.disconnect();
+ }
+
+ attributeChangedCallback(attrName, oldValue, newValue) {
+ this._rowElementName = newValue || "tree-view-table-row";
+ this._rowElementClass = customElements.get(this._rowElementName);
+
+ this.#calculateToleranceBufferSize();
+
+ if (this._view) {
+ this.reset();
+ }
+ }
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "keyup": {
+ if (
+ ["Tab", "F6"].includes(event.key) &&
+ this.currentIndex == -1 &&
+ this._view?.rowCount
+ ) {
+ let selectionChanged = false;
+ if (this.selectedIndex == -1) {
+ this._selection.select(0);
+ selectionChanged = true;
+ }
+ this.currentIndex = this.selectedIndex;
+ if (selectionChanged) {
+ this.onSelectionChanged();
+ }
+ }
+ break;
+ }
+ case "click": {
+ if (event.button !== 0) {
+ return;
+ }
+
+ let row = event.target.closest(`tr[is="${this._rowElementName}"]`);
+ if (!row) {
+ return;
+ }
+
+ let index = row.index;
+
+ if (event.target.classList.contains("tree-button-thread")) {
+ if (this._view.isContainerOpen(index)) {
+ let children = 0;
+ for (
+ let i = index + 1;
+ i < this._view.rowCount && this._view.getLevel(i) > 0;
+ i++
+ ) {
+ children++;
+ }
+ this._selectRange(index, index + children, event[accelKeyName]);
+ } else {
+ let addedRows = this.expandRowAtIndex(index);
+ this._selectRange(index, index + addedRows, event[accelKeyName]);
+ }
+ this.table.body.focus();
+ return;
+ }
+
+ if (this._view.isContainer(index) && event.target.closest(".twisty")) {
+ if (this._view.isContainerOpen(index)) {
+ this.collapseRowAtIndex(index);
+ } else {
+ let addedRows = this.expandRowAtIndex(index);
+ this.scrollToIndex(
+ index + Math.min(addedRows, this.#calculateVisibleRowCount() - 1)
+ );
+ }
+ this.table.body.focus();
+ return;
+ }
+
+ // Handle the click as a CTRL extension if it happens on the checkbox
+ // image inside the selection column.
+ if (event.target.classList.contains("tree-view-row-select-checkbox")) {
+ if (event.shiftKey) {
+ this._selectRange(-1, index, event[accelKeyName]);
+ } else {
+ this._toggleSelected(index);
+ }
+ this.table.body.focus();
+ return;
+ }
+
+ if (event.target.classList.contains("tree-button-request-delete")) {
+ this.table.body.dispatchEvent(
+ new CustomEvent("request-delete", {
+ bubbles: true,
+ detail: {
+ index,
+ },
+ })
+ );
+ this.table.body.focus();
+ return;
+ }
+
+ if (event.target.classList.contains("tree-button-flag")) {
+ this.table.body.dispatchEvent(
+ new CustomEvent("toggle-flag", {
+ bubbles: true,
+ detail: {
+ isFlagged: row.dataset.properties.includes("flagged"),
+ index,
+ },
+ })
+ );
+ this.table.body.focus();
+ return;
+ }
+
+ if (event.target.classList.contains("tree-button-unread")) {
+ this.table.body.dispatchEvent(
+ new CustomEvent("toggle-unread", {
+ bubbles: true,
+ detail: {
+ isUnread: row.dataset.properties.includes("unread"),
+ index,
+ },
+ })
+ );
+ this.table.body.focus();
+ return;
+ }
+
+ if (event.target.classList.contains("tree-button-spam")) {
+ this.table.body.dispatchEvent(
+ new CustomEvent("toggle-spam", {
+ bubbles: true,
+ detail: {
+ isJunk: row.dataset.properties.split(" ").includes("junk"),
+ index,
+ },
+ })
+ );
+ this.table.body.focus();
+ return;
+ }
+
+ if (event[accelKeyName] && !event.shiftKey) {
+ this._toggleSelected(index);
+ } else if (event.shiftKey) {
+ this._selectRange(-1, index, event[accelKeyName]);
+ } else {
+ this._selectSingle(index);
+ }
+
+ this.table.body.focus();
+ break;
+ }
+ case "keydown": {
+ if (event.altKey || event[otherKeyName]) {
+ return;
+ }
+
+ let currentIndex = this.currentIndex == -1 ? 0 : this.currentIndex;
+ let newIndex;
+ switch (event.key) {
+ case "ArrowUp":
+ newIndex = currentIndex - 1;
+ break;
+ case "ArrowDown":
+ newIndex = currentIndex + 1;
+ break;
+ case "ArrowLeft":
+ case "ArrowRight": {
+ event.preventDefault();
+ if (this.currentIndex == -1) {
+ return;
+ }
+ let isArrowRight = event.key == "ArrowRight";
+ let isRTL = this.matches(":dir(rtl)");
+ if (isArrowRight == isRTL) {
+ // Collapse action.
+ let currentLevel = this._view.getLevel(this.currentIndex);
+ if (this._view.isContainerOpen(this.currentIndex)) {
+ this.collapseRowAtIndex(this.currentIndex);
+ return;
+ } else if (currentLevel == 0) {
+ return;
+ }
+
+ let parentIndex = this._view.getParentIndex(this.currentIndex);
+ if (parentIndex != -1) {
+ newIndex = parentIndex;
+ }
+ } else if (this._view.isContainer(this.currentIndex)) {
+ // Expand action.
+ if (!this._view.isContainerOpen(this.currentIndex)) {
+ let addedRows = this.expandRowAtIndex(this.currentIndex);
+ this.scrollToIndex(
+ this.currentIndex +
+ Math.min(addedRows, this.#calculateVisibleRowCount() - 1)
+ );
+ } else {
+ newIndex = this.currentIndex + 1;
+ }
+ }
+ if (newIndex != undefined) {
+ this._selectSingle(newIndex);
+ }
+ return;
+ }
+ case "Home":
+ newIndex = 0;
+ break;
+ case "End":
+ newIndex = this._view.rowCount - 1;
+ break;
+ case "PageUp":
+ newIndex = Math.max(
+ 0,
+ currentIndex - this.#calculateVisibleRowCount()
+ );
+ break;
+ case "PageDown":
+ newIndex = Math.min(
+ this._view.rowCount - 1,
+ currentIndex + this.#calculateVisibleRowCount()
+ );
+ break;
+ }
+
+ if (newIndex != undefined) {
+ newIndex = this._clampIndex(newIndex);
+ if (newIndex != null) {
+ if (event[accelKeyName] && !event.shiftKey) {
+ // Change focus, but not selection.
+ this.currentIndex = newIndex;
+ } else if (event.shiftKey) {
+ this._selectRange(-1, newIndex, event[accelKeyName]);
+ } else {
+ this._selectSingle(newIndex, true);
+ }
+ }
+ event.preventDefault();
+ return;
+ }
+
+ // Space bar keystroke selection toggling.
+ if (event.key == " " && this.currentIndex != -1) {
+ // Don't do anything if we're on macOS and the target row is already
+ // selected.
+ if (
+ AppConstants.platform == "macosx" &&
+ this._selection.isSelected(this.currentIndex)
+ ) {
+ return;
+ }
+
+ // Handle the macOS exception of toggling the selection with only
+ // the space bar since CMD+Space is captured by the OS.
+ if (event[accelKeyName] || AppConstants.platform == "macosx") {
+ this._toggleSelected(this.currentIndex);
+ event.preventDefault();
+ } else if (!this._selection.isSelected(this.currentIndex)) {
+ // The target row is not currently selected.
+ this._selectSingle(this.currentIndex, true);
+ event.preventDefault();
+ }
+ }
+ break;
+ }
+ case "scroll":
+ this._ensureVisibleRowsAreDisplayed();
+ break;
+ }
+ }
+
+ /**
+ * The current view for this list.
+ *
+ * @type {nsITreeView}
+ */
+ get view() {
+ return this._view;
+ }
+
+ set view(view) {
+ this._selection = null;
+ if (this._view) {
+ this._view.setTree(null);
+ this._view.selection = null;
+ }
+ if (this._selection) {
+ this._selection.view = null;
+ }
+
+ this._view = view;
+ if (view) {
+ try {
+ this._selection = new TreeSelection();
+ this._selection.tree = this;
+ this._selection.view = view;
+
+ view.selection = this._selection;
+ view.setTree(this);
+ } catch (ex) {
+ // This isn't a XULTreeElement, and we can't make it one, so if the
+ // `setTree` call crosses XPCOM, an exception will be thrown.
+ if (ex.result != Cr.NS_ERROR_XPC_BAD_CONVERT_JS) {
+ throw ex;
+ }
+ }
+ }
+
+ // Clear the height of the top spacer to avoid confusing
+ // `_ensureVisibleRowsAreDisplayed`.
+ this.table.spacerTop.setHeight(0);
+ this.reset();
+
+ this.dispatchEvent(new CustomEvent("viewchange"));
+ }
+
+ /**
+ * Set the colspan of the spacer row cells.
+ *
+ * @param {int} count - The amount of visible columns.
+ */
+ setSpacersColspan(count) {
+ // Add an extra column if the table is editable to account for the column
+ // picker column.
+ if (this.parentNode.editable) {
+ count++;
+ }
+ this.table.spacerTop.setColspan(count);
+ this.table.spacerBottom.setColspan(count);
+ }
+
+ /**
+ * Clear all rows from the buffer, empty the table body, and reset spacers.
+ */
+ #resetRowBuffer() {
+ this.#cancelToleranceFillCallback();
+ this.table.body.replaceChildren();
+ this._rows.clear();
+ this.#firstBufferRowIndex = 0;
+ this.#lastBufferRowIndex = 0;
+ this.#firstVisibleRowIndex = 0;
+
+ // Set the height of the bottom spacer to account for the now-missing rows.
+ // We want to ensure that the overall scroll height does not decrease.
+ // Otherwise, we may lose our scroll position and cause unnecessary
+ // scrolling. However, we don't always want to change the height of the top
+ // spacer for the same reason.
+ let rowCount = this._view?.rowCount ?? 0;
+ this.table.spacerBottom.setHeight(
+ rowCount * this._rowElementClass.ROW_HEIGHT
+ );
+ }
+
+ /**
+ * Clear all rows from the list and create them again.
+ */
+ reset() {
+ this.#resetRowBuffer();
+ this._ensureVisibleRowsAreDisplayed();
+ }
+
+ /**
+ * Updates all existing rows in place, without removing all the rows and
+ * starting again. This can be used if the row element class hasn't changed
+ * and its `index` setter is capable of handling any modifications required.
+ */
+ invalidate() {
+ this.invalidateRange(this.#firstBufferRowIndex, this.#lastBufferRowIndex);
+ }
+
+ /**
+ * Perform the actions necessary to invalidate the specified row. Implemented
+ * separately to allow {@link invalidateRange} to handle testing event fires
+ * on its own.
+ *
+ * @param {integer} index
+ */
+ #doInvalidateRow(index) {
+ const rowCount = this._view?.rowCount ?? 0;
+ let row = this.getRowAtIndex(index);
+ if (row) {
+ if (index >= rowCount) {
+ this._removeRowAtIndex(index);
+ } else {
+ row.index = index;
+ row.selected = this._selection.isSelected(index);
+ }
+ } else if (
+ index >= this.#firstBufferRowIndex &&
+ index <= Math.min(rowCount - 1, this.#lastBufferRowIndex)
+ ) {
+ this._addRowAtIndex(index);
+ }
+ }
+
+ /**
+ * Invalidate the rows between `startIndex` and `endIndex`.
+ *
+ * @param {integer} startIndex
+ * @param {integer} endIndex
+ */
+ invalidateRange(startIndex, endIndex) {
+ for (
+ let index = Math.max(startIndex, this.#firstBufferRowIndex),
+ last = Math.min(endIndex, this.#lastBufferRowIndex);
+ index <= last;
+ index++
+ ) {
+ this.#doInvalidateRow(index);
+ }
+ this._ensureVisibleRowsAreDisplayed();
+ }
+
+ /**
+ * Invalidate the row at `index` in place. If `index` refers to a row that
+ * should exist but doesn't (because the row count increased), adds a row.
+ * If `index` refers to a row that does exist but shouldn't (because the
+ * row count decreased), removes it.
+ *
+ * @param {integer} index
+ */
+ invalidateRow(index) {
+ this.#doInvalidateRow(index);
+ this.#dispatchRowBufferReadyEvent();
+ }
+
+ /**
+ * A contiguous range, inclusive of both extremes.
+ *
+ * @typedef InclusiveRange
+ * @property {integer} first - The inclusive start of the range.
+ * @property {integer} last - The inclusive end of the range.
+ */
+
+ /**
+ * Calculate the range of rows we wish to have in a filled tolerance buffer
+ * based on a given range of visible rows.
+ *
+ * @param {integer} firstVisibleRow - The first visible row in the range.
+ * @param {integer} lastVisibleRow - The last visible row in the range.
+ * @param {integer} dataRowCount - The total number of available rows in the
+ * source data.
+ * @returns {InclusiveRange} - The full range of the desired buffer.
+ */
+ #calculateDesiredBufferRange(firstVisibleRow, lastVisibleRow, dataRowCount) {
+ const desiredRowRange = {};
+
+ desiredRowRange.first = Math.max(firstVisibleRow - this._toleranceSize, 0);
+ desiredRowRange.last = Math.min(
+ lastVisibleRow + this._toleranceSize,
+ dataRowCount - 1
+ );
+
+ return desiredRowRange;
+ }
+
+ #createToleranceFillCallback() {
+ // Don't schedule a new buffer fill callback if we already have one.
+ if (!this.#bufferFillIdleCallbackHandle) {
+ this.#bufferFillIdleCallbackHandle = requestIdleCallback(deadline =>
+ this.#fillToleranceBuffer(deadline)
+ );
+ }
+ }
+
+ #cancelToleranceFillCallback() {
+ cancelIdleCallback(this.#bufferFillIdleCallbackHandle);
+ this.#bufferFillIdleCallbackHandle = null;
+ }
+
+ /**
+ * Fill the buffer with tolerance rows above and below the visible rows.
+ *
+ * As fetching data and modifying the DOM is expensive, this is intended to be
+ * run within an idle callback and includes management of the idle callback
+ * handle and creation of further callbacks if work is not completed.
+ *
+ * @param {IdleDeadline} deadline - A deadline object for fetching the
+ * remaining time in the idle tick.
+ */
+ #fillToleranceBuffer(deadline) {
+ this.#bufferFillIdleCallbackHandle = null;
+
+ const rowCount = this._view?.rowCount ?? 0;
+ if (!rowCount) {
+ return;
+ }
+
+ const bufferRange = this.#calculateDesiredBufferRange(
+ this.#firstVisibleRowIndex,
+ this.#lastVisibleRowIndex,
+ rowCount
+ );
+
+ // Set the amount of time to leave in the deadline to fill another row. In
+ // order to cooperatively schedule work, we shouldn't overrun the time
+ // allotted for the idle tick. This value should be set such that it leaves
+ // enough time to perform another row fill and adjust the relevant spacer
+ // while doing the maximal amount of work per callback.
+ const MS_TO_LEAVE_PER_FILL = 1.25;
+
+ // Fill in the beginning of the buffer.
+ if (bufferRange.first < this.#firstBufferRowIndex) {
+ for (
+ let i = this.#firstBufferRowIndex - 1;
+ i >= bufferRange.first &&
+ deadline.timeRemaining() > MS_TO_LEAVE_PER_FILL;
+ i--
+ ) {
+ this._addRowAtIndex(i, this.table.body.firstElementChild);
+
+ // Update as we go in case we need to wait for the next idle.
+ this.#firstBufferRowIndex = i;
+ }
+
+ // Adjust the height of the top spacer to account for the new rows we've
+ // added.
+ this.table.spacerTop.setHeight(
+ this.#firstBufferRowIndex * this._rowElementClass.ROW_HEIGHT
+ );
+
+ // If we haven't completed the work of filling the tolerance buffer,
+ // schedule a new job to do so.
+ if (this.#firstBufferRowIndex != bufferRange.first) {
+ this.#createToleranceFillCallback();
+ return;
+ }
+ }
+
+ // Fill in the end of the buffer.
+ if (bufferRange.last > this.#lastBufferRowIndex) {
+ for (
+ let i = this.#lastBufferRowIndex + 1;
+ i <= bufferRange.last &&
+ deadline.timeRemaining() > MS_TO_LEAVE_PER_FILL;
+ i++
+ ) {
+ this._addRowAtIndex(i);
+
+ // Update as we go in case we need to wait for the next idle.
+ this.#lastBufferRowIndex = i;
+ }
+
+ // Adjust the height of the bottom spacer to account for the new rows
+ // we've added.
+ this.table.spacerBottom.setHeight(
+ (rowCount - 1 - this.#lastBufferRowIndex) *
+ this._rowElementClass.ROW_HEIGHT
+ );
+
+ // If we haven't completed the work of filling the tolerance buffer,
+ // schedule a new job to do so.
+ if (this.#lastBufferRowIndex != bufferRange.last) {
+ this.#createToleranceFillCallback();
+ return;
+ }
+ }
+
+ // Notify tests that we have finished work.
+ this.#dispatchRowBufferReadyEvent();
+ }
+
+ /**
+ * The calculated ranges which determine the shape of the row buffer at
+ * various stages of processing.
+ *
+ * @typedef RowBufferRanges
+ * @property {InclusiveRange} visibleRows - The range of rows which should be
+ * displayed to the user.
+ * @property {integer?} pruneBefore - The index of the row before which any
+ * additional rows should be discarded.
+ * @property {integer?} pruneAfter - The index of the row after which any
+ * additional rows should be discarded.
+ * @property {InclusiveRange} finalizedRows - The range of rows which should
+ * exist in the row buffer after any additions and removals have been made.
+ */
+
+ /**
+ * Calculate the values necessary for building the list of visible rows and
+ * retaining any rows in the buffer which fall inside the desired tolerance
+ * and form a contiguous range with the visible rows.
+ *
+ * WARNING: This function makes calculations based on existing DOM dimensions.
+ * Do not use it after you have modified the DOM.
+ *
+ * @returns {RowBufferRanges}
+ */
+ #calculateRowBufferRanges(dataRowCount) {
+ /** @type {RowBufferRanges} */
+ const ranges = {
+ visibleRows: {},
+ pruneBefore: null,
+ pruneAfter: null,
+ finalizedRows: {},
+ };
+
+ // We adjust the row buffer in several stages. First, we'll use the new
+ // scroll position to determine the boundaries of the buffer. Then, we'll
+ // create and add any new rows which are necessary to fit the new
+ // boundaries. Next, we prune rows added in previous scrolls which now fall
+ // outside the boundaries. Finally, we recalculate the height of the spacers
+ // which position the visible rows within the rendered area.
+ ranges.visibleRows.first = Math.max(
+ Math.floor(this.scrollTop / this._rowElementClass.ROW_HEIGHT),
+ 0
+ );
+
+ const lastPossibleVisibleRow = Math.ceil(
+ (this.scrollTop + this.#calculateVisibleHeight()) /
+ this._rowElementClass.ROW_HEIGHT
+ );
+
+ ranges.visibleRows.last =
+ Math.min(lastPossibleVisibleRow, dataRowCount) - 1;
+
+ // Determine the number of rows desired in the tolerance buffer in order to
+ // determine whether there are any that we can save.
+ const desiredRowRange = this.#calculateDesiredBufferRange(
+ ranges.visibleRows.first,
+ ranges.visibleRows.last,
+ dataRowCount
+ );
+
+ // Determine which rows are no longer wanted in the buffer. If we've
+ // scrolled past the previous visible rows, it's possible that the tolerance
+ // buffer will still contain some rows we'd like to have in the buffer. Note
+ // that we insist on a contiguous range of rows in the buffer to simplify
+ // determining which rows exist and appropriately spacing the viewport.
+ if (this.#lastBufferRowIndex < ranges.visibleRows.first) {
+ // There is a discontiguity between the visible rows and anything that's
+ // in the buffer. Prune everything before the visible rows.
+ ranges.pruneBefore = ranges.visibleRows.first;
+ ranges.finalizedRows.first = ranges.visibleRows.first;
+ } else if (this.#firstBufferRowIndex < desiredRowRange.first) {
+ // The range of rows in the buffer overlaps the start of the visible rows,
+ // but there are rows outside of the desired buffer as well. Prune them.
+ ranges.pruneBefore = desiredRowRange.first;
+ ranges.finalizedRows.first = desiredRowRange.first;
+ } else {
+ // Determine the beginning of the finalized buffer based on whether the
+ // buffer contains rows before the start of the visible rows.
+ ranges.finalizedRows.first = Math.min(
+ ranges.visibleRows.first,
+ this.#firstBufferRowIndex
+ );
+ }
+
+ if (this.#firstBufferRowIndex > ranges.visibleRows.last) {
+ // There is a discontiguity between the visible rows and anything that's
+ // in the buffer. Prune everything after the visible rows.
+ ranges.pruneAfter = ranges.visibleRows.last;
+ ranges.finalizedRows.last = ranges.visibleRows.last;
+ } else if (this.#lastBufferRowIndex > desiredRowRange.last) {
+ // The range of rows in the buffer overlaps the end of the visible rows,
+ // but there are rows outside of the desired buffer as well. Prune them.
+ ranges.pruneAfter = desiredRowRange.last;
+ ranges.finalizedRows.last = desiredRowRange.last;
+ } else {
+ // Determine the end of the finalized buffer based on whether the buffer
+ // contains rows after the end of the visible rows.
+ ranges.finalizedRows.last = Math.max(
+ ranges.visibleRows.last,
+ this.#lastBufferRowIndex
+ );
+ }
+
+ return ranges;
+ }
+
+ /**
+ * Display the table rows which should be shown in the visible area and
+ * request filling of the tolerance buffer when idle.
+ */
+ _ensureVisibleRowsAreDisplayed() {
+ this.#cancelToleranceFillCallback();
+
+ let rowCount = this._view?.rowCount ?? 0;
+ this.placeholder?.classList.toggle("show", !rowCount);
+
+ if (!rowCount || this.#calculateVisibleRowCount() == 0) {
+ return;
+ }
+
+ if (this.scrollTop > rowCount * this._rowElementClass.ROW_HEIGHT) {
+ // Beyond the end of the list. We're about to scroll anyway, so clear
+ // everything out and wait for it to happen. Don't call `invalidate` here,
+ // or you'll end up in an infinite loop.
+ this.table.spacerTop.setHeight(0);
+ this.#resetRowBuffer();
+ return;
+ }
+
+ const ranges = this.#calculateRowBufferRanges(rowCount);
+
+ // *WARNING: Do not request any DOM dimensions after this point. Modifying
+ // the DOM will invalidate existing calculations and any additional requests
+ // will cause synchronous reflow.
+
+ // Add a row if the table is empty. Either we're initializing or have
+ // invalidated the tree, and the next two steps pass over row zero if there
+ // are no rows already in the buffer.
+ if (
+ this.#lastBufferRowIndex == 0 &&
+ this.table.body.childElementCount == 0 &&
+ ranges.visibleRows.first == 0
+ ) {
+ this._addRowAtIndex(0);
+ }
+
+ // Expand the row buffer to include newly-visible rows which weren't already
+ // visible or preloaded in the tolerance buffer.
+
+ const earliestMissingEndRowIdx = Math.max(
+ this.#lastBufferRowIndex + 1,
+ ranges.visibleRows.first
+ );
+ for (let i = earliestMissingEndRowIdx; i <= ranges.visibleRows.last; i++) {
+ // We are missing rows at the end of the buffer. Either the last row of
+ // the existing buffer lies within the range of visible rows and we begin
+ // there, or the entire range of visible rows occurs after the end of the
+ // buffer and we fill in from the start.
+ this._addRowAtIndex(i);
+ }
+
+ const latestMissingStartRowIdx = Math.min(
+ this.#firstBufferRowIndex - 1,
+ ranges.visibleRows.last
+ );
+ for (let i = latestMissingStartRowIdx; i >= ranges.visibleRows.first; i--) {
+ // We are missing rows at the start of the buffer. We'll add them working
+ // backwards so that we can prepend. Either the first row of the existing
+ // buffer lies within the range of visible rows and we begin there, or the
+ // entire range of visible rows occurs before the end of the buffer and we
+ // fill in from the end.
+ this._addRowAtIndex(i, this.table.body.firstElementChild);
+ }
+
+ // Prune the buffer of any rows outside of our desired buffer range.
+ if (ranges.pruneBefore !== null) {
+ const pruneBeforeRow = this.getRowAtIndex(ranges.pruneBefore);
+ let rowToPrune = pruneBeforeRow.previousElementSibling;
+ while (rowToPrune) {
+ this._removeRowAtIndex(rowToPrune.index);
+ rowToPrune = pruneBeforeRow.previousElementSibling;
+ }
+ }
+
+ if (ranges.pruneAfter !== null) {
+ const pruneAfterRow = this.getRowAtIndex(ranges.pruneAfter);
+ let rowToPrune = pruneAfterRow.nextElementSibling;
+ while (rowToPrune) {
+ this._removeRowAtIndex(rowToPrune.index);
+ rowToPrune = pruneAfterRow.nextElementSibling;
+ }
+ }
+
+ // Set the indices of the new first and last rows in the DOM. They may come
+ // from the tolerance buffer if we haven't exhausted it.
+ this.#firstBufferRowIndex = ranges.finalizedRows.first;
+ this.#lastBufferRowIndex = ranges.finalizedRows.last;
+
+ this.#firstVisibleRowIndex = ranges.visibleRows.first;
+ this.#lastVisibleRowIndex = ranges.visibleRows.last;
+
+ // Adjust the height of the spacers to ensure that visible rows fall within
+ // the visible space and the overall scroll height is correct.
+ this.table.spacerTop.setHeight(
+ this.#firstBufferRowIndex * this._rowElementClass.ROW_HEIGHT
+ );
+
+ this.table.spacerBottom.setHeight(
+ (rowCount - this.#lastBufferRowIndex - 1) *
+ this._rowElementClass.ROW_HEIGHT
+ );
+
+ // The row buffer ideally contains some tolerance on either end to avoid
+ // creating rows and fetching data for them during short scrolls. However,
+ // actually creating those rows can be expensive, and during a long scroll
+ // we may throw them away very quickly. To save the expense, only fill the
+ // buffer while idle.
+
+ this.#createToleranceFillCallback();
+ }
+
+ /**
+ * Index of the first visible or partly visible row.
+ *
+ * @returns {integer}
+ */
+ getFirstVisibleIndex() {
+ return this.#firstVisibleRowIndex;
+ }
+
+ /**
+ * Index of the last visible or partly visible row.
+ *
+ * @returns {integer}
+ */
+ getLastVisibleIndex() {
+ return this.#lastVisibleRowIndex;
+ }
+
+ /**
+ * Ensures that the row at `index` is on the screen.
+ *
+ * @param {integer} index
+ */
+ scrollToIndex(index, instant = false) {
+ const rowCount = this._view.rowCount;
+ if (rowCount == 0) {
+ // If there are no rows, make sure we're scrolled to the top.
+ this.scrollTo({ top: 0, behavior: "instant" });
+ return;
+ }
+ if (index < 0 || index >= rowCount) {
+ // Bad index. Report, and do nothing.
+ console.error(
+ `<${this.localName} id="${this.id}"> tried to scroll to a row that doesn't exist: ${index}`
+ );
+ return;
+ }
+
+ const topOfRow = this._rowElementClass.ROW_HEIGHT * index;
+ let scrollTop = this.scrollTop;
+ const visibleHeight = this.#calculateVisibleHeight();
+ const behavior = instant ? "instant" : "auto";
+
+ // Scroll up to the row.
+ if (topOfRow < scrollTop) {
+ this.scrollTo({ top: topOfRow, behavior });
+ return;
+ }
+
+ // Scroll down to the row.
+ const bottomOfRow = topOfRow + this._rowElementClass.ROW_HEIGHT;
+ if (bottomOfRow > scrollTop + visibleHeight) {
+ this.scrollTo({ top: bottomOfRow - visibleHeight, behavior });
+ return;
+ }
+
+ // Call `scrollTo` even if the row is in view, to stop any earlier smooth
+ // scrolling that might be happening.
+ this.scrollTo({ top: this.scrollTop, behavior });
+ }
+
+ /**
+ * Updates the list to reflect added or removed rows.
+ *
+ * @param {integer} index - The position in the existing list where rows were
+ * added or removed.
+ * @param {integer} delta - The change in number of rows; positive if rows
+ * were added and negative if rows were removed.
+ */
+ rowCountChanged(index, delta) {
+ if (!this._selection) {
+ return;
+ }
+
+ this._selection.adjustSelection(index, delta);
+ this._updateCurrentIndexClasses();
+ this.dispatchEvent(new CustomEvent("rowcountchange"));
+ }
+
+ /**
+ * Clamps `index` to a value between 0 and `rowCount - 1`.
+ *
+ * @param {integer} index
+ * @returns {integer}
+ */
+ _clampIndex(index) {
+ if (!this._view.rowCount) {
+ return null;
+ }
+ if (index < 0) {
+ return 0;
+ }
+ if (index >= this._view.rowCount) {
+ return this._view.rowCount - 1;
+ }
+ return index;
+ }
+
+ /**
+ * Creates a new row element and adds it to the DOM.
+ *
+ * @param {integer} index
+ */
+ _addRowAtIndex(index, before = null) {
+ let row = document.createElement("tr", { is: this._rowElementName });
+ row.setAttribute("is", this._rowElementName);
+ this.table.body.insertBefore(row, before);
+ row.setAttribute("aria-setsize", this._view.rowCount);
+ row.style.height = `${this._rowElementClass.ROW_HEIGHT}px`;
+ row.index = index;
+ if (this._selection?.isSelected(index)) {
+ row.selected = true;
+ }
+ if (this.currentIndex === index) {
+ row.classList.add("current");
+ this.table.body.setAttribute("aria-activedescendant", row.id);
+ }
+ this._rows.set(index, row);
+ }
+
+ /**
+ * Removes the row element at `index` from the DOM and map of rows.
+ *
+ * @param {integer} index
+ */
+ _removeRowAtIndex(index) {
+ const row = this._rows.get(index);
+ row?.remove();
+ this._rows.delete(index);
+ }
+
+ /**
+ * Returns the row element at `index` or null if `index` is out of range.
+ *
+ * @param {integer} index
+ * @returns {HTMLTableRowElement}
+ */
+ getRowAtIndex(index) {
+ return this._rows.get(index) ?? null;
+ }
+
+ /**
+ * Collapses the row at `index` if it can be collapsed. If the selected
+ * row is a descendant of the collapsing row, selection is moved to the
+ * collapsing row.
+ *
+ * @param {integer} index
+ */
+ collapseRowAtIndex(index) {
+ if (!this._view.isContainerOpen(index)) {
+ return;
+ }
+
+ // If the selected row is going to be collapsed, move the selection.
+ // Even if the row to be collapsed is already selected, set
+ // selectIndex to ensure currentIndex also points to the correct row.
+ let selectedIndex = this.selectedIndex;
+ while (selectedIndex >= index) {
+ if (selectedIndex == index) {
+ this.selectedIndex = index;
+ break;
+ }
+ selectedIndex = this._view.getParentIndex(selectedIndex);
+ }
+
+ // Check if the view calls rowCountChanged. If it didn't, we'll have to
+ // call it. This can happen if the view has no reference to the tree.
+ let rowCountDidChange = false;
+ let rowCountChangeListener = () => {
+ rowCountDidChange = true;
+ };
+
+ let countBefore = this._view.rowCount;
+ this.addEventListener("rowcountchange", rowCountChangeListener);
+ this._view.toggleOpenState(index);
+ this.removeEventListener("rowcountchange", rowCountChangeListener);
+ let countAdded = this._view.rowCount - countBefore;
+
+ // Call rowCountChanged, if it hasn't already happened.
+ if (countAdded && !rowCountDidChange) {
+ this.invalidateRow(index);
+ this.rowCountChanged(index + 1, countAdded);
+ }
+
+ this.dispatchEvent(
+ new CustomEvent("collapsed", { bubbles: true, detail: index })
+ );
+ }
+
+ /**
+ * Expands the row at `index` if it can be expanded.
+ *
+ * @param {integer} index
+ * @returns {integer} - the number of rows that were added
+ */
+ expandRowAtIndex(index) {
+ if (!this._view.isContainer(index) || this._view.isContainerOpen(index)) {
+ return 0;
+ }
+
+ // Check if the view calls rowCountChanged. If it didn't, we'll have to
+ // call it. This can happen if the view has no reference to the tree.
+ let rowCountDidChange = false;
+ let rowCountChangeListener = () => {
+ rowCountDidChange = true;
+ };
+
+ let countBefore = this._view.rowCount;
+ this.addEventListener("rowcountchange", rowCountChangeListener);
+ this._view.toggleOpenState(index);
+ this.removeEventListener("rowcountchange", rowCountChangeListener);
+ let countAdded = this._view.rowCount - countBefore;
+
+ // Call rowCountChanged, if it hasn't already happened.
+ if (countAdded && !rowCountDidChange) {
+ this.invalidateRow(index);
+ this.rowCountChanged(index + 1, countAdded);
+ }
+
+ this.dispatchEvent(
+ new CustomEvent("expanded", { bubbles: true, detail: index })
+ );
+
+ return countAdded;
+ }
+
+ /**
+ * In a selection, index of the most-recently-selected row.
+ *
+ * @type {integer}
+ */
+ get currentIndex() {
+ return this._selection ? this._selection.currentIndex : -1;
+ }
+
+ set currentIndex(index) {
+ if (!this._view) {
+ return;
+ }
+
+ this._selection.currentIndex = index;
+ this._updateCurrentIndexClasses();
+ if (index >= 0 && index < this._view.rowCount) {
+ this.scrollToIndex(index);
+ }
+ }
+
+ /**
+ * Set the "current" class on the right row, and remove it from all other rows.
+ */
+ _updateCurrentIndexClasses() {
+ let index = this.currentIndex;
+
+ for (let row of this.querySelectorAll(
+ `tr[is="${this._rowElementName}"].current`
+ )) {
+ row.classList.remove("current");
+ }
+
+ if (!this._view || index < 0 || index > this._view.rowCount - 1) {
+ this.table.body.removeAttribute("aria-activedescendant");
+ return;
+ }
+
+ let row = this.getRowAtIndex(index);
+ if (row) {
+ // We need to clear the attribute in order to let screen readers know that
+ // a new message has been selected even if the ID is identical. For
+ // example when we delete the first message with ID 0, the next message
+ // becomes ID 0 itself. Therefore the attribute wouldn't trigger the screen
+ // reader to announce the new message without being cleared first.
+ this.table.body.removeAttribute("aria-activedescendant");
+ row.classList.add("current");
+ this.table.body.setAttribute("aria-activedescendant", row.id);
+ }
+ }
+
+ /**
+ * Select and focus the given index.
+ *
+ * @param {integer} index - The index to select.
+ * @param {boolean} [delaySelect=false] - If the selection should be delayed.
+ */
+ _selectSingle(index, delaySelect = false) {
+ let changeSelection =
+ this._selection.count != 1 || !this._selection.isSelected(index);
+ // Update the TreeSelection selection to trigger a tree reset().
+ if (changeSelection) {
+ this._selection.select(index);
+ }
+ this.currentIndex = index;
+ if (changeSelection) {
+ this.onSelectionChanged(delaySelect);
+ }
+ }
+
+ /**
+ * Start or extend a range selection to the given index and focus it.
+ *
+ * @param {number} start - Start index of selection. -1 for current index.
+ * @param {number} end - End index of selection.
+ * @param {boolean} extend[false] - If the new selection range should extend
+ * the current selection.
+ */
+ _selectRange(start, end, extend = false) {
+ this._selection.rangedSelect(start, end, extend);
+ this.currentIndex = start == -1 ? end : start;
+ this.onSelectionChanged();
+ }
+
+ /**
+ * Toggle the selection state at the given index and focus it.
+ *
+ * @param {integer} index - The index to toggle.
+ */
+ _toggleSelected(index) {
+ this._selection.toggleSelect(index);
+ // We hack the internals of the TreeSelection to clear the
+ // shiftSelectPivot.
+ this._selection._shiftSelectPivot = null;
+ this.currentIndex = index;
+ this.onSelectionChanged();
+ }
+
+ /**
+ * Select all rows.
+ */
+ selectAll() {
+ this._selection.selectAll();
+ this.onSelectionChanged();
+ }
+
+ /**
+ * Toggle between selecting all rows or none, depending on the current
+ * selection state.
+ */
+ toggleSelectAll() {
+ if (!this.selectedIndices.length) {
+ const index = this._view.rowCount - 1;
+ this._selection.selectAll();
+ this.currentIndex = index;
+ } else {
+ this._selection.clearSelection();
+ }
+ // Make sure the body is focused when the selection is changed as
+ // clicking on the "select all" header button steals the focus.
+ this.focus();
+
+ this.onSelectionChanged();
+ }
+
+ /**
+ * In a selection, index of the most-recently-selected row.
+ *
+ * @type {integer}
+ */
+ get selectedIndex() {
+ if (!this._selection?.count) {
+ return -1;
+ }
+
+ let min = {};
+ this._selection.getRangeAt(0, min, {});
+ return min.value;
+ }
+
+ set selectedIndex(index) {
+ this._selectSingle(index);
+ }
+
+ /**
+ * An array of the indices of all selected rows.
+ *
+ * @type {integer[]}
+ */
+ get selectedIndices() {
+ let indices = [];
+ let rangeCount = this._selection.getRangeCount();
+
+ for (let range = 0; range < rangeCount; range++) {
+ let min = {};
+ let max = {};
+ this._selection.getRangeAt(range, min, max);
+
+ if (min.value == -1) {
+ continue;
+ }
+
+ for (let index = min.value; index <= max.value; index++) {
+ indices.push(index);
+ }
+ }
+
+ return indices;
+ }
+
+ set selectedIndices(indices) {
+ this.setSelectedIndices(indices);
+ }
+
+ /**
+ * An array of the indices of all selected rows.
+ *
+ * @param {integer[]} indices
+ * @param {boolean} suppressEvent - Prevent a "select" event firing.
+ */
+ setSelectedIndices(indices, suppressEvent) {
+ this._selection.clearSelection();
+ for (let index of indices) {
+ this._selection.toggleSelect(index);
+ }
+ this.onSelectionChanged(false, suppressEvent);
+ }
+
+ /**
+ * Changes the selection state of the row at `index`.
+ *
+ * @param {integer} index
+ * @param {boolean?} selected - if set, set the selection state to this
+ * value, otherwise toggle the current state
+ * @param {boolean?} suppressEvent - prevent a "select" event firing
+ * @returns {boolean} - if the index is now selected
+ */
+ toggleSelectionAtIndex(index, selected, suppressEvent) {
+ let wasSelected = this._selection.isSelected(index);
+ if (selected === undefined) {
+ selected = !wasSelected;
+ }
+
+ if (selected != wasSelected) {
+ this._selection.toggleSelect(index);
+ this.onSelectionChanged(false, suppressEvent);
+ }
+
+ return selected;
+ }
+
+ /**
+ * Loop through all available child elements of the placeholder slot and
+ * show those that are needed.
+ * @param {array} idsToShow - Array of ids to show.
+ */
+ updatePlaceholders(idsToShow) {
+ for (let element of this.placeholder.children) {
+ element.hidden = !idsToShow.includes(element.id);
+ }
+ }
+
+ /**
+ * Update the classes on the table element to reflect the current selection
+ * state, and dispatch an event to allow implementations to handle the
+ * change in the selection state.
+ *
+ * @param {boolean} [delaySelect=false] - If the selection should be delayed.
+ * @param {boolean} [suppressEvent=false] - Prevent a "select" event firing.
+ */
+ onSelectionChanged(delaySelect = false, suppressEvent = false) {
+ const selectedCount = this._selection.count;
+ const allSelected = selectedCount == this._view.rowCount;
+
+ this.table.classList.toggle("all-selected", allSelected);
+ this.table.classList.toggle("some-selected", !allSelected && selectedCount);
+ this.table.classList.toggle("multi-selected", selectedCount > 1);
+
+ const selectButton = this.table.querySelector(".tree-view-header-select");
+ // Some implementations might not use a select header.
+ if (selectButton) {
+ // Only mark the `select` button as "checked" if all rows are selected.
+ selectButton.toggleAttribute("aria-checked", allSelected);
+ // The default action for the header button is to deselect all messages
+ // if even one message is currently selected.
+ document.l10n.setAttributes(
+ selectButton,
+ selectedCount
+ ? "threadpane-column-header-deselect-all"
+ : "threadpane-column-header-select-all"
+ );
+ }
+
+ if (suppressEvent) {
+ return;
+ }
+
+ // No need to handle a delayed select if not required.
+ if (!delaySelect) {
+ // Clear the timeout in case something was still running.
+ if (this._selectTimeout) {
+ window.clearTimeout(this._selectTimeout);
+ }
+ this.dispatchEvent(new CustomEvent("select", { bubbles: true }));
+ return;
+ }
+
+ let delay = this.dataset.selectDelay || 50;
+ if (delay != -1) {
+ if (this._selectTimeout) {
+ window.clearTimeout(this._selectTimeout);
+ }
+ this._selectTimeout = window.setTimeout(() => {
+ this.dispatchEvent(new CustomEvent("select", { bubbles: true }));
+ this._selectTimeout = null;
+ }, delay);
+ }
+ }
+}
+customElements.define("tree-view", TreeView);
+
+/**
+ * The main <table> element containing the thead and the TreeViewTableBody
+ * tbody. This class is used to expose all those methods and custom events
+ * needed at the implementation level.
+ */
+class TreeViewTable extends HTMLTableElement {
+ /**
+ * The array of objects containing the data to generate the needed columns.
+ * Keep this public so child elements can access it if needed.
+ * @type {Array}
+ */
+ columns;
+
+ /**
+ * The header row for the table.
+ *
+ * @type {TreeViewTableHeader}
+ */
+ header;
+
+ /**
+ * Array containing the IDs of templates holding menu items to dynamically add
+ * to the menupopup of the column picker.
+ * @type {Array}
+ */
+ popupMenuTemplates = [];
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+
+ this.setAttribute("is", "tree-view-table");
+ this.classList.add("tree-table");
+
+ // Use a fragment to append child elements to later add them all at once
+ // to the DOM. Performance is important.
+ const fragment = new DocumentFragment();
+
+ this.header = document.createElement("thead", {
+ is: "tree-view-table-header",
+ });
+ fragment.append(this.header);
+
+ this.spacerTop = document.createElement("tbody", {
+ is: "tree-view-table-spacer",
+ });
+ fragment.append(this.spacerTop);
+
+ this.body = document.createElement("tbody", {
+ is: "tree-view-table-body",
+ });
+ fragment.append(this.body);
+
+ this.spacerBottom = document.createElement("tbody", {
+ is: "tree-view-table-spacer",
+ });
+ fragment.append(this.spacerBottom);
+
+ this.append(fragment);
+ }
+
+ /**
+ * If set to TRUE before generating the columns, the table will
+ * automatically create a column picker in the table header.
+ *
+ * @type {boolean}
+ */
+ set editable(val) {
+ this.dataset.editable = val;
+ }
+
+ get editable() {
+ return this.dataset.editable === "true";
+ }
+
+ /**
+ * Set the id attribute of the TreeViewTableBody for selection and styling
+ * purpose.
+ *
+ * @param {string} id - The string ID to set.
+ */
+ setBodyID(id) {
+ this.body.id = id;
+ }
+
+ setPopupMenuTemplates(array) {
+ this.popupMenuTemplates = array;
+ }
+
+ /**
+ * Set the columns array of the table. This should only be used during
+ * initialization and any following change to the columns visibility should
+ * be handled via the updateColumns() method.
+ *
+ * @param {Array} columns - The array of columns to generate.
+ */
+ setColumns(columns) {
+ this.columns = columns;
+ this.header.setColumns();
+ this.#updateView();
+ }
+
+ /**
+ * Update the currently visible columns.
+ *
+ * @param {Array} columns - The array of columns to update. It should match
+ * the original array set via the setColumn() method since this method will
+ * only update the column visibility without generating new elements.
+ */
+ updateColumns(columns) {
+ this.columns = columns;
+ this.#updateView();
+ }
+
+ /**
+ * Store the newly resized column values in the xul store.
+ *
+ * @param {string} url - The document URL used to store the values.
+ * @param {DOMEvent} event - The dom event bubbling from the resized action.
+ */
+ setColumnsWidths(url, event) {
+ const width = event.detail.splitter.width;
+ const column = event.detail.column;
+ const newValue = `${column}:${width}`;
+ let newWidths;
+
+ // Check if we already have stored values and update it if so.
+ let columnsWidths = Services.xulStore.getValue(url, "columns", "widths");
+ if (columnsWidths) {
+ let updated = false;
+ columnsWidths = columnsWidths.split(",");
+ for (let index = 0; index < columnsWidths.length; index++) {
+ const cw = columnsWidths[index].split(":");
+ if (cw[0] == column) {
+ cw[1] = width;
+ updated = true;
+ columnsWidths[index] = newValue;
+ break;
+ }
+ }
+ // Push the new value into the array if we didn't have an existing one.
+ if (!updated) {
+ columnsWidths.push(newValue);
+ }
+ newWidths = columnsWidths.join(",");
+ } else {
+ newWidths = newValue;
+ }
+
+ // Store the values as a plain string with the current format:
+ // columnID:width,columnID:width,...
+ Services.xulStore.setValue(url, "columns", "widths", newWidths);
+ }
+
+ /**
+ * Restore the previously saved widths of the various columns if we have
+ * any.
+ *
+ * @param {string} url - The document URL used to store the values.
+ */
+ restoreColumnsWidths(url) {
+ let columnsWidths = Services.xulStore.getValue(url, "columns", "widths");
+ if (!columnsWidths) {
+ return;
+ }
+
+ for (let column of columnsWidths.split(",")) {
+ column = column.split(":");
+ this.querySelector(`#${column[0]}`)?.style.setProperty(
+ `--${column[0]}Splitter-width`,
+ `${column[1]}px`
+ );
+ }
+ }
+
+ /**
+ * Update the visibility of the currently available columns.
+ */
+ #updateView() {
+ let lastResizableColumn = this.columns.findLast(
+ c => !c.hidden && (c.resizable ?? true)
+ );
+
+ for (let column of this.columns) {
+ document.getElementById(column.id).hidden = column.hidden;
+
+ // No need to update the splitter visibility if the column is
+ // specifically not resizable.
+ if (column.resizable === false) {
+ continue;
+ }
+
+ document.getElementById(column.id).resizable =
+ column != lastResizableColumn;
+ }
+ }
+}
+customElements.define("tree-view-table", TreeViewTable, { extends: "table" });
+
+/**
+ * Class used to generate the thead of the TreeViewTable. This class will take
+ * care of handling columns sizing and sorting order, with bubbling events to
+ * allow listening for those changes on the implementation level.
+ */
+class TreeViewTableHeader extends HTMLTableSectionElement {
+ /**
+ * An array of all table header cells that can be reordered.
+ *
+ * @returns {HTMLTableCellElement[]}
+ */
+ get #orderableChildren() {
+ return [...this.querySelectorAll("th[draggable]:not([hidden])")];
+ }
+
+ /**
+ * Used to simulate a change in the order. The element remains in the same
+ * DOM position.
+ *
+ * @param {HTMLTableRowElement} element - The row to animate.
+ * @param {number} to - The new Y position of the element after animation.
+ */
+ static _transitionTranslation(element, to) {
+ if (!reducedMotionMedia.matches) {
+ element.style.transition = `transform ${ANIMATION_DURATION_MS}ms ease`;
+ }
+ element.style.transform = to ? `translateX(${to}px)` : null;
+ }
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+
+ this.setAttribute("is", "tree-view-table-header");
+ this.classList.add("tree-table-header");
+ this.row = document.createElement("tr");
+ this.appendChild(this.row);
+
+ this.addEventListener("keypress", this);
+ this.addEventListener("dragstart", this);
+ this.addEventListener("dragover", this);
+ this.addEventListener("dragend", this);
+ this.addEventListener("drop", this);
+ }
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "keypress":
+ this.#onKeyPress(event);
+ break;
+ case "dragstart":
+ this.#onDragStart(event);
+ break;
+ case "dragover":
+ this.#onDragOver(event);
+ break;
+ case "dragend":
+ this.#onDragEnd();
+ break;
+ case "drop":
+ this.#onDrop(event);
+ break;
+ }
+ }
+
+ #onKeyPress(event) {
+ if (!event.altKey || !["ArrowRight", "ArrowLeft"].includes(event.key)) {
+ this.triggerTableHeaderRovingTab(event);
+ return;
+ }
+
+ let column = event.target.closest(`th[is="tree-view-table-header-cell"]`);
+ if (!column) {
+ return;
+ }
+
+ let visibleColumns = this.parentNode.columns.filter(c => !c.hidden);
+ let forward =
+ event.key == (document.dir === "rtl" ? "ArrowLeft" : "ArrowRight");
+
+ // Bail out if the user is trying to shift backward the first column, or
+ // shift forward the last column.
+ if (
+ (!forward && visibleColumns.at(0)?.id == column.id) ||
+ (forward && visibleColumns.at(-1)?.id == column.id)
+ ) {
+ return;
+ }
+
+ event.preventDefault();
+ this.dispatchEvent(
+ new CustomEvent("shift-column", {
+ bubbles: true,
+ detail: {
+ column: column.id,
+ forward,
+ },
+ })
+ );
+ }
+
+ #onDragStart(event) {
+ if (!event.target.closest("th[draggable]")) {
+ // This shouldn't be necessary, but is?!
+ event.preventDefault();
+ return;
+ }
+
+ const orderable = this.#orderableChildren;
+ if (orderable.length < 2) {
+ return;
+ }
+
+ const headerCell = orderable.find(th => th.contains(event.target));
+ const rect = headerCell.getBoundingClientRect();
+
+ this._dragInfo = {
+ cell: headerCell,
+ // How far can we move `headerCell` horizontally.
+ min: orderable.at(0).getBoundingClientRect().left - rect.left,
+ max: orderable.at(-1).getBoundingClientRect().right - rect.right,
+ // Where is the drag event starting.
+ startX: event.clientX,
+ offsetX: event.clientX - rect.left,
+ };
+
+ headerCell.classList.add("column-dragging");
+ // Prevent `headerCell` being used as the drag image. We don't
+ // really want any drag image, but there's no way to not have one.
+ event.dataTransfer.setDragImage(document.createElement("img"), 0, 0);
+ }
+
+ #onDragOver(event) {
+ if (!this._dragInfo) {
+ return;
+ }
+
+ const { cell, min, max, startX, offsetX } = this._dragInfo;
+ // Move `cell` with the mouse pointer.
+ let dragX = Math.min(max, Math.max(min, event.clientX - startX));
+ cell.style.transform = `translateX(${dragX}px)`;
+
+ let thisRect = this.getBoundingClientRect();
+
+ // How much space is there before the `cell`? We'll see how many cells fit
+ // in the space and put the `cell` in after them.
+ let spaceBefore = Math.max(
+ 0,
+ event.clientX + this.scrollLeft - offsetX - thisRect.left
+ );
+ // The width of all cells seen in the loop so far.
+ let totalWidth = 0;
+ // If we've looped past the cell being dragged.
+ let afterDraggedTh = false;
+ // The cell before where a drop would take place. If null, drop would
+ // happen at the start of the table header.
+ let header = null;
+
+ for (let headerCell of this.#orderableChildren) {
+ if (headerCell == cell) {
+ afterDraggedTh = true;
+ continue;
+ }
+
+ let rect = headerCell.getBoundingClientRect();
+ let enoughSpace = spaceBefore > totalWidth + rect.width / 2;
+
+ let multiplier = 0;
+ if (enoughSpace) {
+ if (afterDraggedTh) {
+ multiplier = -1;
+ }
+ header = headerCell;
+ } else if (!afterDraggedTh) {
+ multiplier = 1;
+ }
+ TreeViewTableHeader._transitionTranslation(
+ headerCell,
+ multiplier * cell.clientWidth
+ );
+
+ totalWidth += rect.width;
+ }
+
+ this._dragInfo.dropTarget = header;
+
+ event.preventDefault();
+ }
+
+ #onDragEnd() {
+ if (!this._dragInfo) {
+ return;
+ }
+
+ this._dragInfo.cell.classList.remove("column-dragging");
+ delete this._dragInfo;
+
+ for (let headerCell of this.#orderableChildren) {
+ headerCell.style.transform = null;
+ headerCell.style.transition = null;
+ }
+ }
+
+ #onDrop(event) {
+ if (!this._dragInfo) {
+ return;
+ }
+
+ let { cell, startX, dropTarget } = this._dragInfo;
+
+ let newColumns = this.parentNode.columns.map(column => ({ ...column }));
+
+ const draggedColumn = newColumns.find(c => c.id == cell.id);
+ const initialPosition = newColumns.indexOf(draggedColumn);
+
+ let targetCell;
+ let newPosition;
+ if (!dropTarget) {
+ // Get the first visible cell.
+ targetCell = this.querySelector("th:not([hidden])");
+ newPosition = newColumns.indexOf(
+ newColumns.find(c => c.id == targetCell.id)
+ );
+ } else {
+ // Get the next non hidden sibling.
+ targetCell = dropTarget.nextElementSibling;
+ while (targetCell.hidden) {
+ targetCell = targetCell.nextElementSibling;
+ }
+ newPosition = newColumns.indexOf(
+ newColumns.find(c => c.id == targetCell.id)
+ );
+ }
+
+ // Reduce the new position index if we're moving forward in order to get the
+ // accurate index position of the column we're taking the position of.
+ if (event.clientX > startX) {
+ newPosition -= 1;
+ }
+
+ newColumns.splice(newPosition, 0, newColumns.splice(initialPosition, 1)[0]);
+
+ // Update the ordinal of the columns to reflect the new positions.
+ newColumns.forEach((column, index) => {
+ column.ordinal = index;
+ });
+
+ this.querySelector("tr").insertBefore(cell, targetCell);
+
+ this.dispatchEvent(
+ new CustomEvent("reorder-columns", {
+ bubbles: true,
+ detail: {
+ columns: newColumns,
+ },
+ })
+ );
+ event.preventDefault();
+ }
+
+ /**
+ * Create all the table header cells based on the currently set columns.
+ */
+ setColumns() {
+ this.row.replaceChildren();
+
+ for (let column of this.parentNode.columns) {
+ /** @type {TreeViewTableHeaderCell} */
+ let cell = document.createElement("th", {
+ is: "tree-view-table-header-cell",
+ });
+ this.row.appendChild(cell);
+ cell.setColumn(column);
+ }
+
+ // Create a column picker if the table is editable.
+ if (this.parentNode.editable) {
+ const picker = document.createElement("th", {
+ is: "tree-view-table-column-picker",
+ });
+ this.row.appendChild(picker);
+ }
+
+ this.updateRovingTab();
+ }
+
+ /**
+ * Get all currently visible columns of the table header.
+ *
+ * @returns {Array} An array of buttons.
+ */
+ get headerColumns() {
+ return this.row.querySelectorAll(`th:not([hidden]) button`);
+ }
+
+ /**
+ * Update the `tabindex` attribute of the currently visible columns.
+ */
+ updateRovingTab() {
+ for (let button of this.headerColumns) {
+ button.tabIndex = -1;
+ }
+ // Allow focus on the first available button.
+ this.headerColumns[0].tabIndex = 0;
+ }
+
+ /**
+ * Handles the keypress event on the table header.
+ *
+ * @param {Event} event - The keypress DOMEvent.
+ */
+ triggerTableHeaderRovingTab(event) {
+ if (!["ArrowRight", "ArrowLeft"].includes(event.key)) {
+ return;
+ }
+
+ const headerColumns = [...this.headerColumns];
+ let focusableButton = headerColumns.find(b => b.tabIndex != -1);
+ let elementIndex = headerColumns.indexOf(focusableButton);
+
+ // Find the adjacent focusable element based on the pressed key.
+ let isRTL = document.dir == "rtl";
+ if (
+ (isRTL && event.key == "ArrowLeft") ||
+ (!isRTL && event.key == "ArrowRight")
+ ) {
+ elementIndex++;
+ if (elementIndex > headerColumns.length - 1) {
+ elementIndex = 0;
+ }
+ } else if (
+ (!isRTL && event.key == "ArrowLeft") ||
+ (isRTL && event.key == "ArrowRight")
+ ) {
+ elementIndex--;
+ if (elementIndex == -1) {
+ elementIndex = headerColumns.length - 1;
+ }
+ }
+
+ // Move the focus to a new column and update the tabindex attribute.
+ let newFocusableButton = headerColumns[elementIndex];
+ if (newFocusableButton) {
+ focusableButton.tabIndex = -1;
+ newFocusableButton.tabIndex = 0;
+ newFocusableButton.focus();
+ }
+ }
+}
+customElements.define("tree-view-table-header", TreeViewTableHeader, {
+ extends: "thead",
+});
+
+/**
+ * Class to generated the TH elements for the TreeViewTableHeader.
+ */
+class TreeViewTableHeaderCell extends HTMLTableCellElement {
+ /**
+ * The div needed to handle the header button in an absolute position.
+ * @type {HTMLElement}
+ */
+ #container;
+
+ /**
+ * The clickable button to change the sorting of the table.
+ * @type {HTMLButtonElement}
+ */
+ #button;
+
+ /**
+ * If this cell is resizable.
+ * @type {boolean}
+ */
+ #resizable = true;
+
+ /**
+ * If this cell can be clicked to affect the sorting order of the tree.
+ * @type {boolean}
+ */
+ #sortable = true;
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+
+ this.setAttribute("is", "tree-view-table-header-cell");
+ this.draggable = true;
+
+ this.#container = document.createElement("div");
+ this.#container.classList.add(
+ "tree-table-cell",
+ "tree-table-cell-container"
+ );
+
+ this.#button = document.createElement("button");
+ this.#container.appendChild(this.#button);
+ this.appendChild(this.#container);
+ }
+
+ /**
+ * Set the proper data to the newly generated table header cell and create
+ * the needed child elements.
+ *
+ * @param {object} column - The column object with all the data to generate
+ * the correct header cell.
+ */
+ setColumn(column) {
+ // Set a public ID so parent elements can loop through the available
+ // columns after they're created.
+ this.id = column.id;
+ this.#button.id = `${column.id}Button`;
+
+ // Add custom classes if needed.
+ if (column.classes) {
+ this.#button.classList.add(...column.classes);
+ }
+
+ if (column.l10n?.header) {
+ document.l10n.setAttributes(this.#button, column.l10n.header);
+ }
+
+ // Add an image if this is a table header that needs to display an icon,
+ // and set the column as icon.
+ if (column.icon) {
+ this.dataset.type = "icon";
+ const img = document.createElement("img");
+ img.src = "";
+ img.alt = "";
+ this.#button.appendChild(img);
+ }
+
+ this.resizable = column.resizable ?? true;
+
+ this.hidden = column.hidden;
+
+ this.#sortable = column.sortable ?? true;
+ // Make the button clickable if the column can trigger a sorting of rows.
+ if (this.#sortable) {
+ this.#button.addEventListener("click", () => {
+ this.dispatchEvent(
+ new CustomEvent("sort-changed", {
+ bubbles: true,
+ detail: {
+ column: column.id,
+ },
+ })
+ );
+ });
+ }
+
+ this.#button.addEventListener("contextmenu", event => {
+ event.stopPropagation();
+ const table = this.closest("table");
+ if (table.editable) {
+ table
+ .querySelector("#columnPickerMenuPopup")
+ .openPopup(event.target, { triggerEvent: event });
+ }
+ });
+
+ // This is the column handling the thread toggling.
+ if (column.thread) {
+ this.#button.classList.add("tree-view-header-thread");
+ this.#button.addEventListener("click", () => {
+ this.dispatchEvent(
+ new CustomEvent("thread-changed", {
+ bubbles: true,
+ })
+ );
+ });
+ }
+
+ // This is the column handling bulk selection.
+ if (column.select) {
+ this.#button.classList.add("tree-view-header-select");
+ this.#button.addEventListener("click", () => {
+ this.closest("tree-view").toggleSelectAll();
+ });
+ }
+
+ // This is the column handling delete actions.
+ if (column.delete) {
+ this.#button.classList.add("tree-view-header-delete");
+ }
+ }
+
+ /**
+ * Set this table header as responsible for the sorting of rows.
+ *
+ * @param {string["ascending"|"descending"]} direction - The new sorting
+ * direction.
+ */
+ setSorting(direction) {
+ this.#button.classList.add("sorting", direction);
+ }
+
+ /**
+ * If this current column can be resized.
+ *
+ * @type {boolean}
+ */
+ set resizable(val) {
+ this.#resizable = val;
+ this.dataset.resizable = val;
+
+ let splitter = this.querySelector("hr");
+
+ // Add a splitter if we don't have one already.
+ if (!splitter) {
+ splitter = document.createElement("hr", { is: "pane-splitter" });
+ splitter.setAttribute("is", "pane-splitter");
+ this.appendChild(splitter);
+ splitter.resizeDirection = "horizontal";
+ splitter.resizeElement = this;
+ splitter.id = `${this.id}Splitter`;
+ // Emit a custom event after a resize action. Methods at implementation
+ // level should listen to this event if the edited column size needs to
+ // be stored or used.
+ splitter.addEventListener("splitter-resized", () => {
+ this.dispatchEvent(
+ new CustomEvent("column-resized", {
+ bubbles: true,
+ detail: {
+ splitter,
+ column: this.id,
+ },
+ })
+ );
+ });
+ }
+
+ this.style.setProperty("width", val ? `var(--${splitter.id}-width)` : null);
+ // Disable the splitter if this is not a resizable column.
+ splitter.isDisabled = !val;
+ }
+
+ get resizable() {
+ return this.#resizable;
+ }
+
+ /**
+ * If the current column can trigger a sorting of rows.
+ *
+ * @type {boolean}
+ */
+ set sortable(val) {
+ this.#sortable = val;
+ this.#button.disabled = !val;
+ }
+
+ get sortable() {
+ return this.#sortable;
+ }
+}
+customElements.define("tree-view-table-header-cell", TreeViewTableHeaderCell, {
+ extends: "th",
+});
+
+/**
+ * Class used to generate a column picker used for the TreeViewTableHeader in
+ * case the visibility of the columns of a table can be changed.
+ *
+ * Include treeView.ftl for strings.
+ */
+class TreeViewTableColumnPicker extends HTMLTableCellElement {
+ /**
+ * The clickable button triggering the picker context menu.
+ * @type {HTMLButtonElement}
+ */
+ #button;
+
+ /**
+ * The menupopup allowing users to show and hide columns.
+ * @type {XULElement}
+ */
+ #context;
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+
+ this.setAttribute("is", "tree-view-table-column-picker");
+ this.classList.add("tree-table-cell-container");
+
+ this.#button = document.createElement("button");
+ document.l10n.setAttributes(this.#button, "tree-list-view-column-picker");
+ this.#button.classList.add("button-flat", "button-column-picker");
+ this.appendChild(this.#button);
+
+ const img = document.createElement("img");
+ img.src = "";
+ img.alt = "";
+ this.#button.appendChild(img);
+
+ this.#context = document.createXULElement("menupopup");
+ this.#context.id = "columnPickerMenuPopup";
+ this.#context.setAttribute("position", "bottomleft topleft");
+ this.appendChild(this.#context);
+ this.#context.addEventListener("popupshowing", event => {
+ // Bail out if we're opening a submenu.
+ if (event.target.id != this.#context.id) {
+ return;
+ }
+
+ if (!this.#context.hasChildNodes()) {
+ this.#initPopup();
+ }
+
+ let columns = this.closest("table").columns;
+ for (let column of columns) {
+ let item = this.#context.querySelector(`[value="${column.id}"]`);
+ if (!item) {
+ continue;
+ }
+
+ if (!column.hidden) {
+ item.setAttribute("checked", "true");
+ continue;
+ }
+
+ item.removeAttribute("checked");
+ }
+ });
+
+ this.#button.addEventListener("click", event => {
+ this.#context.openPopup(event.target, { triggerEvent: event });
+ });
+ }
+
+ /**
+ * Add all toggable columns to the context menu popup of the picker button.
+ */
+ #initPopup() {
+ let table = this.closest("table");
+ let columns = table.columns;
+ let items = new DocumentFragment();
+ for (let column of columns) {
+ // Skip those columns we don't want to allow hiding.
+ if (column.picker === false) {
+ continue;
+ }
+
+ let menuitem = document.createXULElement("menuitem");
+ items.append(menuitem);
+ menuitem.setAttribute("type", "checkbox");
+ menuitem.setAttribute("name", "toggle");
+ menuitem.setAttribute("value", column.id);
+ menuitem.setAttribute("closemenu", "none");
+ if (column.l10n?.menuitem) {
+ document.l10n.setAttributes(menuitem, column.l10n.menuitem);
+ }
+
+ menuitem.addEventListener("command", () => {
+ this.dispatchEvent(
+ new CustomEvent("columns-changed", {
+ bubbles: true,
+ detail: {
+ target: menuitem,
+ value: column.id,
+ },
+ })
+ );
+ });
+ }
+
+ items.append(document.createXULElement("menuseparator"));
+ let restoreItem = document.createXULElement("menuitem");
+ restoreItem.id = "restoreColumnOrder";
+ restoreItem.addEventListener("command", () => {
+ this.dispatchEvent(
+ new CustomEvent("restore-columns", {
+ bubbles: true,
+ })
+ );
+ });
+ document.l10n.setAttributes(
+ restoreItem,
+ "tree-list-view-column-picker-restore"
+ );
+ items.append(restoreItem);
+
+ for (const templateID of table.popupMenuTemplates) {
+ items.append(document.getElementById(templateID).content.cloneNode(true));
+ }
+
+ this.#context.replaceChildren(items);
+ }
+}
+customElements.define(
+ "tree-view-table-column-picker",
+ TreeViewTableColumnPicker,
+ { extends: "th" }
+);
+
+/**
+ * A more powerful list designed to be used with a view (nsITreeView or
+ * whatever replaces it in time) and be scalable to a very large number of
+ * items if necessary. Multiple selections are possible and changes in the
+ * connected view are cause updates to the list (provided `rowCountChanged`/
+ * `invalidate` are called as appropriate).
+ *
+ * Rows are provided by a custom element that inherits from
+ * TreeViewTableRow below. Set the name of the custom element as the "rows"
+ * attribute.
+ *
+ * Include tree-listbox.css for appropriate styling.
+ */
+class TreeViewTableBody extends HTMLTableSectionElement {
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+
+ this.tabIndex = 0;
+ this.setAttribute("is", "tree-view-table-body");
+ this.setAttribute("role", "tree");
+ this.setAttribute("aria-multiselectable", "true");
+
+ let treeView = this.closest("tree-view");
+ this.addEventListener("keyup", treeView);
+ this.addEventListener("click", treeView);
+ this.addEventListener("keydown", treeView);
+
+ if (treeView.dataset.labelId) {
+ this.setAttribute("aria-labelledby", treeView.dataset.labelId);
+ }
+ }
+}
+customElements.define("tree-view-table-body", TreeViewTableBody, {
+ extends: "tbody",
+});
+
+/**
+ * Base class for rows in a TreeViewTableBody. Rows have a fixed height and
+ * their position on screen is managed by the owning list.
+ *
+ * Sub-classes should override ROW_HEIGHT, styles, and fragment to suit the
+ * intended layout. The index getter/setter should be overridden to fill the
+ * layout with values.
+ */
+class TreeViewTableRow extends HTMLTableRowElement {
+ /**
+ * Fixed height of this row. Rows in the list will be spaced this far
+ * apart. This value must not change at runtime.
+ *
+ * @type {integer}
+ */
+ static ROW_HEIGHT = 50;
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+
+ this.tabIndex = -1;
+ this.list = this.closest("tree-view");
+ this.view = this.list.view;
+ this.setAttribute("aria-selected", !!this.selected);
+ }
+
+ /**
+ * The 0-based position of this row in the list. Override this setter to
+ * fill layout based on values from the list's view. Always call back to
+ * this class's getter/setter when inheriting.
+ *
+ * @note Don't short-circuit the setter if the given index is equal to the
+ * existing index. Rows can be reused to display new data at the same index.
+ *
+ * @type {integer}
+ */
+ get index() {
+ return this._index;
+ }
+
+ set index(index) {
+ this.setAttribute(
+ "role",
+ this.list.table.body.getAttribute("role") === "tree"
+ ? "treeitem"
+ : "option"
+ );
+ this.setAttribute("aria-posinset", index + 1);
+ this.id = `${this.list.id}-row${index}`;
+
+ const isGroup = this.view.isContainer(index);
+ this.classList.toggle("children", isGroup);
+
+ const isGroupOpen = this.view.isContainerOpen(index);
+ if (isGroup) {
+ this.setAttribute("aria-expanded", isGroupOpen);
+ } else {
+ this.removeAttribute("aria-expanded");
+ }
+ this.classList.toggle("collapsed", !isGroupOpen);
+ this._index = index;
+
+ let table = this.closest("table");
+ for (let column of table.columns) {
+ let cell = this.querySelector(`.${column.id.toLowerCase()}-column`);
+ // No need to do anything if this cell doesn't exist. This can happen
+ // for non-table layouts.
+ if (!cell) {
+ continue;
+ }
+
+ // Always clear the colspan when updating the columns.
+ cell.removeAttribute("colspan");
+
+ // No need to do anything if this column is hidden.
+ if (cell.hidden) {
+ continue;
+ }
+
+ // Handle the special case for the selectable checkbox column.
+ if (column.select) {
+ let img = cell.firstElementChild;
+ if (!img) {
+ cell.classList.add("tree-view-row-select");
+ img = document.createElement("img");
+ img.src = "";
+ img.tabIndex = -1;
+ img.classList.add("tree-view-row-select-checkbox");
+ cell.replaceChildren(img);
+ }
+ document.l10n.setAttributes(
+ img,
+ this.list._selection.isSelected(index)
+ ? "tree-list-view-row-deselect"
+ : "tree-list-view-row-select"
+ );
+ continue;
+ }
+
+ // No need to do anything if an earlier call to this function already
+ // added the cell contents.
+ if (cell.firstElementChild) {
+ continue;
+ }
+ }
+
+ // Account for the column picker in the last visible column if the table
+ // if editable.
+ if (table.editable) {
+ let last = table.columns.filter(c => !c.hidden).pop();
+ this.querySelector(`.${last.id.toLowerCase()}-column`)?.setAttribute(
+ "colspan",
+ "2"
+ );
+ }
+ }
+
+ /**
+ * Tracks the selection state of the current row.
+ *
+ * @type {boolean}
+ */
+ get selected() {
+ return this.classList.contains("selected");
+ }
+
+ set selected(selected) {
+ this.setAttribute("aria-selected", !!selected);
+ this.classList.toggle("selected", !!selected);
+ }
+}
+customElements.define("tree-view-table-row", TreeViewTableRow, {
+ extends: "tr",
+});
+
+/**
+ * Simple tbody spacer used above and below the main tbody for space
+ * allocation and ensuring the correct scrollable height.
+ */
+class TreeViewTableSpacer extends HTMLTableSectionElement {
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+
+ this.cell = document.createElement("td");
+ const row = document.createElement("tr");
+ row.appendChild(this.cell);
+ this.appendChild(row);
+ }
+
+ /**
+ * Set the cell colspan to reflect the number of visible columns in order
+ * to generate a correct HTML markup.
+ *
+ * @param {int} count - The columns count.
+ */
+ setColspan(count) {
+ this.cell.setAttribute("colspan", count);
+ }
+
+ /**
+ * Set the height of the cell in order to occupy the empty area that will
+ * be filled by new rows on demand when needed.
+ *
+ * @param {int} val - The pixel height the row should occupy.
+ */
+ setHeight(val) {
+ this.cell.style.height = `${val}px`;
+ }
+}
+customElements.define("tree-view-table-spacer", TreeViewTableSpacer, {
+ extends: "tbody",
+});
diff --git a/comm/mail/base/jar.mn b/comm/mail/base/jar.mn
new file mode 100644
index 0000000000..53a295719e
--- /dev/null
+++ b/comm/mail/base/jar.mn
@@ -0,0 +1,144 @@
+#filter substitution
+
+messenger.jar:
+% content messagebody %content/messagebody/ contentaccessible=yes
+% content messenger %content/messenger/ contentaccessible=yes
+% override chrome://messagebody/skin/messageBody.css chrome://messenger/skin/messageBody.css
+% override chrome://messagebody/skin/abPrint.css chrome://messenger/skin/shared/abPrint.css
+% override chrome://global/content/commonDialog.xhtml chrome://messenger/content/commonDialog.xhtml
+# Override utilityOverlay.js from browser because it is used directly in devtools
+% override chrome://browser/content/utilityOverlay.js chrome://communicator/content/utilityOverlay.js
+ content/messenger/about3Pane.js (content/about3Pane.js)
+* content/messenger/about3Pane.xhtml (content/about3Pane.xhtml)
+ content/messenger/aboutAddonsExtra.js (content/aboutAddonsExtra.js)
+ content/messenger/aboutDialog-appUpdater.js (content/aboutDialog-appUpdater.js)
+ content/messenger/aboutDialog.css (content/aboutDialog.css)
+ content/messenger/aboutDialog.js (content/aboutDialog.js)
+* content/messenger/aboutDialog.xhtml (content/aboutDialog.xhtml)
+ content/messenger/aboutMessage.js (content/aboutMessage.js)
+* content/messenger/aboutMessage.xhtml (content/aboutMessage.xhtml)
+ content/messenger/aboutRights.xhtml (content/aboutRights.xhtml)
+ content/messenger/browserRequest.js (content/browserRequest.js)
+* content/messenger/browserRequest.xhtml (content/browserRequest.xhtml)
+ content/messenger/commonDialog.xhtml (content/commonDialog.xhtml)
+ content/messenger/compactFoldersDialog.js (content/compactFoldersDialog.js)
+ content/messenger/compactFoldersDialog.xhtml (content/compactFoldersDialog.xhtml)
+ content/messenger/customElements.js (content/customElements.js)
+ content/messenger/customizeToolbar.js (content/customizeToolbar.js)
+ content/messenger/customizeToolbar.xhtml (content/customizeToolbar.xhtml)
+ content/messenger/dialogShadowDom.js (content/dialogShadowDom.js)
+ content/messenger/editContactPanel.js (content/editContactPanel.js)
+ content/messenger/FilterListDialog.js (content/FilterListDialog.js)
+ content/messenger/FilterListDialog.xhtml (content/FilterListDialog.xhtml)
+ content/messenger/folderDisplay.js (content/folderDisplay.js)
+ content/messenger/globalOverlay.js (content/globalOverlay.js)
+ content/messenger/glodaFacetTab.js (content/glodaFacetTab.js)
+ content/messenger/glodaFacetView.js (content/glodaFacetView.js)
+ content/messenger/glodaFacetView.xhtml (content/glodaFacetView.xhtml)
+ content/messenger/glodaFacetViewWrapper.xhtml (content/glodaFacetViewWrapper.xhtml)
+ content/messenger/glodaFacetVis.js (content/glodaFacetVis.js)
+#ifdef XP_MACOSX
+ content/messenger/hiddenWindowMac.js (content/hiddenWindowMac.js)
+* content/messenger/hiddenWindowMac.xhtml (content/hiddenWindowMac.xhtml)
+ content/messenger/macMessengerMenu.js (content/macMessengerMenu.js)
+#endif
+ content/messenger/mail-offline.js (content/mail-offline.js)
+ content/messenger/mail3PaneWindowCommands.js (content/mail3PaneWindowCommands.js)
+ content/messenger/mailCommands.js (content/mailCommands.js)
+ content/messenger/mailCommon.js (content/mailCommon.js)
+ content/messenger/mailContext.js (content/mailContext.js)
+ content/messenger/mailCore.js (content/mailCore.js)
+ content/messenger/mailTabs.js (content/mailTabs.js)
+ content/messenger/mailWindow.js (content/mailWindow.js)
+ content/messenger/mailWindowOverlay.js (content/mailWindowOverlay.js)
+ content/messenger/messageWindow.js (content/messageWindow.js)
+* content/messenger/messageWindow.xhtml (content/messageWindow.xhtml)
+ content/messenger/messenger-customization.js (content/messenger-customization.js)
+ content/messenger/messenger.js (content/messenger.js)
+* content/messenger/messenger.xhtml (content/messenger.xhtml)
+ content/messenger/webextensions.css (content/webextensions.css)
+#ifdef XP_WIN
+ content/messenger/minimizeToTray.js (content/minimizeToTray.js)
+#endif
+ content/messenger/migrationProgress.js (content/migrationProgress.js)
+ content/messenger/migrationProgress.xhtml (content/migrationProgress.xhtml)
+ content/messenger/msgHdrView.js (content/msgHdrView.js)
+ content/messenger/msgSecurityPane.js (content/msgSecurityPane.js)
+ content/messenger/msgViewNavigation.js (content/msgViewNavigation.js)
+ content/messenger/multimessageview.js (content/multimessageview.js)
+ content/messenger/multimessageview.xhtml (content/multimessageview.xhtml)
+ content/messenger/newTagDialog.js (content/newTagDialog.js)
+* content/messenger/newTagDialog.xhtml (content/newTagDialog.xhtml)
+ content/messenger/printUtils.js (content/printUtils.js)
+ content/messenger/protovis-r2.6-modded.js (content/protovis-r2.6-modded.js)
+ content/messenger/quickFilterBar.js (content/quickFilterBar.js)
+ content/messenger/sanitize.js (content/sanitize.js)
+ content/messenger/sanitize.xhtml (content/sanitize.xhtml)
+ content/messenger/sanitizeDialog.js (content/sanitizeDialog.js)
+ content/messenger/searchBar.js (content/searchBar.js)
+ content/messenger/SearchDialog.js (content/SearchDialog.js)
+* content/messenger/SearchDialog.xhtml (content/SearchDialog.xhtml)
+ content/messenger/selectionsummaries.js (content/selectionsummaries.js)
+ content/messenger/shortcutsOverlay.js (content/shortcutsOverlay.js)
+ content/messenger/spacesToolbar.js (content/spacesToolbar.js)
+ content/messenger/specialTabs.js (content/specialTabs.js)
+#ifdef NIGHTLY_BUILD
+ content/messenger/sync.js (content/sync.js)
+#endif
+ content/messenger/systemIntegrationDialog.js (content/systemIntegrationDialog.js)
+ content/messenger/systemIntegrationDialog.xhtml (content/systemIntegrationDialog.xhtml)
+ content/messenger/tabmail.js (content/tabmail.js)
+ content/messenger/threadPane.js (content/threadPane.js)
+ content/messenger/toolbarIconColor.js (content/toolbarIconColor.js)
+ content/messenger/troubleshootMode.js (content/troubleshootMode.js)
+ content/messenger/troubleshootMode.xhtml (content/troubleshootMode.xhtml)
+ content/messenger/viewSource.js (content/viewSource.js)
+* content/messenger/viewSource.xhtml (content/viewSource.xhtml)
+ content/messenger/viewZoomOverlay.js (content/viewZoomOverlay.js)
+ content/messenger/browserPopups.js (content/widgets/browserPopups.js)
+ content/messenger/customizable-toolbar.js (content/widgets/customizable-toolbar.js)
+ content/messenger/foldersummary.js (content/widgets/foldersummary.js)
+ content/messenger/gloda-autocomplete-input.js (content/widgets/gloda-autocomplete-input.js)
+ content/messenger/glodaFacet.js (content/widgets/glodaFacet.js)
+ content/messenger/header-fields.js (content/widgets/header-fields.js)
+ content/messenger/mailWidgets.js (content/widgets/mailWidgets.js)
+ content/messenger/pane-splitter.js (content/widgets/pane-splitter.js)
+ content/messenger/statuspanel.js (content/widgets/statuspanel.js)
+ content/messenger/tabmail-tab.js (content/widgets/tabmail-tab.js)
+ content/messenger/tabmail-tabs.js (content/widgets/tabmail-tabs.js)
+ content/messenger/toolbarbutton-menu-button.js (content/widgets/toolbarbutton-menu-button.js)
+ content/messenger/tree-listbox.js (content/widgets/tree-listbox.js)
+ content/messenger/tree-selection.mjs (content/widgets/tree-selection.mjs)
+ content/messenger/tree-view.mjs (content/widgets/tree-view.mjs)
+ content/messenger/thread-pane-columns.mjs (content/modules/thread-pane-columns.mjs)
+
+# the following files are mail-specific overrides
+* content/messenger/license.html (/toolkit/content/license.html)
+% override chrome://global/content/license.html chrome://messenger/content/license.html
+ content/messenger/profileDowngrade.js (content/profileDowngrade.js)
+* content/messenger/profileDowngrade.xhtml (content/profileDowngrade.xhtml)
+% override chrome://mozapps/content/profile/profileDowngrade.js chrome://messenger/content/profileDowngrade.js
+% override chrome://mozapps/content/profile/profileDowngrade.xhtml chrome://messenger/content/profileDowngrade.xhtml
+
+* content/messenger/buildconfig.html (content/buildconfig.html)
+% override chrome://global/content/buildconfig.html chrome://messenger/content/buildconfig.html
+
+comm.jar:
+% content communicator %content/communicator/
+ content/communicator/contentAreaClick.js (content/contentAreaClick.js)
+ content/communicator/utilityOverlay.js (content/utilityOverlay.js)
+
+browser.jar:
+# Needed for built_in_addons.json
+% content browser %content/
+ content/extension.css (/browser/components/extensions/extension.css)
+#ifdef XP_LINUX
+ content/extension-linux-panel.css (/browser/components/extensions/extension-linux-panel.css)
+#endif
+#ifdef XP_MACOSX
+ content/extension-mac.css (/browser/components/extensions/extension-mac.css)
+ content/extension-mac-panel.css (/browser/components/extensions/extension-mac-panel.css)
+#endif
+#ifdef XP_WIN
+ content/extension-win-panel.css (/browser/components/extensions/extension-win-panel.css)
+#endif
diff --git a/comm/mail/base/moz.build b/comm/mail/base/moz.build
new file mode 100644
index 0000000000..15558e8d3c
--- /dev/null
+++ b/comm/mail/base/moz.build
@@ -0,0 +1,54 @@
+# vim: set filetype=python:
+# 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/.
+
+TEST_DIRS += ["test"]
+
+JAR_MANIFESTS += ["jar.mn"]
+
+DEFINES["PRE_RELEASE_SUFFIX"] = ""
+DEFINES["MOZ_APP_VERSION"] = CONFIG["MOZ_APP_VERSION"]
+DEFINES["MOZ_APP_VERSION_DISPLAY"] = CONFIG["MOZ_APP_VERSION_DISPLAY"]
+DEFINES["APP_LICENSE_BLOCK"] = "%s/content/overrides/app-license.html" % SRCDIR
+DEFINES["APP_LICENSE_PRODUCT_NAME"] = "%s/content/overrides/app-license-name.html" % SRCDIR
+DEFINES["APP_LICENSE_LIST_BLOCK"] = "%s/content/overrides/app-license-list.html" % SRCDIR
+DEFINES["APP_LICENSE_BODY_BLOCK"] = "%s/content/overrides/app-license-body.html" % SRCDIR
+
+if CONFIG["MOZILLA_OFFICIAL"]:
+ DEFINES["OFFICIAL_BUILD"] = 1
+
+if CONFIG["MOZ_WIDGET_TOOLKIT"] in ("windows", "gtk", "cocoa"):
+ DEFINES["HAVE_SHELL_SERVICE"] = 1
+
+if CONFIG["MOZ_UPDATER"]:
+ DEFINES["MOZ_UPDATER"] = 1
+
+# For customized buildconfig
+DEFINES["TOPOBJDIR"] = TOPOBJDIR
+
+DEFINES["MOZ_APP_DISPLAYNAME"] = CONFIG["MOZ_APP_DISPLAYNAME"]
+DEFINES["MOZ_APP_VERSION_DISPLAY"] = CONFIG["MOZ_APP_VERSION_DISPLAY"]
+DEFINES["THUNDERBIRD_DEVELOPER_WWW"] = "https://developer.thunderbird.net/"
+
+for var in ("CC", "CC_VERSION", "CXX", "RUSTC", "RUSTC_VERSION"):
+ if CONFIG[var]:
+ DEFINES[var] = CONFIG[var]
+
+for var in ("MOZ_CONFIGURE_OPTIONS",):
+ DEFINES[var] = CONFIG[var]
+
+ DEFINES["target"] = CONFIG["target"]
+
+DEFINES["CFLAGS"] = " ".join(CONFIG["OS_CFLAGS"])
+
+rustflags = CONFIG["RUSTFLAGS"]
+if not rustflags:
+ rustflags = []
+DEFINES["RUSTFLAGS"] = " ".join(rustflags)
+
+cxx_flags = []
+for var in ("OS_CPPFLAGS", "OS_CXXFLAGS", "DEBUG", "OPTIMIZE", "FRAMEPTR"):
+ cxx_flags += COMPILE_FLAGS[var] or []
+
+DEFINES["CXXFLAGS"] = " ".join(cxx_flags)
diff --git a/comm/mail/base/test/browser/browser-detachedWindows.ini b/comm/mail/base/test/browser/browser-detachedWindows.ini
new file mode 100644
index 0000000000..5932a9b682
--- /dev/null
+++ b/comm/mail/base/test/browser/browser-detachedWindows.ini
@@ -0,0 +1,15 @@
+[DEFAULT]
+head = head.js
+prefs =
+ mail.provider.suppress_dialog_on_startup=true
+ mail.spellcheck.inline=false
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.database.global.indexer.enabled=false
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+subsuite = thunderbird
+support-files = files/**
+
+[browser_detachedWindows.js]
+skip-if = debug
diff --git a/comm/mail/base/test/browser/browser-drawBelowTitlebar.ini b/comm/mail/base/test/browser/browser-drawBelowTitlebar.ini
new file mode 100644
index 0000000000..63963b8145
--- /dev/null
+++ b/comm/mail/base/test/browser/browser-drawBelowTitlebar.ini
@@ -0,0 +1,17 @@
+[DEFAULT]
+head = head.js
+prefs =
+ mail.provider.suppress_dialog_on_startup=true
+ mail.spellcheck.inline=false
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.database.global.indexer.enabled=false
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+ mail.tabs.drawInTitlebar=false
+subsuite = thunderbird
+support-files =
+ head_spacesToolbar.js
+skip-if = os == 'mac'
+
+[browser_spacesToolbar_drawBelowTitlebar.js]
diff --git a/comm/mail/base/test/browser/browser-drawInTitlebar.ini b/comm/mail/base/test/browser/browser-drawInTitlebar.ini
new file mode 100644
index 0000000000..0b73400c5a
--- /dev/null
+++ b/comm/mail/base/test/browser/browser-drawInTitlebar.ini
@@ -0,0 +1,17 @@
+[DEFAULT]
+head = head.js
+prefs =
+ mail.provider.suppress_dialog_on_startup=true
+ mail.spellcheck.inline=false
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.database.global.indexer.enabled=false
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+ mail.tabs.drawInTitlebar=true
+subsuite = thunderbird
+support-files =
+ head_spacesToolbar.js
+skip-if = os == 'mac'
+
+[browser_spacesToolbar_drawInTitlebar.js]
diff --git a/comm/mail/base/test/browser/browser.ini b/comm/mail/base/test/browser/browser.ini
new file mode 100644
index 0000000000..4ff3a5d866
--- /dev/null
+++ b/comm/mail/base/test/browser/browser.ini
@@ -0,0 +1,66 @@
+[DEFAULT]
+head = head.js
+prefs =
+ mail.biff.show_alert=false
+ mail.provider.suppress_dialog_on_startup=true
+ mail.spellcheck.inline=false
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+subsuite = thunderbird
+support-files = files/**
+
+[browser_3paneTelemetry.js]
+[browser_archive.js]
+[browser_browserContext.js]
+tags = contextmenu webextensions
+[browser_browserRequestWindow.js]
+[browser_cardsView.js]
+[browser_editMenu.js]
+skip-if = os == 'mac'
+[browser_fileMenu.js]
+skip-if = os == 'mac'
+[browser_folderPaneContext.js]
+tags = contextmenu
+[browser_folderTreeProperties.js]
+[browser_folderTreeQuirks.js]
+[browser_formPickers.js]
+tags = webextensions
+[browser_goMenu.js]
+skip-if = os == 'mac'
+[browser_interactionTelemetry.js]
+[browser_linkHandler.js]
+[browser_mailContext.js]
+tags = contextmenu
+[browser_mailTabsAndWindows.js]
+[browser_markAsRead.js]
+[browser_menulist.js]
+skip-if = os == 'mac'
+[browser_messageMenu.js]
+skip-if = os == 'mac'
+[browser_navigation.js]
+[browser_orderableTreeListbox.js]
+[browser_paneFocus.js]
+[browser_paneSplitter.js]
+[browser_preferDisplayName.js]
+[browser_searchMessages.js]
+[browser_smartFolderDelete.js]
+[browser_spacesToolbar.js]
+[browser_spacesToolbarCustomize.js]
+[browser_selectionWidgetController.js]
+[browser_statusFeedback.js]
+[browser_tabIcon.js]
+[browser_tagsMode.js]
+[browser_threads.js]
+[browser_threadTreeDeleting.js]
+[browser_threadTreeQuirks.js]
+[browser_threadTreeSorting.js]
+[browser_toolsMenu.js]
+skip-if = os == 'mac'
+[browser_treeListbox.js]
+[browser_treeView.js]
+[browser_viewMenu.js]
+skip-if = os == 'mac'
+[browser_webSearchTelemetry.js]
+[browser_zoom.js]
diff --git a/comm/mail/base/test/browser/browser_3paneTelemetry.js b/comm/mail/base/test/browser/browser_3paneTelemetry.js
new file mode 100644
index 0000000000..84af064e84
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_3paneTelemetry.js
@@ -0,0 +1,163 @@
+/* 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/. */
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+var { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+
+var tabmail = document.getElementById("tabmail");
+var folders = {};
+
+add_setup(async function () {
+ MailServices.accounts.createLocalMailAccount();
+ let account = MailServices.accounts.accounts[0];
+ let rootFolder = account.incomingServer.rootFolder;
+
+ for (let type of ["Drafts", "SentMail", "Templates", "Junk", "Archive"]) {
+ rootFolder.createSubfolder(`telemetry${type}`, null);
+ folders[type] = rootFolder.getChildNamed(`telemetry${type}`);
+ folders[type].setFlag(Ci.nsMsgFolderFlags[type]);
+ }
+ rootFolder.createSubfolder("telemetryPlain", null);
+ folders.Other = rootFolder.getChildNamed("telemetryPlain");
+
+ let { paneLayout } = tabmail.currentAbout3Pane;
+ let folderPaneVisibleAtStart = paneLayout.folderPaneVisible;
+ let messagePaneVisibleAtStart = paneLayout.messagePaneVisible;
+
+ registerCleanupFunction(function () {
+ MailServices.accounts.removeAccount(account, false);
+ tabmail.closeOtherTabs(0);
+ if (paneLayout.folderPaneVisible != folderPaneVisibleAtStart) {
+ goDoCommand("cmd_toggleFolderPane");
+ }
+ if (paneLayout.messagePaneVisible != messagePaneVisibleAtStart) {
+ goDoCommand("cmd_toggleMessagePane");
+ }
+ });
+});
+
+add_task(async function testFolderOpen() {
+ Services.telemetry.clearScalars();
+
+ let about3Pane = tabmail.currentAbout3Pane;
+ about3Pane.displayFolder(folders.Other.URI);
+
+ let scalarName = "tb.mails.folder_opened";
+ let scalars = TelemetryTestUtils.getProcessScalars("parent", true);
+ TelemetryTestUtils.assertKeyedScalar(scalars, scalarName, "Other", 1);
+
+ about3Pane.displayFolder(folders.Templates.URI);
+ about3Pane.displayFolder(folders.Other.URI);
+
+ scalars = TelemetryTestUtils.getProcessScalars("parent", true);
+ TelemetryTestUtils.assertKeyedScalar(scalars, scalarName, "Other", 2);
+ TelemetryTestUtils.assertKeyedScalar(scalars, scalarName, "Templates", 1);
+
+ about3Pane.displayFolder(folders.Junk.URI);
+ about3Pane.displayFolder(folders.Other.URI);
+
+ scalars = TelemetryTestUtils.getProcessScalars("parent", true);
+ TelemetryTestUtils.assertKeyedScalar(scalars, scalarName, "Other", 3);
+ TelemetryTestUtils.assertKeyedScalar(scalars, scalarName, "Templates", 1);
+ TelemetryTestUtils.assertKeyedScalar(scalars, scalarName, "Junk", 1);
+
+ about3Pane.displayFolder(folders.Junk.URI);
+ about3Pane.displayFolder(folders.Templates.URI);
+ about3Pane.displayFolder(folders.Archive.URI);
+ about3Pane.displayFolder(folders.Other.URI);
+
+ scalars = TelemetryTestUtils.getProcessScalars("parent", true);
+ TelemetryTestUtils.assertKeyedScalar(scalars, scalarName, "Other", 4);
+ TelemetryTestUtils.assertKeyedScalar(scalars, scalarName, "Templates", 2);
+ TelemetryTestUtils.assertKeyedScalar(scalars, scalarName, "Archive", 1);
+ TelemetryTestUtils.assertKeyedScalar(scalars, scalarName, "Junk", 2);
+});
+
+add_task(async function testPaneVisibility() {
+ let { paneLayout, displayFolder } = tabmail.currentAbout3Pane;
+ displayFolder(folders.Other.URI);
+ // Make the folder pane and message pane visible initially.
+ if (!paneLayout.folderPaneVisible) {
+ goDoCommand("cmd_toggleFolderPane");
+ }
+ if (!paneLayout.messagePaneVisible) {
+ goDoCommand("cmd_toggleMessagePane");
+ }
+ // The scalar is updated by switching to the folder tab, so open another tab.
+ window.openContentTab("about:mozilla");
+
+ Services.telemetry.clearScalars();
+
+ tabmail.switchToTab(0);
+
+ let scalarName = "tb.ui.configuration.pane_visibility";
+ let scalars = TelemetryTestUtils.getProcessScalars("parent", true);
+ TelemetryTestUtils.assertKeyedScalar(scalars, scalarName, "folderPane", true);
+ TelemetryTestUtils.assertKeyedScalar(
+ scalars,
+ scalarName,
+ "messagePane",
+ true
+ );
+
+ // Hide the folder pane.
+ goDoCommand("cmd_toggleFolderPane");
+ tabmail.switchToTab(1);
+ tabmail.switchToTab(0);
+
+ scalars = TelemetryTestUtils.getProcessScalars("parent", true);
+ TelemetryTestUtils.assertKeyedScalar(
+ scalars,
+ scalarName,
+ "folderPane",
+ false
+ );
+ TelemetryTestUtils.assertKeyedScalar(
+ scalars,
+ scalarName,
+ "messagePane",
+ true
+ );
+
+ // Hide the message pane.
+ goDoCommand("cmd_toggleMessagePane");
+ tabmail.switchToTab(1);
+ tabmail.switchToTab(0);
+
+ scalars = TelemetryTestUtils.getProcessScalars("parent", true);
+ TelemetryTestUtils.assertKeyedScalar(
+ scalars,
+ scalarName,
+ "folderPane",
+ false
+ );
+ TelemetryTestUtils.assertKeyedScalar(
+ scalars,
+ scalarName,
+ "messagePane",
+ false
+ );
+
+ // Show both panes again.
+ goDoCommand("cmd_toggleFolderPane");
+ goDoCommand("cmd_toggleMessagePane");
+ tabmail.switchToTab(1);
+ tabmail.switchToTab(0);
+
+ scalars = TelemetryTestUtils.getProcessScalars("parent", true);
+ TelemetryTestUtils.assertKeyedScalar(scalars, scalarName, "folderPane", true);
+ TelemetryTestUtils.assertKeyedScalar(
+ scalars,
+ scalarName,
+ "messagePane",
+ true
+ );
+
+ // Close the extra tab.
+ tabmail.closeOtherTabs(0);
+});
diff --git a/comm/mail/base/test/browser/browser_archive.js b/comm/mail/base/test/browser/browser_archive.js
new file mode 100644
index 0000000000..6a84aff45a
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_archive.js
@@ -0,0 +1,98 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals messenger */
+
+const { MessageGenerator } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MessageGenerator.jsm"
+);
+
+const tabmail = document.getElementById("tabmail");
+const about3Pane = tabmail.currentAbout3Pane;
+const { threadTree } = about3Pane;
+
+add_setup(async function () {
+ Services.prefs.setBoolPref("mailnews.scroll_to_new_message", false);
+ // Create an account for the test.
+ MailServices.accounts.createLocalMailAccount();
+ const account = MailServices.accounts.accounts[0];
+ account.addIdentity(MailServices.accounts.createIdentity());
+
+ // Remove test account on cleanup.
+ registerCleanupFunction(() => {
+ // This test should create mailbox://nobody@Local%20Folders/Archives/2000.
+ // Tests following this one may attempt to create a folder at the same URI
+ // and will fail because our folder lookup code is a mess. Renaming should
+ // prevent that.
+ let archiveFolder = rootFolder.getFolderWithFlags(
+ Ci.nsMsgFolderFlags.Archive
+ );
+ archiveFolder?.subFolders[0]?.rename("archive2000", null);
+ archiveFolder?.rename("archiveArchives", null);
+
+ MailServices.accounts.removeAccount(account, false);
+ // Clear the undo and redo stacks to avoid side-effects on
+ // tests expecting them to start in a cleared state.
+ messenger.transactionManager.clear();
+ Services.prefs.setBoolPref("mailnews.scroll_to_new_message", true);
+ });
+
+ // Create a folder for the account to store test messages.
+ const rootFolder = account.incomingServer.rootFolder;
+ rootFolder.createSubfolder("test-archive", null);
+ const testFolder = rootFolder
+ .getChildNamed("test-archive")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+
+ // Generate test messages.
+ const generator = new MessageGenerator();
+ testFolder.addMessageBatch(
+ generator
+ .makeMessages({ count: 2, msgsPerThread: 2 })
+ .map(message => message.toMboxString())
+ );
+
+ // Use the test folder.
+ about3Pane.displayFolder(testFolder.URI);
+});
+
+/**
+ * Tests undoing after archiving a thread.
+ */
+add_task(async function testArchiveUndo() {
+ let row = threadTree.getRowAtIndex(0);
+
+ // Simulate a click on the row's subject line to select the row.
+ const selectPromise = BrowserTestUtils.waitForEvent(threadTree, "select");
+ EventUtils.synthesizeMouseAtCenter(
+ row.querySelector(".thread-card-subject-container"),
+ { clickCount: 1 },
+ about3Pane
+ );
+ await selectPromise;
+
+ // Make sure the thread is selected
+ Assert.ok(
+ row.classList.contains("selected"),
+ "The thread row should be selected"
+ );
+
+ // Archive the message.
+ EventUtils.synthesizeKey("a");
+
+ // Make sure the thread was removed from the thread tree.
+ await TestUtils.waitForCondition(
+ () => threadTree.getRowAtIndex(0) === null,
+ "The thread tree should not have any row"
+ );
+
+ // Undo the operation.
+ EventUtils.synthesizeKey("z", { accelKey: true });
+
+ // Make sure the thread makes it back to the thread tree.
+ await TestUtils.waitForCondition(
+ () => threadTree.getRowAtIndex(0) !== null,
+ "The thread should have returned back from the archive"
+ );
+});
diff --git a/comm/mail/base/test/browser/browser_browserContext.js b/comm/mail/base/test/browser/browser_browserContext.js
new file mode 100644
index 0000000000..2691eba80e
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_browserContext.js
@@ -0,0 +1,398 @@
+/* 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/. */
+
+/* eslint-env webextensions */
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+var { MessageGenerator } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MessageGenerator.jsm"
+);
+
+const TEST_DOCUMENT_URL =
+ "http://mochi.test:8888/browser/comm/mail/base/test/browser/files/sampleContent.html";
+const TEST_MESSAGE_URL =
+ "http://mochi.test:8888/browser/comm/mail/base/test/browser/files/sampleContent.eml";
+const TEST_IMAGE_URL =
+ "http://mochi.test:8888/browser/comm/mail/base/test/browser/files/tb-logo.png";
+
+let about3Pane, testFolder;
+
+async function getImageArrayBuffer() {
+ let response = await fetch(TEST_IMAGE_URL);
+ let blob = await response.blob();
+
+ return new Promise((resolve, reject) => {
+ let reader = new FileReader();
+ reader.addEventListener("loadend", event => {
+ resolve(event.target.result);
+ });
+ reader.readAsArrayBuffer(blob);
+ });
+}
+
+function checkMenuitems(menu, ...expectedItems) {
+ if (expectedItems.length == 0) {
+ // Menu should not be shown.
+ Assert.equal(menu.state, "closed");
+ return;
+ }
+
+ Assert.notEqual(menu.state, "closed");
+
+ let actualItems = [];
+ for (let item of menu.children) {
+ if (
+ ["menu", "menuitem", "menugroup"].includes(item.localName) &&
+ !item.hidden
+ ) {
+ actualItems.push(item.id);
+ }
+ }
+ Assert.deepEqual(actualItems, expectedItems);
+}
+
+async function checkABrowser(browser, doc = browser.ownerDocument) {
+ if (
+ browser.webProgress?.isLoadingDocument ||
+ !browser.currentURI ||
+ browser.currentURI?.spec == "about:blank"
+ ) {
+ await BrowserTestUtils.browserLoaded(
+ browser,
+ undefined,
+ url => url != "about:blank"
+ );
+ }
+
+ let browserContext = doc.getElementById("browserContext");
+ let isMac = AppConstants.platform == "macosx";
+ let isWebPage =
+ browser.currentURI.schemeIs("http") || browser.currentURI.schemeIs("https");
+ let isExtensionPage = browser.currentURI.schemeIs("moz-extension");
+
+ // Just some text.
+
+ let shownPromise = BrowserTestUtils.waitForEvent(
+ browserContext,
+ "popupshown"
+ );
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "p",
+ { type: "contextmenu" },
+ browser
+ );
+ await shownPromise;
+
+ let expectedContextItems = [];
+ if (isWebPage || isExtensionPage) {
+ if (isMac) {
+ // Mac has the nav items directly in the context menu and not in the horizontal
+ // context-navigation menugroup.
+ expectedContextItems.push(
+ "browserContext-back",
+ "browserContext-forward",
+ "browserContext-reload"
+ );
+ } else {
+ expectedContextItems.push("context-navigation");
+ checkMenuitems(
+ doc.getElementById("context-navigation"),
+ "browserContext-back",
+ "browserContext-forward",
+ "browserContext-reload"
+ );
+ }
+ }
+ if (isWebPage) {
+ expectedContextItems.push("browserContext-openInBrowser");
+ }
+ expectedContextItems.push("browserContext-selectall");
+ checkMenuitems(browserContext, ...expectedContextItems);
+ browserContext.hidePopup();
+
+ // A link.
+
+ shownPromise = BrowserTestUtils.waitForEvent(browserContext, "popupshown");
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "a",
+ { type: "contextmenu" },
+ browser
+ );
+ await shownPromise;
+ checkMenuitems(
+ browserContext,
+ "browserContext-openLinkInBrowser",
+ "browserContext-selectall",
+ "browserContext-copylink",
+ "browserContext-savelink"
+ );
+ browserContext.hidePopup();
+
+ // A text input widget.
+
+ await BrowserTestUtils.synthesizeMouseAtCenter("input", {}, browser);
+ shownPromise = BrowserTestUtils.waitForEvent(browserContext, "popupshown");
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "input",
+ { type: "contextmenu" },
+ browser
+ );
+ await shownPromise;
+ checkMenuitems(
+ browserContext,
+ "browserContext-undo",
+ "browserContext-cut",
+ "browserContext-copy",
+ "browserContext-paste",
+ "browserContext-selectall",
+ "browserContext-spell-check-enabled"
+ );
+ browserContext.hidePopup();
+
+ // An image. Also checks Save Image As works.
+
+ shownPromise = BrowserTestUtils.waitForEvent(browserContext, "popupshown");
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "img",
+ { type: "contextmenu" },
+ browser
+ );
+ await shownPromise;
+ checkMenuitems(
+ browserContext,
+ "browserContext-selectall",
+ "browserContext-copyimage",
+ "browserContext-saveimage"
+ );
+
+ let pickerPromise = new Promise(resolve => {
+ SpecialPowers.MockFilePicker.init(window);
+ SpecialPowers.MockFilePicker.showCallback = picker => {
+ resolve(picker.defaultString);
+ return Ci.nsIFilePicker.returnCancel;
+ };
+ });
+ browserContext.activateItem(doc.getElementById("browserContext-saveimage"));
+ Assert.equal(await pickerPromise, "tb-logo.png");
+ SpecialPowers.MockFilePicker.cleanup();
+}
+
+add_setup(async function () {
+ MailServices.accounts.createLocalMailAccount();
+ let account = MailServices.accounts.accounts[0];
+ account.addIdentity(MailServices.accounts.createIdentity());
+ let rootFolder = account.incomingServer.rootFolder;
+ rootFolder.createSubfolder("browserContextFolder", null);
+ testFolder = rootFolder
+ .getChildNamed("browserContextFolder")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+ let message = await fetch(TEST_MESSAGE_URL).then(r => r.text());
+ testFolder.addMessageBatch([message]);
+ let messages = new MessageGenerator().makeMessages({ count: 5 });
+ let messageStrings = messages.map(message => message.toMboxString());
+ testFolder.addMessageBatch(messageStrings);
+
+ about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+ about3Pane.restoreState({
+ folderURI: testFolder.URI,
+ messagePaneVisible: true,
+ });
+
+ registerCleanupFunction(() => {
+ MailServices.accounts.removeAccount(account, false);
+ });
+});
+
+add_task(async function testMessagePane() {
+ about3Pane.messagePane.displayWebPage(TEST_DOCUMENT_URL);
+ await checkABrowser(about3Pane.webBrowser, document);
+ about3Pane.messagePane.clearWebPage();
+});
+
+add_task(async function testContentTab() {
+ let tab = window.openContentTab(TEST_DOCUMENT_URL);
+ await checkABrowser(tab.browser);
+
+ let tabmail = document.getElementById("tabmail");
+ tabmail.closeTab(tab);
+});
+
+add_task(async function testExtensionTab() {
+ let extension = ExtensionTestUtils.loadExtension({
+ background: async () => {
+ await browser.tabs.create({ url: "sampleContent.html" });
+ browser.test.notifyPass("ready");
+ },
+ files: {
+ "sampleContent.html": await fetch(TEST_DOCUMENT_URL).then(response =>
+ response.text()
+ ),
+ "tb-logo.png": await getImageArrayBuffer(),
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("ready");
+
+ let tabmail = document.getElementById("tabmail");
+ await checkABrowser(tabmail.tabInfo[1].browser);
+ tabmail.closeOtherTabs(tabmail.tabInfo[0]);
+
+ await extension.unload();
+});
+
+add_task(async function testExtensionPopupWindow() {
+ let extension = ExtensionTestUtils.loadExtension({
+ background: async () => {
+ await browser.windows.create({
+ url: "sampleContent.html",
+ type: "popup",
+ width: 800,
+ height: 500,
+ });
+ browser.test.notifyPass("ready");
+ },
+ files: {
+ "sampleContent.html": await fetch(TEST_DOCUMENT_URL).then(response =>
+ response.text()
+ ),
+ "tb-logo.png": await getImageArrayBuffer(),
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("ready");
+
+ let extensionPopup = Services.wm.getMostRecentWindow("mail:extensionPopup");
+ // extensionPopup.xhtml needs time to initialise properly.
+ await new Promise(resolve => extensionPopup.setTimeout(resolve, 500));
+ await checkABrowser(extensionPopup.document.getElementById("requestFrame"));
+ await BrowserTestUtils.closeWindow(extensionPopup);
+
+ await extension.unload();
+});
+
+add_task(async function testExtensionBrowserAction() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "sampleContent.html": await fetch(TEST_DOCUMENT_URL).then(response =>
+ response.text()
+ ),
+ "tb-logo.png": await getImageArrayBuffer(),
+ },
+ manifest: {
+ applications: {
+ gecko: {
+ id: "browsercontext@mochi.test",
+ },
+ },
+ browser_action: {
+ default_popup: "sampleContent.html",
+ },
+ },
+ });
+
+ await extension.startup();
+
+ let { panel, browser } = await openExtensionPopup(
+ window,
+ "ext-browsercontext\\@mochi.test"
+ );
+ await TestUtils.waitForCondition(
+ () => browser.clientWidth > 100,
+ "waiting for browser to resize"
+ );
+ await checkABrowser(browser);
+ panel.hidePopup();
+
+ await extension.unload();
+});
+
+add_task(async function testExtensionComposeAction() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "sampleContent.html": await fetch(TEST_DOCUMENT_URL).then(response =>
+ response.text()
+ ),
+ "tb-logo.png": await getImageArrayBuffer(),
+ },
+ manifest: {
+ applications: {
+ gecko: {
+ id: "browsercontext@mochi.test",
+ },
+ },
+ compose_action: {
+ default_popup: "sampleContent.html",
+ },
+ },
+ });
+
+ await extension.startup();
+
+ let params = Cc[
+ "@mozilla.org/messengercompose/composeparams;1"
+ ].createInstance(Ci.nsIMsgComposeParams);
+ params.composeFields = Cc[
+ "@mozilla.org/messengercompose/composefields;1"
+ ].createInstance(Ci.nsIMsgCompFields);
+
+ let composeWindowPromise = BrowserTestUtils.domWindowOpened();
+ MailServices.compose.OpenComposeWindowWithParams(null, params);
+ let composeWindow = await composeWindowPromise;
+ await BrowserTestUtils.waitForEvent(composeWindow, "load");
+
+ let { panel, browser } = await openExtensionPopup(
+ composeWindow,
+ "browsercontext_mochi_test-composeAction-toolbarbutton"
+ );
+ await checkABrowser(browser);
+ panel.hidePopup();
+
+ await extension.unload();
+ await BrowserTestUtils.closeWindow(composeWindow);
+});
+
+add_task(async function testExtensionMessageDisplayAction() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "sampleContent.html": await fetch(TEST_DOCUMENT_URL).then(response =>
+ response.text()
+ ),
+ "tb-logo.png": await getImageArrayBuffer(),
+ },
+ manifest: {
+ applications: {
+ gecko: {
+ id: "browsercontext@mochi.test",
+ },
+ },
+ message_display_action: {
+ default_popup: "sampleContent.html",
+ },
+ },
+ });
+
+ await extension.startup();
+
+ let messageWindowPromise = BrowserTestUtils.domWindowOpened();
+ window.MsgOpenNewWindowForMessage([...testFolder.messages][0]);
+ let messageWindow = await messageWindowPromise;
+ let { target: aboutMessage } = await BrowserTestUtils.waitForEvent(
+ messageWindow,
+ "aboutMessageLoaded"
+ );
+
+ let { panel, browser } = await openExtensionPopup(
+ aboutMessage,
+ "browsercontext_mochi_test-messageDisplayAction-toolbarbutton"
+ );
+ await checkABrowser(browser);
+ panel.hidePopup();
+
+ await extension.unload();
+ await BrowserTestUtils.closeWindow(messageWindow);
+});
diff --git a/comm/mail/base/test/browser/browser_browserRequestWindow.js b/comm/mail/base/test/browser/browser_browserRequestWindow.js
new file mode 100644
index 0000000000..47309e5fc6
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_browserRequestWindow.js
@@ -0,0 +1,74 @@
+/* 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 ../../content/browserRequest.js */
+
+/**
+ * Open the browserRequest window.
+ *
+ * @returns {{cancelledPromise: Promise, requestWindow: DOMWindow}}
+ */
+async function openBrowserRequestWindow() {
+ let onCancelled;
+ let cancelledPromise = new Promise(resolve => {
+ onCancelled = resolve;
+ });
+ let requestWindow = await new Promise(resolve => {
+ Services.ww.openWindow(
+ null,
+ "chrome://messenger/content/browserRequest.xhtml",
+ null,
+ "chrome,private,centerscreen,width=980,height=750",
+ {
+ url: "http://mochi.test:8888/browser/comm/mail/base/test/browser/files/sampleContent.html",
+ cancelled() {
+ onCancelled();
+ },
+ loaded(window, webProgress) {
+ resolve(window);
+ },
+ }
+ );
+ });
+ return { cancelledPromise, requestWindow };
+}
+
+add_task(async function test_urlBar() {
+ let { requestWindow, cancelledPromise } = await openBrowserRequestWindow();
+
+ let browser = requestWindow.getBrowser();
+ await BrowserTestUtils.browserLoaded(browser);
+ ok(browser, "Got a browser from global getBrowser function");
+
+ let urlBar = requestWindow.document.getElementById("headerMessage");
+ is(urlBar.value, browser.currentURI.spec, "Initial page is shown in URL bar");
+
+ let redirect = BrowserTestUtils.browserLoaded(browser);
+ BrowserTestUtils.loadURIString(browser, "about:blank");
+ await redirect;
+ is(urlBar.value, "about:blank", "URL bar value follows browser");
+
+ const closeEvent = new Event("close");
+ requestWindow.dispatchEvent(closeEvent);
+ await BrowserTestUtils.closeWindow(requestWindow);
+ await cancelledPromise;
+});
+
+add_task(async function test_cancelWithEsc() {
+ let { requestWindow, cancelledPromise } = await openBrowserRequestWindow();
+
+ EventUtils.synthesizeKey("VK_ESCAPE", {}, requestWindow);
+ await cancelledPromise;
+});
+
+add_task(async function test_cancelWithAccelW() {
+ let { requestWindow, cancelledPromise } = await openBrowserRequestWindow();
+
+ EventUtils.synthesizeKey(
+ "w",
+ { [AppConstants.platform == "macosx" ? "metaKey" : "ctrlKey"]: true },
+ requestWindow
+ );
+ await cancelledPromise;
+});
diff --git a/comm/mail/base/test/browser/browser_cardsView.js b/comm/mail/base/test/browser/browser_cardsView.js
new file mode 100644
index 0000000000..462e21fba3
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_cardsView.js
@@ -0,0 +1,248 @@
+/* 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 { MessageGenerator } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MessageGenerator.jsm"
+);
+const { click_through_appmenu } = ChromeUtils.import(
+ "resource://testing-common/mozmill/WindowHelpers.jsm"
+);
+
+let tabmail = document.getElementById("tabmail");
+let about3Pane = tabmail.currentAbout3Pane;
+let { threadPane, threadTree } = about3Pane;
+let rootFolder, testFolder, testMessages, displayContext, displayButton;
+
+add_setup(async function () {
+ MailServices.accounts.createLocalMailAccount();
+ let account = MailServices.accounts.accounts[0];
+ account.addIdentity(MailServices.accounts.createIdentity());
+ rootFolder = account.incomingServer.rootFolder;
+
+ rootFolder.createSubfolder("cardsView", null);
+ testFolder = rootFolder
+ .getChildNamed("cardsView")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+
+ let generator = new MessageGenerator();
+ testFolder.addMessageBatch(
+ generator.makeMessages({ count: 5 }).map(message => message.toMboxString())
+ );
+ testMessages = [...testFolder.messages];
+
+ about3Pane.displayFolder(testFolder.URI);
+ about3Pane.paneLayout.messagePaneVisible = false;
+
+ registerCleanupFunction(() => {
+ MailServices.accounts.removeAccount(account, false);
+ about3Pane.paneLayout.messagePaneVisible = true;
+ about3Pane.folderTree.focus();
+ });
+});
+
+add_task(async function testSwitchToCardsView() {
+ Assert.ok(
+ threadTree.getAttribute("rows") == "thread-card",
+ "The tree view should have a card layout"
+ );
+
+ click_through_appmenu(
+ [{ id: "appmenu_View" }, { id: "appmenu_MessagePaneLayout" }],
+ { id: "appmenu_messagePaneClassic" },
+ window
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () => threadTree.getAttribute("rows") == "thread-card",
+ "The tree view should not switch to a table layout"
+ );
+
+ displayContext = about3Pane.document.getElementById(
+ "threadPaneDisplayContext"
+ );
+ displayButton = about3Pane.document.getElementById("threadPaneDisplayButton");
+ let shownPromise = BrowserTestUtils.waitForEvent(
+ displayContext,
+ "popupshown"
+ );
+ EventUtils.synthesizeMouseAtCenter(displayButton, {}, about3Pane);
+ await shownPromise;
+
+ Assert.ok(
+ displayContext
+ .querySelector("#threadPaneCardsView")
+ .getAttribute("checked"),
+ "The cards view menuitem should be checked"
+ );
+
+ let hiddenPromise = BrowserTestUtils.waitForEvent(
+ displayContext,
+ "popuphidden"
+ );
+ displayContext.activateItem(
+ displayContext.querySelector("#threadPaneTableView")
+ );
+ await BrowserTestUtils.waitForCondition(
+ () => threadTree.getAttribute("rows") == "thread-row",
+ "The tree view switched to a table layout"
+ );
+ EventUtils.synthesizeKey("KEY_Escape", {});
+ await hiddenPromise;
+
+ click_through_appmenu(
+ [{ id: "appmenu_View" }, { id: "appmenu_MessagePaneLayout" }],
+ { id: "appmenu_messagePaneVertical" },
+ window
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () => threadTree.getAttribute("rows") == "thread-row",
+ "The tree view should not switch to a card layout"
+ );
+
+ Assert.equal(
+ threadTree.table.body.getAttribute("role"),
+ "tree",
+ "The message list table should be presented as Tree View"
+ );
+ Assert.equal(
+ threadTree.getRowAtIndex(0).getAttribute("role"),
+ "treeitem",
+ "The message row should be presented as Tree Item"
+ );
+
+ displayContext = about3Pane.document.getElementById(
+ "threadPaneDisplayContext"
+ );
+ displayButton = about3Pane.document.getElementById("threadPaneDisplayButton");
+ shownPromise = BrowserTestUtils.waitForEvent(displayContext, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(displayButton, {}, about3Pane);
+ await shownPromise;
+
+ Assert.ok(
+ displayContext
+ .querySelector("#threadPaneTableView")
+ .getAttribute("checked"),
+ "The table view menuitem should be checked"
+ );
+
+ hiddenPromise = BrowserTestUtils.waitForEvent(displayContext, "popuphidden");
+ displayContext.activateItem(
+ displayContext.querySelector("#threadPaneCardsView")
+ );
+ await BrowserTestUtils.waitForCondition(
+ () => threadTree.getAttribute("rows") == "thread-card",
+ "The tree view switched to a card layout"
+ );
+ EventUtils.synthesizeKey("KEY_Escape", {});
+ await hiddenPromise;
+
+ Assert.equal(
+ threadTree.getAttribute("rows"),
+ "thread-card",
+ "tree view in cards layout"
+ );
+ Assert.equal(
+ threadTree.table.body.getAttribute("role"),
+ "tree",
+ "The message list table should remain as Tree View"
+ );
+ Assert.equal(
+ threadTree.getRowAtIndex(0).getAttribute("role"),
+ "treeitem",
+ "The message row should remain as Tree Item"
+ );
+
+ let row = threadTree.getRowAtIndex(0);
+ let star = row.querySelector(".button-star");
+ Assert.ok(BrowserTestUtils.is_visible(star), "star icon should be visible");
+ let tag = row.querySelector(".tag-icon");
+ Assert.ok(BrowserTestUtils.is_hidden(tag), "tag icon should be hidden");
+ let attachment = row.querySelector(".attachment-icon");
+ Assert.ok(
+ BrowserTestUtils.is_hidden(attachment),
+ "attachment icon should be hidden"
+ );
+
+ // Switching to horizontal view shouldn't affect the list layout.
+ click_through_appmenu(
+ [{ id: "appmenu_View" }, { id: "appmenu_MessagePaneLayout" }],
+ { id: "appmenu_messagePaneClassic" },
+ window
+ );
+
+ Assert.equal(
+ threadTree.getAttribute("rows"),
+ "thread-card",
+ "tree view in cards layout"
+ );
+ about3Pane.folderTree.focus();
+});
+
+add_task(async function testTagsInVerticalView() {
+ let row = threadTree.getRowAtIndex(1);
+ EventUtils.synthesizeMouseAtCenter(row, {}, about3Pane);
+ Assert.ok(row.classList.contains("selected"), "the row should be selected");
+
+ let tag = row.querySelector(".tag-icon");
+ Assert.ok(BrowserTestUtils.is_hidden(tag), "tag icon should be hidden");
+
+ // Set the important tag.
+ EventUtils.synthesizeKey("1", {});
+ Assert.ok(BrowserTestUtils.is_visible(tag), "tag icon should be visible");
+ Assert.deepEqual(tag.title, "Important", "The important tag should be set");
+
+ let row2 = threadTree.getRowAtIndex(2);
+ EventUtils.synthesizeMouseAtCenter(row2, {}, about3Pane);
+ Assert.ok(
+ row2.classList.contains("selected"),
+ "the third row should be selected"
+ );
+
+ let tag2 = row2.querySelector(".tag-icon");
+ Assert.ok(BrowserTestUtils.is_hidden(tag2), "tag icon should be hidden");
+
+ // Set the work tag.
+ EventUtils.synthesizeKey("2", {});
+ Assert.ok(BrowserTestUtils.is_visible(tag2), "tag icon should be visible");
+ Assert.deepEqual(tag2.title, "Work", "The work tag should be set");
+
+ // Switch back to a table layout and horizontal view.
+ let shownPromise = BrowserTestUtils.waitForEvent(
+ displayContext,
+ "popupshown"
+ );
+ EventUtils.synthesizeMouseAtCenter(displayButton, {}, about3Pane);
+ await shownPromise;
+
+ Assert.ok(
+ displayContext
+ .querySelector("#threadPaneCardsView")
+ .getAttribute("checked"),
+ "The cards view menuitem should be checked"
+ );
+
+ let hiddenPromise = BrowserTestUtils.waitForEvent(
+ displayContext,
+ "popuphidden"
+ );
+ displayContext.activateItem(
+ displayContext.querySelector("#threadPaneTableView")
+ );
+ await BrowserTestUtils.waitForCondition(
+ () => threadTree.getAttribute("rows") == "thread-row",
+ "The tree view switched to a table layout"
+ );
+ EventUtils.synthesizeKey("KEY_Escape", {});
+ await hiddenPromise;
+
+ Assert.equal(
+ threadTree.getAttribute("rows"),
+ "thread-row",
+ "tree view in table layout"
+ );
+
+ await ensure_cards_view();
+ about3Pane.folderTree.focus();
+});
diff --git a/comm/mail/base/test/browser/browser_detachedWindows.js b/comm/mail/base/test/browser/browser_detachedWindows.js
new file mode 100644
index 0000000000..a523f4a799
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_detachedWindows.js
@@ -0,0 +1,223 @@
+/* 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/. */
+
+var { MessageGenerator } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MessageGenerator.jsm"
+);
+
+let manager = Cc["@mozilla.org/memory-reporter-manager;1"].getService(
+ Ci.nsIMemoryReporterManager
+);
+
+let tabmail = document.getElementById("tabmail");
+let testFolder;
+let testMessages;
+
+add_setup(async function () {
+ let generator = new MessageGenerator();
+
+ MailServices.accounts.createLocalMailAccount();
+ let account = MailServices.accounts.accounts[0];
+ account.addIdentity(MailServices.accounts.createIdentity());
+ let rootFolder = account.incomingServer.rootFolder;
+ rootFolder.createSubfolder("detachedWindows", null);
+ testFolder = rootFolder
+ .getChildNamed("detachedWindows")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+ testFolder.addMessageBatch(
+ generator.makeMessages({ count: 5 }).map(message => message.toMboxString())
+ );
+ testMessages = [...testFolder.messages];
+
+ registerCleanupFunction(() => {
+ MailServices.accounts.removeAccount(account, false);
+ });
+
+ info("Initial state:");
+ await getWindows();
+});
+
+add_task(async function test3PaneTab() {
+ info("Opening a new 3-pane tab");
+ window.MsgOpenNewTabForFolders([testFolder], {
+ background: false,
+ folderPaneVisible: true,
+ messagePaneVisible: true,
+ });
+ let tab = tabmail.tabInfo[1];
+ await BrowserTestUtils.waitForEvent(
+ tab.chromeBrowser,
+ "aboutMessageLoaded",
+ true
+ );
+ await new Promise(resolve =>
+ tab.chromeBrowser.contentWindow.setTimeout(resolve, 500)
+ );
+
+ tab.chromeBrowser.contentWindow.threadTree.selectedIndex = 0;
+ await BrowserTestUtils.waitForEvent(tab.chromeBrowser, "MsgLoaded");
+ await new Promise(resolve =>
+ tab.chromeBrowser.contentWindow.setTimeout(resolve, 500)
+ );
+
+ info("Closing the tab");
+ tabmail.closeOtherTabs(0);
+ tab = null;
+
+ await assertNoDetachedWindows();
+});
+
+add_task(async function testMessageTab() {
+ info("Opening a new message tab");
+ window.OpenMessageInNewTab(testMessages[0], { background: false });
+ let tab = tabmail.tabInfo[1];
+ await BrowserTestUtils.waitForEvent(
+ tab.chromeBrowser,
+ "aboutMessageLoaded",
+ true
+ );
+ await new Promise(resolve =>
+ tab.chromeBrowser.contentWindow.setTimeout(resolve, 500)
+ );
+
+ info("Closing the tab");
+ tabmail.closeOtherTabs(0);
+ tab = null;
+
+ await assertNoDetachedWindows();
+});
+
+add_task(async function testMessageWindow() {
+ info("Opening a standalone message window");
+ let win = await openMessageFromFile(
+ new FileUtils.File(getTestFilePath("files/sampleContent.eml"))
+ );
+ await new Promise(resolve => win.setTimeout(resolve, 500));
+
+ info("Closing the window");
+ await BrowserTestUtils.closeWindow(win);
+ win = null;
+
+ await assertNoDetachedWindows();
+});
+
+add_task(async function testSearchMessagesDialog() {
+ info("Opening the search messages dialog");
+ let about3Pane = tabmail.currentAbout3Pane;
+ let context = about3Pane.document.getElementById("folderPaneContext");
+ let searchMessagesItem = about3Pane.document.getElementById(
+ "folderPaneContext-searchMessages"
+ );
+
+ let shownPromise = BrowserTestUtils.waitForEvent(context, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ about3Pane.folderPane.getRowForFolder(testFolder).querySelector(".name"),
+ { type: "contextmenu" },
+ about3Pane
+ );
+ await shownPromise;
+
+ let searchWindowPromise = BrowserTestUtils.domWindowOpenedAndLoaded(
+ null,
+ w =>
+ w.document.documentURI == "chrome://messenger/content/SearchDialog.xhtml"
+ );
+ context.activateItem(searchMessagesItem);
+ let searchWindow = await searchWindowPromise;
+
+ await new Promise(resolve => searchWindow.setTimeout(resolve, 500));
+
+ info("Closing the dialog");
+ await BrowserTestUtils.closeWindow(searchWindow);
+ searchWindowPromise = null;
+ searchWindow = null;
+
+ await assertNoDetachedWindows();
+});
+
+add_task(async function testAddressBookTab() {
+ info("Opening the Address Book");
+ window.toAddressBook();
+ let tab = tabmail.tabInfo[1];
+ await BrowserTestUtils.waitForEvent(
+ tab.browser,
+ "about-addressbook-ready",
+ true
+ );
+ await new Promise(resolve =>
+ tab.browser.contentWindow.setTimeout(resolve, 500)
+ );
+
+ info("Closing the tab");
+ tabmail.closeOtherTabs(0);
+ tab = null;
+
+ await assertNoDetachedWindows();
+});
+
+async function getWindows() {
+ await new Promise(resolve => manager.minimizeMemoryUsage(resolve));
+
+ let windows = new Set();
+ await new Promise(resolve =>
+ manager.getReports(
+ (process, path, kind, units, amount, description) => {
+ if (path.startsWith("explicit/window-objects/top")) {
+ path = path.replace("top(none)", "top");
+ path = path.substring(0, path.indexOf(")") + 1);
+ path = path.replace(/\\/g, "/");
+ windows.add(path);
+ }
+ },
+ null,
+ resolve,
+ null,
+ false
+ )
+ );
+
+ for (let win of windows) {
+ info(win);
+ }
+
+ return [...windows];
+}
+
+async function assertNoDetachedWindows() {
+ info("Remaining windows:");
+ let windows = await getWindows();
+
+ let noDetachedWindows = true;
+ for (let win of windows) {
+ if (win.includes("detached")) {
+ noDetachedWindows = false;
+ let url = win.substring(win.indexOf("(") + 1, win.indexOf(")"));
+ Assert.report(true, undefined, undefined, `detached window: ${url}`);
+ }
+ }
+
+ if (noDetachedWindows) {
+ Assert.report(false, undefined, undefined, "no detached windows");
+ }
+}
+
+async function openMessageFromFile(file) {
+ let fileURL = Services.io
+ .newFileURI(file)
+ .mutate()
+ .setQuery("type=application/x-message-display")
+ .finalize();
+
+ let winPromise = BrowserTestUtils.domWindowOpenedAndLoaded();
+ window.openDialog(
+ "chrome://messenger/content/messageWindow.xhtml",
+ "_blank",
+ "all,chrome,dialog=no,status,toolbar",
+ fileURL
+ );
+ let win = await winPromise;
+ await BrowserTestUtils.waitForEvent(win, "MsgLoaded");
+ await TestUtils.waitForCondition(() => Services.focus.activeWindow == win);
+ return win;
+}
diff --git a/comm/mail/base/test/browser/browser_editMenu.js b/comm/mail/base/test/browser/browser_editMenu.js
new file mode 100644
index 0000000000..d320a12e36
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_editMenu.js
@@ -0,0 +1,511 @@
+/* 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 { mailTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MailTestUtils.jsm"
+);
+const { MessageGenerator } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MessageGenerator.jsm"
+);
+const { nsMailServer } = ChromeUtils.import(
+ "resource://testing-common/mailnews/Maild.jsm"
+);
+const { PromiseTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mailnews/PromiseTestUtils.jsm"
+);
+
+/** @type MenuData */
+const editMenuData = {
+ menu_undo: { disabled: true },
+ menu_redo: { disabled: true },
+ menu_cut: { disabled: true },
+ menu_copy: { disabled: true },
+ menu_paste: { disabled: true },
+ menu_delete: { disabled: true, l10nID: "text-action-delete" },
+ menu_select: {},
+ menu_SelectAll: {},
+ menu_selectThread: { disabled: true },
+ menu_selectFlagged: { disabled: true },
+ menu_find: {},
+ menu_findCmd: { disabled: true },
+ menu_findAgainCmd: { disabled: true },
+ searchMailCmd: {},
+ glodaSearchCmd: {},
+ searchAddressesCmd: {},
+ menu_favoriteFolder: { disabled: true },
+ menu_properties: { disabled: true },
+ "calendar-properties-menuitem": { disabled: true },
+};
+if (AppConstants.platform == "linux") {
+ editMenuData.menu_preferences = {};
+ editMenuData.menu_accountmgr = {};
+}
+let helper = new MenuTestHelper("menu_Edit", editMenuData);
+
+let tabmail = document.getElementById("tabmail");
+let rootFolder, testFolder, testMessages, virtualFolder;
+let nntpRootFolder, nntpFolder;
+let imapRootFolder, imapFolder;
+
+add_setup(async function () {
+ document.getElementById("toolbar-menubar").removeAttribute("autohide");
+ window.messenger.transactionManager.clear();
+
+ const generator = new MessageGenerator();
+
+ MailServices.accounts.createLocalMailAccount();
+ let account = MailServices.accounts.accounts[0];
+ account.addIdentity(MailServices.accounts.createIdentity());
+ rootFolder = account.incomingServer.rootFolder;
+
+ rootFolder.createSubfolder("edit menu", null);
+ testFolder = rootFolder
+ .getChildNamed("edit menu")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+ testFolder.addMessageBatch(
+ generator.makeMessages({}).map(message => message.toMboxString())
+ );
+ testMessages = [...testFolder.messages];
+
+ rootFolder.createSubfolder("edit menu virtual", null);
+ virtualFolder = rootFolder.getChildNamed("edit menu virtual");
+ virtualFolder.setFlag(Ci.nsMsgFolderFlags.Virtual);
+ let msgDatabase = virtualFolder.msgDatabase;
+ let folderInfo = msgDatabase.dBFolderInfo;
+ folderInfo.setCharProperty("searchStr", "ALL");
+ folderInfo.setCharProperty("searchFolderUri", testFolder.URI);
+
+ NNTPServer.open();
+ NNTPServer.addGroup("edit.menu.newsgroup");
+ let nntpAccount = MailServices.accounts.createAccount();
+ nntpAccount.incomingServer = MailServices.accounts.createIncomingServer(
+ `${nntpAccount.key}user`,
+ "localhost",
+ "nntp"
+ );
+ nntpAccount.incomingServer.port = NNTPServer.port;
+ nntpRootFolder = nntpAccount.incomingServer.rootFolder;
+ nntpRootFolder.createSubfolder("edit.menu.newsgroup", null);
+ nntpFolder = nntpRootFolder.getChildNamed("edit.menu.newsgroup");
+
+ IMAPServer.open();
+ let imapAccount = MailServices.accounts.createAccount();
+ imapAccount.addIdentity(MailServices.accounts.createIdentity());
+ imapAccount.incomingServer = MailServices.accounts.createIncomingServer(
+ `${imapAccount.key}user`,
+ "localhost",
+ "imap"
+ );
+ imapAccount.incomingServer.port = IMAPServer.port;
+ imapAccount.incomingServer.username = "user";
+ imapAccount.incomingServer.password = "password";
+ imapAccount.incomingServer.deleteModel = Ci.nsMsgImapDeleteModels.IMAPDelete;
+ imapRootFolder = imapAccount.incomingServer.rootFolder;
+ imapFolder = imapRootFolder.getFolderWithFlags(Ci.nsMsgFolderFlags.Inbox);
+ IMAPServer.addMessages(imapFolder, generator.makeMessages({}));
+
+ registerCleanupFunction(() => {
+ MailServices.accounts.removeAccount(account, false);
+ MailServices.accounts.removeAccount(nntpAccount, false);
+ MailServices.accounts.removeAccount(imapAccount, false);
+ NNTPServer.close();
+ IMAPServer.close();
+ });
+});
+
+add_task(async function test3PaneTab() {
+ await helper.testAllItems("mail3PaneTab");
+});
+
+/**
+ * Tests the "Delete" item in the menu. This item calls cmd_delete, which does
+ * various things depending on the current context.
+ */
+add_task(async function testDeleteItem() {
+ let about3Pane = tabmail.currentAbout3Pane;
+ let { displayFolder, folderTree, paneLayout, threadTree } = about3Pane;
+ paneLayout.messagePaneVisible = true;
+
+ // Focus on the folder tree and check that an NNTP account shows
+ // "Unsubscribe Folder". The account can't be deleted this way so the menu
+ // item should be disabled.
+
+ folderTree.focus();
+ displayFolder(nntpRootFolder);
+ await helper.testItems({
+ menu_delete: {
+ disabled: true,
+ l10nID: "text-action-delete",
+ },
+ });
+
+ // Check that an NNTP folder shows "Unsubscribe Folder". Then check that
+ // calling cmd_delete actually attempts to unsubscribe the folder.
+
+ displayFolder(nntpFolder);
+ await Promise.all([
+ BrowserTestUtils.promiseAlertDialog("cancel"),
+ helper.activateItem("menu_delete", {
+ l10nID: "menu-edit-unsubscribe-newsgroup",
+ }),
+ ]);
+
+ // Check that a mail account shows "Delete Folder". The account can't be
+ // deleted this way so the menu item should be disabled.
+
+ displayFolder(rootFolder);
+ await helper.testItems({
+ menu_delete: {
+ disabled: true,
+ l10nID: "text-action-delete",
+ },
+ });
+
+ // Check that focus on the folder tree and a mail folder shows "Delete
+ // Folder". Then check that calling cmd_delete actually attempts to delete
+ // the folder.
+
+ displayFolder(testFolder);
+ await Promise.all([
+ BrowserTestUtils.promiseAlertDialog("cancel"),
+ helper.activateItem("menu_delete", { l10nID: "menu-edit-delete-folder" }),
+ ]);
+ await new Promise(resolve => setTimeout(resolve));
+
+ // Focus the Quick Filter bar text box and check the menu item shows "Delete".
+
+ goDoCommand("cmd_showQuickFilterBar");
+ about3Pane.document.getElementById("qfb-qs-textbox").focus();
+ await helper.testItems({
+ menu_delete: {
+ disabled: true,
+ l10nID: "text-action-delete",
+ },
+ });
+
+ // Focus on the thread tree with no messages selected and check the menu
+ // item shows "Delete".
+
+ threadTree.table.body.focus();
+ threadTree.selectedIndex = -1;
+ await helper.testItems({
+ menu_delete: {
+ disabled: true,
+ l10nID: "text-action-delete",
+ },
+ });
+
+ // With one message selected check the menu item shows "Delete Message".
+
+ threadTree.selectedIndex = 0;
+ await helper.testItems({
+ menu_delete: {
+ l10nID: "menu-edit-delete-messages",
+ l10nArgs: { count: 1 },
+ },
+ });
+
+ // Focus the Quick Filter bar text box and check the menu item shows "Delete".
+ // It should *not* show "Delete Message" even though one is selected.
+
+ about3Pane.document.getElementById("qfb-qs-textbox").focus();
+ await helper.testItems({
+ menu_delete: {
+ disabled: true,
+ l10nID: "text-action-delete",
+ },
+ });
+
+ // Focus on about:message and check the menu item shows "Delete Message".
+
+ about3Pane.messageBrowser.focus();
+ await helper.testItems({
+ menu_delete: {
+ l10nID: "menu-edit-delete-messages",
+ l10nArgs: { count: 1 },
+ },
+ });
+
+ // With multiple messages selected and check the menu item shows "Delete
+ // Messages". Then check that calling cmd_delete actually deletes the messages.
+
+ threadTree.table.body.focus();
+ threadTree.selectedIndices = [0, 1, 3];
+ await Promise.all([
+ new PromiseTestUtils.promiseFolderEvent(
+ testFolder,
+ "DeleteOrMoveMsgCompleted"
+ ),
+ helper.activateItem("menu_delete", {
+ l10nID: "menu-edit-delete-messages",
+ l10nArgs: { count: 3 },
+ }),
+ ]);
+
+ // Load an IMAP folder with the "just mark deleted" model. With no messages
+ // selected check the menu item shows "Delete".
+
+ // Note that for each flag change, we wait for a second for the change to
+ // be sent to the IMAP server.
+
+ displayFolder(imapFolder);
+ await TestUtils.waitForCondition(() => threadTree.view.rowCount == 10);
+ let dbView = about3Pane.gDBView;
+ threadTree.selectedIndex = -1;
+ await helper.testItems({
+ menu_delete: {
+ disabled: true,
+ l10nID: "text-action-delete",
+ },
+ });
+
+ // With one message selected check the menu item shows "Delete Message".
+ // Then check that calling cmd_delete sets the flag on the message.
+
+ threadTree.selectedIndex = 0;
+ let message = dbView.getMsgHdrAt(0);
+ await helper.activateItem("menu_delete", {
+ l10nID: "menu-edit-delete-messages",
+ l10nArgs: { count: 1 },
+ });
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 1000));
+ Assert.ok(
+ message.flags & Ci.nsMsgMessageFlags.IMAPDeleted,
+ "IMAPDeleted flag should be set"
+ );
+
+ // Check the menu item now shows "Undelete Message" and that calling
+ // cmd_delete clears the flag on the message.
+
+ // The delete operation moved the selection, go back.
+ threadTree.selectedIndex = 0;
+ await helper.activateItem("menu_delete", {
+ l10nID: "menu-edit-undelete-messages",
+ l10nArgs: { count: 1 },
+ });
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 1000));
+ Assert.ok(
+ !(message.flags & Ci.nsMsgMessageFlags.IMAPDeleted),
+ "IMAPDeleted flag should be cleared on message 0"
+ );
+
+ // Check the menu item again shows "Delete Message".
+
+ await helper.testItems({
+ menu_delete: {
+ l10nID: "menu-edit-delete-messages",
+ l10nArgs: { count: 1 },
+ },
+ });
+
+ // With multiple messages selected and check the menu item shows "Delete
+ // Messages". Check that calling cmd_delete sets the flag on the messages.
+
+ threadTree.selectedIndices = [1, 3, 5];
+ let messages = dbView.getSelectedMsgHdrs();
+ await helper.testItems({
+ menu_delete: {
+ l10nID: "menu-edit-delete-messages",
+ l10nArgs: { count: 3 },
+ },
+ });
+ await helper.activateItem("menu_delete");
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 1000));
+ Assert.ok(
+ messages.every(m => m.flags & Ci.nsMsgMessageFlags.IMAPDeleted),
+ "IMAPDeleted flags should be set"
+ );
+
+ // Check the menu item now shows "Undelete Messages" and that calling
+ // cmd_delete clears the flag on the messages.
+
+ threadTree.selectedIndices = [1, 3, 5];
+ await helper.activateItem("menu_delete", {
+ l10nID: "menu-edit-undelete-messages",
+ l10nArgs: { count: 3 },
+ });
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => setTimeout(r, 1000));
+ Assert.ok(
+ messages.every(m => !(m.flags & Ci.nsMsgMessageFlags.IMAPDeleted)),
+ "IMAPDeleted flags should be cleared"
+ );
+
+ // Check the menu item again shows "Delete Messages".
+
+ threadTree.selectedIndices = [1, 3, 5];
+ await helper.testItems({
+ menu_delete: {
+ l10nID: "menu-edit-delete-messages",
+ l10nArgs: { count: 3 },
+ },
+ });
+
+ Services.focus.focusedWindow = window;
+}).__skipMe = AppConstants.DEBUG; // Too unreliable.
+
+/**
+ * Tests the "Favorite Folder" item in the menu is checked/unchecked as expected.
+ */
+add_task(async function testFavoriteFolderItem() {
+ let { displayFolder } = tabmail.currentAbout3Pane;
+
+ testFolder.clearFlag(Ci.nsMsgFolderFlags.Favorite);
+ displayFolder(testFolder);
+ await helper.testItems({ menu_favoriteFolder: {} });
+
+ testFolder.setFlag(Ci.nsMsgFolderFlags.Favorite);
+ await helper.activateItem("menu_favoriteFolder", { checked: true });
+ Assert.ok(
+ !testFolder.getFlag(Ci.nsMsgFolderFlags.Favorite),
+ "favorite flag should be cleared"
+ );
+
+ await helper.activateItem("menu_favoriteFolder", {});
+ Assert.ok(
+ testFolder.getFlag(Ci.nsMsgFolderFlags.Favorite),
+ "favorite flag should be set"
+ );
+
+ testFolder.clearFlag(Ci.nsMsgFolderFlags.Favorite);
+});
+
+/**
+ * Tests the "Properties" item in the menu is enabled/disabled as expected,
+ * and has the correct label.
+ */
+add_task(async function testPropertiesItem() {
+ async function testDialog(folder, data, which = "folderProps.xhtml") {
+ await Promise.all([
+ BrowserTestUtils.promiseAlertDialog(
+ undefined,
+ `chrome://messenger/content/${which}`,
+ {
+ callback(win) {
+ Assert.ok(true, "folder properties dialog opened");
+ Assert.equal(
+ win.gMsgFolder.URI,
+ folder.URI,
+ "dialog has correct folder"
+ );
+ win.document.querySelector("dialog").getButton("cancel").click();
+ },
+ }
+ ),
+ helper.activateItem("menu_properties", data),
+ ]);
+ await SimpleTest.promiseFocus(window);
+ }
+
+ let { displayFolder } = tabmail.currentAbout3Pane;
+
+ displayFolder(rootFolder);
+ await helper.testItems({
+ menu_properties: { disabled: true, l10nID: "menu-edit-properties" },
+ });
+
+ displayFolder(testFolder);
+ await testDialog(testFolder, { l10nID: "menu-edit-folder-properties" });
+
+ displayFolder(virtualFolder);
+ await testDialog(
+ virtualFolder,
+ { l10nID: "menu-edit-folder-properties" },
+ "virtualFolderProperties.xhtml"
+ );
+
+ displayFolder(imapRootFolder);
+ await helper.testItems({
+ menu_properties: { disabled: true, l10nID: "menu-edit-properties" },
+ });
+
+ displayFolder(imapFolder);
+ await testDialog(imapFolder, { l10nID: "menu-edit-folder-properties" });
+
+ displayFolder(nntpRootFolder);
+ await helper.testItems({
+ menu_properties: { disabled: true, l10nID: "menu-edit-properties" },
+ });
+
+ displayFolder(nntpFolder);
+ await testDialog(nntpFolder, { l10nID: "menu-edit-newsgroup-properties" });
+});
+
+var NNTPServer = {
+ open() {
+ let { NNTP_RFC977_handler, NntpDaemon } = ChromeUtils.import(
+ "resource://testing-common/mailnews/Nntpd.jsm"
+ );
+
+ this.daemon = new NntpDaemon();
+ this.server = new nsMailServer(
+ daemon => new NNTP_RFC977_handler(daemon),
+ this.daemon
+ );
+ this.server.start();
+
+ registerCleanupFunction(() => this.close());
+ },
+
+ close() {
+ this.server.stop();
+ },
+
+ get port() {
+ return this.server.port;
+ },
+
+ addGroup(group) {
+ return this.daemon.addGroup(group);
+ },
+};
+
+var IMAPServer = {
+ open() {
+ let { ImapDaemon, ImapMessage, IMAP_RFC3501_handler } = ChromeUtils.import(
+ "resource://testing-common/mailnews/Imapd.jsm"
+ );
+ IMAPServer.ImapMessage = ImapMessage;
+
+ this.daemon = new ImapDaemon();
+ this.server = new nsMailServer(
+ daemon => new IMAP_RFC3501_handler(daemon),
+ this.daemon
+ );
+ this.server.start();
+
+ registerCleanupFunction(() => this.close());
+ },
+ close() {
+ this.server.stop();
+ },
+ get port() {
+ return this.server.port;
+ },
+
+ addMessages(folder, messages) {
+ let fakeFolder = IMAPServer.daemon.getMailbox(folder.name);
+ messages.forEach(message => {
+ if (typeof message != "string") {
+ message = message.toMessageString();
+ }
+ let msgURI = Services.io.newURI(
+ "data:text/plain;base64," + btoa(message)
+ );
+ let imapMsg = new IMAPServer.ImapMessage(
+ msgURI.spec,
+ fakeFolder.uidnext++,
+ []
+ );
+ fakeFolder.addMessage(imapMsg);
+ });
+
+ return new Promise(resolve =>
+ mailTestUtils.updateFolderAndNotify(folder, resolve)
+ );
+ },
+};
diff --git a/comm/mail/base/test/browser/browser_fileMenu.js b/comm/mail/base/test/browser/browser_fileMenu.js
new file mode 100644
index 0000000000..927628eb93
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_fileMenu.js
@@ -0,0 +1,137 @@
+/* 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 { MessageGenerator } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MessageGenerator.jsm"
+);
+
+/** @type MenuData */
+const fileMenuData = {
+ menu_New: {},
+ menu_newNewMsgCmd: {},
+ "calendar-new-event-menuitem": { hidden: true },
+ "calendar-new-task-menuitem": { hidden: true },
+ menu_newFolder: { hidden: ["mailMessageTab", "contentTab"] },
+ menu_newVirtualFolder: { hidden: ["mailMessageTab", "contentTab"] },
+ newCreateEmailAccountMenuItem: {},
+ newMailAccountMenuItem: {},
+ newIMAccountMenuItem: {},
+ newFeedAccountMenuItem: {},
+ newNewsgroupAccountMenuItem: {},
+ "calendar-new-calendar-menuitem": {},
+ menu_newCard: {},
+ newIMContactMenuItem: { disabled: true },
+ menu_Open: {},
+ openMessageFileMenuitem: {},
+ "calendar-open-calendar-file-menuitem": {},
+ menu_close: {},
+ "calendar-save-menuitem": { hidden: true },
+ "calendar-save-and-close-menuitem": { hidden: true },
+ menu_saveAs: {},
+ menu_saveAsFile: { disabled: ["mail3PaneTab", "contentTab"] },
+ menu_saveAsTemplate: { disabled: ["mail3PaneTab", "contentTab"] },
+ menu_getAllNewMsg: {},
+ menu_getnewmsgs_all_accounts: { disabled: true },
+ menu_getnewmsgs_current_account: { disabled: true },
+ menu_getnextnmsg: { hidden: true },
+ menu_sendunsentmsgs: { disabled: true },
+ menu_subscribe: { disabled: true },
+ menu_deleteFolder: { disabled: true },
+ menu_renameFolder: { disabled: true },
+ menu_compactFolder: { disabled: ["mailMessageTab", "contentTab"] },
+ menu_emptyTrash: { disabled: ["mailMessageTab", "contentTab"] },
+ offlineMenuItem: {},
+ goOfflineMenuItem: {},
+ menu_synchronizeOffline: {},
+ menu_settingsOffline: { disabled: true },
+ menu_downloadFlagged: { disabled: true },
+ menu_downloadSelected: { disabled: true },
+ printMenuItem: { disabled: ["mail3PaneTab"] },
+ menu_FileQuitItem: {},
+};
+let helper = new MenuTestHelper("menu_File", fileMenuData);
+
+let tabmail = document.getElementById("tabmail");
+let inboxFolder, plainFolder, rootFolder, testMessages, trashFolder;
+
+add_setup(async function () {
+ document.getElementById("toolbar-menubar").removeAttribute("autohide");
+
+ let generator = new MessageGenerator();
+
+ MailServices.accounts.createLocalMailAccount();
+ let account = MailServices.accounts.accounts[0];
+ account.addIdentity(MailServices.accounts.createIdentity());
+ rootFolder = account.incomingServer.rootFolder;
+
+ rootFolder.createSubfolder("file menu inbox", null);
+ inboxFolder = rootFolder
+ .getChildNamed("file menu inbox")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+ inboxFolder.setFlag(Ci.nsMsgFolderFlags.Inbox);
+ inboxFolder.addMessageBatch(
+ generator.makeMessages({ count: 5 }).map(message => message.toMboxString())
+ );
+ testMessages = [...inboxFolder.messages];
+
+ rootFolder.createSubfolder("file menu plain", null);
+ plainFolder = rootFolder.getChildNamed("file menu plain");
+
+ trashFolder = rootFolder.getFolderWithFlags(Ci.nsMsgFolderFlags.Trash);
+
+ window.OpenMessageInNewTab(testMessages[0], { background: true });
+ await BrowserTestUtils.waitForEvent(
+ tabmail.tabInfo[1].chromeBrowser,
+ "MsgLoaded"
+ );
+
+ window.openTab("contentTab", {
+ url: "https://example.com/",
+ background: true,
+ });
+
+ registerCleanupFunction(() => {
+ tabmail.closeOtherTabs(0);
+ MailServices.accounts.removeAccount(account, false);
+ });
+});
+
+add_task(async function test3PaneTab() {
+ tabmail.currentAbout3Pane.displayFolder(rootFolder);
+ await helper.testAllItems("mail3PaneTab");
+
+ tabmail.currentAbout3Pane.displayFolder(inboxFolder);
+ await helper.testItems({
+ menu_deleteFolder: { disabled: true },
+ menu_renameFolder: { disabled: true },
+ menu_compactFolder: { disabled: false },
+ menu_emptyTrash: {},
+ });
+
+ tabmail.currentAbout3Pane.displayFolder(plainFolder);
+ await helper.testItems({
+ menu_deleteFolder: { disabled: false },
+ menu_renameFolder: { disabled: false },
+ menu_compactFolder: { disabled: false },
+ menu_emptyTrash: {},
+ });
+
+ tabmail.currentAbout3Pane.displayFolder(trashFolder);
+ await helper.testItems({
+ menu_deleteFolder: { disabled: true },
+ menu_renameFolder: { disabled: true },
+ menu_compactFolder: { disabled: false },
+ menu_emptyTrash: {},
+ });
+});
+
+add_task(async function testMessageTab() {
+ tabmail.switchToTab(1);
+ await helper.testAllItems("mailMessageTab");
+});
+
+add_task(async function testContentTab() {
+ tabmail.switchToTab(2);
+ await helper.testAllItems("contentTab");
+});
diff --git a/comm/mail/base/test/browser/browser_folderPaneContext.js b/comm/mail/base/test/browser/browser_folderPaneContext.js
new file mode 100644
index 0000000000..843965c966
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_folderPaneContext.js
@@ -0,0 +1,198 @@
+/* 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/. */
+
+var { FeedUtils } = ChromeUtils.import("resource:///modules/FeedUtils.jsm");
+
+let servers = ["server", "rssRoot"];
+let realFolders = ["plain", "inbox", "junk", "trash", "rssFeed"];
+
+const folderPaneContextData = {
+ "folderPaneContext-getMessages": [...servers, "rssFeed"],
+ "folderPaneContext-pauseAllUpdates": ["rssRoot"],
+ "folderPaneContext-pauseUpdates": ["rssFeed"],
+ "folderPaneContext-openNewTab": true,
+ "folderPaneContext-openNewWindow": true,
+ "folderPaneContext-searchMessages": [...servers, ...realFolders],
+ "folderPaneContext-subscribe": ["rssRoot", "rssFeed"],
+ "folderPaneContext-newsUnsubscribe": [],
+ "folderPaneContext-new": [...servers, ...realFolders],
+ "folderPaneContext-remove": ["plain", "junk", "virtual", "rssFeed"],
+ "folderPaneContext-rename": ["plain", "junk", "virtual", "rssFeed"],
+ "folderPaneContext-compact": [...servers, ...realFolders],
+ "folderPaneContext-markMailFolderAllRead": [...realFolders, "virtual"],
+ "folderPaneContext-markNewsgroupAllRead": [],
+ "folderPaneContext-emptyTrash": ["trash"],
+ "folderPaneContext-emptyJunk": ["junk"],
+ "folderPaneContext-sendUnsentMessages": [],
+ "folderPaneContext-favoriteFolder": [...realFolders, "virtual"],
+ "folderPaneContext-properties": [...realFolders, "virtual"],
+ "folderPaneContext-markAllFoldersRead": [...servers],
+ "folderPaneContext-settings": [...servers],
+ "folderPaneContext-manageTags": ["tags"],
+ "folderPaneContext-moveMenu": ["plain", "virtual", "rssFeed"],
+ "folderPaneContext-copyMenu": ["plain", "rssFeed"],
+};
+
+let about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+let context = about3Pane.document.getElementById("folderPaneContext");
+let rootFolder,
+ plainFolder,
+ inboxFolder,
+ junkFolder,
+ trashFolder,
+ virtualFolder;
+let rssRootFolder, rssFeedFolder;
+let tagsFolder;
+
+add_setup(async function () {
+ let account = MailServices.accounts.createAccount();
+ account.incomingServer = MailServices.accounts.createIncomingServer(
+ `${account.key}user`,
+ "localhost",
+ "pop3"
+ );
+ account.addIdentity(MailServices.accounts.createIdentity());
+ rootFolder = account.incomingServer.rootFolder.QueryInterface(
+ Ci.nsIMsgLocalMailFolder
+ );
+
+ plainFolder = rootFolder.createLocalSubfolder("folderPaneContextFolder");
+ inboxFolder = rootFolder.createLocalSubfolder("folderPaneContextInbox");
+ inboxFolder.setFlag(Ci.nsMsgFolderFlags.Inbox);
+ junkFolder = rootFolder.createLocalSubfolder("folderPaneContextJunk");
+ junkFolder.setFlag(Ci.nsMsgFolderFlags.Junk);
+ trashFolder = rootFolder.createLocalSubfolder("folderPaneContextTrash");
+ trashFolder.setFlag(Ci.nsMsgFolderFlags.Trash);
+
+ virtualFolder = rootFolder.createLocalSubfolder("folderPaneContextVirtual");
+ virtualFolder.setFlag(Ci.nsMsgFolderFlags.Virtual);
+ let msgDatabase = virtualFolder.msgDatabase;
+ let folderInfo = msgDatabase.dBFolderInfo;
+ folderInfo.setCharProperty("searchStr", "ALL");
+ folderInfo.setCharProperty("searchFolderUri", plainFolder.URI);
+
+ let rssAccount = FeedUtils.createRssAccount("rss");
+ rssRootFolder = rssAccount.incomingServer.rootFolder;
+ FeedUtils.subscribeToFeed(
+ "https://example.org/browser/comm/mail/base/test/browser/files/rss.xml?folderPaneContext",
+ rssRootFolder,
+ null
+ );
+ await TestUtils.waitForCondition(() => rssRootFolder.subFolders.length == 2);
+ rssFeedFolder = rssRootFolder.getChildNamed("Test Feed");
+
+ about3Pane.folderPane.activeModes = ["all", "tags"];
+ tagsFolder = about3Pane.folderPane._modes.tags._tagsFolder.subFolders[0];
+
+ registerCleanupFunction(() => {
+ MailServices.accounts.removeAccount(account, false);
+ MailServices.accounts.removeAccount(rssAccount, false);
+ about3Pane.folderPane.activeModes = ["all"];
+ });
+});
+
+add_task(async function () {
+ // Check the menu has the right items for the selected folder.
+ leftClickOn(rootFolder);
+ await rightClickOn(rootFolder, "server");
+ leftClickOn(plainFolder);
+ await rightClickOn(plainFolder, "plain");
+ leftClickOn(inboxFolder);
+ await rightClickOn(inboxFolder, "inbox");
+ leftClickOn(junkFolder);
+ await rightClickOn(junkFolder, "junk");
+ leftClickOn(trashFolder);
+ await rightClickOn(trashFolder, "trash");
+ leftClickOn(virtualFolder);
+ await rightClickOn(virtualFolder, "virtual");
+ leftClickOn(rssRootFolder);
+ await rightClickOn(rssRootFolder, "rssRoot");
+ leftClickOn(rssFeedFolder);
+ await rightClickOn(rssFeedFolder, "rssFeed");
+ leftClickOn(tagsFolder);
+ await rightClickOn(tagsFolder, "tags");
+
+ // Check the menu has the right items when the selected folder is not the
+ // folder that was right-clicked on.
+ await rightClickOn(rootFolder, "server");
+ leftClickOn(rootFolder);
+ await rightClickOn(plainFolder, "plain");
+ await rightClickOn(inboxFolder, "inbox");
+ await rightClickOn(junkFolder, "junk");
+ await rightClickOn(trashFolder, "trash");
+ await rightClickOn(virtualFolder, "virtual");
+ await rightClickOn(rssRootFolder, "rssRoot");
+ await rightClickOn(rssFeedFolder, "rssFeed");
+ await rightClickOn(tagsFolder, "tags");
+});
+
+function leftClickOn(folder) {
+ EventUtils.synthesizeMouseAtCenter(
+ about3Pane.folderPane.getRowForFolder(folder).querySelector(".name"),
+ {},
+ about3Pane
+ );
+}
+
+async function rightClickOn(folder, mode) {
+ let shownPromise = BrowserTestUtils.waitForEvent(context, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ about3Pane.folderPane.getRowForFolder(folder).querySelector(".name"),
+ { type: "contextmenu" },
+ about3Pane
+ );
+ await shownPromise;
+ checkMenuitems(context, mode);
+ let hiddenPromise = BrowserTestUtils.waitForEvent(context, "popuphidden");
+ context.hidePopup();
+ await hiddenPromise;
+}
+
+function checkMenuitems(menu, mode) {
+ if (!mode) {
+ // Menu should not be shown.
+ Assert.equal(menu.state, "closed");
+ return;
+ }
+
+ Assert.notEqual(menu.state, "closed");
+
+ let expectedItems = [];
+ for (let [id, modes] of Object.entries(folderPaneContextData)) {
+ if (modes === true || modes.includes(mode)) {
+ expectedItems.push(id);
+ }
+ }
+
+ let actualItems = [];
+ for (let item of menu.children) {
+ if (["menu", "menuitem"].includes(item.localName) && !item.hidden) {
+ actualItems.push(item.id);
+ }
+ }
+
+ let notFoundItems = expectedItems.filter(i => !actualItems.includes(i));
+ if (notFoundItems.length) {
+ Assert.report(
+ true,
+ undefined,
+ undefined,
+ "items expected but not found: " + notFoundItems.join(", ")
+ );
+ }
+
+ let unexpectedItems = actualItems.filter(i => !expectedItems.includes(i));
+ if (unexpectedItems.length) {
+ Assert.report(
+ true,
+ undefined,
+ undefined,
+ "items found but not expected: " + unexpectedItems.join(", ")
+ );
+ }
+
+ if (notFoundItems.length + unexpectedItems.length == 0) {
+ Assert.report(false, undefined, undefined, `all ${mode} items are correct`);
+ }
+}
diff --git a/comm/mail/base/test/browser/browser_folderTreeProperties.js b/comm/mail/base/test/browser/browser_folderTreeProperties.js
new file mode 100644
index 0000000000..6fdfa4f2ad
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_folderTreeProperties.js
@@ -0,0 +1,236 @@
+/* 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/. */
+
+var { FolderTreeProperties } = ChromeUtils.import(
+ "resource:///modules/FolderTreeProperties.jsm"
+);
+
+const TRASH_COLOR_HEX = "#52507c";
+const TRASH_COLOR_RGB = "rgb(82, 80, 124)";
+const VIRTUAL_COLOR_HEX = "#cd26a5";
+const VIRTUAL_COLOR_RGB = "rgb(205, 38, 165)";
+
+let about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+let { folderPane, folderTree, threadTree } = about3Pane;
+let rootFolder, trashFolder, trashFolderRows, virtualFolder, virtualFolderRows;
+
+add_setup(async function () {
+ Services.prefs.setIntPref("ui.prefersReducedMotion", 1);
+ FolderTreeProperties.resetColors();
+
+ let account = MailServices.accounts.createAccount();
+ account.incomingServer = MailServices.accounts.createIncomingServer(
+ `${account.key}user`,
+ "localhost",
+ "none"
+ );
+ account.addIdentity(MailServices.accounts.createIdentity());
+ rootFolder = account.incomingServer.rootFolder;
+
+ trashFolder = rootFolder.getFolderWithFlags(Ci.nsMsgFolderFlags.Trash);
+ trashFolder.setFlag(Ci.nsMsgFolderFlags.Favorite);
+
+ rootFolder.createSubfolder("folderTreePropsVirtual", null);
+ virtualFolder = rootFolder.getChildNamed("folderTreePropsVirtual");
+ virtualFolder.flags |=
+ Ci.nsMsgFolderFlags.Virtual | Ci.nsMsgFolderFlags.Favorite;
+ let virtualFolderInfo = virtualFolder.msgDatabase.dBFolderInfo;
+ virtualFolderInfo.setCharProperty("searchStr", "ALL");
+ virtualFolderInfo.setCharProperty("searchFolderUri", trashFolder.URI);
+
+ // Test the colours change in all folder modes, not just the current one.
+ folderPane.activeModes = ["all", "favorite"];
+ await new Promise(resolve => setTimeout(resolve));
+ for (let row of folderTree.querySelectorAll(".collapsed")) {
+ folderTree.expandRow(row);
+ }
+
+ trashFolderRows = {
+ all: folderPane.getRowForFolder(trashFolder, "all"),
+ favorite: folderPane.getRowForFolder(trashFolder, "favorite"),
+ };
+ virtualFolderRows = {
+ all: folderPane.getRowForFolder(virtualFolder, "all"),
+ favorite: folderPane.getRowForFolder(virtualFolder, "favorite"),
+ };
+
+ registerCleanupFunction(async () => {
+ folderPane.activeModes = ["all"];
+ MailServices.accounts.removeAccount(account, false);
+ FolderTreeProperties.resetColors();
+ Services.prefs.clearUserPref("ui.prefersReducedMotion");
+ });
+});
+
+add_task(async function testNormalFolderColors() {
+ await subtestColors(trashFolderRows, TRASH_COLOR_HEX, TRASH_COLOR_RGB);
+});
+
+add_task(async function testVirtualFolderColors() {
+ await subtestColors(virtualFolderRows, VIRTUAL_COLOR_HEX, VIRTUAL_COLOR_RGB);
+});
+
+async function subtestColors(rows, defaultHex, defaultRGB) {
+ assertRowColors(rows, defaultRGB);
+
+ // Accept the dialog without changing anything.
+ let dialog = await openFolderProperties(rows.all);
+ dialog.assertColor(defaultHex);
+ await dialog.accept();
+ assertRowColors(rows, defaultRGB);
+
+ // Cancel the dialog without changing anything.
+ dialog = await openFolderProperties(rows.favorite);
+ dialog.assertColor(defaultHex);
+ await dialog.cancel();
+ assertRowColors(rows, defaultRGB);
+
+ // Set a non-default color.
+ dialog = await openFolderProperties(rows.all);
+ dialog.assertColor(defaultHex);
+ await dialog.setColor("#ff6600");
+ assertRowColors(rows, "rgb(255, 102, 0)");
+ await dialog.accept();
+ assertRowColors(rows, "rgb(255, 102, 0)");
+
+ // Reset to the default color.
+ dialog = await openFolderProperties(rows.favorite);
+ dialog.assertColor("#ff6600");
+ dialog.resetColor();
+ dialog.assertColor(defaultHex);
+ assertRowColors(rows, defaultRGB);
+ await dialog.accept();
+ assertRowColors(rows, defaultRGB);
+
+ // Set a color, but cancel the dialog.
+ dialog = await openFolderProperties(rows.all);
+ dialog.assertColor(defaultHex);
+ await dialog.setColor("#ffcc00");
+ assertRowColors(rows, "rgb(255, 204, 0)");
+ await dialog.cancel();
+ assertRowColors(rows, defaultRGB);
+
+ // Set a color, but reset it and accept the dialog.
+ dialog = await openFolderProperties(rows.favorite);
+ dialog.assertColor(defaultHex);
+ await dialog.setColor("#00cc00");
+ assertRowColors(rows, "rgb(0, 204, 0)");
+ dialog.resetColor();
+ dialog.assertColor(defaultHex);
+ assertRowColors(rows, defaultRGB);
+ await dialog.accept();
+ assertRowColors(rows, defaultRGB);
+
+ // Set a non-default color.
+ dialog = await openFolderProperties(rows.all);
+ dialog.assertColor(defaultHex);
+ await dialog.setColor("#0000cc");
+ assertRowColors(rows, "rgb(0, 0, 204)");
+ await dialog.accept();
+ assertRowColors(rows, "rgb(0, 0, 204)");
+
+ // Accept the dialog without changing anything.
+ dialog = await openFolderProperties(rows.favorite);
+ dialog.assertColor("#0000cc");
+ await dialog.accept();
+ assertRowColors(rows, "rgb(0, 0, 204)");
+
+ // Cancel the dialog without changing anything.
+ dialog = await openFolderProperties(rows.all);
+ dialog.assertColor("#0000cc");
+ await dialog.cancel();
+ assertRowColors(rows, "rgb(0, 0, 204)");
+
+ // Reset the color and cancel the dialog.
+ dialog = await openFolderProperties(rows.favorite);
+ dialog.assertColor("#0000cc");
+ dialog.resetColor();
+ dialog.assertColor(defaultHex);
+ assertRowColors(rows, defaultRGB);
+ await dialog.cancel();
+ assertRowColors(rows, "rgb(0, 0, 204)");
+
+ // Reset the color, pick a new one, and accept the dialog.
+ dialog = await openFolderProperties(rows.all);
+ dialog.assertColor("#0000cc");
+ dialog.resetColor();
+ dialog.assertColor(defaultHex);
+ assertRowColors(rows, defaultRGB);
+ await dialog.setColor("#0066cc");
+ assertRowColors(rows, "rgb(0, 102, 204)");
+ await dialog.accept();
+ assertRowColors(rows, "rgb(0, 102, 204)");
+}
+
+async function openFolderProperties(row) {
+ let folderPaneContext =
+ about3Pane.document.getElementById("folderPaneContext");
+ let folderPaneContextProperties = about3Pane.document.getElementById(
+ "folderPaneContext-properties"
+ );
+
+ let shownPromise = BrowserTestUtils.waitForEvent(
+ folderPaneContext,
+ "popupshown"
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ row.querySelector(".name"),
+ { type: "contextmenu" },
+ about3Pane
+ );
+ await shownPromise;
+
+ let windowOpenedPromise = BrowserTestUtils.domWindowOpenedAndLoaded();
+ folderPaneContext.activateItem(folderPaneContextProperties);
+ let dialogWindow = await windowOpenedPromise;
+ let dialogDocument = dialogWindow.document;
+
+ let colorButton = dialogDocument.getElementById("color");
+ let resetColorButton = dialogDocument.getElementById("resetColor");
+ let folderPropertiesDialog = dialogDocument.querySelector("dialog");
+
+ return {
+ assertColor(hex) {
+ Assert.equal(colorButton.value, hex);
+ },
+ async setColor(hex) {
+ SpecialPowers.MockColorPicker.init(dialogWindow);
+ SpecialPowers.MockColorPicker.returnColor = hex;
+ let inputPromise = BrowserTestUtils.waitForEvent(colorButton, "input");
+ EventUtils.synthesizeMouseAtCenter(colorButton, {}, dialogWindow);
+ await inputPromise;
+ SpecialPowers.MockColorPicker.cleanup();
+ },
+ resetColor() {
+ EventUtils.synthesizeMouseAtCenter(resetColorButton, {}, dialogWindow);
+ },
+ async accept() {
+ let windowClosedPromise = BrowserTestUtils.domWindowClosed(dialogWindow);
+ EventUtils.synthesizeMouseAtCenter(
+ folderPropertiesDialog.getButton("accept"),
+ {},
+ dialogWindow
+ );
+ await windowClosedPromise;
+ },
+ async cancel() {
+ let windowClosedPromise = BrowserTestUtils.domWindowClosed(dialogWindow);
+ EventUtils.synthesizeMouseAtCenter(
+ folderPropertiesDialog.getButton("cancel"),
+ {},
+ dialogWindow
+ );
+ await windowClosedPromise;
+ },
+ };
+}
+
+function assertRowColors(rows, rgb) {
+ // Always move the focus away from the row otherwise we might get the selected
+ // state which turns the icon white.
+ threadTree.table.body.focus();
+ for (let row of Object.values(rows)) {
+ Assert.equal(getComputedStyle(row.querySelector(".icon")).stroke, rgb);
+ }
+}
diff --git a/comm/mail/base/test/browser/browser_folderTreeQuirks.js b/comm/mail/base/test/browser/browser_folderTreeQuirks.js
new file mode 100644
index 0000000000..885be9242c
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_folderTreeQuirks.js
@@ -0,0 +1,1450 @@
+/* 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 { MessageGenerator, SyntheticMessageSet } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MessageGenerator.jsm"
+);
+const { MessageInjection } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MessageInjection.jsm"
+);
+const { PromiseTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mailnews/PromiseTestUtils.jsm"
+);
+const { VirtualFolderHelper } = ChromeUtils.import(
+ "resource:///modules/VirtualFolderWrapper.jsm"
+);
+
+let about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+let { folderPane, folderTree, threadTree } = about3Pane;
+let account,
+ rootFolder,
+ inboxFolder,
+ trashFolder,
+ outboxFolder,
+ folderA,
+ folderB,
+ folderC,
+ moreButton,
+ moreContext;
+let generator = new MessageGenerator();
+let messageInjection = new MessageInjection(
+ {
+ mode: "local",
+ },
+ generator
+);
+
+add_setup(async function () {
+ account = MailServices.accounts.accounts[0];
+ rootFolder = account.incomingServer.rootFolder;
+ inboxFolder = rootFolder.getChildNamed("Inbox");
+ trashFolder = rootFolder.getChildNamed("Trash");
+ outboxFolder = rootFolder.getChildNamed("Outbox");
+ moreButton = about3Pane.document.querySelector("#folderPaneMoreButton");
+ moreContext = about3Pane.document.getElementById("folderPaneMoreContext");
+
+ rootFolder.createSubfolder("folderTreeQuirksA", null);
+ folderA = rootFolder
+ .getChildNamed("folderTreeQuirksA")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+
+ folderA.createSubfolder("folderTreeQuirksB", null);
+ folderB = folderA
+ .getChildNamed("folderTreeQuirksB")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+
+ folderB.createSubfolder("folderTreeQuirksC", null);
+ folderC = folderB
+ .getChildNamed("folderTreeQuirksC")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+
+ messageInjection.addSetsToFolders(
+ [folderA, folderB, folderC],
+ [
+ new SyntheticMessageSet(generator.makeMessages({ read: true })),
+ new SyntheticMessageSet(generator.makeMessages({ read: true })),
+ new SyntheticMessageSet(generator.makeMessages({ read: true })),
+ ]
+ );
+
+ Services.prefs.setIntPref("ui.prefersReducedMotion", 1);
+ about3Pane.paneLayout.messagePaneVisible = false;
+
+ registerCleanupFunction(() => {
+ MailServices.accounts.removeAccount(account, false);
+ Services.prefs.clearUserPref("ui.prefersReducedMotion");
+ folderPane.activeModes = ["all"];
+ Services.xulStore.removeDocument(
+ "chrome://messenger/content/messenger.xhtml"
+ );
+ about3Pane.paneLayout.messagePaneVisible = true;
+ });
+});
+
+/**
+ * Tests the Favorite Folders mode.
+ */
+add_task(async function testFavoriteFolders() {
+ folderPane.activeModes = ["all", "favorite"];
+ await checkModeListItems("favorite", []);
+
+ folderA.setFlag(Ci.nsMsgFolderFlags.Favorite);
+ await checkModeListItems("favorite", [rootFolder, folderA]);
+
+ folderA.clearFlag(Ci.nsMsgFolderFlags.Favorite);
+ await checkModeListItems("favorite", []);
+
+ folderB.setFlag(Ci.nsMsgFolderFlags.Favorite);
+ await checkModeListItems("favorite", [rootFolder, folderA, folderB]);
+
+ folderB.clearFlag(Ci.nsMsgFolderFlags.Favorite);
+ await checkModeListItems("favorite", []);
+
+ folderC.setFlag(Ci.nsMsgFolderFlags.Favorite);
+ folderA.setFlag(Ci.nsMsgFolderFlags.Favorite);
+ await checkModeListItems("favorite", [rootFolder, folderA, folderB, folderC]);
+
+ folderA.clearFlag(Ci.nsMsgFolderFlags.Favorite);
+ await checkModeListItems("favorite", [rootFolder, folderA, folderB, folderC]);
+
+ folderC.clearFlag(Ci.nsMsgFolderFlags.Favorite);
+ await checkModeListItems("favorite", []);
+});
+
+/**
+ * Tests the compact Favorite Folders mode.
+ */
+add_task(async function testCompactFavoriteFolders() {
+ folderPane.activeModes = ["all", "favorite"];
+ folderPane.isCompact = true;
+ await checkModeListItems("favorite", []);
+
+ folderA.setFlag(Ci.nsMsgFolderFlags.Favorite);
+ await checkModeListItems("favorite", [folderA]);
+
+ folderA.clearFlag(Ci.nsMsgFolderFlags.Favorite);
+ await checkModeListItems("favorite", []);
+
+ folderB.setFlag(Ci.nsMsgFolderFlags.Favorite);
+ await checkModeListItems("favorite", [folderB]);
+
+ folderB.clearFlag(Ci.nsMsgFolderFlags.Favorite);
+ await checkModeListItems("favorite", []);
+
+ folderC.setFlag(Ci.nsMsgFolderFlags.Favorite);
+ folderA.setFlag(Ci.nsMsgFolderFlags.Favorite);
+ await checkModeListItems("favorite", [folderA, folderC]); // c, a
+
+ folderA.clearFlag(Ci.nsMsgFolderFlags.Favorite);
+ await checkModeListItems("favorite", [folderC]);
+
+ folderC.clearFlag(Ci.nsMsgFolderFlags.Favorite);
+ await checkModeListItems("favorite", []);
+
+ // Test with multiple accounts.
+
+ let foo = MailServices.accounts.createAccount();
+ foo.incomingServer = MailServices.accounts.createIncomingServer(
+ `${foo.key}user`,
+ "localhost",
+ "none"
+ );
+ let fooRootFolder = foo.incomingServer.rootFolder;
+ let fooTrashFolder = fooRootFolder.getChildNamed("Trash");
+
+ fooTrashFolder.setFlag(Ci.nsMsgFolderFlags.Favorite);
+ await checkModeListItems("favorite", [fooTrashFolder]);
+
+ folderC.setFlag(Ci.nsMsgFolderFlags.Favorite);
+ await checkModeListItems("favorite", [fooTrashFolder, folderC]);
+
+ MailServices.accounts.reorderAccounts([account.key, foo.key]);
+ await checkModeListItems("favorite", [folderC, fooTrashFolder]);
+
+ fooTrashFolder.clearFlag(Ci.nsMsgFolderFlags.Favorite);
+ await checkModeListItems("favorite", [folderC]);
+
+ fooTrashFolder.setFlag(Ci.nsMsgFolderFlags.Favorite);
+ await checkModeListItems("favorite", [folderC, fooTrashFolder]);
+
+ folderC.clearFlag(Ci.nsMsgFolderFlags.Favorite);
+ await checkModeListItems("favorite", [fooTrashFolder]);
+
+ // Clean up.
+
+ MailServices.accounts.removeAccount(foo, false);
+ await checkModeListItems("favorite", []);
+ folderPane.isCompact = false;
+});
+
+/**
+ * Tests the Unread Folders mode.
+ */
+add_task(async function testUnreadFolders() {
+ let folderAMessages = [...folderA.messages];
+ let folderBMessages = [...folderB.messages];
+ let folderCMessages = [...folderC.messages];
+
+ folderPane.activeModes = ["all", "unread"];
+ await checkModeListItems("unread", []);
+
+ folderAMessages[0].markRead(false);
+ await checkModeListItems("unread", [rootFolder, folderA]);
+
+ folderAMessages[1].markRead(false);
+ folderAMessages[2].markRead(false);
+ await checkModeListItems("unread", [rootFolder, folderA]);
+
+ window.MsgMarkAllRead([folderA]);
+ await checkModeListItems("unread", [rootFolder, folderA]);
+
+ folderAMessages[0].markRead(false);
+ folderBMessages[0].markRead(false);
+ await checkModeListItems("unread", [rootFolder, folderA, folderB]);
+
+ folderCMessages[0].markRead(false);
+ await checkModeListItems("unread", [rootFolder, folderA, folderB, folderC]);
+
+ folderBMessages[0].markRead(true);
+ await checkModeListItems("unread", [rootFolder, folderA, folderB, folderC]);
+
+ folderAMessages[0].markRead(true);
+ await checkModeListItems("unread", [rootFolder, folderA, folderB, folderC]);
+
+ folderCMessages[0].markRead(true);
+ await checkModeListItems("unread", [rootFolder, folderA, folderB, folderC]);
+
+ folderCMessages[0].markRead(false);
+ await checkModeListItems("unread", [rootFolder, folderA, folderB, folderC]);
+
+ folderCMessages[1].markRead(false);
+ folderCMessages[2].markRead(false);
+ await checkModeListItems("unread", [rootFolder, folderA, folderB, folderC]);
+
+ window.MsgMarkAllRead([folderC]);
+ await checkModeListItems("unread", [rootFolder, folderA, folderB, folderC]);
+});
+
+/**
+ * Tests the compact Unread Folders mode.
+ */
+add_task(async function testCompactUnreadFolders() {
+ let folderAMessages = [...folderA.messages];
+ let folderBMessages = [...folderB.messages];
+ let folderCMessages = [...folderC.messages];
+
+ folderPane.activeModes = ["all", "unread"];
+ folderPane.isCompact = true;
+ await checkModeListItems("unread", []);
+
+ folderAMessages[0].markRead(false);
+ await checkModeListItems("unread", [folderA]);
+
+ folderAMessages[1].markRead(false);
+ folderAMessages[2].markRead(false);
+ await checkModeListItems("unread", [folderA]);
+
+ window.MsgMarkAllRead([folderA]);
+ await checkModeListItems("unread", [folderA]);
+
+ folderAMessages[0].markRead(false);
+ folderBMessages[0].markRead(false);
+ await checkModeListItems("unread", [folderA, folderB]);
+
+ folderCMessages[0].markRead(false);
+ await checkModeListItems("unread", [folderA, folderB, folderC]);
+
+ folderBMessages[0].markRead(true);
+ await checkModeListItems("unread", [folderA, folderB, folderC]);
+
+ folderAMessages[0].markRead(true);
+ await checkModeListItems("unread", [folderA, folderB, folderC]);
+
+ folderCMessages[0].markRead(true);
+ await checkModeListItems("unread", [folderA, folderB, folderC]);
+
+ folderCMessages[0].markRead(false);
+ await checkModeListItems("unread", [folderA, folderB, folderC]);
+
+ folderCMessages[1].markRead(false);
+ folderCMessages[2].markRead(false);
+ await checkModeListItems("unread", [folderA, folderB, folderC]);
+
+ window.MsgMarkAllRead([folderC]);
+ await checkModeListItems("unread", [folderA, folderB, folderC]);
+
+ // Test with multiple accounts.
+
+ let foo = MailServices.accounts.createAccount();
+ foo.incomingServer = MailServices.accounts.createIncomingServer(
+ `${foo.key}user`,
+ "localhost",
+ "none"
+ );
+ let fooRootFolder = foo.incomingServer.rootFolder;
+ let fooTrashFolder = fooRootFolder.getChildNamed("Trash");
+
+ let generator = new MessageGenerator();
+ fooTrashFolder
+ .QueryInterface(Ci.nsIMsgLocalMailFolder)
+ .addMessage(generator.makeMessages({}).map(m => m.toMboxString()));
+ let fooMessages = [...fooTrashFolder.messages];
+
+ fooMessages[0].markRead(false);
+ await checkModeListItems("unread", [
+ fooTrashFolder,
+ folderA,
+ folderB,
+ folderC,
+ ]);
+
+ folderCMessages[0].markRead(false);
+ await checkModeListItems("unread", [
+ fooTrashFolder,
+ folderA,
+ folderB,
+ folderC,
+ ]);
+
+ MailServices.accounts.reorderAccounts([account.key, foo.key]);
+ await checkModeListItems("unread", [
+ folderA,
+ folderB,
+ folderC,
+ fooTrashFolder,
+ ]);
+
+ fooMessages[0].markRead(true);
+ await checkModeListItems("unread", [
+ folderA,
+ folderB,
+ folderC,
+ fooTrashFolder,
+ ]);
+
+ fooMessages[0].markRead(false);
+ await checkModeListItems("unread", [
+ folderA,
+ folderB,
+ folderC,
+ fooTrashFolder,
+ ]);
+
+ folderCMessages[0].markRead(true);
+ await checkModeListItems("unread", [
+ folderA,
+ folderB,
+ folderC,
+ fooTrashFolder,
+ ]);
+
+ // Clean up.
+
+ MailServices.accounts.removeAccount(foo, false);
+ await checkModeListItems("unread", [folderA, folderB, folderC]);
+ folderPane.isCompact = false;
+});
+
+/**
+ * Tests the Smart Folders mode.
+ */
+add_task(async function testSmartFolders() {
+ folderPane.activeModes = ["smart"];
+
+ // Check the mode is set up correctly.
+ let localExtraFolders = [rootFolder, outboxFolder, folderA, folderB, folderC];
+ let smartServer = MailServices.accounts.findServer(
+ "nobody",
+ "smart mailboxes",
+ "none"
+ );
+ let smartInbox = smartServer.rootFolder.getChildNamed("Inbox");
+ let smartInboxFolders = [smartInbox, inboxFolder];
+ let otherSmartFolders = [
+ smartServer.rootFolder.getChildNamed("Drafts"),
+ smartServer.rootFolder.getChildNamed("Templates"),
+ smartServer.rootFolder.getChildNamed("Sent"),
+ smartServer.rootFolder.getChildNamed("Archives"),
+ smartServer.rootFolder.getChildNamed("Junk"),
+ smartServer.rootFolder.getChildNamed("Trash"),
+ ];
+ await checkModeListItems("smart", [
+ ...smartInboxFolders,
+ ...otherSmartFolders,
+ trashFolder,
+ ...localExtraFolders,
+ ]);
+
+ // Add some subfolders of existing folders.
+ rootFolder.createSubfolder("folderTreeQuirksX", null);
+ let folderX = rootFolder.getChildNamed("folderTreeQuirksX");
+ inboxFolder.createSubfolder("folderTreeQuirksY", null);
+ let folderY = inboxFolder.getChildNamed("folderTreeQuirksY");
+ folderY.createSubfolder("folderTreeQuirksYY", null);
+ let folderYY = folderY.getChildNamed("folderTreeQuirksYY");
+ folderB.createSubfolder("folderTreeQuirksZ", null);
+ let folderZ = folderB.getChildNamed("folderTreeQuirksZ");
+
+ // Check the folders are listed in the right order.
+ await checkModeListItems("smart", [
+ ...smartInboxFolders,
+ folderY,
+ folderYY,
+ ...otherSmartFolders,
+ trashFolder,
+ ...localExtraFolders,
+ folderZ,
+ folderX,
+ ]);
+
+ // Check the hierarchy.
+ let rootRow = folderPane.getRowForFolder(rootFolder);
+ let inboxRow = folderPane.getRowForFolder(inboxFolder);
+ let trashRow = folderPane.getRowForFolder(trashFolder);
+ let rowB = folderPane.getRowForFolder(folderB);
+ let rowX = folderPane.getRowForFolder(folderX);
+ let rowY = folderPane.getRowForFolder(folderY);
+ let rowYY = folderPane.getRowForFolder(folderYY);
+ let rowZ = folderPane.getRowForFolder(folderZ);
+ Assert.equal(
+ rowX.parentNode.parentNode,
+ rootRow,
+ "folderX should be displayed as a child of rootFolder"
+ );
+ Assert.equal(
+ rowY.parentNode.parentNode,
+ inboxRow,
+ "folderY should be displayed as a child of inboxFolder"
+ );
+ Assert.equal(
+ rowYY.parentNode.parentNode,
+ rowY,
+ "folderYY should be displayed as a child of folderY"
+ );
+ Assert.equal(
+ rowZ.parentNode.parentNode,
+ rowB,
+ "folderZ should be displayed as a child of folderB"
+ );
+
+ // Stop searching folderY and folderYY in the smart inbox. They should stop
+ // being listed under the inbox and instead appear under the root folder.
+ let wrappedInbox = VirtualFolderHelper.wrapVirtualFolder(smartInbox);
+ Assert.deepEqual(wrappedInbox.searchFolders, [
+ inboxFolder,
+ folderY,
+ folderYY,
+ ]);
+ wrappedInbox.searchFolders = [inboxFolder];
+
+ // Check the folders are listed in the right order.
+ await checkModeListItems("smart", [
+ ...smartInboxFolders,
+ ...otherSmartFolders,
+ trashFolder,
+ ...localExtraFolders,
+ folderZ,
+ folderX,
+ folderY,
+ folderYY,
+ ]);
+
+ // Check the hierarchy.
+ rowY = folderPane.getRowForFolder(folderY);
+ rowYY = folderPane.getRowForFolder(folderYY);
+ Assert.equal(
+ rowY.parentNode.parentNode,
+ rootRow,
+ "folderY should be displayed as a child of the rootFolder"
+ );
+ Assert.equal(
+ rowYY.parentNode.parentNode,
+ rowY,
+ "folderYY should be displayed as a child of folderY"
+ );
+
+ // Search them again. They should move back to the smart inbox section.
+ wrappedInbox.searchFolders = [inboxFolder, folderY, folderYY];
+
+ // Check the folders are listed in the right order.
+ await checkModeListItems("smart", [
+ ...smartInboxFolders,
+ folderY,
+ folderYY,
+ ...otherSmartFolders,
+ trashFolder,
+ ...localExtraFolders,
+ folderZ,
+ folderX,
+ ]);
+
+ // Check the hierarchy.
+ rowY = folderPane.getRowForFolder(folderY);
+ rowYY = folderPane.getRowForFolder(folderYY);
+ Assert.equal(
+ rowY.parentNode.parentNode,
+ inboxRow,
+ "folderY should be displayed as a child of inboxFolder"
+ );
+ Assert.equal(
+ rowYY.parentNode.parentNode,
+ rowY,
+ "folderYY should be displayed as a child of folderY"
+ );
+
+ // Delete the added folders.
+ folderX.deleteSelf(null);
+ folderY.deleteSelf(null);
+ folderZ.deleteSelf(null);
+ folderX = trashFolder.getChildNamed("folderTreeQuirksX");
+ folderY = trashFolder.getChildNamed("folderTreeQuirksY");
+ folderYY = folderY.getChildNamed("folderTreeQuirksYY");
+ folderZ = trashFolder.getChildNamed("folderTreeQuirksZ");
+
+ // Check they appear in the trash.
+ await checkModeListItems("smart", [
+ ...smartInboxFolders,
+ ...otherSmartFolders,
+ trashFolder,
+ folderX,
+ folderY,
+ folderYY,
+ folderZ,
+ ...localExtraFolders,
+ ]);
+
+ // Check the hierarchy.
+ rowX = folderPane.getRowForFolder(folderX);
+ rowY = folderPane.getRowForFolder(folderY);
+ rowYY = folderPane.getRowForFolder(folderYY);
+ rowZ = folderPane.getRowForFolder(folderZ);
+ Assert.equal(
+ rowX.parentNode.parentNode,
+ trashRow,
+ "folderX should be displayed as a child of trashFolder"
+ );
+ Assert.equal(
+ rowY.parentNode.parentNode,
+ trashRow,
+ "folderY should be displayed as a child of trashFolder"
+ );
+ Assert.equal(
+ rowYY.parentNode.parentNode,
+ rowY,
+ "folderYY should be displayed as a child of folderY"
+ );
+ Assert.equal(
+ rowZ.parentNode.parentNode,
+ trashRow,
+ "folderZ should be displayed as a child of trashFolder"
+ );
+
+ // Empty the trash and check everything is back to normal.
+ rootFolder.emptyTrash(null, null);
+ await checkModeListItems("smart", [
+ ...smartInboxFolders,
+ ...otherSmartFolders,
+ trashFolder,
+ ...localExtraFolders,
+ ]);
+});
+
+/**
+ * Tests that after moving a folder it is in the right place in the tree,
+ * with any subfolders if they should be shown.
+ */
+add_task(async function testFolderMove() {
+ rootFolder.createSubfolder("new parent", null);
+ let newParentFolder = rootFolder.getChildNamed("new parent");
+ [...folderC.messages][6].markRead(false);
+ folderC.setFlag(Ci.nsMsgFolderFlags.Favorite);
+
+ // Set up and check initial state.
+
+ folderPane.activeModes = ["all", "unread", "favorite"];
+ folderPane.isCompact = false;
+
+ await checkModeListItems("all", [
+ rootFolder,
+ inboxFolder,
+ trashFolder,
+ outboxFolder,
+ folderA,
+ folderB,
+ folderC,
+ newParentFolder,
+ ]);
+ await checkModeListItems("unread", [rootFolder, folderA, folderB, folderC]);
+ await checkModeListItems("favorite", [rootFolder, folderA, folderB, folderC]);
+
+ // Move `folderB` from `folderA` to `newParentFolder`.
+
+ let copyListener = new PromiseTestUtils.PromiseCopyListener();
+ MailServices.copy.copyFolder(
+ folderB,
+ newParentFolder,
+ true,
+ copyListener,
+ window.msgWindow
+ );
+ await copyListener.promise;
+
+ let movedFolderB = newParentFolder.getChildNamed("folderTreeQuirksB");
+ let movedFolderC = movedFolderB.getChildNamed("folderTreeQuirksC");
+
+ await checkModeListItems("all", [
+ rootFolder,
+ inboxFolder,
+ trashFolder,
+ outboxFolder,
+ folderA,
+ newParentFolder,
+ movedFolderB,
+ movedFolderC,
+ ]);
+ await checkModeListItems("unread", [
+ rootFolder,
+ newParentFolder,
+ movedFolderB,
+ movedFolderC,
+ ]);
+ await checkModeListItems("favorite", [
+ rootFolder,
+ newParentFolder,
+ movedFolderB,
+ movedFolderC,
+ ]);
+
+ // Switch to compact mode for the return move.
+
+ folderPane.isCompact = true;
+ await checkModeListItems("unread", [movedFolderC]);
+ await checkModeListItems("favorite", [movedFolderC]);
+
+ // Move `movedFolderB` from `newParentFolder` back to `folderA`.
+
+ copyListener = new PromiseTestUtils.PromiseCopyListener();
+ MailServices.copy.copyFolder(
+ movedFolderB,
+ folderA,
+ true,
+ copyListener,
+ window.msgWindow
+ );
+ await copyListener.promise;
+
+ await checkModeListItems("all", [
+ rootFolder,
+ inboxFolder,
+ trashFolder,
+ outboxFolder,
+ folderA,
+ folderB,
+ folderC,
+ newParentFolder,
+ ]);
+ await checkModeListItems("unread", [folderC]);
+ await checkModeListItems("favorite", [folderC]);
+
+ // Clean up.
+
+ newParentFolder.deleteSelf(null);
+ rootFolder.emptyTrash(null, null);
+ folderC.markAllMessagesRead(null);
+ folderC.clearFlag(Ci.nsMsgFolderFlags.Favorite);
+ folderPane.isCompact = false;
+});
+
+/**
+ * Tests that after renaming a folder it is in the right place in the tree,
+ * with any subfolders if they should be shown.
+ */
+add_task(async function testFolderRename() {
+ let extraFolders = {};
+ for (let name of ["aaa", "ggg", "zzz"]) {
+ rootFolder.createSubfolder(name, null);
+ extraFolders[name] = rootFolder
+ .getChildNamed(name)
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+ extraFolders[name].addMessage(generator.makeMessage({}).toMboxString());
+ extraFolders[name].setFlag(Ci.nsMsgFolderFlags.Favorite);
+ }
+ [...folderC.messages][4].markRead(false);
+ folderC.setFlag(Ci.nsMsgFolderFlags.Favorite);
+
+ // Set up and check initial state.
+
+ folderPane.activeModes = ["all", "unread", "favorite"];
+ folderPane.isCompact = false;
+
+ await checkModeListItems("all", [
+ rootFolder,
+ inboxFolder,
+ trashFolder,
+ outboxFolder,
+ extraFolders.aaa,
+ folderA,
+ folderB,
+ folderC,
+ extraFolders.ggg,
+ extraFolders.zzz,
+ ]);
+ await checkModeListItems("unread", [
+ rootFolder,
+ extraFolders.aaa,
+ folderA,
+ folderB,
+ folderC,
+ extraFolders.ggg,
+ extraFolders.zzz,
+ ]);
+ await checkModeListItems("favorite", [
+ rootFolder,
+ extraFolders.aaa,
+ folderA,
+ folderB,
+ folderC,
+ extraFolders.ggg,
+ extraFolders.zzz,
+ ]);
+
+ // Rename `folderA`.
+
+ folderA.rename("renamedA", window.msgWindow);
+ let renamedFolderA = rootFolder.getChildNamed("renamedA");
+ let renamedFolderB = renamedFolderA.getChildNamed("folderTreeQuirksB");
+ let renamedFolderC = renamedFolderB.getChildNamed("folderTreeQuirksC");
+
+ await checkModeListItems("all", [
+ rootFolder,
+ inboxFolder,
+ trashFolder,
+ outboxFolder,
+ extraFolders.aaa,
+ extraFolders.ggg,
+ renamedFolderA,
+ renamedFolderB,
+ renamedFolderC,
+ extraFolders.zzz,
+ ]);
+ await checkModeListItems("unread", [
+ rootFolder,
+ extraFolders.aaa,
+ extraFolders.ggg,
+ renamedFolderA,
+ renamedFolderB,
+ renamedFolderC,
+ extraFolders.zzz,
+ ]);
+ await checkModeListItems("favorite", [
+ rootFolder,
+ extraFolders.aaa,
+ extraFolders.ggg,
+ renamedFolderA,
+ renamedFolderB,
+ renamedFolderC,
+ extraFolders.zzz,
+ ]);
+
+ // Switch to compact mode.
+
+ folderPane.isCompact = true;
+ await checkModeListItems("unread", [
+ extraFolders.aaa,
+ renamedFolderC,
+ extraFolders.ggg,
+ extraFolders.zzz,
+ ]);
+ await checkModeListItems("favorite", [
+ extraFolders.aaa,
+ renamedFolderC,
+ extraFolders.ggg,
+ extraFolders.zzz,
+ ]);
+
+ // Rename the folder back to its original name.
+
+ renamedFolderA.rename("folderTreeQuirksA", window.msgWindow);
+
+ await checkModeListItems("all", [
+ rootFolder,
+ inboxFolder,
+ trashFolder,
+ outboxFolder,
+ extraFolders.aaa,
+ folderA,
+ folderB,
+ folderC,
+ extraFolders.ggg,
+ extraFolders.zzz,
+ ]);
+ await checkModeListItems("unread", [
+ extraFolders.aaa,
+ folderC,
+ extraFolders.ggg,
+ extraFolders.zzz,
+ ]);
+ await checkModeListItems("favorite", [
+ extraFolders.aaa,
+ folderC,
+ extraFolders.ggg,
+ extraFolders.zzz,
+ ]);
+
+ // Clean up.
+
+ extraFolders.aaa.deleteSelf(null);
+ extraFolders.ggg.deleteSelf(null);
+ extraFolders.zzz.deleteSelf(null);
+ rootFolder.emptyTrash(null, null);
+ folderC.markAllMessagesRead(null);
+ folderC.clearFlag(Ci.nsMsgFolderFlags.Favorite);
+ folderPane.isCompact = false;
+});
+
+/**
+ * The creation of a virtual folder involves two "folderAdded" notifications.
+ * Check that only one entry in the folder tree is created.
+ */
+add_task(async function testSearchFolderAddedOnlyOnce() {
+ let context = about3Pane.document.getElementById("folderPaneContext");
+ let searchMessagesItem = about3Pane.document.getElementById(
+ "folderPaneContext-searchMessages"
+ );
+ let removeItem = about3Pane.document.getElementById(
+ "folderPaneContext-remove"
+ );
+
+ // Start searching for messages.
+
+ let shownPromise = BrowserTestUtils.waitForEvent(context, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ folderPane.getRowForFolder(rootFolder).querySelector(".name"),
+ { type: "contextmenu" },
+ about3Pane
+ );
+ await shownPromise;
+
+ let searchWindowPromise = BrowserTestUtils.domWindowOpenedAndLoaded(
+ null,
+ w =>
+ w.document.documentURI == "chrome://messenger/content/SearchDialog.xhtml"
+ );
+ context.activateItem(searchMessagesItem);
+ let searchWindow = await searchWindowPromise;
+
+ EventUtils.synthesizeMouseAtCenter(
+ searchWindow.document.getElementById("searchVal0"),
+ {},
+ searchWindow
+ );
+ EventUtils.sendString("hovercraft", searchWindow);
+
+ // Create a virtual folder for the search.
+
+ let vfWindowPromise = BrowserTestUtils.promiseAlertDialogOpen(
+ null,
+ "chrome://messenger/content/virtualFolderProperties.xhtml",
+ {
+ async callback(vfWindow) {
+ EventUtils.synthesizeMouseAtCenter(
+ vfWindow.document.getElementById("name"),
+ {},
+ vfWindow
+ );
+ EventUtils.sendString("virtual folder", vfWindow);
+ EventUtils.synthesizeMouseAtCenter(
+ vfWindow.document.querySelector("dialog").getButton("accept"),
+ {},
+ vfWindow
+ );
+ },
+ }
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ searchWindow.document.getElementById("saveAsVFButton"),
+ {},
+ searchWindow
+ );
+ await vfWindowPromise;
+
+ await BrowserTestUtils.closeWindow(searchWindow);
+
+ // Find the folder and the row for it in the tree.
+
+ let virtualFolder = rootFolder.getChildNamed("virtual folder");
+ let row = await TestUtils.waitForCondition(() =>
+ folderPane.getRowForFolder(virtualFolder)
+ );
+
+ // Check it exists only once.
+
+ await checkModeListItems("all", [
+ rootFolder,
+ inboxFolder,
+ trashFolder,
+ virtualFolder,
+ outboxFolder,
+ folderA,
+ folderB,
+ folderC,
+ ]);
+
+ // Delete the virtual folder.
+
+ shownPromise = BrowserTestUtils.waitForEvent(context, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ row.querySelector(".name"),
+ { type: "contextmenu" },
+ about3Pane
+ );
+ await shownPromise;
+
+ let dialogPromise = BrowserTestUtils.promiseAlertDialogOpen("accept");
+ context.activateItem(removeItem);
+ await dialogPromise;
+ await new Promise(resolve => setTimeout(resolve));
+
+ // Check it went away.
+
+ await checkModeListItems("all", [
+ rootFolder,
+ inboxFolder,
+ trashFolder,
+ outboxFolder,
+ folderA,
+ folderB,
+ folderC,
+ ]);
+});
+
+/**
+ * Tests deferred POP3 accounts are not displayed in All Folders mode, and
+ * that a change in their deferred status updates the folder tree.
+ */
+add_task(async function testDeferredAccount() {
+ let pop3Account = MailServices.accounts.createAccount();
+ let pop3Server = MailServices.accounts.createIncomingServer(
+ `${pop3Account.key}user`,
+ "localhost",
+ "pop3"
+ );
+ pop3Server.QueryInterface(Ci.nsIPop3IncomingServer);
+ pop3Account.incomingServer = pop3Server.QueryInterface(
+ Ci.nsIPop3IncomingServer
+ );
+
+ let pop3RootFolder = pop3Server.rootFolder;
+ let pop3Folders = [
+ pop3RootFolder,
+ pop3RootFolder.getChildNamed("Inbox"),
+ pop3RootFolder.getChildNamed("Trash"),
+ ];
+ let localFolders = [
+ rootFolder,
+ inboxFolder,
+ trashFolder,
+ outboxFolder,
+ folderA,
+ folderB,
+ folderC,
+ ];
+
+ folderPane.activeModes = ["all"];
+ await checkModeListItems("all", [...pop3Folders, ...localFolders]);
+
+ // Defer the account to Local Folders.
+ pop3Server.deferredToAccount = account.key;
+ await checkModeListItems("all", localFolders);
+
+ // Remove and add the All mode again to check that an existing deferred
+ // folder is not shown when the mode initialises.
+ folderPane.activeModes = ["recent"];
+ folderPane.activeModes = ["all"];
+ await checkModeListItems("all", localFolders);
+
+ // Stop deferring the account.
+ pop3Server.deferredToAccount = null;
+ await checkModeListItems("all", [...pop3Folders, ...localFolders]);
+
+ MailServices.accounts.removeAccount(pop3Account, false);
+});
+
+/**
+ * We deliberately hide the special [Gmail] folder from the folder tree.
+ * Check that it doesn't appear when for a new or existing account.
+ */
+add_task(async function testGmailFolders() {
+ IMAPServer.open();
+ // Set up a fake Gmail account.
+ let gmailAccount = MailServices.accounts.createAccount();
+ let gmailServer = MailServices.accounts.createIncomingServer(
+ "user",
+ "localhost",
+ "imap"
+ );
+ gmailServer.port = IMAPServer.port;
+ gmailServer.password = "password";
+ gmailAccount.incomingServer = gmailServer;
+
+ let gmailIdentity = MailServices.accounts.createIdentity();
+ gmailIdentity.email = "imap@invalid";
+ gmailAccount.addIdentity(gmailIdentity);
+ gmailAccount.defaultIdentity = gmailIdentity;
+
+ let gmailRootFolder = gmailServer.rootFolder;
+ gmailServer.performExpand(window.msgWindow);
+ await TestUtils.waitForCondition(
+ () => gmailRootFolder.subFolders.length == 3,
+ "waiting for folders to be created"
+ );
+
+ let gmailInboxFolder = gmailRootFolder.getChildNamed("INBOX");
+ let gmailTrashFolder = gmailRootFolder.getChildNamed("Trash");
+ let gmailGmailFolder = gmailRootFolder.getChildNamed("[Gmail]");
+ await TestUtils.waitForCondition(
+ () => gmailGmailFolder.subFolders.length == 1,
+ "waiting for All Mail folder to be created"
+ );
+ let gmailAllMailFolder = gmailGmailFolder.getChildNamed("All Mail");
+
+ Assert.ok(
+ !folderPane._isGmailFolder(gmailRootFolder),
+ "_isGmailFolder should be false for the root folder"
+ );
+ Assert.ok(
+ folderPane._isGmailFolder(gmailGmailFolder),
+ "_isGmailFolder should be true for the [Gmail] folder"
+ );
+ Assert.ok(
+ !folderPane._isGmailFolder(gmailAllMailFolder),
+ "_isGmailFolder should be false for the All Mail folder"
+ );
+
+ Assert.equal(
+ folderPane._getNonGmailFolder(gmailRootFolder),
+ gmailRootFolder,
+ "_getNonGmailFolder should return the same folder for the root folder"
+ );
+ Assert.equal(
+ folderPane._getNonGmailFolder(gmailGmailFolder),
+ gmailRootFolder,
+ "_getNonGmailFolder should return the root folder for the [Gmail] folder"
+ );
+ Assert.equal(
+ folderPane._getNonGmailFolder(gmailAllMailFolder),
+ gmailAllMailFolder,
+ "_getNonGmailFolder should return the same folder for the All Mail folder"
+ );
+
+ Assert.equal(
+ folderPane._getNonGmailParent(gmailRootFolder),
+ null,
+ "_getNonGmailParent should return null for the root folder"
+ );
+ Assert.equal(
+ folderPane._getNonGmailParent(gmailGmailFolder),
+ gmailRootFolder,
+ "_getNonGmailParent should return the root folder for the [Gmail] folder"
+ );
+ Assert.equal(
+ folderPane._getNonGmailParent(gmailAllMailFolder),
+ gmailRootFolder,
+ "_getNonGmailParent should return the root folder for the All Mail folder"
+ );
+
+ await checkModeListItems("all", [
+ gmailRootFolder,
+ gmailInboxFolder,
+ gmailAllMailFolder,
+ gmailTrashFolder,
+ rootFolder,
+ inboxFolder,
+ trashFolder,
+ outboxFolder,
+ folderA,
+ folderB,
+ folderC,
+ ]);
+
+ // The accounts didn't exist when about:3pane loaded, but we can simulate
+ // that by removing the mode and then re-adding it.
+ folderPane.activeModes = ["favorite"];
+ folderPane.activeModes = ["all"];
+
+ await checkModeListItems("all", [
+ gmailRootFolder,
+ gmailInboxFolder,
+ gmailAllMailFolder,
+ gmailTrashFolder,
+ rootFolder,
+ inboxFolder,
+ trashFolder,
+ outboxFolder,
+ folderA,
+ folderB,
+ folderC,
+ ]);
+
+ MailServices.accounts.removeAccount(gmailAccount, false);
+});
+
+add_task(async function testAccountOrder() {
+ // Make some changes to the main account so that it appears in all modes.
+
+ [...folderA.messages][0].markRead(false);
+ folderA.setFlag(Ci.nsMsgFolderFlags.Favorite);
+ folderPane.activeModes = ["all", "smart", "unread", "favorite"];
+
+ let localFolders = [
+ rootFolder,
+ inboxFolder,
+ trashFolder,
+ outboxFolder,
+ folderA,
+ folderB,
+ folderC,
+ ];
+ let localExtraFolders = [rootFolder, outboxFolder, folderA, folderB, folderC];
+ let smartServer = MailServices.accounts.findServer(
+ "nobody",
+ "smart mailboxes",
+ "none"
+ );
+ let smartFolders = [
+ smartServer.rootFolder.getChildNamed("Inbox"),
+ inboxFolder,
+ smartServer.rootFolder.getChildNamed("Drafts"),
+ smartServer.rootFolder.getChildNamed("Templates"),
+ smartServer.rootFolder.getChildNamed("Sent"),
+ smartServer.rootFolder.getChildNamed("Archives"),
+ smartServer.rootFolder.getChildNamed("Junk"),
+ smartServer.rootFolder.getChildNamed("Trash"),
+ // There are trash folders in each account, they go here.
+ ];
+
+ // Check the initial items in the folder tree.
+
+ await checkModeListItems("all", localFolders);
+ await checkModeListItems("smart", [
+ ...smartFolders,
+ trashFolder,
+ ...localExtraFolders,
+ ]);
+ await checkModeListItems("unread", [rootFolder, folderA]);
+ await checkModeListItems("favorite", [rootFolder, folderA]);
+
+ // Create two new "none" accounts, foo and bar.
+
+ let foo = MailServices.accounts.createAccount();
+ foo.incomingServer = MailServices.accounts.createIncomingServer(
+ `${foo.key}user`,
+ "localhost",
+ "none"
+ );
+ let fooRootFolder = foo.incomingServer.rootFolder;
+ let fooTrashFolder = fooRootFolder.getChildNamed("Trash");
+ let fooOutboxFolder = fooRootFolder.getChildNamed("Outbox");
+ let fooFolders = [fooRootFolder, fooTrashFolder, fooOutboxFolder];
+ let fooExtraFolders = [fooRootFolder, fooOutboxFolder];
+
+ let bar = MailServices.accounts.createAccount();
+ bar.incomingServer = MailServices.accounts.createIncomingServer(
+ `${bar.key}user`,
+ "localhost",
+ "none"
+ );
+ let barRootFolder = bar.incomingServer.rootFolder;
+ let barTrashFolder = barRootFolder.getChildNamed("Trash");
+ let barOutboxFolder = barRootFolder.getChildNamed("Outbox");
+ let barFolders = [barRootFolder, barTrashFolder, barOutboxFolder];
+ let barExtraFolders = [barRootFolder, barOutboxFolder];
+
+ let generator = new MessageGenerator();
+ fooTrashFolder
+ .QueryInterface(Ci.nsIMsgLocalMailFolder)
+ .addMessage(generator.makeMessage({}).toMboxString());
+ fooTrashFolder.setFlag(Ci.nsMsgFolderFlags.Favorite);
+ barTrashFolder
+ .QueryInterface(Ci.nsIMsgLocalMailFolder)
+ .addMessage(generator.makeMessage({}).toMboxString());
+ barTrashFolder.setFlag(Ci.nsMsgFolderFlags.Favorite);
+
+ // Check the addition of accounts has put them in the right order.
+
+ Assert.deepEqual(
+ MailServices.accounts.accounts.map(a => a.key),
+ [foo.key, bar.key, account.key]
+ );
+ await checkModeListItems("all", [
+ ...fooFolders,
+ ...barFolders,
+ ...localFolders,
+ ]);
+ await checkModeListItems("smart", [
+ ...smartFolders,
+ fooTrashFolder,
+ barTrashFolder,
+ trashFolder,
+ ...fooExtraFolders,
+ ...barExtraFolders,
+ ...localExtraFolders,
+ ]);
+ await checkModeListItems("unread", [
+ fooRootFolder,
+ fooTrashFolder,
+ barRootFolder,
+ barTrashFolder,
+ rootFolder,
+ folderA,
+ ]);
+ await checkModeListItems("favorite", [
+ fooRootFolder,
+ fooTrashFolder,
+ barRootFolder,
+ barTrashFolder,
+ rootFolder,
+ folderA,
+ ]);
+
+ // Remove and add the modes again. This should reinitialise them.
+
+ folderPane.activeModes = ["recent"];
+ folderPane.activeModes = ["all", "smart", "unread", "favorite"];
+ await checkModeListItems("all", [
+ ...fooFolders,
+ ...barFolders,
+ ...localFolders,
+ ]);
+ await checkModeListItems("smart", [
+ ...smartFolders,
+ fooTrashFolder,
+ barTrashFolder,
+ trashFolder,
+ ...fooExtraFolders,
+ ...barExtraFolders,
+ ...localExtraFolders,
+ ]);
+ await checkModeListItems("unread", [
+ fooRootFolder,
+ fooTrashFolder,
+ barRootFolder,
+ barTrashFolder,
+ rootFolder,
+ folderA,
+ ]);
+ await checkModeListItems("favorite", [
+ fooRootFolder,
+ fooTrashFolder,
+ barRootFolder,
+ barTrashFolder,
+ rootFolder,
+ folderA,
+ ]);
+
+ // Reorder the accounts.
+
+ MailServices.accounts.reorderAccounts([bar.key, account.key, foo.key]);
+ await checkModeListItems("all", [
+ ...barFolders,
+ ...localFolders,
+ ...fooFolders,
+ ]);
+ await checkModeListItems("smart", [
+ ...smartFolders,
+ barTrashFolder,
+ trashFolder,
+ fooTrashFolder,
+ ...barExtraFolders,
+ ...localExtraFolders,
+ ...fooExtraFolders,
+ ]);
+ await checkModeListItems("unread", [
+ barRootFolder,
+ barTrashFolder,
+ rootFolder,
+ folderA,
+ fooRootFolder,
+ fooTrashFolder,
+ ]);
+ await checkModeListItems("favorite", [
+ barRootFolder,
+ barTrashFolder,
+ rootFolder,
+ folderA,
+ fooRootFolder,
+ fooTrashFolder,
+ ]);
+
+ // Reorder the accounts again.
+
+ MailServices.accounts.reorderAccounts([foo.key, account.key, bar.key]);
+ await checkModeListItems("all", [
+ ...fooFolders,
+ ...localFolders,
+ ...barFolders,
+ ]);
+ await checkModeListItems("smart", [
+ ...smartFolders,
+ fooTrashFolder,
+ trashFolder,
+ barTrashFolder,
+ ...fooExtraFolders,
+ ...localExtraFolders,
+ ...barExtraFolders,
+ ]);
+ await checkModeListItems("unread", [
+ fooRootFolder,
+ fooTrashFolder,
+ rootFolder,
+ folderA,
+ barRootFolder,
+ barTrashFolder,
+ ]);
+ await checkModeListItems("favorite", [
+ fooRootFolder,
+ fooTrashFolder,
+ rootFolder,
+ folderA,
+ barRootFolder,
+ barTrashFolder,
+ ]);
+
+ // Remove one of the added accounts.
+
+ MailServices.accounts.removeAccount(foo, false);
+ await checkModeListItems("all", [...localFolders, ...barFolders]);
+ await checkModeListItems("smart", [
+ ...smartFolders,
+ trashFolder,
+ barTrashFolder,
+ ...localExtraFolders,
+ ...barExtraFolders,
+ ]);
+ await checkModeListItems("unread", [
+ rootFolder,
+ folderA,
+ barRootFolder,
+ barTrashFolder,
+ ]);
+ await checkModeListItems("favorite", [
+ rootFolder,
+ folderA,
+ barRootFolder,
+ barTrashFolder,
+ ]);
+
+ // Remove the other added account, folder flags, and the added folder.
+
+ MailServices.accounts.removeAccount(bar, false);
+ folderA.markAllMessagesRead(null);
+ folderA.clearFlag(Ci.nsMsgFolderFlags.Favorite);
+ rootFolder.emptyTrash(null);
+
+ await checkModeListItems("all", localFolders);
+ await checkModeListItems("smart", [
+ ...smartFolders,
+ trashFolder,
+ ...localExtraFolders,
+ ]);
+ await checkModeListItems("unread", [rootFolder, folderA]);
+ await checkModeListItems("favorite", []);
+
+ let shownPromise = BrowserTestUtils.waitForEvent(moreContext, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(moreButton, {}, about3Pane);
+ await shownPromise;
+
+ moreContext.activateItem(
+ moreContext.querySelector("#folderPaneHeaderToggleLocalFolders")
+ );
+ // Force a 500ms timeout due to a weird intermittent macOS issue that prevents
+ // the Escape key press from closing the menupopup.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 500));
+
+ let menuHiddenPromise = BrowserTestUtils.waitForEvent(
+ moreContext,
+ "popuphidden"
+ );
+ EventUtils.synthesizeKey("KEY_Escape", {}, about3Pane);
+ await menuHiddenPromise;
+
+ // All instances of local folders shouldn't be present.
+ await checkModeListItems("all", []);
+ await checkModeListItems("smart", [...smartFolders, trashFolder]);
+ await checkModeListItems("unread", []);
+ await checkModeListItems("favorite", []);
+});
+
+async function checkModeListItems(modeName, folders) {
+ // Jump to the end of the event queue so that any code listening for changes
+ // can run first.
+ await new Promise(resolve => setTimeout(resolve));
+ expandAll(modeName);
+
+ Assert.deepEqual(
+ Array.from(
+ folderPane._modes[modeName].containerList.querySelectorAll("li"),
+ folderTreeRow => folderTreeRow.uri
+ ),
+ folders.map(folder => folder.URI)
+ );
+}
+
+function expandAll(modeName) {
+ for (let folderTreeRow of folderPane._modes[
+ modeName
+ ].containerList.querySelectorAll("li")) {
+ folderTree.expandRow(folderTreeRow);
+ }
+}
+
+var IMAPServer = {
+ open() {
+ const {
+ ImapDaemon,
+ ImapMessage,
+ IMAP_GMAIL_extension,
+ IMAP_RFC3348_extension,
+ IMAP_RFC3501_handler,
+ mixinExtension,
+ } = ChromeUtils.import("resource://testing-common/mailnews/Imapd.jsm");
+ const { nsMailServer } = ChromeUtils.import(
+ "resource://testing-common/mailnews/Maild.jsm"
+ );
+ IMAPServer.ImapMessage = ImapMessage;
+
+ this.daemon = new ImapDaemon();
+ this.daemon.getMailbox("INBOX").specialUseFlag = "\\Inbox";
+ this.daemon.getMailbox("INBOX").subscribed = true;
+ this.daemon.createMailbox("Trash", {
+ flags: ["\\Trash"],
+ subscribed: true,
+ });
+ this.daemon.createMailbox("[Gmail]", {
+ flags: ["\\NoSelect"],
+ subscribed: true,
+ });
+ this.daemon.createMailbox("[Gmail]/All Mail", {
+ flags: ["\\Archive"],
+ subscribed: true,
+ specialUseFlag: "\\AllMail",
+ });
+ this.server = new nsMailServer(daemon => {
+ let handler = new IMAP_RFC3501_handler(daemon);
+ mixinExtension(handler, IMAP_GMAIL_extension);
+ mixinExtension(handler, IMAP_RFC3348_extension);
+ return handler;
+ }, this.daemon);
+ this.server.start();
+
+ registerCleanupFunction(() => this.close());
+ },
+ close() {
+ this.server.stop();
+ },
+ get port() {
+ return this.server.port;
+ },
+};
diff --git a/comm/mail/base/test/browser/browser_formPickers.js b/comm/mail/base/test/browser/browser_formPickers.js
new file mode 100644
index 0000000000..1b98a0584a
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_formPickers.js
@@ -0,0 +1,352 @@
+/* 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/. */
+
+/* eslint-env webextensions */
+
+var { MailE10SUtils } = ChromeUtils.import(
+ "resource:///modules/MailE10SUtils.jsm"
+);
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+var { MessageGenerator } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MessageGenerator.jsm"
+);
+
+const TEST_DOCUMENT_URL =
+ "http://mochi.test:8888/browser/comm/mail/base/test/browser/files/formContent.html";
+
+let testFolder;
+
+async function checkABrowser(browser) {
+ if (
+ browser.webProgress?.isLoadingDocument ||
+ browser.currentURI?.spec == "about:blank"
+ ) {
+ await BrowserTestUtils.browserLoaded(
+ browser,
+ undefined,
+ url => url != "about:blank"
+ );
+ }
+
+ let win = browser.ownerGlobal;
+ let doc = browser.ownerDocument;
+
+ // Date picker
+
+ let picker = win.top.document.getElementById("DateTimePickerPanel");
+ Assert.ok(picker, "date/time picker exists");
+
+ // Open the popup.
+ let shownPromise = BrowserTestUtils.waitForEvent(picker, "popupshown");
+ await SpecialPowers.spawn(browser, [], function () {
+ content.document.notifyUserGestureActivation();
+ content.document.querySelector(`input[type="date"]`).showPicker();
+ });
+ await shownPromise;
+
+ // Allow the picker time to initialise.
+ await new Promise(r => win.setTimeout(r, 500));
+
+ // Click in the middle of the picker. This should always land on a date and
+ // close the picker.
+ let hiddenPromise = BrowserTestUtils.waitForEvent(picker, "popuphidden");
+ let frame = picker.querySelector("#dateTimePopupFrame");
+ EventUtils.synthesizeMouseAtCenter(
+ frame.contentDocument.querySelector(".days-view td"),
+ {},
+ frame.contentWindow
+ );
+ await hiddenPromise;
+
+ // Check the date was assigned to the input.
+ await SpecialPowers.spawn(browser, [], () => {
+ Assert.ok(content.document.querySelector(`input[type="date"]`).value);
+ });
+
+ // Select drop-down
+
+ let menulist = win.top.document.getElementById("ContentSelectDropdown");
+ Assert.ok(menulist, "select menulist exists");
+ let menupopup = menulist.menupopup;
+
+ // Click on the select control to open the popup.
+ shownPromise = BrowserTestUtils.waitForEvent(menulist, "popupshown");
+ await BrowserTestUtils.synthesizeMouseAtCenter("select", {}, browser);
+ await shownPromise;
+
+ // Allow the menulist time to initialise.
+ await new Promise(r => win.setTimeout(r, 500));
+
+ Assert.equal(menulist.value, "0");
+ Assert.equal(menupopup.childElementCount, 3);
+ // Item values do not match the content document, but are 0-indexed.
+ Assert.equal(menupopup.children[0].label, "");
+ Assert.equal(menupopup.children[0].value, "0");
+ Assert.equal(menupopup.children[1].label, "π");
+ Assert.equal(menupopup.children[1].value, "1");
+ Assert.equal(menupopup.children[2].label, "τ");
+ Assert.equal(menupopup.children[2].value, "2");
+
+ // Click the second option. This sets the value and closes the menulist.
+ hiddenPromise = BrowserTestUtils.waitForEvent(menulist, "popuphidden");
+ menupopup.activateItem(menupopup.children[1]);
+ await hiddenPromise;
+
+ // Sometimes the next change doesn't happen soon enough.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => setTimeout(r, 1000));
+
+ // Check the value was assigned to the control.
+ await SpecialPowers.spawn(browser, [], () => {
+ Assert.equal(content.document.querySelector("select").value, "3.141592654");
+ });
+
+ // Input auto-complete
+
+ browser.focus();
+
+ let popup = doc.getElementById(browser.getAttribute("autocompletepopup"));
+ Assert.ok(popup, "auto-complete popup exists");
+
+ // Click on the input box and type some letters to open the popup.
+ shownPromise = BrowserTestUtils.waitForEvent(popup, "popupshown");
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ `input[list="letters"]`,
+ {},
+ browser
+ );
+ await BrowserTestUtils.synthesizeKey("e", {}, browser);
+ await BrowserTestUtils.synthesizeKey("t", {}, browser);
+ await BrowserTestUtils.synthesizeKey("a", {}, browser);
+ await shownPromise;
+
+ // Allow the popup time to initialise.
+ await new Promise(r => win.setTimeout(r, 500));
+
+ let list = popup.querySelector("richlistbox");
+ Assert.ok(list, "list added to popup");
+ Assert.equal(list.itemCount, 4);
+ Assert.equal(list.itemChildren[0].getAttribute("title"), "beta");
+ Assert.equal(list.itemChildren[1].getAttribute("title"), "zeta");
+ Assert.equal(list.itemChildren[2].getAttribute("title"), "eta");
+ Assert.equal(list.itemChildren[3].getAttribute("title"), "theta");
+
+ // Click the second option. This sets the value and closes the popup.
+ hiddenPromise = BrowserTestUtils.waitForEvent(popup, "popuphidden");
+ EventUtils.synthesizeMouseAtCenter(list.itemChildren[1], {}, win);
+ await hiddenPromise;
+
+ // Check the value was assigned to the input.
+ await SpecialPowers.spawn(browser, [], () => {
+ Assert.equal(
+ content.document.querySelector(`input[list="letters"]`).value,
+ "zeta"
+ );
+ });
+}
+
+add_setup(async function () {
+ MailServices.accounts.createLocalMailAccount();
+ let account = MailServices.accounts.accounts[0];
+ account.addIdentity(MailServices.accounts.createIdentity());
+ let rootFolder = account.incomingServer.rootFolder;
+ rootFolder.createSubfolder("formPickerFolder", null);
+ testFolder = rootFolder
+ .getChildNamed("formPickerFolder")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+ let messages = new MessageGenerator().makeMessages({ count: 5 });
+ let messageStrings = messages.map(message => message.toMboxString());
+ testFolder.addMessageBatch(messageStrings);
+
+ registerCleanupFunction(async () => {
+ MailServices.accounts.removeAccount(account, false);
+ });
+});
+
+add_task(async function testMessagePane() {
+ let about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+ about3Pane.restoreState({
+ folderURI: testFolder.URI,
+ messagePaneVisible: true,
+ });
+
+ about3Pane.messagePane.displayWebPage(TEST_DOCUMENT_URL);
+ await checkABrowser(about3Pane.webBrowser);
+});
+
+add_task(async function testContentTab() {
+ let tab = window.openContentTab(TEST_DOCUMENT_URL);
+ await checkABrowser(tab.browser);
+
+ let tabmail = document.getElementById("tabmail");
+ tabmail.closeTab(tab);
+});
+
+add_task(async function testExtensionPopupWindow() {
+ let extension = ExtensionTestUtils.loadExtension({
+ background: async () => {
+ await browser.windows.create({
+ url: "formContent.html",
+ type: "popup",
+ width: 800,
+ height: 500,
+ });
+ browser.test.notifyPass("ready");
+ },
+ files: {
+ "formContent.html": await fetch(TEST_DOCUMENT_URL).then(response =>
+ response.text()
+ ),
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("ready");
+
+ let extensionPopup = Services.wm.getMostRecentWindow("mail:extensionPopup");
+ // extensionPopup.xhtml needs time to initialise properly.
+ await new Promise(resolve => extensionPopup.setTimeout(resolve, 500));
+ await checkABrowser(extensionPopup.document.getElementById("requestFrame"));
+ await BrowserTestUtils.closeWindow(extensionPopup);
+
+ await extension.unload();
+});
+
+add_task(async function testExtensionBrowserAction() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "formContent.html": await fetch(TEST_DOCUMENT_URL).then(response =>
+ response.text()
+ ),
+ },
+ manifest: {
+ applications: {
+ gecko: {
+ id: "formpickers@mochi.test",
+ },
+ },
+ browser_action: {
+ default_popup: "formContent.html",
+ },
+ },
+ });
+
+ await extension.startup();
+
+ let { panel, browser } = await openExtensionPopup(
+ window,
+ "ext-formpickers\\@mochi.test"
+ );
+ await checkABrowser(browser);
+ panel.hidePopup();
+
+ await extension.unload();
+});
+
+add_task(async function testExtensionComposeAction() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "formContent.html": await fetch(TEST_DOCUMENT_URL).then(response =>
+ response.text()
+ ),
+ },
+ manifest: {
+ applications: {
+ gecko: {
+ id: "formpickers@mochi.test",
+ },
+ },
+ compose_action: {
+ default_popup: "formContent.html",
+ },
+ },
+ });
+
+ await extension.startup();
+
+ let params = Cc[
+ "@mozilla.org/messengercompose/composeparams;1"
+ ].createInstance(Ci.nsIMsgComposeParams);
+ params.composeFields = Cc[
+ "@mozilla.org/messengercompose/composefields;1"
+ ].createInstance(Ci.nsIMsgCompFields);
+
+ let composeWindowPromise = BrowserTestUtils.domWindowOpened();
+ MailServices.compose.OpenComposeWindowWithParams(null, params);
+ let composeWindow = await composeWindowPromise;
+ await BrowserTestUtils.waitForEvent(composeWindow, "load");
+
+ let { panel, browser } = await openExtensionPopup(
+ composeWindow,
+ "formpickers_mochi_test-composeAction-toolbarbutton"
+ );
+ await checkABrowser(browser);
+ panel.hidePopup();
+
+ await extension.unload();
+ await BrowserTestUtils.closeWindow(composeWindow);
+});
+
+add_task(async function testExtensionMessageDisplayAction() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "formContent.html": await fetch(TEST_DOCUMENT_URL).then(response =>
+ response.text()
+ ),
+ },
+ manifest: {
+ applications: {
+ gecko: {
+ id: "formpickers@mochi.test",
+ },
+ },
+ message_display_action: {
+ default_popup: "formContent.html",
+ },
+ },
+ });
+
+ await extension.startup();
+
+ let messageWindowPromise = BrowserTestUtils.domWindowOpened();
+ window.MsgOpenNewWindowForMessage([...testFolder.messages][0]);
+ let messageWindow = await messageWindowPromise;
+ let { target: aboutMessage } = await BrowserTestUtils.waitForEvent(
+ messageWindow,
+ "aboutMessageLoaded"
+ );
+
+ let { panel, browser } = await openExtensionPopup(
+ aboutMessage,
+ "formpickers_mochi_test-messageDisplayAction-toolbarbutton"
+ );
+ await checkABrowser(browser);
+ panel.hidePopup();
+
+ await extension.unload();
+ await BrowserTestUtils.closeWindow(messageWindow);
+});
+
+add_task(async function testBrowserRequestWindow() {
+ let requestWindow = await new Promise(resolve => {
+ Services.ww.openWindow(
+ null,
+ "chrome://messenger/content/browserRequest.xhtml",
+ null,
+ "chrome,private,centerscreen,width=980,height=750",
+ {
+ url: TEST_DOCUMENT_URL,
+ cancelled() {},
+ loaded(window, webProgress) {
+ resolve(window);
+ },
+ }
+ );
+ });
+
+ await checkABrowser(requestWindow.document.getElementById("requestFrame"));
+ await BrowserTestUtils.closeWindow(requestWindow);
+});
diff --git a/comm/mail/base/test/browser/browser_goMenu.js b/comm/mail/base/test/browser/browser_goMenu.js
new file mode 100644
index 0000000000..6a15a5e1d2
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_goMenu.js
@@ -0,0 +1,35 @@
+/* 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/. */
+
+/** @type MenuData */
+const goMenuData = {
+ goNextMenu: {},
+ menu_nextMsg: { disabled: true },
+ menu_nextUnreadMsg: { disabled: true },
+ menu_nextFlaggedMsg: { disabled: true },
+ menu_nextUnreadThread: { disabled: true },
+ "calendar-go-menu-next": { hidden: true },
+ goPreviousMenu: {},
+ menu_prevMsg: { disabled: true },
+ menu_prevUnreadMsg: { disabled: true },
+ menu_prevFlaggedMsg: { disabled: true },
+ "calendar-go-menu-previous": { hidden: true },
+ menu_goForward: { disabled: true },
+ menu_goBack: { disabled: true },
+ "calendar-go-to-today-menuitem": { hidden: true },
+ menu_goChat: {},
+ goFolderMenu: {},
+ goRecentlyClosedTabs: { disabled: true },
+ goStartPage: {},
+};
+let helper = new MenuTestHelper("menu_Go", goMenuData);
+
+add_setup(async function () {
+ document.getElementById("tabmail").clearRecentlyClosedTabs();
+ document.getElementById("toolbar-menubar").removeAttribute("autohide");
+});
+
+add_task(async function test3PaneTab() {
+ await helper.testAllItems("mail3PaneTab");
+});
diff --git a/comm/mail/base/test/browser/browser_interactionTelemetry.js b/comm/mail/base/test/browser/browser_interactionTelemetry.js
new file mode 100644
index 0000000000..a958a24bdf
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_interactionTelemetry.js
@@ -0,0 +1,67 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const AREAS = ["keyboard", "calendar", "chat", "message_display", "toolbox"];
+
+// Checks that the correct number of clicks are registered against the correct
+// keys in the scalars.
+function assertInteractionScalars(expectedAreas) {
+ let processScalars =
+ Services.telemetry.getSnapshotForKeyedScalars("main", true)?.parent ?? {};
+
+ for (let source of AREAS) {
+ let scalars = processScalars?.[`tb.ui.interaction.${source}`] ?? {};
+
+ let expected = expectedAreas[source] ?? {};
+
+ let expectedKeys = new Set(
+ Object.keys(scalars).concat(Object.keys(expected))
+ );
+ for (let key of expectedKeys) {
+ Assert.equal(
+ scalars[key],
+ expected[key],
+ `Expected to see the correct value for ${key} in ${source}.`
+ );
+ }
+ }
+}
+
+add_task(async function () {
+ Services.telemetry.clearScalars();
+
+ EventUtils.synthesizeMouseAtCenter(
+ document.getElementById("calendarButton"),
+ {},
+ window
+ );
+
+ let calendarWindowPromise = BrowserTestUtils.promiseAlertDialog(
+ "cancel",
+ "chrome://calendar/content/calendar-creation.xhtml"
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ document.querySelector("#newCalendarSidebarButton"),
+ {},
+ window
+ );
+ await calendarWindowPromise;
+
+ EventUtils.synthesizeMouseAtCenter(
+ document.querySelector("#tabmail-tabs tab:nth-child(2) .tab-close-button"),
+ {},
+ window
+ );
+
+ assertInteractionScalars({
+ calendar: {
+ newCalendarSidebarButton: 1,
+ },
+ toolbox: {
+ calendarButton: 1,
+ "tab-close-button": 1,
+ },
+ });
+});
diff --git a/comm/mail/base/test/browser/browser_linkHandler.js b/comm/mail/base/test/browser/browser_linkHandler.js
new file mode 100644
index 0000000000..38cb8e5b05
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_linkHandler.js
@@ -0,0 +1,294 @@
+/* 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/. */
+
+/* eslint-disable @microsoft/sdl/no-insecure-url */
+
+let { MockRegistrar } = ChromeUtils.importESModule(
+ "resource://testing-common/MockRegistrar.sys.mjs"
+);
+
+const TEST_DOMAIN = "http://example.org";
+const TEST_IP = "http://127.0.0.1:8888";
+const TEST_PATH = "/browser/comm/mail/base/test/browser/files/links.html";
+
+let links = new Map([
+ ["#this-hash", `${TEST_PATH}#hash`],
+ ["#this-nohash", `${TEST_PATH}`],
+ [
+ "#local-here",
+ "/browser/comm/mail/base/test/browser/files/sampleContent.html",
+ ],
+ [
+ "#local-elsewhere",
+ "/browser/comm/mail/components/extensions/test/browser/data/content.html",
+ ],
+ ["#other-https", `https://example.org${TEST_PATH}`],
+ ["#other-port", `http://example.org:8000${TEST_PATH}`],
+ ["#other-subdomain", `http://test1.example.org${TEST_PATH}`],
+ ["#other-subsubdomain", `http://sub1.test1.example.org${TEST_PATH}`],
+ ["#other-domain", `http://mochi.test:8888${TEST_PATH}`],
+]);
+
+/** @implements {nsIWebProgressListener} */
+let webProgressListener = {
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIWebProgressListener",
+ "nsISupportsWeakReference",
+ ]),
+
+ _browser: null,
+ _deferred: null,
+
+ onStateChange(webProgress, request, stateFlags, status) {
+ if (
+ !(stateFlags & Ci.nsIWebProgressListener.STATE_STOP) ||
+ this._browser?.currentURI.spec == "about:blank"
+ ) {
+ return;
+ }
+
+ if (this._deferred) {
+ let deferred = this._deferred;
+ let url = this._browser.currentURI.spec;
+ this.cancelPromise();
+
+ deferred.resolve(url);
+ } else {
+ this.cancelPromise();
+ Assert.ok(false, "unexpected state change");
+ }
+ },
+
+ onLocationChange(webProgress, request, location, flags) {
+ if (!(flags & Ci.nsIWebProgressListener.LOCATION_CHANGE_HASHCHANGE)) {
+ return;
+ }
+
+ if (this._deferred) {
+ let deferred = this._deferred;
+ let url = this._browser.currentURI.spec;
+ this.cancelPromise();
+
+ deferred.resolve(url);
+ } else {
+ this.cancelPromise();
+ Assert.ok(false, "unexpected location change");
+ }
+ },
+
+ promiseEvent(browser) {
+ this._browser = browser;
+ browser.webProgress.addProgressListener(
+ this,
+ Ci.nsIWebProgress.NOTIFY_STATE_ALL | Ci.nsIWebProgress.NOTIFY_LOCATION
+ );
+
+ this._deferred = PromiseUtils.defer();
+ return this._deferred.promise;
+ },
+
+ cancelPromise() {
+ this._deferred = null;
+ this._browser.removeProgressListener(this);
+ this._browser = null;
+ },
+};
+
+/** @implements {nsIExternalProtocolService} */
+let mockExternalProtocolService = {
+ QueryInterface: ChromeUtils.generateQI(["nsIExternalProtocolService"]),
+
+ _deferred: null,
+
+ loadURI(aURI, aWindowContext) {
+ if (this._deferred) {
+ let deferred = this._deferred;
+ this._deferred = null;
+
+ deferred.resolve(aURI.spec);
+ } else {
+ this.cancelPromise();
+ Assert.ok(false, "unexpected call to external protocol service");
+ }
+ },
+
+ promiseEvent() {
+ this._deferred = PromiseUtils.defer();
+ return this._deferred.promise;
+ },
+
+ cancelPromise() {
+ this._deferred = null;
+ },
+};
+
+let mockExternalProtocolServiceCID = MockRegistrar.register(
+ "@mozilla.org/uriloader/external-protocol-service;1",
+ mockExternalProtocolService
+);
+
+registerCleanupFunction(() => {
+ let tabmail = document.getElementById("tabmail");
+ Assert.equal(tabmail.tabInfo.length, 1);
+
+ while (tabmail.tabInfo.length > 1) {
+ tabmail.closeTab(tabmail.tabInfo[1]);
+ }
+
+ MockRegistrar.unregister(mockExternalProtocolServiceCID);
+});
+
+async function clickOnLink(
+ browser,
+ selector,
+ url,
+ pageURL,
+ shouldLoadInternally
+) {
+ if (
+ browser.webProgress?.isLoadingDocument ||
+ browser.currentURI?.spec == "about:blank"
+ ) {
+ await BrowserTestUtils.browserLoaded(browser);
+
+ // Clear the event queue.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => setTimeout(r, 250));
+ }
+ Assert.equal(
+ browser.currentURI?.spec,
+ pageURL,
+ "original URL should be loaded"
+ );
+
+ let webProgressPromise = webProgressListener.promiseEvent(browser);
+ let externalProtocolPromise = mockExternalProtocolService.promiseEvent();
+
+ info(`clicking on ${selector}`);
+ await BrowserTestUtils.synthesizeMouseAtCenter(selector, {}, browser);
+
+ await Promise.any([webProgressPromise, externalProtocolPromise]);
+
+ if (selector == "#this-hash") {
+ await SpecialPowers.spawn(browser, [], () => {
+ let doc = content.document;
+ let target = doc.querySelector("#hash");
+ let targetRect = target.getBoundingClientRect();
+ Assert.less(
+ targetRect.bottom,
+ doc.documentElement.clientHeight,
+ "page did scroll"
+ );
+ });
+ }
+
+ if (shouldLoadInternally) {
+ Assert.equal(
+ await webProgressPromise,
+ url,
+ `${url} should load internally`
+ );
+ mockExternalProtocolService.cancelPromise();
+ } else {
+ Assert.equal(
+ await externalProtocolPromise,
+ url,
+ `${url} should load externally`
+ );
+ webProgressListener.cancelPromise();
+ }
+
+ if (browser.currentURI?.spec != pageURL) {
+ let promise = webProgressListener.promiseEvent(browser);
+ browser.browsingContext.goBack();
+ await promise;
+ Assert.equal(browser.currentURI?.spec, pageURL, "should have gone back");
+ }
+}
+
+async function subtest(pagePrePath, group, shouldLoadCB) {
+ let tabmail = document.getElementById("tabmail");
+ let tab = window.openContentTab(
+ `${pagePrePath}${TEST_PATH}`,
+ undefined,
+ group
+ );
+
+ let expectedGroup = group;
+ if (group === null) {
+ expectedGroup = "browsers";
+ } else if (group === undefined) {
+ expectedGroup = "single-site";
+ }
+ Assert.equal(tab.browser.getAttribute("messagemanagergroup"), expectedGroup);
+
+ try {
+ for (let [selector, url] of links) {
+ if (url.startsWith("/")) {
+ url = `${pagePrePath}${url}`;
+ }
+ await clickOnLink(
+ tab.browser,
+ selector,
+ url,
+ `${pagePrePath}${TEST_PATH}`,
+ shouldLoadCB(selector)
+ );
+ }
+ } finally {
+ tabmail.closeTab(tab);
+ }
+}
+
+add_task(function testNoGroup() {
+ return subtest(
+ TEST_DOMAIN,
+ undefined,
+ selector => selector != "#other-domain"
+ );
+});
+
+add_task(function testBrowsersGroup() {
+ return subtest(TEST_DOMAIN, null, selector => true);
+});
+
+add_task(function testSingleSiteGroup() {
+ return subtest(
+ TEST_DOMAIN,
+ "single-site",
+ selector => selector != "#other-domain"
+ );
+});
+
+add_task(function testSinglePageGroup() {
+ return subtest(TEST_DOMAIN, "single-page", selector =>
+ selector.startsWith("#this")
+ );
+});
+
+add_task(function testNoGroupWithIP() {
+ return subtest(
+ TEST_IP,
+ undefined,
+ selector => selector.startsWith("#this") || selector.startsWith("#local")
+ );
+});
+
+add_task(function testBrowsersGroupWithIP() {
+ return subtest(TEST_IP, null, selector => true);
+});
+
+add_task(function testSingleSiteGroupWithIP() {
+ return subtest(
+ TEST_IP,
+ "single-site",
+ selector => selector.startsWith("#this") || selector.startsWith("#local")
+ );
+});
+
+add_task(function testSinglePageGroupWithIP() {
+ return subtest(TEST_IP, "single-page", selector =>
+ selector.startsWith("#this")
+ );
+});
diff --git a/comm/mail/base/test/browser/browser_mailContext.js b/comm/mail/base/test/browser/browser_mailContext.js
new file mode 100644
index 0000000000..3b75b2827e
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_mailContext.js
@@ -0,0 +1,950 @@
+/* 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/. */
+
+var { ConversationOpener } = ChromeUtils.import(
+ "resource:///modules/ConversationOpener.jsm"
+);
+var { Gloda } = ChromeUtils.import("resource:///modules/gloda/Gloda.jsm");
+var { GlodaSyntheticView } = ChromeUtils.import(
+ "resource:///modules/gloda/GlodaSyntheticView.jsm"
+);
+var { MailConsts } = ChromeUtils.import("resource:///modules/MailConsts.jsm");
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+var { MailUtils } = ChromeUtils.import("resource:///modules/MailUtils.jsm");
+var { MessageGenerator } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MessageGenerator.jsm"
+);
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+const TEST_MESSAGE_URL =
+ "http://mochi.test:8888/browser/comm/mail/base/test/browser/files/sampleContent.eml";
+
+let tabmail = document.getElementById("tabmail");
+let testFolder, testMessages;
+let draftsFolder, draftsMessages;
+let templatesFolder, templatesMessages;
+let listFolder, listMessages;
+
+let singleSelectionMessagePane = [
+ "singleMessage",
+ "draftsFolder",
+ "templatesFolder",
+ "listFolder",
+ "syntheticFolderDraft",
+ "syntheticFolder",
+];
+let singleSelectionThreadPane = [
+ "singleMessageTree",
+ "draftsFolderTree",
+ "templatesFolderTree",
+ "listFolderTree",
+ "syntheticFolderDraftTree",
+ "syntheticFolderTree",
+];
+let onePane = ["messageTab", "messageWindow"];
+let external = ["externalMessageTab", "externalMessageWindow"];
+let allSingleSelection = [
+ ...singleSelectionMessagePane,
+ ...singleSelectionThreadPane,
+ ...onePane,
+ ...external,
+];
+let allThreePane = [
+ ...singleSelectionMessagePane,
+ ...singleSelectionThreadPane,
+ "multipleMessagesTree",
+ "collapsedThreadTree",
+ "multipleDraftsFolderTree",
+ "multipleTemplatesFolderTree",
+];
+const noCollapsedThreads = [
+ ...singleSelectionMessagePane,
+ ...singleSelectionThreadPane,
+ "multipleMessagesTree",
+ "multipleDraftsFolderTree",
+ "multipleTemplatesFolderTree",
+ ...onePane,
+ ...external,
+];
+let notExternal = [...allThreePane, ...onePane];
+let singleNotExternal = [
+ ...singleSelectionMessagePane,
+ ...singleSelectionThreadPane,
+ ...onePane,
+];
+
+const mailContextData = {
+ "mailContext-selectall": [
+ ...singleSelectionMessagePane,
+ ...onePane,
+ ...external,
+ ],
+ "mailContext-editDraftMsg": [
+ "draftsFolder",
+ "draftsFolderTree",
+ "multipleDraftsFolderTree",
+ "syntheticFolderDraft",
+ "syntheticFolderDraftTree",
+ ],
+ "mailContext-newMsgFromTemplate": [
+ "templatesFolder",
+ "templatesFolderTree",
+ "multipleTemplatesFolderTree",
+ ],
+ "mailContext-editTemplateMsg": [
+ "templatesFolder",
+ "templatesFolderTree",
+ "multipleTemplatesFolderTree",
+ ],
+ "mailContext-openNewTab": singleSelectionThreadPane,
+ "mailContext-openNewWindow": singleSelectionThreadPane,
+ "mailContext-openConversation": [
+ ...singleSelectionMessagePane,
+ ...singleSelectionThreadPane,
+ ...onePane,
+ "collapsedThreadTree",
+ ],
+ "mailContext-openContainingFolder": [
+ "syntheticFolderDraft",
+ "syntheticFolderDraftTree",
+ "syntheticFolder",
+ "syntheticFolderTree",
+ ...onePane,
+ ],
+ "mailContext-replySender": noCollapsedThreads,
+ "mailContext-replyAll": noCollapsedThreads,
+ "mailContext-replyList": ["listFolder", "listFolderTree"],
+ "mailContext-forward": allSingleSelection,
+ "mailContext-forwardAsMenu": allSingleSelection,
+ "mailContext-multiForwardAsAttachment": [
+ "multipleMessagesTree",
+ "multipleDraftsFolderTree",
+ "multipleTemplatesFolderTree",
+ ],
+ "mailContext-redirect": noCollapsedThreads,
+ "mailContext-editAsNew": noCollapsedThreads,
+ "mailContext-tags": notExternal,
+ "mailContext-mark": notExternal,
+ "mailContext-archive": notExternal,
+ "mailContext-moveMenu": notExternal,
+ "mailContext-copyMenu": true,
+ "mailContext-decryptToFolder": [
+ "multipleMessagesTree",
+ "collapsedThreadTree",
+ "multipleDraftsFolderTree",
+ "multipleTemplatesFolderTree",
+ ],
+ "mailContext-calendar-convert-menu": singleNotExternal,
+ "mailContext-delete": notExternal,
+ "mailContext-ignoreThread": allThreePane,
+ "mailContext-ignoreSubthread": allThreePane,
+ "mailContext-watchThread": notExternal,
+ "mailContext-saveAs": true,
+ "mailContext-print": true,
+ "mailContext-downloadSelected": [
+ "multipleMessagesTree",
+ "collapsedThreadTree",
+ "multipleDraftsFolderTree",
+ "multipleTemplatesFolderTree",
+ ],
+};
+
+function checkMenuitems(menu, mode) {
+ if (!mode) {
+ // Menu should not be shown.
+ Assert.equal(menu.state, "closed");
+ return;
+ }
+
+ info(`Checking menus for ${mode} ...`);
+
+ Assert.notEqual(menu.state, "closed", "Menu should be closed");
+
+ let expectedItems = [];
+ for (let [id, modes] of Object.entries(mailContextData)) {
+ if (modes === true || modes.includes(mode)) {
+ expectedItems.push(id);
+ }
+ }
+
+ let actualItems = [];
+ for (let item of menu.children) {
+ if (["menu", "menuitem"].includes(item.localName) && !item.hidden) {
+ actualItems.push(item.id);
+ }
+ }
+
+ let notFoundItems = expectedItems.filter(i => !actualItems.includes(i));
+ if (notFoundItems.length) {
+ Assert.report(
+ true,
+ undefined,
+ undefined,
+ "items expected but not found: " + notFoundItems.join(", ")
+ );
+ }
+
+ let unexpectedItems = actualItems.filter(i => !expectedItems.includes(i));
+ if (unexpectedItems.length) {
+ Assert.report(
+ true,
+ undefined,
+ undefined,
+ "items found but not expected: " + unexpectedItems.join(", ")
+ );
+ }
+
+ Assert.deepEqual(actualItems, expectedItems);
+
+ menu.hidePopup();
+}
+
+add_setup(async function () {
+ let generator = new MessageGenerator();
+
+ MailServices.accounts.createLocalMailAccount();
+ let account = MailServices.accounts.accounts[0];
+ account.addIdentity(MailServices.accounts.createIdentity());
+ let rootFolder = account.incomingServer.rootFolder;
+ rootFolder.createSubfolder("mailContextFolder", null);
+ testFolder = rootFolder
+ .getChildNamed("mailContextFolder")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+ let message = await fetch(TEST_MESSAGE_URL).then(r => r.text());
+ testFolder.addMessageBatch([message]);
+ let messages = [
+ ...generator.makeMessages({ count: 5 }),
+ ...generator.makeMessages({ count: 5, msgsPerThread: 5 }),
+ ...generator.makeMessages({ count: 200 }),
+ ];
+ let messageStrings = messages.map(message => message.toMboxString());
+ testFolder.addMessageBatch(messageStrings);
+ testMessages = [...testFolder.messages];
+ rootFolder.createSubfolder("mailContextDrafts", null);
+ draftsFolder = rootFolder
+ .getChildNamed("mailContextDrafts")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+ draftsFolder.setFlag(Ci.nsMsgFolderFlags.Drafts);
+ draftsFolder.addMessageBatch(
+ generator.makeMessages({ count: 5 }).map(message => message.toMboxString())
+ );
+ draftsMessages = [...draftsFolder.messages];
+ rootFolder.createSubfolder("mailContextTemplates", null);
+ templatesFolder = rootFolder
+ .getChildNamed("mailContextTemplates")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+ templatesFolder.setFlag(Ci.nsMsgFolderFlags.Templates);
+ templatesFolder.addMessageBatch(
+ generator.makeMessages({ count: 5 }).map(message => message.toMboxString())
+ );
+ templatesMessages = [...templatesFolder.messages];
+ rootFolder.createSubfolder("mailContextMailingList", null);
+ listFolder = rootFolder
+ .getChildNamed("mailContextMailingList")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+ listFolder.addMessage(
+ "From - Mon Jan 01 00:00:00 2001\n" +
+ "To: Mailing List <list@example.com>\n" +
+ "Date: Mon, 01 Jan 2001 00:00:00 +0100\n" +
+ "List-Help: <https://list.example.com>\n" +
+ "List-Post: <mailto:list@example.com>\n" +
+ "List-Software: Mailing List Software\n" +
+ "List-Subscribe: <https://subscribe.example.com>\n" +
+ "Precedence: list\n" +
+ "Subject: Mailing List Test Mail\n" +
+ `Message-ID: <${Date.now()}@example.com>\n` +
+ "From: Mailing List <list@example.com>\n" +
+ "List-Unsubscribe: <https://unsubscribe.example.com>,\n" +
+ " <mailto:unsubscribe@example.com?subject=Unsubscribe Test>\n" +
+ "MIME-Version: 1.0\n" +
+ "Content-Type: text/plain; charset=UTF-8\n" +
+ "Content-Transfer-Encoding: quoted-printable\n" +
+ "\n" +
+ "Mailing List Message Body\n"
+ );
+ listMessages = [...listFolder.messages];
+
+ tabmail.currentAbout3Pane.restoreState({
+ folderURI: testFolder.URI,
+ messagePaneVisible: true,
+ });
+
+ // Enable home calendar.
+ cal.manager.getCalendars()[0].setProperty("disabled", false);
+
+ registerCleanupFunction(() => {
+ MailServices.accounts.removeAccount(account, false);
+ Services.prefs.clearUserPref("mail.openMessageBehavior");
+ cal.manager.getCalendars()[0].setProperty("disabled", true);
+ });
+});
+
+/**
+ * Tests the mailContext menu on the thread tree and message pane when no
+ * messages are selected.
+ */
+add_task(async function testNoMessages() {
+ let about3Pane = tabmail.currentAbout3Pane;
+ let mailContext = about3Pane.document.getElementById("mailContext");
+ let { messageBrowser, messagePane, threadTree } = about3Pane;
+ messagePane.clearAll();
+
+ // The message pane browser isn't visible.
+
+ Assert.ok(
+ BrowserTestUtils.is_hidden(messageBrowser),
+ "message browser should be hidden"
+ );
+ Assert.equal(messageBrowser.currentURI.spec, "about:message");
+ Assert.equal(
+ messageBrowser.contentWindow.getMessagePaneBrowser().currentURI.spec,
+ "about:blank"
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ about3Pane.document.getElementById("messagePane"),
+ { type: "contextmenu" }
+ );
+ checkMenuitems(mailContext);
+
+ // Open the menu from an empty part of the thread pane.
+
+ let treeRect = threadTree.getBoundingClientRect();
+ EventUtils.synthesizeMouse(
+ threadTree,
+ treeRect.x + treeRect.width / 2,
+ treeRect.bottom - 10,
+ { type: "contextmenu" },
+ about3Pane
+ );
+ checkMenuitems(mailContext);
+});
+
+/**
+ * Tests the mailContext menu on the thread tree and message pane when one
+ * message is selected.
+ */
+add_task(async function testSingleMessage() {
+ await TestUtils.waitForCondition(
+ () => ConversationOpener.isMessageIndexed(testMessages[0]),
+ "waiting for Gloda to finish indexing",
+ 500
+ );
+
+ let about3Pane = tabmail.currentAbout3Pane;
+ let mailContext = about3Pane.document.getElementById("mailContext");
+ let { gDBView, messageBrowser, threadTree } = about3Pane;
+ let messagePaneBrowser = messageBrowser.contentWindow.getMessagePaneBrowser();
+
+ let loadedPromise = BrowserTestUtils.browserLoaded(
+ messagePaneBrowser,
+ undefined,
+ url => url.endsWith(gDBView.getKeyAt(0))
+ );
+ threadTree.selectedIndex = 0;
+ threadTree.scrollToIndex(0, true);
+ await loadedPromise;
+
+ // Open the menu from the message pane.
+
+ Assert.ok(
+ BrowserTestUtils.is_visible(messageBrowser),
+ "message browser should be visible"
+ );
+
+ let shownPromise = BrowserTestUtils.waitForEvent(mailContext, "popupshown");
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ ":root",
+ { type: "contextmenu" },
+ messagePaneBrowser
+ );
+ await shownPromise;
+ checkMenuitems(mailContext, "singleMessage");
+
+ // Open the menu from the thread pane.
+
+ shownPromise = BrowserTestUtils.waitForEvent(mailContext, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ threadTree.getRowAtIndex(0),
+ { type: "contextmenu" },
+ about3Pane
+ );
+ await shownPromise;
+ checkMenuitems(mailContext, "singleMessageTree");
+
+ // Open the menu through the keyboard.
+
+ shownPromise = BrowserTestUtils.waitForEvent(mailContext, "popupshown");
+ const row = threadTree.getRowAtIndex(0);
+ row.focus();
+ EventUtils.synthesizeMouseAtCenter(
+ row,
+ { type: "contextmenu", button: 0 },
+ about3Pane
+ );
+ await shownPromise;
+ Assert.ok(
+ BrowserTestUtils.is_visible(mailContext),
+ "Context menu is shown through keyboard action"
+ );
+ mailContext.hidePopup();
+
+ // Open the menu through the keyboard on a message that is scrolled slightly
+ // out of view.
+
+ threadTree.selectedIndex = 5;
+ threadTree.scrollToIndex(threadTree.getLastVisibleIndex() + 7, true);
+ await new Promise(resolve => window.requestAnimationFrame(resolve));
+ Assert.equal(threadTree.currentIndex, 5, "Row 5 is the current row");
+ Assert.ok(row.parentNode, "Row element should still be attached");
+ Assert.greater(
+ threadTree.getFirstVisibleIndex(),
+ 5,
+ "Selected row should no longer be visible"
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ threadTree,
+ { type: "contextmenu", button: 0 },
+ about3Pane
+ );
+ await new Promise(resolve => window.requestAnimationFrame(resolve));
+ await BrowserTestUtils.waitForPopupEvent(mailContext, "shown");
+ Assert.greaterOrEqual(
+ 5,
+ threadTree.getFirstVisibleIndex(),
+ "Current row is greater than or equal to first visible index"
+ );
+ Assert.lessOrEqual(
+ 5,
+ threadTree.getLastVisibleIndex(),
+ "Current row is less than or equal to last visible index"
+ );
+ mailContext.hidePopup();
+
+ // Open the menu on a message that is scrolled out of view.
+
+ shownPromise = BrowserTestUtils.waitForEvent(mailContext, "popupshown");
+ threadTree.scrollToIndex(200, true);
+ await new Promise(resolve => window.requestAnimationFrame(resolve));
+ Assert.ok(!row.parentNode, "Row element should no longer be attached");
+ Assert.equal(threadTree.currentIndex, 5, "Row 5 is the current row");
+ Assert.ok(
+ !threadTree.getRowAtIndex(threadTree.currentIndex),
+ "Current row is scrolled out of view"
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ threadTree,
+ { type: "contextmenu", button: 0 },
+ about3Pane
+ );
+ await shownPromise;
+ Assert.ok(
+ threadTree.getRowAtIndex(threadTree.currentIndex),
+ "Current row is scrolled into view when showing context menu"
+ );
+ Assert.greaterOrEqual(
+ 5,
+ threadTree.getFirstVisibleIndex(),
+ "Current row is greater than or equal to first visible index"
+ );
+ Assert.lessOrEqual(
+ 5,
+ threadTree.getLastVisibleIndex(),
+ "Current row is less than or equal to last visible index"
+ );
+ mailContext.hidePopup();
+
+ Assert.ok(BrowserTestUtils.is_hidden(mailContext), "Context menu is hidden");
+});
+
+/**
+ * Tests the mailContext menu on the thread tree when more than one message is
+ * selected.
+ */
+add_task(async function testMultipleMessages() {
+ await TestUtils.waitForCondition(
+ () => ConversationOpener.isMessageIndexed(testMessages[6]),
+ "waiting for Gloda to finish indexing",
+ 500
+ );
+
+ let about3Pane = tabmail.currentAbout3Pane;
+ let mailContext = about3Pane.document.getElementById("mailContext");
+ let { messageBrowser, multiMessageBrowser, threadTree } = about3Pane;
+ threadTree.scrollToIndex(1, true);
+ threadTree.selectedIndices = [1, 2, 3];
+ await TestUtils.waitForTick(); // Wait for rows to be added.
+
+ // The message pane browser isn't visible.
+
+ Assert.ok(
+ BrowserTestUtils.is_hidden(messageBrowser),
+ "message browser should be hidden"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_visible(multiMessageBrowser),
+ "multimessage browser should be visible"
+ );
+
+ // Open the menu from the thread pane.
+ let shownPromise = BrowserTestUtils.waitForEvent(mailContext, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ threadTree.getRowAtIndex(2),
+ { type: "contextmenu" },
+ about3Pane
+ );
+ await shownPromise;
+ checkMenuitems(mailContext, "multipleMessagesTree");
+
+ // Select a collapsed thread and open the menu.
+
+ threadTree.scrollToIndex(6, true);
+ threadTree.selectedIndices = [6];
+
+ shownPromise = BrowserTestUtils.waitForEvent(mailContext, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ threadTree.getRowAtIndex(6),
+ { type: "contextmenu" },
+ about3Pane
+ );
+ await shownPromise;
+ checkMenuitems(mailContext, "collapsedThreadTree");
+
+ // Open the menu in the thread pane on a message scrolled out of view.
+
+ threadTree.selectAll();
+ threadTree.currentIndex = 200;
+ await TestUtils.waitForTick();
+ await new Promise(resolve => window.requestAnimationFrame(resolve));
+ threadTree.scrollToIndex(0, true);
+ await new Promise(resolve => window.requestAnimationFrame(resolve));
+ Assert.ok(
+ !threadTree.getRowAtIndex(threadTree.currentIndex),
+ "Current row is scrolled out of view"
+ );
+
+ shownPromise = BrowserTestUtils.waitForEvent(mailContext, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ threadTree,
+ { type: "contextmenu", button: 0 },
+ about3Pane
+ );
+ await shownPromise;
+ Assert.ok(
+ threadTree.getRowAtIndex(threadTree.currentIndex),
+ "Current row is scrolled into view when popup is shown"
+ );
+ mailContext.hidePopup();
+});
+
+/**
+ * Tests the mailContext menu on the thread tree and message pane of a Drafts
+ * folder.
+ */
+add_task(async function testDraftsFolder() {
+ let about3Pane = tabmail.currentAbout3Pane;
+ about3Pane.restoreState({ folderURI: draftsFolder.URI });
+
+ await TestUtils.waitForCondition(
+ () => ConversationOpener.isMessageIndexed(draftsMessages[1]),
+ "waiting for Gloda to finish indexing",
+ 500
+ );
+
+ let mailContext = about3Pane.document.getElementById("mailContext");
+ let { gDBView, messageBrowser, threadTree } = about3Pane;
+ let messagePaneBrowser = messageBrowser.contentWindow.getMessagePaneBrowser();
+
+ let loadedPromise = BrowserTestUtils.browserLoaded(
+ messagePaneBrowser,
+ undefined,
+ url => url.endsWith(gDBView.getKeyAt(0))
+ );
+ threadTree.selectedIndex = 0;
+ await loadedPromise;
+
+ // Open the menu from the message pane.
+
+ Assert.ok(
+ BrowserTestUtils.is_visible(messageBrowser),
+ "message browser should be visible"
+ );
+ let shownPromise = BrowserTestUtils.waitForEvent(mailContext, "popupshown");
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ ":root",
+ { type: "contextmenu" },
+ messagePaneBrowser
+ );
+ await shownPromise;
+ checkMenuitems(mailContext, "draftsFolder");
+
+ // Open the menu from the thread pane.
+
+ shownPromise = BrowserTestUtils.waitForEvent(mailContext, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ threadTree.getRowAtIndex(0),
+ { type: "contextmenu" },
+ about3Pane
+ );
+ await shownPromise;
+ checkMenuitems(mailContext, "draftsFolderTree");
+
+ threadTree.scrollToIndex(1, true);
+ threadTree.selectedIndices = [1, 2, 3];
+
+ shownPromise = BrowserTestUtils.waitForEvent(mailContext, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ threadTree.getRowAtIndex(2),
+ { type: "contextmenu" },
+ about3Pane
+ );
+ await shownPromise;
+ checkMenuitems(mailContext, "multipleDraftsFolderTree");
+});
+
+/**
+ * Tests the mailContext menu on the thread tree and message pane of a Templates
+ * folder.
+ */
+add_task(async function testTemplatesFolder() {
+ let about3Pane = tabmail.currentAbout3Pane;
+ about3Pane.restoreState({ folderURI: templatesFolder.URI });
+
+ await TestUtils.waitForCondition(
+ () => ConversationOpener.isMessageIndexed(templatesMessages[1]),
+ "waiting for Gloda to finish indexing",
+ 500
+ );
+
+ let mailContext = about3Pane.document.getElementById("mailContext");
+ let { gDBView, messageBrowser, threadTree } = about3Pane;
+ let messagePaneBrowser = messageBrowser.contentWindow.getMessagePaneBrowser();
+
+ let loadedPromise = BrowserTestUtils.browserLoaded(
+ messagePaneBrowser,
+ undefined,
+ url => url.endsWith(gDBView.getKeyAt(0))
+ );
+ threadTree.selectedIndex = 0;
+ await loadedPromise;
+
+ // Open the menu from the message pane.
+
+ Assert.ok(
+ BrowserTestUtils.is_visible(messageBrowser),
+ "message browser should be visible"
+ );
+ let shownPromise = BrowserTestUtils.waitForEvent(mailContext, "popupshown");
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ ":root",
+ { type: "contextmenu" },
+ messagePaneBrowser
+ );
+ await shownPromise;
+ checkMenuitems(mailContext, "templatesFolder");
+
+ // Open the menu from the thread pane.
+
+ shownPromise = BrowserTestUtils.waitForEvent(mailContext, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ threadTree.getRowAtIndex(0),
+ { type: "contextmenu" },
+ about3Pane
+ );
+ await shownPromise;
+ checkMenuitems(mailContext, "templatesFolderTree");
+
+ threadTree.scrollToIndex(1, true);
+ threadTree.selectedIndices = [1, 2, 3];
+
+ shownPromise = BrowserTestUtils.waitForEvent(mailContext, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ threadTree.getRowAtIndex(2),
+ { type: "contextmenu" },
+ about3Pane
+ );
+ await shownPromise;
+ checkMenuitems(mailContext, "multipleTemplatesFolderTree");
+});
+
+/**
+ * Tests the mailContext menu on the thread tree and message pane of a
+ * mailing list message.
+ */
+
+add_task(async function testListMessage() {
+ let about3Pane = tabmail.currentAbout3Pane;
+ about3Pane.restoreState({ folderURI: listFolder.URI });
+
+ await TestUtils.waitForCondition(
+ () => ConversationOpener.isMessageIndexed(listMessages[0]),
+ "waiting for Gloda to finish indexing",
+ 500
+ );
+
+ let mailContext = about3Pane.document.getElementById("mailContext");
+ let { gDBView, messageBrowser, threadTree } = about3Pane;
+ let messagePaneBrowser = messageBrowser.contentWindow.getMessagePaneBrowser();
+
+ let loadedPromise = BrowserTestUtils.browserLoaded(
+ messagePaneBrowser,
+ undefined,
+ url => url.endsWith(gDBView.getKeyAt(0))
+ );
+ threadTree.selectedIndex = 0;
+ await loadedPromise;
+
+ // Open the menu from the message pane.
+
+ Assert.ok(
+ BrowserTestUtils.is_visible(messageBrowser),
+ "message browser should be visible"
+ );
+ let shownPromise = BrowserTestUtils.waitForEvent(mailContext, "popupshown");
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ ":root",
+ { type: "contextmenu" },
+ messagePaneBrowser
+ );
+ await shownPromise;
+ checkMenuitems(mailContext, "listFolder");
+
+ // Open the menu from the thread pane.
+
+ shownPromise = BrowserTestUtils.waitForEvent(mailContext, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ threadTree.getRowAtIndex(0),
+ { type: "contextmenu" },
+ about3Pane
+ );
+ await shownPromise;
+ checkMenuitems(mailContext, "listFolderTree");
+});
+
+/**
+ * Tests the mailContext menu on the thread tree and message pane of a Gloda
+ * synthetic view (in this case a conversation, but a list of search results
+ * should be the same).
+ */
+add_task(async function testSyntheticFolder() {
+ await TestUtils.waitForCondition(
+ () => ConversationOpener.isMessageIndexed(testMessages[9]),
+ "waiting for Gloda to finish indexing",
+ 500
+ );
+ await TestUtils.waitForCondition(
+ () => ConversationOpener.isMessageIndexed(draftsMessages[4]),
+ "waiting for Gloda to finish indexing",
+ 500
+ );
+
+ let tabPromise = BrowserTestUtils.waitForEvent(window, "aboutMessageLoaded");
+ tabmail.openTab("mail3PaneTab", {
+ syntheticView: new GlodaSyntheticView({
+ collection: Gloda.getMessageCollectionForHeaders([
+ ...draftsMessages,
+ ...testMessages.slice(6),
+ ]),
+ }),
+ title: "Test gloda results",
+ });
+ await tabPromise;
+ await new Promise(resolve => setTimeout(resolve));
+
+ let about3Pane = tabmail.currentAbout3Pane;
+ let mailContext = about3Pane.document.getElementById("mailContext");
+ let { gDBView, messageBrowser, threadTree } = about3Pane;
+ let messagePaneBrowser = messageBrowser.contentWindow.getMessagePaneBrowser();
+
+ let loadedPromise = BrowserTestUtils.browserLoaded(
+ messagePaneBrowser,
+ undefined,
+ url => url.endsWith(gDBView.getKeyAt(0))
+ );
+ threadTree.selectedIndex = 0;
+ await loadedPromise;
+
+ // Open the menu from the message pane.
+
+ Assert.ok(
+ BrowserTestUtils.is_visible(messageBrowser),
+ "message browser should be visible"
+ );
+ let shownPromise = BrowserTestUtils.waitForEvent(mailContext, "popupshown");
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ ":root",
+ { type: "contextmenu" },
+ messagePaneBrowser
+ );
+ await shownPromise;
+ checkMenuitems(mailContext, "syntheticFolderDraft");
+
+ // Open the menu from the thread pane.
+
+ shownPromise = BrowserTestUtils.waitForEvent(mailContext, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ threadTree.getRowAtIndex(0),
+ { type: "contextmenu" },
+ about3Pane
+ );
+ await shownPromise;
+ checkMenuitems(mailContext, "syntheticFolderDraftTree");
+
+ loadedPromise = BrowserTestUtils.browserLoaded(
+ messagePaneBrowser,
+ undefined,
+ url => url.endsWith(gDBView.getKeyAt(5))
+ );
+ threadTree.selectedIndex = 5;
+ await loadedPromise;
+
+ // Open the menu from the message pane.
+
+ Assert.ok(
+ BrowserTestUtils.is_visible(messageBrowser),
+ "message browser should be visible"
+ );
+ shownPromise = BrowserTestUtils.waitForEvent(mailContext, "popupshown");
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ ":root",
+ { type: "contextmenu" },
+ messagePaneBrowser
+ );
+ await shownPromise;
+ checkMenuitems(mailContext, "syntheticFolder");
+
+ // Open the menu from the thread pane.
+
+ shownPromise = BrowserTestUtils.waitForEvent(mailContext, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ threadTree.getRowAtIndex(5),
+ { type: "contextmenu" },
+ about3Pane
+ );
+ await shownPromise;
+ checkMenuitems(mailContext, "syntheticFolderTree");
+
+ tabmail.closeOtherTabs(0);
+});
+
+/**
+ * Tests the mailContext menu on the message pane of a message in a tab.
+ */
+add_task(async function testMessageTab() {
+ let tabPromise = BrowserTestUtils.waitForEvent(window, "MsgLoaded");
+ window.OpenMessageInNewTab(testMessages[0], { background: false });
+ await tabPromise;
+ await new Promise(resolve => setTimeout(resolve));
+
+ let aboutMessage = tabmail.currentAboutMessage;
+ let mailContext = aboutMessage.document.getElementById("mailContext");
+
+ let shownPromise = BrowserTestUtils.waitForEvent(mailContext, "popupshown");
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ ":root",
+ { type: "contextmenu" },
+ aboutMessage.getMessagePaneBrowser()
+ );
+ await shownPromise;
+ checkMenuitems(mailContext, "messageTab");
+
+ tabmail.closeOtherTabs(0);
+});
+
+/**
+ * Tests the mailContext menu on the message pane of a file message in a tab.
+ */
+add_task(async function testExternalMessageTab() {
+ let tabPromise = BrowserTestUtils.waitForEvent(window, "MsgLoaded");
+ let messageFile = new FileUtils.File(
+ getTestFilePath("files/sampleContent.eml")
+ );
+ Services.prefs.setIntPref(
+ "mail.openMessageBehavior",
+ MailConsts.OpenMessageBehavior.NEW_TAB
+ );
+ MailUtils.openEMLFile(
+ window,
+ messageFile,
+ Services.io.newFileURI(messageFile)
+ );
+ await tabPromise;
+ await new Promise(resolve => setTimeout(resolve));
+
+ let aboutMessage = tabmail.currentAboutMessage;
+ let mailContext = aboutMessage.document.getElementById("mailContext");
+
+ let shownPromise = BrowserTestUtils.waitForEvent(mailContext, "popupshown");
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ ":root",
+ { type: "contextmenu" },
+ aboutMessage.getMessagePaneBrowser()
+ );
+ await shownPromise;
+ checkMenuitems(mailContext, "externalMessageTab");
+
+ tabmail.closeOtherTabs(0);
+});
+
+/**
+ * Tests the mailContext menu on the message pane of a message in a window.
+ */
+add_task(async function testMessageWindow() {
+ let winPromise = BrowserTestUtils.domWindowOpenedAndLoaded();
+ window.MsgOpenNewWindowForMessage(testMessages[0]);
+ let win = await winPromise;
+ await BrowserTestUtils.waitForEvent(win, "MsgLoaded");
+ await TestUtils.waitForCondition(() => Services.focus.activeWindow == win);
+
+ let aboutMessage = win.messageBrowser.contentWindow;
+ let mailContext = aboutMessage.document.getElementById("mailContext");
+
+ let shownPromise = BrowserTestUtils.waitForEvent(mailContext, "popupshown");
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ ":root",
+ { type: "contextmenu" },
+ aboutMessage.getMessagePaneBrowser()
+ );
+ await shownPromise;
+ checkMenuitems(mailContext, "messageWindow");
+
+ await BrowserTestUtils.closeWindow(win);
+});
+
+/**
+ * Tests the mailContext menu on the message pane of a file message in a window.
+ */
+add_task(async function testExternalMessageWindow() {
+ let winPromise = BrowserTestUtils.domWindowOpenedAndLoaded();
+ let messageFile = new FileUtils.File(
+ getTestFilePath("files/sampleContent.eml")
+ );
+ Services.prefs.setIntPref(
+ "mail.openMessageBehavior",
+ MailConsts.OpenMessageBehavior.NEW_WINDOW
+ );
+ MailUtils.openEMLFile(
+ window,
+ messageFile,
+ Services.io.newFileURI(messageFile)
+ );
+ let win = await winPromise;
+ await BrowserTestUtils.waitForEvent(win, "MsgLoaded");
+ await TestUtils.waitForCondition(() => Services.focus.activeWindow == win);
+
+ let aboutMessage = win.messageBrowser.contentWindow;
+ let mailContext = aboutMessage.document.getElementById("mailContext");
+
+ let shownPromise = BrowserTestUtils.waitForEvent(mailContext, "popupshown");
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ ":root",
+ { type: "contextmenu" },
+ aboutMessage.getMessagePaneBrowser()
+ );
+ await shownPromise;
+ checkMenuitems(mailContext, "externalMessageWindow");
+
+ await BrowserTestUtils.closeWindow(win);
+});
diff --git a/comm/mail/base/test/browser/browser_mailTabsAndWindows.js b/comm/mail/base/test/browser/browser_mailTabsAndWindows.js
new file mode 100644
index 0000000000..3da815edd9
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_mailTabsAndWindows.js
@@ -0,0 +1,355 @@
+/* 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/. */
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+var { MailUtils } = ChromeUtils.import("resource:///modules/MailUtils.jsm");
+var { MessageGenerator } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MessageGenerator.jsm"
+);
+
+let folderA, messagesA, folderB, messagesB;
+
+add_setup(async function () {
+ let tabmail = document.getElementById("tabmail");
+ if (tabmail.tabInfo.length > 1) {
+ info(`Will close ${tabmail.tabInfo.length - 1} tabs left over from others`);
+ for (let i = tabmail.tabInfo.length - 1; i > 0; i--) {
+ tabmail.closeTab(i);
+ }
+ }
+ Assert.equal(tabmail.tabInfo.length, 1, "should be set up with one tab");
+
+ let generator = new MessageGenerator();
+
+ MailServices.accounts.createLocalMailAccount();
+ let account = MailServices.accounts.accounts[0];
+ let rootFolder = account.incomingServer.rootFolder;
+
+ rootFolder.createSubfolder("mailTabsA", null);
+ folderA = rootFolder
+ .getChildNamed("mailTabsA")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+ folderA.addMessageBatch(
+ generator.makeMessages({ count: 5 }).map(message => message.toMboxString())
+ );
+ messagesA = [...folderA.messages];
+
+ rootFolder.createSubfolder("mailTabsB", null);
+ folderB = rootFolder
+ .getChildNamed("mailTabsB")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+ folderB.addMessageBatch(
+ generator.makeMessages({ count: 2 }).map(message => message.toMboxString())
+ );
+ messagesB = [...folderB.messages];
+
+ registerCleanupFunction(() => {
+ MailServices.accounts.removeAccount(account, false);
+ });
+});
+
+add_task(async function testTabs() {
+ let tabmail = document.getElementById("tabmail");
+ Assert.equal(tabmail.tabInfo.length, 1, "should start off with one tab open");
+ Assert.equal(tabmail.currentTabInfo, tabmail.tabInfo[0], "should show tab0");
+
+ // Check the first tab.
+
+ let firstTab = tabmail.currentTabInfo;
+ Assert.equal(firstTab.mode.name, "mail3PaneTab");
+ Assert.equal(firstTab.mode.tabType.name, "mailTab");
+
+ let firstChromeBrowser = firstTab.chromeBrowser;
+ Assert.equal(firstChromeBrowser.currentURI.spec, "about:3pane");
+ Assert.equal(tabmail.currentAbout3Pane, firstChromeBrowser.contentWindow);
+
+ let firstMessageBrowser =
+ firstChromeBrowser.contentDocument.getElementById("messageBrowser");
+ Assert.equal(firstMessageBrowser.currentURI.spec, "about:message");
+
+ let firstMessagePane =
+ firstMessageBrowser.contentDocument.getElementById("messagepane");
+ Assert.equal(firstMessagePane.currentURI.spec, "about:blank");
+ Assert.equal(
+ tabmail.currentAboutMessage,
+ null,
+ "currentAboutMessage should be null with no message selected"
+ );
+ Assert.equal(firstTab.browser, null);
+ Assert.equal(firstTab.linkedBrowser, null);
+
+ let { folderTree, threadTree, messagePane, paneLayout } =
+ firstChromeBrowser.contentWindow;
+
+ firstTab.folder = folderA;
+ Assert.equal(firstTab.folder, folderA);
+ Assert.equal(
+ folderTree.querySelector(".selected .name").textContent,
+ "mailTabsA"
+ );
+ Assert.equal(threadTree.view.rowCount, 5);
+ Assert.equal(threadTree.selectedIndex, -1);
+
+ Assert.equal(firstTab.message, null);
+ threadTree.selectedIndex = 0;
+ Assert.equal(
+ tabmail.currentAboutMessage,
+ firstMessageBrowser.contentWindow,
+ "currentAboutMessage should have a value with a message selected"
+ );
+ Assert.equal(firstTab.message, messagesA[0]);
+ Assert.equal(firstTab.browser, firstMessagePane);
+ Assert.equal(firstTab.linkedBrowser, firstMessagePane);
+
+ Assert.ok(BrowserTestUtils.is_visible(folderTree));
+ Assert.ok(BrowserTestUtils.is_visible(firstMessageBrowser));
+
+ paneLayout.folderPaneVisible = false;
+ Assert.ok(BrowserTestUtils.is_hidden(folderTree));
+ Assert.ok(BrowserTestUtils.is_visible(firstMessageBrowser));
+
+ paneLayout.messagePaneVisible = false;
+ Assert.ok(BrowserTestUtils.is_hidden(folderTree));
+ Assert.ok(BrowserTestUtils.is_hidden(firstMessageBrowser));
+ Assert.equal(
+ tabmail.currentAboutMessage,
+ null,
+ "currentAboutMessage should be null with the message pane hidden"
+ );
+ Assert.equal(firstTab.browser, null);
+ Assert.equal(firstTab.linkedBrowser, null);
+
+ paneLayout.folderPaneVisible = true;
+ Assert.ok(BrowserTestUtils.is_visible(folderTree));
+ Assert.ok(BrowserTestUtils.is_hidden(firstMessageBrowser));
+
+ paneLayout.messagePaneVisible = true;
+ Assert.ok(BrowserTestUtils.is_visible(folderTree));
+ Assert.ok(BrowserTestUtils.is_visible(firstMessageBrowser));
+ Assert.equal(
+ tabmail.currentAboutMessage,
+ firstMessageBrowser.contentWindow,
+ "currentAboutMessage should have a value with the message pane shown"
+ );
+ Assert.equal(firstTab.browser, firstMessagePane);
+ Assert.equal(firstTab.linkedBrowser, firstMessagePane);
+
+ Assert.equal(firstChromeBrowser.contentWindow.tabOrWindow, firstTab);
+ Assert.equal(firstMessageBrowser.contentWindow.tabOrWindow, firstTab);
+
+ // Select multiple messages.
+
+ let firstMultiMessageBrowser =
+ firstChromeBrowser.contentDocument.getElementById("multiMessageBrowser");
+ let firstWebBrowser =
+ firstChromeBrowser.contentDocument.getElementById("webBrowser");
+
+ threadTree.selectedIndices = [1, 2];
+ Assert.ok(BrowserTestUtils.is_hidden(firstWebBrowser));
+ Assert.ok(BrowserTestUtils.is_hidden(firstMessageBrowser));
+ Assert.ok(BrowserTestUtils.is_visible(firstMultiMessageBrowser));
+ Assert.equal(
+ tabmail.currentAboutMessage,
+ null,
+ "currentAboutMessage should be null with multiple messages selected"
+ );
+ Assert.equal(firstTab.browser, null);
+ Assert.equal(firstTab.linkedBrowser, null);
+
+ // Load a web page.
+
+ let loadedPromise = BrowserTestUtils.browserLoaded(
+ firstWebBrowser,
+ false,
+ "http://mochi.test:8888/"
+ );
+ messagePane.displayWebPage("http://mochi.test:8888/");
+ await loadedPromise;
+ Assert.ok(BrowserTestUtils.is_visible(firstWebBrowser));
+ Assert.ok(BrowserTestUtils.is_hidden(firstMessageBrowser));
+ Assert.ok(BrowserTestUtils.is_hidden(firstMultiMessageBrowser));
+ Assert.equal(firstWebBrowser.currentURI.spec, "http://mochi.test:8888/");
+ Assert.equal(
+ tabmail.currentAboutMessage,
+ null,
+ "currentAboutMessage should be null with a web page loaded"
+ );
+ Assert.equal(firstTab.browser, firstWebBrowser);
+ Assert.equal(firstTab.linkedBrowser, firstWebBrowser);
+
+ // Go back to a single selection.
+
+ threadTree.selectedIndex = 0;
+ Assert.ok(BrowserTestUtils.is_hidden(firstWebBrowser));
+ Assert.ok(BrowserTestUtils.is_visible(firstMessageBrowser));
+ Assert.ok(BrowserTestUtils.is_hidden(firstMultiMessageBrowser));
+ Assert.equal(
+ tabmail.currentAboutMessage,
+ firstMessageBrowser.contentWindow,
+ "currentAboutMessage should have a value with a single message selected"
+ );
+ Assert.equal(firstTab.browser, firstMessagePane);
+ Assert.equal(firstTab.linkedBrowser, firstMessagePane);
+
+ // Open some more tabs. These should open in the background.
+
+ window.MsgOpenNewTabForFolders([folderB], {
+ folderPaneVisible: true,
+ messagePaneVisible: true,
+ });
+
+ for (let message of messagesB) {
+ window.OpenMessageInNewTab(message, {});
+ }
+
+ Assert.equal(tabmail.tabInfo.length, 4);
+ Assert.equal(tabmail.currentTabInfo, firstTab);
+ Assert.equal(tabmail.currentAbout3Pane, firstChromeBrowser.contentWindow);
+ Assert.equal(tabmail.currentAboutMessage, firstMessageBrowser.contentWindow);
+
+ // Check the second tab.
+
+ tabmail.switchToTab(1);
+ Assert.equal(tabmail.currentTabInfo, tabmail.tabInfo[1]);
+
+ let secondTab = tabmail.currentTabInfo;
+ Assert.equal(secondTab.mode.name, "mail3PaneTab");
+ Assert.equal(secondTab.mode.tabType.name, "mailTab");
+
+ let secondChromeBrowser = secondTab.chromeBrowser;
+ await ensureBrowserLoaded(secondChromeBrowser);
+ Assert.equal(secondChromeBrowser.currentURI.spec, "about:3pane");
+ Assert.equal(tabmail.currentAbout3Pane, secondChromeBrowser.contentWindow);
+
+ let secondMessageBrowser =
+ secondChromeBrowser.contentDocument.getElementById("messageBrowser");
+ await ensureBrowserLoaded(secondMessageBrowser);
+ Assert.equal(secondMessageBrowser.currentURI.spec, "about:message");
+
+ let secondMessagePane =
+ secondMessageBrowser.contentDocument.getElementById("messagepane");
+ Assert.equal(secondMessagePane.currentURI.spec, "about:blank");
+ Assert.equal(
+ tabmail.currentAboutMessage,
+ null,
+ "currentAboutMessage should be null with no message selected"
+ );
+ Assert.equal(secondTab.browser, null);
+ Assert.equal(secondTab.linkedBrowser, null);
+
+ Assert.equal(secondTab.folder, folderB);
+
+ secondChromeBrowser.contentWindow.threadTree.selectedIndex = 0;
+ Assert.equal(
+ tabmail.currentAboutMessage,
+ secondMessageBrowser.contentWindow,
+ "currentAboutMessage should have a value with a message selected"
+ );
+
+ Assert.equal(secondChromeBrowser.contentWindow.tabOrWindow, secondTab);
+ Assert.equal(secondMessageBrowser.contentWindow.tabOrWindow, secondTab);
+
+ // Check the third tab.
+
+ tabmail.switchToTab(2);
+ Assert.equal(tabmail.currentTabInfo, tabmail.tabInfo[2]);
+
+ let thirdTab = tabmail.currentTabInfo;
+ Assert.equal(thirdTab.mode.name, "mailMessageTab");
+ Assert.equal(thirdTab.mode.tabType.name, "mailTab");
+
+ let thirdChromeBrowser = thirdTab.chromeBrowser;
+ await ensureBrowserLoaded(thirdChromeBrowser);
+ Assert.equal(thirdChromeBrowser.currentURI.spec, "about:message");
+ Assert.equal(tabmail.currentAbout3Pane, null);
+ Assert.equal(tabmail.currentAboutMessage, thirdChromeBrowser.contentWindow);
+
+ let thirdMessagePane =
+ thirdChromeBrowser.contentDocument.getElementById("messagepane");
+ Assert.equal(thirdMessagePane.currentURI.spec, messageToURL(messagesB[0]));
+ Assert.equal(thirdTab.browser, thirdMessagePane);
+ Assert.equal(thirdTab.linkedBrowser, thirdMessagePane);
+
+ Assert.equal(thirdTab.folder, folderB);
+ Assert.equal(thirdTab.message, messagesB[0]);
+
+ Assert.equal(thirdChromeBrowser.contentWindow.tabOrWindow, thirdTab);
+
+ // Check the fourth tab.
+
+ tabmail.switchToTab(3);
+ Assert.equal(tabmail.currentTabInfo, tabmail.tabInfo[3]);
+
+ let fourthTab = tabmail.currentTabInfo;
+ Assert.equal(fourthTab.mode.name, "mailMessageTab");
+ Assert.equal(fourthTab.mode.tabType.name, "mailTab");
+
+ let fourthChromeBrowser = fourthTab.chromeBrowser;
+ await ensureBrowserLoaded(fourthChromeBrowser);
+ Assert.equal(fourthChromeBrowser.currentURI.spec, "about:message");
+ Assert.equal(tabmail.currentAbout3Pane, null);
+ Assert.equal(tabmail.currentAboutMessage, fourthChromeBrowser.contentWindow);
+
+ let fourthMessagePane =
+ fourthChromeBrowser.contentDocument.getElementById("messagepane");
+ Assert.equal(fourthMessagePane.currentURI.spec, messageToURL(messagesB[1]));
+ Assert.equal(fourthTab.browser, fourthMessagePane);
+ Assert.equal(fourthTab.linkedBrowser, fourthMessagePane);
+
+ Assert.equal(fourthTab.folder, folderB);
+ Assert.equal(fourthTab.message, messagesB[1]);
+
+ Assert.equal(fourthChromeBrowser.contentWindow.tabOrWindow, fourthTab);
+
+ // Close tabs.
+
+ tabmail.closeTab(3);
+ Assert.equal(tabmail.currentTabInfo, thirdTab);
+ Assert.equal(tabmail.currentAbout3Pane, null);
+ Assert.equal(tabmail.currentAboutMessage, thirdChromeBrowser.contentWindow);
+
+ tabmail.closeTab(2);
+ Assert.equal(tabmail.currentTabInfo, secondTab);
+ Assert.equal(tabmail.currentAbout3Pane, secondChromeBrowser.contentWindow);
+ Assert.equal(tabmail.currentAboutMessage, secondMessageBrowser.contentWindow);
+
+ tabmail.closeTab(1);
+ Assert.equal(tabmail.currentTabInfo, firstTab);
+ Assert.equal(tabmail.currentAbout3Pane, firstChromeBrowser.contentWindow);
+ Assert.equal(tabmail.currentAboutMessage, firstMessageBrowser.contentWindow);
+});
+
+add_task(async function testMessageWindow() {
+ let messageWindowPromise = BrowserTestUtils.domWindowOpenedAndLoaded(
+ undefined,
+ async win =>
+ win.document.documentURI ==
+ "chrome://messenger/content/messageWindow.xhtml"
+ );
+ MailUtils.openMessageInNewWindow(messagesB[0]);
+
+ let messageWindow = await messageWindowPromise;
+ let messageBrowser = messageWindow.messageBrowser;
+ await ensureBrowserLoaded(messageBrowser);
+ Assert.equal(messageBrowser.contentWindow.tabOrWindow, messageWindow);
+
+ await BrowserTestUtils.closeWindow(messageWindow);
+});
+
+async function ensureBrowserLoaded(browser) {
+ await TestUtils.waitForCondition(
+ () =>
+ browser.currentURI.spec != "about:blank" &&
+ browser.contentDocument.readyState == "complete",
+ "waiting for browser to finish loading"
+ );
+}
+
+function messageToURL(message) {
+ let messageService = MailServices.messageServiceFromURI("mailbox-message://");
+ let uri = message.folder.getUriForMsg(message);
+ return messageService.getUrlForUri(uri).spec;
+}
diff --git a/comm/mail/base/test/browser/browser_markAsRead.js b/comm/mail/base/test/browser/browser_markAsRead.js
new file mode 100644
index 0000000000..62b19d5b4e
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_markAsRead.js
@@ -0,0 +1,204 @@
+/* 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/. */
+
+/**
+ * Tests that a message does not get marked as read if it is opened in a
+ * background tab.
+ */
+
+const { MessageGenerator } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MessageGenerator.jsm"
+);
+
+let localTestFolder;
+
+add_setup(async function () {
+ // We need to get messages directly from the server when displaying them,
+ // or this test isn't really testing what it should.
+ Services.prefs.setBoolPref("mail.server.default.offline_download", false);
+
+ const generator = new MessageGenerator();
+
+ MailServices.accounts.createLocalMailAccount();
+ const account = MailServices.accounts.accounts[0];
+ const rootFolder = account.incomingServer.rootFolder.QueryInterface(
+ Ci.nsIMsgLocalMailFolder
+ );
+ localTestFolder = rootFolder
+ .createLocalSubfolder("markAsRead")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+
+ localTestFolder.addMessageBatch(
+ generator.makeMessages({}).map(message => message.toMboxString())
+ );
+
+ registerCleanupFunction(() => {
+ MailServices.accounts.removeAccount(account, false);
+ Services.prefs.clearUserPref("mail.server.default.offline_download");
+ Services.prefs.clearUserPref("mailnews.mark_message_read.auto");
+ Services.prefs.clearUserPref("mailnews.mark_message_read.delay");
+ Services.prefs.clearUserPref("mailnews.mark_message_read.delay.interval");
+ });
+});
+
+add_task(async function testLocal() {
+ await subtest(localTestFolder);
+});
+
+async function subtest(testFolder) {
+ const tabmail = document.getElementById("tabmail");
+ const firstAbout3Pane = tabmail.currentAbout3Pane;
+ firstAbout3Pane.displayFolder(testFolder);
+ const testMessages = testFolder.messages;
+
+ // Open a message in the first tab. It should get marked as read immediately.
+
+ let message = testMessages.getNext();
+ Assert.ok(!message.isRead, "message 0 should not be read before load");
+ firstAbout3Pane.threadTree.selectedIndex =
+ firstAbout3Pane.gDBView.findIndexOfMsgHdr(message, false);
+ await BrowserTestUtils.waitForEvent(window, "MsgLoaded");
+ await TestUtils.waitForCondition(
+ () => message.isRead,
+ "waiting for message 0 to be marked as read"
+ );
+
+ firstAbout3Pane.threadTree.selectedIndex = -1; // Unload the message.
+
+ // Open a message in a background tab. It should not get marked as read.
+
+ message = testMessages.getNext();
+ Assert.ok(!message.isRead, "message 1 should not be read before load");
+ window.OpenMessageInNewTab(message, { background: true });
+ await BrowserTestUtils.waitForEvent(window, "MsgLoaded");
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 1000));
+ Assert.ok(
+ !message.isRead,
+ "message 1 should not be read after opening in a background tab"
+ );
+
+ // Switch to the tab. The message should get marked as read immediately.
+
+ tabmail.switchToTab(1);
+ await TestUtils.waitForTick();
+ Assert.ok(
+ message.isRead,
+ "message 1 should be read after switching to the background tab"
+ );
+ tabmail.closeTab(1);
+
+ // With the marking delayed by preferences, open a message in a background tab.
+ // It should not get marked as read.
+
+ Services.prefs.setBoolPref("mailnews.mark_message_read.delay", true);
+ Services.prefs.setIntPref("mailnews.mark_message_read.delay.interval", 2);
+
+ message = testMessages.getNext();
+ Assert.ok(!message.isRead, "message 2 should not be read before load");
+ window.OpenMessageInNewTab(message, { background: true });
+ await BrowserTestUtils.waitForEvent(window, "MsgLoaded");
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 3000));
+ Assert.ok(
+ !message.isRead,
+ "message 2 should not be read after opening in a background tab"
+ );
+
+ // Switch to the tab. The message should get marked as read after the delay.
+
+ const timeBeforeSwitchingTab = Date.now();
+ tabmail.switchToTab(1);
+ Assert.ok(
+ !message.isRead,
+ "message 2 should not be read immediately after switching to the background tab"
+ );
+ await TestUtils.waitForCondition(
+ () => message.isRead,
+ "waiting for message 2 to be marked as read"
+ );
+ Assert.greaterOrEqual(
+ Date.now() - timeBeforeSwitchingTab,
+ 2000,
+ "message 2 should be read after switching to the background tab and the 2s delay"
+ );
+ tabmail.closeTab(1);
+
+ Services.prefs.setBoolPref("mailnews.mark_message_read.delay", false);
+
+ // With the marking disabled by preferences, open a message in a background
+ // tab. It should not get marked as read.
+
+ Services.prefs.setBoolPref("mailnews.mark_message_read.auto", false);
+
+ message = testMessages.getNext();
+ Assert.ok(!message.isRead, "message 3 should not be read before load");
+ window.OpenMessageInNewTab(message, { background: true });
+ await BrowserTestUtils.waitForEvent(window, "MsgLoaded");
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 1000));
+ Assert.ok(
+ !message.isRead,
+ "message 3 should not be read after opening in a background tab"
+ );
+
+ // Switch to the tab. The message should not get marked as read.
+
+ tabmail.switchToTab(1);
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 1000));
+ Assert.ok(
+ !message.isRead,
+ "message 3 should not be read after switching to the background tab"
+ );
+ tabmail.closeTab(1);
+
+ Services.prefs.setBoolPref("mailnews.mark_message_read.auto", true);
+
+ // Open a new 3-pane tab in the background and load a message in it. The
+ // message should not get marked as read.
+
+ window.MsgOpenNewTabForFolders([testFolder], {
+ background: true,
+ messagePaneVisible: true,
+ });
+ const secondAbout3Pane = tabmail.tabInfo[1].chromeBrowser.contentWindow;
+ await TestUtils.waitForCondition(
+ () => secondAbout3Pane.gDBView,
+ "waiting for view to load"
+ );
+
+ message = testMessages.getNext();
+ Assert.ok(!message.isRead, "message 4 should not be read before load");
+ secondAbout3Pane.threadTree.selectedIndex =
+ secondAbout3Pane.gDBView.findIndexOfMsgHdr(message, false);
+ await BrowserTestUtils.waitForEvent(window, "MsgLoaded");
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 1000));
+ Assert.ok(
+ !message.isRead,
+ "message 4 should not be read after opening in a background tab"
+ );
+
+ tabmail.switchToTab(1);
+ await TestUtils.waitForTick();
+ Assert.ok(
+ message.isRead,
+ "message 4 should be read after switching to the background tab"
+ );
+ tabmail.closeTab(1);
+
+ // Open a message in a new foreground tab. It should get marked as read
+ // immediately.
+
+ message = testMessages.getNext();
+ Assert.ok(!message.isRead, "message 5 should not be read before load");
+ window.OpenMessageInNewTab(message, { background: false });
+ await BrowserTestUtils.waitForEvent(window, "MsgLoaded");
+ Assert.ok(
+ message.isRead,
+ "message 5 should be read after opening the foreground tab"
+ );
+ tabmail.closeTab(1);
+}
diff --git a/comm/mail/base/test/browser/browser_menulist.js b/comm/mail/base/test/browser/browser_menulist.js
new file mode 100644
index 0000000000..39a6a840d2
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_menulist.js
@@ -0,0 +1,183 @@
+/* import-globals-from ../../content/utilityOverlay.js */
+
+add_task(async () => {
+ let TEST_DOCUMENT_URL = getRootDirectory(gTestPath) + "files/menulist.xhtml";
+ let testDocument = await new Promise(resolve => {
+ Services.obs.addObserver(function documentLoaded(subject) {
+ if (subject.URL == TEST_DOCUMENT_URL) {
+ Services.obs.removeObserver(documentLoaded, "chrome-document-loaded");
+ resolve(subject);
+ }
+ }, "chrome-document-loaded");
+ openContentTab(TEST_DOCUMENT_URL);
+ });
+ ok(testDocument.URL == TEST_DOCUMENT_URL);
+ let testWindow = testDocument.ownerGlobal;
+ let MENULIST_CLASS = testWindow.customElements.get("menulist");
+ let MENULIST_EDITABLE_CLASS =
+ testWindow.customElements.get("menulist-editable");
+
+ let menulists = testDocument.querySelectorAll("menulist");
+ is(menulists.length, 3);
+
+ // Menulist 0 is an ordinary, non-editable menulist.
+ ok(menulists[0] instanceof MENULIST_CLASS);
+ ok(!(menulists[0] instanceof MENULIST_EDITABLE_CLASS));
+ ok(!("editable" in menulists[0]));
+
+ // Menulist 1 is an editable menulist, but not in editing mode.
+ ok(menulists[1] instanceof MENULIST_CLASS);
+ ok(menulists[1] instanceof MENULIST_EDITABLE_CLASS);
+ ok("editable" in menulists[1]);
+ ok(!menulists[1].editable);
+
+ // Menulist 2 is an editable menulist, in editing mode.
+ ok(menulists[2] instanceof MENULIST_CLASS);
+ ok(menulists[2] instanceof MENULIST_EDITABLE_CLASS);
+ ok("editable" in menulists[2]);
+ ok(menulists[2].editable);
+
+ // Okay, let's check the focus order.
+ let testBrowser = document.getElementById("tabmail").currentTabInfo.browser;
+ EventUtils.synthesizeMouseAtCenter(testBrowser, { clickCount: 1 });
+ await new Promise(resolve => setTimeout(resolve));
+
+ let beforeButton = testDocument.querySelector("button#before");
+ beforeButton.focus();
+ is(testDocument.activeElement, beforeButton);
+
+ EventUtils.synthesizeKey("VK_TAB", { shiftKey: false }, testWindow);
+ is(testDocument.activeElement, menulists[0]);
+
+ EventUtils.synthesizeKey("VK_TAB", { shiftKey: false }, testWindow);
+ is(testDocument.activeElement, menulists[1]);
+
+ EventUtils.synthesizeKey("VK_TAB", { shiftKey: false }, testWindow);
+ is(testDocument.activeElement, menulists[2]);
+
+ EventUtils.synthesizeKey("VK_TAB", { shiftKey: false }, testWindow);
+ is(testDocument.activeElement, menulists[2]);
+ is(menulists[2].shadowRoot.activeElement, menulists[2]._inputField);
+
+ EventUtils.synthesizeKey("VK_TAB", { shiftKey: false }, testWindow);
+ is(testDocument.activeElement, testDocument.querySelector("button#after"));
+
+ // Now go back again.
+ EventUtils.synthesizeKey("VK_TAB", { shiftKey: true }, testWindow);
+ is(testDocument.activeElement, menulists[2]);
+ is(menulists[2].shadowRoot.activeElement, menulists[2]._inputField);
+
+ EventUtils.synthesizeKey("VK_TAB", { shiftKey: true }, testWindow);
+ is(testDocument.activeElement, menulists[2]);
+
+ EventUtils.synthesizeKey("VK_TAB", { shiftKey: true }, testWindow);
+ is(testDocument.activeElement, menulists[1]);
+
+ EventUtils.synthesizeKey("VK_TAB", { shiftKey: true }, testWindow);
+ is(testDocument.activeElement, menulists[0]);
+
+ EventUtils.synthesizeKey("VK_TAB", { shiftKey: true }, testWindow);
+ is(testDocument.activeElement, beforeButton, "focus back to the start");
+
+ let popup = menulists[2].menupopup;
+ // The dropmarker should open and close the popup.
+ let openEvent = BrowserTestUtils.waitForEvent(popup, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ menulists[2]._dropmarker,
+ { clickCount: 1 },
+ testWindow
+ );
+ await openEvent;
+ ok(menulists[2].hasAttribute("open"), "popup open");
+
+ let hiddenEvent = BrowserTestUtils.waitForEvent(popup, "popuphidden");
+ popup.hidePopup();
+ await hiddenEvent;
+ ok(!menulists[2].hasAttribute("open"), "closed again");
+
+ // Open the popup and choose an item.
+ EventUtils.synthesizeMouseAtCenter(
+ menulists[2]._dropmarker,
+ { clickCount: 1 },
+ testWindow
+ );
+ await BrowserTestUtils.waitForEvent(popup, "popupshown");
+ ok(menulists[2].hasAttribute("open"), "open for item click");
+
+ await new Promise(resolve => {
+ menulists[2].addEventListener("select", () => setTimeout(resolve), {
+ once: true,
+ });
+ popup.activateItem(menulists[2].querySelectorAll("menuitem")[0]);
+ });
+ ok(!menulists[2].hasAttribute("open"));
+ is(testDocument.activeElement, menulists[2]);
+ is(menulists[2].shadowRoot.activeElement, menulists[2]._inputField);
+ is(menulists[2]._inputField.value, "foo");
+ is(menulists[2].value, "foo");
+ is(menulists[2].getAttribute("value"), "foo");
+
+ // Again.
+ EventUtils.synthesizeMouseAtCenter(
+ menulists[2]._dropmarker,
+ { clickCount: 1 },
+ testWindow
+ );
+ await BrowserTestUtils.waitForEvent(popup, "popupshown");
+ ok(menulists[2].hasAttribute("open"));
+
+ await new Promise(resolve => {
+ menulists[2].addEventListener("select", () => setTimeout(resolve), {
+ once: true,
+ });
+ popup.activateItem(menulists[2].querySelectorAll("menuitem")[1]);
+ });
+ ok(!menulists[2].hasAttribute("open"));
+ is(testDocument.activeElement, menulists[2]);
+ is(menulists[2].shadowRoot.activeElement, menulists[2]._inputField);
+ is(menulists[2]._inputField.value, "bar");
+ is(menulists[2].value, "bar");
+ is(menulists[2].getAttribute("value"), "bar");
+
+ // Type in a value.
+ is(menulists[2]._inputField.selectionStart, 0);
+ is(menulists[2]._inputField.selectionEnd, 3);
+ EventUtils.sendString("quux", testWindow);
+ await new Promise(resolve => {
+ menulists[2].addEventListener(
+ "change",
+ event => {
+ is(event.target, menulists[2]);
+ resolve();
+ },
+ { once: true }
+ );
+ EventUtils.synthesizeKey("VK_TAB", { shiftKey: false }, testWindow);
+ });
+ is(menulists[2].value, "quux");
+ is(menulists[2].getAttribute("value"), "quux");
+
+ // Open the popup and choose an item.
+ EventUtils.synthesizeMouseAtCenter(
+ menulists[2]._dropmarker,
+ { clickCount: 1 },
+ testWindow
+ );
+ await BrowserTestUtils.waitForEvent(popup, "popupshown");
+ ok(menulists[2].hasAttribute("open"));
+
+ await new Promise(resolve => {
+ menulists[2].addEventListener("select", () => setTimeout(resolve), {
+ once: true,
+ });
+ popup.activateItem(menulists[2].querySelectorAll("menuitem")[0]);
+ });
+ ok(!menulists[2].hasAttribute("open"));
+ is(testDocument.activeElement, menulists[2]);
+ is(menulists[2].shadowRoot.activeElement, menulists[2]._inputField);
+ is(menulists[2]._inputField.value, "foo");
+ is(menulists[2].value, "foo");
+ is(menulists[2].getAttribute("value"), "foo");
+
+ document.getElementById("tabmail").closeOtherTabs(0);
+});
diff --git a/comm/mail/base/test/browser/browser_messageMenu.js b/comm/mail/base/test/browser/browser_messageMenu.js
new file mode 100644
index 0000000000..7b397f922e
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_messageMenu.js
@@ -0,0 +1,355 @@
+/* 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 { GlodaIndexer } = ChromeUtils.import(
+ "resource:///modules/gloda/GlodaIndexer.jsm"
+);
+const { MessageGenerator } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MessageGenerator.jsm"
+);
+
+const nothingSelected = ["rootFolder", "noSelection", "contentTab"];
+const nothingOrMultiSelected = [...nothingSelected, "multiSelection"];
+
+/** @type MenuData */
+const messageMenuData = {
+ newMsgCmd: {},
+ replyMainMenu: { disabled: nothingSelected },
+ replyNewsgroupMainMenu: { hidden: true },
+ replySenderMainMenu: { hidden: true },
+ menu_replyToAll: { disabled: nothingSelected },
+ menu_replyToList: { disabled: true },
+ menu_forwardMsg: { disabled: nothingSelected },
+ forwardAsMenu: { disabled: nothingSelected },
+ menu_forwardAsInline: { disabled: nothingSelected },
+ menu_forwardAsAttachment: { disabled: nothingSelected },
+ menu_redirectMsg: { disabled: nothingSelected },
+ menu_editMsgAsNew: { disabled: nothingSelected },
+ menu_editDraftMsg: { hidden: true },
+ menu_newMsgFromTemplate: { hidden: true },
+ menu_editTemplate: { hidden: true },
+ openMessageWindowMenuitem: {
+ disabled: [...nothingSelected, "message", "externalMessage"],
+ },
+ openConversationMenuitem: {
+ disabled: [...nothingOrMultiSelected, "externalMessage"],
+ },
+ openFeedMessage: { hidden: true },
+ menu_openFeedWebPage: { disabled: nothingSelected },
+ menu_openFeedSummary: { disabled: nothingSelected },
+ menu_openFeedWebPageInMessagePane: {
+ disabled: nothingSelected,
+ },
+ msgAttachmentMenu: { disabled: true },
+ tagMenu: { disabled: [...nothingSelected, "externalMessage"] },
+ "tagMenu-addNewTag": { disabled: nothingSelected },
+ "tagMenu-manageTags": { disabled: nothingSelected },
+ "tagMenu-tagRemoveAll": { disabled: nothingSelected },
+ markMenu: { disabled: ["rootFolder", "externalMessage", "contentTab"] },
+ markReadMenuItem: { disabled: nothingSelected },
+ markUnreadMenuItem: { disabled: true },
+ menu_markThreadAsRead: { disabled: nothingSelected },
+ menu_markReadByDate: { disabled: nothingSelected },
+ menu_markAllRead: { disabled: ["rootFolder"] },
+ markFlaggedMenuItem: { disabled: nothingSelected },
+ menu_markAsJunk: { disabled: nothingSelected },
+ menu_markAsNotJunk: { disabled: nothingSelected },
+ menu_recalculateJunkScore: {
+ disabled: [...nothingSelected, "message"],
+ },
+ archiveMainMenu: { disabled: [...nothingSelected, "externalMessage"] },
+ menu_cancel: { hidden: true },
+ moveMenu: { disabled: [...nothingSelected, "externalMessage"] },
+ copyMenu: { disabled: nothingSelected },
+ moveToFolderAgain: { disabled: true },
+ createFilter: { disabled: [...nothingOrMultiSelected, "externalMessage"] },
+ killThread: { disabled: [...nothingSelected, "message", "externalMessage"] },
+ killSubthread: {
+ disabled: [...nothingSelected, "message", "externalMessage"],
+ },
+ watchThread: { disabled: [...nothingSelected, "externalMessage"] },
+};
+let helper = new MenuTestHelper("messageMenu", messageMenuData);
+
+let tabmail = document.getElementById("tabmail");
+let rootFolder, testFolder, testMessages;
+let draftsFolder, draftsMessages, templatesFolder, templatesMessages;
+
+add_setup(async function () {
+ Services.prefs.setBoolPref("mailnews.mark_message_read.auto", false);
+ document.getElementById("toolbar-menubar").removeAttribute("autohide");
+
+ let generator = new MessageGenerator();
+
+ MailServices.accounts.createLocalMailAccount();
+ let account = MailServices.accounts.accounts[0];
+ account.addIdentity(MailServices.accounts.createIdentity());
+ rootFolder = account.incomingServer.rootFolder;
+
+ rootFolder.createSubfolder("messageMenu", null);
+ testFolder = rootFolder
+ .getChildNamed("messageMenu")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+ const messages = [
+ ...generator.makeMessages({ count: 5 }),
+ ...generator.makeMessages({ count: 5, msgsPerThread: 5 }),
+ ];
+ testFolder.addMessageBatch(messages.map(message => message.toMboxString()));
+ testFolder.addMessage(
+ generator
+ .makeMessage({
+ attachments: [
+ {
+ body: "an attachment",
+ contentType: "text/plain",
+ filename: "attachment.txt",
+ },
+ ],
+ })
+ .toMboxString()
+ );
+ testFolder.addMessage(
+ "From - Mon Jan 01 00:00:00 2001\n" +
+ "To: Mailing List <list@example.com>\n" +
+ "Date: Mon, 01 Jan 2001 00:00:00 +0100\n" +
+ "List-Help: <https://list.example.com>\n" +
+ "List-Post: <mailto:list@example.com>\n" +
+ "List-Software: Mailing List Software\n" +
+ "List-Subscribe: <https://subscribe.example.com>\n" +
+ "Precedence: list\n" +
+ "Subject: Mailing List Test Mail\n" +
+ `Message-ID: <${Date.now()}@example.com>\n` +
+ "From: Mailing List <list@example.com>\n" +
+ "List-Unsubscribe: <https://unsubscribe.example.com>,\n" +
+ " <mailto:unsubscribe@example.com?subject=Unsubscribe Test>\n" +
+ "MIME-Version: 1.0\n" +
+ "Content-Type: text/plain; charset=UTF-8\n" +
+ "Content-Transfer-Encoding: quoted-printable\n" +
+ "\n" +
+ "Mailing List Message Body\n"
+ );
+ testMessages = [...testFolder.messages];
+
+ rootFolder.createSubfolder("messageMenuDrafts", null);
+ draftsFolder = rootFolder
+ .getChildNamed("messageMenuDrafts")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+ draftsFolder.setFlag(Ci.nsMsgFolderFlags.Drafts);
+ draftsFolder.addMessageBatch(
+ generator.makeMessages({ count: 5 }).map(message => message.toMboxString())
+ );
+ draftsMessages = [...draftsFolder.messages];
+ rootFolder.createSubfolder("messageMenuTemplates", null);
+ templatesFolder = rootFolder
+ .getChildNamed("messageMenuTemplates")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+ templatesFolder.setFlag(Ci.nsMsgFolderFlags.Templates);
+ templatesFolder.addMessageBatch(
+ generator.makeMessages({ count: 5 }).map(message => message.toMboxString())
+ );
+ templatesMessages = [...templatesFolder.messages];
+
+ window.OpenMessageInNewTab(testMessages[0], { background: true });
+ await BrowserTestUtils.waitForEvent(
+ tabmail.tabInfo[1].chromeBrowser,
+ "MsgLoaded"
+ );
+
+ let messageFile = new FileUtils.File(
+ getTestFilePath("files/sampleContent.eml")
+ );
+ let messageURI =
+ Services.io.newFileURI(messageFile).spec +
+ "?type=application/x-message-display";
+ tabmail.openTab("mailMessageTab", { background: true, messageURI });
+
+ window.openTab("contentTab", {
+ url: "https://example.com/",
+ background: true,
+ });
+
+ await TestUtils.waitForCondition(
+ () => !GlodaIndexer.indexing,
+ "waiting for Gloda to finish indexing",
+ 500
+ );
+
+ registerCleanupFunction(() => {
+ tabmail.closeOtherTabs(0);
+ MailServices.accounts.removeAccount(account, false);
+ Services.prefs.clearUserPref("mailnews.mark_message_read.auto");
+ });
+});
+
+add_task(async function testRootFolder() {
+ tabmail.currentAbout3Pane.restoreState({
+ folderPaneVisible: true,
+ messagePaneVisible: true,
+ folderURI: rootFolder,
+ });
+ await new Promise(resolve => setTimeout(resolve));
+ await helper.testAllItems("rootFolder");
+});
+
+add_task(async function testNoSelection() {
+ tabmail.currentAbout3Pane.restoreState({
+ folderPaneVisible: true,
+ messagePaneVisible: true,
+ folderURI: testFolder,
+ });
+ await new Promise(resolve => setTimeout(resolve));
+ await helper.testAllItems("noSelection");
+});
+
+add_task(async function testSingleSelection() {
+ tabmail.currentAbout3Pane.restoreState({
+ folderPaneVisible: true,
+ messagePaneVisible: true,
+ folderURI: testFolder,
+ });
+ await new Promise(resolve => setTimeout(resolve));
+
+ // This message is not marked as read.
+ tabmail.currentAbout3Pane.threadTree.selectedIndex = 1;
+ await helper.testAllItems("singleSelection");
+
+ // Mark it as read.
+ testMessages[1].markRead(true);
+ await helper.testItems({
+ markMenu: {},
+ markReadMenuItem: { disabled: true },
+ markUnreadMenuItem: {},
+ menu_markThreadAsRead: { disabled: true },
+ });
+
+ // Mark it as starred.
+ testMessages[1].markFlagged(true);
+ await helper.testItems({
+ markMenu: {},
+ markFlaggedMenuItem: { checked: true },
+ });
+
+ testFolder.addKeywordsToMessages([testMessages[1]], "$label1");
+ await helper.testItems({
+ tagMenu: {},
+ "tagMenu-tagRemoveAll": {},
+ });
+
+ // This message has an attachment.
+ tabmail.currentAbout3Pane.threadTree.selectedIndex = 6;
+ await BrowserTestUtils.browserLoaded(
+ tabmail.currentAboutMessage.getMessagePaneBrowser()
+ );
+
+ await helper.testItems({
+ msgAttachmentMenu: {},
+ "menu-openAllAttachments": {},
+ "menu-saveAllAttachments": {},
+ "menu-detachAllAttachments": {},
+ "menu-deleteAllAttachments": {},
+ });
+
+ // This message is from a mailing list.
+ tabmail.currentAbout3Pane.threadTree.selectedIndex = 7;
+ await BrowserTestUtils.browserLoaded(
+ tabmail.currentAboutMessage.getMessagePaneBrowser()
+ );
+ await helper.testItems({
+ menu_replyToList: { disabled: false },
+ });
+
+ // FIXME: Select another message and wait for it load in order to properly
+ // clear about:message.
+ tabmail.currentAbout3Pane.threadTree.selectedIndex = 1;
+ await BrowserTestUtils.browserLoaded(
+ tabmail.currentAboutMessage.getMessagePaneBrowser()
+ );
+});
+
+add_task(async function testMultiSelection() {
+ tabmail.currentAbout3Pane.restoreState({
+ folderPaneVisible: true,
+ messagePaneVisible: true,
+ folderURI: testFolder,
+ });
+ await new Promise(resolve => setTimeout(resolve));
+
+ // These messages aren't marked as read or flagged, or have a tag.
+ tabmail.currentAbout3Pane.threadTree.selectedIndices = [2, 4];
+ await helper.testAllItems("multiSelection");
+
+ // ONE of these messages IS marked as read and flagged, and it has a tag.
+ tabmail.currentAbout3Pane.threadTree.selectedIndices = [1, 2, 4];
+ await helper.testItems({
+ markMenu: {},
+ markReadMenuItem: {},
+ markUnreadMenuItem: {},
+ menu_markThreadAsRead: { disabled: false },
+ markFlaggedMenuItem: { checked: true },
+ tagMenu: {},
+ "tagMenu-tagRemoveAll": {},
+ });
+
+ // Messages in a collapsed thread.
+ tabmail.currentAbout3Pane.threadTree.selectedIndex = 5;
+ await helper.testItems({
+ replyMainMenu: { disabled: true },
+ menu_replyToAll: { disabled: true },
+ menu_redirectMsg: { disabled: true },
+ menu_editMsgAsNew: { disabled: true },
+ });
+});
+
+add_task(async function testDraftsFolder() {
+ tabmail.currentAbout3Pane.restoreState({
+ folderPaneVisible: true,
+ messagePaneVisible: true,
+ folderURI: draftsFolder,
+ });
+ await new Promise(resolve => setTimeout(resolve));
+
+ tabmail.currentAbout3Pane.threadTree.selectedIndices = [1, 2, 4];
+ await helper.testItems({
+ menu_editDraftMsg: { hidden: false },
+ });
+ tabmail.currentAbout3Pane.threadTree.selectedIndices = [3];
+ await helper.testItems({
+ menu_editDraftMsg: { hidden: false },
+ });
+});
+
+add_task(async function testTemplatesFolder() {
+ tabmail.currentAbout3Pane.restoreState({
+ folderPaneVisible: true,
+ messagePaneVisible: true,
+ folderURI: templatesFolder,
+ });
+ await new Promise(resolve => setTimeout(resolve));
+
+ tabmail.currentAbout3Pane.threadTree.selectedIndices = [1, 2, 4];
+ await helper.testItems({
+ menu_newMsgFromTemplate: { hidden: false },
+ menu_editTemplate: { hidden: false },
+ });
+ tabmail.currentAbout3Pane.threadTree.selectedIndices = [3];
+ await helper.testItems({
+ menu_newMsgFromTemplate: { hidden: false },
+ menu_editTemplate: { hidden: false },
+ });
+});
+
+add_task(async function testMessageTab() {
+ tabmail.switchToTab(1);
+ await helper.testAllItems("message");
+});
+
+add_task(async function testExternalMessageTab() {
+ tabmail.switchToTab(2);
+ await helper.testAllItems("externalMessage");
+});
+
+add_task(async function testContentTab() {
+ tabmail.switchToTab(3);
+ await helper.testAllItems("contentTab");
+});
diff --git a/comm/mail/base/test/browser/browser_navigation.js b/comm/mail/base/test/browser/browser_navigation.js
new file mode 100644
index 0000000000..baa7bc6142
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_navigation.js
@@ -0,0 +1,1035 @@
+/* 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 { MessageGenerator } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MessageGenerator.jsm"
+);
+const { PromiseTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mailnews/PromiseTestUtils.jsm"
+);
+
+let tabmail = document.getElementById("tabmail");
+let about3Pane = tabmail.currentAbout3Pane;
+let { messageBrowser, multiMessageBrowser, threadTree } = about3Pane;
+let mailboxService = MailServices.messageServiceFromURI("mailbox:");
+let folderA,
+ folderAMessages,
+ folderB,
+ folderBMessages,
+ folderC,
+ folderCMessages,
+ folderD,
+ folderDMessages;
+
+add_setup(async function () {
+ Services.prefs.setBoolPref("mailnews.mark_message_read.auto", false);
+
+ let generator = new MessageGenerator();
+
+ MailServices.accounts.createLocalMailAccount();
+ let account = MailServices.accounts.accounts[0];
+ account.addIdentity(MailServices.accounts.createIdentity());
+ let rootFolder = account.incomingServer.rootFolder;
+
+ rootFolder.createSubfolder("Navigation A", null);
+ folderA = rootFolder
+ .getChildNamed("Navigation A")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+ folderA.addMessageBatch(
+ generator.makeMessages({ count: 5 }).map(message => message.toMboxString())
+ );
+ folderAMessages = [...folderA.messages];
+ folderA.markAllMessagesRead(null);
+
+ rootFolder.createSubfolder("Navigation B", null);
+ folderB = rootFolder
+ .getChildNamed("Navigation B")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+ folderB.addMessageBatch(
+ generator.makeMessages({ count: 5 }).map(message => message.toMboxString())
+ );
+ folderBMessages = [...folderB.messages];
+ folderB.markAllMessagesRead(null);
+
+ rootFolder.createSubfolder("Navigation C", null);
+ folderC = rootFolder
+ .getChildNamed("Navigation C")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+ // Add a lot of messages so scrolling can be tested.
+ folderC.addMessageBatch(
+ generator
+ .makeMessages({ count: 500 })
+ .map(message => message.toMboxString())
+ );
+ folderC.addMessageBatch(
+ generator.makeMessages({ count: 5 }).map(message => message.toMboxString())
+ );
+ folderCMessages = [...folderC.messages];
+ folderC.markAllMessagesRead(null);
+
+ rootFolder.createSubfolder("Navigation D", null);
+ folderD = rootFolder
+ .getChildNamed("Navigation D")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+ folderD.addMessageBatch(
+ generator
+ .makeMessages({
+ count: 12,
+ msgsPerThread: 3,
+ })
+ .map(message => message.toMboxString())
+ );
+ folderDMessages = [...folderD.messages];
+ folderD.markAllMessagesRead(null);
+
+ registerCleanupFunction(() => {
+ MailServices.accounts.removeAccount(account, false);
+ Services.prefs.clearUserPref("mailnews.mark_message_read.auto");
+ });
+});
+
+/** Tests the next message/previous message commands. */
+add_task(async function testNextPreviousMessageInAbout3Pane() {
+ const aboutMessage = messageBrowser.contentWindow;
+ const messagePaneBrowser = aboutMessage.getMessagePaneBrowser();
+
+ about3Pane.displayFolder(folderA.URI);
+ assertSelectedMessage();
+ await assertNoDisplayedMessage(aboutMessage);
+
+ for (let i = 0; i < 5; i++) {
+ goDoCommand("cmd_nextMsg");
+ assertSelectedMessage(folderAMessages[i]);
+ await assertDisplayedMessage(aboutMessage, folderAMessages[i]);
+ }
+
+ threadTree.addEventListener("select", reportBadSelectEvent);
+ messagePaneBrowser.addEventListener("load", reportBadLoad, true);
+ goDoCommand("cmd_nextMsg");
+ assertSelectedMessage(
+ folderAMessages[4],
+ "the selected message should not change"
+ );
+ await assertDisplayedMessage(aboutMessage, folderAMessages[4]);
+
+ // Wait to prove bad things didn't happen.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 500));
+ threadTree.removeEventListener("select", reportBadSelectEvent);
+ messagePaneBrowser.removeEventListener("load", reportBadLoad, true);
+
+ for (let i = 3; i >= 0; i--) {
+ goDoCommand("cmd_previousMsg");
+ assertSelectedMessage(folderAMessages[i]);
+ await assertDisplayedMessage(aboutMessage, folderAMessages[i]);
+ }
+
+ threadTree.addEventListener("select", reportBadSelectEvent);
+ messagePaneBrowser.addEventListener("load", reportBadLoad, true);
+ goDoCommand("cmd_previousMsg");
+ assertSelectedMessage(
+ folderAMessages[0],
+ "the selected message should not change"
+ );
+ await assertDisplayedMessage(aboutMessage, folderAMessages[0]);
+
+ // Wait to prove bad things didn't happen.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 500));
+ threadTree.removeEventListener("select", reportBadSelectEvent);
+ messagePaneBrowser.removeEventListener("load", reportBadLoad, true);
+
+ threadTree.selectedIndex = -1;
+ await assertNoDisplayedMessage(aboutMessage);
+});
+
+async function subtestNextPreviousMessage(win, aboutMessage) {
+ const messagePaneBrowser = aboutMessage.getMessagePaneBrowser();
+
+ await assertDisplayedMessage(aboutMessage, folderAMessages[2]);
+
+ for (let i = 3; i < 5; i++) {
+ win.goDoCommand("cmd_nextMsg");
+ await assertDisplayedMessage(aboutMessage, folderAMessages[i]);
+ }
+
+ messagePaneBrowser.addEventListener("load", reportBadLoad, true);
+ win.goDoCommand("cmd_nextMsg");
+ await assertDisplayedMessage(aboutMessage, folderAMessages[4]);
+
+ // Wait to prove bad things didn't happen.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 500));
+ messagePaneBrowser.removeEventListener("load", reportBadLoad, true);
+
+ for (let i = 3; i >= 0; i--) {
+ win.goDoCommand("cmd_previousMsg");
+ await assertDisplayedMessage(aboutMessage, folderAMessages[i]);
+ }
+
+ messagePaneBrowser.addEventListener("load", reportBadLoad, true);
+ win.goDoCommand("cmd_previousMsg");
+ await assertDisplayedMessage(aboutMessage, folderAMessages[0]);
+
+ // Wait to prove bad things didn't happen.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 500));
+ messagePaneBrowser.removeEventListener("load", reportBadLoad, true);
+}
+
+/** Tests the next message/previous message commands in a message tab. */
+add_task(async function testNextPreviousMessageInATab() {
+ await withMessageInATab(folderAMessages[2], subtestNextPreviousMessage);
+});
+
+/** Tests the next message/previous message commands in a message window. */
+add_task(async function testNextPreviousMessageInAWindow() {
+ await withMessageInAWindow(folderAMessages[2], subtestNextPreviousMessage);
+});
+
+/** Tests the next unread message command. */
+add_task(async function testNextUnreadMessageInAbout3Pane() {
+ const aboutMessage = messageBrowser.contentWindow;
+ const messagePaneBrowser = aboutMessage.getMessagePaneBrowser();
+
+ folderA.markMessagesRead([folderAMessages[1], folderAMessages[3]], false);
+ folderC.markMessagesRead(
+ [folderCMessages[500], folderCMessages[501], folderCMessages[504]],
+ false
+ );
+ folderD.markMessagesRead(
+ [
+ folderDMessages[3],
+ folderDMessages[4],
+ folderDMessages[6],
+ folderDMessages[7],
+ ],
+ false
+ );
+
+ about3Pane.displayFolder(folderA.URI);
+ threadTree.selectedIndex = -1;
+ assertSelectedMessage();
+ await assertNoDisplayedMessage(aboutMessage);
+
+ // Select the first unread message.
+ goDoCommand("cmd_nextUnreadMsg");
+ assertSelectedMessage(folderAMessages[1]);
+ await assertDisplayedMessage(aboutMessage, folderAMessages[1]);
+
+ // Select the next unread message.
+ goDoCommand("cmd_nextUnreadMsg");
+ assertSelectedMessage(folderAMessages[3]);
+ await assertDisplayedMessage(aboutMessage, folderAMessages[3]);
+
+ // Select the next unread message. Loops to start of folder.
+ goDoCommand("cmd_nextUnreadMsg");
+ assertSelectedMessage(folderAMessages[1]);
+ await assertDisplayedMessage(aboutMessage, folderAMessages[1]);
+
+ // Mark the message as read.
+ goDoCommand("cmd_markAsRead");
+ assertSelectedMessage(folderAMessages[1]);
+ await assertDisplayedMessage(aboutMessage, folderAMessages[1]);
+
+ // Select the next unread message.
+ goDoCommand("cmd_nextUnreadMsg");
+ assertSelectedMessage(folderAMessages[3]);
+ await assertDisplayedMessage(aboutMessage, folderAMessages[3]);
+
+ // Select the next unread message. Changes to the next folder.
+ let dialogPromise = BrowserTestUtils.promiseAlertDialog("accept");
+ goDoCommand("cmd_nextUnreadMsg");
+ await dialogPromise;
+ await new Promise(resolve => setTimeout(resolve));
+ assertSelectedFolder(folderC);
+ assertSelectedMessage(folderCMessages[500]);
+ await assertDisplayedMessage(aboutMessage, folderCMessages[500]);
+
+ // Select the next unread message.
+ goDoCommand("cmd_nextUnreadMsg");
+ assertSelectedMessage(folderCMessages[501]);
+ await assertDisplayedMessage(aboutMessage, folderCMessages[501]);
+
+ // Select the next unread message.
+ goDoCommand("cmd_nextUnreadMsg");
+ assertSelectedMessage(folderCMessages[504]);
+ await assertDisplayedMessage(aboutMessage, folderCMessages[504]);
+
+ // Select the first message in folder D and make sure all threads are
+ // collapsed.
+ about3Pane.displayFolder(folderD.URI);
+ threadTree.selectedIndex = 0;
+ let selectPromise = BrowserTestUtils.waitForEvent(threadTree, "select");
+ goDoCommand("cmd_collapseAllThreads");
+ await selectPromise;
+ assertSelectedMessage(folderDMessages[0]);
+
+ // Go to the next thread without expanding it.
+ EventUtils.synthesizeKey("KEY_ArrowDown");
+ assertSelectedMessage(folderDMessages[3]);
+
+ // The next displayed message should be the root message of the now expanded
+ // thread.
+ goDoCommand("cmd_nextUnreadMsg");
+ assertSelectedMessage(folderDMessages[3]);
+ await assertDisplayedMessage(aboutMessage, folderDMessages[3]);
+
+ // Select the next unread message in the thread.
+ goDoCommand("cmd_nextUnreadMsg");
+ assertSelectedMessage(folderDMessages[4]);
+ await assertDisplayedMessage(aboutMessage, folderDMessages[4]);
+
+ // Select the next unread message.
+ goDoCommand("cmd_nextUnreadMsg");
+ assertSelectedMessage(folderDMessages[6]);
+ await assertDisplayedMessage(aboutMessage, folderDMessages[6]);
+
+ // Select the next unread message.
+ goDoCommand("cmd_nextUnreadMsg");
+ assertSelectedMessage(folderDMessages[7]);
+ await assertDisplayedMessage(aboutMessage, folderDMessages[7]);
+
+ // Mark folder D read again.
+ folderD.markAllMessagesRead(null);
+
+ // Go back to the first folder. The previous selection should be restored.
+ about3Pane.displayFolder(folderA.URI);
+ assertSelectedMessage(folderAMessages[3]);
+ await assertDisplayedMessage(aboutMessage, folderAMessages[3]);
+
+ // Select the next unread message. Changes to the next folder.
+ // The previous selection should NOT be restored.
+ dialogPromise = BrowserTestUtils.promiseAlertDialog("accept");
+ goDoCommand("cmd_nextUnreadMsg");
+ await dialogPromise;
+ await new Promise(resolve => setTimeout(resolve));
+ assertSelectedFolder(folderC);
+ assertSelectedMessage(folderCMessages[500]);
+ await assertDisplayedMessage(aboutMessage, folderCMessages[500]);
+
+ folderC.markAllMessagesRead(null);
+ // No more unread messages, prompt to move to the next folder.
+ // Cancel the prompt.
+ threadTree.addEventListener("select", reportBadSelectEvent);
+ messagePaneBrowser.addEventListener("load", reportBadLoad, true);
+ dialogPromise = BrowserTestUtils.promiseAlertDialog("cancel");
+ goDoCommand("cmd_nextUnreadMsg");
+ await dialogPromise;
+ assertSelectedFolder(folderC);
+ assertSelectedMessage(folderCMessages[500]);
+
+ folderA.markAllMessagesRead(null);
+ // No unread messages anywhere, do nothing.
+ goDoCommand("cmd_nextUnreadMsg");
+ assertSelectedFolder(folderC);
+ assertSelectedMessage(folderCMessages[500]);
+
+ // Wait to prove bad things didn't happen.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 500));
+ threadTree.removeEventListener("select", reportBadSelectEvent);
+ messagePaneBrowser.removeEventListener("load", reportBadLoad, true);
+
+ threadTree.selectedIndex = -1;
+ await assertNoDisplayedMessage(aboutMessage);
+});
+
+async function subtestNextUnreadMessage(win, aboutMessage) {
+ const messagePaneBrowser = aboutMessage.getMessagePaneBrowser();
+
+ folderA.markMessagesRead([folderAMessages[1], folderAMessages[3]], false);
+ folderC.markMessagesRead(
+ [folderCMessages[500], folderCMessages[501], folderCMessages[504]],
+ false
+ );
+ Assert.equal(folderC.getNumUnread(false), 3);
+
+ await assertDisplayedMessage(aboutMessage, folderAMessages[0]);
+
+ // Select the first unread message.
+ win.goDoCommand("cmd_nextUnreadMsg");
+ await assertDisplayedMessage(aboutMessage, folderAMessages[1]);
+
+ // Select the next unread message.
+ win.goDoCommand("cmd_nextUnreadMsg");
+ await assertDisplayedMessage(aboutMessage, folderAMessages[3]);
+
+ // Select the next unread message. Loops to start of folder.
+ win.goDoCommand("cmd_nextUnreadMsg");
+ await assertDisplayedMessage(aboutMessage, folderAMessages[1]);
+
+ // Mark the message as read.
+ win.goDoCommand("cmd_markAsRead");
+ await assertDisplayedMessage(aboutMessage, folderAMessages[1]);
+
+ // Select the next unread message.
+ win.goDoCommand("cmd_nextUnreadMsg");
+ await assertDisplayedMessage(aboutMessage, folderAMessages[3]);
+
+ // Select the next unread message. Changes to the next folder.
+ let dialogPromise = BrowserTestUtils.promiseAlertDialog("accept");
+ win.goDoCommand("cmd_nextUnreadMsg");
+ await dialogPromise;
+ await new Promise(resolve => setTimeout(resolve));
+ await assertDisplayedMessage(aboutMessage, folderCMessages[500]);
+
+ // Select the next unread message.
+ win.goDoCommand("cmd_nextUnreadMsg");
+ await assertDisplayedMessage(aboutMessage, folderCMessages[501]);
+
+ // Select the next unread message.
+ win.goDoCommand("cmd_nextUnreadMsg");
+ await assertDisplayedMessage(aboutMessage, folderCMessages[504]);
+
+ folderC.markAllMessagesRead(null);
+ // No more unread messages, prompt to move to the next folder.
+ // Cancel the prompt.
+ messagePaneBrowser.addEventListener("load", reportBadLoad, true);
+ dialogPromise = BrowserTestUtils.promiseAlertDialog("cancel");
+ win.goDoCommand("cmd_nextUnreadMsg");
+ await dialogPromise;
+
+ folderA.markAllMessagesRead(null);
+ // No unread messages anywhere, do nothing.
+ win.goDoCommand("cmd_nextUnreadMsg");
+
+ // Wait to prove bad things didn't happen.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 500));
+ messagePaneBrowser.removeEventListener("load", reportBadLoad, true);
+}
+
+/** Tests the next unread message command in a message tab. */
+add_task(async function testNextUnreadMessageInATab() {
+ await withMessageInATab(folderAMessages[0], subtestNextUnreadMessage);
+});
+
+/** Tests the next unread message command in a message window. */
+add_task(async function testNextUnreadMessageInAWindow() {
+ await withMessageInAWindow(folderAMessages[0], subtestNextUnreadMessage);
+});
+
+/** Tests the previous unread message command. This doesn't cross folders. */
+add_task(async function testPreviousUnreadMessageInAbout3Pane() {
+ const aboutMessage = messageBrowser.contentWindow;
+ const messagePaneBrowser = aboutMessage.getMessagePaneBrowser();
+
+ folderA.markMessagesRead([folderAMessages[1], folderAMessages[3]], false);
+ folderC.markMessagesRead(
+ [folderCMessages[500], folderCMessages[501], folderCMessages[504]],
+ false
+ );
+
+ about3Pane.displayFolder(folderC.URI);
+ threadTree.scrollToIndex(504, true);
+ // Ensure the scrolling from the previous line happens.
+ await new Promise(resolve => requestAnimationFrame(resolve));
+ threadTree.selectedIndex = 504;
+ assertSelectedMessage(folderCMessages[504]);
+ await assertDisplayedMessage(aboutMessage, folderCMessages[504]);
+
+ goDoCommand("cmd_previousUnreadMsg");
+ assertSelectedMessage(folderCMessages[501]);
+ await assertDisplayedMessage(aboutMessage, folderCMessages[501]);
+
+ goDoCommand("cmd_previousUnreadMsg");
+ assertSelectedMessage(folderCMessages[500]);
+ await assertDisplayedMessage(aboutMessage, folderCMessages[500]);
+
+ threadTree.addEventListener("select", reportBadSelectEvent);
+ messagePaneBrowser.addEventListener("load", reportBadLoad, true);
+ goDoCommand("cmd_previousUnreadMsg");
+ assertSelectedMessage(folderCMessages[500]);
+ await assertDisplayedMessage(aboutMessage, folderCMessages[500]);
+
+ // Wait to prove bad things didn't happen.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 500));
+ threadTree.removeEventListener("select", reportBadSelectEvent);
+ messagePaneBrowser.removeEventListener("load", reportBadLoad, true);
+
+ threadTree.selectedIndex = -1;
+ await assertNoDisplayedMessage(aboutMessage);
+});
+
+async function subtestPreviousUnreadMessage(win, aboutMessage) {
+ const messagePaneBrowser = aboutMessage.getMessagePaneBrowser();
+
+ folderA.markMessagesRead([folderAMessages[1], folderAMessages[3]], false);
+ folderC.markMessagesRead(
+ [folderCMessages[500], folderCMessages[501], folderCMessages[504]],
+ false
+ );
+
+ await assertDisplayedMessage(aboutMessage, folderCMessages[504]);
+
+ win.goDoCommand("cmd_previousUnreadMsg");
+ await assertDisplayedMessage(aboutMessage, folderCMessages[501]);
+
+ win.goDoCommand("cmd_previousUnreadMsg");
+ await assertDisplayedMessage(aboutMessage, folderCMessages[500]);
+
+ messagePaneBrowser.addEventListener("load", reportBadLoad, true);
+ win.goDoCommand("cmd_previousUnreadMsg");
+ await assertDisplayedMessage(aboutMessage, folderCMessages[500]);
+
+ // Wait to prove bad things didn't happen.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 500));
+ messagePaneBrowser.removeEventListener("load", reportBadLoad, true);
+}
+
+/** Tests the previous unread message command in a message tab. */
+add_task(async function testPreviousUnreadMessageInATab() {
+ await withMessageInATab(folderCMessages[504], subtestPreviousUnreadMessage);
+});
+
+/** Tests the previous unread message command in a message window. */
+add_task(async function testPreviousUnreadMessageInAWindow() {
+ await withMessageInAWindow(
+ folderCMessages[504],
+ subtestPreviousUnreadMessage
+ );
+});
+
+/**
+ * Tests the next unread thread command. This command depends on marking the
+ * thread as read, despite mailnews.mark_message_read.auto being false in this
+ * test. Seems wrong, but it does make this test less complicated!
+ */
+add_task(async function testNextUnreadThreadInAbout3Pane() {
+ const aboutMessage = messageBrowser.contentWindow;
+
+ folderC.markMessagesRead(
+ [folderCMessages[500], folderCMessages[501], folderCMessages[504]],
+ false
+ );
+ folderD.markMessagesRead(
+ [
+ folderDMessages[0],
+ folderDMessages[1],
+ folderDMessages[2],
+ folderDMessages[8],
+ folderDMessages[9],
+ folderDMessages[10],
+ folderDMessages[11],
+ ],
+ false
+ );
+
+ // In folder C, there are no threads. Going to the next unread thread is the
+ // same as going to the next unread message. But as stated above, it does
+ // mark the current message as read.
+ about3Pane.displayFolder(folderC.URI);
+ threadTree.scrollToIndex(504, true);
+ // Ensure the scrolling from the previous line happens.
+ await new Promise(resolve => requestAnimationFrame(resolve));
+ threadTree.selectedIndex = 500;
+ assertSelectedMessage(folderCMessages[500]);
+ await assertDisplayedMessage(aboutMessage, folderCMessages[500]);
+
+ goDoCommand("cmd_nextUnreadThread");
+ assertSelectedMessage(folderCMessages[501]);
+ await assertDisplayedMessage(aboutMessage, folderCMessages[501]);
+
+ goDoCommand("cmd_nextUnreadThread");
+ assertSelectedMessage(folderCMessages[504]);
+ await assertDisplayedMessage(aboutMessage, folderCMessages[504]);
+
+ // No more unread messages, we'll move to folder D.
+ let dialogPromise = BrowserTestUtils.promiseAlertDialog("accept");
+ goDoCommand("cmd_nextUnreadThread");
+ await dialogPromise;
+ assertSelectedFolder(folderD);
+ assertSelectedMessage(folderDMessages[0]);
+ await assertDisplayedMessage(aboutMessage, folderDMessages[0]);
+
+ goDoCommand("cmd_nextUnreadThread");
+ // The root message is read, we're looking at a single message in the thread.
+ assertSelectedMessage(folderDMessages[8]);
+ await assertDisplayedMessage(aboutMessage, folderDMessages[8]);
+
+ goDoCommand("cmd_nextUnreadThread");
+ // The root message is unread.
+ assertSelectedMessage(folderDMessages[9]);
+ await assertDisplayedMessage(aboutMessage, folderDMessages[9]);
+
+ // No more unread messages, prompt to move to the next folder.
+ // Cancel the prompt.
+ dialogPromise = BrowserTestUtils.promiseAlertDialog("cancel");
+ goDoCommand("cmd_nextUnreadThread");
+ await dialogPromise;
+ assertSelectedMessage(folderDMessages[9]);
+
+ threadTree.selectedIndex = -1;
+ await assertNoDisplayedMessage(aboutMessage);
+});
+
+async function subtestNextUnreadThread(win, aboutMessage) {
+ folderC.markMessagesRead(
+ [folderCMessages[500], folderCMessages[501], folderCMessages[504]],
+ false
+ );
+ folderD.markMessagesRead(
+ [
+ folderDMessages[0],
+ folderDMessages[1],
+ folderDMessages[2],
+ folderDMessages[8],
+ folderDMessages[9],
+ folderDMessages[10],
+ folderDMessages[11],
+ ],
+ false
+ );
+
+ await assertDisplayedMessage(aboutMessage, folderCMessages[500]);
+
+ win.goDoCommand("cmd_nextUnreadThread");
+ await assertDisplayedMessage(aboutMessage, folderCMessages[501]);
+
+ win.goDoCommand("cmd_nextUnreadThread");
+ await assertDisplayedMessage(aboutMessage, folderCMessages[504]);
+
+ // No more unread messages, we'll move to folder D.
+ let dialogPromise = BrowserTestUtils.promiseAlertDialog("accept");
+ win.goDoCommand("cmd_nextUnreadThread");
+ await dialogPromise;
+ await assertDisplayedMessage(aboutMessage, folderDMessages[0]);
+
+ win.goDoCommand("cmd_nextUnreadThread");
+ // The root message is read, we're looking at a single message in the thread.
+ await assertDisplayedMessage(aboutMessage, folderDMessages[8]);
+
+ win.goDoCommand("cmd_nextUnreadThread");
+ // The root message is unread.
+ await assertDisplayedMessage(aboutMessage, folderDMessages[9]);
+
+ // No more unread messages, prompt to move to the next folder.
+ // Cancel the prompt.
+ dialogPromise = BrowserTestUtils.promiseAlertDialog("cancel");
+ win.goDoCommand("cmd_nextUnreadThread");
+ await dialogPromise;
+}
+
+/** Tests the next unread thread command in a message tab. */
+add_task(async function testNextUnreadThreadInATab() {
+ await withMessageInATab(folderCMessages[500], subtestNextUnreadThread);
+});
+
+/** Tests the next unread thread command in a message window. */
+add_task(async function testNextUnreadThreadInAWindow() {
+ await withMessageInAWindow(folderCMessages[500], subtestNextUnreadThread);
+});
+
+/** Tests that navigation with a closed message pane does not load messages. */
+add_task(async function testHiddenMessagePaneInAbout3Pane() {
+ const aboutMessage = messageBrowser.contentWindow;
+ const messagePaneBrowser = aboutMessage.getMessagePaneBrowser();
+
+ about3Pane.paneLayout.messagePaneVisible = false;
+ about3Pane.displayFolder(folderA.URI);
+ threadTree.selectedIndex = 0;
+ assertSelectedMessage(folderAMessages[0]);
+ await assertNoDisplayedMessage(aboutMessage);
+
+ messagePaneBrowser.addEventListener("load", reportBadLoad, true);
+ goDoCommand("cmd_nextMsg");
+ assertSelectedMessage(folderAMessages[1]);
+ await assertNoDisplayedMessage(aboutMessage);
+
+ goDoCommand("cmd_previousMsg");
+ assertSelectedMessage(folderAMessages[0]);
+ await assertNoDisplayedMessage(aboutMessage);
+
+ // Wait to prove bad things didn't happen.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 500));
+ messagePaneBrowser.removeEventListener("load", reportBadLoad, true);
+
+ threadTree.selectedIndex = -1;
+ about3Pane.paneLayout.messagePaneVisible = true;
+});
+
+/** Tests the go back/forward commands. */
+add_task(async function testMessageHistoryInAbout3Pane() {
+ const aboutMessage = messageBrowser.contentWindow;
+ const { messageHistory } = aboutMessage;
+ messageHistory.clear();
+ about3Pane.displayFolder(folderA.URI);
+ threadTree.selectedIndex = 0;
+ assertSelectedMessage(folderAMessages[0]);
+ await assertDisplayedMessage(aboutMessage, folderAMessages[0]);
+
+ goDoCommand("cmd_nextMsg");
+ assertSelectedMessage(folderAMessages[1]);
+ await assertDisplayedMessage(aboutMessage, folderAMessages[1]);
+
+ Assert.ok(messageHistory.canPop(-1), "Going back should be available");
+ Assert.ok(
+ !messageHistory.canPop(0),
+ "Should not be able to go back to the current message"
+ );
+ Assert.ok(
+ !messageHistory.canPop(1),
+ "Should not have any message to go forward to"
+ );
+ Assert.ok(
+ !window.getEnabledControllerForCommand("cmd_goForward"),
+ "Go forward should be disabled"
+ );
+
+ goDoCommand("cmd_goBack");
+ assertSelectedMessage(folderAMessages[0]);
+ await assertDisplayedMessage(aboutMessage, folderAMessages[0]);
+
+ Assert.ok(!messageHistory.canPop(-1), "Should have no message to go back to");
+ Assert.ok(messageHistory.canPop(1), "Should have a message to go forward to");
+ Assert.ok(
+ !window.getEnabledControllerForCommand("cmd_goBack"),
+ "Go back should be disabled"
+ );
+
+ goDoCommand("cmd_goForward");
+ assertSelectedMessage(folderAMessages[1]);
+ await assertDisplayedMessage(aboutMessage, folderAMessages[1]);
+
+ Assert.ok(messageHistory.canPop(-1), "Should have a message to go back to");
+ Assert.ok(
+ !messageHistory.canPop(1),
+ "Should have no message to go forward to"
+ );
+ Assert.ok(
+ window.getEnabledControllerForCommand("cmd_goBack"),
+ "Go back should be enabled"
+ );
+
+ // Switching folder to test going back/forward between folders.
+ about3Pane.displayFolder(folderB.URI);
+ threadTree.selectedIndex = 0;
+ assertSelectedMessage(folderBMessages[0]);
+ await assertDisplayedMessage(aboutMessage, folderBMessages[0]);
+
+ Assert.ok(messageHistory.canPop(-1), "Should have a message to go back to");
+ Assert.ok(
+ !messageHistory.canPop(1),
+ "Should have no message to go forward to"
+ );
+ Assert.ok(
+ !window.getEnabledControllerForCommand("cmd_goForward"),
+ "Go forward should be disabled"
+ );
+
+ goDoCommand("cmd_goBack");
+
+ assertSelectedFolder(folderA);
+ assertSelectedMessage(folderAMessages[1]);
+ await assertDisplayedMessage(aboutMessage, folderAMessages[1]);
+
+ Assert.ok(messageHistory.canPop(-1), "Should have a message to go back to");
+ Assert.ok(messageHistory.canPop(1), "Should have a message to go forward to");
+ Assert.ok(
+ window.getEnabledControllerForCommand("cmd_goBack"),
+ "Go back should be enabled"
+ );
+
+ goDoCommand("cmd_goForward");
+
+ assertSelectedFolder(folderB);
+ assertSelectedMessage(folderBMessages[0]);
+ await assertDisplayedMessage(aboutMessage, folderBMessages[0]);
+
+ Assert.ok(messageHistory.canPop(-1), "Should have a message to go back to");
+ Assert.ok(
+ !messageHistory.canPop(1),
+ "Should have no message to go forward to"
+ );
+ Assert.ok(
+ !window.getEnabledControllerForCommand("cmd_goForward"),
+ "Go forward should be disabled"
+ );
+
+ goDoCommand("cmd_goBack");
+
+ assertSelectedFolder(folderA);
+ assertSelectedMessage(folderAMessages[1]);
+ await assertDisplayedMessage(aboutMessage, folderAMessages[1]);
+
+ Assert.ok(messageHistory.canPop(-1), "Should have a message to go back to");
+ Assert.ok(messageHistory.canPop(1), "Should have a message to go forward to");
+ Assert.ok(
+ window.getEnabledControllerForCommand("cmd_goBack"),
+ "Go back should be enabled"
+ );
+
+ // Select a different message while going forward is possible, clearing the
+ // previous forward history.
+
+ goDoCommand("cmd_nextMsg");
+
+ assertSelectedMessage(folderAMessages[2]);
+ await assertDisplayedMessage(aboutMessage, folderAMessages[2]);
+
+ Assert.ok(messageHistory.canPop(-1), "Should have a message to go back to");
+ Assert.ok(
+ !messageHistory.canPop(1),
+ "Should have no message to go forward to"
+ );
+ Assert.ok(
+ !window.getEnabledControllerForCommand("cmd_goForward"),
+ "Go forward should be disabled"
+ );
+
+ goDoCommand("cmd_goBack");
+
+ assertSelectedMessage(folderAMessages[1]);
+ await assertDisplayedMessage(aboutMessage, folderAMessages[1]);
+
+ Assert.ok(messageHistory.canPop(-1), "Should have a message to go back to");
+ Assert.ok(messageHistory.canPop(1), "Should have a message to go forward to");
+ Assert.ok(
+ window.getEnabledControllerForCommand("cmd_goBack"),
+ "Go back should be enabled"
+ );
+
+ // Remove the previous message in the history from the folder it was
+ // displayed in.
+
+ let movedMessage = folderAMessages[0];
+ await moveMessage(folderA, movedMessage, folderB);
+
+ Assert.ok(!messageHistory.canPop(-1), "Should have no message to go back to");
+ Assert.ok(
+ !window.getEnabledControllerForCommand("cmd_goBack"),
+ "Go back should be disabled"
+ );
+
+ // Display no message, so going back goes to the previously displayed message,
+ // which is also the current history entry.
+ threadTree.selectedIndex = -1;
+ await assertNoDisplayedMessage(aboutMessage);
+
+ Assert.ok(
+ messageHistory.canPop(0),
+ "Can go back to current history entry without selected message"
+ );
+ Assert.ok(
+ window.getEnabledControllerForCommand("cmd_goForward"),
+ "Go forward should be enabled"
+ );
+
+ goDoCommand("cmd_goBack");
+
+ assertSelectedMessage(folderAMessages[1]);
+ await assertDisplayedMessage(aboutMessage, folderAMessages[1]);
+
+ threadTree.selectedIndex = -1;
+ let currentFolderBMessages = [...folderB.messages];
+ movedMessage = currentFolderBMessages.find(
+ message => !folderBMessages.includes(message)
+ );
+ await moveMessage(folderB, movedMessage, folderA);
+ folderAMessages = [...folderA.messages];
+});
+
+async function subtestMessageHistory(win, aboutMessage) {
+ const { messageHistory } = aboutMessage;
+ await assertDisplayedMessage(aboutMessage, folderAMessages[0]);
+
+ Assert.ok(win.getEnabledControllerForCommand("cmd_nextMsg"));
+ win.goDoCommand("cmd_nextMsg");
+ await assertDisplayedMessage(aboutMessage, folderAMessages[1]);
+
+ Assert.ok(messageHistory.canPop(-1), "Going back should be available");
+ Assert.ok(
+ !messageHistory.canPop(0),
+ "Should not be able to go back to the current message"
+ );
+ Assert.ok(
+ !messageHistory.canPop(1),
+ "Should not have any message to go forward to"
+ );
+ Assert.ok(
+ !win.getEnabledControllerForCommand("cmd_goForward"),
+ "Go forward should be disabled"
+ );
+
+ Assert.ok(win.getEnabledControllerForCommand("cmd_goBack"));
+ win.goDoCommand("cmd_goBack");
+ await assertDisplayedMessage(aboutMessage, folderAMessages[0]);
+
+ Assert.ok(!messageHistory.canPop(-1), "Should have no message to go back to");
+ Assert.ok(messageHistory.canPop(1), "Should have a message to go forward to");
+ Assert.ok(
+ !win.getEnabledControllerForCommand("cmd_goBack"),
+ "Go back should be disabled"
+ );
+
+ Assert.ok(win.getEnabledControllerForCommand("cmd_goForward"));
+ win.goDoCommand("cmd_goForward");
+ await assertDisplayedMessage(aboutMessage, folderAMessages[1]);
+
+ Assert.ok(messageHistory.canPop(-1), "Should have a message to go back to");
+ Assert.ok(
+ !messageHistory.canPop(1),
+ "Should have no message to go forward to"
+ );
+ Assert.ok(
+ win.getEnabledControllerForCommand("cmd_goBack"),
+ "Go back should be enabled"
+ );
+}
+
+/** Tests the go back/forward commands in a message tab. */
+add_task(async function testMessageHistoryInATab() {
+ await withMessageInATab(folderAMessages[0], subtestMessageHistory);
+});
+
+/** Tests the go back/forward commands in a message window. */
+add_task(async function testMessageHistoryInAWindow() {
+ await withMessageInAWindow(folderAMessages[0], subtestMessageHistory);
+});
+
+function assertSelectedFolder(expected) {
+ Assert.equal(about3Pane.gFolder.URI, expected.URI, "selected folder");
+}
+
+function assertSelectedMessage(expected, comment) {
+ if (expected) {
+ Assert.notEqual(
+ threadTree.selectedIndex,
+ -1,
+ "a message should be selected"
+ );
+ Assert.ok(
+ threadTree.getRowAtIndex(threadTree.selectedIndex),
+ "row for selected message should exist and be in view"
+ );
+ Assert.equal(
+ about3Pane.gDBView.getMsgHdrAt(threadTree.selectedIndex).messageId,
+ expected.messageId,
+ comment ?? "selected message"
+ );
+ } else {
+ Assert.equal(threadTree.selectedIndex, -1, "no message should be selected");
+ }
+}
+
+async function assertDisplayedMessage(aboutMessage, expected) {
+ const messagePaneBrowser = aboutMessage.getMessagePaneBrowser();
+ let mailboxURL = expected.folder.getUriForMsg(expected);
+ let messageURI = mailboxService.getUrlForUri(mailboxURL);
+
+ if (
+ messagePaneBrowser.webProgess?.isLoadingDocument ||
+ !messagePaneBrowser.currentURI.equals(messageURI)
+ ) {
+ await BrowserTestUtils.browserLoaded(
+ messagePaneBrowser,
+ undefined,
+ messageURI.spec
+ );
+ }
+ Assert.equal(
+ aboutMessage.gMessage.messageId,
+ expected.messageId,
+ "correct message loaded"
+ );
+ Assert.equal(
+ messagePaneBrowser.currentURI.spec,
+ messageURI.spec,
+ "correct message displayed"
+ );
+}
+
+async function assertDisplayedThread(firstMessage) {
+ let items = multiMessageBrowser.contentDocument.querySelectorAll("li");
+ Assert.equal(
+ items[0].dataset.messageId,
+ firstMessage.messageId,
+ "correct thread displayed"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_visible(multiMessageBrowser),
+ "multimessageview visible"
+ );
+}
+
+async function assertNoDisplayedMessage(aboutMessage) {
+ const messagePaneBrowser = aboutMessage.getMessagePaneBrowser();
+ if (
+ messagePaneBrowser.webProgess?.isLoadingDocument ||
+ messagePaneBrowser.currentURI.spec != "about:blank"
+ ) {
+ await BrowserTestUtils.browserLoaded(
+ messagePaneBrowser,
+ undefined,
+ "about:blank"
+ );
+ }
+
+ Assert.equal(aboutMessage.gMessage, null, "no message loaded");
+ Assert.equal(
+ messagePaneBrowser.currentURI.spec,
+ "about:blank",
+ "no message displayed"
+ );
+ Assert.ok(BrowserTestUtils.is_hidden(messageBrowser), "about:message hidden");
+}
+
+function reportBadSelectEvent() {
+ Assert.report(
+ true,
+ undefined,
+ undefined,
+ "should not have fired a select event"
+ );
+}
+
+function reportBadLoad() {
+ Assert.report(
+ true,
+ undefined,
+ undefined,
+ "should not have reloaded the message"
+ );
+}
+
+function moveMessage(sourceFolder, message, targetFolder) {
+ let copyListener = new PromiseTestUtils.PromiseCopyListener();
+ MailServices.copy.copyMessages(
+ sourceFolder,
+ [message],
+ targetFolder,
+ true,
+ copyListener,
+ window.msgWindow,
+ true
+ );
+ return copyListener.promise;
+}
+
+async function withMessageInATab(message, subtest) {
+ let tabPromise = BrowserTestUtils.waitForEvent(window, "MsgLoaded");
+ window.OpenMessageInNewTab(message, { background: false });
+ await tabPromise;
+ await new Promise(resolve => setTimeout(resolve));
+
+ await subtest(window, tabmail.currentAboutMessage);
+
+ tabmail.closeOtherTabs(0);
+}
+
+async function withMessageInAWindow(message, subtest) {
+ let winPromise = BrowserTestUtils.domWindowOpenedAndLoaded();
+ window.MsgOpenNewWindowForMessage(message);
+ let win = await winPromise;
+ await BrowserTestUtils.waitForEvent(win, "MsgLoaded");
+ await TestUtils.waitForCondition(() => Services.focus.activeWindow == win);
+
+ await subtest(win, win.messageBrowser.contentWindow);
+
+ await BrowserTestUtils.closeWindow(win);
+}
diff --git a/comm/mail/base/test/browser/browser_orderableTreeListbox.js b/comm/mail/base/test/browser/browser_orderableTreeListbox.js
new file mode 100644
index 0000000000..54634cbd88
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_orderableTreeListbox.js
@@ -0,0 +1,481 @@
+/* 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/. */
+
+/* eslint mozilla/no-arbitrary-setTimeout: off */
+
+let dragService = Cc["@mozilla.org/widget/dragservice;1"].getService(
+ Ci.nsIDragService
+);
+
+let WAIT_TIME = 0;
+
+let tabmail = document.getElementById("tabmail");
+registerCleanupFunction(() => {
+ tabmail.closeOtherTabs(tabmail.tabInfo[0]);
+ Services.prefs.clearUserPref("ui.prefersReducedMotion");
+ Services.prefs.clearUserPref("mailnews.default_view_flags");
+});
+
+async function withMotion(subtest) {
+ Services.prefs.setIntPref("ui.prefersReducedMotion", 0);
+ WAIT_TIME = 300;
+ await TestUtils.waitForCondition(
+ () => !matchMedia("(prefers-reduced-motion)").matches
+ );
+ return subtest();
+}
+
+async function withoutMotion(subtest) {
+ Services.prefs.setIntPref("ui.prefersReducedMotion", 1);
+ WAIT_TIME = 0;
+ await TestUtils.waitForCondition(
+ () => matchMedia("(prefers-reduced-motion)").matches
+ );
+ await subtest();
+}
+
+let win, doc, list, dataTransfer;
+
+async function orderWithKeys(key) {
+ selectHandler.reset();
+ orderedHandler.reset();
+
+ list.addEventListener("select", selectHandler);
+ list.addEventListener("ordered", orderedHandler);
+ EventUtils.synthesizeKey(key, { altKey: true }, win);
+ await new Promise(resolve => win.setTimeout(resolve, WAIT_TIME));
+ list.removeEventListener("select", selectHandler);
+ list.removeEventListener("ordered", orderedHandler);
+
+ await checkNoTransformations();
+}
+
+async function startDrag(index) {
+ let listRect = list.getBoundingClientRect();
+ let clientY = listRect.top + index * 32 + 4;
+
+ dragService.startDragSessionForTests(Ci.nsIDragService.DRAGDROP_ACTION_NONE);
+ [, dataTransfer] = EventUtils.synthesizeDragOver(
+ list.rows[index],
+ list,
+ null,
+ null,
+ win,
+ win,
+ {
+ clientY,
+ _domDispatchOnly: true,
+ }
+ );
+
+ await new Promise(resolve => setTimeout(resolve, WAIT_TIME));
+}
+
+async function continueDrag(index) {
+ let listRect = list.getBoundingClientRect();
+ let destClientX = listRect.left + listRect.width / 2;
+ let destClientY = listRect.top + index * 32 + 4;
+ let destScreenX = win.mozInnerScreenX + destClientX;
+ let destScreenY = win.mozInnerScreenY + destClientY;
+
+ let result = EventUtils.sendDragEvent(
+ {
+ type: "dragover",
+ screenX: destScreenX,
+ screenY: destScreenY,
+ clientX: destClientX,
+ clientY: destClientY,
+ dataTransfer,
+ _domDispatchOnly: true,
+ },
+ list,
+ win
+ );
+
+ await new Promise(resolve => setTimeout(resolve, WAIT_TIME));
+ return result;
+}
+
+async function endDrag(index) {
+ let listRect = list.getBoundingClientRect();
+ let clientY = listRect.top + index * 32 + 4;
+
+ EventUtils.synthesizeDropAfterDragOver(false, dataTransfer, list, win, {
+ clientY,
+ _domDispatchOnly: true,
+ });
+ list.dispatchEvent(new CustomEvent("dragend", { bubbles: true }));
+ dragService.endDragSession(true);
+
+ await new Promise(resolve => setTimeout(resolve, WAIT_TIME));
+}
+
+function checkRowOrder(expectedOrder) {
+ expectedOrder = expectedOrder.split(" ").map(i => `row-${i}`);
+ Assert.equal(list.rowCount, expectedOrder.length, "rowCount is correct");
+ Assert.deepEqual(
+ list.rows.map(row => row.id),
+ expectedOrder,
+ "order in DOM is correct"
+ );
+
+ let apparentOrder = list.rows.sort(
+ (a, b) => a.getBoundingClientRect().top - b.getBoundingClientRect().top
+ );
+ Assert.deepEqual(
+ apparentOrder.map(row => row.id),
+ expectedOrder,
+ "order on screen is correct"
+ );
+
+ if (orderedHandler.orderAtEvent) {
+ Assert.deepEqual(
+ orderedHandler.orderAtEvent,
+ expectedOrder,
+ "order at the last 'ordered' event was correct"
+ );
+ }
+}
+
+function checkYPositions(...expectedPositions) {
+ let offset = list.getBoundingClientRect().top;
+
+ for (let i = 0; i < 5; i++) {
+ let id = `row-${i + 1}`;
+ let row = doc.getElementById(id);
+ Assert.equal(
+ row.getBoundingClientRect().top - offset,
+ expectedPositions[i],
+ id
+ );
+ }
+}
+
+async function checkNoTransformations() {
+ for (let row of list.children) {
+ await TestUtils.waitForCondition(
+ () => win.getComputedStyle(row).transform == "none",
+ `${row.id} has no transforms`
+ );
+ Assert.equal(
+ row
+ .getAnimations()
+ .filter(animation => animation.transitionProperty != "opacity").length,
+ 0,
+ `${row.id} has no animations`
+ );
+ }
+}
+
+let selectHandler = {
+ seenEvent: null,
+
+ reset() {
+ this.seenEvent = null;
+ },
+ handleEvent(event) {
+ this.seenEvent = event;
+ },
+};
+
+let orderedHandler = {
+ seenEvent: null,
+ orderAtEvent: null,
+
+ reset() {
+ this.seenEvent = null;
+ this.orderAtEvent = null;
+ },
+ handleEvent(event) {
+ if (this.seenEvent) {
+ throw new Error("we already have an 'ordered' event");
+ }
+ this.seenEvent = event;
+ this.orderAtEvent = list.rows.map(row => row.id);
+ },
+};
+
+/** Test Alt+Up and Alt+Down. */
+async function subtestKeyReorder() {
+ list.focus();
+ list.selectedIndex = 0;
+
+ // Move row 1 down the list to the bottom.
+
+ await orderWithKeys("KEY_ArrowDown");
+ Assert.ok(orderedHandler.seenEvent);
+ checkRowOrder("2 2-1 2-2 1 3 3-1 3-2 3-3 4 5 5-1 5-2");
+
+ // Some additional checks to prove the right row is selected.
+
+ Assert.ok(!selectHandler.seenEvent);
+ Assert.equal(list.selectedIndex, 3, "correct index is selected");
+ Assert.equal(
+ list.querySelector(".selected").id,
+ "row-1",
+ "correct row is selected"
+ );
+
+ EventUtils.synthesizeKey("KEY_ArrowUp", {}, win);
+ Assert.equal(
+ list.querySelector(".selected").id,
+ "row-2-2",
+ "key press moved to the correct row"
+ );
+ EventUtils.synthesizeKey("KEY_ArrowDown", {}, win);
+
+ await orderWithKeys("KEY_ArrowDown");
+ Assert.ok(orderedHandler.seenEvent);
+ checkRowOrder("2 2-1 2-2 3 3-1 3-2 3-3 1 4 5 5-1 5-2");
+
+ await orderWithKeys("KEY_ArrowDown");
+ Assert.ok(orderedHandler.seenEvent);
+ checkRowOrder("2 2-1 2-2 3 3-1 3-2 3-3 4 1 5 5-1 5-2");
+
+ await orderWithKeys("KEY_ArrowDown");
+ Assert.ok(orderedHandler.seenEvent);
+ checkRowOrder("2 2-1 2-2 3 3-1 3-2 3-3 4 5 5-1 5-2 1");
+
+ // Move row 1 back to the top.
+
+ await orderWithKeys("KEY_ArrowUp");
+ Assert.ok(orderedHandler.seenEvent);
+ checkRowOrder("2 2-1 2-2 3 3-1 3-2 3-3 4 1 5 5-1 5-2");
+
+ await orderWithKeys("KEY_ArrowUp");
+ Assert.ok(orderedHandler.seenEvent);
+ checkRowOrder("2 2-1 2-2 3 3-1 3-2 3-3 1 4 5 5-1 5-2");
+
+ await orderWithKeys("KEY_ArrowUp");
+ Assert.ok(orderedHandler.seenEvent);
+ checkRowOrder("2 2-1 2-2 1 3 3-1 3-2 3-3 4 5 5-1 5-2");
+
+ await orderWithKeys("KEY_ArrowUp");
+ Assert.ok(orderedHandler.seenEvent);
+ checkRowOrder("1 2 2-1 2-2 3 3-1 3-2 3-3 4 5 5-1 5-2");
+
+ // Move row 3 around. Row 3 has children, so we're checking they move with it.
+
+ list.selectedIndex = 4;
+
+ await orderWithKeys("KEY_ArrowUp");
+ Assert.ok(orderedHandler.seenEvent);
+ checkRowOrder("1 3 3-1 3-2 3-3 2 2-1 2-2 4 5 5-1 5-2");
+
+ await orderWithKeys("KEY_ArrowUp");
+ Assert.ok(orderedHandler.seenEvent);
+ checkRowOrder("3 3-1 3-2 3-3 1 2 2-1 2-2 4 5 5-1 5-2");
+
+ await orderWithKeys("KEY_ArrowDown");
+ Assert.ok(orderedHandler.seenEvent);
+ checkRowOrder("1 3 3-1 3-2 3-3 2 2-1 2-2 4 5 5-1 5-2");
+
+ await orderWithKeys("KEY_ArrowDown");
+ Assert.ok(orderedHandler.seenEvent);
+ checkRowOrder("1 2 2-1 2-2 3 3-1 3-2 3-3 4 5 5-1 5-2");
+
+ await orderWithKeys("KEY_ArrowDown");
+ Assert.ok(orderedHandler.seenEvent);
+ checkRowOrder("1 2 2-1 2-2 4 3 3-1 3-2 3-3 5 5-1 5-2");
+
+ await orderWithKeys("KEY_ArrowDown");
+ Assert.ok(orderedHandler.seenEvent);
+ checkRowOrder("1 2 2-1 2-2 4 5 5-1 5-2 3 3-1 3-2 3-3");
+
+ await orderWithKeys("KEY_ArrowUp");
+ Assert.ok(orderedHandler.seenEvent);
+ checkRowOrder("1 2 2-1 2-2 4 3 3-1 3-2 3-3 5 5-1 5-2");
+
+ await orderWithKeys("KEY_ArrowUp");
+ Assert.ok(orderedHandler.seenEvent);
+ checkRowOrder("1 2 2-1 2-2 3 3-1 3-2 3-3 4 5 5-1 5-2");
+}
+
+/** Drag the first item to the end. */
+async function subtestDragReorder1() {
+ orderedHandler.reset();
+ list.addEventListener("ordered", orderedHandler);
+
+ checkYPositions(1, 33, 129, 257, 289);
+
+ await startDrag(0);
+ checkYPositions(1, 33, 129, 257, 289);
+
+ await continueDrag(2);
+ checkYPositions(52, 1, 129, 257, 289);
+ await continueDrag(3);
+ checkYPositions(84, 1, 129, 257, 289);
+ await continueDrag(4);
+ checkYPositions(116, 1, 129, 257, 289);
+ await continueDrag(5);
+ checkYPositions(148, 1, 129, 257, 289);
+ await continueDrag(6);
+ checkYPositions(180, 1, 97, 257, 289);
+ await continueDrag(7);
+ checkYPositions(212, 1, 97, 257, 289);
+ await continueDrag(8);
+ checkYPositions(244, 1, 97, 225, 289);
+ await continueDrag(9);
+ checkYPositions(276, 1, 97, 225, 289);
+ await continueDrag(10);
+ checkYPositions(308, 1, 97, 225, 257);
+ await continueDrag(11);
+ checkYPositions(340, 1, 97, 225, 257);
+ await continueDrag(12);
+ checkYPositions(353, 1, 97, 225, 257);
+
+ await endDrag(12);
+ list.removeEventListener("ordered", orderedHandler);
+
+ Assert.ok(orderedHandler.seenEvent);
+ checkYPositions(353, 1, 97, 225, 257);
+ checkRowOrder("2 2-1 2-2 3 3-1 3-2 3-3 4 5 5-1 5-2 1");
+ await checkNoTransformations();
+}
+
+/** Drag the (now) last item back to the start. */
+async function subtestDragReorder2() {
+ orderedHandler.reset();
+ list.addEventListener("ordered", orderedHandler);
+
+ await startDrag(11);
+ checkYPositions(340, 1, 97, 225, 257);
+
+ await continueDrag(9);
+ checkYPositions(276, 1, 97, 225, 289);
+
+ await continueDrag(7);
+ checkYPositions(212, 1, 97, 257, 289);
+
+ await continueDrag(4);
+ checkYPositions(116, 1, 129, 257, 289);
+
+ await continueDrag(1);
+ checkYPositions(20, 33, 129, 257, 289);
+
+ await endDrag(0);
+ list.removeEventListener("ordered", orderedHandler);
+
+ Assert.ok(orderedHandler.seenEvent);
+ checkYPositions(1, 33, 129, 257, 289);
+ checkRowOrder("1 2 2-1 2-2 3 3-1 3-2 3-3 4 5 5-1 5-2");
+ await checkNoTransformations();
+}
+
+/**
+ * Listen for the 'ordering' event and prevent dropping on some rows.
+ *
+ * In this test, we'll prevent dragging an item below the last one - row-5 and
+ * its descendants. Other use cases may be possible but haven't been needed
+ * yet, so they are untested.
+ */
+async function subtestDragUndroppable() {
+ let originalGetter = list.__lookupGetter__("_orderableChildren");
+ list.__defineGetter__("_orderableChildren", function () {
+ let rows = [...this.children];
+ rows.pop();
+ return rows;
+ });
+
+ orderedHandler.reset();
+ list.addEventListener("ordered", orderedHandler);
+
+ checkYPositions(1, 33, 129, 257, 289);
+
+ await startDrag(0);
+ checkYPositions(1, 33, 129, 257, 289);
+
+ await continueDrag(8);
+ checkYPositions(244, 1, 97, 225, 289);
+ await continueDrag(9);
+ checkYPositions(257, 1, 97, 225, 289);
+ await continueDrag(10);
+ checkYPositions(257, 1, 97, 225, 289);
+ await continueDrag(11);
+ checkYPositions(257, 1, 97, 225, 289);
+ await continueDrag(12);
+ checkYPositions(257, 1, 97, 225, 289);
+
+ await endDrag(12);
+ list.removeEventListener("ordered", orderedHandler);
+
+ Assert.ok(orderedHandler.seenEvent);
+ checkYPositions(257, 1, 97, 225, 289);
+ checkRowOrder("2 2-1 2-2 3 3-1 3-2 3-3 4 1 5 5-1 5-2");
+ await checkNoTransformations();
+
+ // Move row-3 down with the keyboard.
+
+ list.selectedIndex = 7;
+ await orderWithKeys("KEY_ArrowDown");
+ Assert.ok(orderedHandler.seenEvent);
+ checkRowOrder("2 2-1 2-2 3 3-1 3-2 3-3 1 4 5 5-1 5-2");
+
+ // It should not move further down.
+
+ await orderWithKeys("KEY_ArrowDown");
+ Assert.ok(!orderedHandler.seenEvent);
+ checkRowOrder("2 2-1 2-2 3 3-1 3-2 3-3 1 4 5 5-1 5-2");
+
+ // Reset the order.
+
+ await orderWithKeys("KEY_ArrowUp");
+ Assert.ok(orderedHandler.seenEvent);
+ checkRowOrder("2 2-1 2-2 3 3-1 3-2 3-3 4 1 5 5-1 5-2");
+
+ orderedHandler.reset();
+ await startDrag(8);
+ await continueDrag(1);
+ await endDrag(1);
+ checkRowOrder("1 2 2-1 2-2 3 3-1 3-2 3-3 4 5 5-1 5-2");
+
+ list.__defineGetter__("_orderableChildren", originalGetter);
+}
+
+add_setup(async function () {
+ // Make sure the whole test runs with an unthreaded view in all folders.
+ Services.prefs.setIntPref("mailnews.default_view_flags", 0);
+
+ let tab = tabmail.openTab("contentTab", {
+ url: "chrome://mochitests/content/browser/comm/mail/base/test/browser/files/orderableTreeListbox.xhtml",
+ });
+
+ await BrowserTestUtils.browserLoaded(tab.browser);
+ tab.browser.focus();
+
+ win = tab.browser.contentWindow;
+ doc = win.document;
+
+ list = doc.querySelector(`ol[is="orderable-tree-listbox"]`);
+ Assert.ok(!!list, "the list exists");
+
+ checkRowOrder("1 2 2-1 2-2 3 3-1 3-2 3-3 4 5 5-1 5-2");
+ Assert.equal(list.selectedIndex, 0, "selectedIndex is set to 0");
+});
+
+add_task(async function testKeyReorder() {
+ await withMotion(subtestKeyReorder);
+});
+add_task(async function testDragReorder1() {
+ await withMotion(subtestDragReorder1);
+});
+add_task(async function testDragReorder2() {
+ await withMotion(subtestDragReorder2);
+});
+add_task(async function testDragUndroppable() {
+ await withMotion(subtestDragUndroppable);
+});
+
+add_task(async function testKeyReorderReducedMotion() {
+ await withoutMotion(subtestKeyReorder);
+});
+add_task(async function testDragReorder1ReducedMotion() {
+ await withoutMotion(subtestDragReorder1);
+});
+add_task(async function testDragReorder2ReducedMotion() {
+ await withoutMotion(subtestDragReorder2);
+});
+add_task(async function testDragUndroppableReducedMotion() {
+ await withoutMotion(subtestDragUndroppable);
+});
diff --git a/comm/mail/base/test/browser/browser_paneFocus.js b/comm/mail/base/test/browser/browser_paneFocus.js
new file mode 100644
index 0000000000..9b85b4afe3
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_paneFocus.js
@@ -0,0 +1,375 @@
+/* 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 { MessageGenerator } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MessageGenerator.jsm"
+);
+
+let mailButton = document.getElementById("mailButton");
+let globalSearch = document.querySelector("#unifiedToolbar global-search-bar");
+let addressBookButton = document.getElementById("addressBookButton");
+let calendarButton = document.getElementById("calendarButton");
+let tasksButton = document.getElementById("tasksButton");
+let tabmail = document.getElementById("tabmail");
+
+let rootFolder, testFolder, testMessages, addressBook;
+
+add_setup(async function () {
+ let generator = new MessageGenerator();
+
+ MailServices.accounts.createLocalMailAccount();
+ let account = MailServices.accounts.accounts[0];
+ account.addIdentity(MailServices.accounts.createIdentity());
+ rootFolder = account.incomingServer.rootFolder;
+
+ // Quick Filter Bar needs to be toggled on for F6 focus shift to be accurate.
+ goDoCommand("cmd_showQuickFilterBar");
+
+ rootFolder.createSubfolder("paneFocus", null);
+ testFolder = rootFolder
+ .getChildNamed("paneFocus")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+ testFolder.addMessageBatch(
+ generator.makeMessages({ count: 5 }).map(message => message.toMboxString())
+ );
+ testMessages = [...testFolder.messages];
+
+ let prefName = MailServices.ab.newAddressBook(
+ "paneFocus",
+ null,
+ Ci.nsIAbManager.JS_DIRECTORY_TYPE
+ );
+ addressBook = MailServices.ab.getDirectoryFromId(prefName);
+ let contact = Cc["@mozilla.org/addressbook/cardproperty;1"].createInstance(
+ Ci.nsIAbCard
+ );
+ contact.displayName = "contact 1";
+ contact.firstName = "contact";
+ contact.lastName = "1";
+ contact.primaryEmail = "contact.1@invalid";
+ addressBook.addCard(contact);
+
+ registerCleanupFunction(async () => {
+ MailServices.accounts.removeAccount(account, false);
+ let removePromise = TestUtils.topicObserved("addrbook-directory-deleted");
+ MailServices.ab.deleteAddressBook(addressBook.URI);
+ await removePromise;
+ });
+});
+
+add_task(async function testMail3PaneTab() {
+ document.body.focus();
+
+ let about3Pane = tabmail.currentAbout3Pane;
+ about3Pane.restoreState({
+ folderPaneVisible: true,
+ messagePaneVisible: true,
+ });
+ let {
+ folderTree,
+ threadTree,
+ webBrowser,
+ messageBrowser,
+ multiMessageBrowser,
+ accountCentralBrowser,
+ } = about3Pane;
+
+ // Reset focus to accountCentralBrowser because QFB was toggled on.
+ accountCentralBrowser.focus();
+ info("Displaying the root folder");
+ about3Pane.displayFolder(rootFolder.URI);
+ cycle(
+ mailButton,
+ globalSearch,
+ folderTree,
+ accountCentralBrowser,
+ mailButton
+ );
+
+ info("Displaying the test folder");
+ about3Pane.displayFolder(testFolder.URI);
+ threadTree.selectedIndex = 0;
+ cycle(
+ globalSearch,
+ folderTree,
+ threadTree.table.body,
+ messageBrowser.contentWindow.getMessagePaneBrowser(),
+ mailButton,
+ globalSearch
+ );
+
+ info("Hiding the folder pane");
+ about3Pane.restoreState({ folderPaneVisible: false });
+ cycle(
+ threadTree.table.body,
+ messageBrowser.contentWindow.getMessagePaneBrowser(),
+ mailButton,
+ globalSearch,
+ threadTree.table.body
+ );
+
+ info("Showing the folder pane, hiding the message pane");
+ about3Pane.restoreState({
+ folderPaneVisible: true,
+ messagePaneVisible: false,
+ });
+ cycle(
+ mailButton,
+ globalSearch,
+ folderTree,
+ threadTree.table.body,
+ mailButton
+ );
+
+ info("Showing the message pane, selecting multiple messages");
+ about3Pane.restoreState({ messagePaneVisible: true });
+ threadTree.selectedIndices = [1, 2];
+ cycle(
+ globalSearch,
+ folderTree,
+ threadTree.table.body,
+ multiMessageBrowser,
+ mailButton,
+ globalSearch
+ );
+
+ info("Showing a web page");
+ about3Pane.messagePane.displayWebPage("https://example.com/");
+ cycle(
+ folderTree,
+ threadTree.table.body,
+ webBrowser,
+ mailButton,
+ globalSearch,
+ folderTree
+ );
+
+ info("Testing focus from secondary focus targets");
+ about3Pane.document.getElementById("folderPaneMoreButton").focus();
+ EventUtils.synthesizeKey("KEY_F6", {}, about3Pane);
+ Assert.equal(
+ getActiveElement(),
+ folderTree,
+ "F6 moved the focus to the folder tree"
+ );
+
+ about3Pane.document.getElementById("folderPaneMoreButton").focus();
+ EventUtils.synthesizeKey("KEY_F6", { shiftKey: true }, about3Pane);
+ Assert.equal(
+ getActiveElement().id,
+ globalSearch.id,
+ "Shift+F6 moved the focus to the toolbar"
+ );
+
+ about3Pane.document.getElementById("qfb-qs-textbox").focus();
+ EventUtils.synthesizeKey("KEY_F6", {}, about3Pane);
+ Assert.equal(
+ getActiveElement(),
+ threadTree.table.body,
+ "F6 moved the focus to the threadTree"
+ );
+
+ about3Pane.document.getElementById("qfb-qs-textbox").focus();
+ EventUtils.synthesizeKey("KEY_F6", { shiftKey: true }, about3Pane);
+ Assert.equal(
+ getActiveElement(),
+ folderTree,
+ "Shift+F6 moved the focus to the folder tree"
+ );
+});
+
+add_task(async function testMailMessageTab() {
+ document.body.focus();
+
+ window.OpenMessageInNewTab(testMessages[0], { background: false });
+ await BrowserTestUtils.waitForEvent(
+ tabmail.tabInfo[1].chromeBrowser,
+ "MsgLoaded"
+ );
+ cycle(mailButton, globalSearch, tabmail.tabInfo[1].browser, mailButton);
+
+ tabmail.closeOtherTabs(0);
+});
+
+add_task(async function testAddressBookTab() {
+ EventUtils.synthesizeMouseAtCenter(addressBookButton, {});
+ await BrowserTestUtils.browserLoaded(tabmail.currentTabInfo.browser);
+
+ let abWindow = tabmail.currentTabInfo.browser.contentWindow;
+ let abDocument = abWindow.document;
+ let booksList = abDocument.getElementById("books");
+ let searchInput = abDocument.getElementById("searchInput");
+ let cardsList = abDocument.getElementById("cards");
+ let detailsPane = abDocument.getElementById("detailsPane");
+ let editButton = abDocument.getElementById("editButton");
+
+ // Switch to the table view so the edit button isn't falling off the window.
+ abWindow.cardsPane.toggleLayout(true);
+
+ // Check what happens with a contact selected.
+ let row = booksList.getRowForUID(addressBook.UID);
+ EventUtils.synthesizeMouseAtCenter(row.querySelector("span"), {}, abWindow);
+
+ Assert.ok(BrowserTestUtils.is_hidden(detailsPane));
+ // NOTE: When the "cards" element first receives focus it will select the
+ // first item, which causes the panel to be displayed.
+ cycle(
+ searchInput,
+ cardsList.table.body,
+ editButton,
+ addressBookButton,
+ globalSearch,
+ booksList,
+ searchInput
+ );
+ Assert.ok(BrowserTestUtils.is_visible(detailsPane));
+
+ // Check with no selection.
+ EventUtils.synthesizeMouseAtCenter(
+ cardsList.getRowAtIndex(0),
+ { accelKey: true },
+ abWindow
+ );
+ Assert.equal(getActiveElement(), cardsList.table.body);
+ Assert.ok(BrowserTestUtils.is_hidden(detailsPane));
+ cycle(
+ addressBookButton,
+ globalSearch,
+ booksList,
+ searchInput,
+ cardsList.table.body,
+ addressBookButton
+ );
+ // Still hidden.
+ Assert.ok(BrowserTestUtils.is_hidden(detailsPane));
+
+ // Check what happens while editing. It should be nothing.
+ EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(0), {}, abWindow);
+ Assert.equal(getActiveElement(), cardsList.table.body);
+ Assert.ok(BrowserTestUtils.is_visible(detailsPane));
+
+ editButton.scrollIntoView();
+ EventUtils.synthesizeMouseAtCenter(editButton, {}, abWindow);
+ Assert.equal(abDocument.activeElement.id, "vcard-n-firstname");
+ EventUtils.synthesizeKey("KEY_F6", {}, abWindow);
+ Assert.equal(
+ abDocument.activeElement.id,
+ "vcard-n-firstname",
+ "F6 did nothing"
+ );
+ EventUtils.synthesizeKey("KEY_F6", { shiftKey: true }, abWindow);
+ Assert.equal(
+ abDocument.activeElement.id,
+ "vcard-n-firstname",
+ "Shift+F6 did nothing"
+ );
+
+ tabmail.closeOtherTabs(0);
+});
+
+add_task(async function testCalendarTab() {
+ EventUtils.synthesizeMouseAtCenter(calendarButton, {});
+
+ cycle(calendarButton, globalSearch, calendarButton);
+
+ tabmail.closeOtherTabs(0);
+});
+
+add_task(async function testTasksTab() {
+ EventUtils.synthesizeMouseAtCenter(tasksButton, {});
+
+ cycle(tasksButton, globalSearch, tasksButton);
+
+ tabmail.closeOtherTabs(0);
+});
+
+add_task(async function testContentTab() {
+ document.body.focus();
+
+ window.openTab("contentTab", {
+ url: "https://example.com/",
+ background: false,
+ });
+ await BrowserTestUtils.browserLoaded(
+ tabmail.currentTabInfo.browser,
+ undefined,
+ "https://example.com/"
+ );
+ cycle(mailButton, globalSearch, tabmail.currentTabInfo.browser, mailButton);
+
+ document.body.focus();
+
+ window.openTab("contentTab", { url: "about:mozilla", background: false });
+ await BrowserTestUtils.browserLoaded(
+ tabmail.currentTabInfo.browser,
+ undefined,
+ "about:mozilla"
+ );
+ cycle(
+ globalSearch,
+ tabmail.currentTabInfo.browser.contentDocument.body,
+ mailButton,
+ globalSearch
+ );
+
+ tabmail.closeOtherTabs(0);
+});
+
+/**
+ * Gets the active element. If it is a browser, returns the browser in some
+ * special cases we're interested in, or the browser's active element.
+ *
+ * @returns {Element}
+ */
+function getActiveElement() {
+ let activeElement = document.activeElement;
+ if (globalSearch.contains(activeElement)) {
+ return globalSearch;
+ }
+ if (activeElement.localName == "browser" && !activeElement.isRemoteBrowser) {
+ activeElement = activeElement.contentDocument.activeElement;
+ }
+ if (
+ activeElement.localName == "browser" &&
+ activeElement.id == "messageBrowser"
+ ) {
+ activeElement = activeElement.contentDocument.activeElement;
+ }
+ return activeElement;
+}
+
+/**
+ * Presses F6 for each element in `elements`, and checks the element has focus.
+ * Then presses Shift+F6 to go back through the elements.
+ * Note that the currently selected element should *not* be the first element.
+ *
+ * @param {Element[]}
+ */
+function cycle(...elements) {
+ let activeElement = getActiveElement();
+
+ for (let i = 0; i < elements.length; i++) {
+ EventUtils.synthesizeKey("KEY_F6", {}, activeElement.ownerGlobal);
+ activeElement = getActiveElement();
+ Assert.equal(
+ activeElement.id || activeElement.localName,
+ elements[i].id || elements[i].localName,
+ "F6 moved the focus"
+ );
+ }
+
+ for (let i = elements.length - 2; i >= 0; i--) {
+ EventUtils.synthesizeKey(
+ "KEY_F6",
+ { shiftKey: true },
+ activeElement.ownerGlobal
+ );
+ activeElement = getActiveElement();
+ Assert.equal(
+ activeElement.id || activeElement.localName,
+ elements[i].id || elements[i].localName,
+ "Shift+F6 moved the focus"
+ );
+ }
+}
diff --git a/comm/mail/base/test/browser/browser_paneSplitter.js b/comm/mail/base/test/browser/browser_paneSplitter.js
new file mode 100644
index 0000000000..1646703b96
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_paneSplitter.js
@@ -0,0 +1,572 @@
+/* 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/. */
+
+let tabmail = document.getElementById("tabmail");
+registerCleanupFunction(() => {
+ tabmail.closeOtherTabs(tabmail.tabInfo[0]);
+});
+
+// Increase this value to slow the test down if you want to see what it is doing.
+let MOUSE_DELAY = 0;
+
+let win, doc;
+
+let resizingEvents = 0;
+let resizedEvents = 0;
+let collapsedEvents = 0;
+let expandedEvents = 0;
+
+// This object keeps the test simple by removing the differences between
+// horizontal and vertical, and which pane is controlled by the splitter.
+let testRunner = {
+ outer: null, // The container for the splitter and panes.
+ splitter: null, // The splitter.
+ resizedIsBefore: null, // Whether resized is before the splitter.
+ resized: null, // The pane the splitter controls the size of.
+ fill: null, // The pane that splitter doesn't control.
+ dimension: null, // Which dimension the splitter resizes.
+
+ getSize(element) {
+ return element.getBoundingClientRect()[this.dimension];
+ },
+
+ assertElementSizes(size, msg = "") {
+ Assert.equal(
+ this.getSize(this.resized),
+ size,
+ `Resized element should take up the expected ${this.dimension}: ${msg}`
+ );
+ Assert.equal(
+ this.getSize(this.fill),
+ 500 - size,
+ `Fill element should take up the rest of the ${this.dimension}: ${msg}`
+ );
+ },
+
+ assertSplitterSize(size, msg = "") {
+ Assert.equal(
+ this.splitter[this.dimension],
+ size,
+ `Splitter ${this.dimension} should match expected ${size}: ${msg}`
+ );
+ },
+
+ get minSizeProperty() {
+ return this.dimension == "width" ? "minWidth" : "minHeight";
+ },
+
+ get maxSizeProperty() {
+ return this.dimension == "width" ? "maxWidth" : "maxHeight";
+ },
+
+ get collapseSizeAttribute() {
+ return this.dimension == "width" ? "collapse-width" : "collapse-height";
+ },
+
+ setCollapseSize(size) {
+ this.splitter.setAttribute(this.collapseSizeAttribute, size);
+ },
+
+ clearCollapseSize() {
+ this.splitter.removeAttribute(this.collapseSizeAttribute);
+ },
+
+ async synthMouse(position, type = "mousemove", otherPosition = 50) {
+ let x, y;
+ if (!this.resizedIsBefore) {
+ position = 500 - position;
+ }
+ if (this.dimension == "width") {
+ [x, y] = [position, otherPosition];
+ } else {
+ [x, y] = [otherPosition, position];
+ }
+ EventUtils.synthesizeMouse(
+ this.splitter.parentNode,
+ x,
+ y,
+ { type, buttons: 1 },
+ win
+ );
+
+ if (MOUSE_DELAY) {
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, MOUSE_DELAY));
+ }
+
+ await new Promise(resolve => requestAnimationFrame(resolve));
+ },
+};
+
+add_setup(async function () {
+ let tab = tabmail.openTab("contentTab", {
+ url: "chrome://mochitests/content/browser/comm/mail/base/test/browser/files/paneSplitter.xhtml",
+ });
+
+ await BrowserTestUtils.browserLoaded(tab.browser);
+ tab.browser.focus();
+
+ win = tab.browser.contentWindow;
+ doc = win.document;
+
+ win.addEventListener("splitter-resizing", event => resizingEvents++);
+ win.addEventListener("splitter-resized", event => resizedEvents++);
+ win.addEventListener("splitter-collapsed", event => collapsedEvents++);
+ win.addEventListener("splitter-expanded", event => expandedEvents++);
+});
+
+add_task(async function testHorizontalBefore() {
+ let outer = doc.getElementById("horizontal-before");
+ let resized = outer.querySelector(".resized");
+ let splitter = outer.querySelector(`hr[is="pane-splitter"]`);
+ let fill = outer.querySelector(".fill");
+
+ Assert.equal(resized.clientWidth, 200);
+ Assert.equal(fill.clientWidth, 300);
+ Assert.equal(win.getComputedStyle(splitter).cursor, "ew-resize");
+
+ testRunner.outer = outer;
+ testRunner.splitter = splitter;
+ testRunner.resizedIsBefore = true;
+ testRunner.resized = resized;
+ testRunner.fill = fill;
+ testRunner.dimension = "width";
+
+ await subtestDrag();
+ await subtestDragSizeBounds();
+ await subtestDragAutoCollapse();
+ await subtestCollapseExpand();
+});
+
+add_task(async function testHorizontalAfter() {
+ let outer = doc.getElementById("horizontal-after");
+ let fill = outer.querySelector(".fill");
+ let splitter = outer.querySelector(`hr[is="pane-splitter"]`);
+ let resized = outer.querySelector(".resized");
+
+ Assert.equal(fill.clientWidth, 300);
+ Assert.equal(resized.clientWidth, 200);
+ Assert.equal(win.getComputedStyle(splitter).cursor, "ew-resize");
+
+ testRunner.outer = outer;
+ testRunner.splitter = splitter;
+ testRunner.resizedIsBefore = false;
+ testRunner.resized = resized;
+ testRunner.fill = fill;
+ testRunner.dimension = "width";
+
+ await subtestDrag();
+ await subtestDragSizeBounds();
+ await subtestDragAutoCollapse();
+ await subtestCollapseExpand();
+});
+
+add_task(async function testVerticalBefore() {
+ let outer = doc.getElementById("vertical-before");
+ let resized = outer.querySelector(".resized");
+ let splitter = outer.querySelector(`hr[is="pane-splitter"]`);
+ let fill = outer.querySelector(".fill");
+
+ Assert.equal(resized.clientHeight, 200);
+ Assert.equal(fill.clientHeight, 300);
+ Assert.equal(win.getComputedStyle(splitter).cursor, "ns-resize");
+
+ testRunner.outer = outer;
+ testRunner.splitter = splitter;
+ testRunner.resizedIsBefore = true;
+ testRunner.resized = resized;
+ testRunner.fill = fill;
+ testRunner.dimension = "height";
+
+ await subtestDrag();
+ await subtestDragSizeBounds();
+ await subtestDragAutoCollapse();
+ await subtestCollapseExpand();
+});
+
+add_task(async function testVerticalAfter() {
+ let outer = doc.getElementById("vertical-after");
+ let fill = outer.querySelector(".fill");
+ let splitter = outer.querySelector(`hr[is="pane-splitter"]`);
+ let resized = outer.querySelector(".resized");
+
+ testRunner.outer = outer;
+ testRunner.splitter = splitter;
+ testRunner.resizedIsBefore = false;
+ testRunner.resized = resized;
+ testRunner.fill = fill;
+ testRunner.dimension = "height";
+
+ Assert.equal(fill.clientHeight, 300);
+ Assert.equal(resized.clientHeight, 200);
+ Assert.equal(win.getComputedStyle(splitter).cursor, "ns-resize");
+
+ await subtestDrag();
+ await subtestDragSizeBounds();
+ await subtestDragAutoCollapse();
+ await subtestCollapseExpand();
+});
+
+async function subtestDrag() {
+ info("subtestDrag");
+ resizingEvents = 0;
+ resizedEvents = 0;
+
+ let originalPosition = testRunner.getSize(testRunner.resized);
+ let position = 200;
+
+ await testRunner.synthMouse(position, "mousedown");
+
+ await testRunner.synthMouse(position, "mousemove", 25);
+ Assert.equal(resizingEvents, 0, "moving up the splitter does nothing");
+ await testRunner.synthMouse(position, "mousemove", 75);
+ Assert.equal(resizingEvents, 0, "moving down the splitter does nothing");
+
+ position--;
+ await testRunner.synthMouse(position);
+ Assert.equal(resizingEvents, 0, "moving 1px does nothing");
+
+ position--;
+ await testRunner.synthMouse(position);
+ Assert.equal(resizingEvents, 0, "moving 2px does nothing");
+
+ position--;
+ await testRunner.synthMouse(position);
+ Assert.equal(resizingEvents, 1, "a resizing event fired");
+
+ // Drag in steps to the left-hand/top end.
+ for (; position >= 0; position -= 50) {
+ await testRunner.synthMouse(position);
+ testRunner.assertElementSizes(position);
+ }
+
+ // Drag beyond the left-hand/top end.
+ position = -50;
+ await testRunner.synthMouse(position);
+ testRunner.assertElementSizes(0);
+
+ // Drag in steps to the right-hand/bottom end.
+ for (let position = 0; position <= 500; position += 50) {
+ await testRunner.synthMouse(position);
+ testRunner.assertElementSizes(position);
+ }
+
+ // Drag beyond the right-hand/bottom end.
+ position = 550;
+ await testRunner.synthMouse(position);
+ testRunner.assertElementSizes(500);
+
+ // Drop.
+ position = 400;
+ Assert.equal(resizingEvents, 1, "no more resizing events fired");
+ Assert.equal(resizedEvents, 0, "no resized events fired");
+ await testRunner.synthMouse(position);
+ await testRunner.synthMouse(position, "mouseup");
+ testRunner.assertElementSizes(400);
+ Assert.equal(resizingEvents, 1, "no more resizing events fired");
+ Assert.equal(resizedEvents, 1, "a resized event fired");
+
+ // Pick up again.
+ await testRunner.synthMouse(position, "mousedown");
+
+ // Move.
+ for (; position >= originalPosition; position -= 50) {
+ await testRunner.synthMouse(position);
+ }
+
+ // Drop.
+ Assert.equal(resizingEvents, 2, "a resizing event fired");
+ Assert.equal(resizedEvents, 1, "no more resized events fired");
+ await testRunner.synthMouse(position, "mouseup");
+ testRunner.assertElementSizes(originalPosition);
+ Assert.equal(resizingEvents, 2, "no more resizing events fired");
+ Assert.equal(resizedEvents, 2, "a resized event fired");
+}
+
+async function subtestDragSizeBounds() {
+ info("subtestDragSizeBounds");
+
+ let { splitter, resized, fill, minSizeProperty, maxSizeProperty } =
+ testRunner;
+
+ // Various min or max sizes to set on the resized and fill elements.
+ // NOTE: the sum of the max sizes is greater than 500px.
+ // Moreover, the resized element's min size is below 200px, and the max size
+ // above it. Similarly, the fill element's min size is below 300px. This
+ // ensures that the initial sizes of 200px and 300px are within their
+ // respective min-max bounds.
+ // NOTE: We do not set a max size on the fill element. The grid layout does
+ // not handle this. Nor is it an expected usage of the splitter.
+ for (let [minResized, min] of [
+ [null, 0],
+ ["100.5px", 100.5],
+ ]) {
+ for (let [maxResized, expectMax1] of [
+ [null, 500],
+ ["360px", 360],
+ ]) {
+ for (let [minFill, expectMax2] of [
+ [null, 500],
+ ["148px", 352],
+ ]) {
+ info(`Bounds [${minResized}, ${maxResized}] and [${minFill}, none]`);
+ let max = Math.min(expectMax1, expectMax2);
+ info(`Overall bound [${min}px, ${max}px]`);
+
+ // Construct a set of positions we are interested in.
+ let roundMin = Math.floor(min);
+ let roundMax = Math.ceil(max);
+ let positionSet = [-50, 150, 350, 550];
+ // Include specific positions around the minimum and maximum points.
+ positionSet.push(roundMin - 1, roundMin, roundMin + 1);
+ positionSet.push(roundMax - 1, roundMax, roundMax + 1);
+ positionSet.sort();
+
+ // Reset the splitter.
+ splitter.width = null;
+ splitter.height = null;
+
+ resized.style[minSizeProperty] = minResized;
+ resized.style[maxSizeProperty] = maxResized;
+ fill.style[minSizeProperty] = minFill;
+
+ testRunner.assertElementSizes(200, "initial position");
+ await testRunner.synthMouse(200, "mousedown");
+
+ for (let position of positionSet) {
+ await testRunner.synthMouse(position);
+ let size = Math.min(Math.max(position, min), max);
+ testRunner.assertElementSizes(size, `Moved forward to ${position}`);
+ testRunner.assertSplitterSize(size, `Moved forward to ${position}`);
+ }
+
+ await testRunner.synthMouse(500);
+ await testRunner.synthMouse(500, "mouseup");
+ testRunner.assertElementSizes(max, "positioned at max");
+ testRunner.assertSplitterSize(max, "positioned at max");
+
+ // Reverse.
+ await testRunner.synthMouse(max, "mousedown");
+
+ for (let position of positionSet.reverse()) {
+ await testRunner.synthMouse(position);
+ let size = Math.min(Math.max(position, min), max);
+ testRunner.assertElementSizes(size, `Moved backward to ${position}`);
+ testRunner.assertSplitterSize(size, `Moved backward to ${position}`);
+ }
+
+ await testRunner.synthMouse(0);
+ await testRunner.synthMouse(0, "mouseup");
+ testRunner.assertElementSizes(min, "positioned at min");
+ testRunner.assertSplitterSize(min, "positioned at min");
+ }
+ }
+ }
+
+ // Reset.
+ splitter.width = null;
+ splitter.height = null;
+ resized.style[minSizeProperty] = null;
+ resized.style[maxSizeProperty] = null;
+ fill.style[minSizeProperty] = null;
+}
+
+async function subtestDragAutoCollapse() {
+ info("subtestDragAutoCollapse");
+ testRunner.setCollapseSize(78);
+
+ collapsedEvents = 0;
+ expandedEvents = 0;
+
+ let { splitter } = testRunner;
+
+ let originalPosition = 200;
+
+ // Drag in steps toward the left-hand/top end.
+ await testRunner.synthMouse(200, "mousedown");
+ for (let position of [180, 160, 140, 120, 100, 80, 78]) {
+ await testRunner.synthMouse(position);
+ testRunner.assertElementSizes(
+ position,
+ `Should have ${position} size at ${position}`
+ );
+ Assert.ok(!splitter.isCollapsed, `Should not be collapsed at ${position}`);
+ }
+
+ // For the first 20 pixels inside the minimum size, nothing happens.
+ for (let position of [74, 68, 64, 60, 58]) {
+ await testRunner.synthMouse(position);
+ testRunner.assertElementSizes(
+ 78,
+ `Should be at collapse-size at ${position}`
+ );
+ Assert.ok(!splitter.isCollapsed, `Should not be collapsed at ${position}`);
+ }
+
+ // Then the pane collapses.
+ await testRunner.synthMouse(57);
+ Assert.equal(collapsedEvents, 1, "collapsed event fired");
+ for (let position of [57, 55, 51, 40, 20, 0, -20]) {
+ await testRunner.synthMouse(position);
+ testRunner.assertElementSizes(0, `Should have no size at ${position}`);
+ Assert.ok(splitter.isCollapsed, `Should be collapsed at ${position}`);
+ }
+
+ await testRunner.synthMouse(-20, "mouseup");
+ testRunner.assertElementSizes(
+ 0,
+ "Should be at min size after releasing mouse"
+ );
+ Assert.ok(splitter.isCollapsed, "Should be collapsed after releasing mouse");
+
+ // Drag it from the collapsed state.
+ await testRunner.synthMouse(0, "mousedown");
+ for (let position of [0, 8, 16, 19]) {
+ await testRunner.synthMouse(position);
+ testRunner.assertElementSizes(
+ 0,
+ `Should still have no size at ${position}`
+ );
+ Assert.ok(splitter.isCollapsed, `Should still be collapsed at ${position}`);
+ }
+
+ // Then the pane expands. For the first 20 pixels, nothing happens.
+ await testRunner.synthMouse(20);
+ Assert.equal(expandedEvents, 1, "expanded event fired");
+ for (let position of [40, 60, 78]) {
+ await testRunner.synthMouse(position);
+ testRunner.assertElementSizes(
+ 78,
+ `Should expand to collapse-size at ${position}`
+ );
+ Assert.ok(
+ !splitter.isCollapsed,
+ `Should no longer be collapsed at ${position}`
+ );
+ }
+
+ for (let position of [79, 100, 120, 200, 250, 300, 400, 450]) {
+ await testRunner.synthMouse(position);
+ testRunner.assertElementSizes(
+ position,
+ `Should have ${position} size at ${position}`
+ );
+ Assert.ok(!splitter.isCollapsed, `Should not be collapsed at ${position}`);
+ }
+
+ await testRunner.synthMouse(450, "mouseup");
+ testRunner.assertElementSizes(
+ 450,
+ "Should be at final size after releasing mouse"
+ );
+ Assert.ok(
+ !splitter.isCollapsed,
+ "Should not be collapsed after releasing mouse"
+ );
+
+ // Test that collapse and expand can happen in the same drag.
+ await testRunner.synthMouse(450, "mousedown");
+ let position;
+ let expectedSize;
+ for ([position, expectedSize] of [
+ [58, 78],
+ [57, 0],
+ [58, 78],
+ [57, 0],
+ ]) {
+ await testRunner.synthMouse(position);
+ testRunner.assertElementSizes(
+ expectedSize,
+ `Should have ${expectedSize} size at ${position}`
+ );
+ }
+ Assert.equal(collapsedEvents, 3, "collapsed events fired");
+ Assert.equal(expandedEvents, 2, "expanded events fired");
+ await testRunner.synthMouse(position, "mouseup");
+
+ // Test that expansion from collapsed reverts to normal behaviour after
+ // dragging out to the minimum size.
+ await testRunner.synthMouse(0, "mousedown");
+ for ([position, expectedSize] of [
+ [0, 0],
+ [10, 0],
+ [20, 78],
+ [40, 78],
+ [60, 78],
+ [40, 0],
+ [60, 78],
+ [40, 0],
+ [80, 80],
+ [100, 100],
+ ]) {
+ await testRunner.synthMouse(position);
+ testRunner.assertElementSizes(
+ expectedSize,
+ `Should have ${expectedSize} size at ${position}`
+ );
+ }
+ Assert.equal(collapsedEvents, 5, "collapsed events fired");
+ Assert.equal(expandedEvents, 5, "expanded events fired");
+ await testRunner.synthMouse(position, "mouseup");
+
+ // Restore the original position.
+ await testRunner.synthMouse(position, "mousedown");
+ position = originalPosition;
+ await testRunner.synthMouse(position);
+ await testRunner.synthMouse(position, "mouseup");
+ testRunner.assertElementSizes(originalPosition);
+
+ testRunner.clearCollapseSize();
+}
+
+async function subtestCollapseExpand() {
+ info("subtestCollapseExpand");
+ collapsedEvents = 0;
+ expandedEvents = 0;
+
+ let { splitter } = testRunner;
+
+ let originalSize = testRunner.getSize(testRunner.resized);
+
+ // Collapse.
+ Assert.ok(!splitter.isCollapsed, "splitter is not collapsed");
+ Assert.equal(collapsedEvents, 0, "no collapsed events have fired");
+
+ splitter.collapse();
+ testRunner.assertElementSizes(0);
+ Assert.ok(splitter.isCollapsed, "splitter is collapsed");
+ Assert.equal(collapsedEvents, 1, "a collapsed event fired");
+
+ splitter.collapse();
+ Assert.ok(splitter.isCollapsed, "splitter is collapsed");
+ Assert.equal(collapsedEvents, 1, "no more collapsed events have fired");
+
+ // Expand.
+ splitter.expand();
+ testRunner.assertElementSizes(originalSize);
+ Assert.ok(!splitter.isCollapsed, "splitter is not collapsed");
+ Assert.equal(expandedEvents, 1, "an expanded event fired");
+
+ splitter.expand();
+ Assert.ok(!splitter.isCollapsed, "splitter is not collapsed");
+ Assert.equal(expandedEvents, 1, "no more expanded events have fired");
+
+ collapsedEvents = 0;
+ expandedEvents = 0;
+
+ // Collapse again. Then drag to expand.
+ splitter.collapse();
+ Assert.equal(collapsedEvents, 1, "a collapsed event fired");
+
+ testRunner.setCollapseSize(78);
+
+ await testRunner.synthMouse(0, "mousedown");
+ await testRunner.synthMouse(200);
+ await testRunner.synthMouse(200, "mouseup");
+ testRunner.assertElementSizes(200);
+ Assert.ok(!splitter.isCollapsed, "splitter is not collapsed");
+ Assert.equal(expandedEvents, 1, "an expanded event fired");
+
+ testRunner.clearCollapseSize();
+}
diff --git a/comm/mail/base/test/browser/browser_preferDisplayName.js b/comm/mail/base/test/browser/browser_preferDisplayName.js
new file mode 100644
index 0000000000..d041b3bd37
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_preferDisplayName.js
@@ -0,0 +1,456 @@
+/* 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 { AddrBookCard } = ChromeUtils.import(
+ "resource:///modules/AddrBookCard.jsm"
+);
+const { MessageGenerator } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MessageGenerator.jsm"
+);
+
+let book, emily, felix, testFolder;
+
+add_setup(async function () {
+ book = MailServices.ab.getDirectory("jsaddrbook://abook.sqlite");
+
+ emily = new AddrBookCard();
+ emily.displayName = "This is Emily!";
+ emily.primaryEmail = "emily@ekberg.invalid";
+ book.addCard(emily);
+
+ felix = new AddrBookCard();
+ felix.displayName = "Felix's Flower Co.";
+ felix.primaryEmail = "felix@flowers.invalid";
+ felix.setPropertyAsBool("PreferDisplayName", false);
+ book.addCard(felix);
+
+ let generator = new MessageGenerator();
+
+ MailServices.accounts.createLocalMailAccount();
+ let account = MailServices.accounts.accounts[0];
+ account.addIdentity(MailServices.accounts.createIdentity());
+
+ registerCleanupFunction(async () => {
+ await ensure_cards_view();
+ book.deleteCards(book.childCards);
+ MailServices.accounts.removeAccount(account, false);
+ });
+
+ let rootFolder = account.incomingServer.rootFolder;
+ rootFolder.createSubfolder("preferDisplayName", null);
+ testFolder = rootFolder
+ .getChildNamed("preferDisplayName")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+ testFolder.addMessageBatch(
+ generator.makeMessages({ count: 5 }).map(message => message.toMboxString())
+ );
+});
+
+add_task(async function () {
+ let about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+ let { threadPane, threadTree, messageBrowser } = about3Pane;
+ // Not `currentAboutMessage` as that's null right now.
+ let aboutMessage = messageBrowser.contentWindow;
+ let messagePaneBrowser = aboutMessage.getMessagePaneBrowser();
+
+ // Set up the UI.
+
+ about3Pane.restoreState({
+ folderURI: testFolder.URI,
+ messagePaneVisible: true,
+ });
+ threadPane.onColumnsVisibilityChanged({
+ value: "senderCol",
+ target: { hasAttribute: () => true },
+ });
+ threadPane.onColumnsVisibilityChanged({
+ value: "recipientCol",
+ target: { hasAttribute: () => true },
+ });
+
+ // Switch to classic view and table layout as the test requires this state.
+ await ensure_table_view();
+
+ // It's important that we don't cause the thread tree to invalidate the row
+ // in question, and selecting it would do that, so select it first.
+ threadTree.selectedIndex = 2;
+ await BrowserTestUtils.browserLoaded(messagePaneBrowser);
+
+ // Check the initial state of everything.
+
+ let fromLabel = aboutMessage.document.querySelector(
+ `.header-recipient[data-header-name="from"]`
+ );
+ let fromSingleLine = fromLabel.querySelector(".recipient-single-line");
+ let fromMultiLineName = fromLabel.querySelector(".recipient-multi-line-name");
+ let fromMultiLineAddress = fromLabel.querySelector(
+ ".recipient-multi-line-address"
+ );
+
+ let toLabel = aboutMessage.document.querySelector(
+ `.header-recipient[data-header-name="to"]`
+ );
+ let toSingleLine = toLabel.querySelector(".recipient-single-line");
+
+ let row = about3Pane.threadTree.getRowAtIndex(2);
+ Assert.equal(
+ row.querySelector(".correspondentcol-column").textContent,
+ "This is Emily!",
+ "initial state of Correspondent column"
+ );
+ Assert.equal(
+ row.querySelector(".sendercol-column").textContent,
+ "This is Emily!",
+ "initial state of Sender column"
+ );
+ Assert.equal(
+ row.querySelector(".recipientcol-column").textContent,
+ "Felix Flowers",
+ "initial state of Recipient column"
+ );
+ Assert.equal(
+ fromSingleLine.textContent,
+ "This is Emily!",
+ "initial state of From single-line label"
+ );
+ Assert.equal(
+ fromSingleLine.title,
+ "Emily Ekberg <emily@ekberg.invalid>",
+ "initial state of From single-line title"
+ );
+ Assert.equal(
+ fromMultiLineName.textContent,
+ "This is Emily!",
+ "initial state of From multi-line name"
+ );
+ Assert.equal(
+ fromMultiLineAddress.textContent,
+ "emily@ekberg.invalid",
+ "initial state of From multi-line address"
+ );
+ Assert.equal(
+ toSingleLine.textContent,
+ "Felix Flowers <felix@flowers.invalid>",
+ "initial state of To single-line label"
+ );
+ Assert.equal(toSingleLine.title, "", "initial state of To single-line title");
+
+ // Change Emily's display name.
+
+ emily.displayName = "I'm Emily!";
+ book.modifyCard(emily);
+
+ row = about3Pane.threadTree.getRowAtIndex(2);
+ Assert.equal(
+ row.querySelector(".correspondentcol-column").textContent,
+ "I'm Emily!",
+ "Correspondent column should be the new display name"
+ );
+ Assert.equal(
+ row.querySelector(".sendercol-column").textContent,
+ "I'm Emily!",
+ "Sender column should be the new display name"
+ );
+ Assert.equal(
+ fromSingleLine.textContent,
+ "I'm Emily!",
+ "From single-line label should be the new display name"
+ );
+ Assert.equal(
+ fromSingleLine.title,
+ "Emily Ekberg <emily@ekberg.invalid>",
+ "From single-line title should not change"
+ );
+ Assert.equal(
+ fromMultiLineName.textContent,
+ "I'm Emily!",
+ "From multi-line name should be the new display name"
+ );
+ Assert.equal(
+ fromMultiLineAddress.textContent,
+ "emily@ekberg.invalid",
+ "From multi-line address should not change"
+ );
+
+ // Stop preferring Emily's display name.
+
+ emily.setPropertyAsBool("PreferDisplayName", false);
+ book.modifyCard(emily);
+
+ row = about3Pane.threadTree.getRowAtIndex(2);
+ Assert.equal(
+ row.querySelector(".correspondentcol-column").textContent,
+ "Emily Ekberg",
+ "Correspondent column should be the name from the header"
+ );
+ Assert.equal(
+ row.querySelector(".sendercol-column").textContent,
+ "Emily Ekberg",
+ "Sender column should be the name from the header"
+ );
+ Assert.equal(
+ fromSingleLine.textContent,
+ "Emily Ekberg <emily@ekberg.invalid>",
+ "From single-line label should match the header"
+ );
+ Assert.equal(
+ fromSingleLine.title,
+ "",
+ "From single-line title should be cleared"
+ );
+ Assert.equal(
+ fromMultiLineName.textContent,
+ "Emily Ekberg",
+ "From multi-line name should be the name from the header"
+ );
+ Assert.equal(
+ fromMultiLineAddress.textContent,
+ "emily@ekberg.invalid",
+ "From multi-line address should not change"
+ );
+
+ // Prefer Emily's display name.
+
+ emily.setPropertyAsBool("PreferDisplayName", true);
+ book.modifyCard(emily);
+
+ row = about3Pane.threadTree.getRowAtIndex(2);
+ Assert.equal(
+ row.querySelector(".correspondentcol-column").textContent,
+ "I'm Emily!",
+ "Correspondent column should be the display name"
+ );
+ Assert.equal(
+ row.querySelector(".sendercol-column").textContent,
+ "I'm Emily!",
+ "Sender column should be the display name"
+ );
+ Assert.equal(
+ fromSingleLine.textContent,
+ "I'm Emily!",
+ "From single-line label should be the display name"
+ );
+ Assert.equal(
+ fromSingleLine.title,
+ "Emily Ekberg <emily@ekberg.invalid>",
+ "From single-line title should match the header"
+ );
+ Assert.equal(
+ fromMultiLineName.textContent,
+ "I'm Emily!",
+ "From multi-line name should be the display name"
+ );
+ Assert.equal(
+ fromMultiLineAddress.textContent,
+ "emily@ekberg.invalid",
+ "From multi-line address should not change"
+ );
+
+ // Prefer Felix's display name.
+
+ felix.setPropertyAsBool("PreferDisplayName", true);
+ book.modifyCard(felix);
+
+ row = about3Pane.threadTree.getRowAtIndex(2);
+ Assert.equal(
+ row.querySelector(".recipientcol-column").textContent,
+ "Felix's Flower Co.",
+ "Recipient column should be the display name"
+ );
+ Assert.equal(
+ toSingleLine.textContent,
+ "Felix's Flower Co.",
+ "To single-line label should be the display name"
+ );
+ Assert.equal(
+ toSingleLine.title,
+ "Felix Flowers <felix@flowers.invalid>",
+ "To single-line title should match the header"
+ );
+
+ // Stop preferring Felix's display name.
+
+ felix.setPropertyAsBool("PreferDisplayName", false);
+ book.modifyCard(felix);
+
+ row = about3Pane.threadTree.getRowAtIndex(2);
+ Assert.equal(
+ row.querySelector(".recipientcol-column").textContent,
+ "Felix Flowers",
+ "Recipient column should be the name from the header"
+ );
+ Assert.equal(
+ toSingleLine.textContent,
+ "Felix Flowers <felix@flowers.invalid>",
+ "To single-line label should match the header"
+ );
+ Assert.equal(
+ toSingleLine.title,
+ "",
+ "To single-line title should be cleared"
+ );
+
+ // Prefer Felix's display name.
+
+ felix.setPropertyAsBool("PreferDisplayName", true);
+ book.modifyCard(felix);
+
+ // Set global prefer display name preference to false.
+
+ Services.prefs.setBoolPref("mail.showCondensedAddresses", false);
+ await TestUtils.waitForCondition(
+ () => !toLabel.parentNode,
+ "Waiting for the header labels to reload."
+ );
+ fromLabel = aboutMessage.document.querySelector(
+ `.header-recipient[data-header-name="from"]`
+ );
+ toLabel = aboutMessage.document.querySelector(
+ `.header-recipient[data-header-name="to"]`
+ );
+
+ fromSingleLine = fromLabel.querySelector(".recipient-single-line");
+ fromMultiLineName = fromLabel.querySelector(".recipient-multi-line-name");
+ fromMultiLineAddress = fromLabel.querySelector(
+ ".recipient-multi-line-address"
+ );
+ toSingleLine = toLabel.querySelector(".recipient-single-line");
+
+ row = about3Pane.threadTree.getRowAtIndex(2);
+ Assert.equal(
+ row.querySelector(".correspondentcol-column").textContent,
+ "Emily Ekberg",
+ "Correspondent column should be the name from the header"
+ );
+ Assert.equal(
+ row.querySelector(".sendercol-column").textContent,
+ "Emily Ekberg",
+ "Sender column should be the name from the header"
+ );
+ Assert.equal(
+ fromSingleLine.textContent,
+ "Emily Ekberg <emily@ekberg.invalid>",
+ "From single-line label should match the header"
+ );
+ Assert.equal(
+ fromSingleLine.title,
+ "",
+ "From single-line title should be cleared"
+ );
+ Assert.equal(
+ fromMultiLineName.textContent,
+ "Emily Ekberg <emily@ekberg.invalid>",
+ "From multi-line name should be the name from the header"
+ );
+ Assert.equal(
+ fromMultiLineAddress.textContent,
+ "emily@ekberg.invalid",
+ "From multi-line address should not change"
+ );
+ Assert.equal(
+ row.querySelector(".recipientcol-column").textContent,
+ "Felix Flowers",
+ "Recipient column should be the name from the header"
+ );
+ Assert.equal(
+ toSingleLine.textContent,
+ "Felix Flowers <felix@flowers.invalid>",
+ "To single-line label should match the header"
+ );
+ Assert.equal(
+ toSingleLine.title,
+ "",
+ "To single-line title should be cleared"
+ );
+
+ // Reset prefer display name global preference to true.
+
+ Services.prefs.setBoolPref("mail.showCondensedAddresses", true);
+ await TestUtils.waitForCondition(
+ () => !toLabel.parentNode,
+ "Waiting for the header labels to reload."
+ );
+ fromLabel = aboutMessage.document.querySelector(
+ `.header-recipient[data-header-name="from"]`
+ );
+ toLabel = aboutMessage.document.querySelector(
+ `.header-recipient[data-header-name="to"]`
+ );
+
+ fromSingleLine = fromLabel.querySelector(".recipient-single-line");
+ fromMultiLineName = fromLabel.querySelector(".recipient-multi-line-name");
+ fromMultiLineAddress = fromLabel.querySelector(
+ ".recipient-multi-line-address"
+ );
+ toSingleLine = toLabel.querySelector(".recipient-single-line");
+
+ row = about3Pane.threadTree.getRowAtIndex(2);
+ Assert.equal(
+ row.querySelector(".correspondentcol-column").textContent,
+ "I'm Emily!",
+ "Correspondent column should be the new display name"
+ );
+ Assert.equal(
+ row.querySelector(".sendercol-column").textContent,
+ "I'm Emily!",
+ "Sender column should be the new display name"
+ );
+ Assert.equal(
+ fromSingleLine.textContent,
+ "I'm Emily!",
+ "From single-line label should be the new display name"
+ );
+ Assert.equal(
+ fromSingleLine.title,
+ "Emily Ekberg <emily@ekberg.invalid>",
+ "From single-line title should not change"
+ );
+ Assert.equal(
+ fromMultiLineName.textContent,
+ "I'm Emily!",
+ "From multi-line name should be the new display name"
+ );
+ Assert.equal(
+ fromMultiLineAddress.textContent,
+ "emily@ekberg.invalid",
+ "From multi-line address should not change"
+ );
+ Assert.equal(
+ row.querySelector(".recipientcol-column").textContent,
+ "Felix's Flower Co.",
+ "Recipient column should be the display name"
+ );
+ Assert.equal(
+ toSingleLine.textContent,
+ "Felix's Flower Co.",
+ "To single-line label should be the display name"
+ );
+ Assert.equal(
+ toSingleLine.title,
+ "Felix Flowers <felix@flowers.invalid>",
+ "To single-line title should match the header"
+ );
+
+ // Restore the default for Felix.
+
+ felix.deleteProperty("PreferDisplayName");
+ book.modifyCard(felix);
+
+ row = about3Pane.threadTree.getRowAtIndex(2);
+ Assert.equal(
+ row.querySelector(".recipientcol-column").textContent,
+ "Felix's Flower Co.",
+ "Recipient column should be the display name"
+ );
+ Assert.equal(
+ toSingleLine.textContent,
+ "Felix's Flower Co.",
+ "To single-line label should be the display name"
+ );
+ Assert.equal(
+ toSingleLine.title,
+ "Felix Flowers <felix@flowers.invalid>",
+ "To single-line title should match the header"
+ );
+});
diff --git a/comm/mail/base/test/browser/browser_searchMessages.js b/comm/mail/base/test/browser/browser_searchMessages.js
new file mode 100644
index 0000000000..4207b0019c
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_searchMessages.js
@@ -0,0 +1,460 @@
+/* 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 { mailTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MailTestUtils.jsm"
+);
+const { MessageGenerator } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MessageGenerator.jsm"
+);
+const { PromiseTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mailnews/PromiseTestUtils.jsm"
+);
+
+const tabmail = document.getElementById("tabmail");
+let rootFolder, testFolder, otherFolder;
+
+add_setup(async function () {
+ const generator = new MessageGenerator();
+
+ MailServices.accounts.createLocalMailAccount();
+ const account = MailServices.accounts.accounts[0];
+ account.addIdentity(MailServices.accounts.createIdentity());
+ rootFolder = account.incomingServer.rootFolder;
+ rootFolder.QueryInterface(Ci.nsIMsgLocalMailFolder);
+ testFolder = rootFolder.createLocalSubfolder("searchMessagesFolder");
+ testFolder.QueryInterface(Ci.nsIMsgLocalMailFolder);
+ const messageStrings = generator
+ .makeMessages({ count: 20 })
+ .map(message => message.toMboxString());
+ testFolder.addMessageBatch(messageStrings);
+ otherFolder = rootFolder.createLocalSubfolder("searchMessagesOtherFolder");
+
+ tabmail.currentAbout3Pane.paneLayout.messagePaneVisible = true;
+ Services.xulStore.removeDocument(
+ "chrome://messenger/content/SearchDialog.xhtml"
+ );
+
+ registerCleanupFunction(() => {
+ MailServices.accounts.removeAccount(account, false);
+ });
+});
+
+add_task(async function () {
+ const windowOpenedPromise = BrowserTestUtils.domWindowOpenedAndLoaded(
+ null,
+ w =>
+ w.document.documentURI == "chrome://messenger/content/SearchDialog.xhtml"
+ );
+ goDoCommand("cmd_searchMessages");
+ const win = await windowOpenedPromise;
+ const doc = win.document;
+
+ await SimpleTest.promiseFocus(win);
+
+ const searchButton = doc.getElementById("search-button");
+ const clearButton = doc.querySelector(
+ "#searchTerms > vbox > hbox:nth-child(2) > button"
+ );
+ const searchTermList = doc.getElementById("searchTermList");
+ const threadTree = doc.getElementById("threadTree");
+ const columns = threadTree.columns;
+ const picker = threadTree.querySelector("treecolpicker");
+ const popup = picker.querySelector("menupopup");
+ const openButton = doc.getElementById("openButton");
+ const deleteButton = doc.getElementById("deleteButton");
+ const fileMessageButton = doc.getElementById("fileMessageButton");
+ const fileMessagePopup = fileMessageButton.querySelector("menupopup");
+ const openInFolderButton = doc.getElementById("openInFolderButton");
+ const saveAsVFButton = doc.getElementById("saveAsVFButton");
+ const statusText = doc.getElementById("statusText");
+
+ const treeClick = mailTestUtils.treeClick.bind(
+ null,
+ EventUtils,
+ win,
+ threadTree
+ );
+
+ // Test search criteria. The search results are deterministic unless
+ // MessageGenerator is changed.
+
+ await TestUtils.waitForCondition(
+ () => searchTermList.itemCount == 1,
+ "waiting for a search term to exist"
+ );
+ const searchTerm0 = searchTermList.getItemAtIndex(0);
+ const input0 = searchTerm0.querySelector("search-value input");
+ const button0 = searchTerm0.querySelector("button.small-button:first-child");
+
+ // Row 0 will look for subjects including "hovercraft".
+ Assert.equal(input0.value, "");
+ input0.focus();
+ EventUtils.sendString("hovercraft", win);
+
+ // Add another row.
+ EventUtils.synthesizeMouseAtCenter(button0, {}, win);
+ await TestUtils.waitForCondition(
+ () => searchTermList.itemCount == 2,
+ "waiting for a second search term to exist"
+ );
+
+ const searchTerm1 = searchTermList.getItemAtIndex(1);
+ const menulist = searchTerm1.querySelector("search-attribute menulist");
+ const menuitem = menulist.querySelector(`menuitem[value="1"]`);
+ const input1 = searchTerm1.querySelector("search-value input");
+
+ // Change row 1's search attribute.
+ EventUtils.synthesizeMouseAtCenter(menulist, {}, win);
+ await BrowserTestUtils.waitForPopupEvent(menulist, "shown");
+ menulist.menupopup.activateItem(menuitem);
+ await BrowserTestUtils.waitForPopupEvent(menulist, "hidden");
+
+ // Row 1 will look for the sender Emily Ekberg.
+ Assert.equal(input1.value, "");
+ EventUtils.synthesizeMouseAtCenter(input1, {}, win);
+ EventUtils.sendString("emily@ekberg.invalid", win);
+
+ // Search. Emily didn't send a message about hovercraft, so no results.
+ EventUtils.synthesizeMouseAtCenter(searchButton, {}, win);
+ // Allows 5 seconds for expected statusText to appear.
+ await TestUtils.waitForCondition(
+ () => statusText.value == "No matches found",
+ "waiting for status text to update"
+ );
+
+ // Change the search from AND to OR.
+ EventUtils.synthesizeMouseAtCenter(
+ doc.querySelector(`#booleanAndGroup > radio[value="or"]`),
+ {},
+ win
+ );
+ // Change the subject search to something more common.
+ input0.select();
+ EventUtils.sendString("in", win);
+
+ // Search. 10 messages should be found.
+ EventUtils.synthesizeMouseAtCenter(searchButton, {}, win);
+ await TestUtils.waitForCondition(
+ () => threadTree.view.rowCount == 10,
+ "waiting for tree view to be filled"
+ );
+ // statusText changes on 500 ms time base.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 500));
+ await TestUtils.waitForCondition(
+ () => statusText.value == "10 matches found",
+ "waiting for status text to update"
+ );
+
+ // Test tree sort column and direction.
+
+ EventUtils.synthesizeMouseAtCenter(columns.subjectCol.element, {}, win);
+ Assert.equal(
+ columns.subjectCol.element.getAttribute("sortDirection"),
+ "ascending"
+ );
+ EventUtils.synthesizeMouseAtCenter(columns.dateCol.element, {}, win);
+ Assert.equal(
+ columns.dateCol.element.getAttribute("sortDirection"),
+ "ascending"
+ );
+ EventUtils.synthesizeMouseAtCenter(columns.dateCol.element, {}, win);
+ Assert.equal(
+ columns.dateCol.element.getAttribute("sortDirection"),
+ "descending"
+ );
+
+ // Test tree column visibility and order.
+
+ checkTreeColumnsInOrder(threadTree, [
+ "flaggedCol",
+ "attachmentCol",
+ "subjectCol",
+ "unreadButtonColHeader",
+ "correspondentCol",
+ "junkStatusCol",
+ "dateCol",
+ "locationCol",
+ ]);
+ EventUtils.synthesizeMouseAtCenter(picker, {}, win);
+ await BrowserTestUtils.waitForPopupEvent(popup, "shown");
+ popup.activateItem(
+ popup.querySelector(`[colindex="${columns.selectCol.index}"]`)
+ );
+ popup.activateItem(
+ popup.querySelector(`[colindex="${columns.deleteCol.index}"]`)
+ );
+ popup.hidePopup();
+ await BrowserTestUtils.waitForPopupEvent(popup, "hidden");
+ // Wait for macOS to catch up.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 500));
+ checkTreeColumnsInOrder(threadTree, [
+ "selectCol",
+ "flaggedCol",
+ "attachmentCol",
+ "subjectCol",
+ "unreadButtonColHeader",
+ "correspondentCol",
+ "junkStatusCol",
+ "dateCol",
+ "locationCol",
+ "deleteCol",
+ ]);
+
+ threadTree._reorderColumn(
+ columns.deleteCol.element,
+ columns.selectCol.element,
+ false
+ );
+ threadTree.invalidate();
+ // Wait for macOS to catch up.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 500));
+ checkTreeColumnsInOrder(threadTree, [
+ "selectCol",
+ "deleteCol",
+ "flaggedCol",
+ "attachmentCol",
+ "subjectCol",
+ "unreadButtonColHeader",
+ "correspondentCol",
+ "junkStatusCol",
+ "dateCol",
+ "locationCol",
+ ]);
+
+ // Test message selection with the select column.
+
+ treeClick(0, "subjectCol", {});
+ await TestUtils.waitForCondition(
+ () => threadTree.view.selection.count == 1,
+ "waiting for first message to be selected"
+ );
+ Assert.ok(!openButton.disabled);
+ Assert.ok(!deleteButton.disabled);
+ Assert.ok(!fileMessageButton.disabled);
+ Assert.ok(!openInFolderButton.disabled);
+ treeClick(1, "selectCol", {});
+ await TestUtils.waitForCondition(
+ () => threadTree.view.selection.count == 2,
+ "waiting for second message to be selected"
+ );
+ Assert.ok(!openButton.disabled);
+ Assert.ok(!deleteButton.disabled);
+ Assert.ok(!fileMessageButton.disabled);
+ Assert.ok(openInFolderButton.disabled);
+ treeClick(1, "selectCol", {});
+ await TestUtils.waitForCondition(
+ () => threadTree.view.selection.count == 1,
+ "waiting for second message to be unselected"
+ );
+ Assert.ok(!openButton.disabled);
+ Assert.ok(!deleteButton.disabled);
+ Assert.ok(!fileMessageButton.disabled);
+ Assert.ok(!openInFolderButton.disabled);
+ treeClick(0, "selectCol", {});
+ await TestUtils.waitForCondition(
+ () => threadTree.view.selection.count == 0,
+ "waiting for first message to be selected"
+ );
+ Assert.ok(openButton.disabled);
+ Assert.ok(deleteButton.disabled);
+ Assert.ok(fileMessageButton.disabled);
+ Assert.ok(openInFolderButton.disabled);
+
+ // Opening messages.
+
+ // Test opening a message with the "Open" button.
+ treeClick(0, "subjectCol", {});
+ let tabOpenPromise = BrowserTestUtils.waitForEvent(window, "TabOpen");
+ EventUtils.synthesizeMouseAtCenter(openButton, {}, win);
+ const {
+ detail: { tabInfo: tab1 },
+ } = await tabOpenPromise;
+ await BrowserTestUtils.waitForEvent(tab1.chromeBrowser, "MsgLoaded");
+ Assert.equal(tab1.mode.name, "mailMessageTab");
+
+ await SimpleTest.promiseFocus(win);
+
+ // Test opening a message with a double click.
+ tabOpenPromise = BrowserTestUtils.waitForEvent(window, "TabOpen");
+ treeClick(0, "subjectCol", { clickCount: 2 });
+ const {
+ detail: { tabInfo: tab2 },
+ } = await tabOpenPromise;
+ await BrowserTestUtils.waitForEvent(tab2.chromeBrowser, "MsgLoaded");
+ Assert.equal(tab2.mode.name, "mailMessageTab");
+
+ await SimpleTest.promiseFocus(win);
+
+ // Test opening a message with the keyboard.
+ tabOpenPromise = BrowserTestUtils.waitForEvent(window, "TabOpen");
+ threadTree.focus();
+ EventUtils.synthesizeKey("VK_RETURN", {}, win);
+ const {
+ detail: { tabInfo: tab3 },
+ } = await tabOpenPromise;
+ await BrowserTestUtils.waitForEvent(tab3.chromeBrowser, "MsgLoaded");
+ Assert.equal(tab3.mode.name, "mailMessageTab");
+
+ await SimpleTest.promiseFocus(win);
+
+ // Test opening a message with the "Open in Folder" button.
+ const tabSelectPromise = BrowserTestUtils.waitForEvent(window, "TabSelect");
+ EventUtils.synthesizeMouseAtCenter(openInFolderButton, {}, win);
+ const {
+ detail: { tabInfo: tab0 },
+ } = await tabSelectPromise;
+ await BrowserTestUtils.waitForEvent(tab0.chromeBrowser, "MsgLoaded");
+ Assert.equal(tab0, tabmail.tabInfo[0]);
+
+ tabmail.closeOtherTabs(tab0);
+
+ await SimpleTest.promiseFocus(win);
+
+ // Deleting messages.
+
+ // Test deleting a message with the delete column.
+ let deletePromise = PromiseTestUtils.promiseFolderEvent(
+ testFolder,
+ "DeleteOrMoveMsgCompleted"
+ );
+ treeClick(0, "deleteCol", {});
+ await deletePromise;
+ await TestUtils.waitForCondition(
+ () => threadTree.view.rowCount == 9,
+ "waiting for row to be removed from tree view"
+ );
+
+ // Test deleting a message with the "Delete" button.
+ deletePromise = PromiseTestUtils.promiseFolderEvent(
+ testFolder,
+ "DeleteOrMoveMsgCompleted"
+ );
+ EventUtils.synthesizeMouseAtCenter(deleteButton, {}, win);
+ await deletePromise;
+ await TestUtils.waitForCondition(
+ () => threadTree.view.rowCount == 8,
+ "waiting for row to be removed from tree view"
+ );
+
+ // Test deleting a message with the keyboard.
+ treeClick(0, "subjectCol", {});
+ deletePromise = PromiseTestUtils.promiseFolderEvent(
+ testFolder,
+ "DeleteOrMoveMsgCompleted"
+ );
+ EventUtils.synthesizeKey("VK_DELETE", { shiftKey: true }, win);
+ await deletePromise;
+ await TestUtils.waitForCondition(
+ () => threadTree.view.rowCount == 7,
+ "waiting for row to be removed from tree view"
+ );
+
+ // Moving messages.
+
+ // Test moving a message to another folder with the "Move To" button.
+ treeClick(0, "subjectCol", {});
+ const movePromise = PromiseTestUtils.promiseFolderEvent(
+ testFolder,
+ "DeleteOrMoveMsgCompleted"
+ );
+
+ EventUtils.synthesizeMouseAtCenter(fileMessageButton, {}, win);
+ await BrowserTestUtils.waitForPopupEvent(fileMessagePopup, "shown");
+ const rootFolderMenu = [...fileMessagePopup.children].find(
+ i => i._folder == rootFolder
+ );
+ rootFolderMenu.openMenu(true);
+ await BrowserTestUtils.waitForPopupEvent(rootFolderMenu.menupopup, "shown");
+ const otherFolderItem = [...rootFolderMenu.menupopup.children].find(
+ i => i._folder == otherFolder
+ );
+ rootFolderMenu.menupopup.activateItem(otherFolderItem);
+ await BrowserTestUtils.waitForPopupEvent(fileMessagePopup, "hidden");
+
+ await movePromise;
+ await TestUtils.waitForCondition(
+ () => threadTree.view.rowCount == 6,
+ "waiting for row to be removed from tree view"
+ );
+
+ // TODO: Test dragging a message to another folder.
+
+ // Test the "Save as Search Folder" button.
+
+ const virtualFolderDialogPromise = BrowserTestUtils.promiseAlertDialogOpen(
+ undefined,
+ "chrome://messenger/content/virtualFolderProperties.xhtml",
+ {
+ async callback(vfWin) {
+ await SimpleTest.promiseFocus(vfWin);
+ await BrowserTestUtils.closeWindow(vfWin);
+ },
+ }
+ );
+ EventUtils.synthesizeMouseAtCenter(saveAsVFButton, {}, win);
+ await virtualFolderDialogPromise;
+
+ await SimpleTest.promiseFocus(win);
+
+ // Test clearing the search.
+
+ EventUtils.synthesizeMouseAtCenter(clearButton, {}, win);
+ await TestUtils.waitForCondition(
+ () => searchTermList.itemCount == 1,
+ "waiting for search term list to be cleared"
+ );
+ await TestUtils.waitForCondition(
+ () => threadTree.view.rowCount == 0,
+ "waiting for tree view to be cleared"
+ );
+
+ const newSearchTerm0 = searchTermList.getItemAtIndex(0);
+ Assert.notEqual(newSearchTerm0, searchTerm0);
+ const newInput0 = newSearchTerm0.querySelector("search-value input");
+ Assert.equal(newInput0.value, "");
+
+ await BrowserTestUtils.closeWindow(win);
+
+ // Open the window again, and check the tree columns are as we left them.
+
+ const window2OpenedPromise = BrowserTestUtils.domWindowOpenedAndLoaded(
+ null,
+ w =>
+ w.document.documentURI == "chrome://messenger/content/SearchDialog.xhtml"
+ );
+ goDoCommand("cmd_searchMessages");
+ const win2 = await window2OpenedPromise;
+ const doc2 = win.document;
+ await SimpleTest.promiseFocus(win2);
+
+ const threadTree2 = doc2.getElementById("threadTree");
+
+ checkTreeColumnsInOrder(threadTree2, [
+ "selectCol",
+ "deleteCol",
+ "flaggedCol",
+ "attachmentCol",
+ "subjectCol",
+ "unreadButtonColHeader",
+ "correspondentCol",
+ "junkStatusCol",
+ "dateCol",
+ "locationCol",
+ ]);
+
+ await BrowserTestUtils.closeWindow(win2);
+});
+
+function checkTreeColumnsInOrder(tree, expectedOrder) {
+ Assert.deepEqual(
+ Array.from(tree.querySelectorAll("treecol:not([hidden])"))
+ .sort((a, b) => a.ordinal - b.ordinal)
+ .map(c => c.id),
+ expectedOrder
+ );
+}
diff --git a/comm/mail/base/test/browser/browser_selectionWidgetController.js b/comm/mail/base/test/browser/browser_selectionWidgetController.js
new file mode 100644
index 0000000000..8e57c64bdc
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_selectionWidgetController.js
@@ -0,0 +1,6196 @@
+/* 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/. */
+
+var tabInfo;
+var win;
+
+add_setup(async () => {
+ tabInfo = window.openContentTab(
+ "chrome://mochitests/content/browser/comm/mail/base/test/browser/files/selectionWidget.xhtml"
+ );
+ await BrowserTestUtils.browserLoaded(tabInfo.browser);
+
+ tabInfo.browser.focus();
+ win = tabInfo.browser.contentWindow;
+});
+
+registerCleanupFunction(() => {
+ window.tabmail.closeTab(tabInfo);
+});
+
+var selectionModels = ["focus", "browse", "browse-multi"];
+
+/**
+ * The selection widget.
+ *
+ * @type {HTMLElement}
+ */
+var widget;
+/**
+ * A focusable item before the widget.
+ *
+ * @type {HTMLElement}
+ */
+var before;
+/**
+ * A focusable item after the widget.
+ *
+ * @type {HTMLElement}
+ */
+var after;
+
+/**
+ * Reset the page and create a new widget.
+ *
+ * The "widget", "before" and "after" variables will be reset to the new
+ * elements.
+ *
+ * @param {object} options - Options to set.
+ * @param {string} options.model - The selection model to use.
+ * @param {string} [options.direction="right-to-left"] - The direction of the
+ * widget. Choosing "top-to-bottom" will layout items from top to bottom.
+ * Choosing "right-to-left" or "left-to-right" will set the page's direction
+ * to "rtl" or "ltr", respectively, and will layout items in the writing
+ * direction.
+ * @param {boolean} [options.draggable=false] - Whether to make the items
+ * draggable.
+ */
+function reset(options) {
+ function createTabStop(text) {
+ let el = win.document.createElement("span");
+ el.tabIndex = 0;
+ el.id = text;
+ el.textContent = text;
+ return el;
+ }
+ before = createTabStop("before");
+ after = createTabStop("after");
+
+ let { model, direction } = options;
+ if (!direction) {
+ // Default to a less-common format.
+ direction = "right-to-left";
+ }
+ info(`Creating ${direction} widget with "${model}" model`);
+
+ widget = win.document.createElement("test-selection-widget");
+ widget.id = "widget";
+ widget.setAttribute("selection-model", model);
+ widget.setAttribute(
+ "layout-direction",
+ direction == "top-to-bottom" ? "vertical" : "horizontal"
+ );
+ widget.toggleAttribute("items-draggable", options.draggable);
+
+ win.document.body.replaceChildren(before, widget, after);
+
+ win.document.dir = direction == "left-to-right" ? "ltr" : "rtl";
+
+ before.focus();
+}
+
+/**
+ * Create an array of sequential integers.
+ *
+ * @param {number} start - The starting integer.
+ * @param {number} num - The number of integers.
+ *
+ * @returns {number[]} - Array of integers between start and (start + num - 1).
+ */
+function range(start, num) {
+ return Array.from({ length: num }, (_, i) => start + i);
+}
+
+/**
+ * Assert that the specified items are selected in the widget, and nothing else.
+ *
+ * @param {number[]} indices - The indices of the selected items.
+ * @param {string} msg - A message to use for the assertion.
+ */
+function assertSelection(indices, msg) {
+ let selected = widget.selectedIndices();
+ Assert.deepEqual(selected, indices, `Selected indices should match: ${msg}`);
+ // Test that the return of getSelectionRanges is as expected.
+ let expectRanges = [];
+ let lastIndex = -2;
+ let rangeIndex = -1;
+ for (let index of indices) {
+ if (index == lastIndex + 1) {
+ expectRanges[rangeIndex].end++;
+ } else {
+ rangeIndex++;
+ expectRanges.push({ start: index, end: index + 1 });
+ }
+ lastIndex = index;
+ }
+ Assert.deepEqual(
+ widget.getSelectionRanges(),
+ expectRanges,
+ `Selection ranges should match expected: ${msg}`
+ );
+}
+
+/**
+ * Assert that the given element is focused.
+ *
+ * @param {object} expect - The expected focused element.
+ * @param {HTMLElement} [expect.element] - The expected element that will
+ * have focus.
+ * @param {number} [expect.index] - If the `element` property is not given, this
+ * specifies the index of the item widget we expect to have focus.
+ * @param {string} [expect.text] - Optionally test that the element also has the
+ * given text content.
+ * @param {string} msg - A message to use for the assertion.
+ */
+function assertFocus(expect, msg) {
+ let expectElement;
+ let name;
+ if (expect.element != undefined) {
+ expectElement = expect.element;
+ name = `Element #${expectElement.id}`;
+ } else {
+ expectElement = widget.items[expect.index].element;
+ name = `Item ${expect.index}`;
+ }
+ let active = win.document.activeElement;
+ let activeIndex = widget.items.findIndex(i => i.element == active);
+ if (activeIndex >= 0) {
+ active = `"${active.textContent}", index: ${activeIndex}`;
+ } else if (active.id) {
+ active = `#${active.id}`;
+ } else {
+ active = `<${active.localName}>`;
+ }
+ Assert.ok(
+ expectElement.matches(":focus"),
+ `${name} should have focus (active: ${active}): ${msg}`
+ );
+}
+
+/**
+ * Shift the focus by one step by pressing Tab and assert the new focused
+ * element.
+ *
+ * @param {boolean} forward - Whether to move the focus forward.
+ * @param {object} expect - The expected focused element after pressing tab.
+ * Same as passed to {@link assertFocus}.
+ * @param {string} msg - A message to use for the assertion.
+ */
+function stepFocus(forward, expect, msg) {
+ EventUtils.synthesizeKey("KEY_Tab", { shiftKey: !forward }, win);
+ assertFocus(
+ expect,
+ `After moving ${forward ? "forward" : "backward"}: ${msg}`
+ );
+}
+
+/**
+ * @typedef {object} ItemState
+ * @property {string} text - The text content of the item.
+ * @property {boolean} [selected=false] - Whether the item is selected.
+ * @property {boolean} [focused=false] - Whether the item is focused.
+ */
+
+/**
+ * Assert the text order, selection state and focus of the widget items.
+ *
+ * @param {ItemState[]} expected - The expected state of the widget items, in
+ * the expected order of the items.
+ * @param {string} msg - A message to use for the assertion.
+ */
+function assertState(expected, msg) {
+ let textOrder = [];
+ let focusIndex;
+ let selectedIndices = [];
+ for (let [index, state] of expected.entries()) {
+ textOrder.push(state.text);
+ if (state.selected) {
+ selectedIndices.push(index);
+ }
+ if (state.focused) {
+ if (focusIndex != undefined) {
+ throw new Error("More than one item specified as having focus");
+ }
+ focusIndex = index;
+ }
+ }
+ Assert.deepEqual(
+ Array.from(widget.items, i => i.element.textContent),
+ textOrder,
+ `Text order should match: ${msg}`
+ );
+ assertSelection(selectedIndices, msg);
+ if (focusIndex != undefined) {
+ assertFocus({ index: focusIndex }, msg);
+ } else {
+ Assert.ok(
+ !widget.querySelector(":focus"),
+ `Widget should not contain any focus: ${msg}`
+ );
+ }
+}
+
+/**
+ * Click the empty space of the widget.
+ *
+ * @param {object} mouseEvent - Properties for the click event.
+ */
+function clickWidgetEmptySpace(mouseEvent) {
+ let widgetRect = widget.getBoundingClientRect();
+ if (widget.getAttribute("layout-direction") == "vertical") {
+ // Try click end, which we assume is empty.
+ EventUtils.synthesizeMouse(
+ widget,
+ widgetRect.width / 2,
+ widgetRect.height - 5,
+ mouseEvent,
+ win
+ );
+ } else if (widget.matches(":dir(rtl)")) {
+ // Try click the left, which we assume is empty.
+ EventUtils.synthesizeMouse(
+ widget,
+ 5,
+ widgetRect.height / 2,
+ mouseEvent,
+ win
+ );
+ } else {
+ // Try click the right, which we assume is empty.
+ EventUtils.synthesizeMouse(
+ widget,
+ widgetRect.width - 5,
+ widgetRect.height / 2,
+ mouseEvent,
+ win
+ );
+ }
+}
+
+/**
+ * Click the specified widget item.
+ *
+ * @param {number} index - The index of the item to click.
+ * @param {object} mouseEvent - Properties for the click event.
+ */
+function clickWidgetItem(index, mouseEvent) {
+ EventUtils.synthesizeMouseAtCenter(
+ widget.items[index].element,
+ mouseEvent,
+ win
+ );
+}
+
+/**
+ * Trigger the select-all shortcut.
+ */
+function selectAllShortcut() {
+ EventUtils.synthesizeKey(
+ "a",
+ AppConstants.platform == "macosx" ? { metaKey: true } : { ctrlKey: true },
+ win
+ );
+}
+
+// If the widget is empty, it receives focus on itself.
+add_task(function test_empty_widget_focus() {
+ for (let model of selectionModels) {
+ reset({ model });
+
+ assertFocus({ element: before }, "Initial");
+
+ // Move focus forward.
+ stepFocus(true, { element: widget }, "Move into widget");
+ stepFocus(true, { element: after }, "Move out of widget");
+
+ // Move focus backward.
+ stepFocus(false, { element: widget }, "Move back to widget");
+ stepFocus(false, { element: before }, "Move back out of widget");
+
+ // Clicking also gives focus.
+ for (let shiftKey of [false, true]) {
+ for (let ctrlKey of [false, true]) {
+ info(
+ `Clicking empty widget: ctrlKey: ${ctrlKey}, shiftKey: ${shiftKey}`
+ );
+ clickWidgetEmptySpace({ shiftKey, ctrlKey });
+ assertFocus({ element: widget }, "Widget receives focus after click");
+ // Move focus for the next loop.
+ stepFocus(true, { element: after }, "Move back out");
+ }
+ }
+ }
+});
+
+/**
+ * Test that the initial focus is as expected.
+ *
+ * @param {string} model - The selection model to use.
+ * @param {Function} setup - A callback to set up the widget.
+ * @param {number} clickIndex - The index of an item to click.
+ * @param {object} expect - The expected states.
+ * @param {number} expect.focusIndex - The expected focus index.
+ * @param {number[]} expect.selection - The expected initial selection.
+ * @param {boolean} expect.selectFocus - Whether we expect the focused item to
+ * become selected.
+ */
+function subtest_initial_focus(model, setup, expect) {
+ let { focusIndex: index, selection, selectFocus } = expect;
+
+ reset({ model });
+ setup();
+
+ assertFocus({ element: before }, "Forward start");
+ assertSelection(selection, "Initial selection");
+
+ stepFocus(true, { index }, "Move onto selected item");
+ if (selectFocus) {
+ assertSelection([index], "Focus becomes selected");
+ } else {
+ assertSelection(selection, "Selection remains when focussing");
+ }
+ stepFocus(true, { element: after }, "Move out of widget");
+
+ // Reverse.
+ reset({ model });
+ after.focus();
+ setup();
+
+ assertFocus({ element: after }, "Reverse start");
+ assertSelection(selection, "Reverse start");
+
+ stepFocus(false, { index }, "Move backward to selected item");
+ if (selectFocus) {
+ assertSelection([index], "Focus becomes selected");
+ } else {
+ assertSelection(selection, "Selection remains when focussing");
+ }
+ stepFocus(false, { element: before }, "Move out of widget");
+
+ // With mouse click.
+ for (let shiftKey of [false, true]) {
+ for (let ctrlKey of [false, true]) {
+ info(`Clicking widget: ctrlKey: ${ctrlKey}, shiftKey: ${shiftKey}`);
+
+ reset({ model });
+ setup();
+
+ assertFocus({ element: before }, "Click empty start");
+ assertSelection(selection, "Click empty start");
+ clickWidgetEmptySpace({ ctrlKey, shiftKey });
+ assertFocus(
+ { index },
+ "Selected item becomes focused with click on empty"
+ );
+ if (selectFocus) {
+ assertSelection([index], "Focus becomes selected on click on empty");
+ } else {
+ assertSelection(selection, "Selection remains when click on empty");
+ }
+
+ // With mouse click on item focus moves to the clicked item instead.
+ for (let clickIndex of [
+ (index || widget.items.length) - 1,
+ index,
+ index + 1,
+ ]) {
+ reset({ model });
+ setup();
+
+ assertFocus({ element: before }, "Click first item start");
+ assertSelection(selection, "Click first item start");
+
+ clickWidgetItem(clickIndex, { shiftKey, ctrlKey });
+
+ if (
+ (shiftKey && ctrlKey) ||
+ ((shiftKey || ctrlKey) && (model == "focus" || model == "browse"))
+ ) {
+ // Both modifiers, or multi-selection not supported, so acts the
+ // same as clicking empty.
+ assertFocus(
+ { index },
+ "Selected item becomes focused with click on item"
+ );
+ if (selectFocus) {
+ assertSelection([index], "Focus becomes selected on click on item");
+ } else {
+ assertSelection(selection, "Selection remains when click on item");
+ }
+ } else {
+ assertFocus(
+ { index: clickIndex },
+ "Clicked item becomes focused with click on item"
+ );
+ let clickSelection;
+ if (ctrlKey) {
+ if (selection.includes(clickIndex)) {
+ // Toggle off clicked item.
+ clickSelection = selection.filter(index => index != clickIndex);
+ } else {
+ clickSelection = selection.concat([clickIndex]).sort();
+ }
+ } else if (shiftKey) {
+ // Range selection is always from 0, regardless of the selection
+ // before the click.
+ clickSelection = range(0, clickIndex + 1);
+ } else {
+ clickSelection = [clickIndex];
+ }
+ assertSelection(clickSelection, "Selection after click on item");
+ }
+ }
+ }
+ }
+}
+
+// If the widget has a selection when we move into it, the selected item is
+// focused.
+add_task(function test_initial_focus() {
+ for (let model of selectionModels) {
+ // With no initial selection.
+ subtest_initial_focus(
+ model,
+ () => {
+ widget.addItems(0, ["First", "Second", "Third", "Fourth"]);
+ },
+ { focusIndex: 0, selection: [], selectFocus: true }
+ );
+ // With call to selectSingleItem
+ subtest_initial_focus(
+ model,
+ () => {
+ widget.addItems(0, ["First", "Second", "Third", "Fourth"]);
+ widget.selectSingleItem(2);
+ },
+ { focusIndex: 2, selection: [2], selectFocus: false }
+ );
+
+ // Using the setItemSelected API
+ if (model == "focus" || model == "browse") {
+ continue;
+ }
+
+ subtest_initial_focus(
+ model,
+ () => {
+ widget.addItems(0, ["First", "Second", "Third", "Fourth"]);
+ widget.setItemSelected(2, true);
+ },
+ { focusIndex: 2, selection: [2], selectFocus: false }
+ );
+
+ // With multiple selected, we move focus to the first selected.
+ subtest_initial_focus(
+ model,
+ () => {
+ widget.addItems(0, ["First", "Second", "Third", "Fourth"]);
+ widget.setItemSelected(2, true);
+ widget.setItemSelected(1, true);
+ },
+ { focusIndex: 1, selection: [1, 2], selectFocus: false }
+ );
+
+ // If we use both methods.
+ subtest_initial_focus(
+ model,
+ () => {
+ widget.addItems(0, ["First", "Second", "Third", "Fourth"]);
+ widget.selectSingleItem(2, true);
+ widget.setItemSelected(1, true);
+ },
+ { focusIndex: 1, selection: [1, 2], selectFocus: false }
+ );
+
+ // If we call selectSingleItem and then unselect it, we act same as the
+ // default case.
+ subtest_initial_focus(
+ model,
+ () => {
+ widget.addItems(0, ["First", "Second", "Third", "Fourth"]);
+ widget.selectSingleItem(2, true);
+ widget.setItemSelected(2, false);
+ },
+ { focusIndex: 0, selection: [], selectFocus: true }
+ );
+ }
+});
+
+// If selectSingleItem API method is called, we select an item and make it the
+// focus.
+add_task(function test_select_single_item_method() {
+ function subTestSelectSingleItem(outside, index) {
+ if (outside) {
+ stepFocus(true, { element: after }, "Moving focus to outside widget");
+ }
+
+ widget.selectSingleItem(index);
+ assertSelection([index], "Item becomes selected after call");
+
+ if (outside) {
+ assertFocus({ element: after }, "Focus remains outside the widget");
+ // Return.
+ stepFocus(false, { index }, "Focus moves to selected item on return");
+ assertSelection([index], "Item remains selected on return");
+ } else {
+ assertFocus({ index }, "Focus force moved to selected item");
+ }
+ }
+
+ for (let model of selectionModels) {
+ reset({ model });
+ widget.addItems(0, ["First", "Second", "Third", "Fourth"]);
+
+ stepFocus(true, { index: 0 }, "Move onto first item");
+
+ for (let outside of [false, true]) {
+ info(`Testing selecting item${outside ? " with focus outside" : ""}`);
+
+ EventUtils.synthesizeKey("KEY_Home", {}, win);
+ assertFocus({ index: 0 }, "Focus initially on first item");
+ assertSelection([0], "Initial selection on first item");
+
+ subTestSelectSingleItem(outside, 1);
+ // Selecting again.
+ subTestSelectSingleItem(outside, 1);
+
+ if (model == "focus") {
+ continue;
+ }
+
+ // Split focus from selection
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win);
+ assertFocus({ index: 2 }, "Third item has focus");
+ assertSelection([1], "Second item remains selected");
+
+ // Select focused item.
+ subTestSelectSingleItem(outside, 2);
+
+ // Split again.
+ EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win);
+ assertFocus({ index: 1 }, "Second item has focus");
+ assertSelection([2], "Third item remains selected");
+
+ // Selecting selected item will still move focus.
+ subTestSelectSingleItem(outside, 2);
+
+ // Split again.
+ EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win);
+ assertFocus({ index: 0 }, "First item has focus");
+ assertSelection([2], "Third item remains selected");
+
+ // Select neither focused nor selected.
+ subTestSelectSingleItem(outside, 1);
+ }
+
+ // With mouse click to focus.
+ for (let shiftKey of [false, true]) {
+ for (let ctrlKey of [false, true]) {
+ info(`Clicking widget: ctrlKey: ${ctrlKey}, shiftKey: ${shiftKey}`);
+
+ reset({ model });
+ widget.addItems(0, ["First", "Second", "Third"]);
+ stepFocus(true, { index: 0 }, "Move onto first item");
+ assertSelection([0], "First item becomes selected");
+
+ // Move focus outside widget.
+ stepFocus(true, { element: after }, "Move focus outside");
+
+ // Select an item.
+ widget.selectSingleItem(1);
+
+ // Click empty space will focus the selected item.
+ clickWidgetEmptySpace({});
+ assertFocus(
+ { index: 1 },
+ "Selected item becomes focused with click on empty"
+ );
+ assertSelection(
+ [1],
+ "Second item remains selected with click on empty"
+ );
+
+ // With mouse click on selected item.
+ stepFocus(false, { element: before }, "Move focus outside");
+ widget.selectSingleItem(2);
+
+ clickWidgetItem(2, { shiftKey, ctrlKey });
+ assertFocus(
+ { index: 2 },
+ "Selected item becomes focused with click on selected"
+ );
+ if (ctrlKey && !shiftKey && model == "browse-multi") {
+ assertSelection(
+ [],
+ "Item becomes unselected with Ctrl+click on selected"
+ );
+ } else {
+ // NOTE: Shift+Click will select from the item to itself.
+ assertSelection(
+ [2],
+ "Selected item remains selected with click on selected"
+ );
+ }
+
+ // With mouse click on non-selected item.
+ stepFocus(false, { element: before }, "Move focus outside");
+ widget.selectSingleItem(1);
+
+ clickWidgetItem(2, { shiftKey, ctrlKey });
+ if (
+ (shiftKey && ctrlKey) ||
+ ((shiftKey || ctrlKey) && (model == "focus" || model == "browse"))
+ ) {
+ // Both modifiers, or multi-selection not supported, so acts the
+ // same as clicking empty.
+ assertFocus(
+ { index: 1 },
+ "Selected item becomes focused with click on item"
+ );
+ assertSelection(
+ [1],
+ "Selected item remains selected with click on item"
+ );
+ } else {
+ assertFocus(
+ { index: 2 },
+ "Third item becomes focused with click on item"
+ );
+ if (ctrlKey) {
+ assertSelection(
+ [1, 2],
+ "Third item becomes selected with Ctrl+click"
+ );
+ } else if (shiftKey) {
+ assertSelection(
+ [1, 2],
+ "Second to third item become selected with Shift+click"
+ );
+ } else {
+ assertSelection(
+ [2],
+ "Third item becomes selected with click on item"
+ );
+ }
+ }
+ }
+ }
+ }
+});
+
+// If setItemSelected API method is called, we set the selection state of an
+// item but do not change anything else.
+add_task(function test_set_item_selected_method() {
+ for (let model of selectionModels) {
+ reset({ model });
+ widget.addItems(0, ["First", "Second", "Third", "Fourth", "Fifth"]);
+ stepFocus(true, { index: 0 }, "Initial focus on first item");
+ assertSelection([0], "Initial selection on first item");
+
+ if (model == "focus" || model == "browse") {
+ // This method always throws.
+ Assert.throws(
+ () => widget.setItemSelected(2, true),
+ /Widget does not support multi-selection/
+ );
+ // Even if it would not change the single selection state.
+ Assert.throws(
+ () => widget.setItemSelected(2, false),
+ /Widget does not support multi-selection/
+ );
+ Assert.throws(
+ () => widget.setItemSelected(0, true),
+ /Widget does not support multi-selection/
+ );
+ continue;
+ }
+
+ // Can select.
+ widget.setItemSelected(2, true);
+ assertFocus({ index: 0 }, "Same focus");
+ assertSelection([0, 2], "Item 2 becomes selected");
+
+ // And unselect.
+ widget.setItemSelected(0, false);
+ assertFocus({ index: 0 }, "Same focus");
+ assertSelection([2], "Item 0 is unselected");
+
+ // Does nothing extra if already selected/unselected.
+ widget.setItemSelected(2, true);
+ assertFocus({ index: 0 }, "Same focus");
+ assertSelection([2], "Same selected");
+
+ widget.setItemSelected(0, false);
+ assertFocus({ index: 0 }, "Same focus");
+ assertSelection([2], "Same selected");
+
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win);
+
+ // Select the focused item.
+ assertFocus({ index: 3 }, "Focus on item 3");
+ assertSelection([2], "Same selected");
+
+ widget.setItemSelected(3, true);
+ assertFocus({ index: 3 }, "Same focus");
+ assertSelection([2, 3], "Item 3 selected");
+
+ widget.setItemSelected(2, false);
+ assertFocus({ index: 3 }, "Same focus");
+ assertSelection([3], "Item 2 unselected");
+
+ // Can select none this way.
+ widget.setItemSelected(3, false);
+ assertFocus({ index: 3 }, "Same focus");
+ assertSelection([], "None selected");
+ }
+});
+
+/**
+ * Test navigation for the given direction.
+ *
+ * @param {string} model - The selection model to use.
+ * @param {string} direction - The layout direction of the widget.
+ * @param {object} keys - Navigation keys.
+ * @param {string} keys.forward - The key to move forward.
+ * @param {string} keys.backward - The key to move backward.
+ */
+function subtest_keyboard_navigation(model, direction, keys) {
+ let { forward: forwardKey, backward: backwardKey } = keys;
+ reset({ model, direction });
+ widget.addItems(0, ["First", "Second", "Third"]);
+
+ stepFocus(true, { index: 0 }, "Initially on first item");
+
+ // Without Ctrl, selection follows focus.
+
+ // Forward.
+ EventUtils.synthesizeKey(forwardKey, {}, win);
+ assertFocus({ index: 1 }, "Forward to second item");
+ assertSelection([1], "Second item becomes selected on focus");
+ EventUtils.synthesizeKey(forwardKey, {}, win);
+ assertFocus({ index: 2 }, "Forward to third item");
+ assertSelection([2], "Third item becomes selected on focus");
+ EventUtils.synthesizeKey(forwardKey, {}, win);
+ assertFocus({ index: 2 }, "Forward at end remains on third item");
+ assertSelection([2], "Third item remains selected");
+
+ // Backward.
+ EventUtils.synthesizeKey(backwardKey, {}, win);
+ assertFocus({ index: 1 }, "Backward to second item");
+ assertSelection([1], "Second item becomes selected on focus");
+ EventUtils.synthesizeKey(backwardKey, {}, win);
+ assertFocus({ index: 0 }, "Backward to first item");
+ assertSelection([0], "First item becomes selected on focus");
+ EventUtils.synthesizeKey(backwardKey, {}, win);
+ assertFocus({ index: 0 }, "Backward at end remains on first item");
+ assertSelection([0], "First item remains selected");
+
+ // End.
+ EventUtils.synthesizeKey("KEY_End", {}, win);
+ assertFocus({ index: 2 }, "Third becomes focused on End");
+ assertSelection([2], "Third becomes selected on End");
+ // Move to middle.
+ EventUtils.synthesizeKey(backwardKey, {}, win);
+ EventUtils.synthesizeKey("KEY_End", {}, win);
+ assertFocus({ index: 2 }, "Third becomes focused on End from second");
+ assertSelection([2], "Third becomes selected on End from second");
+ EventUtils.synthesizeKey("KEY_End", {}, win);
+ assertFocus({ index: 2 }, "Third remains focused on End from third");
+ assertSelection([2], "Third becomes selected on End from third");
+
+ // Home.
+ EventUtils.synthesizeKey("KEY_Home", {}, win);
+ assertFocus({ index: 0 }, "First becomes focused on Home");
+ assertSelection([0], "First becomes selected on Home");
+ // Move to middle.
+ EventUtils.synthesizeKey(forwardKey, {}, win);
+ EventUtils.synthesizeKey("KEY_Home", {}, win);
+ assertFocus({ index: 0 }, "First becomes focused on Home from second");
+ assertSelection([0], "First becomes selected on Home from second");
+ EventUtils.synthesizeKey("KEY_Home", {}, win);
+ assertFocus({ index: 0 }, "First remains focused on Home from first");
+ assertSelection([0], "First becomes selected on Home from first");
+
+ // With Ctrl key, selection does not follow focus.
+ if (model == "focus") {
+ // Disabled in "focus" model.
+ // Move to middle item.
+ EventUtils.synthesizeKey(forwardKey, {}, win);
+ assertFocus({ index: 1 }, "Second item is focused");
+ assertFocus({ index: 1 }, "Second item is selected");
+
+ for (let key of [backwardKey, forwardKey, "KEY_Home", "KEY_End"]) {
+ for (let shiftKey of [false, true]) {
+ info(
+ `Pressing Ctrl+${
+ shiftKey ? "Shift+" : ""
+ }${key} on "focus" model widget`
+ );
+ EventUtils.synthesizeKey(key, { ctrlKey: true, shiftKey }, win);
+ assertFocus({ index: 1 }, "Second item is still focused");
+ assertSelection([1], "Second item is still selected");
+ }
+ }
+ } else {
+ EventUtils.synthesizeKey(forwardKey, { ctrlKey: true }, win);
+ assertFocus({ index: 1 }, "Ctrl+Forward to second item");
+ assertSelection([0], "First item remains selected on Ctrl+Forward");
+
+ EventUtils.synthesizeKey(forwardKey, { ctrlKey: true }, win);
+ assertFocus({ index: 2 }, "Ctrl+Forward to third item");
+ assertSelection([0], "First item remains selected on Ctrl+Forward");
+
+ EventUtils.synthesizeKey(backwardKey, { ctrlKey: true }, win);
+ assertFocus({ index: 1 }, "Ctrl+Backward to second item");
+ assertSelection([0], "First item remains selected on Ctrl+Backward");
+
+ EventUtils.synthesizeKey(backwardKey, { ctrlKey: true }, win);
+ assertFocus({ index: 0 }, "Ctrl+Backward to first item");
+ assertSelection([0], "First item remains selected on Ctrl+Backward");
+
+ EventUtils.synthesizeKey("KEY_End", { ctrlKey: true }, win);
+ assertFocus({ index: 2 }, "Ctrl+End to third item");
+ assertSelection([0], "First item remains selected on Ctrl+End");
+
+ EventUtils.synthesizeKey(backwardKey, {}, win);
+ assertFocus({ index: 1 }, "Backward to second item");
+ assertSelection([1], "Selection moves with focus when not pressing Ctrl");
+
+ EventUtils.synthesizeKey("KEY_Home", { ctrlKey: true }, win);
+ assertFocus({ index: 0 }, "Ctrl+Home to first item");
+ assertSelection([1], "Second item remains selected on Ctrl+Home");
+
+ // Does nothing if combined with Shift.
+ for (let key of [backwardKey, forwardKey, "KEY_Home", "KEY_End"]) {
+ info(`Pressing Ctrl+Shift+${key} on "${model}" model widget`);
+ EventUtils.synthesizeKey(key, { ctrlKey: true, shiftKey: true }, win);
+ assertFocus({ index: 0 }, "First item is still focused");
+ assertSelection([1], "Second item is still selected");
+ }
+
+ // Even if focus remains the same, the selection is still updated if we
+ // don't press Ctrl.
+ EventUtils.synthesizeKey(backwardKey, {}, win);
+ assertFocus({ index: 0 }, "Focus remains on first item");
+ assertSelection(
+ [0],
+ "Selection moves to the first item since Ctrl was not pressed"
+ );
+ }
+}
+
+// Navigating with keyboard will move focus, and possibly selection.
+add_task(function test_keyboard_navigation() {
+ for (let model of selectionModels) {
+ subtest_keyboard_navigation(model, "top-to-bottom", {
+ forward: "KEY_ArrowDown",
+ backward: "KEY_ArrowUp",
+ });
+ subtest_keyboard_navigation(model, "right-to-left", {
+ forward: "KEY_ArrowLeft",
+ backward: "KEY_ArrowRight",
+ });
+ subtest_keyboard_navigation(model, "left-to-right", {
+ forward: "KEY_ArrowRight",
+ backward: "KEY_ArrowLeft",
+ });
+ }
+});
+
+/**
+ * A method to scroll the widget.
+ *
+ * @callback ScrollMethod
+ * @param {number} pos - The position/offset to scroll to.
+ */
+/**
+ * The position of an element, relative to the layout of the widget.
+ *
+ * @typedef {object} StartEndPositions
+ * @property {number} start - The starting position of the element in the
+ * direction of the widget's layout. The value should be a pixel offset
+ * from some fixed point, such that a higher value indicates an element
+ * further from the start of the widget.
+ * @property {number} end - The ending position of the element in the
+ * direction of the widget's layout. This should use the same fixed point
+ * as the start.
+ * @property {number} xStart - An X position in the client coordinates that
+ * points to the inside of the element, close to the starting corner. I.e.
+ * the block-start and inline-start.
+ * @property {number} yStart - A Y position in the client coordinates that
+ * points to the inside of the element, close to the starting corner.
+ * @property {number} xEnd - An X position in the client coordinates that
+ * points to the inside of the element, close to the ending corner. I.e.
+ * the block-end and inline-end.
+ * @property {number} yEnd - A Y position in the client coordinates that
+ * points to the inside of the element, close to the ending corner.
+ */
+/**
+ * A method to return the starting and ending positions of the bounding
+ * client rectangle of an element.
+ *
+ * @callback GetStartEndMethod
+ * @param {DOMRect} rect - The rectangle to get the positions of.
+ * @returns {object} positions
+/**
+ * Test page navigation for the given direction.
+ *
+ * @param {string} model - The selection model to use on the widget.
+ * @param {string} direction - The direction of the widget layout.
+ * @param {object} details - Details about the direction.
+ * @param {string} details.sizeName - The CSS style name that controls the
+ * widget size in the direction of widget layout.
+ * @param {string} details.forwardKey - The key to press to move forward one
+ * item.
+ * @param {string} details.backwardKey - The key to press to move backward
+ * one item.
+ * @param {ScrollMethod} details.scrollTo - A method to call to scroll the
+ * widget.
+ * @param {GetStartEndMethod} details.getStartEnd - A method to get the
+ * positioning of an element.
+ */
+function subtest_page_navigation(model, direction, details) {
+ let { sizeName, forwardKey, backwardKey, scrollTo, getStartEnd } = details;
+ function getStartEndBoundary(element) {
+ return getStartEnd(element.getBoundingClientRect());
+ }
+ function assertInView(expect, msg) {
+ let { first, firstClipped, last, lastClipped } = expect;
+ if (!firstClipped) {
+ firstClipped = 0;
+ }
+ if (!lastClipped) {
+ lastClipped = 0;
+ }
+ let { start: viewStart, end: viewEnd } = getStartEndBoundary(widget);
+ // The widget has a 1px border that should not contribute to the view
+ // size.
+ viewStart += 1;
+ viewEnd -= 1;
+ let firstStart = getStartEndBoundary(
+ widget.items[expect.first].element
+ ).start;
+ Assert.equal(
+ firstStart,
+ viewStart - firstClipped,
+ `Item ${first} should be at the start of the view (${viewStart}) clipped by ${firstClipped}: ${msg}`
+ );
+ if (expect.first > 0) {
+ Assert.lessOrEqual(
+ getStartEndBoundary(widget.items[expect.first - 1].element).end,
+ viewStart,
+ `Item ${expect.first - 1} should be out of view: ${msg}`
+ );
+ }
+ let lastEnd = getStartEndBoundary(widget.items[expect.last].element).end;
+ Assert.equal(
+ lastEnd,
+ viewEnd + lastClipped,
+ `Item ${last} should be at the end of the view (${viewEnd}) clipped by ${lastClipped}: ${msg}`
+ );
+ if (expect.last < widget.items.length - 1) {
+ Assert.greaterOrEqual(
+ getStartEndBoundary(widget.items[expect.last + 1].element).start,
+ viewEnd,
+ `Item ${expect.last + 1} should be out of view: ${msg}`
+ );
+ }
+ }
+ reset({ model, direction });
+ widget.addItems(
+ 0,
+ range(0, 70).map(i => `add-${i}`)
+ );
+ let { start: itemStart, end: itemEnd } = getStartEndBoundary(
+ widget.items[0].element
+ );
+ Assert.equal(itemEnd - itemStart, 30, "Expected item size");
+
+ assertInView({ first: 0, last: 19 }, "First 20 items in view");
+ stepFocus(true, { index: 0 }, "Move into widget");
+ assertSelection([0], "Fist item selected");
+ assertInView({ first: 0, last: 19 }, "First 20 items still in view");
+
+ // PageDown goes to the end of the current page.
+ EventUtils.synthesizeKey("KEY_PageDown", {}, win);
+ assertInView({ first: 0, last: 19 }, "First 20 item still in view");
+ assertFocus({ index: 19 }, "Focus moves to end of the page");
+ assertSelection([19], "Selection at end of the page");
+
+ // Pressing forward key will scroll the next item into view.
+ EventUtils.synthesizeKey(forwardKey, {}, win);
+ assertInView({ first: 1, last: 20 }, "Items 1 to 20 in view");
+ assertFocus({ index: 20 }, "Focus at end of the page");
+ assertSelection([20], "Selection at end of the page");
+
+ // Pressing backward will not change the view.
+ EventUtils.synthesizeKey(backwardKey, {}, win);
+ assertInView({ first: 1, last: 20 }, "Items 1 to 20 still in view");
+ assertFocus({ index: 19 }, "Focus moves up to 19");
+ assertSelection([19], "Selection moves up to 19");
+
+ // PageDown goes to the end of the current page.
+ EventUtils.synthesizeKey("KEY_PageDown", {}, win);
+ assertInView({ first: 1, last: 20 }, "Items 1 to 20 still in view");
+ assertFocus({ index: 20 }, "Focus moves to end of page");
+ assertSelection([20], "Selection moves to end of page");
+
+ // PageDown when already at the end of the page will move to the next
+ // page.
+ // The last index from the previous page (20) should still be visible at
+ // the top.
+ EventUtils.synthesizeKey("KEY_PageDown", {}, win);
+ assertInView({ first: 20, last: 39 }, "Items 20 to 39 in view");
+ assertFocus({ index: 39 }, "Focus moves to end of new page");
+ assertSelection([39], "Selection moves to end of new page");
+
+ // Another PageDown will do the same.
+ EventUtils.synthesizeKey("KEY_PageDown", {}, win);
+ assertInView({ first: 39, last: 58 }, "Items 39 to 58 in view");
+ assertFocus({ index: 58 }, "Focus moves to end of new page");
+ assertSelection([58], "Selection moves to end of new page");
+
+ // Last PageDown will take us to the end.
+ EventUtils.synthesizeKey("KEY_PageDown", {}, win);
+ assertInView({ first: 50, last: 69 }, "Last 20 items in view");
+ assertFocus({ index: 69 }, "Focus moves to end");
+ assertSelection([69], "Selection moves to end");
+
+ // Same thing in reverse with PageUp.
+ // PageUp goes to the start of the current page.
+ EventUtils.synthesizeKey("KEY_PageUp", {}, win);
+ assertInView({ first: 50, last: 69 }, "Last 20 item still in view");
+ assertFocus({ index: 50 }, "Focus moves to start of the page");
+ assertSelection([50], "Selection at end of the page");
+
+ // Pressing backward will scroll the previous item into view.
+ EventUtils.synthesizeKey(backwardKey, {}, win);
+ assertInView({ first: 49, last: 68 }, "Items 49 to 68 in view");
+ assertFocus({ index: 49 }, "Focus at start of the page");
+ assertSelection([49], "Selection at start of the page");
+
+ // Pressing forward will not change the view.
+ EventUtils.synthesizeKey(forwardKey, {}, win);
+ assertInView({ first: 49, last: 68 }, "Items 49 to 68 still in view");
+ assertFocus({ index: 50 }, "Focus moves up to 50");
+ assertSelection([50], "Selection moves up to 50");
+
+ // PageUp goes to the start of the current page.
+ EventUtils.synthesizeKey("KEY_PageUp", {}, win);
+ assertInView({ first: 49, last: 68 }, "Items 49 to 68 still in view");
+ assertFocus({ index: 49 }, "Focus moves to start of page");
+ assertSelection([49], "Selection moves to start of page");
+
+ // PageUp when already at the start of the page will move one page up.
+ // The first index from the previously shown page (49) should still be
+ // visible at the bottom.
+ EventUtils.synthesizeKey("KEY_PageUp", {}, win);
+ assertInView({ first: 30, last: 49 }, "Items 30 to 49 in view");
+ assertFocus({ index: 30 }, "Focus moves to start of new page");
+ assertSelection([30], "Selection moves to start of new page");
+
+ // Another PageUp will do the same.
+ EventUtils.synthesizeKey("KEY_PageUp", {}, win);
+ assertInView({ first: 11, last: 30 }, "Items 11 to 30 in view");
+ assertFocus({ index: 11 }, "Focus moves to start of new page");
+ assertSelection([11], "Selection moves to start of new page");
+
+ // Last PageUp will take us to the start.
+ EventUtils.synthesizeKey("KEY_PageUp", {}, win);
+ assertInView({ first: 0, last: 19 }, "Items 0 to 19 in view");
+ assertFocus({ index: 0 }, "Focus moves to start");
+ assertSelection([0], "Selection moves to start");
+
+ // PageDown with focus above the view. Focus should move to the end of the
+ // visible page.
+ scrollTo(120);
+ assertInView({ first: 4, last: 23 }, "Items 4 to 23 in view");
+ assertFocus({ index: 0 }, "Focus remains above the view");
+ assertSelection([0], "Selection remains above the view");
+
+ EventUtils.synthesizeKey("KEY_PageDown", {}, win);
+ assertInView({ first: 4, last: 23 }, "Same items in view");
+ assertFocus({ index: 23 }, "Focus moves to the end of the visible page");
+ assertSelection([23], "Selection moves to the end of the visible page");
+
+ // PageDown with focus below the view. Focus should shift by one page,
+ // with the previous focus at the top of the page.
+ scrollTo(60);
+ assertInView({ first: 2, last: 21 }, "Items 2 to 21 in view");
+ assertFocus({ index: 23 }, "Focus remains below the view");
+ assertSelection([23], "Selection remains below the view");
+
+ EventUtils.synthesizeKey("KEY_PageDown", {}, win);
+ assertInView(
+ { first: 23, last: 42 },
+ "View shifts by a page relative to focus"
+ );
+ assertFocus({ index: 42 }, "Focus moves to end of new page");
+ assertSelection([42], "Selection moves to end of new page");
+
+ // PageUp with focus below the view. Focus should move to the start of the
+ // visible page.
+ scrollTo(630);
+ assertInView({ first: 21, last: 40 }, "Items 21 to 40 in view");
+ assertFocus({ index: 42 }, "Focus remains below the view");
+ assertSelection([42], "Selection remains below the view");
+
+ EventUtils.synthesizeKey("KEY_PageUp", {}, win);
+ assertInView({ first: 21, last: 40 }, "Same items in view");
+ assertFocus({ index: 21 }, "Focus moves to the start of the visible page");
+ assertSelection([21], "Selection moves to the start of the visible page");
+
+ // PageUp with focus above the view. Focus should shift by one page, with
+ // the previous focus at the bottom of the page.
+ scrollTo(750);
+ assertInView({ first: 25, last: 44 }, "Items 25 to 44 in view");
+ assertFocus({ index: 21 }, "Focus remains above the view");
+ assertSelection([21], "Selection remains above the view");
+
+ EventUtils.synthesizeKey("KEY_PageUp", {}, win);
+ assertInView(
+ { first: 2, last: 21 },
+ "View shifts by a page relative to focus"
+ );
+ assertFocus({ index: 2 }, "Focus moves to start of new page");
+ assertSelection([2], "Selection moves to start of new page");
+
+ // Test when view does not exactly fit items.
+ for (let sizeDiff of [0, 10, 15, 20]) {
+ info(`Reducing widget size by ${sizeDiff}px`);
+ widget.style[sizeName] = `${600 - sizeDiff}px`;
+
+ // When we reduce the size of the view by half an item or more, we
+ // reduce the page size from 20 to 19.
+ // NOTE: At each sizeDiff still fits strictly more than 19 items in its
+ // view.
+ let pageSize = sizeDiff < 15 ? 20 : 19;
+
+ // Make sure that Home and End keys scroll the view and clip the items
+ // as expected.
+ EventUtils.synthesizeKey("KEY_Home", {}, win);
+ assertInView(
+ { first: 0, last: 19, lastClipped: sizeDiff },
+ `Start of view with last item clipped by ${sizeDiff}px`
+ );
+ assertFocus({ index: 0 }, "First item has focus");
+ assertSelection([0], "First item is selected");
+
+ EventUtils.synthesizeKey("KEY_End", {}, win);
+ assertInView(
+ { first: 50, firstClipped: sizeDiff, last: 69 },
+ `End of view with first item clipped by ${sizeDiff}px`
+ );
+ assertFocus({ index: 69 }, "Last item has focus");
+ assertSelection([69], "Last item is selected");
+
+ for (let lastClipped of [0, 10, 15, 20]) {
+ info(`Testing PageDown with last item clipped by ${lastClipped}px`);
+ // Across all sizeDiff and lastClipped values we still want the last
+ // item to be index 21 clipped by lastClipped.
+ // E.g. when sizeDiff is 10 and lastClipped is 10, then the scroll
+ // will be 60px and the first item will be index 2 with no clipping.
+ // But when the sizeDiff is 10 and the lastClipped is 20, then the
+ // scroll will be 50px and the first item will be index 1 with 20px
+ // clipping.
+ let scroll = 60 + sizeDiff - lastClipped;
+ scrollTo(scroll);
+ let first = Math.floor(scroll / 30);
+ let firstClipped = scroll % 30;
+ clickWidgetItem(3, {});
+ assertInView(
+ { first, firstClipped, last: 21, lastClipped },
+ `Last item 21 in view clipped by ${lastClipped}px`
+ );
+ assertFocus({ index: 3 }, "Focus on item 3");
+ assertSelection([3], "Selection on item 3");
+
+ EventUtils.synthesizeKey("KEY_PageDown", {}, win);
+ let pageEnd;
+ if (lastClipped < 15) {
+ // The last item is more than half in view, so counts as part of the
+ // page.
+ // NOTE: Index of the first item is always "2", even if it was "1"
+ // before the scroll, because the view fits (19, 20] items.
+ assertInView(
+ { first: 2, firstClipped: sizeDiff, last: 21 },
+ "Scrolls down to fully include the last item 21"
+ );
+ pageEnd = 21;
+ } else {
+ // The last item is half or less in view, so only the one before it
+ // counts as being part of the page.
+ assertInView(
+ { first, firstClipped, last: 21, lastClipped },
+ "Same view"
+ );
+ pageEnd = 20;
+ }
+ assertFocus({ index: pageEnd }, "Focus moves to pageEnd");
+ assertSelection([pageEnd], "Selection moves to pageEnd");
+
+ // Reset scroll to test scrolling when the focus is already at the
+ // pageEnd.
+ scrollTo(scroll);
+ assertInView(
+ { first, firstClipped, last: 21, lastClipped },
+ `Last item 21 in view clipped by ${lastClipped}px`
+ );
+
+ // PageDown again will move by a page. The new end of the page will be
+ // scrolled just into view at the bottom.
+ EventUtils.synthesizeKey("KEY_PageDown", {}, win);
+ let newPageEnd = pageEnd + pageSize - 1;
+ // NOTE: If the previous pageEnd would fit mostly in view, then we
+ // expect the first item in the view to be this item. Otherwise, we
+ // expect it to be the one before, which will ensure the previous
+ // pageEnd is fully visible.
+ firstClipped = sizeDiff;
+ first = sizeDiff < 15 ? pageEnd : pageEnd - 1;
+ assertInView(
+ { first, firstClipped, last: newPageEnd },
+ "New page end scrolled into view, previous page end mostly visible"
+ );
+ assertFocus({ index: newPageEnd }, "Focus moves to end of new page");
+ assertSelection([newPageEnd], "Selection moves to end of new page");
+
+ // PageUp reverses the focus.
+ // We don't test the the view since that is handled lower down.
+ EventUtils.synthesizeKey("KEY_PageUp", {}, win);
+ assertFocus({ index: pageEnd }, "Focus returns to pageEnd");
+ assertSelection([pageEnd], "Selection returns to pageEnd");
+ }
+
+ for (let firstClipped of [0, 10, 15, 20]) {
+ // Across all sizeDiff and firstClipped values we still want the first
+ // item to be index 24 clipped by firstClipped.
+ // E.g. when sizeDiff is 10 and firstClipped is 10, then the scroll
+ // will be 730px and the last item will be index 44 with no clipping.
+ // But when the sizeDiff is 10 and the firstClipped is 0, then the
+ // scroll will be 720px and the last item will be index 43 with 10px
+ // clipping.
+ info(`Testing PageUp with first item clipped by ${firstClipped}px`);
+ scrollTo(720 + firstClipped);
+ let viewEnd = 720 + firstClipped + 600 - sizeDiff;
+ let last = Math.floor(viewEnd / 30);
+ let lastClipped = 30 - (viewEnd % 30);
+ clickWidgetItem(42, {});
+ assertInView(
+ { first: 24, firstClipped, last, lastClipped },
+ `First item 24 in view clipped by ${firstClipped}px`
+ );
+ assertFocus({ index: 42 }, "Focus on item 42");
+ assertSelection([42], "Selection on item 42");
+
+ EventUtils.synthesizeKey("KEY_PageUp", {}, win);
+ let pageStart;
+ if (firstClipped < 15) {
+ // The first item is more than half in view, so counts as part of
+ // the page.
+ // NOTE: Index of the last item is always "43", even if it was "44"
+ // before the scroll, because the view fits (19, 20] items.
+ assertInView(
+ { first: 24, last: 43, lastClipped: sizeDiff },
+ "Scrolls up to fully include the first item 24"
+ );
+ pageStart = 24;
+ } else {
+ // The first item is half or less in view, so only the one after it
+ // counts as being part of the page.
+ assertInView(
+ { first: 24, firstClipped, last, lastClipped },
+ "Same view"
+ );
+ pageStart = 25;
+ }
+ assertFocus({ index: pageStart }, "Focus moves to pageStart");
+ assertSelection([pageStart], "Selection moves to pageStart");
+
+ // Reset scroll.
+ scrollTo(720 + firstClipped);
+ assertInView(
+ { first: 24, firstClipped, last, lastClipped },
+ `First item 24 in view clipped by ${firstClipped}px`
+ );
+
+ // PageUp again will move by a page. The new start of the page will be
+ // scrolled just into view at the top.
+ EventUtils.synthesizeKey("KEY_PageUp", {}, win);
+ let newPageStart = pageStart - pageSize + 1;
+ // NOTE: If the previous pageStart would fit mostly in view, then we
+ // expect the last item in the view to be this item. Otherwise, we
+ // expect it to be the one after, which will ensure the previous
+ // pageStart is fully visible.
+ lastClipped = sizeDiff;
+ last = sizeDiff < 15 ? pageStart : pageStart + 1;
+ assertInView(
+ { first: newPageStart, last, lastClipped },
+ "New page end scrolled into view, previous page end mostly visible"
+ );
+ assertFocus({ index: newPageStart }, "Focus moves to start of new page");
+ assertSelection([newPageStart], "Selection moves to start of new page");
+
+ // PageDown reverses the focus.
+ // We don't test the the view since that is handled further up.
+ EventUtils.synthesizeKey("KEY_PageDown", {}, win);
+ assertFocus({ index: pageStart }, "Focus returns to pageStart");
+ assertSelection([pageStart], "Selection returns to pageStart");
+ }
+ }
+
+ // When widget only fits 1 visible item or less.
+ for (let size of [10, 20, 30, 45, 50]) {
+ info(`Resizing widget to ${size}px`);
+ widget.style[sizeName] = `${size}px`;
+
+ scrollTo(600);
+ // When the view size is less than the size of an item, we cannot always
+ // click the center of the item, so we need to click the start instead.
+ let { xStart, yStart } = getStartEndBoundary(widget.items[20].element);
+ EventUtils.synthesizeMouseAtPoint(xStart, yStart, {}, win);
+ let last = size > 30 ? 21 : 20;
+ let lastClipped = size > 30 ? 60 - size : 30 - size;
+ assertInView({ first: 20, last, lastClipped }, "Small number of items");
+ assertFocus({ index: 20 }, "Focus on item 20");
+ assertSelection([20], "Item 20 selected");
+
+ EventUtils.synthesizeKey("KEY_PageDown", {}, win);
+ if (size <= 45) {
+ // Only 1 or 0 items fit on the page, so does nothing.
+ assertInView({ first: 20, last, lastClipped }, "Same view");
+ assertFocus({ index: 20 }, "Same focus");
+ assertSelection([20], "Same selected");
+ } else {
+ // 2 items fit visibly on the page, so acts as normal.
+ assertInView(
+ { first: 20, firstClipped: lastClipped, last: 21 },
+ "Last item scrolled into view"
+ );
+ assertFocus({ index: 21 }, "Focus increases by one");
+ assertSelection([21], "Selected moves to focus");
+ }
+
+ scrollTo(660 - size);
+ let { xEnd, yEnd } = getStartEndBoundary(widget.items[21].element);
+ EventUtils.synthesizeMouseAtPoint(xEnd, yEnd, {}, win);
+ let first = size > 30 ? 20 : 21;
+ let firstClipped = size > 30 ? 60 - size : 30 - size;
+ assertInView({ first, firstClipped, last: 21 }, "Small number of items");
+ assertFocus({ index: 21 }, "Focus on item 21");
+ assertSelection([21], "Item 21 selected");
+
+ EventUtils.synthesizeKey("KEY_PageUp", {}, win);
+ if (size <= 45) {
+ // Only 1 or 0 items fit on the page, so does nothing.
+ assertInView({ first, firstClipped, last: 21 }, "Same view");
+ assertFocus({ index: 21 }, "Same focus");
+ assertSelection([21], "Same selected");
+ } else {
+ // 2 items fit visibly on the page, so acts as normal.
+ assertInView(
+ { first: 20, last: 21, lastClipped: firstClipped },
+ "First item scrolled into view"
+ );
+ assertFocus({ index: 20 }, "Focus decreases by one");
+ assertSelection([20], "Selected moves to focus");
+ }
+ }
+ widget.style[sizeName] = null;
+
+ // Disable page navigation.
+ // This would be used when the item sizes or the page layout do not allow
+ // for page navigation, or if PageUp and PageDown should be used for something
+ // else.
+ widget.toggleAttribute("no-pages", true);
+
+ let gotKeys = [];
+ let keydownListener = event => {
+ gotKeys.push(event.key);
+ };
+ win.document.body.addEventListener("keydown", keydownListener);
+ scrollTo(600);
+ clickWidgetItem(20, {});
+ assertInView({ first: 20, last: 39 }, "Items 20 to 39 in view");
+ assertFocus({ index: 20 }, "First item focused");
+ assertSelection([20], "First item selected");
+
+ EventUtils.synthesizeKey("KEY_PageUp", {}, win);
+ assertInView({ first: 20, last: 39 }, "Same view");
+ assertFocus({ index: 20 }, "Same focus");
+ assertSelection([20], "Same selected");
+ Assert.deepEqual(gotKeys, ["PageUp"], "PageUp reaches document body");
+ gotKeys = [];
+ EventUtils.synthesizeKey("KEY_PageDown", {}, win);
+ assertInView({ first: 20, last: 39 }, "Same view");
+ assertFocus({ index: 20 }, "Same focus");
+ assertSelection([20], "Same selected");
+ Assert.deepEqual(gotKeys, ["PageDown"], "PageDown reaches document body");
+ gotKeys = [];
+
+ clickWidgetItem(39, {});
+ assertInView({ first: 20, last: 39 }, "Items 20 to 39 in view");
+ assertFocus({ index: 39 }, "Last item focused");
+ assertSelection([39], "Last item selected");
+
+ EventUtils.synthesizeKey("KEY_PageUp", {}, win);
+ assertInView({ first: 20, last: 39 }, "Same view");
+ assertFocus({ index: 39 }, "Same focus");
+ assertSelection([39], "Same selected");
+ Assert.deepEqual(gotKeys, ["PageUp"], "PageUp reaches document body");
+ gotKeys = [];
+ EventUtils.synthesizeKey("KEY_PageDown", {}, win);
+ assertInView({ first: 20, last: 39 }, "Same view");
+ assertFocus({ index: 39 }, "Same focus");
+ assertSelection([39], "Same selected");
+ Assert.deepEqual(gotKeys, ["PageDown"], "PageDown reaches document body");
+ gotKeys = [];
+
+ widget.removeAttribute("no-pages");
+
+ // With page navigation enabled key-presses do not reach the document body.
+ EventUtils.synthesizeKey("KEY_PageUp", {}, win);
+ Assert.deepEqual(gotKeys, [], "No key reaches document body");
+ EventUtils.synthesizeKey("KEY_PageDown", {}, win);
+ Assert.deepEqual(gotKeys, [], "No key reaches document body");
+
+ win.document.body.removeEventListener("keydown", keydownListener);
+
+ // Test with modifiers.
+ for (let { shiftKey, ctrlKey } of [
+ { shiftKey: true, ctrlKey: true },
+ { shiftKey: false, ctrlKey: true },
+ { shiftKey: true, ctrlKey: false },
+ ]) {
+ info(
+ `Pressing ${ctrlKey ? "Ctrl+" : ""}${shiftKey ? "Shift+" : ""}PageUp/Down`
+ );
+ EventUtils.synthesizeKey("KEY_Home", {}, win);
+ EventUtils.synthesizeKey(forwardKey, {}, win);
+ assertInView({ first: 0, last: 19 }, "First 20 items in view");
+ assertFocus({ index: 1 }, "Item 1 has focus");
+ assertSelection([1], "Item 1 is selected");
+
+ EventUtils.synthesizeKey("KEY_PageDown", { ctrlKey, shiftKey }, win);
+ assertInView({ first: 0, last: 19 }, "Same view");
+ if (
+ (ctrlKey && shiftKey) ||
+ model == "focus" ||
+ (model == "browse" && shiftKey)
+ ) {
+ // Does nothing.
+ assertFocus({ index: 1 }, "Same focus");
+ assertSelection([1], "Same selected");
+ // Move focus to the end of the view.
+ clickWidgetItem(19, {});
+ assertFocus({ index: 19 }, "Focus at end of page");
+ assertSelection([19], "Selected at end of page");
+ } else {
+ assertFocus({ index: 19 }, "Focus moves to end of page");
+ if (ctrlKey) {
+ // Splits focus from selected.
+ assertSelection([1], "Same selected");
+ } else {
+ assertSelection(range(1, 19), "Range selection from 1 to 19");
+ }
+ }
+ // And again, with focus at the end of the page.
+ EventUtils.synthesizeKey("KEY_PageDown", { ctrlKey, shiftKey }, win);
+ if (
+ (ctrlKey && shiftKey) ||
+ model == "focus" ||
+ (model == "browse" && shiftKey)
+ ) {
+ // Does nothing.
+ assertInView({ first: 0, last: 19 }, "Same view");
+ assertFocus({ index: 19 }, "Same focus");
+ assertSelection([19], "Same selected");
+ } else {
+ assertInView({ first: 19, last: 38 }, "View scrolls to focus");
+ assertFocus({ index: 38 }, "Focus moves to end of new page");
+ if (ctrlKey) {
+ // Splits focus from selected.
+ assertSelection([1], "Same selected");
+ } else {
+ assertSelection(range(1, 38), "Range selection from 1 to 38");
+ }
+ }
+
+ EventUtils.synthesizeKey("KEY_End", {}, win);
+ EventUtils.synthesizeKey(backwardKey, {}, win);
+ assertInView({ first: 50, last: 69 }, "Last 20 items in view");
+ assertFocus({ index: 68 }, "Item 68 has focus");
+ assertSelection([68], "Item 68 is selected");
+
+ EventUtils.synthesizeKey("KEY_PageUp", { ctrlKey, shiftKey }, win);
+ assertInView({ first: 50, last: 69 }, "Same view");
+ if (
+ (ctrlKey && shiftKey) ||
+ model == "focus" ||
+ (model == "browse" && shiftKey)
+ ) {
+ // Does nothing.
+ assertFocus({ index: 68 }, "Same focus");
+ assertSelection([68], "Same selected");
+ // Move focus to the end of the view.
+ clickWidgetItem(50, {});
+ assertFocus({ index: 50 }, "Focus at start of page");
+ assertSelection([50], "Selected at start of page");
+ } else {
+ assertFocus({ index: 50 }, "Focus moves to start of page");
+ if (ctrlKey) {
+ // Splits focus from selected.
+ assertSelection([68], "Same selected");
+ } else {
+ assertSelection(range(50, 19), "Range selection from 50 to 68");
+ }
+ }
+ // And again, with focus at the start of the page.
+ EventUtils.synthesizeKey("KEY_PageUp", { ctrlKey, shiftKey }, win);
+ if (
+ (ctrlKey && shiftKey) ||
+ model == "focus" ||
+ (model == "browse" && shiftKey)
+ ) {
+ // Does nothing.
+ assertInView({ first: 50, last: 69 }, "Same view");
+ assertFocus({ index: 50 }, "Same focus");
+ assertSelection([50], "Same selected");
+ } else {
+ assertInView({ first: 31, last: 50 }, "View scrolls to focus");
+ assertFocus({ index: 31 }, "Focus moves to start of new page");
+ if (ctrlKey) {
+ // Splits focus from selected.
+ assertSelection([68], "Same selected");
+ } else {
+ assertSelection(range(31, 38), "Range selection from 31 to 68");
+ }
+ }
+ }
+
+ // Does nothing with an empty widget.
+ reset({ model, direction });
+ stepFocus(true, { element: widget }, "Focus on empty widget");
+ assertState([], "Empty");
+ EventUtils.synthesizeKey("KEY_PageDown", {}, win);
+ assertFocus({ element: widget }, "No change in focus");
+ assertState([], "Empty");
+ EventUtils.synthesizeKey("KEY_PageUp", {}, win);
+ assertFocus({ element: widget }, "No change in focus");
+ assertState([], "Empty");
+}
+
+// Test that pressing PageUp or PageDown shifts the view according to the
+// visible items.
+add_task(function test_page_navigation() {
+ for (let model of selectionModels) {
+ subtest_page_navigation(model, "top-to-bottom", {
+ sizeName: "height",
+ forwardKey: "KEY_ArrowDown",
+ backwardKey: "KEY_ArrowUp",
+ scrollTo: pos => {
+ widget.scrollTop = pos;
+ },
+ getStartEnd: rect => {
+ return {
+ start: rect.top,
+ end: rect.bottom,
+ xStart: rect.right - 1,
+ xEnd: rect.left + 1,
+ yStart: rect.top + 1,
+ yEnd: rect.bottom - 1,
+ };
+ },
+ });
+ subtest_page_navigation(model, "right-to-left", {
+ sizeName: "width",
+ forwardKey: "KEY_ArrowLeft",
+ backwardKey: "KEY_ArrowRight",
+ scrollTo: pos => {
+ widget.scrollLeft = -pos;
+ },
+ getStartEnd: rect => {
+ return {
+ start: -rect.right,
+ end: -rect.left,
+ xStart: rect.right - 1,
+ xEnd: rect.left + 1,
+ yStart: rect.top + 1,
+ yEnd: rect.bottom - 1,
+ };
+ },
+ });
+ subtest_page_navigation(model, "left-to-right", {
+ sizeName: "width",
+ forwardKey: "KEY_ArrowRight",
+ backwardKey: "KEY_ArrowLeft",
+ scrollTo: pos => {
+ widget.scrollLeft = pos;
+ },
+ getStartEnd: rect => {
+ return {
+ start: rect.left,
+ end: rect.right,
+ xStart: rect.left + 1,
+ xEnd: rect.right - 1,
+ yStart: rect.top + 1,
+ yEnd: rect.bottom - 1,
+ };
+ },
+ });
+ }
+});
+
+// Using Space to select items.
+add_task(function test_space_selection() {
+ for (let model of selectionModels) {
+ reset({ model, direction: "right-to-left" });
+ widget.addItems(0, ["First", "Second", "Third", "Fourth"]);
+
+ stepFocus(true, { index: 0 }, "Move focus to first item");
+ assertSelection([0], "First item is selected");
+
+ // Selecting an already selected item does nothing.
+ EventUtils.synthesizeKey(" ", {}, win);
+ assertFocus({ index: 0 }, "First item still has focus");
+ assertSelection([0], "First item is still selected");
+
+ if (model == "focus") {
+ // Just move to second item as set up for the loop.
+ EventUtils.synthesizeKey("KEY_ArrowLeft", {}, win);
+ } else {
+ // Selecting a non-selected item will move selection to it.
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win);
+ assertFocus({ index: 1 }, "Second item has focus");
+ assertSelection([0], "First item is still selected");
+ EventUtils.synthesizeKey(" ", {}, win);
+ assertFocus({ index: 1 }, "Second item still has focus");
+ assertSelection([1], "Second item becomes selected");
+ }
+
+ // Ctrl + Space will toggle the selection if multi-selection is supported.
+ EventUtils.synthesizeKey(" ", { ctrlKey: true }, win);
+ if (model == "focus") {
+ // Did nothing.
+ assertFocus({ index: 1 }, "Second item still has focus");
+ assertSelection([1], "Second item is still selected");
+ } else if (model == "browse") {
+ // Did nothing.
+ assertFocus({ index: 1 }, "Second item still has focus");
+ assertSelection([1], "Second item is still selected");
+ // Make sure nothing happens when on a non-selected item as well.
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win);
+ assertFocus({ index: 2 }, "Third item has focus");
+ assertSelection([1], "Second item is still selected");
+ EventUtils.synthesizeKey(" ", { ctrlKey: true }, win);
+ assertFocus({ index: 2 }, "Third item still has focus");
+ assertSelection([1], "Second item is still selected");
+ // Restore the previous state.
+ EventUtils.synthesizeKey("KEY_ArrowRight", {}, win);
+ } else {
+ // Unselected the item.
+ assertFocus({ index: 1 }, "Second item still has focus");
+ assertSelection([], "Second item was un-selected");
+ // Toggle again.
+ EventUtils.synthesizeKey(" ", { ctrlKey: true }, win);
+ assertFocus({ index: 1 }, "Second item still has focus");
+ assertSelection([1], "Second item was re-selected");
+
+ // Do on another index.
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win);
+ assertFocus({ index: 3 }, "Fourth item has focus");
+ assertSelection([1], "Second item is still selected");
+ EventUtils.synthesizeKey(" ", { ctrlKey: true }, win);
+ assertFocus({ index: 3 }, "Fourth item still has focus");
+ assertSelection([1, 3], "Fourth item becomes selected as well");
+
+ // Move to third without clearing.
+ EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win);
+ assertFocus({ index: 2 }, "Third item has focus");
+ assertSelection([1, 3], "Fourth and second item remain selected");
+
+ // Merge the two ranges together.
+ EventUtils.synthesizeKey(" ", { ctrlKey: true }, win);
+ assertFocus({ index: 2 }, "Third item still has focus");
+ assertSelection([1, 2, 3], "Third item becomes selected");
+
+ // Shrink the range at the end.
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win);
+ assertFocus({ index: 3 }, "Fourth item has focus");
+ assertSelection([1, 2, 3], "Same selection");
+ EventUtils.synthesizeKey(" ", { ctrlKey: true }, win);
+ assertFocus({ index: 3 }, "Fourth item still has focus");
+ assertSelection([1, 2], "Fourth item unselected");
+
+ // Shrink the range at the start.
+ EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win);
+ assertFocus({ index: 1 }, "Second item has focus");
+ assertSelection([1, 2], "Same selection");
+ EventUtils.synthesizeKey(" ", { ctrlKey: true }, win);
+ assertFocus({ index: 1 }, "Second item still has focus");
+ assertSelection([2], "Second item unselected");
+
+ // No selection.
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win);
+ assertFocus({ index: 2 }, "Third item has focus");
+ assertSelection([2], "Same selection");
+ EventUtils.synthesizeKey(" ", { ctrlKey: true }, win);
+ assertFocus({ index: 2 }, "Third item still has focus");
+ assertSelection([], "Third item unselected");
+
+ // Using arrow keys without modifier will re-introduce a single selection.
+ EventUtils.synthesizeKey("KEY_ArrowRight");
+ assertFocus({ index: 1 }, "Second item has focus");
+ assertSelection([1], "Second item becomes selected");
+
+ // Grow range at the start.
+ EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win);
+ assertFocus({ index: 0 }, "First item has focus");
+ assertSelection([1], "Same selection");
+ EventUtils.synthesizeKey(" ", { ctrlKey: true }, win);
+ assertFocus({ index: 0 }, "First item still has focus");
+ assertSelection([0, 1], "First item becomes selected");
+
+ // Grow range at the end.
+ EventUtils.synthesizeKey("KEY_End", { ctrlKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win);
+ assertFocus({ index: 2 }, "Third item has focus");
+ assertSelection([0, 1], "Same selection");
+ EventUtils.synthesizeKey(" ", { ctrlKey: true }, win);
+ assertFocus({ index: 2 }, "Third item still has focus");
+ assertSelection([0, 1, 2], "Third item becomes selected");
+
+ // Split the range in half.
+ EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win);
+ assertFocus({ index: 1 }, "Second item has focus");
+ assertSelection([0, 1, 2], "Same selection");
+ EventUtils.synthesizeKey(" ", { ctrlKey: true }, win);
+ assertFocus({ index: 1 }, "Second item still has focus");
+ assertSelection([0, 2], "Second item unselected");
+
+ // Pressing Space without a modifier clears the multi-selection.
+ EventUtils.synthesizeKey(" ", {}, win);
+ }
+
+ // Make sure we are in the expected shared state between models.
+ assertFocus({ index: 1 }, "Second item has focus");
+ assertSelection([1], "Second item is selected");
+
+ // Shift + Space will do nothing.
+ for (let ctrlKey of [false, true]) {
+ info(`Pressing ${ctrlKey ? "Ctrl+" : ""}Shift+space on item`);
+ // On selected item.
+ EventUtils.synthesizeKey(" ", { ctrlKey, shiftKey: true }, win);
+ assertFocus({ index: 1 }, "Second item still has focus");
+ assertSelection([1], "Second item is still selected");
+
+ if (model == "focus") {
+ continue;
+ }
+
+ // On non-selected item.
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win);
+ assertFocus({ index: 2 }, "Third item has focus");
+ assertSelection([1], "Second item is still selected");
+ EventUtils.synthesizeKey(" ", { ctrlKey, shiftKey: true }, win);
+ assertFocus({ index: 2 }, "Third item still has focus");
+ assertSelection([1], "Second item is still selected");
+
+ // Restore for next loop.
+ EventUtils.synthesizeKey("KEY_ArrowRight", {}, win);
+ assertFocus({ index: 1 }, "Second item has focus");
+ assertSelection([1], "Second item is selected");
+ }
+ }
+});
+
+// Clicking an item will focus and select it.
+add_task(function test_clicking_items() {
+ for (let model of selectionModels) {
+ reset({ model, direction: "right-to-left" });
+ widget.addItems(0, [
+ "First",
+ "Second",
+ "Third",
+ "Fourth",
+ "Fifth",
+ "Sixth",
+ "Seventh",
+ "Eighth",
+ ]);
+
+ assertFocus({ element: before }, "Focus initially outside widget");
+ assertSelection([], "No initial selection");
+
+ // Focus moves into widget, onto the clicked item.
+ clickWidgetItem(1, {});
+ assertFocus({ index: 1 }, "Focus clicked second item");
+ assertSelection([1], "Selected clicked second item");
+
+ // Focus moves to different item.
+ clickWidgetItem(2, {});
+ assertFocus({ index: 2 }, "Focus clicked third item");
+ assertSelection([2], "Selected clicked third item");
+
+ // Click same item.
+ clickWidgetItem(2, {});
+ assertFocus({ index: 2 }, "Focus remains on third item");
+ assertSelection([2], "Selected remains on third item");
+
+ // Focus outside widget, focus moves but selection remains.
+ before.focus();
+ assertFocus({ element: before }, "Focus outside widget");
+ assertSelection([2], "Selected remains on third item");
+
+ // Clicking same item will return focus to it.
+ clickWidgetItem(2, {});
+ assertFocus({ index: 2 }, "Focus returns to third item");
+ assertSelection([2], "Selected remains on third item");
+
+ // Do the same, but return to a different item.
+ before.focus();
+ assertFocus({ element: before }, "Focus outside widget");
+ assertSelection([2], "Selected remains on third item");
+
+ // Clicking same item will return focus to it.
+ clickWidgetItem(1, {});
+ assertFocus({ index: 1 }, "Focus moves to second item");
+ assertSelection([1], "Selected moves to second item");
+
+ // Switching to keyboard works.
+ EventUtils.synthesizeKey("KEY_ArrowRight", {}, win);
+ assertFocus({ index: 0 }, "Focus moves to first item");
+ assertSelection([0], "Selected moves to first item");
+
+ // Returning to mouse works.
+ clickWidgetItem(1, {});
+ assertFocus({ index: 1 }, "Focus moves to second item");
+ assertSelection([1], "Selected moves to second item");
+
+ // Toggle selection with Ctrl+Click.
+ clickWidgetItem(3, { ctrlKey: true });
+ if (model == "browse-multi") {
+ assertFocus({ index: 3 }, "Focus moves to fourth item");
+ assertSelection([1, 3], "Fourth item is selected");
+
+ clickWidgetItem(7, { ctrlKey: true });
+ assertFocus({ index: 7 }, "Focus moves to eighth item");
+ assertSelection([1, 3, 7], "Eighth item selected");
+
+ // Extend selection range by one after.
+ clickWidgetItem(4, { ctrlKey: true });
+ assertFocus({ index: 4 }, "Focus moves to fifth item");
+ assertSelection([1, 3, 4, 7], "Fifth item is selected");
+
+ // Extend selection range by one before.
+ clickWidgetItem(6, { ctrlKey: true });
+ assertFocus({ index: 6 }, "Focus moves to seventh item");
+ assertSelection([1, 3, 4, 6, 7], "Seventh item is selected");
+
+ // Merge the two ranges together.
+ clickWidgetItem(5, { ctrlKey: true });
+ assertFocus({ index: 5 }, "Focus moves to sixth item");
+ assertSelection([1, 3, 4, 5, 6, 7], "Sixth item is selected");
+
+ // Reverse by unselecting.
+ clickWidgetItem(7, { ctrlKey: true });
+ assertFocus({ index: 7 }, "Focus moves to eight item");
+ assertSelection([1, 3, 4, 5, 6], "Eight item is unselected");
+
+ clickWidgetItem(3, { ctrlKey: true });
+ assertFocus({ index: 3 }, "Focus moves to fourth item");
+ assertSelection([1, 4, 5, 6], "Fourth item is unselected");
+
+ // Split a range.
+ clickWidgetItem(5, { ctrlKey: true });
+ assertFocus({ index: 5 }, "Focus moves to sixth item");
+ assertSelection([1, 4, 6], "Sixth item is unselected");
+
+ clickWidgetItem(1, { ctrlKey: true });
+ assertFocus({ index: 1 }, "Focus moves to second item");
+ assertSelection([4, 6], "Second item is unselected");
+
+ clickWidgetItem(6, { ctrlKey: true });
+ assertFocus({ index: 6 }, "Focus moves to seventh item");
+ assertSelection([4], "Seventh item is unselected");
+
+ // Can get zero-selection.
+ clickWidgetItem(4, { ctrlKey: true });
+ assertFocus({ index: 4 }, "Focus moves to fifth item");
+ assertSelection([], "None selected");
+
+ // Get into the same state as the other case.
+ clickWidgetItem(1, { ctrlKey: true });
+ assertFocus({ index: 1 }, "Focus moves to second item");
+ assertSelection([1], "Second item is selected");
+ } else {
+ // No multi-selection, so does nothing.
+ assertFocus({ index: 1 }, "Focus remains on second item");
+ assertSelection([1], "Second item remains selected");
+ }
+
+ // Ctrl+Shift+Click does nothing in all models.
+ clickWidgetItem(2, { ctrlKey: true, shiftKey: true });
+ assertFocus({ index: 1 }, "Focus remains on second item");
+ assertSelection([1], "Second item remains selected");
+ }
+});
+
+add_task(function test_select_all() {
+ for (let model of selectionModels) {
+ reset({ model, direction: "right-to-left" });
+ widget.addItems(0, ["First", "Second", "Third", "Fourth", "Fifth"]);
+
+ stepFocus(true, { index: 0 }, "Move focus to first item");
+ assertSelection([0], "First item is selected");
+
+ EventUtils.synthesizeKey("KEY_ArrowLeft", {}, win);
+ assertFocus({ index: 1 }, "Focus on second item");
+ assertSelection([1], "Second item is selected");
+
+ selectAllShortcut();
+
+ assertFocus({ index: 1 }, "Focus remains on second item");
+ if (model == "browse-multi") {
+ assertSelection([0, 1, 2, 3, 4], "All items are selected");
+ // Can insert a hole.
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win);
+ assertFocus({ index: 2 }, "Focus moves to third item");
+ assertSelection([0, 1, 2, 3, 4], "All items are still selected");
+ EventUtils.synthesizeKey(" ", { ctrlKey: true }, win);
+ assertFocus({ index: 2 }, "Focus remains on third item");
+ assertSelection([0, 1, 3, 4], "Third item was unselected");
+ EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win);
+ assertFocus({ index: 1 }, "Focus moves to the second item");
+ assertSelection([0, 1, 3, 4], "Selection remains the same");
+ EventUtils.synthesizeKey(" ", {}, win);
+ assertFocus({ index: 1 }, "Focus remains on the second item");
+ assertSelection([1], "Only the second item is selected");
+ } else {
+ // Did nothing.
+ assertSelection([1], "Second item is still selected");
+ }
+
+ // Wrong platform modifier does nothing.
+ EventUtils.synthesizeKey(
+ "a",
+ AppConstants.platform == "macosx" ? { ctrlKey: true } : { metaKey: true },
+ win
+ );
+ assertFocus({ index: 1 }, "Focus remains on second item");
+ assertSelection([1], "Second item still selected");
+ }
+});
+
+// Holding the shift key should perform a range selection if multi-selection is
+// supported by the model.
+add_task(function test_range_selection() {
+ for (let model of selectionModels) {
+ reset({ model, direction: "right-to-left" });
+ widget.addItems(0, ["First", "Second", "Third", "Fourth", "Fifth"]);
+
+ stepFocus(true, { index: 0 }, "Move focus to first item");
+ assertSelection([0], "First item is selected");
+
+ EventUtils.synthesizeKey("KEY_ArrowLeft", {}, win);
+ EventUtils.synthesizeKey("KEY_ArrowLeft", {}, win);
+
+ assertFocus({ index: 2 }, "Focus on third item");
+ assertSelection([2], "Third item is selected");
+
+ // Nothing happens with Ctrl+Shift in any model.
+ EventUtils.synthesizeKey(
+ "KEY_ArrowLeft",
+ { shiftKey: true, ctrlKey: true },
+ win
+ );
+ assertFocus({ index: 2 }, "Focus remains on third item");
+ assertSelection([2], "Only second item is selected");
+ EventUtils.synthesizeKey(
+ "KEY_ArrowRight",
+ { shiftKey: true, ctrlKey: true },
+ win
+ );
+ assertFocus({ index: 2 }, "Focus remains on third item");
+ assertSelection([2], "Only second item is selected");
+
+ // With just Shift modifier.
+ if (model == "focus" || model == "browse") {
+ // No range selection.
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win);
+ assertFocus({ index: 2 }, "Focus remains on third item");
+ assertSelection([2], "Only second item is selected");
+
+ EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win);
+ assertFocus({ index: 2 }, "Focus remains on third item");
+ assertSelection([2], "Only second item is selected");
+
+ clickWidgetItem(3, { shiftKey: true });
+ assertFocus({ index: 2 }, "Focus remains on third item");
+ assertSelection([2], "Only second item is selected");
+
+ clickWidgetItem(1, { shiftKey: true });
+ assertFocus({ index: 2 }, "Focus remains on third item");
+ assertSelection([2], "Only second item is selected");
+ continue;
+ }
+
+ // Range selection with shift key.
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win);
+ assertFocus({ index: 3 }, "Focus on fourth item");
+ assertSelection([2, 3], "Select from third to fourth item");
+
+ // Reverse
+ EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win);
+ assertFocus({ index: 2 }, "Focus on third item");
+ assertSelection([2], "Select from third to same item");
+
+ // Go back another step.
+ EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win);
+ assertFocus({ index: 1 }, "Focus on second item");
+ assertSelection([1, 2], "Third to second items are selected");
+
+ // Split focus from selection.
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win);
+ assertFocus({ index: 2 }, "Focus on third item");
+ assertSelection([1, 2], "Third to second items are still selected");
+
+ // Back to range selection.
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win);
+ assertFocus({ index: 3 }, "Focus on fourth item");
+ assertSelection([2, 3], "Third to fourth items are selected");
+
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win);
+ assertFocus({ index: 4 }, "Focus on fifth item");
+ assertSelection([2, 3, 4], "Third to fifth items are selected");
+
+ // Moving without a modifier breaks the range.
+ EventUtils.synthesizeKey("KEY_ArrowLeft", {}, win);
+ assertFocus({ index: 4 }, "Focus remains on final fifth item");
+ assertSelection([4], "Fifth item is selected");
+
+ // Again at the middle.
+ EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win);
+ assertFocus({ index: 3 }, "Focus moves to fourth item");
+ assertSelection([3, 4], "Fifth to fourth items are selected");
+ EventUtils.synthesizeKey("KEY_ArrowRight", {}, win);
+ assertFocus({ index: 2 }, "Focus moves to third item");
+ assertSelection([2], "Only third item is selected");
+
+ // Home and End also work.
+ EventUtils.synthesizeKey("KEY_Home", { shiftKey: true }, win);
+ assertFocus({ index: 0 }, "Focus moves to first item");
+ assertSelection([0, 1, 2], "Up to third item is selected");
+
+ EventUtils.synthesizeKey("KEY_End", { shiftKey: true }, win);
+ assertFocus({ index: 4 }, "Focus moves to last item");
+ assertSelection([2, 3, 4], "Third item and above is selected");
+
+ // Ctrl+A breaks range selection sequence, so we no longer select around the
+ // third item when we go back to using Shift+Arrow.
+ selectAllShortcut();
+ assertFocus({ index: 4 }, "Focus remains on last item");
+ assertSelection([0, 1, 2, 3, 4], "All items are selected");
+ // The new shift+range will be from the focus index (the fifth item) rather
+ // than the third item used for the previous range.
+ EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win);
+ assertFocus({ index: 3 }, "Focus moves to fourth item");
+ assertSelection([3, 4], "Fifth to fourth item are selected");
+
+ // Ctrl+Space also breaks range selection sequence.
+ EventUtils.synthesizeKey("KEY_Home", { ctrlKey: true }, win);
+ assertFocus({ index: 0 }, "Focus moves to first item");
+ assertSelection([3, 4], "Range selection remains");
+ EventUtils.synthesizeKey(" ", { ctrlKey: true }, win);
+ assertFocus({ index: 0 }, "Focus still on first item");
+ assertSelection([0, 3, 4], "First item added to selection");
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win);
+ assertFocus({ index: 1 }, "Focus moves to second item");
+ assertSelection([0, 1], "First to second item are selected");
+
+ // Same when unselecting.
+ EventUtils.synthesizeKey(" ", { ctrlKey: true }, win);
+ assertFocus({ index: 1 }, "Focus remains on second item");
+ assertSelection([0], "Second item is no longer selected");
+
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win);
+ assertFocus({ index: 2 }, "Focus moves to third item");
+ assertSelection([1, 2], "Second to third item are selected");
+
+ // Same when using setItemSelected API
+ widget.setItemSelected(4, true);
+ assertFocus({ index: 2 }, "Focus remains on third item");
+ assertSelection([1, 2, 4], "Fifth item becomes selected");
+
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win);
+ assertFocus({ index: 3 }, "Focus moves to fourth item");
+ assertSelection([2, 3], "Third to fourth item are selected");
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win);
+ assertFocus({ index: 4 }, "Focus moves to fifth item");
+ assertSelection([2, 3, 4], "Third to fifth item are selected");
+
+ widget.setItemSelected(3, false);
+ assertFocus({ index: 4 }, "Focus remains on fifth item");
+ assertSelection([2, 4], "Fourth item becomes unselected");
+
+ EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win);
+ assertFocus({ index: 3 }, "Focus moves to fourth item");
+ assertSelection([3, 4], "Fifth to fourth item are selected");
+
+ // Even when the selection state does not change.
+ widget.setItemSelected(3, true);
+ assertFocus({ index: 3 }, "Focus remains on fourth item");
+ assertSelection([3, 4], "Same selection");
+ EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win);
+ assertFocus({ index: 2 }, "Focus moves to third item");
+ assertSelection([2, 3], "Fourth to third item are selected");
+ EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win);
+ assertFocus({ index: 1 }, "Focus moves to second item");
+ assertSelection([1, 2, 3], "Fourth to second item are selected");
+
+ widget.setItemSelected(4, false);
+ assertFocus({ index: 1 }, "Focus remains on second item");
+ assertSelection([1, 2, 3], "Same selection");
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win);
+ assertFocus({ index: 2 }, "Focus moves to third item");
+ assertSelection([1, 2], "Second to third item are selected");
+
+ // Same when selecting with space (no modifier).
+ EventUtils.synthesizeKey(" ", {}, win);
+ assertFocus({ index: 2 }, "Focus remains on third item");
+ assertSelection([2], "Third item is selected");
+
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win);
+ assertFocus({ index: 3 }, "Focus moves to fourth item");
+ assertSelection([2, 3], "Third to fourth item are selected");
+
+ // Same when using the selectSingleItem API.
+ widget.selectSingleItem(1);
+ assertFocus({ index: 1 }, "Focus moves to second item");
+ assertSelection([1], "Second item is selected");
+
+ EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win);
+ assertFocus({ index: 0 }, "Focus moves to first item");
+ assertSelection([0, 1], "Second to first item are selected");
+
+ // If focus goes out and we return, the range origin is remembered.
+ stepFocus(true, { element: after }, "Move focus outside the widget");
+ assertSelection([0, 1], "Second to first item are still selected");
+ stepFocus(false, { index: 0 }, "Focus returns to the widget");
+ assertSelection([0, 1], "Second to first item are still selected");
+
+ EventUtils.synthesizeKey("KEY_End", { shiftKey: true }, win);
+ assertFocus({ index: 4 }, "Focus moves to last item");
+ assertSelection([1, 2, 3, 4], "Second to fifth item are selected");
+
+ // Clicking empty space does not clear it.
+ clickWidgetEmptySpace({});
+ assertFocus({ index: 4 }, "Focus remains on last item");
+ assertSelection([1, 2, 3, 4], "Second to fifth item are still selected");
+
+ // Shift+Click an item will use the same range origin established by the
+ // current selection.
+ clickWidgetItem(3, { shiftKey: true });
+ assertFocus({ index: 3 }, "Focus moves to fourth item");
+ assertSelection([1, 2, 3], "Second to fourth item are selected");
+
+ // Clicking without the modifier breaks the range selection sequence.
+ clickWidgetItem(2, {});
+ assertFocus({ index: 2 }, "Focus moves to third item");
+ assertSelection([2], "Only the third item is selected");
+
+ // Shift click will select between the third item and the the clicked item.
+ clickWidgetItem(4, { shiftKey: true });
+ assertFocus({ index: 4 }, "Focus moves to fifth item");
+ assertSelection([2, 3, 4], "Third to fifth item are selected");
+
+ // Reverse direction about the same point.
+ clickWidgetItem(0, { shiftKey: true });
+ assertFocus({ index: 0 }, "Focus moves to first item");
+ assertSelection([0, 1, 2], "Third to first item are selected");
+
+ // Ctrl+Click breaks the range selection sequence.
+ clickWidgetItem(1, { ctrlKey: true });
+ assertFocus({ index: 1 }, "Focus moves to second item");
+ assertSelection([0, 2], "Second item is unselected");
+ clickWidgetItem(3, { shiftKey: true });
+ assertFocus({ index: 3 }, "Focus moves to fourth item");
+ assertSelection([1, 2, 3], "Second to fourth item are selected");
+
+ // Same when Ctrl+Click on non-selected.
+ clickWidgetItem(4, { ctrlKey: true });
+ assertFocus({ index: 4 }, "Focus moves to fifth item");
+ assertSelection([1, 2, 3, 4], "Fifth item is selected");
+ clickWidgetItem(3, { shiftKey: true });
+ assertFocus({ index: 3 }, "Focus moves to fourth item");
+ assertSelection([3, 4], "Fifth to fourth item are selected");
+
+ // Selecting-all also breaks range selection sequence.
+ EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win);
+ assertFocus({ index: 2 }, "Focus moves to third item");
+ assertSelection([3, 4], "Same selection");
+
+ selectAllShortcut();
+ assertFocus({ index: 2 }, "Focus remains on third item");
+ assertSelection([0, 1, 2, 3, 4], "All items selected");
+
+ EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win);
+ assertFocus({ index: 1 }, "Focus moves to second item");
+ assertSelection([1, 2], "Third to second item selected");
+ }
+});
+
+// Adding items to widget with existing items, should not change the selected
+// item.
+add_task(function test_add_items_to_nonempty() {
+ for (let model of selectionModels) {
+ reset({ model, direction: "right-to-left" });
+ assertState([], "Empty");
+
+ widget.addItems(0, ["0-add"]);
+ stepFocus(true, { index: 0, text: "0-add" }, "Move focus to 0-add");
+ assertState([{ text: "0-add", selected: true, focused: true }], "One item");
+
+ // Add item after.
+ widget.addItems(1, ["1-add"]);
+ assertState(
+ [{ text: "0-add", selected: true, focused: true }, { text: "1-add" }],
+ "0-add still focused and selected"
+ );
+
+ // Add item before. 0-add moves to index 1.
+ widget.addItems(0, ["2-add"]);
+ assertState(
+ [
+ { text: "2-add" },
+ { text: "0-add", selected: true, focused: true },
+ { text: "1-add" },
+ ],
+ "0-add still focused and selected"
+ );
+
+ // Add several before.
+ widget.addItems(1, ["3-add", "4-add", "5-add"]);
+ assertState(
+ [
+ { text: "2-add" },
+ { text: "3-add" },
+ { text: "4-add" },
+ { text: "5-add" },
+ { text: "0-add", selected: true, focused: true },
+ { text: "1-add" },
+ ],
+ "0-add still focused and selected"
+ );
+
+ // Key navigation works.
+ EventUtils.synthesizeKey("KEY_ArrowRight", {}, win);
+ assertState(
+ [
+ { text: "2-add" },
+ { text: "3-add" },
+ { text: "4-add" },
+ { text: "5-add", selected: true, focused: true },
+ { text: "0-add" },
+ { text: "1-add" },
+ ],
+ "5-add becomes focused and selected"
+ );
+
+ // With focus outside the widget.
+ reset({ model, direction: "right-to-left" });
+ assertState([], "Empty");
+
+ widget.addItems(0, ["0-add"]);
+ stepFocus(true, { index: 0 }, "Move focus to 0-add");
+ assertState([{ text: "0-add", selected: true, focused: true }], "One item");
+
+ stepFocus(true, { element: after }, "Move focus to after widget");
+ // Add after.
+ widget.addItems(1, ["1-add", "2-add"]);
+ assertState(
+ [{ text: "0-add", selected: true }, { text: "1-add" }, { text: "2-add" }],
+ "0-add still selected but not focused"
+ );
+ stepFocus(false, { index: 0 }, "Move focus back to 0-add");
+ assertState(
+ [
+ { text: "0-add", selected: true, focused: true },
+ { text: "1-add" },
+ { text: "2-add" },
+ ],
+ "0-add selected and focused"
+ );
+
+ stepFocus(false, { element: before }, "Move focus to before widget");
+ // Add before.
+ widget.addItems(0, ["3-add", "4-add"]);
+ assertState(
+ [
+ { text: "3-add" },
+ { text: "4-add" },
+ { text: "0-add", selected: true },
+ { text: "1-add" },
+ { text: "2-add" },
+ ],
+ "0-add selected but not focused"
+ );
+ stepFocus(true, { index: 2 }, "Move focus back to 0-add");
+ assertState(
+ [
+ { text: "3-add" },
+ { text: "4-add" },
+ { text: "0-add", selected: true, focused: true },
+ { text: "1-add" },
+ { text: "2-add" },
+ ],
+ "0-add selected and focused"
+ );
+
+ // With focus separate from selection.
+ if (model == "focus") {
+ continue;
+ }
+
+ reset({ model, direction: "right-to-left" });
+ assertState([], "Empty");
+
+ widget.addItems(0, ["0-add", "1-add", "2-add"]);
+ assertState(
+ [{ text: "0-add" }, { text: "1-add" }, { text: "2-add" }],
+ "None selected or focused"
+ );
+ stepFocus(true, { index: 0 }, "Move focus to 0-add");
+
+ // With selection after focus.
+ EventUtils.synthesizeKey("KEY_End", {}, win);
+ EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win);
+ assertState(
+ [
+ { text: "0-add" },
+ { text: "1-add", focused: true },
+ { text: "2-add", selected: true },
+ ],
+ "Selection after focus"
+ );
+
+ // Add after both selection and focus.
+ widget.addItems(3, ["3-add"]);
+ assertState(
+ [
+ { text: "0-add" },
+ { text: "1-add", focused: true },
+ { text: "2-add", selected: true },
+ { text: "3-add" },
+ ],
+ "Same items selected and focused"
+ );
+
+ // Add before both selection and focus.
+ widget.addItems(1, ["4-add"]);
+ assertState(
+ [
+ { text: "0-add" },
+ { text: "4-add" },
+ { text: "1-add", focused: true },
+ { text: "2-add", selected: true },
+ { text: "3-add" },
+ ],
+ "Same items selected and focused"
+ );
+
+ // Before selection, after focus.
+ widget.addItems(3, ["5-add"]);
+ assertState(
+ [
+ { text: "0-add" },
+ { text: "4-add" },
+ { text: "1-add", focused: true },
+ { text: "5-add" },
+ { text: "2-add", selected: true },
+ { text: "3-add" },
+ ],
+ "Same items selected and focused"
+ );
+
+ // Swap selection to be before focus.
+ EventUtils.synthesizeKey("KEY_ArrowRight", {}, win);
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win);
+ assertState(
+ [
+ { text: "0-add" },
+ { text: "4-add", selected: true },
+ { text: "1-add" },
+ { text: "5-add", focused: true },
+ { text: "2-add" },
+ { text: "3-add" },
+ ],
+ "Selection before focus"
+ );
+
+ // After selection, before focus.
+ widget.addItems(2, ["6-add", "7-add", "8-add"]);
+ assertState(
+ [
+ { text: "0-add" },
+ { text: "4-add", selected: true },
+ { text: "6-add" },
+ { text: "7-add" },
+ { text: "8-add" },
+ { text: "1-add" },
+ { text: "5-add", focused: true },
+ { text: "2-add" },
+ { text: "3-add" },
+ ],
+ "Same items selected and focused"
+ );
+
+ // With multi-selection.
+ if (model == "browse") {
+ continue;
+ }
+
+ reset({ model, direction: "right-to-left" });
+ assertState([], "Empty");
+
+ widget.addItems(0, ["0-add", "1-add", "2-add"]);
+ assertState(
+ [{ text: "0-add" }, { text: "1-add" }, { text: "2-add" }],
+ "None selected"
+ );
+ stepFocus(true, { index: 0 }, "Move focus to 0-add");
+
+ // Select all.
+ EventUtils.synthesizeKey("KEY_End", { shiftKey: true }, win);
+ assertState(
+ [
+ { text: "0-add", selected: true },
+ { text: "1-add", selected: true },
+ { text: "2-add", selected: true, focused: true },
+ ],
+ "All selected"
+ );
+
+ // Add after all.
+ widget.addItems(3, ["3-add", "4-add", "5-add"]);
+ assertState(
+ [
+ { text: "0-add", selected: true },
+ { text: "1-add", selected: true },
+ { text: "2-add", selected: true, focused: true },
+ { text: "3-add" },
+ { text: "4-add" },
+ { text: "5-add" },
+ ],
+ "Same range selected"
+ );
+
+ // Can continue shift selection to newly added item
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win);
+ assertState(
+ [
+ { text: "0-add", selected: true },
+ { text: "1-add", selected: true },
+ { text: "2-add", selected: true },
+ { text: "3-add", selected: true, focused: true },
+ { text: "4-add" },
+ { text: "5-add" },
+ ],
+ "Range extended to new item"
+ );
+
+ // Add before all.
+ widget.addItems(0, ["6-add", "7-add"]);
+ assertState(
+ [
+ { text: "6-add" },
+ { text: "7-add" },
+ { text: "0-add", selected: true },
+ { text: "1-add", selected: true },
+ { text: "2-add", selected: true },
+ { text: "3-add", selected: true, focused: true },
+ { text: "4-add" },
+ { text: "5-add" },
+ ],
+ "Same range selected"
+ );
+
+ // Can continue shift selection about the "0-add" item.
+ EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win);
+ assertState(
+ [
+ { text: "6-add" },
+ { text: "7-add" },
+ { text: "0-add", selected: true },
+ { text: "1-add", selected: true },
+ { text: "2-add", selected: true, focused: true },
+ { text: "3-add" },
+ { text: "4-add" },
+ { text: "5-add" },
+ ],
+ "Range extended backward"
+ );
+
+ // And change direction of shift selection range.
+ EventUtils.synthesizeKey("KEY_Home", { ctrlKey: true }, win);
+ assertState(
+ [
+ { text: "6-add", focused: true },
+ { text: "7-add" },
+ { text: "0-add", selected: true },
+ { text: "1-add", selected: true },
+ { text: "2-add", selected: true },
+ { text: "3-add" },
+ { text: "4-add" },
+ { text: "5-add" },
+ ],
+ "Focus moves to first item"
+ );
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win);
+ assertState(
+ [
+ { text: "6-add" },
+ { text: "7-add", selected: true, focused: true },
+ { text: "0-add", selected: true },
+ { text: "1-add" },
+ { text: "2-add" },
+ { text: "3-add" },
+ { text: "4-add" },
+ { text: "5-add" },
+ ],
+ "Selection pivoted about 0-add"
+ );
+
+ // Add items in the middle of the range. Selection in the range is not added
+ // initially.
+ widget.addItems(2, ["8-add", "9-add", "10-add"]);
+ assertState(
+ [
+ { text: "6-add" },
+ { text: "7-add", selected: true, focused: true },
+ { text: "8-add" },
+ { text: "9-add" },
+ { text: "10-add" },
+ { text: "0-add", selected: true },
+ { text: "1-add" },
+ { text: "2-add" },
+ { text: "3-add" },
+ { text: "4-add" },
+ { text: "5-add" },
+ ],
+ "Backward range selection with single gap"
+ );
+
+ // But continuing the shift selection will fill in the holes again.
+ EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win);
+ assertState(
+ [
+ { text: "6-add", selected: true, focused: true },
+ { text: "7-add", selected: true },
+ { text: "8-add", selected: true },
+ { text: "9-add", selected: true },
+ { text: "10-add", selected: true },
+ { text: "0-add", selected: true },
+ { text: "1-add" },
+ { text: "2-add" },
+ { text: "3-add" },
+ { text: "4-add" },
+ { text: "5-add" },
+ ],
+ "Backward range selection with no gap"
+ );
+
+ // Do the same but with a selection range moving forward and two holes.
+ EventUtils.synthesizeKey("KEY_ArrowLeft", {}, win);
+ EventUtils.synthesizeKey("KEY_ArrowLeft", {}, win);
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win);
+ assertState(
+ [
+ { text: "6-add" },
+ { text: "7-add" },
+ { text: "8-add", selected: true },
+ { text: "9-add", selected: true },
+ { text: "10-add", selected: true, focused: true },
+ { text: "0-add" },
+ { text: "1-add" },
+ { text: "2-add" },
+ { text: "3-add" },
+ { text: "4-add" },
+ { text: "5-add" },
+ ],
+ "Forward range selection"
+ );
+
+ widget.addItems(3, ["11-add"]);
+ assertState(
+ [
+ { text: "6-add" },
+ { text: "7-add" },
+ { text: "8-add", selected: true },
+ { text: "11-add" },
+ { text: "9-add", selected: true },
+ { text: "10-add", selected: true, focused: true },
+ { text: "0-add" },
+ { text: "1-add" },
+ { text: "2-add" },
+ { text: "3-add" },
+ { text: "4-add" },
+ { text: "5-add" },
+ ],
+ "Forward range selection with one gap"
+ );
+
+ widget.addItems(5, ["12-add", "13-add"]);
+ assertState(
+ [
+ { text: "6-add" },
+ { text: "7-add" },
+ { text: "8-add", selected: true },
+ { text: "11-add" },
+ { text: "9-add", selected: true },
+ { text: "12-add" },
+ { text: "13-add" },
+ { text: "10-add", selected: true, focused: true },
+ { text: "0-add" },
+ { text: "1-add" },
+ { text: "2-add" },
+ { text: "3-add" },
+ { text: "4-add" },
+ { text: "5-add" },
+ ],
+ "Forward range selection with two gaps"
+ );
+
+ // Continuing the shift selection will fill in the holes.
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win);
+ assertState(
+ [
+ { text: "6-add" },
+ { text: "7-add" },
+ { text: "8-add", selected: true },
+ { text: "11-add", selected: true },
+ { text: "9-add", selected: true },
+ { text: "12-add", selected: true },
+ { text: "13-add", selected: true },
+ { text: "10-add", selected: true },
+ { text: "0-add", selected: true, focused: true },
+ { text: "1-add" },
+ { text: "2-add" },
+ { text: "3-add" },
+ { text: "4-add" },
+ { text: "5-add" },
+ ],
+ "Extended range forward with no gaps"
+ );
+
+ // With multi-selection via toggling.
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win);
+ EventUtils.synthesizeKey(" ", { ctrlKey: true }, win);
+ assertState(
+ [
+ { text: "6-add" },
+ { text: "7-add" },
+ { text: "8-add", selected: true },
+ { text: "11-add", selected: true },
+ { text: "9-add", selected: true },
+ { text: "12-add", selected: true },
+ { text: "13-add", selected: true },
+ { text: "10-add", selected: true },
+ { text: "0-add", selected: true },
+ { text: "1-add" },
+ { text: "2-add", selected: true, focused: true },
+ { text: "3-add" },
+ { text: "4-add" },
+ { text: "5-add" },
+ ],
+ "Selected 2-add"
+ );
+ EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win);
+ EventUtils.synthesizeKey(" ", { ctrlKey: true }, win);
+ assertState(
+ [
+ { text: "6-add" },
+ { text: "7-add" },
+ { text: "8-add", selected: true },
+ { text: "11-add", selected: true },
+ { text: "9-add", selected: true },
+ { text: "12-add", selected: true },
+ { text: "13-add", focused: true },
+ { text: "10-add", selected: true },
+ { text: "0-add", selected: true },
+ { text: "1-add" },
+ { text: "2-add", selected: true },
+ { text: "3-add" },
+ { text: "4-add" },
+ { text: "5-add" },
+ ],
+ "De-selected 13-add"
+ );
+
+ widget.addItems(6, ["14-add", "15-add"]);
+ assertState(
+ [
+ { text: "6-add" },
+ { text: "7-add" },
+ { text: "8-add", selected: true },
+ { text: "11-add", selected: true },
+ { text: "9-add", selected: true },
+ { text: "12-add", selected: true },
+ { text: "14-add" },
+ { text: "15-add" },
+ { text: "13-add", focused: true },
+ { text: "10-add", selected: true },
+ { text: "0-add", selected: true },
+ { text: "1-add" },
+ { text: "2-add", selected: true },
+ { text: "3-add" },
+ { text: "4-add" },
+ { text: "5-add" },
+ ],
+ "Same selected items"
+ );
+
+ widget.addItems(3, ["16-add"]);
+ assertState(
+ [
+ { text: "6-add" },
+ { text: "7-add" },
+ { text: "8-add", selected: true },
+ { text: "16-add" },
+ { text: "11-add", selected: true },
+ { text: "9-add", selected: true },
+ { text: "12-add", selected: true },
+ { text: "14-add" },
+ { text: "15-add" },
+ { text: "13-add", focused: true },
+ { text: "10-add", selected: true },
+ { text: "0-add", selected: true },
+ { text: "1-add" },
+ { text: "2-add", selected: true },
+ { text: "3-add" },
+ { text: "4-add" },
+ { text: "5-add" },
+ ],
+ "Same selected items"
+ );
+
+ // With select-all
+ selectAllShortcut();
+ assertState(
+ [
+ { text: "6-add", selected: true },
+ { text: "7-add", selected: true },
+ { text: "8-add", selected: true },
+ { text: "16-add", selected: true },
+ { text: "11-add", selected: true },
+ { text: "9-add", selected: true },
+ { text: "12-add", selected: true },
+ { text: "14-add", selected: true },
+ { text: "15-add", selected: true },
+ { text: "13-add", selected: true, focused: true },
+ { text: "10-add", selected: true },
+ { text: "0-add", selected: true },
+ { text: "1-add", selected: true },
+ { text: "2-add", selected: true },
+ { text: "3-add", selected: true },
+ { text: "4-add", selected: true },
+ { text: "5-add", selected: true },
+ ],
+ "All items selected"
+ );
+
+ // Added items do not become selected.
+ widget.addItems(4, ["17-add", "18-add"]);
+ assertState(
+ [
+ { text: "6-add", selected: true },
+ { text: "7-add", selected: true },
+ { text: "8-add", selected: true },
+ { text: "16-add", selected: true },
+ { text: "17-add" },
+ { text: "18-add" },
+ { text: "11-add", selected: true },
+ { text: "9-add", selected: true },
+ { text: "12-add", selected: true },
+ { text: "14-add", selected: true },
+ { text: "15-add", selected: true },
+ { text: "13-add", selected: true, focused: true },
+ { text: "10-add", selected: true },
+ { text: "0-add", selected: true },
+ { text: "1-add", selected: true },
+ { text: "2-add", selected: true },
+ { text: "3-add", selected: true },
+ { text: "4-add", selected: true },
+ { text: "5-add", selected: true },
+ ],
+ "Added items not selected"
+ );
+
+ // Added items will be selected if we select-all again.
+ selectAllShortcut();
+ assertState(
+ [
+ { text: "6-add", selected: true },
+ { text: "7-add", selected: true },
+ { text: "8-add", selected: true },
+ { text: "16-add", selected: true },
+ { text: "17-add", selected: true },
+ { text: "18-add", selected: true },
+ { text: "11-add", selected: true },
+ { text: "9-add", selected: true },
+ { text: "12-add", selected: true },
+ { text: "14-add", selected: true },
+ { text: "15-add", selected: true },
+ { text: "13-add", selected: true, focused: true },
+ { text: "10-add", selected: true },
+ { text: "0-add", selected: true },
+ { text: "1-add", selected: true },
+ { text: "2-add", selected: true },
+ { text: "3-add", selected: true },
+ { text: "4-add", selected: true },
+ { text: "5-add", selected: true },
+ ],
+ "All items selected"
+ );
+ }
+});
+
+/**
+ * Test that pressing a key on a non-empty widget that has focus on itself will
+ * move to the expected index.
+ *
+ * @param {object} initialState - The initial state of the widget to set up.
+ * @param {string} initialState.model - The selection model to use.
+ * @param {string} initialState.direction - The layout direction of the widget.
+ * @param {number} initialState.numItems - The number of items in the widget.
+ * @param {Function} [initialState.scroll] - A method to call to scroll the
+ * widget.
+ * @param {string} key - The key to press once the widget is set up.
+ * @param {number} index - The expected index for the item that will receive
+ * focus after the key press.
+ */
+function subtest_keypress_on_focused_widget(initialState, key, index) {
+ let { model, direction, numItems, scroll } = initialState;
+ for (let ctrlKey of [false, true]) {
+ for (let shiftKey of [false, true]) {
+ info(
+ `Adding items to empty ${direction} widget and then pressing ${
+ ctrlKey ? "Ctrl+" : ""
+ }${shiftKey ? "Shift+" : ""}${key}`
+ );
+ reset({ model, direction });
+
+ stepFocus(true, { element: widget }, "Move focus onto empty widget");
+ widget.addItems(
+ 0,
+ range(0, numItems).map(i => `add-${i}`)
+ );
+ scroll?.();
+
+ assertFocus(
+ { element: widget },
+ "Focus remains on the widget after adding items"
+ );
+ assertSelection([], "No items are selected yet");
+
+ EventUtils.synthesizeKey(key, { ctrlKey, shiftKey }, win);
+ if (
+ (ctrlKey && shiftKey) ||
+ (model == "browse" && shiftKey) ||
+ (model == "focus" && (ctrlKey || shiftKey))
+ ) {
+ // Does nothing.
+ assertFocus({ element: widget }, "Focus remains on widget");
+ assertSelection([], "No change in selection");
+ continue;
+ }
+
+ assertFocus({ index }, `Focus moves to ${index} after ${key}`);
+ if (ctrlKey) {
+ assertSelection([], `No selection if pressing Ctrl+${key}`);
+ } else if (shiftKey) {
+ assertSelection(
+ range(0, index + 1),
+ `Range selection from 0 to ${index} if pressing Shift+${key}`
+ );
+ } else {
+ assertSelection([index], `Item selected after ${key}`);
+ }
+ }
+ }
+}
+
+// If items are added to an empty widget that has focus, nothing happens
+// initially. Arrow keys will focus the first item.
+add_task(function test_add_items_to_empty_with_focus() {
+ for (let model of selectionModels) {
+ // Step navigation always takes us to the first item.
+ subtest_keypress_on_focused_widget(
+ { model, direction: "top-to-bottom", numItems: 3 },
+ "KEY_ArrowUp",
+ 0
+ );
+ subtest_keypress_on_focused_widget(
+ { model, direction: "top-to-bottom", numItems: 3 },
+ "KEY_ArrowDown",
+ 0
+ );
+ subtest_keypress_on_focused_widget(
+ { model, direction: "right-to-left", numItems: 3 },
+ "KEY_ArrowRight",
+ 0
+ );
+ subtest_keypress_on_focused_widget(
+ { model, direction: "right-to-left", numItems: 3 },
+ "KEY_ArrowLeft",
+ 0
+ );
+ subtest_keypress_on_focused_widget(
+ { model, direction: "left-to-right", numItems: 3 },
+ "KEY_ArrowLeft",
+ 0
+ );
+ subtest_keypress_on_focused_widget(
+ { model, direction: "left-to-right", numItems: 3 },
+ "KEY_ArrowRight",
+ 0
+ );
+ // Home also takes us to the first item.
+ subtest_keypress_on_focused_widget(
+ { model, direction: "top-to-bottom", numItems: 3 },
+ "KEY_Home",
+ 0
+ );
+ subtest_keypress_on_focused_widget(
+ { model, direction: "right-to-left", numItems: 3 },
+ "KEY_Home",
+ 0
+ );
+ subtest_keypress_on_focused_widget(
+ { model, direction: "left-to-right", numItems: 3 },
+ "KEY_Home",
+ 0
+ );
+ // End takes us to the last item.
+ subtest_keypress_on_focused_widget(
+ { model, direction: "top-to-bottom", numItems: 3 },
+ "KEY_End",
+ 2
+ );
+ subtest_keypress_on_focused_widget(
+ { model, direction: "right-to-left", numItems: 3 },
+ "KEY_End",
+ 2
+ );
+ subtest_keypress_on_focused_widget(
+ { model, direction: "left-to-right", numItems: 3 },
+ "KEY_End",
+ 2
+ );
+ // PageUp and PageDown take us to the start or end of the visible page.
+ subtest_keypress_on_focused_widget(
+ { model, direction: "top-to-bottom", numItems: 3 },
+ "KEY_PageUp",
+ 0
+ );
+ subtest_keypress_on_focused_widget(
+ { model, direction: "top-to-bottom", numItems: 3 },
+ "KEY_PageDown",
+ 2
+ );
+ subtest_keypress_on_focused_widget(
+ {
+ model,
+ direction: "top-to-bottom",
+ numItems: 30,
+ scroll: () => {
+ widget.scrollTop = 270;
+ },
+ },
+ "KEY_PageUp",
+ 9
+ );
+ subtest_keypress_on_focused_widget(
+ {
+ model,
+ direction: "top-to-bottom",
+ numItems: 30,
+ scroll: () => {
+ widget.scrollTop = 60;
+ },
+ },
+ "KEY_PageDown",
+ 21
+ );
+
+ // Arrow keys in other directions do nothing.
+ reset({ model, direction: "top-to-bottom" });
+ stepFocus(true, { element: widget }, "Move focus onto empty widget");
+ widget.addItems(0, ["First", "Second"]);
+ for (let key of ["KEY_ArrowRight", "KEY_ArrowLeft"]) {
+ EventUtils.synthesizeKey(key, {}, win);
+ assertFocus({ element: widget }, `Focus remains on widget after ${key}`);
+ assertSelection([], `No items become selected after ${key}`);
+ }
+
+ reset({ model, direction: "right-to-left" });
+ stepFocus(true, { element: widget }, "Move focus onto empty widget");
+ widget.addItems(0, ["First", "Second"]);
+ for (let key of ["KEY_ArrowUp", "KEY_ArrowDown"]) {
+ EventUtils.synthesizeKey(key, {}, win);
+ assertFocus({ element: widget }, `Focus remains on widget after ${key}`);
+ assertSelection([], `No items become selected after ${key}`);
+ }
+
+ // Pressing Space does nothing.
+ reset({ model });
+ stepFocus(true, { element: widget }, "Move focus onto empty widget");
+ widget.addItems(0, ["First", "Second"]);
+ for (let ctrlKey of [false, true]) {
+ for (let shiftKey of [false, true]) {
+ info(
+ `Pressing ${ctrlKey ? "Ctrl+" : ""}${shiftKey ? "Shift+" : ""}Space`
+ );
+ EventUtils.synthesizeKey(" ", {}, win);
+ assertFocus({ element: widget }, "Focus remains on widget after Space");
+ assertSelection([], "No items become selected after Space");
+ }
+ }
+
+ // Selecting all
+ reset({ model });
+ stepFocus(true, { element: widget }, "Move focus onto empty widget");
+ widget.addItems(0, ["First", "Second", "Third"]);
+
+ selectAllShortcut();
+ assertFocus({ element: widget }, "Focus remains on the widget");
+ if (model == "browse-multi") {
+ assertSelection([0, 1, 2], "All items selected");
+ } else {
+ assertSelection([], "still no selection");
+ }
+
+ // Adding and then removing items does not set focus.
+ reset({ model });
+ stepFocus(true, { element: widget }, "Move focus onto empty widget");
+ widget.addItems(0, ["First", "Second", "Third", "Fourth"]);
+ widget.removeItems(2, 2);
+ assertState(
+ [{ text: "First" }, { text: "Second" }],
+ "No item focused or selected"
+ );
+ assertFocus({ element: widget }, "Focus remains on the widget");
+ widget.removeItems(0, 1);
+ assertState([{ text: "Second" }], "No item focused or selected");
+ assertFocus({ element: widget }, "Focus remains on the widget");
+
+ // Moving items does not set focus.
+ reset({ model });
+ stepFocus(true, { element: widget }, "Move focus onto empty widget");
+ widget.addItems(0, ["First", "Second", "Third", "Fourth"]);
+ widget.moveItems(1, 0, 2, false);
+ assertState(
+ [
+ { text: "Second" },
+ { text: "Third" },
+ { text: "First" },
+ { text: "Fourth" },
+ ],
+ "No item focused or selected"
+ );
+ assertFocus({ element: widget }, "Focus remains on the widget");
+ widget.moveItems(0, 1, 3, true);
+ assertState(
+ [
+ { text: "Fourth" },
+ { text: "Second" },
+ { text: "Third" },
+ { text: "First" },
+ ],
+ "No item focused or selected"
+ );
+ assertFocus({ element: widget }, "Focus remains on the widget");
+
+ // This does not effect clicking.
+ // NOTE: case where widget does not initially have focus on clicking is
+ // handled by test_initial_no_select_focus
+ for (let ctrlKey of [false, true]) {
+ for (let shiftKey of [false, true]) {
+ info(
+ `Adding items to empty focused widget and then ${
+ ctrlKey ? "Ctrl+" : ""
+ }${shiftKey ? "Shift+" : ""}Click`
+ );
+ reset({ model });
+ stepFocus(true, { element: widget }, "Move focus onto empty widget");
+ widget.addItems(0, ["First", "Second", "Third"]);
+
+ // Clicking empty space does nothing.
+ clickWidgetEmptySpace({ ctrlKey, shiftKey });
+ assertFocus({ element: widget }, "Focus remains on widget");
+ assertSelection([], "No item selected");
+
+ // Clicking an item can change focus and selection.
+ clickWidgetItem(1, { ctrlKey, shiftKey });
+ if (
+ (ctrlKey && shiftKey) ||
+ ((model == "focus" || model == "browse") && (ctrlKey || shiftKey))
+ ) {
+ assertFocus({ element: widget }, "Focus remains on widget");
+ assertSelection([], "No selection");
+ continue;
+ }
+ assertFocus({ index: 1 }, "Focus moves to second item");
+ if (shiftKey) {
+ assertSelection([0, 1], "First and second item selected");
+ } else {
+ assertSelection([1], "Second item selected");
+ }
+ }
+ }
+ }
+});
+
+// Removing items from the widget with existing items, may change focus or
+// selection if the corresponding item was removed.
+add_task(function test_remove_items_nonempty() {
+ for (let model of selectionModels) {
+ reset({ model, direction: "right-to-left" });
+
+ widget.addItems(0, ["0-add", "1-add", "2-add", "3-add", "4-add", "5-add"]);
+ assertState(
+ [
+ { text: "0-add" },
+ { text: "1-add" },
+ { text: "2-add" },
+ { text: "3-add" },
+ { text: "4-add" },
+ { text: "5-add" },
+ ],
+ "No initial focus or selection"
+ );
+
+ clickWidgetItem(2, {});
+ assertState(
+ [
+ { text: "0-add" },
+ { text: "1-add" },
+ { text: "2-add", selected: true, focused: true },
+ { text: "3-add" },
+ { text: "4-add" },
+ { text: "5-add" },
+ ],
+ "2-add focused and selected"
+ );
+
+ // Remove one after.
+ widget.removeItems(3, 1);
+ assertState(
+ [
+ { text: "0-add" },
+ { text: "1-add" },
+ { text: "2-add", selected: true, focused: true },
+ { text: "4-add" },
+ { text: "5-add" },
+ ],
+ "2-add still focused and selected"
+ );
+
+ // Remove one before.
+ widget.removeItems(0, 1);
+ assertState(
+ [
+ { text: "1-add" },
+ { text: "2-add", selected: true, focused: true },
+ { text: "4-add" },
+ { text: "5-add" },
+ ],
+ "2-add still focused and selected"
+ );
+
+ widget.addItems(0, ["6-add", "7-add"]);
+ assertState(
+ [
+ { text: "6-add" },
+ { text: "7-add" },
+ { text: "1-add" },
+ { text: "2-add", selected: true, focused: true },
+ { text: "4-add" },
+ { text: "5-add" },
+ ],
+ "2-add still focused and selected"
+ );
+
+ // Remove several before.
+ widget.removeItems(1, 2);
+ assertState(
+ [
+ { text: "6-add" },
+ { text: "2-add", selected: true, focused: true },
+ { text: "4-add" },
+ { text: "5-add" },
+ ],
+ "2-add still focused and selected"
+ );
+
+ // Remove selected and focused. Focus should move to the next item.
+ widget.removeItems(1, 1);
+ assertState(
+ [
+ { text: "6-add" },
+ { text: "4-add", selected: true, focused: true },
+ { text: "5-add" },
+ ],
+ "Selection and focus move to 4-add"
+ );
+
+ widget.addItems(0, ["8-add"]);
+ widget.addItems(3, ["9-add", "10-add"]);
+ assertState(
+ [
+ { text: "8-add" },
+ { text: "6-add" },
+ { text: "4-add", selected: true, focused: true },
+ { text: "9-add" },
+ { text: "10-add" },
+ { text: "5-add" },
+ ],
+ "Selection and focus still on 4-add"
+ );
+
+ // Remove selected and focused, not at boundary.
+ widget.removeItems(1, 3);
+ assertState(
+ [
+ { text: "8-add" },
+ { text: "10-add", selected: true, focused: true },
+ { text: "5-add" },
+ ],
+ "Selection and focus move to 10-add"
+ );
+
+ // Remove last item whilst it has focus. Focus should move to the new last
+ // item.
+ EventUtils.synthesizeKey("KEY_ArrowLeft", {}, win);
+ assertState(
+ [
+ { text: "8-add" },
+ { text: "10-add" },
+ { text: "5-add", selected: true, focused: true },
+ ],
+ "Last item is focused and selected"
+ );
+
+ widget.removeItems(2, 1);
+ assertState(
+ [{ text: "8-add" }, { text: "10-add", selected: true, focused: true }],
+ "New last item is focused and selected"
+ );
+
+ // Delete focused whilst outside widget.
+ widget.addItems(2, ["11-add"]);
+ assertState(
+ [
+ { text: "8-add" },
+ { text: "10-add", selected: true, focused: true },
+ { text: "11-add" },
+ ],
+ "10-add is focused and selected"
+ );
+ stepFocus(false, { element: before });
+
+ widget.removeItems(1, 1);
+ assertFocus({ element: before }, "Focus remains outside widget");
+ assertState(
+ [{ text: "8-add" }, { text: "11-add", selected: true }],
+ "11-add becomes selected"
+ );
+
+ stepFocus(true, { index: 1 }, "11-add becomes focused");
+ assertState(
+ [{ text: "8-add" }, { text: "11-add", selected: true, focused: true }],
+ "11-add is selected"
+ );
+
+ // With focus separate from selected.
+ if (model == "focus") {
+ continue;
+ }
+
+ // Move selection to be before focus.
+ widget.addItems(2, ["12-add", "13-add", "14-add"]);
+ assertState(
+ [
+ { text: "8-add" },
+ { text: "11-add", selected: true, focused: true },
+ { text: "12-add" },
+ { text: "13-add" },
+ { text: "14-add" },
+ ],
+ "11-add is selected and focused"
+ );
+
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win);
+ assertState(
+ [
+ { text: "8-add" },
+ { text: "11-add", selected: true },
+ { text: "12-add", focused: true },
+ { text: "13-add" },
+ { text: "14-add" },
+ ],
+ "Selection before focus"
+ );
+
+ // Remove focused, but not selected.
+ widget.removeItems(2, 1);
+ assertState(
+ [
+ { text: "8-add" },
+ { text: "11-add", selected: true },
+ { text: "13-add", focused: true },
+ { text: "14-add" },
+ ],
+ "Focus moves to 13-add, but selection is the same"
+ );
+
+ // Remove focused and selected.
+ widget.removeItems(1, 2);
+ assertState(
+ [{ text: "8-add" }, { text: "14-add", selected: true, focused: true }],
+ "Focus moves to 14-add and becomes selected"
+ );
+
+ // Restore selection before focus.
+ widget.addItems(0, ["15-add"]);
+ assertState(
+ [
+ { text: "15-add" },
+ { text: "8-add" },
+ { text: "14-add", selected: true, focused: true },
+ ],
+ "14-add has focus and selection"
+ );
+ EventUtils.synthesizeKey("KEY_ArrowRight", {}, win);
+ assertState(
+ [
+ { text: "15-add" },
+ { text: "8-add", selected: true, focused: true },
+ { text: "14-add" },
+ ],
+ "8-add is focused and selected"
+ );
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win);
+ assertState(
+ [
+ { text: "15-add" },
+ { text: "8-add", selected: true },
+ { text: "14-add", focused: true },
+ ],
+ "Selection before focus again"
+ );
+
+ // Remove selected, but not focused.
+ widget.removeItems(1, 1);
+ assertState(
+ [{ text: "15-add" }, { text: "14-add", focused: true }],
+ "14-add still has focus, but selection is lost"
+ );
+
+ // Move selection to be after focus.
+ widget.addItems(1, ["16-add", "17-add"]);
+ widget.addItems(4, ["18-add"]);
+ assertState(
+ [
+ { text: "15-add" },
+ { text: "16-add" },
+ { text: "17-add" },
+ { text: "14-add", focused: true },
+ { text: "18-add" },
+ ],
+ "Still no selection"
+ );
+ // Select focused.
+ EventUtils.synthesizeKey(" ", {}, win);
+ assertFocus({ index: 3 }, "14-add has focus");
+ assertSelection([3], "14-add is selected");
+ assertState(
+ [
+ { text: "15-add" },
+ { text: "16-add" },
+ { text: "17-add" },
+ { text: "14-add", selected: true, focused: true },
+ { text: "18-add" },
+ ],
+ "14-add is selected and focused"
+ );
+ // Move focus.
+ EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win);
+ assertState(
+ [
+ { text: "15-add" },
+ { text: "16-add", focused: true },
+ { text: "17-add" },
+ { text: "14-add", selected: true },
+ { text: "18-add" },
+ ],
+ "Selection after focus"
+ );
+
+ // Remove focused, but not selected.
+ widget.removeItems(1, 1);
+ assertState(
+ [
+ { text: "15-add" },
+ { text: "17-add", focused: true },
+ { text: "14-add", selected: true },
+ { text: "18-add" },
+ ],
+ "Focus moves to 17-add, selection stays on 14-add"
+ );
+
+ // Remove focused and selected.
+ widget.removeItems(1, 2);
+ assertState(
+ [{ text: "15-add" }, { text: "18-add", selected: true, focused: true }],
+ "Focus and selection moves to 18-add"
+ );
+
+ // Restore selection after focus.
+ widget.addItems(2, ["19-add", "20-add"]);
+ assertState(
+ [
+ { text: "15-add" },
+ { text: "18-add", selected: true, focused: true },
+ { text: "19-add" },
+ { text: "20-add" },
+ ],
+ "Still no selection"
+ );
+ EventUtils.synthesizeKey("KEY_ArrowLeft", {}, win);
+ assertState(
+ [
+ { text: "15-add" },
+ { text: "18-add" },
+ { text: "19-add", selected: true, focused: true },
+ { text: "20-add" },
+ ],
+ "19-add focused and selected"
+ );
+ EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win);
+ assertState(
+ [
+ { text: "15-add" },
+ { text: "18-add", focused: true },
+ { text: "19-add", selected: true },
+ { text: "20-add" },
+ ],
+ "Selection after focus again"
+ );
+
+ // Remove selected, but not focused.
+ widget.removeItems(2, 2);
+ assertState(
+ [{ text: "15-add" }, { text: "18-add", focused: true }],
+ "18-add still has focus, but selection is lost"
+ );
+
+ // With multi-selection
+ if (model == "browse") {
+ continue;
+ }
+
+ widget.addItems(0, ["21-add", "22-add", "23-add"]);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "22-add" },
+ { text: "23-add" },
+ { text: "15-add" },
+ { text: "18-add", focused: true },
+ ],
+ "18-add focused, no selection yet"
+ );
+ widget.addItems(5, [
+ "24-add",
+ "25-add",
+ "26-add",
+ "27-add",
+ "28-add",
+ "29-add",
+ "30-add",
+ "31-add",
+ ]);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "22-add" },
+ { text: "23-add" },
+ { text: "15-add" },
+ { text: "18-add", focused: true },
+ { text: "24-add" },
+ { text: "25-add" },
+ { text: "26-add" },
+ { text: "27-add" },
+ { text: "28-add" },
+ { text: "29-add" },
+ { text: "30-add" },
+ { text: "31-add" },
+ ],
+ "18-add focused, no selection yet"
+ );
+
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win);
+
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "22-add" },
+ { text: "23-add" },
+ { text: "15-add" },
+ { text: "18-add", selected: true },
+ { text: "24-add", selected: true },
+ { text: "25-add", selected: true },
+ { text: "26-add", selected: true, focused: true },
+ { text: "27-add" },
+ { text: "28-add" },
+ { text: "29-add" },
+ { text: "30-add" },
+ { text: "31-add" },
+ ],
+ "Forward range selection from 18-add to 26-add"
+ );
+
+ // Delete after the selection range
+ widget.removeItems(10, 1);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "22-add" },
+ { text: "23-add" },
+ { text: "15-add" },
+ { text: "18-add", selected: true },
+ { text: "24-add", selected: true },
+ { text: "25-add", selected: true },
+ { text: "26-add", selected: true, focused: true },
+ { text: "27-add" },
+ { text: "28-add" },
+ { text: "30-add" },
+ { text: "31-add" },
+ ],
+ "Same range selection"
+ );
+
+ // Delete before the selection range.
+ widget.removeItems(1, 1);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "23-add" },
+ { text: "15-add" },
+ { text: "18-add", selected: true },
+ { text: "24-add", selected: true },
+ { text: "25-add", selected: true },
+ { text: "26-add", selected: true, focused: true },
+ { text: "27-add" },
+ { text: "28-add" },
+ { text: "30-add" },
+ { text: "31-add" },
+ ],
+ "Same range selection"
+ );
+
+ // Delete the start of the selection range.
+ widget.removeItems(2, 3);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "23-add" },
+ { text: "25-add", selected: true },
+ { text: "26-add", selected: true, focused: true },
+ { text: "27-add" },
+ { text: "28-add" },
+ { text: "30-add" },
+ { text: "31-add" },
+ ],
+ "Selection range from 25-add to 26-add"
+ );
+
+ // Selection pivot is now around 25-add.
+ EventUtils.synthesizeKey("KEY_Home", { shiftKey: true }, win);
+ assertState(
+ [
+ { text: "21-add", selected: true, focused: true },
+ { text: "23-add", selected: true },
+ { text: "25-add", selected: true },
+ { text: "26-add" },
+ { text: "27-add" },
+ { text: "28-add" },
+ { text: "30-add" },
+ { text: "31-add" },
+ ],
+ "Selection range from 25-add to 21-add"
+ );
+ EventUtils.synthesizeKey("KEY_End", { shiftKey: true }, win);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "23-add" },
+ { text: "25-add", selected: true },
+ { text: "26-add", selected: true },
+ { text: "27-add", selected: true },
+ { text: "28-add", selected: true },
+ { text: "30-add", selected: true },
+ { text: "31-add", selected: true, focused: true },
+ ],
+ "Selection range from 25-add to 31-add"
+ );
+ EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "23-add" },
+ { text: "25-add", selected: true },
+ { text: "26-add", selected: true },
+ { text: "27-add", selected: true },
+ { text: "28-add", selected: true, focused: true },
+ { text: "30-add" },
+ { text: "31-add" },
+ ],
+ "Selection range from 25-add to 28-add"
+ );
+
+ // Delete the end of the selection.
+ // As a special case, the focus moves to the end of the selection, rather
+ // than to the next item.
+ widget.removeItems(4, 2);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "23-add" },
+ { text: "25-add", selected: true },
+ { text: "26-add", selected: true, focused: true },
+ { text: "30-add" },
+ { text: "31-add" },
+ ],
+ "Selection range from 25-add to 26-add"
+ );
+
+ // Do same with a gap.
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "23-add" },
+ { text: "25-add", selected: true },
+ { text: "26-add", selected: true },
+ { text: "30-add", selected: true, focused: true },
+ { text: "31-add" },
+ ],
+ "Continue selection range from 25-add to 30-add"
+ );
+
+ widget.addItems(3, ["32-add"]);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "23-add" },
+ { text: "25-add", selected: true },
+ { text: "32-add" },
+ { text: "26-add", selected: true },
+ { text: "30-add", selected: true, focused: true },
+ { text: "31-add" },
+ ],
+ "Selection range from 25-add to 30-add with gap"
+ );
+
+ widget.removeItems(5, 1);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "23-add" },
+ { text: "25-add", selected: true },
+ { text: "32-add" },
+ { text: "26-add", selected: true, focused: true },
+ { text: "31-add" },
+ ],
+ "Focus moves to the end of the range, after the gap"
+ );
+
+ // Do the same with a gap and all items after the gap are removed.
+ widget.addItems(6, ["33-add", "34-add"]);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "23-add" },
+ { text: "25-add", selected: true },
+ { text: "32-add" },
+ { text: "26-add", selected: true, focused: true },
+ { text: "31-add" },
+ { text: "33-add" },
+ { text: "34-add" },
+ ],
+ "Added 33-add and 34-add"
+ );
+
+ clickWidgetItem(5, { shiftKey: true });
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "23-add" },
+ { text: "25-add", selected: true },
+ { text: "32-add", selected: true },
+ { text: "26-add", selected: true },
+ { text: "31-add", selected: true, focused: true },
+ { text: "33-add" },
+ { text: "34-add" },
+ ],
+ "Selection extended to 31-add and gap filled"
+ );
+
+ widget.addItems(4, ["35-add", "36-add", "37-add"]);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "23-add" },
+ { text: "25-add", selected: true },
+ { text: "32-add", selected: true },
+ { text: "35-add" },
+ { text: "36-add" },
+ { text: "37-add" },
+ { text: "26-add", selected: true },
+ { text: "31-add", selected: true, focused: true },
+ { text: "33-add" },
+ { text: "34-add" },
+ ],
+ "Selection from 25-add to 31-add with gap"
+ );
+
+ widget.removeItems(6, 3);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "23-add" },
+ { text: "25-add", selected: true },
+ { text: "32-add", selected: true, focused: true },
+ { text: "35-add" },
+ { text: "36-add" },
+ { text: "33-add" },
+ { text: "34-add" },
+ ],
+ "Focus jumps gap to what is left of the selection range"
+ );
+
+ // Same, with entire gap also removed.
+ clickWidgetItem(6, { shiftKey: true });
+ widget.addItems(5, ["38-add"]);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "23-add" },
+ { text: "25-add", selected: true },
+ { text: "32-add", selected: true },
+ { text: "35-add", selected: true },
+ { text: "38-add" },
+ { text: "36-add", selected: true },
+ { text: "33-add", selected: true, focused: true },
+ { text: "34-add" },
+ ],
+ "Selection from 25-add to 33-add with gap"
+ );
+
+ widget.removeItems(4, 4);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "23-add" },
+ { text: "25-add", selected: true },
+ { text: "32-add", selected: true, focused: true },
+ { text: "34-add" },
+ ],
+ "Focus moves to end of what is left of the selection range"
+ );
+
+ // Test deleting the end of the selection with focus in a gap.
+ // We don't expect to follow the special treatment because the user has
+ // explicitly moved the focus "outside" of the selected range.
+ widget.addItems(3, ["39-add"]);
+ EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "23-add" },
+ { text: "25-add", selected: true },
+ { text: "39-add", focused: true },
+ { text: "32-add", selected: true },
+ { text: "34-add" },
+ ],
+ "Focus in range gap"
+ );
+
+ widget.removeItems(3, 2);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "23-add" },
+ { text: "25-add", selected: true },
+ { text: "34-add", focused: true },
+ ],
+ "Focus moves from gap to 34-add, outside the selection range"
+ );
+
+ // Same, but deleting the start of the range.
+ widget.addItems(4, ["40-add", "41-add", "42-add"]);
+ clickWidgetItem(5, { shiftKey: true });
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "23-add" },
+ { text: "25-add", selected: true },
+ { text: "34-add", selected: true },
+ { text: "40-add", selected: true },
+ { text: "41-add", selected: true, focused: true },
+ { text: "42-add" },
+ ],
+ "Selection from 25-add to 41-add"
+ );
+
+ widget.addItems(3, ["43-add", "44-add", "45-add"]);
+ EventUtils.synthesizeKey("KEY_Home", { ctrlKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "23-add" },
+ { text: "25-add", selected: true },
+ { text: "43-add", focused: true },
+ { text: "44-add" },
+ { text: "45-add" },
+ { text: "34-add", selected: true },
+ { text: "40-add", selected: true },
+ { text: "41-add", selected: true },
+ { text: "42-add" },
+ ],
+ "Focus in gap"
+ );
+
+ widget.removeItems(1, 3);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "44-add", focused: true },
+ { text: "45-add" },
+ { text: "34-add", selected: true },
+ { text: "40-add", selected: true },
+ { text: "41-add", selected: true },
+ { text: "42-add" },
+ ],
+ "Focus moves to next item, rather than the selection start"
+ );
+
+ // Test deleting the end of the selection with the focus towards the end of
+ // the range.
+ widget.addItems(7, ["46-add", "47-add", "48-add", "49-add", "50-add"]);
+ clickWidgetItem(7, { shiftKey: true });
+ EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "44-add" },
+ { text: "45-add" },
+ { text: "34-add", selected: true },
+ { text: "40-add", selected: true },
+ { text: "41-add", selected: true },
+ { text: "42-add", selected: true, focused: true },
+ { text: "46-add", selected: true },
+ { text: "47-add" },
+ { text: "48-add" },
+ { text: "49-add" },
+ { text: "50-add" },
+ ],
+ "Range selection from 34-add to 46-add, with focus on 42-add"
+ );
+
+ widget.removeItems(6, 2);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "44-add" },
+ { text: "45-add" },
+ { text: "34-add", selected: true },
+ { text: "40-add", selected: true },
+ { text: "41-add", selected: true, focused: true },
+ { text: "47-add" },
+ { text: "48-add" },
+ { text: "49-add" },
+ { text: "50-add" },
+ ],
+ "Focus still moves to the end of the selection"
+ );
+
+ // Test deleting with focus after the end of the range.
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true });
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "44-add" },
+ { text: "45-add" },
+ { text: "34-add", selected: true },
+ { text: "40-add", selected: true },
+ { text: "41-add", selected: true },
+ { text: "47-add", focused: true },
+ { text: "48-add" },
+ { text: "49-add" },
+ { text: "50-add" },
+ ],
+ "Focus still moves to the end of the selection"
+ );
+
+ widget.removeItems(5, 2);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "44-add" },
+ { text: "45-add" },
+ { text: "34-add", selected: true },
+ { text: "40-add", selected: true },
+ { text: "48-add", focused: true },
+ { text: "49-add" },
+ { text: "50-add" },
+ ],
+ "Focus remains outside the range"
+ );
+
+ // Test deleting with focus in the middle of the range, and end of the range
+ // is not deleted.
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true });
+ EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true });
+ EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true });
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "44-add" },
+ { text: "45-add" },
+ { text: "34-add", selected: true },
+ { text: "40-add", selected: true, focused: true },
+ { text: "48-add", selected: true },
+ { text: "49-add", selected: true },
+ { text: "50-add" },
+ ],
+ "Focus in the middle of the range"
+ );
+
+ widget.removeItems(4, 1);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "44-add" },
+ { text: "45-add" },
+ { text: "34-add", selected: true },
+ { text: "48-add", selected: true, focused: true },
+ { text: "49-add", selected: true },
+ { text: "50-add" },
+ ],
+ "Focus moves to next item, rather than the end of the range"
+ );
+
+ // With focus just before a gap.
+ widget.addItems(5, ["51-add", "52-add"]);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "44-add" },
+ { text: "45-add" },
+ { text: "34-add", selected: true },
+ { text: "48-add", selected: true, focused: true },
+ { text: "51-add" },
+ { text: "52-add" },
+ { text: "49-add", selected: true },
+ { text: "50-add" },
+ ],
+ "Focus just before a gap"
+ );
+
+ widget.removeItems(4, 1);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "44-add" },
+ { text: "45-add" },
+ { text: "34-add", selected: true },
+ { text: "51-add", focused: true },
+ { text: "52-add" },
+ { text: "49-add", selected: true },
+ { text: "50-add" },
+ ],
+ "Focus moves forward into the gap"
+ );
+
+ // Selection pivot is about 34-add
+ clickWidgetItem(1, { shiftKey: true });
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "44-add", selected: true, focused: true },
+ { text: "45-add", selected: true },
+ { text: "34-add", selected: true },
+ { text: "51-add" },
+ { text: "52-add" },
+ { text: "49-add" },
+ { text: "50-add" },
+ ],
+ "Selection from 34-add backward to 44-add"
+ );
+ clickWidgetItem(5, { shiftKey: true });
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "44-add" },
+ { text: "45-add" },
+ { text: "34-add", selected: true },
+ { text: "51-add", selected: true },
+ { text: "52-add", selected: true, focused: true },
+ { text: "49-add" },
+ { text: "50-add" },
+ ],
+ "Selection from 34-add forward to 52-add"
+ );
+
+ // Delete the whole range.
+ widget.removeItems(3, 3);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "44-add" },
+ { text: "45-add" },
+ { text: "49-add", selected: true, focused: true },
+ { text: "50-add" },
+ ],
+ "Focus and selection moves to after the selection range"
+ );
+
+ // Do the same with focus outside the range.
+ widget.addItems(5, ["53-add", "54-add", "55-add"]);
+ EventUtils.synthesizeKey("KEY_Home", {}, win);
+ EventUtils.synthesizeKey("KEY_ArrowLeft", {}, win);
+ clickWidgetItem(2, { shiftKey: true });
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "44-add", selected: true },
+ { text: "45-add", selected: true },
+ { text: "49-add", focused: true },
+ { text: "50-add" },
+ { text: "53-add" },
+ { text: "54-add" },
+ { text: "55-add" },
+ ],
+ "Focus outside selection"
+ );
+
+ widget.removeItems(1, 2);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "49-add", focused: true },
+ { text: "50-add" },
+ { text: "53-add" },
+ { text: "54-add" },
+ { text: "55-add" },
+ ],
+ "Focus remains on same item and unselected"
+ );
+
+ // Do the same, but the focus is also removed.
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "49-add", selected: true },
+ { text: "50-add", selected: true },
+ { text: "53-add", focused: true },
+ { text: "54-add" },
+ { text: "55-add" },
+ ],
+ "Focus outside selection"
+ );
+
+ widget.removeItems(1, 3);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "54-add", selected: true, focused: true },
+ { text: "55-add" },
+ ],
+ "Focus and selection moves to 49-add"
+ );
+
+ // * Do the same tests but with selection travelling backwards. *
+ widget.addItems(3, [
+ "56-add",
+ "57-add",
+ "58-add",
+ "59-add",
+ "60-add",
+ "61-add",
+ "62-add",
+ "63-add",
+ "64-add",
+ "65-add",
+ "66-add",
+ ]);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "54-add", selected: true, focused: true },
+ { text: "55-add" },
+ { text: "56-add" },
+ { text: "57-add" },
+ { text: "58-add" },
+ { text: "59-add" },
+ { text: "60-add" },
+ { text: "61-add" },
+ { text: "62-add" },
+ { text: "63-add" },
+ { text: "64-add" },
+ { text: "65-add" },
+ { text: "66-add" },
+ ],
+ "Same selection and focus"
+ );
+ EventUtils.synthesizeKey("KEY_End", {}, win);
+ EventUtils.synthesizeKey("KEY_ArrowRight", {}, win);
+ EventUtils.synthesizeKey("KEY_ArrowRight", {}, win);
+ EventUtils.synthesizeKey("KEY_ArrowRight", {}, win);
+ EventUtils.synthesizeKey("KEY_ArrowRight", {}, win);
+ EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "54-add" },
+ { text: "55-add" },
+ { text: "56-add" },
+ { text: "57-add", selected: true, focused: true },
+ { text: "58-add", selected: true },
+ { text: "59-add", selected: true },
+ { text: "60-add", selected: true },
+ { text: "61-add", selected: true },
+ { text: "62-add", selected: true },
+ { text: "63-add" },
+ { text: "64-add" },
+ { text: "65-add" },
+ { text: "66-add" },
+ ],
+ "Backward range selection from 62-add to 57-add"
+ );
+
+ // Delete after the selection range
+ widget.removeItems(11, 2);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "54-add" },
+ { text: "55-add" },
+ { text: "56-add" },
+ { text: "57-add", selected: true, focused: true },
+ { text: "58-add", selected: true },
+ { text: "59-add", selected: true },
+ { text: "60-add", selected: true },
+ { text: "61-add", selected: true },
+ { text: "62-add", selected: true },
+ { text: "63-add" },
+ { text: "66-add" },
+ ],
+ "Same range selection"
+ );
+
+ // Delete before the selection range.
+ widget.removeItems(2, 2);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "54-add" },
+ { text: "57-add", selected: true, focused: true },
+ { text: "58-add", selected: true },
+ { text: "59-add", selected: true },
+ { text: "60-add", selected: true },
+ { text: "61-add", selected: true },
+ { text: "62-add", selected: true },
+ { text: "63-add" },
+ { text: "66-add" },
+ ],
+ "Same range selection"
+ );
+
+ // Delete the end of the selection range.
+ widget.removeItems(7, 1);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "54-add" },
+ { text: "57-add", selected: true, focused: true },
+ { text: "58-add", selected: true },
+ { text: "59-add", selected: true },
+ { text: "60-add", selected: true },
+ { text: "61-add", selected: true },
+ { text: "63-add" },
+ { text: "66-add" },
+ ],
+ "Selection range backwards from 61-add to 57-add"
+ );
+
+ // Selection pivot is now around 61-add.
+ EventUtils.synthesizeKey("KEY_End", { shiftKey: true }, win);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "54-add" },
+ { text: "57-add" },
+ { text: "58-add" },
+ { text: "59-add" },
+ { text: "60-add" },
+ { text: "61-add", selected: true },
+ { text: "63-add", selected: true },
+ { text: "66-add", selected: true, focused: true },
+ ],
+ "Selection range forwards from 61-add to 66-add"
+ );
+ EventUtils.synthesizeKey("KEY_Home", { shiftKey: true }, win);
+ assertState(
+ [
+ { text: "21-add", selected: true, focused: true },
+ { text: "54-add", selected: true },
+ { text: "57-add", selected: true },
+ { text: "58-add", selected: true },
+ { text: "59-add", selected: true },
+ { text: "60-add", selected: true },
+ { text: "61-add", selected: true },
+ { text: "63-add" },
+ { text: "66-add" },
+ ],
+ "Selection range backwards from 61-add to 21-add"
+ );
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "54-add" },
+ { text: "57-add", selected: true, focused: true },
+ { text: "58-add", selected: true },
+ { text: "59-add", selected: true },
+ { text: "60-add", selected: true },
+ { text: "61-add", selected: true },
+ { text: "63-add" },
+ { text: "66-add" },
+ ],
+ "Selection range backwards from 61-add to 57-add"
+ );
+
+ // Delete the start of the selection.
+ widget.removeItems(1, 3);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "59-add", selected: true, focused: true },
+ { text: "60-add", selected: true },
+ { text: "61-add", selected: true },
+ { text: "63-add" },
+ { text: "66-add" },
+ ],
+ "Selection range shrinks to 59-add"
+ );
+
+ // Do the same with a gap after the focus and its next item.
+ widget.addItems(3, ["67-add", "68-add", "69-add"]);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "59-add", selected: true, focused: true },
+ { text: "60-add", selected: true },
+ { text: "67-add" },
+ { text: "68-add" },
+ { text: "69-add" },
+ { text: "61-add", selected: true },
+ { text: "63-add" },
+ { text: "66-add" },
+ ],
+ "Selection range backwards from 61-add to 59-add with gap"
+ );
+
+ widget.removeItems(1, 1);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "60-add", selected: true, focused: true },
+ { text: "67-add" },
+ { text: "68-add" },
+ { text: "69-add" },
+ { text: "61-add", selected: true },
+ { text: "63-add" },
+ { text: "66-add" },
+ ],
+ "Focus moves to the next item, before gap"
+ );
+
+ // Do the same with a gap and all items before the gap are removed.
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "60-add" },
+ { text: "67-add", selected: true, focused: true },
+ { text: "68-add", selected: true },
+ { text: "69-add", selected: true },
+ { text: "61-add", selected: true },
+ { text: "63-add" },
+ { text: "66-add" },
+ ],
+ "Selection range backward reduced to 67-add with gap filled"
+ );
+ widget.addItems(4, ["70-add", "71-add"]);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "60-add" },
+ { text: "67-add", selected: true, focused: true },
+ { text: "68-add", selected: true },
+ { text: "70-add" },
+ { text: "71-add" },
+ { text: "69-add", selected: true },
+ { text: "61-add", selected: true },
+ { text: "63-add" },
+ { text: "66-add" },
+ ],
+ "Selection range backward from 61-add to 67-add with gap"
+ );
+
+ widget.removeItems(2, 2);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "60-add" },
+ { text: "70-add" },
+ { text: "71-add" },
+ { text: "69-add", selected: true, focused: true },
+ { text: "61-add", selected: true },
+ { text: "63-add" },
+ { text: "66-add" },
+ ],
+ "Focus jumps gap to selection range"
+ );
+
+ // Same, with entire gap also removed.
+ clickWidgetItem(1, { shiftKey: true });
+ widget.addItems(2, ["72-add"]);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "60-add", selected: true, focused: true },
+ { text: "72-add" },
+ { text: "70-add", selected: true },
+ { text: "71-add", selected: true },
+ { text: "69-add", selected: true },
+ { text: "61-add", selected: true },
+ { text: "63-add" },
+ { text: "66-add" },
+ ],
+ "Selection range backwards from 60-add to 70-add"
+ );
+
+ widget.removeItems(1, 3);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "71-add", selected: true, focused: true },
+ { text: "69-add", selected: true },
+ { text: "61-add", selected: true },
+ { text: "63-add" },
+ { text: "66-add" },
+ ],
+ "Focus moves to the start of what is left of the selection range"
+ );
+
+ // Test deleting the start of the selection with focus in a gap.
+ // We don't expect to follow the special treatment because the user has
+ // explicitly moved the focus "outside" of the selected range.
+ widget.addItems(2, ["73-add", "74-add", "75-add"]);
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "71-add", selected: true },
+ { text: "73-add", focused: true },
+ { text: "74-add" },
+ { text: "75-add" },
+ { text: "69-add", selected: true },
+ { text: "61-add", selected: true },
+ { text: "63-add" },
+ { text: "66-add" },
+ ],
+ "Focus in range gap"
+ );
+
+ widget.removeItems(1, 2);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "74-add", focused: true },
+ { text: "75-add" },
+ { text: "69-add", selected: true },
+ { text: "61-add", selected: true },
+ { text: "63-add" },
+ { text: "66-add" },
+ ],
+ "Focus moves to the next item, rather than the selection range"
+ );
+
+ // Same, but deleting the end of the range.
+ clickWidgetItem(1, { shiftKey: true });
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "74-add", selected: true },
+ { text: "75-add", selected: true },
+ { text: "69-add", selected: true, focused: true },
+ { text: "61-add", selected: true },
+ { text: "63-add" },
+ { text: "66-add" },
+ ],
+ "Selection range backward from 61-add to 74-add, with focus shifted"
+ );
+ widget.addItems(3, ["76-add", "77-add"]);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "74-add", selected: true },
+ { text: "75-add", selected: true },
+ { text: "76-add" },
+ { text: "77-add" },
+ { text: "69-add", selected: true, focused: true },
+ { text: "61-add", selected: true },
+ { text: "63-add" },
+ { text: "66-add" },
+ ],
+ "Focus in gap"
+ );
+
+ widget.removeItems(5, 2);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "74-add", selected: true },
+ { text: "75-add", selected: true },
+ { text: "76-add" },
+ { text: "77-add" },
+ { text: "63-add", focused: true },
+ { text: "66-add" },
+ ],
+ "Focus moves to next item, rather than selection range end"
+ );
+
+ // Selection pivot now about 75-add.
+ EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "74-add" },
+ { text: "75-add", selected: true },
+ { text: "76-add", selected: true },
+ { text: "77-add", selected: true, focused: true },
+ { text: "63-add" },
+ { text: "66-add" },
+ ],
+ "Selection range forward from 75-add to 77-add"
+ );
+
+ widget.addItems(1, ["78-add", "79-add", "80-add"]);
+ clickWidgetItem(2, { shiftKey: true });
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "78-add" },
+ { text: "79-add", selected: true, focused: true },
+ { text: "80-add", selected: true },
+ { text: "74-add", selected: true },
+ { text: "75-add", selected: true },
+ { text: "76-add" },
+ { text: "77-add" },
+ { text: "63-add" },
+ { text: "66-add" },
+ ],
+ "Selection range backward from 75-add to 79-add"
+ );
+
+ // Move focus to the end of the range and delete again, but with no gap this
+ // time.
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "78-add" },
+ { text: "79-add", selected: true },
+ { text: "80-add", selected: true },
+ { text: "74-add", selected: true },
+ { text: "75-add", selected: true, focused: true },
+ { text: "76-add" },
+ { text: "77-add" },
+ { text: "63-add" },
+ { text: "66-add" },
+ ],
+ "Focus moved to the end of the selection"
+ );
+
+ widget.removeItems(5, 2);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "78-add" },
+ { text: "79-add", selected: true },
+ { text: "80-add", selected: true },
+ { text: "74-add", selected: true },
+ { text: "77-add", focused: true },
+ { text: "63-add" },
+ { text: "66-add" },
+ ],
+ "Focus moved to the next item rather than the selection start"
+ );
+
+ // Deleting with focus before the selection start.
+ EventUtils.synthesizeKey("KEY_Home", { ctrlKey: true });
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true });
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "78-add", focused: true },
+ { text: "79-add", selected: true },
+ { text: "80-add", selected: true },
+ { text: "74-add", selected: true },
+ { text: "77-add" },
+ { text: "63-add" },
+ { text: "66-add" },
+ ],
+ "Focus before the selection start"
+ );
+
+ widget.removeItems(1, 1);
+ assertState(
+ [
+ { text: "21-add" },
+ { text: "79-add", selected: true, focused: true },
+ { text: "80-add", selected: true },
+ { text: "74-add", selected: true },
+ { text: "77-add" },
+ { text: "63-add" },
+ { text: "66-add" },
+ ],
+ "Focus moves to the next item, which happens to be in the selection range"
+ );
+
+ // Test deleting with focus in the middle of the range.
+ EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true });
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true });
+ assertState(
+ [
+ { text: "21-add", selected: true },
+ { text: "79-add", selected: true, focused: true },
+ { text: "80-add", selected: true },
+ { text: "74-add", selected: true },
+ { text: "77-add" },
+ { text: "63-add" },
+ { text: "66-add" },
+ ],
+ "Selection range backwards from 74-add to 21-add, with focus in middle"
+ );
+
+ widget.removeItems(1, 1);
+ assertState(
+ [
+ { text: "21-add", selected: true },
+ { text: "80-add", selected: true, focused: true },
+ { text: "74-add", selected: true },
+ { text: "77-add" },
+ { text: "63-add" },
+ { text: "66-add" },
+ ],
+ "Focus moves to the next item, rather than the selection start or end"
+ );
+
+ // Delete the whole range.
+ widget.removeItems(0, 4);
+ assertState(
+ [{ text: "63-add", selected: true, focused: true }, { text: "66-add" }],
+ "Focus and selection move to the next remaining item"
+ );
+
+ // Do the same with focus outside the range.
+ widget.addItems(0, ["81-add", "82-add", "83-add", "84-add", "85-add"]);
+ EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win);
+ assertState(
+ [
+ { text: "81-add" },
+ { text: "82-add" },
+ { text: "83-add" },
+ { text: "84-add", focused: true },
+ { text: "85-add", selected: true },
+ { text: "63-add", selected: true },
+ { text: "66-add" },
+ ],
+ "Focus outside backward selection range"
+ );
+
+ widget.removeItems(4, 2);
+ assertState(
+ [
+ { text: "81-add" },
+ { text: "82-add" },
+ { text: "83-add" },
+ { text: "84-add", focused: true },
+ { text: "66-add" },
+ ],
+ "Focus remains the same and is not selected"
+ );
+
+ // Same, but with focus also removed.
+ widget.addItems(5, ["86-add"]);
+ EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win);
+ assertState(
+ [
+ { text: "81-add" },
+ { text: "82-add", focused: true },
+ { text: "83-add", selected: true },
+ { text: "84-add", selected: true },
+ { text: "66-add" },
+ { text: "86-add" },
+ ],
+ "Focus outside backwards selection range"
+ );
+
+ widget.removeItems(1, 3);
+ assertState(
+ [
+ { text: "81-add" },
+ { text: "66-add", selected: true, focused: true },
+ { text: "86-add" },
+ ],
+ "Focus moves to next item and selected"
+ );
+
+ // With multi-selection via toggling.
+
+ widget.addItems(3, [
+ "87-add",
+ "88-add",
+ "89-add",
+ "90-add",
+ "91-add",
+ "92-add",
+ "93-add",
+ "94-add",
+ ]);
+ EventUtils.synthesizeKey("KEY_ArrowLeft", {}, win);
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win);
+ assertState(
+ [
+ { text: "81-add" },
+ { text: "66-add" },
+ { text: "86-add" },
+ { text: "87-add", selected: true },
+ { text: "88-add", selected: true },
+ { text: "89-add", selected: true },
+ { text: "90-add", selected: true, focused: true },
+ { text: "91-add" },
+ { text: "92-add" },
+ { text: "93-add" },
+ { text: "94-add" },
+ ],
+ "Start with range selection forward from 87-add to 90-add"
+ );
+
+ clickWidgetItem(4, { ctrlKey: true });
+ clickWidgetItem(5, { ctrlKey: true });
+ assertState(
+ [
+ { text: "81-add" },
+ { text: "66-add" },
+ { text: "86-add" },
+ { text: "87-add", selected: true },
+ { text: "88-add" },
+ { text: "89-add", focused: true },
+ { text: "90-add", selected: true },
+ { text: "91-add" },
+ { text: "92-add" },
+ { text: "93-add" },
+ { text: "94-add" },
+ ],
+ "Range selection from 87-add to 90-add, with 88-add and 89-add unselected"
+ );
+
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true });
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true });
+ EventUtils.synthesizeKey(" ", { ctrlKey: true });
+ assertState(
+ [
+ { text: "81-add" },
+ { text: "66-add" },
+ { text: "86-add" },
+ { text: "87-add", selected: true },
+ { text: "88-add" },
+ { text: "89-add" },
+ { text: "90-add", selected: true },
+ { text: "91-add", selected: true, focused: true },
+ { text: "92-add" },
+ { text: "93-add" },
+ { text: "94-add" },
+ ],
+ "Mixed selection"
+ );
+
+ // Remove before the selected items
+ widget.removeItems(0, 2);
+ assertState(
+ [
+ { text: "86-add" },
+ { text: "87-add", selected: true },
+ { text: "88-add" },
+ { text: "89-add" },
+ { text: "90-add", selected: true },
+ { text: "91-add", selected: true, focused: true },
+ { text: "92-add" },
+ { text: "93-add" },
+ { text: "94-add" },
+ ],
+ "Same selection and focus"
+ );
+
+ // Remove after
+ widget.removeItems(6, 1);
+ assertState(
+ [
+ { text: "86-add" },
+ { text: "87-add", selected: true },
+ { text: "88-add" },
+ { text: "89-add" },
+ { text: "90-add", selected: true },
+ { text: "91-add", selected: true, focused: true },
+ { text: "93-add" },
+ { text: "94-add" },
+ ],
+ "Same selection and focus"
+ );
+
+ // Removed the focused item, unlike a simple range selection, the focused
+ // item is not bound to stay within the selected items.
+ widget.removeItems(5, 1);
+ assertState(
+ [
+ { text: "86-add" },
+ { text: "87-add", selected: true },
+ { text: "88-add" },
+ { text: "89-add" },
+ { text: "90-add", selected: true },
+ { text: "93-add", focused: true },
+ { text: "94-add" },
+ ],
+ "Focus moves to next item and not selected"
+ );
+
+ // Remove the unselected items, merging the two ranges together.
+ widget.removeItems(2, 2);
+ assertState(
+ [
+ { text: "86-add" },
+ { text: "87-add", selected: true },
+ { text: "90-add", selected: true },
+ { text: "93-add", focused: true },
+ { text: "94-add" },
+ ],
+ "Focus remains the same"
+ );
+
+ // Remove the selected items.
+ widget.removeItems(1, 2);
+ assertState(
+ [
+ { text: "86-add" },
+ { text: "93-add", focused: true },
+ { text: "94-add" },
+ ],
+ "Focus remains the same, and not selected"
+ );
+
+ // Remove all selected items, including the focused item.
+ widget.addItems(0, [
+ "95-add",
+ "96-add",
+ "97-add",
+ "98-add",
+ "99-add",
+ "100-add",
+ "101-add",
+ ]);
+ EventUtils.synthesizeKey(" ", {}, win);
+ assertState(
+ [
+ { text: "95-add" },
+ { text: "96-add" },
+ { text: "97-add" },
+ { text: "98-add" },
+ { text: "99-add" },
+ { text: "100-add" },
+ { text: "101-add" },
+ { text: "86-add" },
+ { text: "93-add", selected: true, focused: true },
+ { text: "94-add" },
+ ],
+ "Single selection"
+ );
+
+ clickWidgetItem(6, { ctrlKey: true });
+ clickWidgetItem(5, { ctrlKey: true });
+ assertState(
+ [
+ { text: "95-add" },
+ { text: "96-add" },
+ { text: "97-add" },
+ { text: "98-add" },
+ { text: "99-add" },
+ { text: "100-add", selected: true, focused: true },
+ { text: "101-add", selected: true },
+ { text: "86-add" },
+ { text: "93-add", selected: true },
+ { text: "94-add" },
+ ],
+ "Mixed selection with focus selected"
+ );
+
+ widget.removeItems(5, 4);
+ assertState(
+ [
+ { text: "95-add" },
+ { text: "96-add" },
+ { text: "97-add" },
+ { text: "98-add" },
+ { text: "99-add" },
+ { text: "94-add", selected: true, focused: true },
+ ],
+ "Focus moves to next item and selected"
+ );
+
+ // Remove all selected, with focus outside the selection
+ clickWidgetItem(1, {});
+ clickWidgetItem(3, { ctrlKey: true });
+ EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true });
+ assertState(
+ [
+ { text: "95-add" },
+ { text: "96-add", selected: true },
+ { text: "97-add", focused: true },
+ { text: "98-add", selected: true },
+ { text: "99-add" },
+ { text: "94-add" },
+ ],
+ "Mixed selection with focus not selected"
+ );
+
+ widget.removeItems(1, 3);
+ assertState(
+ [
+ { text: "95-add" },
+ { text: "99-add", selected: true, focused: true },
+ { text: "94-add" },
+ ],
+ "Focus moves to next item and selected"
+ );
+
+ // With select all.
+ widget.addItems(0, ["102-add", "103-add", "104-add"]);
+ widget.addItems(6, ["105-add", "106-add"]);
+ selectAllShortcut();
+ assertState(
+ [
+ { text: "102-add", selected: true },
+ { text: "103-add", selected: true },
+ { text: "104-add", selected: true },
+ { text: "95-add", selected: true },
+ { text: "99-add", selected: true, focused: true },
+ { text: "94-add", selected: true },
+ { text: "105-add", selected: true },
+ { text: "106-add", selected: true },
+ ],
+ "All selected"
+ );
+
+ // Remove middle and focused.
+ widget.removeItems(4, 1);
+ assertState(
+ [
+ { text: "102-add", selected: true },
+ { text: "103-add", selected: true },
+ { text: "104-add", selected: true },
+ { text: "95-add", selected: true },
+ { text: "94-add", selected: true, focused: true },
+ { text: "105-add", selected: true },
+ { text: "106-add", selected: true },
+ ],
+ "Focus moves to the next item, selections remain"
+ );
+
+ // Remove before focused.
+ widget.removeItems(1, 1);
+ assertState(
+ [
+ { text: "102-add", selected: true },
+ { text: "104-add", selected: true },
+ { text: "95-add", selected: true },
+ { text: "94-add", selected: true, focused: true },
+ { text: "105-add", selected: true },
+ { text: "106-add", selected: true },
+ ],
+ "Focus and selection remain"
+ );
+
+ // Remove after the focus.
+ widget.removeItems(4, 2);
+ assertState(
+ [
+ { text: "102-add", selected: true },
+ { text: "104-add", selected: true },
+ { text: "95-add", selected: true },
+ { text: "94-add", selected: true, focused: true },
+ ],
+ "Focus and selection remain"
+ );
+
+ // Remove end and focused.
+ widget.removeItems(3, 1);
+ assertState(
+ [
+ { text: "102-add", selected: true },
+ { text: "104-add", selected: true },
+ { text: "95-add", selected: true, focused: true },
+ ],
+ "Focus moves to the last item, selection remains"
+ );
+
+ // Remove start and focused.
+ EventUtils.synthesizeKey("KEY_Home", { ctrlKey: true }, win);
+ assertState(
+ [
+ { text: "102-add", selected: true, focused: true },
+ { text: "104-add", selected: true },
+ { text: "95-add", selected: true },
+ ],
+ "Focus on first item"
+ );
+
+ widget.removeItems(0, 1);
+ assertState(
+ [
+ { text: "104-add", selected: true, focused: true },
+ { text: "95-add", selected: true },
+ ],
+ "Focus moves to next item"
+ );
+
+ // Remove items to cause two distinct ranges to merge together, with
+ // in-between ranges removed.
+ widget.addItems(2, [
+ "107-add",
+ "108-add",
+ "109-add",
+ "110-add",
+ "111-add",
+ "112-add",
+ "113-add",
+ ]);
+ clickWidgetItem(0, { ctrlKey: true });
+ assertState(
+ [
+ { text: "104-add", focused: true },
+ { text: "95-add", selected: true },
+ { text: "107-add" },
+ { text: "108-add" },
+ { text: "109-add" },
+ { text: "110-add" },
+ { text: "111-add" },
+ { text: "112-add" },
+ { text: "113-add" },
+ ],
+ "Added items and de-selected 104-add"
+ );
+
+ clickWidgetItem(3, { ctrlKey: true });
+ clickWidgetItem(4, { ctrlKey: true });
+ clickWidgetItem(6, { ctrlKey: true });
+ clickWidgetItem(8, { ctrlKey: true });
+ assertState(
+ [
+ { text: "104-add" },
+ { text: "95-add", selected: true },
+ { text: "107-add" },
+ { text: "108-add", selected: true },
+ { text: "109-add", selected: true },
+ { text: "110-add" },
+ { text: "111-add", selected: true },
+ { text: "112-add" },
+ { text: "113-add", selected: true, focused: true },
+ ],
+ "Several selection ranges"
+ );
+
+ widget.removeItems(2, 6);
+ assertState(
+ [
+ { text: "104-add" },
+ { text: "95-add", selected: true },
+ { text: "113-add", selected: true, focused: true },
+ ],
+ "End ranges merged together"
+ );
+
+ // Do the same, but where parts of the end ranges are also removed.
+ widget.addItems(3, [
+ "114-add",
+ "115-add",
+ "116-add",
+ "117-add",
+ "118-add",
+ "119-add",
+ "120-add",
+ "121-add",
+ "122-add",
+ "123-add",
+ "124-add",
+ "125-add",
+ "126-add",
+ "127-add",
+ ]);
+ clickWidgetItem(4, { ctrlKey: true });
+ clickWidgetItem(5, { ctrlKey: true });
+ clickWidgetItem(7, { ctrlKey: true });
+ clickWidgetItem(10, { ctrlKey: true });
+ clickWidgetItem(12, { ctrlKey: true });
+ clickWidgetItem(13, { ctrlKey: true });
+ clickWidgetItem(14, { ctrlKey: true });
+ clickWidgetItem(16, { ctrlKey: true });
+
+ clickWidgetItem(9, { ctrlKey: true });
+ assertState(
+ [
+ { text: "104-add" },
+ { text: "95-add", selected: true },
+ { text: "113-add", selected: true },
+ { text: "114-add" },
+ { text: "115-add", selected: true },
+ { text: "116-add", selected: true },
+ { text: "117-add" },
+ { text: "118-add", selected: true },
+ { text: "119-add" },
+ { text: "120-add", selected: true, focused: true },
+ { text: "121-add", selected: true },
+ { text: "122-add" },
+ { text: "123-add", selected: true },
+ { text: "124-add", selected: true },
+ { text: "125-add", selected: true },
+ { text: "126-add" },
+ { text: "127-add", selected: true },
+ ],
+ "Several ranges"
+ );
+
+ widget.removeItems(5, 8);
+ assertState(
+ [
+ { text: "104-add" },
+ { text: "95-add", selected: true },
+ { text: "113-add", selected: true },
+ { text: "114-add" },
+ { text: "115-add", selected: true },
+ { text: "124-add", selected: true, focused: true },
+ { text: "125-add", selected: true },
+ { text: "126-add" },
+ { text: "127-add", selected: true },
+ ],
+ "Two ranges merged and rest removed, focus moves to next item"
+ );
+ }
+});
+
+// If widget is emptied whilst focused, focus moves to widget.
+add_task(function test_emptying_widget() {
+ for (let model of selectionModels) {
+ // Empty with focused widget.
+ reset({ model });
+ stepFocus(true, { element: widget }, "Initial");
+ widget.addItems(0, ["First", "Second"]);
+ assertFocus({ element: widget }, "Focus still on widget after adding");
+ widget.removeItems(0, 2);
+ assertFocus({ element: widget }, "Focus still on widget after removing");
+
+ // Empty with focused item.
+ widget.addItems(0, ["First", "Second"]);
+ EventUtils.synthesizeKey("KEY_Home", {}, win);
+ assertFocus({ index: 0 }, "Focus on first item");
+ widget.removeItems(0, 2);
+ assertFocus({ element: widget }, "Focus moves to widget after removing");
+
+ // Empty with focus elsewhere.
+ widget.addItems(0, ["First", "Second"]);
+ stepFocus(false, { element: before }, "Focus elsewhere");
+ widget.removeItems(0, 2);
+ assertFocus({ element: before }, "Focus still elsewhere after removing");
+ stepFocus(true, { element: widget }, "Widget becomes focused");
+
+ // Empty with focus elsewhere, but active item.
+ widget.addItems(0, ["First", "Second"]);
+ // Move away from and back to widget to focus second item.
+ stepFocus(true, { element: after }, "Focus elsewhere");
+ widget.selectSingleItem(1);
+ stepFocus(false, { index: 1 }, "Focus on second item");
+ stepFocus(false, { element: before }, "Return focus to elsewhere");
+ widget.removeItems(0, 2);
+ assertFocus({ element: before }, "Focus still elsewhere after removing");
+ stepFocus(true, { element: widget }, "Widget becomes focused");
+ }
+});
+
+/**
+ * Test moving items in the widget.
+ *
+ * @param {string} model - The selection model to use.
+ * @param {boolean} reCreate - Whether the widget should reCreate the items when
+ * moving them.
+ */
+function subtest_move_items(model, reCreate) {
+ reset({ model, direction: "right-to-left" });
+
+ widget.addItems(0, [
+ "0-add",
+ "1-add",
+ "2-add",
+ "3-add",
+ "4-add",
+ "5-add",
+ "6-add",
+ "7-add",
+ "8-add",
+ "9-add",
+ "10-add",
+ "11-add",
+ "12-add",
+ "13-add",
+ ]);
+ clickWidgetItem(5, {});
+
+ assertState(
+ [
+ { text: "0-add" },
+ { text: "1-add" },
+ { text: "2-add" },
+ { text: "3-add" },
+ { text: "4-add" },
+ { text: "5-add", selected: true, focused: true },
+ { text: "6-add" },
+ { text: "7-add" },
+ { text: "8-add" },
+ { text: "9-add" },
+ { text: "10-add" },
+ { text: "11-add" },
+ { text: "12-add" },
+ { text: "13-add" },
+ ],
+ "Item 5 selected and focused"
+ );
+
+ // Move items before focus.
+ widget.moveItems(4, 3, 1, reCreate);
+ assertState(
+ [
+ { text: "0-add" },
+ { text: "1-add" },
+ { text: "2-add" },
+ { text: "4-add" },
+ { text: "3-add" },
+ { text: "5-add", selected: true, focused: true },
+ { text: "6-add" },
+ { text: "7-add" },
+ { text: "8-add" },
+ { text: "9-add" },
+ { text: "10-add" },
+ { text: "11-add" },
+ { text: "12-add" },
+ { text: "13-add" },
+ ],
+ "Same focus and selection"
+ );
+ widget.moveItems(1, 3, 2, reCreate);
+ assertState(
+ [
+ { text: "0-add" },
+ { text: "4-add" },
+ { text: "3-add" },
+ { text: "1-add" },
+ { text: "2-add" },
+ { text: "5-add", selected: true, focused: true },
+ { text: "6-add" },
+ { text: "7-add" },
+ { text: "8-add" },
+ { text: "9-add" },
+ { text: "10-add" },
+ { text: "11-add" },
+ { text: "12-add" },
+ { text: "13-add" },
+ ],
+ "Same focus and selection"
+ );
+
+ // Move items after focus.
+ widget.moveItems(6, 8, 2, reCreate);
+ assertState(
+ [
+ { text: "0-add" },
+ { text: "4-add" },
+ { text: "3-add" },
+ { text: "1-add" },
+ { text: "2-add" },
+ { text: "5-add", selected: true, focused: true },
+ { text: "8-add" },
+ { text: "9-add" },
+ { text: "6-add" },
+ { text: "7-add" },
+ { text: "10-add" },
+ { text: "11-add" },
+ { text: "12-add" },
+ { text: "13-add" },
+ ],
+ "Same focus and selection"
+ );
+ widget.moveItems(9, 6, 1, reCreate);
+ assertState(
+ [
+ { text: "0-add" },
+ { text: "4-add" },
+ { text: "3-add" },
+ { text: "1-add" },
+ { text: "2-add" },
+ { text: "5-add", selected: true, focused: true },
+ { text: "7-add" },
+ { text: "8-add" },
+ { text: "9-add" },
+ { text: "6-add" },
+ { text: "10-add" },
+ { text: "11-add" },
+ { text: "12-add" },
+ { text: "13-add" },
+ ],
+ "Same focus and selection"
+ );
+
+ // Move from before focus to after focus.
+ widget.moveItems(2, 3, 3, reCreate);
+ assertState(
+ [
+ { text: "0-add" },
+ { text: "4-add" },
+ { text: "5-add", selected: true, focused: true },
+ { text: "3-add" },
+ { text: "1-add" },
+ { text: "2-add" },
+ { text: "7-add" },
+ { text: "8-add" },
+ { text: "9-add" },
+ { text: "6-add" },
+ { text: "10-add" },
+ { text: "11-add" },
+ { text: "12-add" },
+ { text: "13-add" },
+ ],
+ "Same focus and selection, but moved"
+ );
+
+ // Move from after focus to before focus.
+ widget.moveItems(3, 2, 5, reCreate);
+ assertState(
+ [
+ { text: "0-add" },
+ { text: "4-add" },
+ { text: "3-add" },
+ { text: "1-add" },
+ { text: "2-add" },
+ { text: "7-add" },
+ { text: "8-add" },
+ { text: "5-add", selected: true, focused: true },
+ { text: "9-add" },
+ { text: "6-add" },
+ { text: "10-add" },
+ { text: "11-add" },
+ { text: "12-add" },
+ { text: "13-add" },
+ ],
+ "Same focus and selection, but moved"
+ );
+
+ // Move selected and focused up.
+ widget.moveItems(7, 3, 1, reCreate);
+ assertState(
+ [
+ { text: "0-add" },
+ { text: "4-add" },
+ { text: "3-add" },
+ { text: "5-add", selected: true, focused: true },
+ { text: "1-add" },
+ { text: "2-add" },
+ { text: "7-add" },
+ { text: "8-add" },
+ { text: "9-add" },
+ { text: "6-add" },
+ { text: "10-add" },
+ { text: "11-add" },
+ { text: "12-add" },
+ { text: "13-add" },
+ ],
+ "Focus and selection moved to index 3"
+ );
+
+ // Move down.
+ widget.moveItems(3, 5, 1, reCreate);
+ assertState(
+ [
+ { text: "0-add" },
+ { text: "4-add" },
+ { text: "3-add" },
+ { text: "1-add" },
+ { text: "2-add" },
+ { text: "5-add", selected: true, focused: true },
+ { text: "7-add" },
+ { text: "8-add" },
+ { text: "9-add" },
+ { text: "6-add" },
+ { text: "10-add" },
+ { text: "11-add" },
+ { text: "12-add" },
+ { text: "13-add" },
+ ],
+ "Focus and selection moved to index 5"
+ );
+
+ // Move in a group.
+ widget.moveItems(4, 5, 3, reCreate);
+ assertState(
+ [
+ { text: "0-add" },
+ { text: "4-add" },
+ { text: "3-add" },
+ { text: "1-add" },
+ { text: "8-add" },
+ { text: "2-add" },
+ { text: "5-add", selected: true, focused: true },
+ { text: "7-add" },
+ { text: "9-add" },
+ { text: "6-add" },
+ { text: "10-add" },
+ { text: "11-add" },
+ { text: "12-add" },
+ { text: "13-add" },
+ ],
+ "Focus and selection moved to index 6"
+ );
+ widget.moveItems(5, 4, 3, reCreate);
+ assertState(
+ [
+ { text: "0-add" },
+ { text: "4-add" },
+ { text: "3-add" },
+ { text: "1-add" },
+ { text: "2-add" },
+ { text: "5-add", selected: true, focused: true },
+ { text: "7-add" },
+ { text: "8-add" },
+ { text: "9-add" },
+ { text: "6-add" },
+ { text: "10-add" },
+ { text: "11-add" },
+ { text: "12-add" },
+ { text: "13-add" },
+ ],
+ "Focus and selection moved back to index 5"
+ );
+
+ // With focus split from selection.
+ if (model == "focus") {
+ return;
+ }
+
+ // Focus before selection.
+ EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win);
+ assertState(
+ [
+ { text: "0-add" },
+ { text: "4-add" },
+ { text: "3-add" },
+ { text: "1-add", focused: true },
+ { text: "2-add" },
+ { text: "5-add", selected: true },
+ { text: "7-add" },
+ { text: "8-add" },
+ { text: "9-add" },
+ { text: "6-add" },
+ { text: "10-add" },
+ { text: "11-add" },
+ { text: "12-add" },
+ { text: "13-add" },
+ ],
+ "Focus before selection"
+ );
+
+ // Move before both.
+ widget.moveItems(0, 1, 1, reCreate);
+ assertState(
+ [
+ { text: "4-add" },
+ { text: "0-add" },
+ { text: "3-add" },
+ { text: "1-add", focused: true },
+ { text: "2-add" },
+ { text: "5-add", selected: true },
+ { text: "7-add" },
+ { text: "8-add" },
+ { text: "9-add" },
+ { text: "6-add" },
+ { text: "10-add" },
+ { text: "11-add" },
+ { text: "12-add" },
+ { text: "13-add" },
+ ],
+ "Same focus and selection"
+ );
+
+ // Move after both.
+ widget.moveItems(8, 7, 1, reCreate);
+ assertState(
+ [
+ { text: "4-add" },
+ { text: "0-add" },
+ { text: "3-add" },
+ { text: "1-add", focused: true },
+ { text: "2-add" },
+ { text: "5-add", selected: true },
+ { text: "7-add" },
+ { text: "9-add" },
+ { text: "8-add" },
+ { text: "6-add" },
+ { text: "10-add" },
+ { text: "11-add" },
+ { text: "12-add" },
+ { text: "13-add" },
+ ],
+ "Same focus and selection"
+ );
+
+ // Move focus to after selected.
+ widget.moveItems(3, 6, 2, reCreate);
+ assertState(
+ [
+ { text: "4-add" },
+ { text: "0-add" },
+ { text: "3-add" },
+ { text: "5-add", selected: true },
+ { text: "7-add" },
+ { text: "9-add" },
+ { text: "1-add", focused: true },
+ { text: "2-add" },
+ { text: "8-add" },
+ { text: "6-add" },
+ { text: "10-add" },
+ { text: "11-add" },
+ { text: "12-add" },
+ { text: "13-add" },
+ ],
+ "Focus moved to after selection"
+ );
+
+ // Move focus before selected.
+ widget.moveItems(5, 2, 3, reCreate);
+ assertState(
+ [
+ { text: "4-add" },
+ { text: "0-add" },
+ { text: "9-add" },
+ { text: "1-add", focused: true },
+ { text: "2-add" },
+ { text: "3-add" },
+ { text: "5-add", selected: true },
+ { text: "7-add" },
+ { text: "8-add" },
+ { text: "6-add" },
+ { text: "10-add" },
+ { text: "11-add" },
+ { text: "12-add" },
+ { text: "13-add" },
+ ],
+ "Focus moved to before selection"
+ );
+
+ // Move selection before focus.
+ widget.moveItems(5, 1, 5, reCreate);
+ assertState(
+ [
+ { text: "4-add" },
+ { text: "3-add" },
+ { text: "5-add", selected: true },
+ { text: "7-add" },
+ { text: "8-add" },
+ { text: "6-add" },
+ { text: "0-add" },
+ { text: "9-add" },
+ { text: "1-add", focused: true },
+ { text: "2-add" },
+ { text: "10-add" },
+ { text: "11-add" },
+ { text: "12-add" },
+ { text: "13-add" },
+ ],
+ "Selected moved to before focus"
+ );
+
+ // Move selection after focus.
+ widget.moveItems(2, 8, 1, reCreate);
+ assertState(
+ [
+ { text: "4-add" },
+ { text: "3-add" },
+ { text: "7-add" },
+ { text: "8-add" },
+ { text: "6-add" },
+ { text: "0-add" },
+ { text: "9-add" },
+ { text: "1-add", focused: true },
+ { text: "5-add", selected: true },
+ { text: "2-add" },
+ { text: "10-add" },
+ { text: "11-add" },
+ { text: "12-add" },
+ { text: "13-add" },
+ ],
+ "Selected moved to after focus"
+ );
+
+ // Navigation still works.
+ EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win);
+ assertState(
+ [
+ { text: "4-add" },
+ { text: "3-add" },
+ { text: "7-add" },
+ { text: "8-add" },
+ { text: "6-add" },
+ { text: "0-add" },
+ { text: "9-add", focused: true },
+ { text: "1-add" },
+ { text: "5-add", selected: true },
+ { text: "2-add" },
+ { text: "10-add" },
+ { text: "11-add" },
+ { text: "12-add" },
+ { text: "13-add" },
+ ],
+ "Selected moved to after focus"
+ );
+
+ // Test with multi-selection.
+ if (model == "browse") {
+ return;
+ }
+
+ EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win);
+ assertState(
+ [
+ { text: "4-add" },
+ { text: "3-add" },
+ { text: "7-add" },
+ { text: "8-add" },
+ { text: "6-add", selected: true, focused: true },
+ { text: "0-add", selected: true },
+ { text: "9-add", selected: true },
+ { text: "1-add" },
+ { text: "5-add" },
+ { text: "2-add" },
+ { text: "10-add" },
+ { text: "11-add" },
+ { text: "12-add" },
+ { text: "13-add" },
+ ],
+ "Range selection from 9-add to 6-add"
+ );
+
+ // Move non-selected into the middle of the selected.
+ widget.moveItems(8, 5, 2, reCreate);
+ assertState(
+ [
+ { text: "4-add" },
+ { text: "3-add" },
+ { text: "7-add" },
+ { text: "8-add" },
+ { text: "6-add", selected: true, focused: true },
+ { text: "5-add" },
+ { text: "2-add" },
+ { text: "0-add", selected: true },
+ { text: "9-add", selected: true },
+ { text: "1-add" },
+ { text: "10-add" },
+ { text: "11-add" },
+ { text: "12-add" },
+ { text: "13-add" },
+ ],
+ "Non-selected gap"
+ );
+
+ // Moving an item always ends a Shift range selection.
+ EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win);
+ assertState(
+ [
+ { text: "4-add" },
+ { text: "3-add" },
+ { text: "7-add" },
+ { text: "8-add", selected: true, focused: true },
+ { text: "6-add", selected: true },
+ { text: "5-add" },
+ { text: "2-add" },
+ { text: "0-add" },
+ { text: "9-add" },
+ { text: "1-add" },
+ { text: "10-add" },
+ { text: "11-add" },
+ { text: "12-add" },
+ { text: "13-add" },
+ ],
+ "Range selection from 6-add to 8-add"
+ );
+
+ clickWidgetItem(9, { shiftKey: true }, win);
+ assertState(
+ [
+ { text: "4-add" },
+ { text: "3-add" },
+ { text: "7-add" },
+ { text: "8-add" },
+ { text: "6-add", selected: true },
+ { text: "5-add", selected: true },
+ { text: "2-add", selected: true },
+ { text: "0-add", selected: true },
+ { text: "9-add", selected: true },
+ { text: "1-add", selected: true, focused: true },
+ { text: "10-add" },
+ { text: "11-add" },
+ { text: "12-add" },
+ { text: "13-add" },
+ ],
+ "Range selection from 6-add to 1-add"
+ );
+
+ // Move selected to middle of selected.
+ widget.moveItems(8, 6, 2, reCreate);
+ assertState(
+ [
+ { text: "4-add" },
+ { text: "3-add" },
+ { text: "7-add" },
+ { text: "8-add" },
+ { text: "6-add", selected: true },
+ { text: "5-add", selected: true },
+ { text: "9-add", selected: true },
+ { text: "1-add", selected: true, focused: true },
+ { text: "2-add", selected: true },
+ { text: "0-add", selected: true },
+ { text: "10-add" },
+ { text: "11-add" },
+ { text: "12-add" },
+ { text: "13-add" },
+ ],
+ "Selection block"
+ );
+
+ // Also ends a Shift range selection.
+ EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win);
+ assertState(
+ [
+ { text: "4-add" },
+ { text: "3-add" },
+ { text: "7-add" },
+ { text: "8-add" },
+ { text: "6-add" },
+ { text: "5-add", selected: true, focused: true },
+ { text: "9-add", selected: true },
+ { text: "1-add", selected: true },
+ { text: "2-add" },
+ { text: "0-add" },
+ { text: "10-add" },
+ { text: "11-add" },
+ { text: "12-add" },
+ { text: "13-add" },
+ ],
+ "Range selection from 1-add to 5-add"
+ );
+
+ // Move from start of selection to end.
+ widget.moveItems(5, 7, 1, reCreate);
+ assertState(
+ [
+ { text: "4-add" },
+ { text: "3-add" },
+ { text: "7-add" },
+ { text: "8-add" },
+ { text: "6-add" },
+ { text: "9-add", selected: true },
+ { text: "1-add", selected: true },
+ { text: "5-add", selected: true, focused: true },
+ { text: "2-add" },
+ { text: "0-add" },
+ { text: "10-add" },
+ { text: "11-add" },
+ { text: "12-add" },
+ { text: "13-add" },
+ ],
+ "Moved to end"
+ );
+
+ // And reverse.
+ widget.moveItems(7, 5, 1, reCreate);
+ assertState(
+ [
+ { text: "4-add" },
+ { text: "3-add" },
+ { text: "7-add" },
+ { text: "8-add" },
+ { text: "6-add" },
+ { text: "5-add", selected: true, focused: true },
+ { text: "9-add", selected: true },
+ { text: "1-add", selected: true },
+ { text: "2-add" },
+ { text: "0-add" },
+ { text: "10-add" },
+ { text: "11-add" },
+ { text: "12-add" },
+ { text: "13-add" },
+ ],
+ "Moved back to start"
+ );
+
+ // Also broke Shift range selection.
+ EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win);
+ assertState(
+ [
+ { text: "4-add" },
+ { text: "3-add", selected: true, focused: true },
+ { text: "7-add", selected: true },
+ { text: "8-add", selected: true },
+ { text: "6-add", selected: true },
+ { text: "5-add", selected: true },
+ { text: "9-add" },
+ { text: "1-add" },
+ { text: "2-add" },
+ { text: "0-add" },
+ { text: "10-add" },
+ { text: "11-add" },
+ { text: "12-add" },
+ { text: "13-add" },
+ ],
+ "Range selection from 5-add to 3-add"
+ );
+
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win);
+ EventUtils.synthesizeKey(" ", { ctrlKey: true }, win);
+ EventUtils.synthesizeKey("KEY_End", { ctrlKey: true }, win);
+ EventUtils.synthesizeKey(" ", { ctrlKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win);
+ EventUtils.synthesizeKey(" ", { ctrlKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win);
+ EventUtils.synthesizeKey(" ", { ctrlKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win);
+ EventUtils.synthesizeKey(" ", { ctrlKey: true }, win);
+
+ assertState(
+ [
+ { text: "4-add" },
+ { text: "3-add", selected: true },
+ { text: "7-add" },
+ { text: "8-add", selected: true },
+ { text: "6-add", selected: true },
+ { text: "5-add", selected: true },
+ { text: "9-add" },
+ { text: "1-add" },
+ { text: "2-add", selected: true, focused: true },
+ { text: "0-add", selected: true },
+ { text: "10-add" },
+ { text: "11-add", selected: true },
+ { text: "12-add" },
+ { text: "13-add", selected: true },
+ ],
+ "Multi-selection"
+ );
+
+ // Move selected with gap into middle of a selection block.
+ widget.moveItems(8, 4, 6, reCreate);
+ assertState(
+ [
+ { text: "4-add" },
+ { text: "3-add", selected: true },
+ { text: "7-add" },
+ { text: "8-add", selected: true },
+ { text: "2-add", selected: true, focused: true },
+ { text: "0-add", selected: true },
+ { text: "10-add" },
+ { text: "11-add", selected: true },
+ { text: "12-add" },
+ { text: "13-add", selected: true },
+ { text: "6-add", selected: true },
+ { text: "5-add", selected: true },
+ { text: "9-add" },
+ { text: "1-add" },
+ ],
+ "Merged ranges together on both sides"
+ );
+
+ // Move selected with gap to start of a selection block.
+ widget.moveItems(5, 1, 5, reCreate);
+ assertState(
+ [
+ { text: "4-add" },
+ { text: "0-add", selected: true },
+ { text: "10-add" },
+ { text: "11-add", selected: true },
+ { text: "12-add" },
+ { text: "13-add", selected: true },
+ { text: "3-add", selected: true },
+ { text: "7-add" },
+ { text: "8-add", selected: true },
+ { text: "2-add", selected: true, focused: true },
+ { text: "6-add", selected: true },
+ { text: "5-add", selected: true },
+ { text: "9-add" },
+ { text: "1-add" },
+ ],
+ "Merged ranges together at start"
+ );
+
+ // Move selected with gap to end of a selection block.
+ widget.moveItems(1, 4, 8, reCreate);
+ assertState(
+ [
+ { text: "4-add" },
+ { text: "2-add", selected: true, focused: true },
+ { text: "6-add", selected: true },
+ { text: "5-add", selected: true },
+ { text: "0-add", selected: true },
+ { text: "10-add" },
+ { text: "11-add", selected: true },
+ { text: "12-add" },
+ { text: "13-add", selected: true },
+ { text: "3-add", selected: true },
+ { text: "7-add" },
+ { text: "8-add", selected: true },
+ { text: "9-add" },
+ { text: "1-add" },
+ ],
+ "Merged ranges together at end"
+ );
+
+ // Move block with non-selected boundaries into middle of selected.
+ widget.moveItems(5, 3, 6, reCreate);
+ assertState(
+ [
+ { text: "4-add" },
+ { text: "2-add", selected: true, focused: true },
+ { text: "6-add", selected: true },
+ { text: "10-add" },
+ { text: "11-add", selected: true },
+ { text: "12-add" },
+ { text: "13-add", selected: true },
+ { text: "3-add", selected: true },
+ { text: "7-add" },
+ { text: "5-add", selected: true },
+ { text: "0-add", selected: true },
+ { text: "8-add", selected: true },
+ { text: "9-add" },
+ { text: "1-add" },
+ ],
+ "Split range block"
+ );
+
+ // Move block with selected at start into middle of selected.
+ widget.moveItems(1, 6, 5, reCreate);
+ assertState(
+ [
+ { text: "4-add" },
+ { text: "13-add", selected: true },
+ { text: "3-add", selected: true },
+ { text: "7-add" },
+ { text: "5-add", selected: true },
+ { text: "0-add", selected: true },
+ { text: "2-add", selected: true, focused: true },
+ { text: "6-add", selected: true },
+ { text: "10-add" },
+ { text: "11-add", selected: true },
+ { text: "12-add" },
+ { text: "8-add", selected: true },
+ { text: "9-add" },
+ { text: "1-add" },
+ ],
+ "Merged ranges together at start"
+ );
+
+ // Move block with selected at end into middle of selected.
+ widget.moveItems(8, 6, 4, reCreate);
+ assertState(
+ [
+ { text: "4-add" },
+ { text: "13-add", selected: true },
+ { text: "3-add", selected: true },
+ { text: "7-add" },
+ { text: "5-add", selected: true },
+ { text: "0-add", selected: true },
+ { text: "10-add" },
+ { text: "11-add", selected: true },
+ { text: "12-add" },
+ { text: "8-add", selected: true },
+ { text: "2-add", selected: true, focused: true },
+ { text: "6-add", selected: true },
+ { text: "9-add" },
+ { text: "1-add" },
+ ],
+ "Merged ranges together at end"
+ );
+
+ // Move selected into non-selected region and move to start.
+ widget.moveItems(4, 0, 6, reCreate);
+ assertState(
+ [
+ { text: "5-add", selected: true },
+ { text: "0-add", selected: true },
+ { text: "10-add" },
+ { text: "11-add", selected: true },
+ { text: "12-add" },
+ { text: "8-add", selected: true },
+ { text: "4-add" },
+ { text: "13-add", selected: true },
+ { text: "3-add", selected: true },
+ { text: "7-add" },
+ { text: "2-add", selected: true, focused: true },
+ { text: "6-add", selected: true },
+ { text: "9-add" },
+ { text: "1-add" },
+ ],
+ "Merged ranges together at end"
+ );
+
+ // Remove gap between two selections and move to end.
+ widget.moveItems(2, 9, 5, reCreate);
+ assertState(
+ [
+ { text: "5-add", selected: true },
+ { text: "0-add", selected: true },
+ { text: "13-add", selected: true },
+ { text: "3-add", selected: true },
+ { text: "7-add" },
+ { text: "2-add", selected: true, focused: true },
+ { text: "6-add", selected: true },
+ { text: "9-add" },
+ { text: "1-add" },
+ { text: "10-add" },
+ { text: "11-add", selected: true },
+ { text: "12-add" },
+ { text: "8-add", selected: true },
+ { text: "4-add" },
+ ],
+ "Merged ranges together"
+ );
+
+ // Navigation still works.
+ EventUtils.synthesizeKey("KEY_ArrowLeft", {}, win);
+ assertState(
+ [
+ { text: "5-add" },
+ { text: "0-add" },
+ { text: "13-add" },
+ { text: "3-add" },
+ { text: "7-add" },
+ { text: "2-add" },
+ { text: "6-add", selected: true, focused: true },
+ { text: "9-add" },
+ { text: "1-add" },
+ { text: "10-add" },
+ { text: "11-add" },
+ { text: "12-add" },
+ { text: "8-add" },
+ { text: "4-add" },
+ ],
+ "Move by one index and single select"
+ );
+}
+
+// Moving items in the widget will move focus and selection with the moved
+// items.
+add_task(function test_move_items() {
+ for (let model of selectionModels) {
+ // We want to be sure the methods work with or without re-creating the
+ // item elements.
+ subtest_move_items(model, false);
+ subtest_move_items(model, true);
+ }
+});
+
+// Test that dragging is possible.
+add_task(function test_can_drag_items() {
+ /**
+ * Assert that dragging can occur and takes place with the expected selection.
+ *
+ * @param {number} index - The index of the item to start dragging on. We also
+ * expect this item to have focus during and after dragging.
+ * @param {number[]} selection - The expected selection during and after
+ * dragging.
+ * @param {string} msg - A message to use in assertions.
+ */
+ function assertDragstart(index, selection, msg) {
+ let element = widget.items[index].element;
+ let eventFired = false;
+
+ let dragstartListener = event => {
+ eventFired = true;
+ Assert.ok(
+ element.contains(event.target),
+ `Item ${index} contains the dragstart target`
+ );
+ assertFocus({ index }, `Item ${index} has focus in dragstart: ${msg}`);
+ assertSelection(selection, `Selection in dragstart: ${msg}`);
+ };
+ widget.addEventListener("dragstart", dragstartListener, true);
+
+ // Synthesize the start of a drag.
+ let rect = element.getBoundingClientRect();
+ let x = rect.left + rect.width / 2;
+ let y = rect.top + rect.height / 2;
+ EventUtils.synthesizeMouseAtPoint(x, y, { type: "mousedown" }, win);
+ EventUtils.synthesizeMouseAtPoint(x, y, { type: "mousemove" }, win);
+ EventUtils.synthesizeMouseAtPoint(x, y + 60, { type: "mousemove" }, win);
+ // Don't care about ending the drag.
+
+ Assert.ok(eventFired, `dragstart event fired: ${msg}`);
+ widget.removeEventListener("dragstart", dragstartListener, true);
+ assertSelection(selection, `Same selection after dragging: ${msg}`);
+ assertFocus(
+ { index },
+ `Item ${index} still has focus after dragging: ${msg}`
+ );
+ }
+
+ for (let model of selectionModels) {
+ reset({ model, draggable: true });
+ widget.addItems(0, ["First", "Second", "Third"]);
+ assertFocus({ element: before }, "Focus outside widget");
+ assertSelection([], "No initial selection");
+ assertDragstart(1, [1], "First drag with no focus or selection");
+
+ assertDragstart(1, [1], "Already selected item");
+ assertDragstart(2, [2], "Non-selected item");
+
+ reset({ model, draggable: true });
+ widget.addItems(0, ["First", "Second", "Third"]);
+ widget.selectSingleItem(1);
+ assertFocus({ element: before }, "Focus outside widget");
+ assertSelection([1], "Initial selection on item 1");
+ assertDragstart(1, [1], "First drag on selected item");
+
+ reset({ model, draggable: true });
+ widget.addItems(0, ["First", "Second", "Third", "Fourth", "Fifth"]);
+ widget.selectSingleItem(3);
+ assertFocus({ element: before }, "Focus outside widget");
+ assertSelection([3], "Initial selection on item 3");
+ assertDragstart(2, [2], "First drag on non-selected item");
+
+ // With focus split from selected.
+ if (model == "focus") {
+ continue;
+ }
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win);
+ assertFocus({ index: 3 }, "Focus on item 3");
+ assertSelection([2], "Item 2 is selected");
+ assertDragstart(3, [3], "Non-selected but focused item");
+
+ EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win);
+ assertFocus({ index: 2 }, "Focus on item 2");
+ assertSelection([3], "Item 3 is selected");
+ assertDragstart(3, [3], "Selected but non-focused item");
+
+ // With mutli-selection.
+ if (model == "browse") {
+ continue;
+ }
+
+ // Clicking a non-selected item will change to selection to the single item
+ // before dragging.
+ EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win);
+ EventUtils.synthesizeKey(" ", { ctrlKey: true }, win);
+ assertFocus({ index: 1 }, "Focus on item 1");
+ assertSelection([1, 3], "Multi selection");
+ assertDragstart(2, [2], "Selection moves to item 2 before drag");
+
+ // Clicking a selected item will keep the same selection for dragging.
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win);
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win);
+ EventUtils.synthesizeKey(" ", { ctrlKey: true }, win);
+ assertFocus({ index: 4 }, "Focus on item 4");
+ assertSelection([2, 4], "Multi selection");
+ assertDragstart(
+ 4,
+ [2, 4],
+ "Selection same when dragging selected and focused"
+ );
+ assertDragstart(
+ 2,
+ [2, 4],
+ "Selection same when dragging selected and non-focussed"
+ );
+ }
+});
diff --git a/comm/mail/base/test/browser/browser_smartFolderDelete.js b/comm/mail/base/test/browser/browser_smartFolderDelete.js
new file mode 100644
index 0000000000..1c3f1fb59c
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_smartFolderDelete.js
@@ -0,0 +1,75 @@
+/* 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 { VirtualFolderHelper } = ChromeUtils.import(
+ "resource:///modules/VirtualFolderWrapper.jsm"
+);
+
+const { MessageGenerator } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MessageGenerator.jsm"
+);
+
+const tabmail = document.getElementById("tabmail");
+const about3Pane = tabmail.currentAbout3Pane;
+
+let rootFolder;
+let inboxFolder;
+
+add_setup(async function () {
+ MailServices.accounts.createLocalMailAccount();
+ const account = MailServices.accounts.accounts[0];
+ rootFolder = account.incomingServer.rootFolder;
+ rootFolder.QueryInterface(Ci.nsIMsgLocalMailFolder);
+
+ // Set the active modes of the folder pane. In theory we only need the "smart"
+ // mode to test with, but in practice we also need the "all" mode to generate
+ // messages in folders.
+ about3Pane.folderPane.activeModes = ["all", "smart"];
+
+ registerCleanupFunction(() => {
+ MailServices.accounts.removeAccount(account, false);
+ about3Pane.folderPane.activeModes = ["all"];
+ });
+});
+
+/**
+ * Test deleting a message from a smart folder using
+ * gDBView.applyCommandToIndices.
+ */
+add_task(async function testDeleteViaDBViewCommand() {
+ // Create an inbox folder.
+ const inboxFolder = rootFolder
+ .createLocalSubfolder("testDeleteInbox")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+ inboxFolder.setFlag(Ci.nsMsgFolderFlags.Inbox);
+
+ // Add a message to the folder.
+ const generator = new MessageGenerator();
+ inboxFolder.addMessage(generator.makeMessage().toMboxString());
+
+ // Create a smart folder from the inbox.
+ const smartInboxFolder = getSmartServer().rootFolder.getFolderWithFlags(
+ Ci.nsMsgFolderFlags.Inbox
+ );
+
+ // Switch the view to the smart folder.
+ about3Pane.displayFolder(smartInboxFolder.URI);
+
+ // Get the DB view and tree view to use to send the command and observe its
+ // effect.
+ const dbView = about3Pane.gDBView;
+ const treeView = dbView.QueryInterface(Ci.nsITreeView);
+
+ // Ensure we currently have one message.
+ Assert.equal(treeView.rowCount, 1, "should have one message before deleting");
+
+ // Delete the message using applyCommandToIndices.
+ dbView.applyCommandToIndices(Ci.nsMsgViewCommandType.deleteMsg, [0]);
+
+ // Test that the message has been deleted.
+ await TestUtils.waitForCondition(
+ () => treeView.rowCount === 0,
+ "there should be no remaining message in the tree"
+ );
+});
diff --git a/comm/mail/base/test/browser/browser_spacesToolbar.js b/comm/mail/base/test/browser/browser_spacesToolbar.js
new file mode 100644
index 0000000000..c2432a2131
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_spacesToolbar.js
@@ -0,0 +1,1173 @@
+/* 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/. */
+
+/**
+ * Test the spaces toolbar features.
+ */
+
+/* globals gSpacesToolbar */
+
+var folderA;
+var folderB;
+var testAccount;
+
+add_setup(function () {
+ // Set up two folders.
+ window.MailServices.accounts.createLocalMailAccount();
+ testAccount = window.MailServices.accounts.accounts[0];
+ let rootFolder = testAccount.incomingServer.rootFolder;
+ rootFolder.createSubfolder("spacesToolbarA", null);
+ folderA = rootFolder.findSubFolder("spacesToolbarA");
+ rootFolder.createSubfolder("spacesToolbarB", null);
+ folderB = rootFolder.findSubFolder("spacesToolbarB");
+});
+
+registerCleanupFunction(async () => {
+ window.MailServices.accounts.removeAccount(testAccount, true);
+ // Close all opened tabs.
+ let tabmail = document.getElementById("tabmail");
+ tabmail.closeOtherTabs(tabmail.tabInfo[0]);
+ // Reset the spaces toolbar to its default visible state.
+ window.gSpacesToolbar.toggleToolbar(false);
+ // Reset the menubar visibility.
+ let menubar = document.getElementById("toolbar-menubar");
+ menubar.removeAttribute("autohide");
+ menubar.removeAttribute("inactive");
+ await new Promise(resolve => requestAnimationFrame(resolve));
+});
+
+async function assertMailShown(win = window) {
+ await TestUtils.waitForCondition(
+ () =>
+ win.document.getElementById("tabmail").currentTabInfo.mode.name ==
+ "mail3PaneTab",
+ "The mail tab should be visible"
+ );
+}
+
+async function assertAddressBookShown(win = window) {
+ await TestUtils.waitForCondition(() => {
+ let panel = win.document.querySelector(
+ // addressBookTabWrapper0, addressBookTabWrapper1, etc
+ "#tabpanelcontainer > [id^=addressBookTabWrapper][selected]"
+ );
+ if (!panel) {
+ return false;
+ }
+ let browser = panel.querySelector("[id^=addressBookTabBrowser]");
+ return browser.contentDocument.readyState == "complete";
+ }, "The address book tab should be visible and loaded");
+}
+
+async function assertChatShown(win = window) {
+ await TestUtils.waitForCondition(
+ () => win.document.getElementById("chatTabPanel").hasAttribute("selected"),
+ "The chat tab should be visible"
+ );
+}
+
+async function assertCalendarShown(win = window) {
+ await TestUtils.waitForCondition(() => {
+ return (
+ win.document
+ .getElementById("calendarTabPanel")
+ .hasAttribute("selected") &&
+ !win.document.getElementById("calendar-view-box").collapsed
+ );
+ }, "The calendar view should be visible");
+}
+
+async function assertTasksShown(win = window) {
+ await TestUtils.waitForCondition(() => {
+ return (
+ win.document
+ .getElementById("calendarTabPanel")
+ .hasAttribute("selected") &&
+ !win.document.getElementById("calendar-task-box").collapsed
+ );
+ }, "The task view should be visible");
+}
+
+async function assertSettingsShown(win = window) {
+ await TestUtils.waitForCondition(() => {
+ let panel = win.document.querySelector(
+ // preferencesTabWrapper0, preferencesTabWrapper1, etc
+ "#tabpanelcontainer > [id^=preferencesTabWrapper][selected]"
+ );
+ if (!panel) {
+ return false;
+ }
+ let browser = panel.querySelector("[id^=preferencesTabBrowser]");
+ return browser.contentDocument.readyState == "complete";
+ }, "The settings tab should be visible and loaded");
+}
+
+async function assertContentShown(url, win = window) {
+ await TestUtils.waitForCondition(() => {
+ let panel = win.document.querySelector(
+ // contentTabWrapper0, contentTabWrapper1, etc
+ "#tabpanelcontainer > [id^=contentTabWrapper][selected]"
+ );
+ if (!panel) {
+ return false;
+ }
+ let doc = panel.querySelector("[id^=contentTabBrowser]").contentDocument;
+ return doc.URL == url && doc.readyState == "complete";
+ }, `The selected content tab should show ${url}`);
+}
+
+async function sub_test_cycle_through_primary_tabs() {
+ // We can't really cycle through all buttons and tabs with a simple for loop
+ // since some tabs are actual collapsing views and other tabs are separate
+ // pages. We can improve this once the new 3pane tab is actually a standalone
+ // tab.
+
+ // Switch to address book.
+ EventUtils.synthesizeMouseAtCenter(
+ document.getElementById("addressBookButton"),
+ {},
+ window
+ );
+ await assertAddressBookShown();
+
+ // Switch to calendar.
+ EventUtils.synthesizeMouseAtCenter(
+ document.getElementById("calendarButton"),
+ {},
+ window
+ );
+ await assertCalendarShown();
+
+ // Switch to Mail.
+ EventUtils.synthesizeMouseAtCenter(
+ document.getElementById("mailButton"),
+ {},
+ window
+ );
+ await assertMailShown();
+
+ // Switch to Tasks.
+ EventUtils.synthesizeMouseAtCenter(
+ document.getElementById("tasksButton"),
+ {},
+ window
+ );
+ await assertTasksShown();
+
+ // Switch to chat.
+ EventUtils.synthesizeMouseAtCenter(
+ document.getElementById("chatButton"),
+ {},
+ window
+ );
+ await assertChatShown();
+
+ // Switch to Settings.
+ EventUtils.synthesizeMouseAtCenter(
+ document.getElementById("settingsButton"),
+ {},
+ window
+ );
+ await assertSettingsShown();
+
+ // Switch to Mail.
+ EventUtils.synthesizeMouseAtCenter(
+ document.getElementById("mailButton"),
+ {},
+ window
+ );
+ await assertMailShown();
+
+ window.tabmail.closeOtherTabs(window.tabmail.tabInfo[0]);
+}
+
+add_task(async function testSpacesToolbarVisibility() {
+ let spacesToolbar = document.getElementById("spacesToolbar");
+ let toggleButton = document.getElementById("spacesToolbarReveal");
+ let pinnedButton = document.getElementById("spacesPinnedButton");
+ Assert.ok(spacesToolbar, "The spaces toolbar exists");
+
+ let assertVisibility = async function (isHidden, msg) {
+ await TestUtils.waitForCondition(
+ () => spacesToolbar.hidden == !isHidden,
+ `The spaces toolbar should be ${!isHidden ? "visible" : "hidden"}: ${msg}`
+ );
+
+ await TestUtils.waitForCondition(
+ () => toggleButton.hidden == isHidden,
+ `The toggle button should be ${isHidden ? "hidden" : "visible"}: ${msg}`
+ );
+
+ await TestUtils.waitForCondition(
+ () => pinnedButton.hidden == isHidden,
+ `The pinned button should be ${isHidden ? "hidden" : "visible"}: ${msg}`
+ );
+ };
+
+ async function toggleVisibilityWithAppMenu(expectChecked) {
+ let appMenu = document.getElementById("appMenu-popup");
+ let menuShownPromise = BrowserTestUtils.waitForEvent(appMenu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ document.getElementById("button-appmenu"),
+ {},
+ window
+ );
+ await menuShownPromise;
+
+ let viewShownPromise = BrowserTestUtils.waitForEvent(
+ appMenu.querySelector("#appMenu-viewView"),
+ "ViewShown"
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ appMenu.querySelector("#appmenu_View"),
+ {},
+ window
+ );
+ await viewShownPromise;
+
+ let toolbarShownPromise = BrowserTestUtils.waitForEvent(
+ appMenu.querySelector("#appMenu-toolbarsView"),
+ "ViewShown"
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ appMenu.querySelector("#appmenu_Toolbars"),
+ {},
+ window
+ );
+ await toolbarShownPromise;
+
+ let appMenuButton = document.getElementById("appmenu_spacesToolbar");
+ Assert.equal(
+ appMenuButton.checked,
+ expectChecked,
+ `The app menu item should ${expectChecked ? "not " : ""}be checked`
+ );
+
+ EventUtils.synthesizeMouseAtCenter(appMenuButton, {}, window);
+
+ // Close the appmenu.
+ EventUtils.synthesizeMouseAtCenter(
+ document.getElementById("button-appmenu"),
+ {},
+ window
+ );
+ }
+ await assertVisibility(true, "on initial load");
+
+ // Collapse with a mouse click.
+ let activeElement = document.activeElement;
+ let collapseButton = document.getElementById("collapseButton");
+ EventUtils.synthesizeMouseAtCenter(collapseButton, {}, window);
+ await assertVisibility(false, "after clicking collapse button");
+
+ await toggleVisibilityWithAppMenu(false);
+ await assertVisibility(true, "after revealing with the app menu");
+
+ // We already clicked the collapse button, so it should already be the
+ // focusButton for the gSpacesToolbar, and thus focusable.
+ collapseButton.focus();
+ Assert.ok(
+ collapseButton.matches(":focus"),
+ "Collapse button should be focusable"
+ );
+
+ // Hide the spaces toolbar using the collapse button, which already has focus.
+ EventUtils.synthesizeKey(" ", {}, window);
+ await assertVisibility(false, "after closing with space key press");
+ Assert.ok(
+ pinnedButton.matches(":focus"),
+ "Pinned button should be focused after closing with a key press"
+ );
+
+ // Show using the pinned button menu.
+ let pinnedMenu = document.getElementById("spacesButtonMenuPopup");
+ let pinnedMenuShown = BrowserTestUtils.waitForEvent(pinnedMenu, "popupshown");
+ EventUtils.synthesizeKey("KEY_Enter", {}, window);
+ await pinnedMenuShown;
+ pinnedMenu.activateItem(document.getElementById("spacesPopupButtonReveal"));
+
+ await assertVisibility(true, "after opening with pinned menu");
+ Assert.ok(
+ collapseButton.matches(":focus"),
+ "Collapse button should be focused again after showing with the pinned menu"
+ );
+
+ // Move focus to the mail button.
+ let mailButton = document.getElementById("mailButton");
+ // Loop around from the collapse button to the mailButton.
+ EventUtils.synthesizeKey("KEY_ArrowDown", {}, window);
+
+ Assert.ok(
+ mailButton.matches(":focus"),
+ "Mail button should become focused after pressing key down"
+ );
+ Assert.ok(
+ spacesToolbar.matches(":focus-within"),
+ "Spaces toolbar should contain the focus"
+ );
+
+ // Now move focus elsewhere.
+ EventUtils.synthesizeKey("KEY_Tab", {}, window);
+ activeElement = document.activeElement;
+ Assert.ok(
+ !mailButton.matches(":focus"),
+ "Mail button should no longer be focused"
+ );
+ Assert.ok(
+ !spacesToolbar.matches(":focus-within"),
+ "Spaces toolbar should no longer contain the focus"
+ );
+
+ // Hide the spaces toolbar using the app menu.
+ await toggleVisibilityWithAppMenu(true);
+ await assertVisibility(false, "after hiding with the app menu");
+
+ // macOS by default doesn't move the focus when clicking on toolbar buttons.
+ if (AppConstants.platform != "macosx") {
+ Assert.notEqual(
+ document.activeElement,
+ activeElement,
+ "The focus moved from the previous element"
+ );
+ // Focus should be on the main app menu since we used the mouse to toggle the
+ // spaces toolbar.
+ Assert.equal(
+ document.activeElement,
+ document.getElementById("button-appmenu"),
+ "Active element is on the app menu"
+ );
+ } else {
+ Assert.equal(
+ document.activeElement,
+ activeElement,
+ "The focus didn't move from the previous element"
+ );
+ }
+
+ // Now click the status bar toggle button to reveal the toolbar again.
+ toggleButton.focus();
+ Assert.ok(
+ toggleButton.matches(":focus"),
+ "Toggle button should be focusable"
+ );
+ EventUtils.synthesizeKey("KEY_Enter", {}, window);
+ await assertVisibility(true, "after showing with the toggle button");
+ // Focus is restored to the mailButton.
+ Assert.ok(
+ mailButton.matches(":focus"),
+ "Mail button should become focused again"
+ );
+
+ // Clicked buttons open or move to the correct tab, starting with just one tab
+ // open.
+ await sub_test_cycle_through_primary_tabs();
+});
+
+add_task(async function testSpacesToolbarContextMenu() {
+ let tabmail = document.getElementById("tabmail");
+ let firstMailTabInfo = tabmail.currentTabInfo;
+ firstMailTabInfo.folder = folderB;
+
+ // Fetch context menu elements.
+ let contextMenu = document.getElementById("spacesContextMenu");
+ let newTabItem = document.getElementById("spacesContextNewTabItem");
+ let newWindowItem = document.getElementById("spacesContextNewWindowItem");
+
+ let settingsMenu = document.getElementById("settingsContextMenu");
+ let settingsItem = document.getElementById("settingsContextOpenSettingsItem");
+ let accountItem = document.getElementById(
+ "settingsContextOpenAccountSettingsItem"
+ );
+ let addonsItem = document.getElementById("settingsContextOpenAddonsItem");
+
+ /**
+ * Open the context menu, test its state, select an action and wait for it to
+ * close.
+ *
+ * @param {object} input - Input data.
+ * @param {Element} input.button - The button whose context menu should be
+ * opened.
+ * @param {Element} [input.item] - The context menu item to select. Either
+ * this or switchItem must be given.
+ * @param {number} [input.switchItem] - The nth switch-to-tab item to select.
+ * @param {object} expect - The expected state of the context menu when
+ * opened.
+ * @param {boolean} [expect.settings=false] - Whether we expect the settings
+ * context menu. If this is true, the other values are ignored.
+ * @param {boolean} [expect.newTab=false] - Whether we expect the "Open in new
+ * tab" item to be visible.
+ * @param {boolean} [expect.newWindow=false] - Whether we expect the "Open in
+ * new window" item to be visible.
+ * @param {number} [expect.numSwitch=0] - The expected number of switch-to-tab
+ * items.
+ * @param {string} msg - A message to use in tests.
+ */
+ async function useContextMenu(input, expect, msg) {
+ let menu = expect.settings ? settingsMenu : contextMenu;
+ let shownPromise = BrowserTestUtils.waitForEvent(menu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ input.button,
+ { type: "contextmenu" },
+ window
+ );
+ await shownPromise;
+ let item = input.item;
+ if (!expect.settings) {
+ Assert.equal(
+ BrowserTestUtils.is_visible(newTabItem),
+ expect.newTab || false,
+ `Open in new tab item visibility: ${msg}`
+ );
+ Assert.equal(
+ BrowserTestUtils.is_visible(newWindowItem),
+ expect.newWindow || false,
+ `Open in new window item visibility: ${msg}`
+ );
+ let switchItems = menu.querySelectorAll(".switch-to-tab");
+ Assert.equal(
+ switchItems.length,
+ expect.numSwitch || 0,
+ `Should have the expected number of switch items: ${msg}`
+ );
+ if (!item) {
+ item = switchItems[input.switchItem];
+ }
+ }
+ let hiddenPromise = BrowserTestUtils.waitForEvent(menu, "popuphidden");
+ menu.activateItem(item);
+ await hiddenPromise;
+ }
+
+ let tabScroll = document.getElementById("tabmail-arrowscrollbox").scrollbox;
+ /**
+ * Ensure the tab is scrolled into view.
+ *
+ * @param {MozTabmailTab} - The tab to scroll into view.
+ */
+ async function scrollToTab(tab) {
+ function tabInView() {
+ let tabRect = tab.getBoundingClientRect();
+ let scrollRect = tabScroll.getBoundingClientRect();
+ return (
+ tabRect.left >= scrollRect.left && tabRect.right <= scrollRect.right
+ );
+ }
+ if (tabInView()) {
+ info(`Tab ${tab.label} already in view`);
+ return;
+ }
+ tab.scrollIntoView();
+ await TestUtils.waitForCondition(
+ tabInView,
+ "Tab should be scrolled into view: " + tab.label
+ );
+ info(`Tab ${tab.label} was scrolled into view`);
+ }
+
+ let numTabs = 0;
+ /**
+ * Wait for and return the latest tab.
+ *
+ * This should be called every time a tab is created so the test can keep
+ * track of the expected number of tabs.
+ *
+ * @returns {MozTabmailTab} - The last tab.
+ */
+ async function waitForNewTab() {
+ numTabs++;
+ let tabs;
+ await TestUtils.waitForCondition(() => {
+ tabs = document.querySelectorAll("tab.tabmail-tab");
+ return tabs.length == numTabs;
+ }, `Waiting for ${numTabs} tabs`);
+ return tabs[numTabs - 1];
+ }
+
+ /**
+ * Close a tab and wait for it to close.
+ *
+ * This should be used alongside waitForNewTab so the test can keep track of
+ * the expected number of tabs.
+ *
+ * @param {MozTabmailTab} - The tab to close.
+ */
+ async function closeTab(tab) {
+ numTabs--;
+ await scrollToTab(tab);
+ EventUtils.synthesizeMouseAtCenter(
+ tab.querySelector(".tab-close-button"),
+ {},
+ window
+ );
+ await TestUtils.waitForCondition(
+ () => document.querySelectorAll("tab.tabmail-tab").length == numTabs,
+ "Waiting for tab to close"
+ );
+ }
+
+ let toolbar = document.getElementById("spacesToolbar");
+ /**
+ * Verify the current tab and space match.
+ *
+ * @param {MozTabmailTab} tab - The expected tab.
+ * @param {Element} spaceButton - The expected button to be shown as the
+ * current space in the spaces toolbar.
+ * @param {string} msg - A message to use in tests.
+ */
+ async function assertTab(tab, spaceButton, msg) {
+ await TestUtils.waitForCondition(
+ () => tab.selected,
+ `Tab should be selected: ${msg}`
+ );
+ let current = toolbar.querySelectorAll("button.current");
+ Assert.equal(current.length, 1, `Should have one current space: ${msg}`);
+ Assert.equal(
+ current[0],
+ spaceButton,
+ `Current button ${current[0].id} should match: ${msg}`
+ );
+ }
+
+ /**
+ * Click on a tab and verify we have switched tabs and spaces.
+ *
+ * @param {MozTabmailTab} tab - The tab to click.
+ * @param {Element} spaceButton - The expected button to be shown as the
+ * current space after clicking the tab.
+ * @param {string} msg - A message to use in tests.
+ */
+ async function switchTab(tab, spaceButton, msg) {
+ await scrollToTab(tab);
+ EventUtils.synthesizeMouseAtCenter(tab, {}, window);
+ await assertTab(tab, spaceButton, msg);
+ }
+
+ // -- Test initial tab --
+
+ let mailButton = document.getElementById("mailButton");
+ let firstTab = await waitForNewTab();
+ await assertTab(firstTab, mailButton, "First tab is mail tab");
+ await assertMailShown();
+
+ // -- Test spaces that only open one tab --
+
+ let calendarTab;
+ let calendarButton = document.getElementById("calendarButton");
+ for (let { name, button, assertShown } of [
+ {
+ name: "address book",
+ button: document.getElementById("addressBookButton"),
+ assertShown: assertAddressBookShown,
+ },
+ {
+ name: "calendar",
+ button: calendarButton,
+ assertShown: assertCalendarShown,
+ },
+ {
+ name: "tasks",
+ button: document.getElementById("tasksButton"),
+ assertShown: assertTasksShown,
+ },
+ {
+ name: "chat",
+ button: document.getElementById("chatButton"),
+ assertShown: assertChatShown,
+ },
+ ]) {
+ info(`Testing ${name} space`);
+ // Only have option to open in new tab.
+ await useContextMenu(
+ { button, item: newTabItem },
+ { newTab: true },
+ `Opening ${name} tab`
+ );
+ let newTab = await waitForNewTab();
+ if (name == "calendar") {
+ calendarTab = newTab;
+ }
+ await assertTab(newTab, button, `Opened ${name} tab`);
+ await assertShown();
+ // Only have option to switch tabs.
+ // Doing this from the same tab does nothing.
+ await useContextMenu(
+ { button, switchItem: 0 },
+ { numSwitch: 1 },
+ `When ${name} tab is open`
+ );
+ // Wait one tick to allow tabs to potentially change.
+ await TestUtils.waitForTick();
+ // However, the same tab should remain shown.
+ await assertShown();
+
+ // Switch to first tab and back.
+ await switchTab(firstTab, mailButton, `${name} to first tab`);
+ await assertMailShown();
+ await useContextMenu(
+ { button, switchItem: 0 },
+ { numSwitch: 1 },
+ `Switching from first tab to ${name}`
+ );
+ await assertTab(newTab, button, `Switched from first tab to ${name}`);
+ await assertShown();
+ }
+
+ // -- Test opening mail space in a new tab --
+
+ // Open new mail tabs whilst we are still in a non-mail tab.
+ await useContextMenu(
+ { button: mailButton, item: newTabItem },
+ { newWindow: true, newTab: true, numSwitch: 1 },
+ "Opening the second mail tab"
+ );
+ let secondMailTab = await waitForNewTab();
+ await assertTab(secondMailTab, mailButton, "Opened second mail tab");
+ await assertMailShown();
+ // Displayed folder should be the same as in the first mail tab.
+ let [, secondMailTabInfo] =
+ tabmail._getTabContextForTabbyThing(secondMailTab);
+ await TestUtils.waitForCondition(
+ () => secondMailTabInfo.folder?.URI == folderB.URI,
+ "Should display folder B in the second mail tab"
+ );
+
+ secondMailTabInfo.folder = folderA;
+
+ // Open a new mail tab whilst in a mail tab.
+ await useContextMenu(
+ { button: mailButton, item: newTabItem },
+ { newWindow: true, newTab: true, numSwitch: 2 },
+ "Opening the third mail tab"
+ );
+ let thirdMailTab = await waitForNewTab();
+ await assertTab(thirdMailTab, mailButton, "Opened third mail tab");
+ await assertMailShown();
+ // Displayed folder should be the same as in the mail tab that was in view
+ // when the context menu was opened, rather than the folder in the first tab.
+ let [, thirdMailTabInfo] = tabmail._getTabContextForTabbyThing(thirdMailTab);
+ await TestUtils.waitForCondition(
+ () => thirdMailTabInfo.folder?.URI == folderA.URI,
+ "Should display folder A in the third mail tab"
+ );
+
+ // -- Test switching between the multiple mail tabs --
+
+ await useContextMenu(
+ { button: mailButton, switchItem: 1 },
+ { newWindow: true, newTab: true, numSwitch: 3 },
+ "Switching to second mail tab"
+ );
+ await assertTab(secondMailTab, mailButton, "Switch to second mail tab");
+ await assertMailShown();
+ await useContextMenu(
+ { button: mailButton, switchItem: 0 },
+ { newWindow: true, newTab: true, numSwitch: 3 },
+ "Switching to first mail tab"
+ );
+ await assertTab(firstTab, mailButton, "Switch to first mail tab");
+ await assertMailShown();
+
+ await switchTab(calendarTab, calendarButton, "First mail to calendar tab");
+ await useContextMenu(
+ { button: mailButton, switchItem: 2 },
+ { newWindow: true, newTab: true, numSwitch: 3 },
+ "Switching to third mail tab"
+ );
+ await assertTab(thirdMailTab, mailButton, "Switch to third mail tab");
+ await assertMailShown();
+
+ // -- Test the mail button with multiple mail tabs --
+
+ // Clicking the mail button whilst in the mail space does nothing.
+ // Specifically, we do not want it to take us to the first tab.
+ EventUtils.synthesizeMouseAtCenter(mailButton, {}, window);
+ // Wait one cycle to see if the tab would change.
+ await TestUtils.waitForTick();
+ await assertTab(thirdMailTab, mailButton, "Remain in third tab");
+ await assertMailShown();
+ Assert.equal(
+ thirdMailTabInfo.folder.URI,
+ folderA.URI,
+ "Still display folder A in the third mail tab"
+ );
+
+ // Clicking the mail button whilst in a different space takes us to the first
+ // mail tab.
+ await switchTab(calendarTab, calendarButton, "Third mail to calendar tab");
+ EventUtils.synthesizeMouseAtCenter(mailButton, {}, window);
+ await assertTab(firstTab, mailButton, "Switch to the first mail tab");
+ await assertMailShown();
+ Assert.equal(
+ firstMailTabInfo.folder.URI,
+ folderB.URI,
+ "Still display folder B in the first mail tab"
+ );
+
+ // -- Test opening the mail space in a new window --
+
+ let windowPromise = BrowserTestUtils.domWindowOpenedAndLoaded();
+ await useContextMenu(
+ { button: mailButton, item: newWindowItem },
+ { newWindow: true, newTab: true, numSwitch: 3 },
+ "Opening mail tab in new window"
+ );
+ let newMailWindow = await windowPromise;
+ let newTabmail = newMailWindow.document.getElementById("tabmail");
+ // Expect the same folder as the previously focused tab.
+ await TestUtils.waitForCondition(
+ () => newTabmail.currentTabInfo.folder?.URI == folderB.URI,
+ "Waiting for folder B to be displayed in the new window"
+ );
+ Assert.equal(
+ newMailWindow.document.querySelectorAll("tab.tabmail-tab").length,
+ 1,
+ "Should only have one tab in the new window"
+ );
+ await assertMailShown(newMailWindow);
+
+ // -- Test opening different tabs that belong to the settings space --
+
+ let settingsButton = document.getElementById("settingsButton");
+ await useContextMenu(
+ { button: settingsButton, item: accountItem },
+ { settings: true },
+ "Opening account settings"
+ );
+ let accountTab = await waitForNewTab();
+ // Shown as part of the settings space.
+ await assertTab(accountTab, settingsButton, "Opened account settings tab");
+ await assertContentShown("about:accountsettings");
+
+ await useContextMenu(
+ { button: settingsButton, item: settingsItem },
+ { settings: true },
+ "Opening settings"
+ );
+ let settingsTab = await waitForNewTab();
+ // Shown as part of the settings space.
+ await assertTab(settingsTab, settingsButton, "Opened settings tab");
+ await assertSettingsShown();
+
+ await useContextMenu(
+ { button: settingsButton, item: addonsItem },
+ { settings: true },
+ "Opening add-ons"
+ );
+ let addonsTab = await waitForNewTab();
+ // Shown as part of the settings space.
+ await assertTab(addonsTab, settingsButton, "Opened add-ons tab");
+ await assertContentShown("about:addons");
+
+ // -- Test the settings button with multiple settings tabs --
+
+ // Clicking the settings button whilst in the settings space does nothing.
+ EventUtils.synthesizeMouseAtCenter(settingsButton, {}, window);
+ // Wait one cycle to see if the tab would change.
+ await TestUtils.waitForTick();
+ await assertTab(addonsTab, settingsButton, "Remain in add-ons tab");
+ await assertContentShown("about:addons");
+
+ // Clicking the settings button whilst in a different space takes us to the
+ // settings tab, rather than the first tab, since this is the primary tab for
+ // the space.
+ await switchTab(calendarTab, calendarButton, "Add-ons to calendar tab");
+ EventUtils.synthesizeMouseAtCenter(settingsButton, {}, window);
+ await assertTab(settingsTab, settingsButton, "Switch to the settings tab");
+ await assertSettingsShown();
+
+ // Clicking the settings button whilst in a different space and no settings
+ // tab will open a new settings tab, rather than switch to another tab in the
+ // settings space because they are not the primary tab for the space.
+ await closeTab(settingsTab);
+ await switchTab(calendarTab, calendarButton, "Settings to calendar tab");
+ EventUtils.synthesizeMouseAtCenter(settingsButton, {}, window);
+ settingsTab = await waitForNewTab();
+ await assertTab(settingsTab, settingsButton, "Re-opened settings tab");
+ await assertSettingsShown();
+
+ // -- Test opening different settings tabs when they already exist --
+
+ await useContextMenu(
+ { button: settingsButton, item: addonsItem },
+ { settings: true },
+ "Switching to add-ons"
+ );
+ await assertTab(addonsTab, settingsButton, "Switched to add-ons");
+ await assertContentShown("about:addons");
+
+ await useContextMenu(
+ { button: settingsButton, item: accountItem },
+ { settings: true },
+ "Switching to account settings"
+ );
+ await assertTab(accountTab, settingsButton, "Switched to account settings");
+ await assertContentShown("about:accountsettings");
+
+ await useContextMenu(
+ { button: settingsButton, item: settingsItem },
+ { settings: true },
+ "Switching to settings"
+ );
+ await assertTab(settingsTab, settingsButton, "Switched to settings");
+ await assertSettingsShown();
+
+ // -- Test clicking the spaces buttons when all the tabs are already open.
+
+ await sub_test_cycle_through_primary_tabs();
+
+ // Tidy up the opened window.
+ // FIXME: Closing the window earlier in the test causes a test failure on the
+ // osx build on the try server.
+ await BrowserTestUtils.closeWindow(newMailWindow);
+});
+
+add_task(async function testSpacesToolbarMenubar() {
+ document.getElementById("toolbar-menubar").removeAttribute("autohide");
+ let spacesToolbar = document.getElementById("spacesToolbar");
+
+ EventUtils.synthesizeMouseAtCenter(
+ document.getElementById("collapseButton"),
+ {},
+ window
+ );
+ Assert.ok(spacesToolbar.hidden, "The spaces toolbar is hidden");
+ Assert.ok(
+ !document.getElementById("spacesToolbarReveal").hidden,
+ "The status bar toggle button is visible"
+ );
+
+ // Test the menubar button.
+ let viewShownPromise = BrowserTestUtils.waitForEvent(
+ document.getElementById("menu_View_Popup"),
+ "popupshown"
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ document.getElementById("menu_View"),
+ {},
+ window
+ );
+ await viewShownPromise;
+
+ let toolbarsShownPromise = BrowserTestUtils.waitForEvent(
+ document.getElementById("view_toolbars_popup"),
+ "popupshown"
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ document.getElementById("menu_Toolbars"),
+ {},
+ window
+ );
+ await toolbarsShownPromise;
+
+ let menuButton = document.getElementById("viewToolbarsPopupSpacesToolbar");
+ Assert.ok(
+ menuButton.getAttribute("checked") != "true",
+ "The menu item is not checked"
+ );
+
+ let viewHiddenPromise = BrowserTestUtils.waitForEvent(
+ document.getElementById("menu_View_Popup"),
+ "popuphidden"
+ );
+ EventUtils.synthesizeMouseAtCenter(menuButton, {}, window);
+ await viewHiddenPromise;
+
+ Assert.ok(
+ menuButton.getAttribute("checked") == "true",
+ "The menu item is checked"
+ );
+}).__skipMe = AppConstants.platform == "macosx"; // Can't click menu bar on Mac.
+
+add_task(async function testSpacesToolbarOSX() {
+ let size = document
+ .getElementById("spacesToolbar")
+ .getBoundingClientRect().width;
+
+ // By default, macOS shouldn't need any custom styling.
+ Assert.ok(
+ !document.getElementById("titlebar").hasAttribute("style"),
+ "The custom styling was cleared from all toolbars"
+ );
+
+ let styleAppliedPromise = BrowserTestUtils.waitForCondition(
+ () =>
+ document.getElementById("tabmail-tabs").getAttribute("style") ==
+ `margin-inline-start: ${size}px;`,
+ "The correct style was applied to the tabmail"
+ );
+
+ // Force full screen.
+ window.fullScreen = true;
+ await new Promise(resolve => requestAnimationFrame(resolve));
+ await styleAppliedPromise;
+
+ let styleRemovedPromise = BrowserTestUtils.waitForCondition(
+ () => !document.getElementById("tabmail-tabs").hasAttribute("style"),
+ "The custom styling was cleared from all toolbars"
+ );
+ // Restore original window size.
+ window.fullScreen = false;
+ await new Promise(resolve => requestAnimationFrame(resolve));
+ await styleRemovedPromise;
+}).__skipMe = AppConstants.platform != "macosx";
+
+add_task(async function testSpacesToolbarClearedAlignment() {
+ // Hide the spaces toolbar to check if the style it's cleared.
+ window.gSpacesToolbar.toggleToolbar(true);
+ Assert.ok(
+ !document.getElementById("titlebar").hasAttribute("style") &&
+ !document.getElementById("navigation-toolbox").hasAttribute("style"),
+ "The custom styling was cleared from all toolbars"
+ );
+});
+
+add_task(async function testSpacesToolbarExtension() {
+ window.gSpacesToolbar.toggleToolbar(false);
+
+ for (let i = 0; i < 6; i++) {
+ await window.gSpacesToolbar.createToolbarButton(`testButton${i}`, {
+ title: `Title ${i}`,
+ url: `https://test.invalid/${i}`,
+ iconStyles: new Map([
+ [
+ "--webextension-toolbar-image",
+ 'url("chrome://messenger/content/extension.svg")',
+ ],
+ ]),
+ });
+ let button = document.getElementById(`testButton${i}`);
+ Assert.ok(button);
+ Assert.equal(button.title, `Title ${i}`);
+
+ let img = button.querySelector("img");
+ Assert.equal(
+ img.style.getPropertyValue("--webextension-toolbar-image"),
+ `url("chrome://messenger/content/extension.svg")`,
+ `Button image should have the correct icon.`
+ );
+
+ let menuitem = document.getElementById(`testButton${i}-menuitem`);
+ Assert.ok(menuitem);
+ Assert.equal(menuitem.label, `Title ${i}`);
+ Assert.equal(
+ menuitem.style.getPropertyValue("--webextension-toolbar-image"),
+ `url("chrome://messenger/content/extension.svg")`,
+ `Menuitem should have the correct icon.`
+ );
+
+ let space = window.gSpacesToolbar.spaces.find(
+ space => space.name == `testButton${i}`
+ );
+ Assert.ok(space);
+ Assert.equal(
+ space.url,
+ `https://test.invalid/${i}`,
+ "Added url should be correct."
+ );
+ }
+
+ for (let i = 0; i < 6; i++) {
+ await window.gSpacesToolbar.updateToolbarButton(`testButton${i}`, {
+ title: `Modified Title ${i}`,
+ url: `https://test.invalid/${i + 1}`,
+ iconStyles: new Map([
+ [
+ "--webextension-toolbar-image",
+ 'url("chrome://messenger/skin/icons/new-addressbook.svg")',
+ ],
+ ]),
+ });
+ let button = document.getElementById(`testButton${i}`);
+ Assert.ok(button);
+ Assert.equal(button.title, `Modified Title ${i}`);
+
+ let img = button.querySelector("img");
+ Assert.equal(
+ img.style.getPropertyValue("--webextension-toolbar-image"),
+ `url("chrome://messenger/skin/icons/new-addressbook.svg")`,
+ `Button image should have the correct icon.`
+ );
+
+ let menuitem = document.getElementById(`testButton${i}-menuitem`);
+ Assert.ok(menuitem);
+ Assert.equal(
+ menuitem.label,
+ `Modified Title ${i}`,
+ "Updated title should be correct."
+ );
+ Assert.equal(
+ menuitem.style.getPropertyValue("--webextension-toolbar-image"),
+ `url("chrome://messenger/skin/icons/new-addressbook.svg")`,
+ `Menuitem should have the correct icon.`
+ );
+
+ let space = window.gSpacesToolbar.spaces.find(
+ space => space.name == `testButton${i}`
+ );
+ Assert.ok(space);
+ Assert.equal(
+ space.url,
+ `https://test.invalid/${i + 1}`,
+ "Updated url should be correct."
+ );
+ }
+
+ let overflowButton = document.getElementById(
+ "spacesToolbarAddonsOverflowButton"
+ );
+
+ let originalHeight = window.outerHeight;
+ // Set a ridiculous tiny height to be sure all add-on buttons are hidden.
+ window.resizeTo(window.outerWidth, 300);
+ await new Promise(resolve => requestAnimationFrame(resolve));
+ await BrowserTestUtils.waitForCondition(
+ () => !overflowButton.hidden,
+ "The overflow button is visible"
+ );
+
+ let overflowPopup = document.getElementById("spacesToolbarAddonsPopup");
+ let popupshown = BrowserTestUtils.waitForEvent(overflowPopup, "popupshown");
+ overflowButton.click();
+ await popupshown;
+
+ Assert.ok(overflowPopup.hasChildNodes());
+
+ let popuphidden = BrowserTestUtils.waitForEvent(overflowPopup, "popuphidden");
+ // Restore the original height.
+ window.resizeTo(window.outerWidth, originalHeight);
+ await new Promise(resolve => requestAnimationFrame(resolve));
+
+ await popuphidden;
+ await BrowserTestUtils.waitForCondition(
+ () => overflowButton.hidden,
+ "The overflow button is hidden"
+ );
+
+ // Remove all previously added toolbar buttons and make sure all previously
+ // generate elements are properly cleared.
+ for (let i = 0; i < 6; i++) {
+ await window.gSpacesToolbar.removeToolbarButton(`testButton${i}`);
+ let space = window.gSpacesToolbar.spaces.find(
+ space => space.name == `testButton${i}`
+ );
+ Assert.ok(!space);
+
+ let button = document.getElementById(`testButton${i}`);
+ Assert.ok(!button);
+
+ let menuitem = document.getElementById(`testButton${i}-menuitem`);
+ Assert.ok(!menuitem);
+ }
+});
+
+add_task(function testPinnedSpacesBadge() {
+ window.gSpacesToolbar.toggleToolbar(true);
+ let spacesPinnedButton = document.getElementById("spacesPinnedButton");
+ let spacesPopupButtonChat = document.getElementById("spacesPopupButtonChat");
+
+ window.gSpacesToolbar.updatePinnedBadgeState();
+
+ Assert.ok(
+ !spacesPinnedButton.classList.contains("has-badge"),
+ "Pinned button does not indicate badged items without any"
+ );
+
+ spacesPopupButtonChat.classList.add("has-badge");
+ window.gSpacesToolbar.updatePinnedBadgeState();
+
+ Assert.ok(
+ spacesPinnedButton.classList.contains("has-badge"),
+ "Pinned button indicates it has badged items"
+ );
+
+ spacesPopupButtonChat.classList.remove("has-badge");
+ window.gSpacesToolbar.updatePinnedBadgeState();
+
+ Assert.ok(
+ !spacesPinnedButton.classList.contains("has-badge"),
+ "Badge state is reset from pinned button"
+ );
+});
+
+add_task(async function testSpacesToolbarFocusRing() {
+ // Make sure the spaces toolbar is visible.
+ window.gSpacesToolbar.toggleToolbar(false);
+ // Move the focus ring on the mail toolbar button.
+ document.getElementById("mailButton").focus();
+
+ // Collect an array of all currently visible buttons.
+ let buttons = [
+ ...document.querySelectorAll(".spaces-toolbar-button:not([hidden])"),
+ ];
+
+ // Simulate the Arrow Down keypress to make sure the correct button gets the
+ // focus.
+ for (let i = 1; i < buttons.length; i++) {
+ let previousElement = document.activeElement;
+ EventUtils.synthesizeKey("KEY_ArrowDown", {}, window);
+ Assert.equal(
+ document.activeElement.id,
+ buttons[i].id,
+ "The next button is focused"
+ );
+ Assert.ok(
+ document.activeElement.tabIndex == 0 && previousElement.tabIndex == -1,
+ "The roving tab index was updated"
+ );
+ }
+
+ // Do the same with the Arrow Up key press but reversing the array.
+ buttons.reverse();
+ for (let i = 1; i < buttons.length; i++) {
+ let previousElement = document.activeElement;
+ EventUtils.synthesizeKey("KEY_ArrowUp", {}, window);
+ Assert.equal(
+ document.activeElement.id,
+ buttons[i].id,
+ "The previous button is focused"
+ );
+ Assert.ok(
+ document.activeElement.tabIndex == 0 && previousElement.tabIndex == -1,
+ "The roving tab index was updated"
+ );
+ }
+
+ // Pressing the END key should move the focus down to the last available
+ // button.
+ EventUtils.synthesizeKey("KEY_End", {}, window);
+ Assert.equal(
+ document.activeElement.id,
+ "collapseButton",
+ "The last button is focused"
+ );
+
+ // Pressing the HOME key should move the focus up to the first available
+ // button.
+ EventUtils.synthesizeKey("KEY_Home", {}, window);
+ Assert.equal(
+ document.activeElement.id,
+ "mailButton",
+ "The first button is focused"
+ );
+
+ // Focus follows the mouse click.
+ EventUtils.synthesizeMouseAtCenter(
+ document.getElementById("calendarButton"),
+ {},
+ window
+ );
+ Assert.equal(
+ document.activeElement.id,
+ "calendarButton",
+ "Focus should move to the clicked calendar button"
+ );
+
+ // Now press a key to make sure roving index was updated with the click.
+ EventUtils.synthesizeKey("KEY_ArrowDown", {}, window);
+ Assert.equal(
+ document.activeElement.id,
+ "tasksButton",
+ "Focus should move to the tasks button"
+ );
+});
diff --git a/comm/mail/base/test/browser/browser_spacesToolbarCustomize.js b/comm/mail/base/test/browser/browser_spacesToolbarCustomize.js
new file mode 100644
index 0000000000..7fc2cc3ae5
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_spacesToolbarCustomize.js
@@ -0,0 +1,119 @@
+/* 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/. */
+
+/**
+ * Test the spaces toolbar customization features.
+ */
+
+const BACKGROUND = "#f00000";
+const ICON = "#00ff0b";
+const ACCENT = "#0300ff";
+const ACCENT_ICON = "#fff600";
+
+const INPUTS = {
+ spacesBackgroundColor: BACKGROUND,
+ spacesIconsColor: ICON,
+ spacesAccentTextColor: ACCENT,
+ spacesAccentBgColor: ACCENT_ICON,
+};
+
+registerCleanupFunction(async () => {
+ // Reset all colors.
+ window.gSpacesToolbar.resetColorCustomization();
+ window.gSpacesToolbar.closeCustomize();
+});
+
+async function sub_test_open_customize_panel() {
+ // Open the panel.
+ let menu = document.getElementById("spacesToolbarContextMenu");
+ let shownPromise = BrowserTestUtils.waitForEvent(menu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ document.getElementById("spacesToolbar"),
+ { type: "contextmenu" },
+ window
+ );
+ await shownPromise;
+
+ let panel = document.getElementById("spacesToolbarCustomizationPanel");
+ let panelShownPromise = BrowserTestUtils.waitForEvent(panel, "popupshown");
+ menu.activateItem(document.getElementById("spacesToolbarContextCustomize"));
+ await panelShownPromise;
+}
+
+function sub_test_apply_colors_to_inputs() {
+ for (let key in INPUTS) {
+ let input = document.getElementById(`${key}`);
+ input.value = INPUTS[key];
+ // We need to force dispatch the onchange event otherwise the listener won't
+ // fire since we're programmatically changing the color value.
+ input.dispatchEvent(new Event("change"));
+ }
+}
+
+/**
+ * Check the current state of the custom color properties applied to the
+ * document style.
+ *
+ * @param {boolean} empty - If the style properties should be empty or filled.
+ */
+function sub_test_check_for_style_properties(empty) {
+ let style = document.documentElement.style;
+ if (empty) {
+ Assert.equal(style.getPropertyValue("--spaces-bg-color"), "");
+ Assert.equal(style.getPropertyValue("--spaces-button-text-color"), "");
+ Assert.equal(
+ style.getPropertyValue("--spaces-button-active-text-color"),
+ ""
+ );
+ Assert.equal(style.getPropertyValue("--spaces-button-active-bg-color"), "");
+ return;
+ }
+
+ Assert.equal(style.getPropertyValue("--spaces-bg-color"), BACKGROUND);
+ Assert.equal(style.getPropertyValue("--spaces-button-text-color"), ICON);
+ Assert.equal(
+ style.getPropertyValue("--spaces-button-active-text-color"),
+ ACCENT
+ );
+ Assert.equal(
+ style.getPropertyValue("--spaces-button-active-bg-color"),
+ ACCENT_ICON
+ );
+}
+
+add_task(async function testSpacesToolbarCustomizationPanel() {
+ // Make sure we're starting from a clean state.
+ window.gSpacesToolbar.resetColorCustomization();
+
+ await sub_test_open_customize_panel();
+
+ // Current colors should be clear.
+ sub_test_check_for_style_properties(true);
+
+ // Test color preview.
+ sub_test_apply_colors_to_inputs();
+ sub_test_check_for_style_properties();
+
+ // Reset should clear all applied colors.
+ window.gSpacesToolbar.resetColorCustomization();
+ window.gSpacesToolbar.closeCustomize();
+ sub_test_check_for_style_properties(true);
+
+ await sub_test_open_customize_panel();
+ // Set colors again.
+ sub_test_apply_colors_to_inputs();
+
+ // "Done" should close the panel and apply all colors.
+ window.gSpacesToolbar.closeCustomize();
+ sub_test_check_for_style_properties();
+
+ // Open the panel and click reset.
+ await sub_test_open_customize_panel();
+ window.gSpacesToolbar.resetColorCustomization();
+ sub_test_check_for_style_properties(true);
+
+ // "Done" should restore the custom colors.
+ window.gSpacesToolbar.closeCustomize();
+ sub_test_check_for_style_properties(true);
+});
diff --git a/comm/mail/base/test/browser/browser_spacesToolbar_drawBelowTitlebar.js b/comm/mail/base/test/browser/browser_spacesToolbar_drawBelowTitlebar.js
new file mode 100644
index 0000000000..f6cf64cf57
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_spacesToolbar_drawBelowTitlebar.js
@@ -0,0 +1,24 @@
+/* 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/. */
+
+// Load subtest shared with tabs-in-titlebar tests.
+Services.scriptloader.loadSubScript(
+ new URL("head_spacesToolbar.js", gTestPath).href,
+ this
+);
+
+registerCleanupFunction(async () => {
+ // Reset the menubar visibility.
+ let menubar = document.getElementById("toolbar-menubar");
+ menubar.removeAttribute("autohide");
+ menubar.removeAttribute("inactive");
+ await new Promise(resolve => requestAnimationFrame(resolve));
+});
+
+add_task(async function testSpacesToolbarAlignment() {
+ // Hide titlebar in toolbar, show menu.
+ await sub_test_toolbar_alignment(false, false);
+ // Hide titlebar in toolbar, hide menu.
+ await sub_test_toolbar_alignment(false, true);
+});
diff --git a/comm/mail/base/test/browser/browser_spacesToolbar_drawInTitlebar.js b/comm/mail/base/test/browser/browser_spacesToolbar_drawInTitlebar.js
new file mode 100644
index 0000000000..5633adad6f
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_spacesToolbar_drawInTitlebar.js
@@ -0,0 +1,24 @@
+/* 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/. */
+
+// Load subtest shared with non-tabs-in-titlebar tests.
+Services.scriptloader.loadSubScript(
+ new URL("head_spacesToolbar.js", gTestPath).href,
+ this
+);
+
+registerCleanupFunction(async () => {
+ // Reset the menubar visibility.
+ let menubar = document.getElementById("toolbar-menubar");
+ menubar.removeAttribute("autohide");
+ menubar.removeAttribute("inactive");
+ await new Promise(resolve => requestAnimationFrame(resolve));
+});
+
+add_task(async function testSpacesToolbarAlignment() {
+ // Show titlebar in toolbar, show menu.
+ await sub_test_toolbar_alignment(true, false);
+ // Show titlebar in toolbar, hide menu.
+ await sub_test_toolbar_alignment(true, true);
+});
diff --git a/comm/mail/base/test/browser/browser_statusFeedback.js b/comm/mail/base/test/browser/browser_statusFeedback.js
new file mode 100644
index 0000000000..66132aa484
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_statusFeedback.js
@@ -0,0 +1,71 @@
+/* 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 { MessageGenerator } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MessageGenerator.jsm"
+);
+
+const statusText = document.getElementById("statusText");
+const tabmail = document.getElementById("tabmail");
+const about3Pane = tabmail.currentAbout3Pane;
+const { threadTree } = about3Pane;
+
+add_setup(async function () {
+ // Create an account for the test.
+ MailServices.accounts.createLocalMailAccount();
+ const account = MailServices.accounts.accounts[0];
+ account.addIdentity(MailServices.accounts.createIdentity());
+
+ // Create a folder for the account to store test messages.
+ const rootFolder = account.incomingServer.rootFolder;
+ rootFolder.createSubfolder("statusFeedback", null);
+ const testFolder = rootFolder
+ .getChildNamed("statusFeedback")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+
+ // Generate a test message.
+ const generator = new MessageGenerator();
+ testFolder.addMessage(generator.makeMessage().toMboxString());
+
+ // Use the test folder.
+ about3Pane.displayFolder(testFolder.URI);
+ await ensure_cards_view();
+
+ // Remove test account on cleanup.
+ registerCleanupFunction(() => {
+ MailServices.accounts.removeAccount(account, false);
+ });
+});
+
+/**
+ * Tests that the correct status message appears when opening a message.
+ */
+add_task(async function testMessageOpen() {
+ const row = threadTree.getRowAtIndex(0);
+ const subjectLine = row.querySelector(
+ ".thread-card-subject-container .subject"
+ );
+
+ // Click on the email.
+ const selectPromise = BrowserTestUtils.waitForEvent(threadTree, "select");
+ EventUtils.synthesizeMouseAtCenter(
+ subjectLine,
+ { clickCount: 1 },
+ about3Pane
+ );
+ await selectPromise;
+
+ // Check the value of the status message.
+ Assert.equal(
+ statusText.value,
+ "Loading Message…",
+ "correct status message is shown"
+ );
+
+ // Check that the status message eventually reset
+ await TestUtils.waitForCondition(
+ () => statusText.value == "",
+ "status message should eventually reset"
+ );
+});
diff --git a/comm/mail/base/test/browser/browser_tabIcon.js b/comm/mail/base/test/browser/browser_tabIcon.js
new file mode 100644
index 0000000000..ae9d3a057f
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_tabIcon.js
@@ -0,0 +1,99 @@
+/* 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 { GlodaIndexer } = ChromeUtils.import(
+ "resource:///modules/gloda/GlodaIndexer.jsm"
+);
+const { MessageGenerator } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MessageGenerator.jsm"
+);
+const { PromiseTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mailnews/PromiseTestUtils.jsm"
+);
+
+const TEST_DOCUMENT_URL =
+ "http://mochi.test:8888/browser/comm/mail/base/test/browser/files/sampleContent.html";
+const TEST_IMAGE_URL =
+ "http://mochi.test:8888/browser/comm/mail/base/test/browser/files/tb-logo.png";
+
+let tabmail = document.getElementById("tabmail");
+let rootFolder, testFolder, testMessages;
+
+add_setup(async function () {
+ MailServices.accounts.createLocalMailAccount();
+ let account = MailServices.accounts.accounts[0];
+ account.addIdentity(MailServices.accounts.createIdentity());
+ rootFolder = account.incomingServer.rootFolder;
+
+ rootFolder.createSubfolder("tabIcon", null);
+ testFolder = rootFolder
+ .getChildNamed("tabIcon")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+
+ let messageFile = new FileUtils.File(
+ getTestFilePath("files/sampleContent.eml")
+ );
+ Assert.ok(messageFile.exists(), "test data file should exist");
+ let promiseCopyListener = new PromiseTestUtils.PromiseCopyListener();
+ // Copy gIncomingMailFile into the Inbox.
+ MailServices.copy.copyFileMessage(
+ messageFile,
+ testFolder,
+ null,
+ false,
+ 0,
+ "",
+ promiseCopyListener,
+ null
+ );
+ await promiseCopyListener.promise;
+ testMessages = [...testFolder.messages];
+ tabmail.currentAbout3Pane.displayFolder(testFolder);
+
+ registerCleanupFunction(() => {
+ tabmail.closeOtherTabs(0);
+ MailServices.accounts.removeAccount(account, false);
+ });
+});
+
+add_task(async function testMsgInFolder() {
+ tabmail.currentAbout3Pane.threadTree.selectedIndex = 0;
+ await BrowserTestUtils.browserLoaded(
+ tabmail.currentAboutMessage.getMessagePaneBrowser()
+ );
+ let icon = tabmail.tabInfo[0].tabNode.querySelector(".tab-icon-image");
+ await TestUtils.waitForCondition(() => icon.complete, "Icon loaded");
+ Assert.equal(
+ icon.src,
+ "chrome://messenger/skin/icons/new/compact/folder.svg"
+ );
+});
+
+add_task(async function testMsgInTab() {
+ window.OpenMessageInNewTab(testMessages[0], { background: false });
+ await BrowserTestUtils.waitForEvent(
+ tabmail.tabInfo[1].chromeBrowser,
+ "MsgLoaded"
+ );
+ let tab = tabmail.tabInfo[1];
+ let icon = tab.tabNode.querySelector(".tab-icon-image");
+ await TestUtils.waitForCondition(() => icon.complete, "Icon loaded");
+ Assert.equal(icon.src, "chrome://messenger/skin/icons/new/compact/draft.svg");
+});
+
+add_task(async function testContentTab() {
+ let tab = window.openTab("contentTab", {
+ url: TEST_DOCUMENT_URL,
+ background: false,
+ });
+ await BrowserTestUtils.browserLoaded(tab.browser);
+
+ let icon = tab.tabNode.querySelector(".tab-icon-image");
+
+ // Start of TEST_IMAGE_URL as data url.
+ await TestUtils.waitForCondition(
+ () => icon.src.startsWith("data:image/png;base64,iVBORw0KGgoAAAANSUhEU"),
+ "Waited for icon to be correct"
+ );
+});
diff --git a/comm/mail/base/test/browser/browser_tagsMode.js b/comm/mail/base/test/browser/browser_tagsMode.js
new file mode 100644
index 0000000000..6077897e48
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_tagsMode.js
@@ -0,0 +1,214 @@
+/* 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/. */
+
+var { FeedUtils } = ChromeUtils.import("resource:///modules/FeedUtils.jsm");
+var { VirtualFolderHelper } = ChromeUtils.import(
+ "resource:///modules/VirtualFolderWrapper.jsm"
+);
+
+let about3Pane = document.getElementById("tabmail").currentAbout3Pane;
+let rootFolder;
+let folders = {};
+
+const FOLDER_PREFIX = "mailbox://nobody@smart%20mailboxes/tags/";
+const DEFAULT_TAGS = new Map([
+ ["$label1", { label: "Important", color: "#FF0000" }],
+ ["$label2", { label: "Work", color: "#FF9900" }],
+ ["$label3", { label: "Personal", color: "#009900" }],
+ ["$label4", { label: "To Do", color: "#3333FF" }],
+ ["$label5", { label: "Later", color: "#993399" }],
+]);
+
+add_setup(async function () {
+ let allTags = MailServices.tags.getAllTags();
+ Assert.deepEqual(
+ allTags.map(t => t.key),
+ [...DEFAULT_TAGS.keys()],
+ "sanity check tag keys"
+ );
+ Assert.deepEqual(
+ allTags.map(t => ({ label: t.tag, color: t.color })),
+ [...DEFAULT_TAGS.values()],
+ "sanity check tag labels"
+ );
+
+ about3Pane.folderPane.activeModes = ["all"];
+ resetSmartMailboxes();
+
+ let account = MailServices.accounts.createAccount();
+ account.incomingServer = MailServices.accounts.createIncomingServer(
+ `${account.key}user`,
+ "localhost",
+ "pop3"
+ );
+ account.addIdentity(MailServices.accounts.createIdentity());
+ rootFolder = account.incomingServer.rootFolder.QueryInterface(
+ Ci.nsIMsgLocalMailFolder
+ );
+
+ for (let flag of [
+ "Inbox",
+ "Drafts",
+ "Templates",
+ "SentMail",
+ "Archive",
+ "Junk",
+ "Trash",
+ "Queue",
+ "Virtual",
+ ]) {
+ let folder = rootFolder.getFolderWithFlags(Ci.nsMsgFolderFlags[flag]);
+ if (!folder) {
+ folder = rootFolder.createLocalSubfolder(`tagsMode${flag}`);
+ folder.setFlag(Ci.nsMsgFolderFlags[flag]);
+ }
+ folders[flag] = folder.QueryInterface(Ci.nsIMsgLocalMailFolder);
+ }
+ folders.Plain = rootFolder.createLocalSubfolder("tagsModePlain");
+
+ let msgDatabase = folders.Virtual.msgDatabase;
+ let folderInfo = msgDatabase.dBFolderInfo;
+ folderInfo.setCharProperty("searchStr", "ALL");
+ folderInfo.setCharProperty("searchFolderUri", folders.Inbox.URI);
+
+ registerCleanupFunction(function () {
+ MailServices.accounts.removeAccount(account, false);
+ about3Pane.folderPane.activeModes = ["all"];
+ });
+});
+
+async function checkFolderTree(expectedTags) {
+ let tagsList = about3Pane.folderTree.querySelector(`li[data-mode="tags"] ul`);
+ await TestUtils.waitForCondition(
+ () => tagsList.childElementCount == expectedTags.size,
+ "waiting for folder tree to update"
+ );
+ let keys = expectedTags.keys();
+ let values = expectedTags.values();
+ for (let row of tagsList.children) {
+ let key = keys.next().value;
+ let { label, color } = values.next().value;
+ Assert.equal(row.uri, FOLDER_PREFIX + encodeURIComponent(key));
+ Assert.equal(row.name, label);
+ Assert.equal(row.icon.style.getPropertyValue("--icon-color"), color);
+ }
+ Assert.ok(keys.next().done, "all tags should have a row in the tree");
+}
+
+add_task(async function testFolderTree() {
+ // Check the default tags are shown initially.
+ let expectedTags = new Map(DEFAULT_TAGS);
+ about3Pane.folderPane.activeModes = ["all", "tags"];
+ await checkFolderTree(DEFAULT_TAGS);
+
+ // Add two custom tags and check they are shown.
+ MailServices.tags.addTagForKey("testkey", "testLabel", "#000000", "");
+ await TestUtils.waitForCondition(
+ () => MailServices.tags.getAllTags().length == 6,
+ "waiting for tag to be created"
+ );
+ expectedTags.set("testkey", { label: "testLabel", color: "#000000" });
+ await checkFolderTree(expectedTags);
+
+ MailServices.tags.addTagForKey("anotherkey", "anotherLabel", "#333333", "");
+ await TestUtils.waitForCondition(
+ () => MailServices.tags.getAllTags().length == 7,
+ "waiting for tag to be created"
+ );
+ expectedTags.set("anotherkey", { label: "anotherLabel", color: "#333333" });
+ await checkFolderTree(expectedTags);
+
+ // Delete the first custom tag and check it is removed.
+ MailServices.tags.deleteKey("testkey");
+ await TestUtils.waitForCondition(
+ () => MailServices.tags.getAllTags().length == 6,
+ "waiting for tag to be removed"
+ );
+ expectedTags.delete("testkey");
+ await checkFolderTree(expectedTags);
+
+ // Hide and reinitialise the Tags mode and check the list is the same.
+ about3Pane.folderPane.activeModes = ["all"];
+ about3Pane.folderPane.activeModes = ["all", "tags"];
+ await checkFolderTree(expectedTags);
+
+ // Delete the second custom tag.
+ MailServices.tags.deleteKey("anotherkey");
+ await TestUtils.waitForCondition(
+ () => MailServices.tags.getAllTags().length == 5,
+ "waiting for tag to be removed"
+ );
+ expectedTags.delete("anotherkey");
+ await checkFolderTree(expectedTags);
+});
+
+function checkVirtualFolder(tagKey, tagLabel, expectedFolderURIs) {
+ let folder = MailServices.folderLookup.getFolderForURL(
+ FOLDER_PREFIX + encodeURIComponent(tagKey)
+ );
+ Assert.ok(folder);
+ let wrappedFolder = VirtualFolderHelper.wrapVirtualFolder(folder);
+ Assert.equal(folder.prettyName, tagLabel);
+ Assert.equal(wrappedFolder.searchString, `AND (tag,contains,${tagKey})`);
+ Assert.equal(wrappedFolder.searchFolderURIs, "*");
+
+ about3Pane.displayFolder(folder);
+ Assert.deepEqual(
+ about3Pane.gViewWrapper._underlyingFolders.map(f => f.URI).sort(),
+ expectedFolderURIs.sort()
+ );
+}
+
+add_task(async function testFolderSelection() {
+ let expectedFolderURIs = [
+ folders.Inbox.URI,
+ folders.Drafts.URI,
+ folders.Templates.URI,
+ folders.SentMail.URI,
+ folders.Archive.URI,
+ folders.Plain.URI,
+ ];
+
+ for (let [key, { label }] of DEFAULT_TAGS) {
+ checkVirtualFolder(key, label, expectedFolderURIs);
+ }
+
+ // Add another plain folder. It should be added to the searched folders.
+ let newPlainFolder = rootFolder.createLocalSubfolder("tagsModePlain2");
+ expectedFolderURIs.push(newPlainFolder.URI);
+ checkVirtualFolder("$label1", "Important", expectedFolderURIs);
+
+ // Add a subfolder to the inbox. It should be added to the searched folders.
+ let newInboxFolder = folders.Inbox.createLocalSubfolder("tagsModeInbox2");
+ expectedFolderURIs.push(newInboxFolder.URI);
+ checkVirtualFolder("$label2", "Work", expectedFolderURIs);
+
+ // Add a subfolder to the trash. It should NOT be added to the searched folders.
+ folders.Trash.createLocalSubfolder("tagsModeTrash2");
+ checkVirtualFolder("$label1", "Important", expectedFolderURIs);
+
+ let rssAccount = FeedUtils.createRssAccount("rss");
+ let rssRootFolder = rssAccount.incomingServer.rootFolder;
+ FeedUtils.subscribeToFeed(
+ "https://example.org/browser/comm/mail/base/test/browser/files/rss.xml?tagsMode",
+ rssRootFolder,
+ null
+ );
+ await TestUtils.waitForCondition(() => rssRootFolder.subFolders.length == 2);
+ let rssFeedFolder = rssRootFolder.getChildNamed("Test Feed");
+
+ expectedFolderURIs.push(rssFeedFolder.URI);
+ checkVirtualFolder("$label2", "Work", expectedFolderURIs);
+
+ // Delete the smart mailboxes server and check it is correctly recreated.
+ about3Pane.folderPane.activeModes = ["all"];
+ resetSmartMailboxes();
+ about3Pane.folderPane.activeModes = ["all", "tags"];
+
+ for (let [key, { label }] of DEFAULT_TAGS) {
+ checkVirtualFolder(key, label, expectedFolderURIs);
+ }
+
+ MailServices.accounts.removeAccount(rssAccount, false);
+});
diff --git a/comm/mail/base/test/browser/browser_threadTreeDeleting.js b/comm/mail/base/test/browser/browser_threadTreeDeleting.js
new file mode 100644
index 0000000000..e034fbbfb3
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_threadTreeDeleting.js
@@ -0,0 +1,572 @@
+/* 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/. */
+
+requestLongerTimeout(2);
+
+const { MessageGenerator } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MessageGenerator.jsm"
+);
+
+let tabmail = document.getElementById("tabmail");
+let about3Pane = tabmail.currentAbout3Pane;
+let threadTree = about3Pane.threadTree;
+// Not `currentAboutMessage` as (a) that's null right now, and (b) we'll be
+// testing things that happen when about:message is hidden.
+let aboutMessage = about3Pane.messageBrowser.contentWindow;
+let messagePaneBrowser = aboutMessage.getMessagePaneBrowser();
+let multiMessageView = about3Pane.multiMessageBrowser.contentWindow;
+let generator = new MessageGenerator();
+let rootFolder, sourceMessageIDs;
+
+add_setup(async function () {
+ MailServices.accounts.createLocalMailAccount();
+ let account = MailServices.accounts.accounts[0];
+ account.addIdentity(MailServices.accounts.createIdentity());
+ rootFolder = account.incomingServer.rootFolder.QueryInterface(
+ Ci.nsIMsgLocalMailFolder
+ );
+
+ registerCleanupFunction(() => {
+ MailServices.accounts.removeAccount(account, false);
+ });
+});
+
+/** Test a real folder, unthreaded. */
+add_task(async function testUnthreaded() {
+ let folderA = rootFolder
+ .createLocalSubfolder("threadTreeDeletingA")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+ folderA.addMessageBatch(
+ generator.makeMessages({ count: 15 }).map(message => message.toMboxString())
+ );
+
+ sourceMessageIDs = Array.from(folderA.messages, m => m.messageId);
+
+ about3Pane.restoreState({
+ messagePaneVisible: true,
+ folderURI: folderA.URI,
+ });
+ await ensure_cards_view();
+ goDoCommand("cmd_sort", { target: { value: "unthreaded" } });
+
+ await subtest();
+});
+
+/** Test a real folder with threads. */
+add_task(async function testThreaded() {
+ let folderB = rootFolder
+ .createLocalSubfolder("threadTreeDeletingB")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+ folderB.addMessageBatch(
+ [
+ // No real reason for the values here other than the total count.
+ ...generator.makeMessages({ count: 4 }),
+ ...generator.makeMessages({ count: 6, msgsPerThread: 3 }),
+ ...generator.makeMessages({ count: 1 }),
+ ...generator.makeMessages({ count: 2, msgsPerThread: 2 }),
+ ...generator.makeMessages({ count: 2 }),
+ ].map(message => message.toMboxString())
+ );
+
+ sourceMessageIDs = Array.from(folderB.messages, m => m.messageId);
+
+ about3Pane.restoreState({
+ messagePaneVisible: true,
+ folderURI: folderB.URI,
+ });
+ goDoCommand("cmd_sort", { target: { value: "threaded" } });
+ goDoCommand("cmd_expandAllThreads");
+
+ await subtest();
+});
+
+/** Test a virtual folder with a single backing folder. */
+add_task(async function testSingleVirtual() {
+ let folderC = rootFolder
+ .createLocalSubfolder("threadTreeDeletingC")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+ folderC.addMessageBatch(
+ generator.makeMessages({ count: 15 }).map(message => message.toMboxString())
+ );
+
+ let virtualFolderC = rootFolder.createLocalSubfolder(
+ "threadTreeDeletingVirtualC"
+ );
+ virtualFolderC.setFlag(Ci.nsMsgFolderFlags.Virtual);
+ let folderInfoC = virtualFolderC.msgDatabase.dBFolderInfo;
+ // Search for something instead of all messages, as the "ALL" search could
+ // detected and the backing folder displayed instead, defeating the point of
+ // this test.
+ folderInfoC.setCharProperty("searchStr", "AND (date,is after,31-Dec-1999)");
+ folderInfoC.setCharProperty("searchFolderUri", folderC.URI);
+
+ sourceMessageIDs = Array.from(folderC.messages, m => m.messageId);
+
+ about3Pane.restoreState({
+ messagePaneVisible: true,
+ folderURI: virtualFolderC.URI,
+ });
+
+ await subtest();
+});
+
+/** Test a virtual folder with multiple backing folders. */
+add_task(async function testXFVirtual() {
+ let folderD = rootFolder
+ .createLocalSubfolder("threadTreeDeletingD")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+ folderD.addMessageBatch(
+ generator.makeMessages({ count: 4 }).map(message => message.toMboxString())
+ );
+
+ let folderE = rootFolder
+ .createLocalSubfolder("threadTreeDeletingE")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+ folderE.addMessageBatch(
+ generator
+ .makeMessages({ count: 11, msgsPerThread: 3 })
+ .map(message => message.toMboxString())
+ );
+
+ let virtualFolderDE = rootFolder.createLocalSubfolder(
+ "threadTreeDeletingVirtualDE"
+ );
+ virtualFolderDE.setFlag(Ci.nsMsgFolderFlags.Virtual);
+ let folderInfoY = virtualFolderDE.msgDatabase.dBFolderInfo;
+ folderInfoY.setCharProperty("searchStr", "AND (date,is after,31-Dec-1999)");
+ folderInfoY.setCharProperty(
+ "searchFolderUri",
+ `${folderD.URI}|${folderE.URI}`
+ );
+
+ sourceMessageIDs = [
+ ...Array.from(folderD.messages, m => m.messageId),
+ ...Array.from(folderE.messages, m => m.messageId),
+ ];
+
+ about3Pane.restoreState({
+ messagePaneVisible: true,
+ folderURI: virtualFolderDE.URI,
+ });
+ goDoCommand("cmd_sort", { target: { value: "threaded" } });
+ goDoCommand("cmd_expandAllThreads");
+
+ await subtest();
+});
+
+/** Test a real folder with a quick filter applied. */
+add_task(async function testQuickFiltered() {
+ let folderF = rootFolder
+ .createLocalSubfolder("threadTreeDeletingF")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+ folderF.addMessageBatch(
+ generator.makeMessages({ count: 30 }).map(message => message.toMboxString())
+ );
+ let flaggedMessages = [];
+ let i = 0;
+ for (let message of folderF.messages) {
+ if (i++ % 2) {
+ flaggedMessages.push(message);
+ }
+ }
+ folderF.markMessagesFlagged(flaggedMessages, true);
+
+ sourceMessageIDs = flaggedMessages.map(m => m.messageId);
+ about3Pane.restoreState({
+ messagePaneVisible: true,
+ folderURI: folderF.URI,
+ });
+ let filterer = about3Pane.quickFilterBar.filterer;
+ filterer.clear();
+ filterer.visible = true;
+ filterer.setFilterValue("starred", true);
+ about3Pane.quickFilterBar.updateSearch();
+
+ await subtest();
+});
+
+/** Test a folder sorted by date descending. */
+add_task(async function testSortDescending() {
+ let folderG = rootFolder
+ .createLocalSubfolder("threadTreeDeletingG")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+ folderG.addMessageBatch(
+ generator.makeMessages({ count: 15 }).map(message => message.toMboxString())
+ );
+
+ sourceMessageIDs = Array.from(folderG.messages, m => m.messageId).reverse();
+
+ about3Pane.restoreState({
+ messagePaneVisible: true,
+ folderURI: folderG.URI,
+ });
+ goDoCommand("cmd_sort", { target: { value: "descending" } });
+
+ await subtest();
+});
+
+/** Test a folder sorted by subject. */
+add_task(async function testSortBySubject() {
+ let folderH = rootFolder
+ .createLocalSubfolder("threadTreeDeletingH")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+ folderH.addMessageBatch(
+ generator.makeMessages({ count: 15 }).map(message => message.toMboxString())
+ );
+
+ sourceMessageIDs = Array.from(folderH.messages)
+ .sort((m1, m2) => (m1.subject < m2.subject ? -1 : 1))
+ .map(m => m.messageId);
+
+ about3Pane.restoreState({
+ messagePaneVisible: true,
+ folderURI: folderH.URI,
+ });
+ goDoCommand("cmd_sort", { target: { value: "bySubject" } });
+
+ await subtest();
+});
+
+/**
+ * Tests that deleting the selected row while smooth-scrolling does not break
+ * the scrolling and leave the tree in a bad scroll position.
+ */
+add_task(async function testDeletionWhileScrolling() {
+ let folderI = rootFolder
+ .createLocalSubfolder("threadTreeDeletingI")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+ folderI.addMessageBatch(
+ generator
+ .makeMessages({ count: 500 })
+ .map(message => message.toMboxString())
+ );
+
+ await ensure_table_view();
+ about3Pane.restoreState({
+ messagePaneVisible: false,
+ folderURI: folderI.URI,
+ });
+
+ const scrollListener = {
+ async promiseScrollingStopped() {
+ this.lastTime = Date.now();
+ await TestUtils.waitForCondition(
+ () => Date.now() - this.lastTime > 1000,
+ "waiting for scrolling to stop"
+ );
+ delete this.direction;
+ delete this.lastPosition;
+ },
+ setScrollExpectation(direction) {
+ this.direction = direction;
+ this.lastPosition = threadTree.scrollTop;
+ },
+ setNoScrollExpectation() {
+ this.direction = 0;
+ },
+ handleEvent(event) {
+ if (this.direction === 0) {
+ Assert.report(true, undefined, undefined, "unexpected scroll event");
+ return;
+ }
+
+ const position = threadTree.scrollTop;
+ if (this.direction == -1) {
+ Assert.lessOrEqual(position, this.lastPosition);
+ } else if (this.direction == 1) {
+ Assert.greaterOrEqual(position, this.lastPosition);
+ }
+ this.lastPosition = position;
+ this.lastTime = Date.now();
+ },
+ };
+
+ async function delayThenPress(millis, key) {
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, millis));
+ if (key) {
+ EventUtils.synthesizeKey(key, {}, about3Pane);
+ await TestUtils.waitForTick();
+ }
+ }
+
+ threadTree.addEventListener("scroll", scrollListener);
+ threadTree.table.body.focus();
+ threadTree.selectedIndex = 299;
+ await scrollListener.promiseScrollingStopped();
+
+ let stopPromise = scrollListener.promiseScrollingStopped();
+ scrollListener.setScrollExpectation(-1);
+
+ // Page up a few times then delete some messages.
+
+ await delayThenPress(0, "VK_PAGE_UP");
+ await delayThenPress(60, "VK_PAGE_UP");
+ await delayThenPress(60, "VK_PAGE_UP");
+ await delayThenPress(400, "VK_DELETE");
+ await delayThenPress(80, "VK_DELETE");
+
+ await stopPromise;
+ Assert.equal(
+ threadTree.getFirstVisibleIndex(),
+ threadTree.selectedIndex,
+ "selected row should be the first visible row"
+ );
+
+ // Page down a few times then delete some messages.
+
+ stopPromise = scrollListener.promiseScrollingStopped();
+ scrollListener.setScrollExpectation(1);
+
+ await delayThenPress(60, "VK_PAGE_DOWN");
+ await delayThenPress(60, "VK_PAGE_DOWN");
+ await delayThenPress(60, "VK_PAGE_DOWN");
+ await delayThenPress(300, "VK_DELETE");
+ await delayThenPress(80, "VK_DELETE");
+ await delayThenPress(80, "VK_DELETE");
+ await delayThenPress(80, "VK_DELETE");
+ await delayThenPress(80, "VK_DELETE");
+
+ await stopPromise;
+ Assert.equal(
+ threadTree.getLastVisibleIndex(),
+ threadTree.selectedIndex,
+ "selected row should be the last visible row"
+ );
+
+ // Select a message somewhere in the middle then delete it.
+
+ scrollListener.setNoScrollExpectation();
+ threadTree.selectedIndex -= 10;
+ await delayThenPress(80, "VK_DELETE");
+ await delayThenPress(80, "VK_DELETE");
+ await delayThenPress(80, "VK_DELETE");
+
+ await delayThenPress(1000);
+ Assert.less(
+ threadTree.getFirstVisibleIndex(),
+ threadTree.selectedIndex,
+ "selected row should be below the first visible row"
+ );
+ Assert.greater(
+ threadTree.getLastVisibleIndex(),
+ threadTree.selectedIndex,
+ "selected row should be above the last visible row"
+ );
+
+ threadTree.removeEventListener("scroll", scrollListener);
+});
+
+async function subtest() {
+ await TestUtils.waitForCondition(
+ () => threadTree.table.body.rows.length == 15,
+ "waiting for all of the table rows"
+ );
+
+ let dbView = about3Pane.gDBView;
+ let subjects = [];
+ for (let i = 0; i < 15; i++) {
+ subjects.push(dbView.cellTextForColumn(i, "subjectCol"));
+ }
+ verifySubjects(subjects);
+
+ threadTree.table.body.focus();
+ threadTree.selectedIndex = 3;
+ await messageLoaded(3);
+
+ // Delete a single message.
+ await doDeleteCommand(4);
+ await verifySelection(14, [3], 3);
+ verifySubjects([subjects[0], subjects[1], subjects[2], ...subjects.slice(4)]);
+
+ // Delete a single message.
+ await doDeleteCommand(5);
+ await verifySelection(13, [3], 3);
+ verifySubjects([subjects[0], subjects[1], subjects[2], ...subjects.slice(5)]);
+
+ // Delete a single message by clicking the about:message Delete button.
+ await doDeleteClick(6);
+ await verifySelection(12, [3], 3);
+ verifySubjects([subjects[0], subjects[1], subjects[2], ...subjects.slice(6)]);
+
+ // Delete adjacent messages.
+ threadTree.selectedIndices = [3, 4, 5];
+ threadTree.currentIndex = 6;
+ await doDeleteCommand(9);
+ await verifySelection(9, [3], 3);
+ verifySubjects([subjects[0], subjects[1], subjects[2], ...subjects.slice(9)]);
+
+ // Delete non-adjacent messages.
+ threadTree.selectedIndices = [2, 4];
+ threadTree.currentIndex = 4;
+ // We should select the message below the current index, but we select the
+ // message below the first selected one.
+ await doDeleteCommand(9);
+ await verifySelection(7, [2], 2);
+ verifySubjects([
+ subjects[0],
+ subjects[1],
+ subjects[9],
+ ...subjects.slice(11),
+ ]);
+
+ // Delete the last message.
+ threadTree.selectedIndex = 6;
+ await messageLoaded(14);
+ await doDeleteCommand(13);
+ await verifySelection(6, [5], 5);
+ verifySubjects([
+ subjects[0],
+ subjects[1],
+ subjects[9],
+ ...subjects.slice(11, 14),
+ ]);
+
+ // Now cause a delete to happen from outside the UI.
+ await doDeleteExternal(1);
+ await verifySelection(5, [4], 4);
+ verifySubjects([subjects[0], subjects[9], ...subjects.slice(11, 14)]);
+
+ // Delete the selected message from outside the UI.
+ threadTree.selectedIndex = 2;
+ await messageLoaded(11);
+ await doDeleteExternal(2, 12);
+ await verifySelection(4, [2], 2);
+ verifySubjects([subjects[0], subjects[9], ...subjects.slice(12, 14)]);
+}
+
+async function messageLoaded(index) {
+ await BrowserTestUtils.browserLoaded(messagePaneBrowser);
+ Assert.equal(
+ aboutMessage.gMessage.messageId,
+ sourceMessageIDs[index],
+ "correct message loaded"
+ );
+}
+
+async function _doDelete(callback, index, expectedLoad) {
+ let selectCount = 0;
+ let onSelect = () => selectCount++;
+ threadTree.addEventListener("select", onSelect);
+
+ let selectPromise;
+ if (expectedLoad !== undefined) {
+ selectPromise = BrowserTestUtils.waitForEvent(threadTree, "select");
+ }
+ await callback();
+ if (selectPromise) {
+ await selectPromise;
+ await messageLoaded(expectedLoad);
+ }
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 25));
+ if (selectPromise) {
+ Assert.equal(selectCount, 1, "'select' event should've happened only once");
+ } else {
+ Assert.equal(selectCount, 0, "'select' event should not have happened");
+ }
+
+ threadTree.removeEventListener("select", onSelect);
+}
+
+async function doDeleteCommand(expectedLoad) {
+ await _doDelete(
+ function () {
+ goDoCommand("cmd_delete");
+ },
+ undefined,
+ expectedLoad
+ );
+}
+
+async function doDeleteClick(expectedLoad) {
+ await _doDelete(
+ function () {
+ let messageView =
+ threadTree.selectedIndices.length == 1
+ ? aboutMessage
+ : multiMessageView;
+ EventUtils.synthesizeMouseAtCenter(
+ messageView.document.getElementById("hdrTrashButton"),
+ {},
+ messageView
+ );
+ },
+ undefined,
+ expectedLoad
+ );
+}
+
+async function doDeleteExternal(index, expectedLoad) {
+ await _doDelete(
+ function () {
+ let message = about3Pane.gDBView.getMsgHdrAt(index);
+ message.folder.deleteMessages(
+ [message], // messages
+ null, // msgWindow
+ true, // deleteStorage
+ false, // isMove
+ null, // listener
+ false // canUndo
+ );
+ },
+ index,
+ expectedLoad
+ );
+}
+
+async function verifySelection(rowCount, selectedIndices, currentIndex) {
+ Assert.equal(threadTree.view.rowCount, rowCount, "row count of view");
+ await TestUtils.waitForCondition(
+ () => threadTree.table.body.rows.length == rowCount,
+ "waiting table row count to match the view's row count"
+ );
+
+ Assert.deepEqual(
+ threadTree.selectedIndices,
+ selectedIndices,
+ "table's selected indices"
+ );
+ let selectedRows = Array.from(threadTree.querySelectorAll(".selected"));
+ Assert.equal(
+ selectedRows.length,
+ selectedIndices.length,
+ "number of rows with .selected class"
+ );
+ for (let index of selectedIndices) {
+ let row = threadTree.getRowAtIndex(index);
+ Assert.ok(
+ selectedRows.includes(row),
+ `.selected row at ${index} is expected`
+ );
+ }
+
+ Assert.equal(threadTree.currentIndex, currentIndex, "table's current index");
+ let currentRows = threadTree.querySelectorAll(".current");
+ Assert.equal(currentRows.length, 1, "one row should have .current");
+ Assert.equal(
+ currentRows[0],
+ threadTree.getRowAtIndex(currentIndex),
+ `.current row at ${currentIndex} is expected`
+ );
+
+ let contextTargetRows = threadTree.querySelectorAll(".context-menu-target");
+ Assert.equal(
+ contextTargetRows.length,
+ 0,
+ "no rows should have .context-menu-target"
+ );
+}
+
+function verifySubjects(expectedSubjects) {
+ let actualSubjects = Array.from(
+ threadTree.table.body.rows,
+ row =>
+ row.querySelector(".thread-card-subject-container > .subject").textContent
+ );
+ Assert.equal(actualSubjects.length, expectedSubjects.length, "row count");
+ for (let i = 0; i < expectedSubjects.length; i++) {
+ Assert.equal(actualSubjects[i], expectedSubjects[i], `subject at ${i}`);
+ }
+}
diff --git a/comm/mail/base/test/browser/browser_threadTreeQuirks.js b/comm/mail/base/test/browser/browser_threadTreeQuirks.js
new file mode 100644
index 0000000000..f0f0012d57
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_threadTreeQuirks.js
@@ -0,0 +1,669 @@
+/* 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 { MessageGenerator } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MessageGenerator.jsm"
+);
+const { PromiseTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mailnews/PromiseTestUtils.jsm"
+);
+
+let tabmail = document.getElementById("tabmail");
+let about3Pane = tabmail.currentAbout3Pane;
+let threadTree = about3Pane.threadTree;
+// Not `currentAboutMessage` as (a) that's null right now, and (b) we'll be
+// testing things that happen when about:message is hidden.
+let aboutMessage = about3Pane.messageBrowser.contentWindow;
+let messagePaneBrowser = aboutMessage.getMessagePaneBrowser();
+let rootFolder, folderA, folderB, trashFolder, sourceMessages, sourceMessageIDs;
+
+add_setup(async function () {
+ let generator = new MessageGenerator();
+
+ MailServices.accounts.createLocalMailAccount();
+ let account = MailServices.accounts.accounts[0];
+ account.addIdentity(MailServices.accounts.createIdentity());
+ rootFolder = account.incomingServer.rootFolder;
+
+ rootFolder.createSubfolder("threadTreeQuirksA", null);
+ folderA = rootFolder
+ .getChildNamed("threadTreeQuirksA")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+
+ rootFolder.createSubfolder("threadTreeQuirksB", null);
+ folderB = rootFolder.getChildNamed("threadTreeQuirksB");
+ trashFolder = rootFolder.getFolderWithFlags(Ci.nsMsgFolderFlags.Trash);
+
+ // Make some messages, then change their dates to simulate a different order.
+ let syntheticMessages = generator.makeMessages({
+ count: 15,
+ msgsPerThread: 5,
+ });
+ syntheticMessages[1].date = generator.makeDate();
+ syntheticMessages[2].date = generator.makeDate();
+ syntheticMessages[3].date = generator.makeDate();
+ syntheticMessages[4].date = generator.makeDate();
+
+ folderA.addMessageBatch(
+ syntheticMessages.map(message => message.toMboxString())
+ );
+ sourceMessages = [...folderA.messages];
+ sourceMessageIDs = sourceMessages.map(m => m.messageId);
+
+ registerCleanupFunction(() => {
+ MailServices.accounts.removeAccount(account, false);
+ });
+});
+
+add_task(async function testExpandCollapseUpdates() {
+ about3Pane.restoreState({
+ messagePaneVisible: true,
+ folderURI: folderA.URI,
+ });
+
+ // Clicking the twisty to collapse a row should update the message display.
+ goDoCommand("cmd_expandAllThreads");
+ threadTree.selectedIndex = 5;
+ await messageLoaded(10);
+
+ let selectPromise = BrowserTestUtils.waitForEvent(threadTree, "select");
+ EventUtils.synthesizeMouseAtCenter(
+ threadTree.getRowAtIndex(5).querySelector(".twisty"),
+ {},
+ about3Pane
+ );
+ await selectPromise;
+ // Thread root still selected.
+ await validateTree(11, [5], 5);
+ Assert.ok(
+ BrowserTestUtils.is_hidden(about3Pane.messageBrowser),
+ "messageBrowser became hidden"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_visible(about3Pane.multiMessageBrowser),
+ "multiMessageBrowser became visible"
+ );
+
+ // Clicking the twisty to expand a row should update the message display.
+ selectPromise = BrowserTestUtils.waitForEvent(threadTree, "select");
+ EventUtils.synthesizeMouseAtCenter(
+ threadTree.getRowAtIndex(5).querySelector(".twisty"),
+ {},
+ about3Pane
+ );
+ await selectPromise;
+ await messageLoaded(10);
+ await validateTree(15, [5], 5);
+ Assert.ok(
+ BrowserTestUtils.is_hidden(about3Pane.multiMessageBrowser),
+ "multiMessageBrowser became hidden"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_visible(about3Pane.messageBrowser),
+ "messageBrowser became visible"
+ );
+
+ // Collapsing all rows while the first message in a thread is selected should
+ // update the message display.
+ selectPromise = BrowserTestUtils.waitForEvent(threadTree, "select");
+ goDoCommand("cmd_collapseAllThreads");
+ await selectPromise;
+ // Thread root still selected.
+ await validateTree(3, [1], 1);
+ Assert.ok(
+ BrowserTestUtils.is_hidden(about3Pane.messageBrowser),
+ "messageBrowser became hidden"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_visible(about3Pane.multiMessageBrowser),
+ "multiMessageBrowser became visible"
+ );
+
+ // Expanding all rows while the first message in a thread is selected should
+ // update the message display.
+ selectPromise = BrowserTestUtils.waitForEvent(threadTree, "select");
+ goDoCommand("cmd_expandAllThreads");
+ await selectPromise;
+ await messageLoaded(10);
+ await validateTree(15, [5], 5);
+ Assert.ok(
+ BrowserTestUtils.is_hidden(about3Pane.multiMessageBrowser),
+ "multiMessageBrowser became hidden"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_visible(about3Pane.messageBrowser),
+ "messageBrowser became visible"
+ );
+
+ // Collapsing all rows while a message inside a thread is selected should
+ // select the first message in the thread and update the message display.
+ threadTree.selectedIndex = 2;
+ await messageLoaded(7);
+
+ selectPromise = BrowserTestUtils.waitForEvent(threadTree, "select");
+ goDoCommand("cmd_collapseAllThreads");
+ await selectPromise;
+ // Thread root became selected.
+ await validateTree(3, [0], 0);
+ Assert.ok(
+ BrowserTestUtils.is_hidden(about3Pane.messageBrowser),
+ "messageBrowser became hidden"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_visible(about3Pane.multiMessageBrowser),
+ "multiMessageBrowser became visible"
+ );
+
+ // Expanding all rows while the first message in a thread is selected should
+ // update the message display. (This is effectively the same test as earlier.)
+ selectPromise = BrowserTestUtils.waitForEvent(threadTree, "select");
+ goDoCommand("cmd_expandAllThreads");
+ await selectPromise;
+ await messageLoaded(5);
+ await validateTree(15, [0], 0);
+ Assert.ok(
+ BrowserTestUtils.is_hidden(about3Pane.multiMessageBrowser),
+ "multiMessageBrowser became hidden"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_visible(about3Pane.messageBrowser),
+ "messageBrowser became visible"
+ );
+
+ // Select several things and collapse all.
+ selectPromise = BrowserTestUtils.waitForEvent(threadTree, "select");
+ threadTree.selectedIndices = [2, 3, 5];
+ await selectPromise;
+ Assert.ok(
+ BrowserTestUtils.is_hidden(about3Pane.messageBrowser),
+ "messageBrowser became hidden"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_visible(about3Pane.multiMessageBrowser),
+ "multiMessageBrowser became visible"
+ );
+
+ selectPromise = BrowserTestUtils.waitForEvent(threadTree, "select");
+ goDoCommand("cmd_collapseAllThreads");
+ await selectPromise;
+ // Thread roots became selected.
+ await validateTree(3, [0, 1], 1);
+ Assert.ok(
+ BrowserTestUtils.is_hidden(about3Pane.messageBrowser),
+ "messageBrowser stayed hidden"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_visible(about3Pane.multiMessageBrowser),
+ "multiMessageBrowser stayed visible"
+ );
+});
+
+add_task(async function testThreadUpdateKeepsSelection() {
+ about3Pane.restoreState({
+ messagePaneVisible: true,
+ folderURI: folderB.URI,
+ });
+
+ // Put some messages from different threads in the folder and select one.
+ await move([sourceMessages[0]], folderA, folderB);
+ await move([sourceMessages[5]], folderA, folderB);
+ threadTree.selectedIndex = 1;
+ await messageLoaded(5);
+
+ // Move a "newer" message into the folder. This should switch the order of
+ // the threads, but no selection change should occur.
+ threadTree.addEventListener("select", reportBadSelectEvent);
+ messagePaneBrowser.addEventListener("load", reportBadLoad, true);
+ await move([sourceMessages[1]], folderA, folderB);
+ // Selection should have moved.
+ await validateTree(2, [0], 0);
+ Assert.equal(
+ aboutMessage.gMessage.messageId,
+ sourceMessageIDs[5],
+ "correct message still loaded"
+ );
+
+ // Wait to prove unwanted selection or load didn't happen.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 500));
+
+ threadTree.removeEventListener("select", reportBadSelectEvent);
+ messagePaneBrowser.removeEventListener("load", reportBadLoad, true);
+
+ await restoreMessages();
+});
+
+add_task(async function testArchiveDeleteUpdates() {
+ about3Pane.restoreState({
+ messagePaneVisible: true,
+ folderURI: folderA.URI,
+ });
+ about3Pane.sortController.sortUnthreaded();
+
+ threadTree.table.body.focus();
+ threadTree.selectedIndex = 3;
+ await messageLoaded(7);
+
+ let selectCount = 0;
+ let onSelect = () => selectCount++;
+ threadTree.addEventListener("select", onSelect);
+
+ let selectPromise = BrowserTestUtils.waitForEvent(threadTree, "select");
+ goDoCommand("cmd_delete");
+ await selectPromise;
+ await messageLoaded(8);
+ await validateTree(14, [3], 3);
+ Assert.equal(selectCount, 1, "'select' event should've happened only once");
+
+ selectPromise = BrowserTestUtils.waitForEvent(threadTree, "select");
+ goDoCommand("cmd_delete");
+ await selectPromise;
+ await messageLoaded(9);
+ await validateTree(13, [3], 3);
+ Assert.equal(selectCount, 2, "'select' event should've happened only once");
+
+ selectPromise = BrowserTestUtils.waitForEvent(threadTree, "select");
+ goDoCommand("cmd_archive");
+ await selectPromise;
+ await messageLoaded(10);
+ await validateTree(12, [3], 3);
+ Assert.equal(selectCount, 3, "'select' event should've happened only once");
+
+ selectPromise = BrowserTestUtils.waitForEvent(threadTree, "select");
+ goDoCommand("cmd_archive");
+ await selectPromise;
+ await messageLoaded(11);
+ await validateTree(11, [3], 3);
+ Assert.equal(selectCount, 4, "'select' event should've happened only once");
+
+ selectPromise = BrowserTestUtils.waitForEvent(threadTree, "select");
+ goDoCommand("cmd_delete");
+ await selectPromise;
+ await messageLoaded(12);
+ await validateTree(10, [3], 3);
+ Assert.equal(selectCount, 5, "'select' event should've happened only once");
+
+ threadTree.removeEventListener("select", onSelect);
+
+ await restoreMessages();
+});
+
+add_task(async function testMessagePaneSelection() {
+ await move(sourceMessages.slice(6, 9), folderA, folderB);
+ about3Pane.restoreState({
+ messagePaneVisible: true,
+ folderURI: folderB.URI,
+ });
+ about3Pane.sortController.sortUnthreaded();
+ about3Pane.sortController.sortThreadPane("byDate");
+ about3Pane.sortController.sortDescending();
+
+ threadTree.table.body.focus();
+ threadTree.selectedIndex = 1;
+ await messageLoaded(7);
+ await validateTree(3, [1], 1);
+
+ // Check the initial selection in about:message.
+ Assert.equal(aboutMessage.gDBView.selection.getRangeCount(), 1);
+ let min = {},
+ max = {};
+ aboutMessage.gDBView.selection.getRangeAt(0, min, max);
+ Assert.equal(min.value, 1);
+ Assert.equal(max.value, 1);
+
+ // Add a new message to the folder, which should appear first.
+ threadTree.addEventListener("select", reportBadSelectEvent);
+ messagePaneBrowser.addEventListener("load", reportBadLoad, true);
+ await move(sourceMessages.slice(9, 10), folderA, folderB);
+ await validateTree(4, [2], 2);
+
+ Assert.deepEqual(
+ Array.from(folderB.messages, m => m.messageId),
+ sourceMessageIDs.slice(6, 10),
+ "all expected messages are in the folder"
+ );
+
+ // Check the selection in about:message.
+ Assert.equal(aboutMessage.gDBView.selection.getRangeCount(), 1);
+ aboutMessage.gDBView.selection.getRangeAt(0, min, max);
+ Assert.equal(min.value, 2);
+ Assert.equal(max.value, 2);
+
+ // Wait to prove unwanted selection or load didn't happen.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 500));
+ threadTree.removeEventListener("select", reportBadSelectEvent);
+ messagePaneBrowser.removeEventListener("load", reportBadLoad, true);
+
+ // Now click the delete button in about:message.
+ let deletePromise = PromiseTestUtils.promiseFolderEvent(
+ folderB,
+ "DeleteOrMoveMsgCompleted"
+ );
+ let loadPromise = messageLoaded(6);
+ EventUtils.synthesizeMouseAtCenter(
+ aboutMessage.document.getElementById("hdrTrashButton"),
+ {},
+ aboutMessage
+ );
+ await Promise.all([deletePromise, loadPromise]);
+
+ // Check which message was deleted.
+ Assert.deepEqual(
+ Array.from(trashFolder.messages, m => m.messageId),
+ [sourceMessageIDs[7]],
+ "the right message was deleted"
+ );
+ Assert.deepEqual(
+ Array.from(folderB.messages, m => m.messageId),
+ [sourceMessageIDs[6], sourceMessageIDs[8], sourceMessageIDs[9]],
+ "the right messages were kept"
+ );
+
+ await validateTree(3, [2], 2);
+
+ // Check the selection in about:message again.
+ Assert.equal(aboutMessage.gDBView.selection.getRangeCount(), 1);
+ aboutMessage.gDBView.selection.getRangeAt(0, min, max);
+ Assert.equal(min.value, 2);
+ Assert.equal(max.value, 2);
+
+ await restoreMessages();
+});
+
+add_task(async function testNonSelectionContextMenu() {
+ let mailContext = about3Pane.document.getElementById("mailContext");
+ let openNewTabItem = about3Pane.document.getElementById(
+ "mailContext-openNewTab"
+ );
+ let replyItem = about3Pane.document.getElementById("mailContext-replySender");
+
+ about3Pane.restoreState({
+ messagePaneVisible: true,
+ folderURI: folderA.URI,
+ });
+ about3Pane.sortController.sortUnthreaded();
+ threadTree.scrollToIndex(0, true);
+
+ threadTree.selectedIndex = 0;
+ await messageLoaded(0);
+ await validateTree(15, [0], 0);
+ await subtestOpenTab(1, sourceMessageIDs[5]);
+ await subtestReply(6, sourceMessageIDs[10]);
+
+ threadTree.selectedIndices = [3, 6, 9];
+ await BrowserTestUtils.browserLoaded(
+ messagePaneBrowser,
+ false,
+ "about:blank"
+ );
+ await subtestOpenTab(0, sourceMessageIDs[0]);
+
+ async function doContextMenu(testIndex, messageId, itemToActivate) {
+ let originalSelection = threadTree.selectedIndices;
+
+ threadTree.addEventListener("select", reportBadSelectEvent);
+ messagePaneBrowser.addEventListener("load", reportBadLoad, true);
+
+ let shownPromise = BrowserTestUtils.waitForEvent(mailContext, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ threadTree
+ .getRowAtIndex(testIndex)
+ .querySelector(".thread-card-subject-container"),
+ { type: "contextmenu" },
+ about3Pane
+ );
+ await shownPromise;
+
+ Assert.ok(about3Pane.mailContextMenu.selectionIsOverridden);
+ Assert.deepEqual(
+ threadTree.selectedIndices,
+ [testIndex],
+ "selection should be only the right-clicked-on row"
+ );
+ let contextTargetRows = threadTree.querySelectorAll(".context-menu-target");
+ Assert.equal(
+ contextTargetRows.length,
+ 1,
+ "one row should have .context-menu-target"
+ );
+ Assert.equal(
+ contextTargetRows[0].index,
+ testIndex,
+ "correct row has .context-menu-target"
+ );
+
+ let hiddenPromise = BrowserTestUtils.waitForEvent(
+ mailContext,
+ "popuphidden"
+ );
+ mailContext.activateItem(itemToActivate);
+ await hiddenPromise;
+
+ Assert.ok(!about3Pane.mailContextMenu.selectionIsOverridden);
+ Assert.equal(
+ document.activeElement,
+ tabmail.tabInfo[0].chromeBrowser,
+ "about:3pane should have focus after context menu"
+ );
+ Assert.equal(
+ about3Pane.document.activeElement,
+ threadTree.table.body,
+ "table body should have focus after context menu"
+ );
+
+ // Selection should be restored.
+ await validateTree(
+ 15,
+ threadTree.selectedIndices,
+ originalSelection.at(-1)
+ );
+
+ // Wait to prove unwanted selection or load didn't happen.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 500));
+
+ threadTree.removeEventListener("select", reportBadSelectEvent);
+ messagePaneBrowser.removeEventListener("load", reportBadLoad, true);
+ }
+
+ // Opening a new tab should open the clicked-on message, not the selected.
+ async function subtestOpenTab(testIndex, messageId) {
+ let newAboutMessagePromise = BrowserTestUtils.waitForEvent(
+ tabmail,
+ "aboutMessageLoaded"
+ ).then(async function (event) {
+ await BrowserTestUtils.browserLoaded(
+ event.target.getMessagePaneBrowser()
+ );
+ return event.target;
+ });
+ await doContextMenu(testIndex, messageId, openNewTabItem);
+
+ let newAboutMessage = await newAboutMessagePromise;
+ Assert.equal(
+ newAboutMessage.gMessage.messageId,
+ messageId,
+ "correct message should have opened in a tab"
+ );
+ Assert.equal(
+ tabmail.tabInfo.length,
+ 2,
+ "only one new tab should have opened"
+ );
+ tabmail.closeOtherTabs(0);
+ }
+
+ // Replying should quote the clicked-on message, not the selected, even when
+ // some text is selected in the message pane.
+ async function subtestReply(testIndex, messageId) {
+ Assert.stringContains(
+ messagePaneBrowser.contentDocument.body.textContent,
+ "Hello Bob Bell!"
+ );
+ messagePaneBrowser.contentWindow
+ .getSelection()
+ .selectAllChildren(messagePaneBrowser.contentDocument.body);
+
+ let composeWindowPromise = BrowserTestUtils.domWindowOpenedAndLoaded();
+ await doContextMenu(testIndex, messageId, replyItem);
+ let composeWindow = await composeWindowPromise;
+ let composeEditor = composeWindow.GetCurrentEditorElement();
+ let composeBody = await TestUtils.waitForCondition(
+ () => composeEditor.contentDocument.body.textContent
+ );
+
+ Assert.stringContains(
+ composeBody,
+ "Hello Felix Flowers!",
+ "new message should quote the right-clicked-on message"
+ );
+ Assert.ok(
+ !composeBody.includes("Hello Bob Bell!"),
+ "new message should not quote the selected message"
+ );
+
+ await BrowserTestUtils.closeWindow(composeWindow);
+ }
+});
+
+async function messageLoaded(index) {
+ await BrowserTestUtils.browserLoaded(messagePaneBrowser);
+ Assert.equal(
+ aboutMessage.gMessage.messageId,
+ sourceMessageIDs[index],
+ "correct message loaded"
+ );
+}
+
+async function validateTree(rowCount, selectedIndices, currentIndex) {
+ Assert.equal(threadTree.view.rowCount, rowCount, "row count of view");
+ await TestUtils.waitForCondition(
+ () => threadTree.table.body.rows.length == rowCount,
+ "waiting table row count to match the view's row count"
+ );
+
+ Assert.deepEqual(
+ threadTree.selectedIndices,
+ selectedIndices,
+ "table's selected indices"
+ );
+ let selectedRows = Array.from(threadTree.querySelectorAll(".selected"));
+ Assert.equal(
+ selectedRows.length,
+ selectedIndices.length,
+ "number of rows with .selected class"
+ );
+ for (let index of selectedIndices) {
+ let row = threadTree.getRowAtIndex(index);
+ Assert.ok(
+ selectedRows.includes(row),
+ `.selected row at ${index} is expected`
+ );
+ }
+
+ Assert.equal(threadTree.currentIndex, currentIndex, "table's current index");
+ let currentRows = threadTree.querySelectorAll(".current");
+ Assert.equal(currentRows.length, 1, "one row should have .current");
+ Assert.equal(
+ currentRows[0],
+ threadTree.getRowAtIndex(currentIndex),
+ ".current row is expected"
+ );
+
+ let contextTargetRows = threadTree.querySelectorAll(".context-menu-target");
+ Assert.equal(
+ contextTargetRows.length,
+ 0,
+ "no rows should have .context-menu-target"
+ );
+}
+
+async function move(messages, source, dest) {
+ let copyListener = new PromiseTestUtils.PromiseCopyListener();
+ MailServices.copy.copyMessages(
+ source,
+ messages,
+ dest,
+ true,
+ copyListener,
+ top.msgWindow,
+ false
+ );
+ await copyListener.promise;
+}
+
+function reportBadSelectEvent() {
+ Assert.report(
+ true,
+ undefined,
+ undefined,
+ "should not have fired a select event"
+ );
+}
+
+function reportBadLoad() {
+ Assert.report(true, undefined, undefined, "should not have loaded a message");
+}
+
+async function restoreMessages() {
+ // Move all of the messages back to folder A.
+ await move([...folderB.messages], folderB, folderA);
+ let archiveFolder = rootFolder.getFolderWithFlags(
+ Ci.nsMsgFolderFlags.Archive
+ );
+ if (archiveFolder) {
+ for (let folder of archiveFolder.subFolders) {
+ await move([...folder.messages], folder, folderA);
+ }
+ }
+ await move([...trashFolder.messages], trashFolder, folderA);
+
+ // Restore all of the messages in `sourceMessages`, in the right order.
+ sourceMessages = [...folderA.messages].sort(
+ (a, b) =>
+ sourceMessageIDs.indexOf(a.messageId) -
+ sourceMessageIDs.indexOf(b.messageId)
+ );
+}
+
+add_task(async function testThreadTreeA11yRoles() {
+ Assert.equal(
+ threadTree.table.body.getAttribute("role"),
+ "listbox",
+ "The tree view should be presented as ListBox"
+ );
+ Assert.equal(
+ threadTree.getRowAtIndex(0).getAttribute("role"),
+ "option",
+ "The message row should be presented as Option"
+ );
+
+ about3Pane.sortController.sortThreaded();
+
+ await BrowserTestUtils.waitForCondition(
+ () => threadTree.table.body.getAttribute("role") == "tree",
+ "The tree view should switch to a Tree View role"
+ );
+ Assert.equal(
+ threadTree.getRowAtIndex(0).getAttribute("role"),
+ "treeitem",
+ "The message row should be presented as Tree Item"
+ );
+
+ about3Pane.sortController.groupBySort();
+ threadTree.scrollToIndex(0, true);
+
+ await BrowserTestUtils.waitForCondition(
+ () => threadTree.table.body.getAttribute("role") == "tree",
+ "The message list table should remain presented as Tree View"
+ );
+ Assert.equal(
+ threadTree.getRowAtIndex(0).getAttribute("role"),
+ "treeitem",
+ "The first dummy message row should be presented as Tree Item"
+ );
+
+ about3Pane.sortController.sortUnthreaded();
+});
diff --git a/comm/mail/base/test/browser/browser_threadTreeSorting.js b/comm/mail/base/test/browser/browser_threadTreeSorting.js
new file mode 100644
index 0000000000..be3bf0f2eb
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_threadTreeSorting.js
@@ -0,0 +1,344 @@
+/* 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 { MessageGenerator } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MessageGenerator.jsm"
+);
+
+let tabmail = document.getElementById("tabmail");
+let about3Pane = tabmail.currentAbout3Pane;
+let { sortController, threadTree } = about3Pane;
+let rootFolder, testFolder, sourceMessageIDs;
+let menuHelper = new MenuTestHelper("menu_View");
+
+add_setup(async function () {
+ Services.prefs.setBoolPref("mailnews.scroll_to_new_message", false);
+ let generator = new MessageGenerator();
+
+ MailServices.accounts.createLocalMailAccount();
+ let account = MailServices.accounts.accounts[0];
+ account.addIdentity(MailServices.accounts.createIdentity());
+ rootFolder = account.incomingServer.rootFolder.QueryInterface(
+ Ci.nsIMsgLocalMailFolder
+ );
+
+ testFolder = rootFolder
+ .createLocalSubfolder("threadTreeSort")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+ testFolder.addMessageBatch(
+ generator
+ .makeMessages({ count: 320 })
+ .map(message => message.toMboxString())
+ );
+
+ about3Pane.restoreState({
+ messagePaneVisible: false,
+ folderURI: testFolder.URI,
+ });
+ await new Promise(resolve => requestAnimationFrame(resolve));
+
+ document.getElementById("toolbar-menubar").removeAttribute("autohide");
+
+ registerCleanupFunction(async () => {
+ await ensure_cards_view();
+ MailServices.accounts.removeAccount(account, false);
+ Services.prefs.setBoolPref("mailnews.scroll_to_new_message", true);
+ });
+});
+
+add_task(async function () {
+ const messagesByDate = [...testFolder.messages];
+ const messagesBySubject = messagesByDate
+ .slice()
+ .sort((m1, m2) => (m1.subject < m2.subject ? -1 : 1));
+ const dateHeaderButton = about3Pane.document.getElementById("dateCol");
+ const subjectHeaderButton = about3Pane.document.getElementById("subjectCol");
+
+ // Sanity check.
+
+ Assert.equal(
+ about3Pane.gViewWrapper.primarySortType,
+ Ci.nsMsgViewSortType.byDate,
+ "initial sort column should be byDate"
+ );
+ Assert.equal(
+ about3Pane.gViewWrapper.primarySortOrder,
+ Ci.nsMsgViewSortOrder.ascending,
+ "initial sort order should be ascending"
+ );
+ Assert.ok(
+ about3Pane.gViewWrapper.showThreaded,
+ "initial mode should be threaded"
+ );
+ Assert.equal(
+ threadTree.view.rowCount,
+ 320,
+ "correct number of rows in the view"
+ );
+ Assert.equal(
+ threadTree.getLastVisibleIndex(),
+ 319,
+ "should be scrolled to the bottom"
+ );
+
+ Assert.equal(getCardActualSubject(320 - 1), messagesByDate.at(-1).subject);
+ Assert.equal(getCardActualSubject(320 - 2), messagesByDate.at(-2).subject);
+ Assert.equal(getCardActualSubject(320 - 3), messagesByDate.at(-3).subject);
+
+ // Switch to horizontal layout and table view so we can interact with the
+ // table header and sort rows properly.
+ await ensure_table_view();
+
+ // Check sorting with no message selected.
+
+ await clickHeader(dateHeaderButton, "byDate", "descending");
+ Assert.equal(
+ threadTree.view.rowCount,
+ 320,
+ "correct number of rows in the view"
+ );
+ Assert.equal(
+ threadTree.getFirstVisibleIndex(),
+ 0,
+ "should be scrolled to the top"
+ );
+
+ Assert.equal(getActualSubject(0), messagesByDate.at(-1).subject);
+ Assert.equal(getActualSubject(1), messagesByDate.at(-2).subject);
+ Assert.equal(getActualSubject(2), messagesByDate.at(-3).subject);
+
+ await clickHeader(dateHeaderButton, "byDate", "ascending");
+ Assert.equal(
+ threadTree.view.rowCount,
+ 320,
+ "correct number of rows in the view"
+ );
+ Assert.equal(
+ threadTree.getLastVisibleIndex(),
+ 319,
+ "should be scrolled to the bottom"
+ );
+
+ Assert.equal(getActualSubject(320 - 1), messagesByDate.at(-1).subject);
+ Assert.equal(getActualSubject(320 - 2), messagesByDate.at(-2).subject);
+ Assert.equal(getActualSubject(320 - 3), messagesByDate.at(-3).subject);
+
+ // Select a message and check the selection remains after sorting.
+
+ const targetMessage = messagesByDate[49];
+ info(`selecting message "${targetMessage.subject}"`);
+ threadTree.scrollToIndex(49, true);
+ await new Promise(resolve => requestAnimationFrame(resolve));
+ threadTree.selectedIndex = 49;
+ verifySelection([49], [targetMessage.subject]);
+
+ await clickHeader(dateHeaderButton, "byDate", "descending");
+ verifySelection([319 - 49], [targetMessage.subject], {
+ where: "last",
+ });
+
+ await clickHeader(dateHeaderButton, "byDate", "ascending");
+ verifySelection([49], [targetMessage.subject], { where: "first" });
+
+ const targetIndexBySubject = messagesBySubject.indexOf(targetMessage);
+
+ // Switch columns.
+
+ await clickHeader(subjectHeaderButton, "bySubject", "ascending");
+ verifySelection([targetIndexBySubject], [targetMessage.subject]);
+
+ await clickHeader(subjectHeaderButton, "bySubject", "descending");
+ verifySelection([319 - targetIndexBySubject], [targetMessage.subject]);
+
+ await clickHeader(subjectHeaderButton, "bySubject", "ascending");
+ verifySelection([targetIndexBySubject], [targetMessage.subject]);
+
+ // Switch back again.
+
+ await clickHeader(dateHeaderButton, "byDate", "ascending");
+ verifySelection([49], [targetMessage.subject], { where: "first" });
+
+ // Select multiple messages, two adjacent to each other, one non-adjacent,
+ // and check the selection remains after sorting.
+
+ const targetMessages = [
+ messagesByDate[80],
+ messagesByDate[81],
+ messagesByDate[83],
+ ];
+ info(
+ `selecting messages "${targetMessages.map(m => m.subject).join('", "')}"`
+ );
+ threadTree.scrollToIndex(83, true);
+ await new Promise(resolve => requestAnimationFrame(resolve));
+ threadTree.selectedIndices = [80, 81, 83];
+ verifySelection(
+ [80, 81, 83],
+ [
+ targetMessages[0].subject,
+ targetMessages[1].subject,
+ targetMessages[2].subject,
+ ],
+ { currentIndex: 83 }
+ );
+
+ await clickHeader(dateHeaderButton, "byDate", "descending");
+ verifySelection(
+ [319 - 83, 319 - 81, 319 - 80],
+ [
+ targetMessages[2].subject,
+ // Rows for these two messages probably don't exist yet.
+ // targetMessages[1].subject,
+ // targetMessages[0].subject,
+ ],
+ { where: "last" }
+ );
+
+ await clickHeader(dateHeaderButton, "byDate", "ascending");
+ verifySelection(
+ [80, 81, 83],
+ [
+ // Rows for these two messages probably don't exist yet.
+ undefined, // targetMessages[0].subject,
+ undefined, // targetMessages[1].subject,
+ targetMessages[2].subject,
+ ],
+ { currentIndex: 83, where: "first" }
+ );
+});
+
+async function clickHeader(header, type, order) {
+ info(`sorting ${type} ${order}`);
+ const button = header.querySelector("button");
+
+ let scrollEvents = 0;
+ let listener = () => scrollEvents++;
+
+ threadTree.addEventListener("scroll", listener);
+ EventUtils.synthesizeMouseAtCenter(button, {}, about3Pane);
+
+ // Wait long enough that any more scrolling would trigger more events.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 100));
+ threadTree.removeEventListener("scroll", listener);
+ Assert.lessOrEqual(scrollEvents, 1, "only one scroll event should fire");
+
+ Assert.equal(
+ about3Pane.gViewWrapper.primarySortType,
+ Ci.nsMsgViewSortType[type],
+ `sort type should be ${type}`
+ );
+ Assert.equal(
+ about3Pane.gViewWrapper.primarySortOrder,
+ Ci.nsMsgViewSortOrder[order],
+ `sort order should be ${order}`
+ );
+ Assert.ok(about3Pane.gViewWrapper.showThreaded, "mode should be threaded");
+
+ Assert.ok(
+ button.classList.contains("sorting"),
+ "header button should have the sorted class"
+ );
+ Assert.equal(
+ button.classList.contains("ascending"),
+ order == "ascending",
+ `header button ${
+ order == "ascending" ? "should" : "should not"
+ } have the ascending class`
+ );
+ Assert.equal(
+ button.classList.contains("descending"),
+ order == "descending",
+ `header button ${
+ order == "descending" ? "should" : "should not"
+ } have the descending class`
+ );
+
+ Assert.equal(
+ threadTree.table.header.querySelectorAll(
+ ".sorting, .ascending, .descending"
+ ).length,
+ 1,
+ "no other header buttons should have sorting classes"
+ );
+
+ if (AppConstants.platform != "macosx") {
+ await menuHelper.testItems({
+ viewSortMenu: {},
+ sortByDateMenuitem: { checked: type == "byDate" },
+ sortBySubjectMenuitem: { checked: type == "bySubject" },
+ sortAscending: { checked: order == "ascending" },
+ sortDescending: { checked: order == "descending" },
+ sortThreaded: { checked: true },
+ sortUnthreaded: {},
+ groupBySort: {},
+ });
+ }
+}
+
+function verifySelection(
+ selectedIndices,
+ subjects,
+ { currentIndex, where } = {}
+) {
+ if (currentIndex === undefined) {
+ currentIndex = selectedIndices[0];
+ }
+
+ Assert.deepEqual(
+ threadTree.selectedIndices,
+ selectedIndices,
+ "selectedIndices"
+ );
+ Assert.equal(threadTree.currentIndex, currentIndex, "currentIndex");
+ if (where == "first") {
+ Assert.equal(
+ threadTree.getFirstVisibleIndex(),
+ currentIndex,
+ "currentIndex should be first"
+ );
+ } else {
+ Assert.lessOrEqual(
+ threadTree.getFirstVisibleIndex(),
+ currentIndex,
+ "currentIndex should be at or below first"
+ );
+ }
+ if (where == "last") {
+ Assert.equal(
+ threadTree.getLastVisibleIndex(),
+ currentIndex,
+ "currentIndex should be last"
+ );
+ } else {
+ Assert.greaterOrEqual(
+ threadTree.getLastVisibleIndex(),
+ currentIndex,
+ "currentIndex should be at or above last"
+ );
+ }
+ for (let i = 0; i < subjects.length; i++) {
+ if (subjects[i]) {
+ Assert.equal(getActualSubject(selectedIndices[i]), subjects[i]);
+ }
+ }
+}
+
+function getCardActualSubject(index) {
+ let row = threadTree.getRowAtIndex(index);
+ return row.querySelector(".thread-card-subject-container > .subject")
+ .textContent;
+}
+
+function getActualSubject(index) {
+ let row = threadTree.getRowAtIndex(index);
+ return row.querySelector(".subject-line > span").textContent;
+}
+
+function getActualSubjects() {
+ return Array.from(
+ threadTree.table.body.rows,
+ row => row.querySelector(".subject-line > span").textContent
+ );
+}
diff --git a/comm/mail/base/test/browser/browser_threads.js b/comm/mail/base/test/browser/browser_threads.js
new file mode 100644
index 0000000000..4bfcb5fc11
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_threads.js
@@ -0,0 +1,385 @@
+/* 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 { MessageGenerator } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MessageGenerator.jsm"
+);
+
+let tabmail = document.getElementById("tabmail");
+let about3Pane = tabmail.currentAbout3Pane;
+let { threadPane, threadTree } = about3Pane;
+let { notificationBox } = threadPane;
+let rootFolder, testFolder, testMessages;
+
+add_setup(async function () {
+ Services.prefs.setStringPref(
+ "mail.ignore_thread.learn_more_url",
+ "http://mochi.test:8888/"
+ );
+ document.getElementById("toolbar-menubar").removeAttribute("autohide");
+
+ let generator = new MessageGenerator();
+
+ MailServices.accounts.createLocalMailAccount();
+ let account = MailServices.accounts.accounts[0];
+ account.addIdentity(MailServices.accounts.createIdentity());
+ rootFolder = account.incomingServer.rootFolder;
+
+ rootFolder.createSubfolder("threads", null);
+ testFolder = rootFolder
+ .getChildNamed("threads")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+
+ testFolder.addMessageBatch(
+ generator
+ .makeMessages({ count: 25, msgsPerThread: 5 })
+ .map(message => message.toMboxString())
+ );
+ testMessages = [...testFolder.messages];
+
+ about3Pane.displayFolder(testFolder.URI);
+ about3Pane.paneLayout.messagePaneVisible = false;
+ goDoCommand("cmd_expandAllThreads");
+
+ await ensure_table_view();
+
+ // Check the initial state of a sample of messages.
+
+ checkRowThreadState(0, true);
+ checkRowThreadState(1, false);
+ checkRowThreadState(2, false);
+ checkRowThreadState(3, false);
+ checkRowThreadState(4, false);
+ checkRowThreadState(5, true);
+ checkRowThreadState(10, true);
+ checkRowThreadState(15, true);
+ checkRowThreadState(20, true);
+
+ registerCleanupFunction(async () => {
+ await ensure_cards_view();
+ MailServices.accounts.removeAccount(account, false);
+ about3Pane.paneLayout.messagePaneVisible = true;
+ Services.prefs.clearUserPref("mail.ignore_thread.learn_more_url");
+ });
+});
+
+/**
+ * Test that a double click on a button doesn't trigger the opening of the
+ * message.
+ */
+add_task(async function checkDoubleClickOnThreadButton() {
+ let row = threadTree.getRowAtIndex(20);
+ Assert.ok(
+ !row.classList.contains("collapsed"),
+ "The thread row should be expanded"
+ );
+
+ Assert.equal(tabmail.tabInfo.length, 1, "Only 1 tab currently visible");
+
+ let button = row.querySelector(".thread-container .twisty");
+ // Simulate a double click on the twisty icon.
+ EventUtils.synthesizeMouseAtCenter(button, { clickCount: 2 }, about3Pane);
+
+ Assert.equal(
+ tabmail.tabInfo.length,
+ 1,
+ "The message wasn't opened in another tab"
+ );
+
+ // Normally a double click on the twisty would close and open the thread, but
+ // this simulated click is too fast and the second click happens before the
+ // row is collapsed. Let's click on it again once to return to the original
+ // state.
+ EventUtils.synthesizeMouseAtCenter(button, {}, about3Pane);
+
+ Assert.ok(
+ !row.classList.contains("collapsed"),
+ "The double click was registered as 2 separate clicks and the thread row is still expanded"
+ );
+});
+
+add_task(async function testIgnoreThread() {
+ // Check the menu items for the root message in a thread.
+
+ threadTree.selectedIndex = 0;
+ await checkMessageMenu({ killThread: false });
+ await checkContextMenu(0, { "mailContext-ignoreThread": false });
+
+ // Check and use the menu items for a message inside a thread. Ignoring a
+ // thread should work from any message in the thread.
+ threadTree.selectedIndex = 2;
+ await checkMessageMenu({ killThread: false });
+ await checkContextMenu(
+ 2,
+ { "mailContext-ignoreThread": false },
+ "mailContext-ignoreThread"
+ );
+
+ // Check the thread is ignored and collapsed.
+
+ checkRowThreadState(0, "ignore");
+ Assert.ok(
+ threadTree.getRowAtIndex(0).classList.contains("collapsed"),
+ "ignored row should have the 'collapsed' class"
+ );
+
+ // Restore the thread using the context menu item.
+
+ threadTree.selectedIndex = 0;
+ await checkMessageMenu({ killThread: true });
+ await checkContextMenu(
+ 0,
+ { "mailContext-ignoreThread": true },
+ "mailContext-ignoreThread"
+ );
+
+ checkRowThreadState(0, true);
+
+ // Ignore the next thread. The first thread was collapsed by ignoring it,
+ // so the next thread is at index 1.
+
+ threadTree.selectedIndex = 1;
+ await checkMessageMenu({ killThread: false });
+ await checkContextMenu(
+ 1,
+ { "mailContext-ignoreThread": false },
+ "mailContext-ignoreThread"
+ );
+
+ checkRowThreadState(1, "ignore");
+ Assert.ok(
+ threadTree.getRowAtIndex(1).classList.contains("collapsed"),
+ "ignored row should have the 'collapsed' class"
+ );
+
+ // Check the notification about the ignored thread.
+
+ let notification =
+ notificationBox.getNotificationWithValue("ignoreThreadInfo");
+ let label = notification.shadowRoot.querySelector(
+ "label.notification-message"
+ );
+ Assert.stringContains(label.textContent, testMessages[5].subject);
+ let buttons = notification.shadowRoot.querySelectorAll(
+ "button.notification-button"
+ );
+ Assert.equal(buttons.length, 2);
+
+ // Click the Learn More button, and check it opens the support page in a new tab.
+ let tabOpenPromise = BrowserTestUtils.waitForEvent(
+ tabmail.tabContainer,
+ "TabOpen"
+ );
+ EventUtils.synthesizeMouseAtCenter(buttons[0], {}, about3Pane);
+ let event = await tabOpenPromise;
+ await BrowserTestUtils.browserLoaded(event.detail.tabInfo.browser);
+ Assert.equal(
+ event.detail.tabInfo.browser.currentURI.spec,
+ "http://mochi.test:8888/"
+ );
+ tabmail.closeTab(event.detail.tabInfo);
+ Assert.ok(notification.parentNode, "notification should not be closed");
+
+ // Click the Undo button, and check it stops ignoring the thread.
+ EventUtils.synthesizeMouseAtCenter(buttons[1], {}, about3Pane);
+ await TestUtils.waitForCondition(() => !notification.parentNode);
+ checkRowThreadState(1, true);
+
+ goDoCommand("cmd_expandAllThreads");
+});
+
+add_task(async function testIgnoreSubthread() {
+ // Check and use the menu items for a message inside a thread.
+
+ threadTree.selectedIndex = 12;
+ await checkMessageMenu({ killSubthread: false });
+ await checkContextMenu(
+ 12,
+ { "mailContext-ignoreSubthread": false },
+ "mailContext-ignoreSubthread"
+ );
+
+ // Check all messages in that subthread are marked as ignored.
+
+ checkRowThreadState(12, "ignoreSubthread");
+ checkRowThreadState(13, "ignoreSubthread");
+ checkRowThreadState(14, "ignoreSubthread");
+
+ // Restore the subthread using the context menu item.
+
+ threadTree.selectedIndex = 12;
+ await checkMessageMenu({ killSubthread: true });
+ await checkContextMenu(
+ 12,
+ { "mailContext-ignoreSubthread": true },
+ "mailContext-ignoreSubthread"
+ );
+
+ checkRowThreadState(12, false);
+ checkRowThreadState(13, false);
+ checkRowThreadState(14, false);
+
+ // Ignore a different subthread.
+
+ threadTree.selectedIndex = 17;
+ await checkMessageMenu({ killSubthread: false });
+ await checkContextMenu(
+ 17,
+ { "mailContext-ignoreSubthread": false },
+ "mailContext-ignoreSubthread"
+ );
+
+ checkRowThreadState(17, "ignoreSubthread");
+ checkRowThreadState(18, "ignoreSubthread");
+ checkRowThreadState(19, "ignoreSubthread");
+
+ // Check the notification about the ignored subthread.
+
+ let notification =
+ notificationBox.getNotificationWithValue("ignoreThreadInfo");
+ let label = notification.shadowRoot.querySelector(
+ "label.notification-message"
+ );
+ Assert.stringContains(label.textContent, testMessages[17].subject);
+ let buttons = notification.shadowRoot.querySelectorAll(
+ "button.notification-button"
+ );
+ Assert.equal(buttons.length, 2);
+
+ // Click the Undo button, and check it stops ignoring the subthread.
+ EventUtils.synthesizeMouseAtCenter(buttons[1], {}, about3Pane);
+ await TestUtils.waitForCondition(() => !notification.parentNode);
+ checkRowThreadState(17, false);
+ checkRowThreadState(18, false);
+ checkRowThreadState(19, false);
+});
+
+add_task(async function testWatchThread() {
+ threadTree.selectedIndex = 20;
+ await checkMessageMenu({ watchThread: false });
+ await checkContextMenu(
+ 20,
+ { "mailContext-watchThread": false },
+ "mailContext-watchThread"
+ );
+
+ checkRowThreadState(20, "watched");
+ checkRowThreadState(21, false);
+
+ await checkMessageMenu({ watchThread: true });
+ await checkContextMenu(
+ 20,
+ { "mailContext-watchThread": true },
+ "mailContext-watchThread"
+ );
+
+ checkRowThreadState(20, true);
+ checkRowThreadState(21, false);
+});
+
+async function checkContextMenu(index, expectedStates, itemToActivate) {
+ let contextMenu = about3Pane.document.getElementById("mailContext");
+ let row = threadTree.getRowAtIndex(index);
+
+ let shownPromise = BrowserTestUtils.waitForEvent(contextMenu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ row.querySelector(".subject-line"),
+ { type: "contextmenu" },
+ about3Pane
+ );
+ await shownPromise;
+
+ for (let [id, checkedState] of Object.entries(expectedStates)) {
+ assertCheckedState(about3Pane.document.getElementById(id), checkedState);
+ }
+
+ let hiddenPromise = BrowserTestUtils.waitForEvent(contextMenu, "popuphidden");
+ if (itemToActivate) {
+ contextMenu.activateItem(
+ about3Pane.document.getElementById(itemToActivate)
+ );
+ } else {
+ contextMenu.hidePopup();
+ }
+ await hiddenPromise;
+}
+
+async function checkMessageMenu(expectedStates) {
+ if (AppConstants.platform == "macosx") {
+ // Can't check the menu.
+ return;
+ }
+
+ let messageMenu = document.getElementById("messageMenu");
+
+ let shownPromise = BrowserTestUtils.waitForEvent(
+ messageMenu.menupopup,
+ "popupshown"
+ );
+ EventUtils.synthesizeMouseAtCenter(messageMenu, {}, window);
+ await shownPromise;
+
+ for (let [id, checkedState] of Object.entries(expectedStates)) {
+ assertCheckedState(document.getElementById(id), checkedState);
+ }
+
+ messageMenu.menupopup.hidePopup();
+}
+
+function assertCheckedState(menuItem, checkedState) {
+ if (checkedState) {
+ Assert.equal(menuItem.getAttribute("checked"), "true");
+ } else {
+ Assert.ok(
+ !menuItem.hasAttribute("checked") ||
+ menuItem.getAttribute("checked") == "false"
+ );
+ }
+}
+
+function checkRowThreadState(index, expected) {
+ let row = threadTree.getRowAtIndex(index);
+ let icon = row.querySelector(".threadcol-column img");
+
+ if (!expected) {
+ Assert.ok(
+ !row.classList.contains("children"),
+ "row should not have the 'children' class"
+ );
+ Assert.ok(BrowserTestUtils.is_hidden(icon), "icon should be hidden");
+ return;
+ }
+
+ Assert.ok(BrowserTestUtils.is_visible(icon), "icon should be visible");
+
+ let shouldHaveChildrenClass = true;
+ let iconContent = getComputedStyle(icon).content;
+
+ switch (expected) {
+ case true:
+ Assert.stringContains(iconContent, "/thread-sm.svg");
+ break;
+ case "ignore":
+ Assert.stringContains(row.dataset.properties, "ignore");
+ Assert.stringContains(iconContent, "/thread-ignored.svg");
+ break;
+ case "ignoreSubthread":
+ Assert.stringContains(row.dataset.properties, "ignoreSubthread");
+ Assert.stringContains(iconContent, "/subthread-ignored.svg");
+ shouldHaveChildrenClass = false;
+ break;
+ case "watched":
+ Assert.stringContains(row.dataset.properties, "watch");
+ Assert.stringContains(iconContent, "/eye.svg");
+ break;
+ }
+
+ Assert.equal(
+ row.classList.contains("children"),
+ shouldHaveChildrenClass,
+ `row should${
+ shouldHaveChildrenClass ? "" : " not"
+ } have the 'children' class`
+ );
+}
diff --git a/comm/mail/base/test/browser/browser_toolsMenu.js b/comm/mail/base/test/browser/browser_toolsMenu.js
new file mode 100644
index 0000000000..8dbf5911c8
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_toolsMenu.js
@@ -0,0 +1,109 @@
+/* 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 { MessageGenerator } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MessageGenerator.jsm"
+);
+
+/** @type MenuData */
+const toolsMenuData = {
+ tasksMenuMail: { hidden: true },
+ addressBook: {},
+ menu_openSavedFilesWnd: {},
+ addonsManager: {},
+ activityManager: {},
+ imAccountsStatus: { disabled: true },
+ imStatusAvailable: {},
+ imStatusUnavailable: {},
+ imStatusOffline: {},
+ imStatusShowAccounts: {},
+ joinChatMenuItem: { disabled: true },
+ filtersCmd: {},
+ applyFilters: { disabled: ["mail3PaneTab", "contentTab"] },
+ applyFiltersToSelection: { disabled: ["mail3PaneTab", "contentTab"] },
+ runJunkControls: { disabled: true },
+ deleteJunk: { disabled: true },
+ menu_import: {},
+ menu_export: {},
+ manageKeysOpenPGP: {},
+ devtoolsMenu: {},
+ devtoolsToolbox: {},
+ addonDebugging: {},
+ javascriptConsole: {},
+ sanitizeHistory: {},
+};
+if (AppConstants.platform == "win") {
+ toolsMenuData.menu_preferences = {};
+ toolsMenuData.menu_accountmgr = {};
+}
+let helper = new MenuTestHelper("tasksMenu", toolsMenuData);
+
+let tabmail = document.getElementById("tabmail");
+let rootFolder, testFolder, testMessages;
+
+add_setup(async function () {
+ document.getElementById("toolbar-menubar").removeAttribute("autohide");
+
+ let generator = new MessageGenerator();
+
+ MailServices.accounts.createLocalMailAccount();
+ let account = MailServices.accounts.accounts[0];
+ account.addIdentity(MailServices.accounts.createIdentity());
+ rootFolder = account.incomingServer.rootFolder;
+
+ rootFolder.createSubfolder("tools menu", null);
+ testFolder = rootFolder
+ .getChildNamed("tools menu")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+ testFolder.addMessageBatch(
+ generator.makeMessages({ count: 5 }).map(message => message.toMboxString())
+ );
+ testMessages = [...testFolder.messages];
+
+ window.OpenMessageInNewTab(testMessages[0], { background: true });
+ await BrowserTestUtils.waitForEvent(
+ tabmail.tabInfo[1].chromeBrowser,
+ "MsgLoaded"
+ );
+
+ window.openTab("contentTab", {
+ url: "https://example.com/",
+ background: true,
+ });
+
+ registerCleanupFunction(() => {
+ tabmail.closeOtherTabs(0);
+ MailServices.accounts.removeAccount(account, false);
+ });
+});
+
+add_task(async function test3PaneTab() {
+ tabmail.currentAbout3Pane.displayFolder(rootFolder);
+ await helper.testAllItems("mail3PaneTab");
+
+ tabmail.currentAbout3Pane.displayFolder(testFolder);
+ await helper.testItems({
+ applyFilters: {},
+ runJunkControls: {},
+ deleteJunk: {},
+ });
+
+ tabmail.currentAbout3Pane.threadTree.selectedIndex = 1;
+ await helper.testItems({
+ applyFilters: {},
+ applyFiltersToSelection: {},
+ runJunkControls: {},
+ deleteJunk: {},
+ });
+});
+
+add_task(async function testMessageTab() {
+ tabmail.switchToTab(1);
+ await helper.testAllItems("mailMessageTab");
+});
+
+add_task(async function testContentTab() {
+ tabmail.switchToTab(2);
+ await helper.testAllItems("contentTab");
+});
diff --git a/comm/mail/base/test/browser/browser_treeListbox.js b/comm/mail/base/test/browser/browser_treeListbox.js
new file mode 100644
index 0000000000..558bef991e
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_treeListbox.js
@@ -0,0 +1,1313 @@
+/* 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/. */
+
+let tabmail = document.getElementById("tabmail");
+registerCleanupFunction(() => {
+ tabmail.closeOtherTabs(tabmail.tabInfo[0]);
+});
+
+async function withTab(callback) {
+ let tab = tabmail.openTab("contentTab", {
+ url: "chrome://mochitests/content/browser/comm/mail/base/test/browser/files/treeListbox.xhtml",
+ });
+ await BrowserTestUtils.browserLoaded(tab.browser);
+
+ tab.browser.focus();
+ await SpecialPowers.spawn(tab.browser, [], callback);
+
+ tabmail.closeTab(tab);
+}
+
+add_task(async function testKeyboard() {
+ await withTab(subtestKeyboard);
+});
+add_task(async function testMutation() {
+ await withTab(subtestMutation);
+});
+add_task(async function testExpandCollapse() {
+ await withTab(subtestExpandCollapse);
+});
+add_task(async function testSelectOnRemoval1() {
+ await withTab(subtestSelectOnRemoval1);
+});
+add_task(async function testSelectOnRemoval2() {
+ await withTab(subtestSelectOnRemoval2);
+});
+add_task(async function testSelectOnRemoval3() {
+ await withTab(subtestSelectOnRemoval3);
+});
+add_task(async function testUnselectable() {
+ await withTab(subtestUnselectable);
+});
+
+/**
+ * Tests keyboard navigation up and down the list.
+ */
+async function subtestKeyboard() {
+ let doc = content.document;
+
+ let list = doc.querySelector(`ul[is="tree-listbox"]`);
+ Assert.ok(!!list, "the list exists");
+
+ let initialRowIds = [
+ "row-1",
+ "row-2",
+ "row-2-1",
+ "row-2-2",
+ "row-3",
+ "row-3-1",
+ "row-3-1-1",
+ "row-3-1-2",
+ ];
+ Assert.equal(list.rowCount, initialRowIds.length, "rowCount is correct");
+ Assert.deepEqual(
+ list.rows.map(r => r.id),
+ initialRowIds,
+ "initial rows are correct"
+ );
+ Assert.equal(list.selectedIndex, 0, "selectedIndex is set to 0");
+
+ let selectHandler = {
+ seenEvent: null,
+ selectedAtEvent: null,
+
+ reset() {
+ this.seenEvent = null;
+ this.selectedAtEvent = null;
+ },
+ handleEvent(event) {
+ this.seenEvent = event;
+ this.selectedAtEvent = list.selectedIndex;
+ },
+ };
+
+ function pressKey(key, expectEvent = true) {
+ info(`pressing ${key}`);
+
+ selectHandler.reset();
+ list.addEventListener("select", selectHandler);
+ EventUtils.synthesizeKey(key, {}, content);
+ list.removeEventListener("select", selectHandler);
+ Assert.equal(
+ !!selectHandler.seenEvent,
+ expectEvent,
+ `'select' event ${expectEvent ? "fired" : "did not fire"}`
+ );
+ }
+
+ function checkSelected(expectedIndex, expectedId) {
+ Assert.equal(list.selectedIndex, expectedIndex, "selectedIndex is correct");
+ if (selectHandler.selectedAtEvent !== null) {
+ // Check the value was already set when the select event fired.
+ Assert.deepEqual(
+ selectHandler.selectedAtEvent,
+ expectedIndex,
+ "selectedIndex was correct at the last 'select' event"
+ );
+ }
+
+ Assert.deepEqual(
+ Array.from(list.querySelectorAll(".selected"), row => row.id),
+ [expectedId],
+ "correct rows have the 'selected' class"
+ );
+ }
+
+ // Key down the list.
+
+ list.focus();
+ for (let i = 1; i < initialRowIds.length; i++) {
+ pressKey("KEY_ArrowDown");
+ checkSelected(i, initialRowIds[i]);
+ }
+
+ pressKey("KEY_ArrowDown", false);
+ checkSelected(7, "row-3-1-2");
+
+ pressKey("KEY_PageDown", false);
+ checkSelected(7, "row-3-1-2");
+
+ pressKey("KEY_End", false);
+ checkSelected(7, "row-3-1-2");
+
+ // And up again.
+
+ for (let i = initialRowIds.length - 2; i >= 0; i--) {
+ pressKey("KEY_ArrowUp");
+ checkSelected(i, initialRowIds[i]);
+ }
+
+ pressKey("KEY_ArrowUp", false);
+ checkSelected(0, "row-1");
+
+ pressKey("KEY_PageUp", false);
+ checkSelected(0, "row-1");
+
+ pressKey("KEY_Home", false);
+ checkSelected(0, "row-1");
+
+ // Jump around.
+
+ pressKey("KEY_End");
+ checkSelected(7, "row-3-1-2");
+
+ pressKey("KEY_PageUp");
+ checkSelected(0, "row-1");
+
+ pressKey("KEY_PageDown");
+ checkSelected(7, "row-3-1-2");
+
+ pressKey("KEY_Home");
+ checkSelected(0, "row-1");
+}
+
+/**
+ * Tests that rows added to or removed from the tree cause their parent rows
+ * to gain or lose the 'children' class as appropriate. This is done with a
+ * mutation observer so the tree is not updated immediately, but at the end of
+ * the event loop.
+ */
+async function subtestMutation() {
+ let doc = content.document;
+ let list = doc.querySelector(`ul[is="tree-listbox"]`);
+ let idsWithChildren = ["row-2", "row-3", "row-3-1"];
+ let idsWithoutChildren = [
+ "row-1",
+ "row-2-1",
+ "row-2-2",
+ "row-3-1-1",
+ "row-3-1-2",
+ ];
+
+ // Check the initial state.
+
+ function createNewRow() {
+ let template = doc.getElementById("rowToAdd");
+ return template.content.cloneNode(true).firstElementChild;
+ }
+
+ function checkHasClass(id, shouldHaveClass = true) {
+ let row = doc.getElementById(id);
+ if (shouldHaveClass) {
+ Assert.ok(
+ row.classList.contains("children"),
+ `${id} should have the 'children' class`
+ );
+ } else {
+ Assert.ok(
+ !row.classList.contains("children"),
+ `${id} should NOT have the 'children' class`
+ );
+ }
+ }
+
+ for (let id of idsWithChildren) {
+ checkHasClass(id, true);
+ }
+ for (let id of idsWithoutChildren) {
+ checkHasClass(id, false);
+ }
+
+ // Add a new row without children to the end of the list.
+
+ info("adding new row to end of list");
+ let newRow = list.appendChild(createNewRow());
+ // Wait for mutation observer. It does nothing, but let's be sure.
+ await new Promise(r => content.setTimeout(r));
+ checkHasClass("new-row", false);
+ newRow.remove();
+ await new Promise(r => content.setTimeout(r));
+
+ // Add and remove a single row to rows with existing children.
+
+ for (let id of idsWithChildren) {
+ let row = doc.getElementById(id);
+
+ info(`adding new row to ${id}`);
+ newRow = row.querySelector("ul").appendChild(createNewRow());
+ // Wait for mutation observer. It does nothing, but let's be sure.
+ await new Promise(r => content.setTimeout(r));
+ checkHasClass("new-row", false);
+ checkHasClass(id, true);
+
+ info(`removing new row from ${id}`);
+ newRow.remove();
+ // Wait for mutation observer. It does nothing, but let's be sure.
+ await new Promise(r => content.setTimeout(r));
+ checkHasClass(id, true);
+
+ if (id == "row-3-1") {
+ checkHasClass("row-3", true);
+ }
+ }
+
+ // Add and remove a single row to rows without existing children.
+
+ for (let id of idsWithoutChildren) {
+ let row = doc.getElementById(id);
+ let childList = row.appendChild(doc.createElement("ul"));
+
+ info(`adding new row to ${id}`);
+ newRow = childList.appendChild(createNewRow());
+ // Wait for mutation observer.
+ await new Promise(r => content.setTimeout(r));
+ checkHasClass("new-row", false);
+ checkHasClass(id, true);
+
+ info(`removing new row from ${id}`);
+ newRow.remove();
+ // Wait for mutation observer.
+ await new Promise(r => content.setTimeout(r));
+ checkHasClass(id, false);
+
+ // This time remove the child list, not the row itself.
+
+ info(`adding new row to ${id} again`);
+ newRow = childList.appendChild(createNewRow());
+ // Wait for mutation observer.
+ await new Promise(r => content.setTimeout(r));
+ checkHasClass("new-row", false);
+ checkHasClass(id, true);
+
+ info(`removing child list from ${id}`);
+ childList.remove();
+ // Wait for mutation observer.
+ await new Promise(r => content.setTimeout(r));
+ checkHasClass(id, false);
+
+ if (["row-2-1", "row-2-2"].includes(id)) {
+ checkHasClass("row-2", true);
+ } else if (["row-3-1-1", "row-3-1-2"].includes(id)) {
+ checkHasClass("row-3-1", true);
+ checkHasClass("row-3", true);
+ }
+ }
+
+ // Add a row with children and a grandchild to the end of the list. The new
+ // row should be given the "children" class. The child with a grandchild
+ // should be given the "children" class. I think it's safe to assume this
+ // works no matter where in the tree it's added.
+
+ let template = doc.getElementById("rowsToAdd");
+ newRow = template.content.cloneNode(true).firstElementChild;
+ list.appendChild(newRow);
+ // Wait for mutation observer.
+ await new Promise(r => content.setTimeout(r));
+ checkHasClass("added-row", true);
+ checkHasClass("added-row-1", true);
+ checkHasClass("added-row-1-1", false);
+ checkHasClass("added-row-2", false);
+ newRow.remove();
+ await new Promise(r => content.setTimeout(r));
+
+ // Add a new row without children to the middle of the list. Selection should
+ // be maintained.
+
+ list.selectedIndex = 5; // row-3-1
+
+ info("adding new row to middle of list");
+ newRow = template.content.cloneNode(true).firstElementChild;
+ list.insertBefore(newRow, list.querySelector("#row-3"));
+ await new Promise(r => content.setTimeout(r));
+ Assert.equal(list.selectedIndex, 9, "row-3-1 is still selected");
+
+ newRow.remove();
+ await new Promise(r => content.setTimeout(r));
+ Assert.equal(list.selectedIndex, 5, "row-3-1 is still selected");
+
+ list.selectedIndex = 0;
+}
+
+/**
+ * Checks that expanding and collapsing works. Twisties in the test file are
+ * styled as coloured squares: red for collapsed, green for expanded.
+ *
+ * @note This is practically the same test as in browser_treeView.js,
+ * but for TreeListbox instead of TreeView. If you make changes here
+ * you may want to make changes there too.
+ */
+async function subtestExpandCollapse() {
+ let doc = content.document;
+ let list = doc.querySelector(`ul[is="tree-listbox"]`);
+ let allIds = [
+ "row-1",
+ "row-2",
+ "row-2-1",
+ "row-2-2",
+ "row-3",
+ "row-3-1",
+ "row-3-1-1",
+ "row-3-1-2",
+ ];
+ let idsWithoutChildren = [
+ "row-1",
+ "row-2-1",
+ "row-2-2",
+ "row-3-1-1",
+ "row-3-1-2",
+ ];
+
+ let listener = {
+ reset() {
+ this.collapsedRow = null;
+ this.expandedRow = null;
+ },
+ handleEvent(event) {
+ if (event.type == "collapsed") {
+ this.collapsedRow = event.target;
+ } else if (event.type == "expanded") {
+ this.expandedRow = event.target;
+ }
+ },
+ };
+ list.addEventListener("collapsed", listener);
+ list.addEventListener("expanded", listener);
+
+ let selectHandler = {
+ seenEvent: null,
+ selectedAtEvent: null,
+
+ reset() {
+ this.seenEvent = null;
+ this.selectedAtEvent = null;
+ },
+ handleEvent(event) {
+ this.seenEvent = event;
+ this.selectedAtEvent = list.selectedIndex;
+ },
+ };
+
+ Assert.equal(
+ list.querySelectorAll("collapsed").length,
+ 0,
+ "no rows are collapsed"
+ );
+ Assert.equal(list.rowCount, 8, "row count");
+ Assert.deepEqual(
+ list.rows.map(r => r.id),
+ [
+ "row-1",
+ "row-2",
+ "row-2-1",
+ "row-2-2",
+ "row-3",
+ "row-3-1",
+ "row-3-1-1",
+ "row-3-1-2",
+ ],
+ "rows property"
+ );
+
+ function checkSelected(expectedIndex, expectedId) {
+ Assert.equal(list.selectedIndex, expectedIndex, "selectedIndex is correct");
+ let selected = [...list.querySelectorAll(".selected")].map(row => row.id);
+ Assert.deepEqual(
+ selected,
+ [expectedId],
+ "correct rows have the 'selected' class"
+ );
+ }
+
+ checkSelected(0, "row-1");
+
+ // Click the twisties of rows without children.
+
+ function performChange(id, expectedChange, changeCallback) {
+ listener.reset();
+ let row = doc.getElementById(id);
+ let before = row.classList.contains("collapsed");
+
+ changeCallback(row);
+
+ if (expectedChange == "collapsed") {
+ Assert.ok(!before, `${id} was expanded`);
+ Assert.ok(row.classList.contains("collapsed"), `${id} collapsed`);
+ Assert.equal(listener.collapsedRow, row, `${id} fired 'collapse' event`);
+ Assert.ok(!listener.expandedRow, `${id} did not fire 'expand' event`);
+ } else if (expectedChange == "expanded") {
+ Assert.ok(before, `${id} was collapsed`);
+ Assert.ok(!row.classList.contains("collapsed"), `${id} expanded`);
+ Assert.ok(!listener.collapsedRow, `${id} did not fire 'collapse' event`);
+ Assert.equal(listener.expandedRow, row, `${id} fired 'expand' event`);
+ } else {
+ Assert.equal(
+ row.classList.contains("collapsed"),
+ before,
+ `${id} state did not change`
+ );
+ }
+ }
+
+ function clickTwisty(id, expectedChange) {
+ info(`clicking the twisty on ${id}`);
+ performChange(id, expectedChange, row =>
+ EventUtils.synthesizeMouseAtCenter(
+ row.querySelector(".twisty"),
+ {},
+ content
+ )
+ );
+ }
+
+ function doubleClick(id, expectedChange) {
+ info(`double clicking on ${id}`);
+ performChange(id, expectedChange, row =>
+ EventUtils.synthesizeMouseAtCenter(row, { clickCount: 2 }, content)
+ );
+ }
+
+ for (let id of idsWithoutChildren) {
+ clickTwisty(id, null);
+ Assert.equal(list.querySelector(".selected").id, id);
+ }
+
+ checkSelected(7, "row-3-1-2");
+
+ // Click the twisties of rows with children.
+
+ function checkRowsAreHidden(...hiddenIds) {
+ let remainingIds = allIds.slice();
+
+ for (let id of allIds) {
+ if (hiddenIds.includes(id)) {
+ Assert.equal(doc.getElementById(id).clientHeight, 0, `${id} is hidden`);
+ remainingIds.splice(remainingIds.indexOf(id), 1);
+ } else {
+ Assert.greater(
+ doc.getElementById(id).clientHeight,
+ 0,
+ `${id} is visible`
+ );
+ }
+ }
+
+ Assert.equal(list.rowCount, 8 - hiddenIds.length, "row count");
+ Assert.deepEqual(
+ list.rows.map(r => r.id),
+ remainingIds,
+ "rows property"
+ );
+ }
+
+ // Collapse row 2.
+
+ clickTwisty("row-2", "collapsed");
+ checkRowsAreHidden("row-2-1", "row-2-2");
+ checkSelected(5, "row-3-1-2");
+
+ // Collapse row 3.
+
+ clickTwisty("row-3", "collapsed");
+ checkRowsAreHidden("row-2-1", "row-2-2", "row-3-1", "row-3-1-1", "row-3-1-2");
+ checkSelected(2, "row-3");
+
+ // Expand row 2.
+
+ doubleClick("row-2", "expanded");
+ checkRowsAreHidden("row-3-1", "row-3-1-1", "row-3-1-2");
+ checkSelected(4, "row-3");
+
+ // Expand row 3.
+
+ doubleClick("row-3", "expanded");
+ checkRowsAreHidden();
+ checkSelected(4, "row-3");
+
+ // Collapse row 3-1.
+
+ clickTwisty("row-3-1", "collapsed");
+ checkRowsAreHidden("row-3-1-1", "row-3-1-2");
+ checkSelected(4, "row-3");
+
+ // Collapse row 3.
+
+ clickTwisty("row-3", "collapsed");
+ checkRowsAreHidden("row-3-1", "row-3-1-1", "row-3-1-2");
+ checkSelected(4, "row-3");
+
+ // Expand row 3.
+
+ clickTwisty("row-3", "expanded");
+ checkRowsAreHidden("row-3-1-1", "row-3-1-2");
+ checkSelected(4, "row-3");
+
+ // Expand row 3-1.
+
+ clickTwisty("row-3-1", "expanded");
+ checkRowsAreHidden();
+ checkSelected(4, "row-3");
+
+ // Test key presses.
+
+ function pressKey(id, key, expectedChange) {
+ info(`pressing ${key}`);
+ performChange(id, expectedChange, row => {
+ EventUtils.synthesizeKey(key, {}, content);
+ });
+ }
+
+ // Row 0 has no children or parent, nothing should happen.
+
+ list.selectedIndex = 0;
+ pressKey("row-1", "VK_LEFT");
+ checkSelected(0, "row-1");
+ pressKey("row-1", "VK_RIGHT");
+ checkSelected(0, "row-1");
+
+ // Collapse row 2.
+
+ list.selectedIndex = 1;
+ pressKey("row-2", "VK_LEFT", "collapsed");
+ checkRowsAreHidden("row-2-1", "row-2-2");
+ checkSelected(1, "row-2");
+
+ pressKey("row-2", "VK_LEFT");
+ checkRowsAreHidden("row-2-1", "row-2-2");
+ checkSelected(1, "row-2");
+
+ // Collapse row 3.
+
+ list.selectedIndex = 2;
+ pressKey("row-3", "VK_LEFT", "collapsed");
+ checkRowsAreHidden("row-2-1", "row-2-2", "row-3-1", "row-3-1-1", "row-3-1-2");
+ checkSelected(2, "row-3");
+
+ pressKey("row-3", "VK_LEFT");
+ checkRowsAreHidden("row-2-1", "row-2-2", "row-3-1", "row-3-1-1", "row-3-1-2");
+ checkSelected(2, "row-3");
+
+ // Expand row 2.
+
+ list.selectedIndex = 1;
+ pressKey("row-2", "VK_RIGHT", "expanded");
+ checkRowsAreHidden("row-3-1", "row-3-1-1", "row-3-1-2");
+ checkSelected(1, "row-2");
+
+ // Expand row 3.
+
+ list.selectedIndex = 4;
+ pressKey("row-3", "VK_RIGHT", "expanded");
+ checkRowsAreHidden();
+ checkSelected(4, "row-3");
+
+ // Go down the tree to row 3-1-1.
+
+ pressKey("row-3", "VK_RIGHT");
+ checkRowsAreHidden();
+ checkSelected(5, "row-3-1");
+
+ pressKey("row-3", "VK_RIGHT");
+ checkRowsAreHidden();
+ checkSelected(6, "row-3-1-1");
+
+ pressKey("row-3-1-1", "VK_RIGHT");
+ checkRowsAreHidden();
+ checkSelected(6, "row-3-1-1");
+
+ // Collapse row 3-1.
+
+ pressKey("row-3-1-1", "VK_LEFT");
+ checkRowsAreHidden();
+ checkSelected(5, "row-3-1");
+
+ pressKey("row-3-1", "VK_LEFT", "collapsed");
+ checkRowsAreHidden("row-3-1-1", "row-3-1-2");
+ checkSelected(5, "row-3-1");
+
+ // Collapse row 3.
+
+ pressKey("row-3-1", "VK_LEFT");
+ checkRowsAreHidden("row-3-1-1", "row-3-1-2");
+ checkSelected(4, "row-3");
+
+ pressKey("row-3", "VK_LEFT", "collapsed");
+ checkRowsAreHidden("row-3-1", "row-3-1-1", "row-3-1-2");
+ checkSelected(4, "row-3");
+
+ // Expand row 3.
+
+ pressKey("row-3", "VK_RIGHT", "expanded");
+ checkRowsAreHidden("row-3-1-1", "row-3-1-2");
+ checkSelected(4, "row-3");
+
+ pressKey("row-3", "VK_RIGHT");
+ checkRowsAreHidden("row-3-1-1", "row-3-1-2");
+ checkSelected(5, "row-3-1");
+
+ // Expand row 3-1.
+
+ pressKey("row-3-1", "VK_RIGHT", "expanded");
+ checkRowsAreHidden();
+ checkSelected(5, "row-3-1");
+
+ pressKey("row-3-1", "VK_RIGHT");
+ checkRowsAreHidden();
+ checkSelected(6, "row-3-1-1");
+
+ pressKey("row-3-1-1", "VK_RIGHT");
+ checkRowsAreHidden();
+ checkSelected(6, "row-3-1-1");
+
+ // Toggle expansion of row 3-1 with Enter key.
+
+ list.selectedIndex = 5;
+ pressKey("row-3-1", "KEY_Enter", "collapsed");
+ checkRowsAreHidden("row-3-1-1", "row-3-1-2");
+ checkSelected(5, "row-3-1");
+
+ pressKey("row-3-1", "KEY_Enter", "expanded");
+ checkRowsAreHidden();
+ checkSelected(5, "row-3-1");
+
+ // Same again, with a RTL tree.
+
+ info("switching to RTL");
+ doc.documentElement.dir = "rtl";
+
+ // Row 0 has no children or parent, nothing should happen.
+
+ list.selectedIndex = 0;
+ pressKey("row-1", "VK_RIGHT");
+ checkSelected(0, "row-1");
+ pressKey("row-1", "VK_LEFT");
+ checkSelected(0, "row-1");
+
+ // Collapse row 2.
+
+ list.selectedIndex = 1;
+ pressKey("row-2", "VK_RIGHT", "collapsed");
+ checkRowsAreHidden("row-2-1", "row-2-2");
+ checkSelected(1, "row-2");
+
+ pressKey("row-2", "VK_RIGHT");
+ checkRowsAreHidden("row-2-1", "row-2-2");
+ checkSelected(1, "row-2");
+
+ // Collapse row 3.
+
+ list.selectedIndex = 2;
+ pressKey("row-3", "VK_RIGHT", "collapsed");
+ checkRowsAreHidden("row-2-1", "row-2-2", "row-3-1", "row-3-1-1", "row-3-1-2");
+ checkSelected(2, "row-3");
+
+ pressKey("row-3", "VK_RIGHT");
+ checkRowsAreHidden("row-2-1", "row-2-2", "row-3-1", "row-3-1-1", "row-3-1-2");
+ checkSelected(2, "row-3");
+
+ // Expand row 2.
+
+ list.selectedIndex = 1;
+ pressKey("row-2", "VK_LEFT", "expanded");
+ checkRowsAreHidden("row-3-1", "row-3-1-1", "row-3-1-2");
+ checkSelected(1, "row-2");
+
+ // Expand row 3.
+
+ list.selectedIndex = 4;
+ pressKey("row-3", "VK_LEFT", "expanded");
+ checkRowsAreHidden();
+ checkSelected(4, "row-3");
+
+ // Go down the tree to row 3-1-1.
+
+ pressKey("row-3", "VK_LEFT");
+ checkRowsAreHidden();
+ checkSelected(5, "row-3-1");
+
+ pressKey("row-3", "VK_LEFT");
+ checkRowsAreHidden();
+ checkSelected(6, "row-3-1-1");
+
+ pressKey("row-3-1-1", "VK_LEFT");
+ checkRowsAreHidden();
+ checkSelected(6, "row-3-1-1");
+
+ // Collapse row 3-1.
+
+ pressKey("row-3-1-1", "VK_RIGHT");
+ checkRowsAreHidden();
+ checkSelected(5, "row-3-1");
+
+ pressKey("row-3-1", "VK_RIGHT", "collapsed");
+ checkRowsAreHidden("row-3-1-1", "row-3-1-2");
+ checkSelected(5, "row-3-1");
+
+ // Collapse row 3.
+
+ pressKey("row-3-1", "VK_RIGHT");
+ checkRowsAreHidden("row-3-1-1", "row-3-1-2");
+ checkSelected(4, "row-3");
+
+ pressKey("row-3", "VK_RIGHT", "collapsed");
+ checkRowsAreHidden("row-3-1", "row-3-1-1", "row-3-1-2");
+ checkSelected(4, "row-3");
+
+ // Expand row 3.
+
+ pressKey("row-3", "VK_LEFT", "expanded");
+ checkRowsAreHidden("row-3-1-1", "row-3-1-2");
+ checkSelected(4, "row-3");
+
+ pressKey("row-3", "VK_LEFT");
+ checkRowsAreHidden("row-3-1-1", "row-3-1-2");
+ checkSelected(5, "row-3-1");
+
+ // Expand row 3-1.
+
+ pressKey("row-3-1", "VK_LEFT", "expanded");
+ checkRowsAreHidden();
+ checkSelected(5, "row-3-1");
+
+ pressKey("row-3-1", "VK_LEFT");
+ checkRowsAreHidden();
+ checkSelected(6, "row-3-1-1");
+
+ pressKey("row-3-1-1", "VK_LEFT");
+ checkRowsAreHidden();
+ checkSelected(6, "row-3-1-1");
+
+ // Use the class methods for expanding and collapsing.
+
+ selectHandler.reset();
+ list.addEventListener("select", selectHandler);
+ listener.reset();
+
+ list.collapseRowAtIndex(6); // No children, no effect.
+ Assert.ok(!selectHandler.seenEvent, "'select' event did not fire");
+ Assert.ok(!listener.collapsedRow, "'collapsed' event did not fire");
+
+ list.expandRowAtIndex(6); // No children, no effect.
+ Assert.ok(!selectHandler.seenEvent, "'select' event did not fire");
+ Assert.ok(!listener.expandedRow, "'expanded' event did not fire");
+
+ list.collapseRowAtIndex(1); // Item with children that aren't selected.
+ Assert.ok(!selectHandler.seenEvent, "'select' event did not fire");
+ Assert.equal(
+ listener.collapsedRow.id,
+ "row-2",
+ "row-2 fired 'collapsed' event"
+ );
+ listener.reset();
+
+ list.expandRowAtIndex(1); // Item with children that aren't selected.
+ Assert.ok(!selectHandler.seenEvent, "'select' event did not fire");
+ Assert.equal(
+ listener.expandedRow.id,
+ "row-2",
+ "row-2 fired 'expanded' event"
+ );
+ listener.reset();
+
+ list.collapseRowAtIndex(5); // Item with children that are selected.
+ Assert.ok(selectHandler.seenEvent, "'select' event fired");
+ Assert.equal(
+ selectHandler.selectedAtEvent,
+ 5,
+ "selectedIndex was correct when 'select' event fired"
+ );
+ Assert.equal(
+ listener.collapsedRow.id,
+ "row-3-1",
+ "row-3-1 fired 'collapsed' event"
+ );
+ checkRowsAreHidden("row-3-1-1", "row-3-1-2");
+ checkSelected(5, "row-3-1");
+ selectHandler.reset();
+ listener.reset();
+
+ list.expandRowAtIndex(5); // Selected item with children.
+ Assert.ok(!selectHandler.seenEvent, "'select' event did not fire");
+ Assert.equal(
+ listener.expandedRow.id,
+ "row-3-1",
+ "row-3-1 fired 'expanded' event"
+ );
+ checkRowsAreHidden();
+ checkSelected(5, "row-3-1");
+ listener.reset();
+
+ list.selectedIndex = 7;
+ selectHandler.reset();
+
+ list.collapseRowAtIndex(4); // Item with grandchildren that are selected.
+ Assert.ok(selectHandler.seenEvent, "'select' event fired");
+ Assert.equal(
+ selectHandler.selectedAtEvent,
+ 4,
+ "selectedIndex was correct when 'select' event fired"
+ );
+ Assert.equal(
+ listener.collapsedRow.id,
+ "row-3",
+ "row-3 fired 'collapsed' event"
+ );
+ checkRowsAreHidden("row-3-1", "row-3-1-1", "row-3-1-2");
+ checkSelected(4, "row-3");
+ selectHandler.reset();
+ listener.reset();
+
+ list.expandRowAtIndex(4); // Selected item with grandchildren.
+ Assert.ok(!selectHandler.seenEvent, "'select' event did not fire");
+ Assert.equal(
+ listener.expandedRow.id,
+ "row-3",
+ "row-3 fired 'expanded' event"
+ );
+ checkRowsAreHidden();
+ checkSelected(4, "row-3");
+ listener.reset();
+
+ list.removeEventListener("collapsed", listener);
+ list.removeEventListener("expanded", listener);
+ list.removeEventListener("select", selectHandler);
+ doc.documentElement.dir = null;
+}
+
+/**
+ * Tests what happens to selection when a row is removed.
+ */
+async function subtestSelectOnRemoval1() {
+ let doc = content.document;
+ let list = doc.getElementById("deleteTree");
+
+ let selectPromise;
+ function promiseSelectEvent() {
+ selectPromise = new Promise(resolve =>
+ list.addEventListener(
+ "select",
+ () => resolve([list.selectedIndex, list.selectedRow?.id ?? null]),
+ {
+ once: true,
+ }
+ )
+ );
+ }
+ // dRow-1
+ // dRow-2
+ // dRow-2-1
+ // dRow-2-2
+ // dRow-3
+ // dRow-3-1
+ // dRow-3-1-1
+ // dRow-3-1-2
+ // dRow-3-1-3
+ // dRow-4
+ // dRow-4-1
+ // dRow-4-2
+ // dRow-4-3
+ // dRow-4-3-1
+ // dRow-4-3-2
+ // dRow-4-4
+ // dRow-5
+ // dRow-5-1
+ // dRow-6
+
+ // Delete a row that is selected, and not at the top level. Selection should
+ // move to the next row under the shared parent.
+
+ list.selectedIndex = 2;
+ Assert.equal(list.selectedRow.id, "dRow-2-1");
+
+ promiseSelectEvent();
+ list.querySelector("#dRow-2-1").remove();
+ Assert.deepEqual(
+ await selectPromise,
+ [2, "dRow-2-2"],
+ "selection moved to the next row"
+ );
+
+ // dRow-1
+ // dRow-2
+ // dRow-2-2
+ // dRow-3
+ // dRow-3-1
+ // dRow-3-1-1
+ // dRow-3-1-2
+ // dRow-3-1-3
+ // dRow-4
+ // dRow-4-1
+ // dRow-4-2
+ // dRow-4-3
+ // dRow-4-3-1
+ // dRow-4-3-2
+ // dRow-4-4
+ // dRow-5
+ // dRow-5-1
+ // dRow-6
+
+ // Delete a row that contains the selected, and at the top level. Selection
+ // should move to the next top-level row.
+
+ Assert.equal(list.selectedRow.id, "dRow-2-2");
+ promiseSelectEvent();
+ list.querySelector("#dRow-2").remove();
+ Assert.deepEqual(
+ await selectPromise,
+ [1, "dRow-3"],
+ "selection moved to the next row"
+ );
+
+ // dRow-1
+ // dRow-3
+ // dRow-3-1
+ // dRow-3-1-1
+ // dRow-3-1-2
+ // dRow-3-1-3
+ // dRow-4
+ // dRow-4-1
+ // dRow-4-2
+ // dRow-4-3
+ // dRow-4-3-1
+ // dRow-4-3-2
+ // dRow-4-4
+ // dRow-5
+ // dRow-5-1
+ // dRow-6
+
+ // Delete the first top-level row that is selected. Should select the first
+ // row.
+ list.selectedIndex = 0;
+ Assert.equal(list.selectedRow.id, "dRow-1");
+ promiseSelectEvent();
+ list.querySelector("#dRow-1").remove();
+ Assert.deepEqual(
+ await selectPromise,
+ [0, "dRow-3"],
+ "selection moved to the first row"
+ );
+
+ // dRow-3
+ // dRow-3-1
+ // dRow-3-1-1
+ // dRow-3-1-2
+ // dRow-3-1-3
+ // dRow-4
+ // dRow-4-1
+ // dRow-4-2
+ // dRow-4-3
+ // dRow-4-3-1
+ // dRow-4-3-2
+ // dRow-4-4
+ // dRow-5
+ // dRow-5-1
+ // dRow-6
+
+ // Delete the last top-level row that is selected. Should select the last row.
+ list.selectedIndex = 14;
+ Assert.equal(list.selectedRow.id, "dRow-6");
+ promiseSelectEvent();
+ list.querySelector("#dRow-6").remove();
+ Assert.deepEqual(
+ await selectPromise,
+ [13, "dRow-5-1"],
+ "selection moved to the new last row"
+ );
+
+ // dRow-3
+ // dRow-3-1
+ // dRow-3-1-1
+ // dRow-3-1-2
+ // dRow-3-1-3
+ // dRow-4
+ // dRow-4-1
+ // dRow-4-2
+ // dRow-4-3
+ // dRow-4-3-1
+ // dRow-4-3-2
+ // dRow-4-4
+ // dRow-5
+ // dRow-5-1
+
+ // Delete the last selected descendant should move selection to the new
+ // descendant child.
+ list.selectedIndex = 11;
+ Assert.equal(list.selectedRow.id, "dRow-4-4");
+ promiseSelectEvent();
+ list.querySelector("#dRow-4-4").remove();
+ Assert.deepEqual(
+ await selectPromise,
+ [10, "dRow-4-3-2"],
+ "selection moved to the new last row"
+ );
+
+ // dRow-3
+ // dRow-3-1
+ // dRow-3-1-1
+ // dRow-3-1-2
+ // dRow-3-1-3
+ // dRow-4
+ // dRow-4-1
+ // dRow-4-2
+ // dRow-4-3
+ // dRow-4-3-1
+ // dRow-4-3-2
+ // dRow-5
+ // dRow-5-1
+
+ // Delete the first selected child should move selection to the new first child.
+ list.selectedIndex = 6;
+ Assert.equal(list.selectedRow.id, "dRow-4-1");
+ promiseSelectEvent();
+ list.querySelector("#dRow-4-1").remove();
+ Assert.deepEqual(
+ await selectPromise,
+ [6, "dRow-4-2"],
+ "selection moved to the new first row"
+ );
+
+ // dRow-3
+ // dRow-3-1
+ // dRow-3-1-1
+ // dRow-3-1-2
+ // dRow-3-1-3
+ // dRow-4
+ // dRow-4-2
+ // dRow-4-3
+ // dRow-4-3-1
+ // dRow-4-3-2
+ // dRow-5
+ // dRow-5-1
+
+ // Delete a row that isn't selected. Nothing should happen.
+
+ list.selectedIndex = 2;
+ Assert.equal(list.selectedRow.id, "dRow-3-1-1");
+
+ list.querySelector("#dRow-3-1-2").remove();
+ await new Promise(resolve => content.setTimeout(resolve));
+ Assert.equal(list.selectedIndex, 2, "selection did not change");
+ Assert.equal(list.selectedRow.id, "dRow-3-1-1", "selection did not change");
+
+ // dRow-3
+ // dRow-3-1
+ // dRow-3-1-1
+ // dRow-3-1-3
+ // dRow-4
+ // dRow-4-2
+ // dRow-4-3
+ // dRow-4-3-1
+ // dRow-4-3-2
+ // dRow-5
+ // dRow-5-1
+
+ // Deleting the last row under a parent that contains the selection should
+ // select the parent.
+ list.selectedIndex = 2;
+ Assert.equal(list.selectedRow.id, "dRow-3-1-1");
+
+ promiseSelectEvent();
+ let rowToReplace = list.querySelector("#dRow-3-1");
+ rowToReplace.remove();
+ Assert.deepEqual(
+ await selectPromise,
+ [0, "dRow-3"],
+ "selection moved to the parent row"
+ );
+
+ // dRow-3
+ // dRow-4
+ // dRow-4-2
+ // dRow-4-3
+ // dRow-4-3-1
+ // dRow-4-3-2
+ // dRow-5
+ // dRow-5-1
+
+ // Deleting several rows under a parent, should select the parent row.
+ list.selectedIndex = 4;
+ Assert.equal(list.selectedRow.id, "dRow-4-3-1");
+ promiseSelectEvent();
+ list.querySelector("#dRow-4 ul").remove();
+ Assert.deepEqual(
+ await selectPromise,
+ [1, "dRow-4"],
+ "selection moved to the parent row"
+ );
+
+ // Delete the last remaining rows. The selected index should be -1.
+
+ promiseSelectEvent();
+ list.replaceChildren();
+ Assert.deepEqual(await selectPromise, [-1, null], "selection was cleared");
+
+ // Add back a row. One of the row's children was selected, this should be
+ // removed and the selection set to the top-level row.
+
+ promiseSelectEvent();
+ list.appendChild(rowToReplace);
+ Assert.deepEqual(
+ await selectPromise,
+ [0, "dRow-3-1"],
+ "selection set to the added row"
+ );
+ Assert.ok(list.querySelector("#dRow-3-1-1"), "child of the added row exists");
+ Assert.ok(
+ !list.querySelector("#dRow-3-1-1").classList.contains("selected"),
+ "child of the added row is not selected"
+ );
+}
+
+/**
+ * Tests what happens to selection when a row is removed.
+ */
+async function subtestSelectOnRemoval2() {
+ let doc = content.document;
+ let list = doc.querySelector(`ul[is="tree-listbox"]`);
+
+ let selectPromise;
+ function promiseSelectEvent() {
+ selectPromise = new Promise(resolve =>
+ list.addEventListener("select", () => resolve(list.selectedIndex), {
+ once: true,
+ })
+ );
+ }
+
+ // Delete row-3 containing the selection.
+
+ list.selectedIndex = 7; // row-3-1-2
+
+ promiseSelectEvent();
+ list.querySelector("#row-3").remove();
+ Assert.equal(await selectPromise, 3, "selection moved to the last row");
+
+ // Delete row-2. Selection should move to the only row.
+
+ promiseSelectEvent();
+ list.querySelector("#row-2").remove();
+ Assert.equal(
+ await selectPromise,
+ 0, // row-1
+ "selection moved to the last row"
+ );
+}
+
+/**
+ * Tests what happens to selection when elements above it are removed.
+ */
+async function subtestSelectOnRemoval3() {
+ let doc = content.document;
+ let list = doc.querySelector(`ul[is="tree-listbox"]`);
+
+ // Delete a row.
+
+ list.selectedIndex = 6; // row-3-1-1
+
+ list.querySelector("#row-2-1").remove();
+ await new Promise(r => content.setTimeout(r));
+ Assert.deepEqual(
+ list.rows.map(r => r.id),
+ ["row-1", "row-2", "row-2-2", "row-3", "row-3-1", "row-3-1-1", "row-3-1-2"]
+ );
+ Assert.equal(
+ list.selectedIndex,
+ 5, // row-3-1-1
+ "selection moved to the previous top-level row"
+ );
+
+ // Delete an element that isn't a row.
+
+ list.querySelector("#row-2 div").remove();
+ await new Promise(r => content.setTimeout(r));
+ Assert.deepEqual(
+ list.rows.map(r => r.id),
+ ["row-1", "row-2", "row-2-2", "row-3", "row-3-1", "row-3-1-1", "row-3-1-2"]
+ );
+ Assert.equal(
+ list.selectedIndex,
+ 5, // row-3-1-1
+ "selection moved to the previous top-level row"
+ );
+
+ // Delete an element that contains a row.
+
+ list.querySelector("#row-2 ul").remove();
+ await new Promise(r => content.setTimeout(r));
+ Assert.deepEqual(
+ list.rows.map(r => r.id),
+ ["row-1", "row-2", "row-3", "row-3-1", "row-3-1-1", "row-3-1-2"]
+ );
+ Assert.equal(
+ list.selectedIndex,
+ 4, // row-3-1-1
+ "selection moved to the previous top-level row"
+ );
+}
+
+/**
+ * Tests that rows marked as unselectable cannot be selected.
+ */
+async function subtestUnselectable() {
+ let doc = content.document;
+
+ let list = doc.querySelector(`ul#unselectableTree`);
+ Assert.ok(!!list, "the list exists");
+
+ let initialRowIds = [
+ "uRow-2-1",
+ "uRow-2-2",
+ "uRow-3-1",
+ "uRow-3-1-1",
+ "uRow-3-1-2",
+ ];
+ Assert.equal(list.rowCount, initialRowIds.length, "rowCount is correct");
+ Assert.deepEqual(
+ list.rows.map(r => r.id),
+ initialRowIds,
+ "initial rows are correct"
+ );
+
+ function checkSelected(expectedIndex, expectedId) {
+ Assert.equal(list.selectedIndex, expectedIndex, "selectedIndex is correct");
+ Assert.deepEqual(
+ Array.from(list.querySelectorAll(".selected"), row => row.id),
+ [expectedId],
+ "correct rows have the 'selected' class"
+ );
+ }
+
+ checkSelected(0, "uRow-2-1");
+
+ // Clicking unselectable rows should not change the selection.
+ EventUtils.synthesizeMouseAtCenter(
+ doc.querySelector("#uRow-1 > div"),
+ {},
+ content
+ );
+ checkSelected(0, "uRow-2-1");
+ EventUtils.synthesizeMouseAtCenter(
+ doc.querySelector("#uRow-2 > div"),
+ {},
+ content
+ );
+ checkSelected(0, "uRow-2-1");
+ EventUtils.synthesizeMouseAtCenter(
+ doc.querySelector("#uRow-3 > div"),
+ {},
+ content
+ );
+ checkSelected(0, "uRow-2-1");
+
+ // Unselectable rows should not be accessible by keyboard.
+ EventUtils.synthesizeKey("KEY_ArrowUp", {}, content);
+ checkSelected(0, "uRow-2-1");
+ EventUtils.synthesizeKey("KEY_ArrowDown", {}, content);
+ EventUtils.synthesizeKey("KEY_ArrowDown", {}, content);
+ checkSelected(2, "uRow-3-1");
+ EventUtils.synthesizeKey("KEY_Home", {}, content);
+ checkSelected(0, "uRow-2-1");
+ EventUtils.synthesizeKey("KEY_End", {}, content);
+ checkSelected(4, "uRow-3-1-2");
+ EventUtils.synthesizeKey("KEY_PageUp", {}, content);
+ checkSelected(0, "uRow-2-1");
+ EventUtils.synthesizeKey("KEY_PageDown", {}, content);
+ checkSelected(4, "uRow-3-1-2");
+
+ EventUtils.synthesizeKey("VK_LEFT", {}, content); // Move up to 3-1.
+ checkSelected(2, "uRow-3-1");
+ EventUtils.synthesizeKey("VK_LEFT", {}, content); // Collapse.
+ checkSelected(2, "uRow-3-1");
+ EventUtils.synthesizeKey("VK_LEFT", {}, content); // Try to move to 3.
+ checkSelected(2, "uRow-3-1");
+}
diff --git a/comm/mail/base/test/browser/browser_treeView.js b/comm/mail/base/test/browser/browser_treeView.js
new file mode 100644
index 0000000000..bf1eeda35a
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_treeView.js
@@ -0,0 +1,1941 @@
+/* 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/. */
+
+let tabmail = document.getElementById("tabmail");
+registerCleanupFunction(() => {
+ tabmail.closeOtherTabs(tabmail.tabInfo[0]);
+});
+
+// We wish to run several variants of each test with minor differences, based on
+// changes in the source file, in order to verify that certain variables don't
+// impact behavior.
+const TEST_VARIANTS = ["header", "no-header"];
+
+/**
+ * Run a given test in a new tab and script sandbox.
+ *
+ * @param {Function} test - The test function to run in the sandbox.
+ * @param {string} filenameFragment - The fragment of the filename representing
+ * the test variant to run.
+ * @param {object[]} sandboxArgs - Arguments to the sandbox spawner to pass to
+ * the test function.
+ */
+async function runTestInSandbox(test, filenameFragment, sandboxArgs = []) {
+ // Create a new tab with our custom content.
+ const tab = tabmail.openTab("contentTab", {
+ url: `chrome://mochitests/content/browser/comm/mail/base/test/browser/files/tree-element-test-${filenameFragment}.xhtml`,
+ });
+
+ await BrowserTestUtils.browserLoaded(tab.browser);
+ tab.browser.focus();
+
+ // Spawn a new JavaScript sandbox for the tab and run the test function inside
+ // of it.
+ await SpecialPowers.spawn(tab.browser, sandboxArgs, test);
+
+ tabmail.closeTab(tab);
+}
+
+/**
+ * Checks that interactions with the widget do as expected.
+ */
+add_task(async function testKeyboardAndMouse() {
+ for (const variant of TEST_VARIANTS) {
+ info(`Running keyboard and mouse test for ${variant}`);
+ await runTestInSandbox(subtestKeyboardAndMouse, variant, [variant]);
+ }
+});
+
+async function subtestKeyboardAndMouse(variant) {
+ let doc = content.document;
+
+ let list = doc.getElementById("testTree");
+ Assert.ok(!!list, "the list exists");
+
+ async function doListActionAndWaitForRowBuffer(actionFn) {
+ // Filling the row buffer is fiddly timing, so provide an event to indicate
+ // that actions which may trigger changes in the row buffer have finished.
+ const eventName = "_treerowbufferfill";
+ list._rowBufferReadyEvent = new content.CustomEvent(eventName);
+
+ const promise = new Promise(resolve =>
+ list.addEventListener(eventName, resolve, { once: true })
+ );
+
+ await actionFn();
+ await promise;
+
+ list._rowBufferReadyEvent = null;
+ }
+
+ async function scrollListToPosition(topOfScroll) {
+ await doListActionAndWaitForRowBuffer(() => {
+ list.scrollTo(0, topOfScroll);
+ });
+ }
+
+ Assert.equal(list._rowElementName, "test-row");
+ Assert.equal(list._rowElementClass, content.customElements.get("test-row"));
+ Assert.equal(
+ list._toleranceSize,
+ 26,
+ "list should have tolerance twice the number of visible rows"
+ );
+
+ // We should be scrolled to the top already, but this will ensure we get an
+ // event fire one way or another.
+ await scrollListToPosition(0);
+
+ let rows = list.querySelectorAll(`tr[is="test-row"]`);
+ // Count is calculated from the height of `list` divided by
+ // TestCardRow.ROW_HEIGHT, plus list._toleranceSize.
+ Assert.equal(rows.length, 13 + 26, "the list has the right number of rows");
+
+ Assert.equal(doc.activeElement, doc.body);
+
+ // Verify the tab order of list elements by tabbing both forward and backward
+ // through them.
+ EventUtils.synthesizeKey("VK_TAB", {}, content);
+ Assert.equal(
+ doc.activeElement.id,
+ "before",
+ "the element before the list should have focus"
+ );
+
+ if (variant == "header") {
+ // Tab order changes slightly if the table has a header versus if it
+ // doesn't, so we need to account for that variation.
+ EventUtils.synthesizeKey("VK_TAB", {}, content);
+ Assert.equal(
+ doc.activeElement.id,
+ "testColButton",
+ "the list header button should have focus"
+ );
+ }
+
+ EventUtils.synthesizeKey("VK_TAB", {}, content);
+ Assert.equal(doc.activeElement.id, "testBody", "the list should have focus");
+
+ EventUtils.synthesizeKey("VK_TAB", {}, content);
+ Assert.equal(
+ doc.activeElement.id,
+ "after",
+ "the element after the list should have focus"
+ );
+
+ EventUtils.synthesizeKey("VK_TAB", { shiftKey: true }, content);
+ Assert.equal(doc.activeElement.id, "testBody", "the list should have focus");
+
+ if (variant == "header") {
+ // Tab order changes slightly if the table has a header versus if it
+ // doesn't, so we need to account for that variation.
+ EventUtils.synthesizeKey("VK_TAB", { shiftKey: true }, content);
+ Assert.equal(
+ doc.activeElement.id,
+ "testColButton",
+ "the list header button should have focus"
+ );
+ }
+
+ EventUtils.synthesizeKey("VK_TAB", { shiftKey: true }, content);
+ Assert.equal(
+ doc.activeElement.id,
+ "before",
+ "the element before the list should have focus"
+ );
+
+ // Check initial selection.
+
+ let selectHandler = {
+ seenEvent: null,
+ currentAtEvent: null,
+ selectedAtEvent: null,
+ t0: Date.now(),
+ time: 0,
+
+ reset() {
+ this.seenEvent = null;
+ this.currentAtEvent = null;
+ this.selectedAtEvent = null;
+ this.t0 = Date.now();
+ },
+ handleEvent(event) {
+ this.seenEvent = event;
+ this.currentAtEvent = list.currentIndex;
+ this.selectedAtEvent = list.selectedIndices;
+ this.time = Date.now() - this.t0;
+ },
+ };
+
+ /**
+ * Check if the spacerTop TBODY of the TreeViewTable is properly allocating
+ * the height of non existing rows.
+ *
+ * @param {int} rows - The number of rows that the spacerTop should be
+ * simulating their height allocation.
+ */
+ function checkTopSpacerHeight(rows) {
+ let table = doc.querySelector(`[is="tree-view-table"]`);
+ // -26 to account for the tolerance buffer.
+ Assert.equal(
+ table.spacerTop.clientHeight,
+ list.getRowAtIndex(rows).clientHeight * (rows - 26),
+ "The top spacer has the correct height"
+ );
+ }
+
+ function checkCurrent(expectedIndex) {
+ Assert.equal(list.currentIndex, expectedIndex, "currentIndex is correct");
+ if (selectHandler.currentAtEvent !== null) {
+ Assert.equal(
+ selectHandler.currentAtEvent,
+ expectedIndex,
+ "currentIndex was correct at the last 'select' event"
+ );
+ }
+
+ let current = list.querySelectorAll(".current");
+ if (expectedIndex == -1) {
+ Assert.equal(current.length, 0, "no rows have the 'current' class");
+ } else {
+ Assert.equal(current.length, 1, "only one row has the 'current' class");
+ Assert.equal(
+ current[0].index,
+ expectedIndex,
+ "correct row has the 'current' class"
+ );
+ }
+ }
+
+ function checkSelected(...expectedIndices) {
+ Assert.deepEqual(
+ list.selectedIndices,
+ expectedIndices,
+ "selectedIndices are correct"
+ );
+
+ if (selectHandler.selectedAtEvent !== null) {
+ // Check the value was already set when the select event fired.
+ Assert.deepEqual(
+ selectHandler.selectedAtEvent,
+ expectedIndices,
+ "selectedIndices were correct at the last 'select' event"
+ );
+ }
+
+ let selected = [...list.querySelectorAll(".selected")].map(
+ row => row.index
+ );
+ expectedIndices.sort((a, b) => a - b);
+ Assert.deepEqual(
+ selected,
+ expectedIndices,
+ "correct rows have the 'selected' class"
+ );
+ }
+
+ checkCurrent(0);
+ checkSelected(0);
+
+ // Click on some individual rows.
+
+ const { TestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TestUtils.sys.mjs"
+ );
+ const { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+ );
+
+ async function clickOnRow(index, modifiers = {}, expectEvent = true) {
+ if (modifiers.shiftKey) {
+ info(`clicking on row ${index} with shift key`);
+ } else if (modifiers.accelKey) {
+ info(`clicking on row ${index} with ctrl key`);
+ } else {
+ info(`clicking on row ${index}`);
+ }
+
+ let x = list.clientWidth / 2;
+ let y = list.table.header.clientHeight + index * 50 + 25;
+
+ selectHandler.reset();
+ list.addEventListener("select", selectHandler, { once: true });
+ EventUtils.synthesizeMouse(list, x, y, modifiers, content);
+ await TestUtils.waitForCondition(
+ () => !!selectHandler.seenEvent == expectEvent,
+ `'select' event should ${expectEvent ? "" : "not "}get fired`
+ );
+ }
+
+ await clickOnRow(0, {}, false);
+ checkCurrent(0);
+ checkSelected(0);
+
+ await clickOnRow(1);
+ checkCurrent(1);
+ checkSelected(1);
+
+ await clickOnRow(2);
+ checkCurrent(2);
+ checkSelected(2);
+
+ // Select multiple rows by shift-clicking.
+
+ await clickOnRow(4, { shiftKey: true });
+ checkCurrent(4);
+ checkSelected(2, 3, 4);
+
+ // Holding ctrl and shift should always produce a range selection.
+ await clickOnRow(6, { accelKey: true, shiftKey: true });
+ checkCurrent(6);
+ checkSelected(2, 3, 4, 5, 6);
+
+ await clickOnRow(0, { shiftKey: true });
+ checkCurrent(0);
+ checkSelected(0, 1, 2);
+
+ await clickOnRow(2, { shiftKey: true });
+ checkCurrent(2);
+ checkSelected(2);
+
+ // Select multiple rows by ctrl-clicking.
+
+ await clickOnRow(5, { accelKey: true });
+ checkCurrent(5);
+ checkSelected(2, 5);
+
+ await clickOnRow(1, { accelKey: true });
+ checkCurrent(1);
+ checkSelected(1, 2, 5);
+
+ await clickOnRow(5, { accelKey: true });
+ checkCurrent(5);
+ checkSelected(1, 2);
+
+ await clickOnRow(1, { accelKey: true });
+ checkCurrent(1);
+ checkSelected(2);
+
+ await clickOnRow(2, { accelKey: true });
+ checkCurrent(2);
+ checkSelected();
+
+ // Move around by pressing keys.
+
+ async function pressKey(key, modifiers = {}, expectEvent = true) {
+ if (modifiers.shiftKey) {
+ info(`pressing ${key} with shift key`);
+ } else if (modifiers.accelKey) {
+ info(`pressing ${key} with accel key`);
+ } else {
+ info(`pressing ${key}`);
+ }
+
+ selectHandler.reset();
+ list.addEventListener("select", selectHandler, { once: true });
+ EventUtils.synthesizeKey(key, modifiers, content);
+ await TestUtils.waitForCondition(
+ () => !!selectHandler.seenEvent == expectEvent,
+ `'select' event should ${expectEvent ? "" : "not "}get fired`
+ );
+ // We don't enforce any delay on multiselection.
+ let multiselect =
+ (AppConstants.platform == "macosx" && key == " ") ||
+ modifiers.shiftKey ||
+ modifiers.accelKey;
+ if (expectEvent && !multiselect) {
+ // We have data-select-delay="250" in treeView.xhtml
+ Assert.greater(selectHandler.time, 240, "should select only after delay");
+ }
+ }
+
+ await pressKey("VK_UP");
+ checkCurrent(1);
+ checkSelected(1);
+
+ await pressKey("VK_UP", { accelKey: true }, false);
+ checkCurrent(0);
+ checkSelected(1);
+
+ // Without Ctrl selection moves with focus again.
+ await pressKey("VK_UP");
+ checkCurrent(0);
+ checkSelected(0);
+
+ // Does nothing.
+ await pressKey("VK_UP", {}, false);
+ checkCurrent(0);
+ checkSelected(0);
+
+ await pressKey("VK_DOWN", { accelKey: true }, false);
+ checkCurrent(1);
+ checkSelected(0);
+
+ await pressKey("VK_DOWN", { accelKey: true }, false);
+ checkCurrent(2);
+ checkSelected(0);
+
+ // Multi select with only Space on macOS on a focused row, since Cmd+Space is
+ // captured by the OS.
+ if (AppConstants.platform == "macosx") {
+ await pressKey(" ");
+ } else {
+ // Multi select with Ctrl+Space for Windows and Linux.
+ await pressKey(" ", { accelKey: true });
+ }
+ checkCurrent(2);
+ checkSelected(0, 2);
+
+ await pressKey("VK_DOWN", { accelKey: true }, false);
+ checkCurrent(3);
+ checkSelected(0, 2);
+
+ await pressKey("VK_DOWN", { accelKey: true }, false);
+ checkCurrent(4);
+ checkSelected(0, 2);
+
+ if (AppConstants.platform == "macosx") {
+ await pressKey(" ");
+ } else {
+ await pressKey(" ", { accelKey: true });
+ }
+ checkCurrent(4);
+ checkSelected(0, 2, 4);
+
+ // Single selection restored with normal navigation.
+ await pressKey("VK_UP");
+ checkCurrent(3);
+ checkSelected(3);
+
+ // We don't allow unselecting a selected row with Space on macOS due to
+ // conflict with the `mail.advance_on_spacebar` pref.
+ if (AppConstants.platform != "macosx") {
+ // Can select none using Ctrl+Space.
+ await pressKey(" ", { accelKey: true });
+ checkCurrent(3);
+ checkSelected();
+ }
+
+ await pressKey("VK_DOWN");
+ checkCurrent(4);
+ checkSelected(4);
+
+ await pressKey("VK_HOME", { accelKey: true }, false);
+ checkCurrent(0);
+ checkSelected(4);
+
+ if (AppConstants.platform == "macosx") {
+ // We can't clear the selection with only Space on macOS, simulate a Arrow
+ // Up to force clear selection and only select the top most row.
+ await pressKey("VK_UP");
+ } else {
+ // Select only the current item with Space (no modifier).
+ await pressKey(" ");
+ }
+ checkCurrent(0);
+ checkSelected(0);
+
+ // The list is 630px high, so rows 0-12 are fully or partly visible.
+
+ await doListActionAndWaitForRowBuffer(async () => {
+ await pressKey("VK_PAGE_DOWN");
+ });
+ checkCurrent(13);
+ checkSelected(13);
+ Assert.equal(
+ list.getFirstVisibleIndex(),
+ 1,
+ "should have scrolled down a page"
+ );
+
+ await doListActionAndWaitForRowBuffer(async () => {
+ await pressKey("VK_PAGE_UP", { shiftKey: true });
+ });
+ checkCurrent(0);
+ checkSelected(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13);
+ Assert.equal(
+ list.getFirstVisibleIndex(),
+ 0,
+ "should have scrolled up a page"
+ );
+
+ // Shrink shift selection.
+ await pressKey("VK_DOWN", { shiftKey: true });
+ checkCurrent(1);
+ checkSelected(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13);
+
+ await pressKey("VK_DOWN", { accelKey: true }, false);
+ checkCurrent(2);
+ checkSelected(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13);
+
+ await pressKey("VK_DOWN", { accelKey: true }, false);
+ checkCurrent(3);
+ checkSelected(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13);
+
+ if (AppConstants.platform == "macosx") {
+ // We don't allow unselecting a selected row with Space on macOS due to
+ // conflict with the `mail.advance_on_spacebar` pref, so simulate a
+ // CMD+click to unselect it.
+ await clickOnRow(3, { accelKey: true });
+ } else {
+ // Break the shift sequence by Ctrl+Space.
+ await pressKey(" ", { accelKey: true });
+ }
+ checkCurrent(3);
+ checkSelected(1, 2, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13);
+
+ await pressKey("VK_DOWN", { shiftKey: true });
+ checkCurrent(4);
+ checkSelected(3, 4);
+
+ // Reverse selection direction.
+ await pressKey("VK_HOME", { shiftKey: true });
+ checkCurrent(0);
+ checkSelected(0, 1, 2, 3);
+
+ // Now rows 138-149 are fully visible.
+
+ await doListActionAndWaitForRowBuffer(async () => {
+ await pressKey("VK_END");
+ });
+ checkCurrent(149);
+ checkSelected(149);
+ Assert.equal(
+ list.getFirstVisibleIndex(),
+ 137,
+ "should have scrolled to the end"
+ );
+ checkTopSpacerHeight(137);
+
+ // Does nothing.
+ await pressKey("VK_DOWN", {}, false);
+ checkCurrent(149);
+ checkSelected(149);
+ Assert.equal(
+ list.getFirstVisibleIndex(),
+ 137,
+ "should not have changed view"
+ );
+ checkTopSpacerHeight(137);
+
+ await doListActionAndWaitForRowBuffer(async () => {
+ await pressKey("VK_PAGE_UP");
+ });
+ checkCurrent(136);
+ checkSelected(136);
+ Assert.equal(
+ list.getFirstVisibleIndex(),
+ 136,
+ "should have scrolled up a page"
+ );
+
+ await doListActionAndWaitForRowBuffer(async () => {
+ await pressKey("VK_PAGE_DOWN", { shiftKey: true });
+ });
+ checkCurrent(149);
+ checkSelected(
+ 136,
+ 137,
+ 138,
+ 139,
+ 140,
+ 141,
+ 142,
+ 143,
+ 144,
+ 145,
+ 146,
+ 147,
+ 148,
+ 149
+ );
+ Assert.equal(
+ list.getFirstVisibleIndex(),
+ 137,
+ "should have scrolled down a page"
+ );
+ checkTopSpacerHeight(137);
+
+ await doListActionAndWaitForRowBuffer(async () => {
+ await pressKey("VK_HOME");
+ });
+ checkCurrent(0);
+ checkSelected(0);
+ Assert.equal(
+ list.getFirstVisibleIndex(),
+ 0,
+ "should have scrolled to the beginning"
+ );
+
+ // Scroll around. Which rows are current and selected should be remembered
+ // even if the row element itself disappears.
+
+ selectHandler.reset();
+ await scrollListToPosition(125);
+ checkCurrent(0);
+ checkSelected(0);
+ Assert.equal(
+ list.getFirstVisibleIndex(),
+ 2,
+ "getFirstVisibleIndex is correct"
+ );
+
+ await scrollListToPosition(2525);
+ Assert.equal(list.currentIndex, 0, "currentIndex is still set");
+ Assert.ok(
+ !list.querySelector(".current"),
+ "no visible rows have the 'current' class"
+ );
+ Assert.deepEqual(list.selectedIndices, [0], "selectedIndices are still set");
+ Assert.ok(
+ !list.querySelector(".selected"),
+ "no visible rows have the 'selected' class"
+ );
+ Assert.equal(
+ list.getFirstVisibleIndex(),
+ 50,
+ "getFirstVisibleIndex is correct"
+ );
+ Assert.ok(!selectHandler.seenEvent, "should not have fired 'select' event");
+ checkTopSpacerHeight(50);
+
+ await doListActionAndWaitForRowBuffer(async () => {
+ await pressKey("VK_DOWN");
+ });
+ checkCurrent(1);
+ checkSelected(1);
+ Assert.equal(
+ list.getFirstVisibleIndex(),
+ 1,
+ "should have scrolled so that the second row is in view"
+ );
+
+ selectHandler.reset();
+ await scrollListToPosition(0);
+ checkCurrent(1);
+ checkSelected(1);
+ Assert.equal(
+ list.getFirstVisibleIndex(),
+ 0,
+ "getFirstVisibleIndex is correct"
+ );
+ Assert.ok(
+ !selectHandler.seenEvent,
+ "'select' event did not fire as expected"
+ );
+
+ await pressKey("VK_UP");
+ checkCurrent(0);
+ checkSelected(0);
+ Assert.equal(
+ list.getFirstVisibleIndex(),
+ 0,
+ "should have scrolled so that the first row is in view"
+ );
+
+ // Some literal edge cases. Clicking on a partially visible row should
+ // scroll it into view.
+
+ // Calculate the visible area in order to verify that rows appear where they
+ // are intended to appear.
+ const listRect = list.getBoundingClientRect();
+ const headerHeight = list.table.header.clientHeight;
+
+ const visibleRect = new content.DOMRect(
+ listRect.x,
+ listRect.y + headerHeight,
+ listRect.width,
+ listRect.height - headerHeight
+ );
+
+ Assert.equal(visibleRect.height, 630, "the table body should be 630px tall");
+
+ rows = list.querySelectorAll(`tr[is="test-row"]`);
+ let bcr = rows[12].getBoundingClientRect();
+ Assert.less(
+ Math.round(bcr.top),
+ Math.round(visibleRect.bottom),
+ "top of row 12 is visible"
+ );
+ Assert.greater(
+ Math.round(bcr.bottom),
+ Math.round(visibleRect.bottom),
+ "bottom of row 12 is not visible"
+ );
+
+ await doListActionAndWaitForRowBuffer(async () => {
+ await clickOnRow(12);
+ });
+ rows = list.querySelectorAll(`tr[is="test-row"]`);
+ bcr = rows[12].getBoundingClientRect();
+ Assert.less(
+ Math.round(bcr.top),
+ Math.round(visibleRect.bottom),
+ "the top of row 12 should be visible"
+ );
+ Assert.equal(
+ Math.round(bcr.bottom),
+ Math.round(visibleRect.bottom),
+ "row 12 should be at the bottom of the visible area"
+ );
+
+ bcr = rows[0].getBoundingClientRect();
+ Assert.less(
+ Math.round(bcr.top),
+ Math.round(visibleRect.top),
+ "top of row 0 is not visible"
+ );
+ Assert.greater(
+ Math.round(bcr.bottom),
+ Math.round(visibleRect.top),
+ "bottom of row 0 is visible"
+ );
+
+ await doListActionAndWaitForRowBuffer(async () => {
+ await clickOnRow(0);
+ });
+ rows = list.querySelectorAll(`tr[is="test-row"]`);
+ bcr = rows[0].getBoundingClientRect();
+ Assert.equal(
+ Math.round(bcr.top),
+ Math.round(visibleRect.top),
+ "row 0 should be at the top of the visible area"
+ );
+ Assert.greater(
+ Math.round(bcr.bottom),
+ Math.round(visibleRect.top),
+ "the bottom of row 0 should be visible"
+ );
+}
+
+/**
+ * Checks that changes in the view are propagated to the list.
+ */
+add_task(async function testRowCountChange() {
+ for (const variant of TEST_VARIANTS) {
+ info(`Running row count change test for ${variant}`);
+ await runTestInSandbox(subtestRowCountChange, variant);
+ }
+});
+
+async function subtestRowCountChange() {
+ let doc = content.document;
+
+ let ROW_HEIGHT = 50;
+ let list = doc.getElementById("testTree");
+
+ async function doListActionAndWaitForRowBuffer(actionFn) {
+ // Filling the row buffer is fiddly timing, so provide an event to indicate
+ // that actions which may trigger changes in the row buffer have finished.
+ const eventName = "_treerowbufferfill";
+ list._rowBufferReadyEvent = new content.CustomEvent(eventName);
+
+ const promise = new Promise(resolve =>
+ list.addEventListener(eventName, resolve, { once: true })
+ );
+
+ await actionFn();
+ await promise;
+
+ list._rowBufferReadyEvent = null;
+ }
+
+ let view = list.view;
+ let rows;
+
+ // Check the initial state.
+
+ function checkRows(first, last) {
+ let expectedIndices = [];
+ for (let i = first; i <= last; i++) {
+ expectedIndices.push(i);
+ }
+ rows = list.querySelectorAll(`tr[is="test-row"]`);
+ Assert.deepEqual(
+ Array.from(rows, r => r.index),
+ expectedIndices,
+ "the list has the right rows"
+ );
+ Assert.deepEqual(
+ Array.from(rows, r => r.dataset.value),
+ view.values.slice(first, last + 1),
+ "the list has the right rows"
+ );
+ }
+
+ function checkSelected(indices, existingIndices) {
+ Assert.deepEqual(list.selectedIndices, indices);
+ let selectedRows = list.querySelectorAll(`tr[is="test-row"].selected`);
+ Assert.deepEqual(
+ Array.from(selectedRows, r => r.index),
+ existingIndices
+ );
+ }
+
+ let expectedCount = 150;
+
+ // Select every tenth row. We'll check what is selected remains selected.
+
+ list.selectedIndices = [4, 14, 24, 34, 44];
+
+ function getRowsHeight() {
+ return list.scrollHeight - list.table.header.clientHeight;
+ }
+
+ async function addValues(index, values) {
+ view.values.splice(index, 0, ...values);
+ info(`Added ${values.join(", ")} at ${index}`);
+ info(view.values);
+
+ expectedCount += values.length;
+ Assert.equal(
+ view.rowCount,
+ expectedCount,
+ "the view has the right number of rows"
+ );
+
+ await doListActionAndWaitForRowBuffer(() => {
+ list.rowCountChanged(index, values.length);
+ });
+
+ Assert.equal(
+ getRowsHeight(),
+ expectedCount * ROW_HEIGHT,
+ "space for all rows is allocated"
+ );
+ }
+
+ async function removeValues(index, count, expectedRemoved) {
+ let values = view.values.splice(index, count);
+ info(`Removed ${values.join(", ")} from ${index}`);
+ info(view.values);
+
+ Assert.deepEqual(values, expectedRemoved);
+
+ expectedCount -= values.length;
+ Assert.equal(
+ view.rowCount,
+ expectedCount,
+ "the view has the right number of rows"
+ );
+
+ await doListActionAndWaitForRowBuffer(() => {
+ list.rowCountChanged(index, -count);
+ });
+
+ Assert.equal(
+ getRowsHeight(),
+ expectedCount * ROW_HEIGHT,
+ "space for all rows is allocated"
+ );
+ }
+
+ async function scrollListToPosition(topOfScroll) {
+ await doListActionAndWaitForRowBuffer(() => {
+ list.scrollTo(0, topOfScroll);
+ });
+ }
+
+ Assert.equal(
+ view.rowCount,
+ expectedCount,
+ "the view has the right number of rows"
+ );
+ Assert.equal(list.scrollTop, 0, "the list is scrolled to the top");
+ Assert.equal(
+ getRowsHeight(),
+ expectedCount * ROW_HEIGHT,
+ "space for all rows is allocated"
+ );
+
+ // We should be scrolled to the top already, but this will ensure we get an
+ // event fire one way or another.
+ await scrollListToPosition(0);
+
+ checkRows(0, 38);
+ checkSelected([4, 14, 24, 34, 44], [4, 14, 24, 34]);
+ Assert.equal(getRowsHeight(), 150 * 50);
+
+ // Add a value at the end. Only the scroll height should change.
+
+ await addValues(150, [150]);
+ checkRows(0, 38);
+ checkSelected([4, 14, 24, 34, 44], [4, 14, 24, 34]);
+ Assert.equal(getRowsHeight(), 151 * 50);
+
+ // Add more values at the end. Only the scroll height should change.
+
+ await addValues(151, [151, 152, 153]);
+ checkRows(0, 38);
+ checkSelected([4, 14, 24, 34, 44], [4, 14, 24, 34]);
+ Assert.equal(getRowsHeight(), 154 * 50);
+
+ // Add values between the last row and the end.
+ // Only the scroll height should change.
+
+ await addValues(40, ["39a", "39b"]);
+ checkRows(0, 38);
+ checkSelected([4, 14, 24, 34, 46], [4, 14, 24, 34]);
+ Assert.equal(getRowsHeight(), 156 * 50);
+
+ // Add values between the last visible row and the last row.
+ // The changed rows and those below them should be updated.
+
+ await addValues(18, ["17a", "17b", "17c"]);
+ checkRows(0, 38);
+ // Hard-coded sanity checks to prove checkRows is working as intended.
+ Assert.equal(rows[17].dataset.value, "17");
+ Assert.equal(rows[18].dataset.value, "17a");
+ Assert.equal(rows[19].dataset.value, "17b");
+ Assert.equal(rows[20].dataset.value, "17c");
+ Assert.equal(rows[21].dataset.value, "18");
+ checkSelected([4, 14, 27, 37, 49], [4, 14, 27, 37]);
+ Assert.equal(getRowsHeight(), 159 * 50);
+
+ // Add values in the visible rows.
+ // The changed rows and those below them should be updated.
+
+ await addValues(8, ["7a", "7b"]);
+ checkRows(0, 38);
+ Assert.equal(rows[7].dataset.value, "7");
+ Assert.equal(rows[8].dataset.value, "7a");
+ Assert.equal(rows[9].dataset.value, "7b");
+ Assert.equal(rows[10].dataset.value, "8");
+ Assert.equal(rows[22].dataset.value, "17c");
+ checkSelected([4, 16, 29, 39, 51], [4, 16, 29]);
+ Assert.equal(getRowsHeight(), 161 * 50);
+
+ // Add a value at the start. All rows should be updated.
+
+ await addValues(0, [-1]);
+ checkRows(0, 38);
+ Assert.equal(rows[0].dataset.value, "-1");
+ Assert.equal(rows[1].dataset.value, "0");
+ Assert.equal(rows[22].dataset.value, "17b");
+ checkSelected([5, 17, 30, 40, 52], [5, 17, 30]);
+ Assert.equal(getRowsHeight(), 162 * 50);
+
+ // Add more values at the start. All rows should be updated.
+
+ await addValues(0, [-3, -2]);
+ checkRows(0, 38);
+ Assert.equal(rows[0].dataset.value, "-3");
+ Assert.equal(rows[1].dataset.value, "-2");
+ Assert.equal(rows[2].dataset.value, "-1");
+ Assert.equal(rows[22].dataset.value, "17");
+ checkSelected([7, 19, 32, 42, 54], [7, 19, 32]);
+ Assert.equal(getRowsHeight(), 164 * 50);
+
+ Assert.equal(list.scrollTop, 0, "the list is still scrolled to the top");
+
+ // Remove values in the order we added them.
+
+ await removeValues(160, 1, [150]);
+ checkRows(0, 38);
+ Assert.equal(rows[0].dataset.value, "-3");
+ Assert.equal(rows[22].dataset.value, "17");
+ checkSelected([7, 19, 32, 42, 54], [7, 19, 32]);
+ Assert.equal(getRowsHeight(), 163 * 50);
+
+ await removeValues(160, 3, [151, 152, 153]);
+ checkRows(0, 38);
+ Assert.equal(rows[0].dataset.value, "-3");
+ Assert.equal(rows[22].dataset.value, "17");
+ checkSelected([7, 19, 32, 42, 54], [7, 19, 32]);
+ Assert.equal(getRowsHeight(), 160 * 50);
+
+ await removeValues(48, 2, ["39a", "39b"]);
+ checkRows(0, 38);
+ Assert.equal(rows[0].dataset.value, "-3");
+ Assert.equal(rows[22].dataset.value, "17");
+ checkSelected([7, 19, 32, 42, 52], [7, 19, 32]);
+ Assert.equal(getRowsHeight(), 158 * 50);
+
+ await removeValues(23, 3, ["17a", "17b", "17c"]);
+ checkRows(0, 38);
+ Assert.equal(rows[0].dataset.value, "-3");
+ Assert.equal(rows[22].dataset.value, "17");
+ checkSelected([7, 19, 29, 39, 49], [7, 19, 29]);
+ Assert.equal(getRowsHeight(), 155 * 50);
+
+ await removeValues(11, 2, ["7a", "7b"]);
+ checkRows(0, 38);
+ Assert.equal(rows[0].dataset.value, "-3");
+ Assert.equal(rows[10].dataset.value, "7");
+ Assert.equal(rows[11].dataset.value, "8");
+ Assert.equal(rows[22].dataset.value, "19");
+ checkSelected([7, 17, 27, 37, 47], [7, 17, 27, 37]);
+ Assert.equal(getRowsHeight(), 153 * 50);
+
+ await removeValues(2, 1, [-1]);
+ checkRows(0, 38);
+ Assert.equal(rows[0].dataset.value, "-3");
+ Assert.equal(rows[1].dataset.value, "-2");
+ Assert.equal(rows[2].dataset.value, "0");
+ Assert.equal(rows[22].dataset.value, "20");
+ checkSelected([6, 16, 26, 36, 46], [6, 16, 26, 36]);
+ Assert.equal(getRowsHeight(), 152 * 50);
+
+ await removeValues(0, 2, [-3, -2]);
+ checkRows(0, 38);
+ Assert.equal(rows[0].dataset.value, "0");
+ Assert.equal(rows[1].dataset.value, "1");
+ Assert.equal(rows[22].dataset.value, "22");
+ checkSelected([4, 14, 24, 34, 44], [4, 14, 24, 34]);
+ Assert.equal(getRowsHeight(), 150 * 50);
+
+ Assert.equal(list.scrollTop, 0, "the list is still scrolled to the top");
+
+ // Now scroll to the middle and repeat.
+
+ await scrollListToPosition(1735);
+ checkRows(8, 73);
+ Assert.equal(rows[0].dataset.value, "8");
+ Assert.equal(rows[65].dataset.value, "73");
+ checkSelected([4, 14, 24, 34, 44], [14, 24, 34, 44]);
+
+ await addValues(150, [150]);
+ checkRows(8, 73);
+ Assert.equal(rows[0].dataset.value, "8");
+ Assert.equal(rows[65].dataset.value, "73");
+ checkSelected([4, 14, 24, 34, 44], [14, 24, 34, 44]);
+
+ await addValues(38, ["37a"]);
+ checkRows(8, 73);
+ Assert.equal(rows[0].dataset.value, "8");
+ Assert.equal(rows[29].dataset.value, "37");
+ Assert.equal(rows[30].dataset.value, "37a");
+ Assert.equal(rows[31].dataset.value, "38");
+ Assert.equal(rows[65].dataset.value, "72");
+ checkSelected([4, 14, 24, 34, 45], [14, 24, 34, 45]);
+
+ await addValues(25, ["24a"]);
+ checkRows(8, 73);
+ Assert.equal(rows[0].dataset.value, "8");
+ Assert.equal(rows[16].dataset.value, "24");
+ Assert.equal(rows[17].dataset.value, "24a");
+ Assert.equal(rows[18].dataset.value, "25");
+ Assert.equal(rows[65].dataset.value, "71");
+ checkSelected([4, 14, 24, 35, 46], [14, 24, 35, 46]);
+
+ await addValues(11, ["10a"]);
+ checkRows(8, 73);
+ Assert.equal(rows[0].dataset.value, "8");
+ Assert.equal(rows[2].dataset.value, "10");
+ Assert.equal(rows[3].dataset.value, "10a");
+ Assert.equal(rows[4].dataset.value, "11");
+ Assert.equal(rows[65].dataset.value, "70");
+ checkSelected([4, 15, 25, 36, 47], [15, 25, 36, 47]);
+
+ await addValues(0, ["-1"]);
+ checkRows(8, 73);
+ Assert.equal(rows[0].dataset.value, "7");
+ Assert.equal(rows[65].dataset.value, "69");
+ checkSelected([5, 16, 26, 37, 48], [16, 26, 37, 48]);
+
+ Assert.equal(
+ list.scrollTop,
+ 1735,
+ "the list is still scrolled to the middle"
+ );
+
+ await removeValues(154, 1, [150]);
+ checkRows(8, 73);
+ Assert.equal(rows[0].dataset.value, "7");
+ Assert.equal(rows[65].dataset.value, "69");
+ checkSelected([5, 16, 26, 37, 48], [16, 26, 37, 48]);
+
+ await removeValues(41, 1, ["37a"]);
+ checkRows(8, 73);
+ Assert.equal(rows[0].dataset.value, "7");
+ Assert.equal(rows[65].dataset.value, "70");
+ checkSelected([5, 16, 26, 37, 47], [16, 26, 37, 47]);
+
+ await removeValues(27, 1, ["24a"]);
+ checkRows(8, 73);
+ Assert.equal(rows[0].dataset.value, "7");
+ Assert.equal(rows[65].dataset.value, "71");
+ checkSelected([5, 16, 26, 36, 46], [16, 26, 36, 46]);
+
+ await removeValues(12, 1, ["10a"]);
+ checkRows(8, 73);
+ Assert.equal(rows[0].dataset.value, "7");
+ Assert.equal(rows[65].dataset.value, "72");
+ checkSelected([5, 15, 25, 35, 45], [15, 25, 35, 45]);
+
+ await removeValues(0, 1, ["-1"]);
+ checkRows(8, 73);
+ Assert.equal(rows[0].dataset.value, "8");
+ Assert.equal(rows[65].dataset.value, "73");
+ checkSelected([4, 14, 24, 34, 44], [14, 24, 34, 44]);
+
+ Assert.equal(
+ list.scrollTop,
+ 1735,
+ "the list is still scrolled to the middle"
+ );
+
+ // Now scroll to the bottom and repeat.
+
+ await scrollListToPosition(6870);
+ checkRows(111, 149);
+ Assert.equal(rows[0].dataset.value, "111");
+ Assert.equal(rows[38].dataset.value, "149");
+ checkSelected([4, 14, 24, 34, 44], []);
+
+ await addValues(50, [50]);
+ checkRows(111, 150);
+ Assert.equal(rows[0].dataset.value, "110");
+ Assert.equal(rows[39].dataset.value, "149");
+ checkSelected([4, 14, 24, 34, 44], []);
+
+ await addValues(49, ["48a"]);
+ checkRows(111, 151);
+ Assert.equal(rows[0].dataset.value, "109");
+ Assert.equal(rows[40].dataset.value, "149");
+ checkSelected([4, 14, 24, 34, 44], []);
+
+ await addValues(30, ["29a"]);
+ checkRows(111, 152);
+ Assert.equal(rows[0].dataset.value, "108");
+ Assert.equal(rows[41].dataset.value, "149");
+ checkSelected([4, 14, 24, 35, 45], []);
+
+ await addValues(0, ["-1"]);
+ checkRows(111, 153);
+ Assert.equal(rows[0].dataset.value, "107");
+ Assert.equal(rows[42].dataset.value, "149");
+ checkSelected([5, 15, 25, 36, 46], []);
+
+ Assert.equal(
+ list.scrollTop,
+ 6870,
+ "the list is still scrolled to the bottom"
+ );
+
+ await removeValues(53, 1, [50]);
+ checkRows(111, 152);
+ Assert.equal(rows[0].dataset.value, "108");
+ Assert.equal(rows[41].dataset.value, "149");
+ checkSelected([5, 15, 25, 36, 46], []);
+
+ await removeValues(51, 1, ["48a"]);
+ checkRows(111, 151);
+ Assert.equal(rows[0].dataset.value, "109");
+ Assert.equal(rows[40].dataset.value, "149");
+ checkSelected([5, 15, 25, 36, 46], []);
+
+ await removeValues(31, 1, ["29a"]);
+ checkRows(111, 150);
+ Assert.equal(rows[0].dataset.value, "110");
+ Assert.equal(rows[39].dataset.value, "149");
+ checkSelected([5, 15, 25, 35, 45], []);
+
+ await removeValues(0, 1, ["-1"]);
+ checkRows(111, 149);
+ Assert.equal(rows[0].dataset.value, "111");
+ Assert.equal(rows[38].dataset.value, "149");
+ checkSelected([4, 14, 24, 34, 44], []);
+
+ Assert.equal(
+ list.scrollTop,
+ 6870,
+ "the list is still scrolled to the bottom"
+ );
+
+ // Remove a selected row and check the selection changes.
+
+ await scrollListToPosition(0);
+
+ checkSelected([4, 14, 24, 34, 44], [4, 14, 24, 34]);
+
+ await removeValues(3, 3, [3, 4, 5]); // 4 is selected.
+ checkSelected([11, 21, 31, 41], [11, 21, 31]);
+
+ await addValues(3, [3, 4, 5]);
+ checkSelected([14, 24, 34, 44], [14, 24, 34]);
+
+ // Remove some consecutive selected rows.
+
+ list.selectedIndices = [6, 7, 8, 9];
+ checkSelected([6, 7, 8, 9], [6, 7, 8, 9]);
+
+ await removeValues(7, 1, [7]);
+ checkSelected([6, 7, 8], [6, 7, 8]);
+
+ await removeValues(7, 1, [8]);
+ checkSelected([6, 7], [6, 7]);
+
+ await removeValues(7, 1, [9]);
+ checkSelected([6], [6]);
+
+ // Reset the list.
+
+ await addValues(7, [7, 8, 9]);
+ list.selectedIndex = -1;
+}
+
+/**
+ * Checks that expanding and collapsing works. Twisties in the test file are
+ * styled as coloured squares: red for collapsed, green for expanded.
+ *
+ * @note This is practically the same test as in browser_treeListbox.js, but
+ * for TreeView instead of TreeListbox. If you make changes here you
+ * may want to make changes there too.
+ */
+add_task(async function testExpandCollapse() {
+ await runTestInSandbox(subtestExpandCollapse, "levels");
+});
+
+async function subtestExpandCollapse() {
+ let doc = content.document;
+ let list = doc.getElementById("testTree");
+ let allIds = [
+ "row-1",
+ "row-2",
+ "row-2-1",
+ "row-2-2",
+ "row-3",
+ "row-3-1",
+ "row-3-1-1",
+ "row-3-1-2",
+ ];
+ let idsWithoutChildren = [
+ "row-1",
+ "row-2-1",
+ "row-2-2",
+ "row-3-1-1",
+ "row-3-1-2",
+ ];
+
+ let listener = {
+ reset() {
+ this.collapsedIndex = null;
+ this.expandedIndex = null;
+ },
+ handleEvent(event) {
+ if (event.type == "collapsed") {
+ this.collapsedIndex = event.detail;
+ } else if (event.type == "expanded") {
+ this.expandedIndex = event.detail;
+ }
+ },
+ };
+ list.addEventListener("collapsed", listener);
+ list.addEventListener("expanded", listener);
+
+ let selectHandler = {
+ seenEvent: null,
+ selectedAtEvent: null,
+
+ reset() {
+ this.seenEvent = null;
+ this.selectedAtEvent = null;
+ },
+ handleEvent(event) {
+ this.seenEvent = event;
+ this.selectedAtEvent = list.selectedIndex;
+ },
+ };
+
+ Assert.equal(
+ list.querySelectorAll("collapsed").length,
+ 0,
+ "no rows are collapsed"
+ );
+ Assert.equal(list.view.rowCount, 8, "row count");
+ Assert.deepEqual(
+ Array.from(list.table.body.children, r => r.id),
+ [
+ "row-1",
+ "row-2",
+ "row-2-1",
+ "row-2-2",
+ "row-3",
+ "row-3-1",
+ "row-3-1-1",
+ "row-3-1-2",
+ ],
+ "rows property"
+ );
+
+ function checkCurrent(expectedIndex) {
+ Assert.equal(list.currentIndex, expectedIndex, "currentIndex is correct");
+ const current = list.querySelectorAll(".current");
+ if (expectedIndex == -1) {
+ Assert.equal(current.length, 0, "no rows have the 'current' class");
+ } else {
+ Assert.equal(current.length, 1, "only one row has the 'current' class");
+ Assert.equal(
+ current[0].index,
+ expectedIndex,
+ "correct row has the 'current' class"
+ );
+ }
+ }
+
+ function checkMultiSelect(...expectedIds) {
+ let selected = [...list.querySelectorAll(".selected")].map(row => row.id);
+ Assert.deepEqual(selected, expectedIds, "selection should be correct");
+ }
+
+ function checkSelectedAndCurrent(expectedIndex, expectedId) {
+ Assert.equal(list.selectedIndex, expectedIndex, "selectedIndex is correct");
+ let selected = [...list.querySelectorAll(".selected")].map(row => row.id);
+ Assert.deepEqual(
+ selected,
+ [expectedId],
+ "correct rows have the 'selected' class"
+ );
+ checkCurrent(expectedIndex);
+ }
+
+ list.selectedIndex = 0;
+ checkSelectedAndCurrent(0, "row-1");
+
+ // Click the twisties of rows without children.
+
+ function performChange(id, expectedChange, changeCallback) {
+ listener.reset();
+ let row = doc.getElementById(id);
+ let before = row.classList.contains("collapsed");
+
+ changeCallback(row);
+
+ row = doc.getElementById(id);
+ if (expectedChange == "collapsed") {
+ Assert.ok(!before, `${id} was expanded`);
+ Assert.ok(row.classList.contains("collapsed"), `${id} collapsed`);
+ Assert.notEqual(
+ listener.collapsedIndex,
+ null,
+ `${id} fired 'collapse' event`
+ );
+ Assert.ok(!listener.expandedIndex, `${id} did not fire 'expand' event`);
+ } else if (expectedChange == "expanded") {
+ Assert.ok(before, `${id} was collapsed`);
+ Assert.ok(!row.classList.contains("collapsed"), `${id} expanded`);
+ Assert.ok(
+ !listener.collapsedIndex,
+ `${id} did not fire 'collapse' event`
+ );
+ Assert.notEqual(
+ listener.expandedIndex,
+ null,
+ `${id} fired 'expand' event`
+ );
+ } else {
+ Assert.equal(
+ row.classList.contains("collapsed"),
+ before,
+ `${id} state did not change`
+ );
+ }
+ }
+
+ function clickTwisty(id, expectedChange) {
+ info(`clicking the twisty on ${id}`);
+ performChange(id, expectedChange, row =>
+ EventUtils.synthesizeMouseAtCenter(
+ row.querySelector(".twisty"),
+ {},
+ content
+ )
+ );
+ }
+
+ function clickThread(id, expectedChange) {
+ info(`clicking the thread on ${id}`);
+ performChange(id, expectedChange, row => {
+ EventUtils.synthesizeMouseAtCenter(
+ row.querySelector(".tree-button-thread"),
+ {},
+ content
+ );
+ });
+ }
+
+ for (let id of idsWithoutChildren) {
+ clickTwisty(id, null);
+ Assert.equal(list.querySelector(".selected").id, id);
+ }
+
+ checkSelectedAndCurrent(7, "row-3-1-2");
+
+ // Click the twisties of rows with children.
+
+ function checkRowsAreHidden(...hiddenIds) {
+ let remainingIds = allIds.slice();
+
+ for (let id of allIds) {
+ if (hiddenIds.includes(id)) {
+ Assert.ok(!doc.getElementById(id), `${id} is hidden`);
+ remainingIds.splice(remainingIds.indexOf(id), 1);
+ } else {
+ Assert.greater(
+ doc.getElementById(id).clientHeight,
+ 0,
+ `${id} is visible`
+ );
+ }
+ }
+
+ Assert.equal(list.view.rowCount, 8 - hiddenIds.length, "row count");
+ Assert.deepEqual(
+ Array.from(list.table.body.children, r => r.id),
+ remainingIds,
+ "rows property"
+ );
+ }
+
+ // Collapse row 2.
+
+ clickTwisty("row-2", "collapsed");
+ checkRowsAreHidden("row-2-1", "row-2-2");
+ checkSelectedAndCurrent(5, "row-3-1-2");
+
+ // Collapse row 3.
+
+ clickTwisty("row-3", "collapsed");
+ checkRowsAreHidden("row-2-1", "row-2-2", "row-3-1", "row-3-1-1", "row-3-1-2");
+ checkSelectedAndCurrent(2, "row-3");
+
+ // Expand row 2.
+
+ clickTwisty("row-2", "expanded");
+ checkRowsAreHidden("row-3-1", "row-3-1-1", "row-3-1-2");
+ checkSelectedAndCurrent(4, "row-3");
+
+ // Expand row 3.
+
+ clickTwisty("row-3", "expanded");
+ checkRowsAreHidden();
+ checkSelectedAndCurrent(4, "row-3");
+
+ // Collapse row 3-1.
+
+ clickTwisty("row-3-1", "collapsed");
+ checkRowsAreHidden("row-3-1-1", "row-3-1-2");
+ checkSelectedAndCurrent(4, "row-3");
+
+ // Collapse row 3.
+
+ clickTwisty("row-3", "collapsed");
+ checkRowsAreHidden("row-3-1", "row-3-1-1", "row-3-1-2");
+ checkSelectedAndCurrent(4, "row-3");
+
+ // Expand row 3.
+
+ clickTwisty("row-3", "expanded");
+ checkRowsAreHidden("row-3-1-1", "row-3-1-2");
+ checkSelectedAndCurrent(4, "row-3");
+
+ // Expand row 3-1.
+
+ clickTwisty("row-3-1", "expanded");
+ checkRowsAreHidden();
+ checkSelectedAndCurrent(4, "row-3");
+
+ // Test key presses.
+
+ function pressKey(id, key, expectedChange) {
+ info(`pressing ${key}`);
+ performChange(id, expectedChange, row => {
+ EventUtils.synthesizeKey(key, {}, content);
+ });
+ }
+
+ // Row 0 has no children or parent, nothing should happen.
+
+ list.selectedIndex = 0;
+ pressKey("row-1", "VK_LEFT");
+ checkSelectedAndCurrent(0, "row-1");
+ pressKey("row-1", "VK_RIGHT");
+ checkSelectedAndCurrent(0, "row-1");
+
+ // Collapse row 2.
+
+ list.selectedIndex = 1;
+ pressKey("row-2", "VK_LEFT", "collapsed");
+ checkRowsAreHidden("row-2-1", "row-2-2");
+ checkSelectedAndCurrent(1, "row-2");
+
+ pressKey("row-2", "VK_LEFT");
+ checkRowsAreHidden("row-2-1", "row-2-2");
+ checkSelectedAndCurrent(1, "row-2");
+
+ // Collapse row 3.
+
+ list.selectedIndex = 2;
+ pressKey("row-3", "VK_LEFT", "collapsed");
+ checkRowsAreHidden("row-2-1", "row-2-2", "row-3-1", "row-3-1-1", "row-3-1-2");
+ checkSelectedAndCurrent(2, "row-3");
+
+ pressKey("row-3", "VK_LEFT");
+ checkRowsAreHidden("row-2-1", "row-2-2", "row-3-1", "row-3-1-1", "row-3-1-2");
+ checkSelectedAndCurrent(2, "row-3");
+
+ // Expand row 2.
+
+ list.selectedIndex = 1;
+ pressKey("row-2", "VK_RIGHT", "expanded");
+ checkRowsAreHidden("row-3-1", "row-3-1-1", "row-3-1-2");
+ checkSelectedAndCurrent(1, "row-2");
+
+ // Expand row 3.
+
+ list.selectedIndex = 4;
+ pressKey("row-3", "VK_RIGHT", "expanded");
+ checkRowsAreHidden();
+ checkSelectedAndCurrent(4, "row-3");
+
+ // Go down the tree to row 3-1-1.
+
+ pressKey("row-3", "VK_RIGHT");
+ checkRowsAreHidden();
+ checkSelectedAndCurrent(5, "row-3-1");
+
+ pressKey("row-3", "VK_RIGHT");
+ checkRowsAreHidden();
+ checkSelectedAndCurrent(6, "row-3-1-1");
+
+ pressKey("row-3-1-1", "VK_RIGHT");
+ checkRowsAreHidden();
+ checkSelectedAndCurrent(6, "row-3-1-1");
+
+ // Collapse row 3-1.
+
+ pressKey("row-3-1-1", "VK_LEFT");
+ checkRowsAreHidden();
+ checkSelectedAndCurrent(5, "row-3-1");
+
+ pressKey("row-3-1", "VK_LEFT", "collapsed");
+ checkRowsAreHidden("row-3-1-1", "row-3-1-2");
+ checkSelectedAndCurrent(5, "row-3-1");
+
+ // Collapse row 3.
+
+ pressKey("row-3-1", "VK_LEFT");
+ checkRowsAreHidden("row-3-1-1", "row-3-1-2");
+ checkSelectedAndCurrent(4, "row-3");
+
+ pressKey("row-3", "VK_LEFT", "collapsed");
+ checkRowsAreHidden("row-3-1", "row-3-1-1", "row-3-1-2");
+ checkSelectedAndCurrent(4, "row-3");
+
+ // Expand row 3.
+
+ pressKey("row-3", "VK_RIGHT", "expanded");
+ checkRowsAreHidden("row-3-1-1", "row-3-1-2");
+ checkSelectedAndCurrent(4, "row-3");
+
+ pressKey("row-3", "VK_RIGHT");
+ checkRowsAreHidden("row-3-1-1", "row-3-1-2");
+ checkSelectedAndCurrent(5, "row-3-1");
+
+ // Expand row 3-1.
+
+ pressKey("row-3-1", "VK_RIGHT", "expanded");
+ checkRowsAreHidden();
+ checkSelectedAndCurrent(5, "row-3-1");
+
+ pressKey("row-3-1", "VK_RIGHT");
+ checkRowsAreHidden();
+ checkSelectedAndCurrent(6, "row-3-1-1");
+
+ pressKey("row-3-1-1", "VK_RIGHT");
+ checkRowsAreHidden();
+ checkSelectedAndCurrent(6, "row-3-1-1");
+
+ // Same again, with a RTL tree.
+
+ info("switching to RTL");
+ doc.documentElement.dir = "rtl";
+
+ // Row 0 has no children or parent, nothing should happen.
+
+ list.selectedIndex = 0;
+ pressKey("row-1", "VK_RIGHT");
+ checkSelectedAndCurrent(0, "row-1");
+ pressKey("row-1", "VK_LEFT");
+ checkSelectedAndCurrent(0, "row-1");
+
+ // Collapse row 2.
+
+ list.selectedIndex = 1;
+ pressKey("row-2", "VK_RIGHT", "collapsed");
+ checkRowsAreHidden("row-2-1", "row-2-2");
+ checkSelectedAndCurrent(1, "row-2");
+
+ pressKey("row-2", "VK_RIGHT");
+ checkRowsAreHidden("row-2-1", "row-2-2");
+ checkSelectedAndCurrent(1, "row-2");
+
+ // Collapse row 3.
+
+ list.selectedIndex = 2;
+ pressKey("row-3", "VK_RIGHT", "collapsed");
+ checkRowsAreHidden("row-2-1", "row-2-2", "row-3-1", "row-3-1-1", "row-3-1-2");
+ checkSelectedAndCurrent(2, "row-3");
+
+ pressKey("row-3", "VK_RIGHT");
+ checkRowsAreHidden("row-2-1", "row-2-2", "row-3-1", "row-3-1-1", "row-3-1-2");
+ checkSelectedAndCurrent(2, "row-3");
+
+ // Expand row 2.
+
+ list.selectedIndex = 1;
+ pressKey("row-2", "VK_LEFT", "expanded");
+ checkRowsAreHidden("row-3-1", "row-3-1-1", "row-3-1-2");
+ checkSelectedAndCurrent(1, "row-2");
+
+ // Expand row 3.
+
+ list.selectedIndex = 4;
+ pressKey("row-3", "VK_LEFT", "expanded");
+ checkRowsAreHidden();
+ checkSelectedAndCurrent(4, "row-3");
+
+ // Go down the tree to row 3-1-1.
+
+ pressKey("row-3", "VK_LEFT");
+ checkRowsAreHidden();
+ checkSelectedAndCurrent(5, "row-3-1");
+
+ pressKey("row-3", "VK_LEFT");
+ checkRowsAreHidden();
+ checkSelectedAndCurrent(6, "row-3-1-1");
+
+ pressKey("row-3-1-1", "VK_LEFT");
+ checkRowsAreHidden();
+ checkSelectedAndCurrent(6, "row-3-1-1");
+
+ // Collapse row 3-1.
+
+ pressKey("row-3-1-1", "VK_RIGHT");
+ checkRowsAreHidden();
+ checkSelectedAndCurrent(5, "row-3-1");
+
+ pressKey("row-3-1", "VK_RIGHT", "collapsed");
+ checkRowsAreHidden("row-3-1-1", "row-3-1-2");
+ checkSelectedAndCurrent(5, "row-3-1");
+
+ // Collapse row 3.
+
+ pressKey("row-3-1", "VK_RIGHT");
+ checkRowsAreHidden("row-3-1-1", "row-3-1-2");
+ checkSelectedAndCurrent(4, "row-3");
+
+ pressKey("row-3", "VK_RIGHT", "collapsed");
+ checkRowsAreHidden("row-3-1", "row-3-1-1", "row-3-1-2");
+ checkSelectedAndCurrent(4, "row-3");
+
+ // Expand row 3.
+
+ pressKey("row-3", "VK_LEFT", "expanded");
+ checkRowsAreHidden("row-3-1-1", "row-3-1-2");
+ checkSelectedAndCurrent(4, "row-3");
+
+ pressKey("row-3", "VK_LEFT");
+ checkRowsAreHidden("row-3-1-1", "row-3-1-2");
+ checkSelectedAndCurrent(5, "row-3-1");
+
+ // Expand row 3-1.
+
+ pressKey("row-3-1", "VK_LEFT", "expanded");
+ checkRowsAreHidden();
+ checkSelectedAndCurrent(5, "row-3-1");
+
+ pressKey("row-3-1", "VK_LEFT");
+ checkRowsAreHidden();
+ checkSelectedAndCurrent(6, "row-3-1-1");
+
+ pressKey("row-3-1-1", "VK_LEFT");
+ checkRowsAreHidden();
+ checkSelectedAndCurrent(6, "row-3-1-1");
+
+ // Use the class methods for expanding and collapsing.
+
+ selectHandler.reset();
+ list.addEventListener("select", selectHandler);
+ listener.reset();
+
+ list.collapseRowAtIndex(6); // No children, no effect.
+ Assert.ok(!selectHandler.seenEvent, "'select' event did not fire");
+ Assert.ok(!listener.collapsedIndex, "'collapsed' event did not fire");
+
+ list.expandRowAtIndex(6); // No children, no effect.
+ Assert.ok(!selectHandler.seenEvent, "'select' event did not fire");
+ Assert.ok(!listener.expandedIndex, "'expanded' event did not fire");
+
+ list.collapseRowAtIndex(1); // Item with children that aren't selected.
+ Assert.ok(!selectHandler.seenEvent, "'select' event did not fire");
+ Assert.equal(listener.collapsedIndex, 1, "row-2 fired 'collapsed' event");
+ listener.reset();
+
+ list.expandRowAtIndex(1); // Item with children that aren't selected.
+ Assert.ok(!selectHandler.seenEvent, "'select' event did not fire");
+ Assert.equal(listener.expandedIndex, 1, "row-2 fired 'expanded' event");
+ listener.reset();
+
+ list.collapseRowAtIndex(5); // Item with children that are selected.
+ Assert.ok(selectHandler.seenEvent, "'select' event fired");
+ Assert.equal(
+ selectHandler.selectedAtEvent,
+ 5,
+ "selectedIndex was correct when 'select' event fired"
+ );
+ Assert.equal(listener.collapsedIndex, 5, "row-3-1 fired 'collapsed' event");
+ checkRowsAreHidden("row-3-1-1", "row-3-1-2");
+ checkSelectedAndCurrent(5, "row-3-1");
+ selectHandler.reset();
+ listener.reset();
+
+ list.expandRowAtIndex(5); // Selected item with children.
+ Assert.ok(!selectHandler.seenEvent, "'select' event did not fire");
+ Assert.equal(listener.expandedIndex, 5, "row-3-1 fired 'expanded' event");
+ checkRowsAreHidden();
+ checkSelectedAndCurrent(5, "row-3-1");
+ listener.reset();
+
+ list.selectedIndex = 7;
+ selectHandler.reset();
+
+ list.collapseRowAtIndex(4); // Item with grandchildren that are selected.
+ Assert.ok(selectHandler.seenEvent, "'select' event fired");
+ Assert.equal(
+ selectHandler.selectedAtEvent,
+ 4,
+ "selectedIndex was correct when 'select' event fired"
+ );
+ Assert.equal(listener.collapsedIndex, 4, "row-3 fired 'collapsed' event");
+ checkRowsAreHidden("row-3-1", "row-3-1-1", "row-3-1-2");
+ checkSelectedAndCurrent(4, "row-3");
+ selectHandler.reset();
+ listener.reset();
+
+ list.expandRowAtIndex(4); // Selected item with grandchildren.
+ Assert.ok(!selectHandler.seenEvent, "'select' event did not fire");
+ Assert.equal(listener.expandedIndex, 4, "row-3 fired 'expanded' event");
+ checkRowsAreHidden();
+ checkSelectedAndCurrent(4, "row-3");
+ listener.reset();
+
+ // Click thread for already expanded thread. Should select all in thread.
+ selectHandler.reset();
+ clickThread("row-3"); // Item with grandchildren.
+ Assert.ok(selectHandler.seenEvent, "'select' event fired");
+ Assert.equal(
+ selectHandler.selectedAtEvent,
+ 4,
+ "selectedIndex was correct when 'select' event fired"
+ );
+ checkRowsAreHidden();
+ checkMultiSelect("row-3", "row-3-1", "row-3-1-1", "row-3-1-2");
+ checkCurrent(4);
+
+ // Click thread for collapsed thread. Should expand the thread and select all
+ // children.
+ list.collapseRowAtIndex(1); // Item with children that aren't selected.
+ Assert.equal(listener.collapsedIndex, 1, "row-2 fired 'collapsed' event");
+ checkRowsAreHidden("row-2-1", "row-2-2");
+ clickThread("row-2", "expanded");
+ Assert.equal(listener.expandedIndex, 1, "row-2 fired 'expanded' event");
+ checkMultiSelect("row-2", "row-2-1", "row-2-2");
+ checkCurrent(1);
+
+ // Select multiple messages in an expanded thread by keyboard, ending with a
+ // child message, then collapse the thread. After that, currentIndex should
+ // be the root message.
+ selectHandler.reset();
+ list.selectedIndex = 1;
+ checkSelectedAndCurrent(1, "row-2");
+ info(`pressing VK_DOWN with shift key twice`);
+ EventUtils.synthesizeKey("VK_DOWN", { shiftKey: true }, content);
+ EventUtils.synthesizeKey("VK_DOWN", { shiftKey: true }, content);
+ checkMultiSelect("row-2", "row-2-1", "row-2-2");
+ checkCurrent(3);
+ clickTwisty("row-2", "collapsed");
+ checkSelectedAndCurrent(1, "row-2");
+
+ list.removeEventListener("collapsed", listener);
+ list.removeEventListener("expanded", listener);
+ list.removeEventListener("select", selectHandler);
+ doc.documentElement.dir = null;
+}
+
+/**
+ * Checks that the row widget can be changed, redrawing the rows and
+ * maintaining the selection.
+ */
+add_task(async function testRowClassChange() {
+ for (const variant of TEST_VARIANTS) {
+ info(`Running row class change test for ${variant}`);
+ await runTestInSandbox(subtestRowClassChange, variant);
+ }
+});
+
+async function subtestRowClassChange() {
+ let doc = content.document;
+ let list = doc.getElementById("testTree");
+ let indices = (list.selectedIndices = [1, 2, 3, 5, 8, 13, 21, 34]);
+ list.currentIndex = 5;
+
+ for (let row of list.table.body.children) {
+ Assert.equal(row.getAttribute("is"), "test-row");
+ Assert.equal(row.clientHeight, 50);
+ Assert.equal(
+ row.classList.contains("selected"),
+ indices.includes(row.index)
+ );
+ Assert.equal(row.classList.contains("current"), row.index == 5);
+ }
+
+ info("switching row class to AlternativeCardRow");
+ list.setAttribute("rows", "alternative-row");
+ Assert.deepEqual(list.selectedIndices, indices);
+ Assert.equal(list.currentIndex, 5);
+
+ for (let row of list.table.body.children) {
+ Assert.equal(row.getAttribute("is"), "alternative-row");
+ Assert.equal(row.clientHeight, 80);
+ Assert.equal(
+ row.classList.contains("selected"),
+ indices.includes(row.index)
+ );
+ Assert.equal(row.classList.contains("current"), row.index == 5);
+ }
+
+ list.selectedIndex = -1;
+ Assert.deepEqual(list.selectedIndices, []);
+ Assert.equal(list.currentIndex, -1);
+
+ info("switching row class to TestCardRow");
+ list.setAttribute("rows", "test-row");
+ Assert.deepEqual(list.selectedIndices, []);
+ Assert.equal(list.currentIndex, -1);
+
+ for (let row of list.table.body.children) {
+ Assert.equal(row.getAttribute("is"), "test-row");
+ Assert.equal(row.clientHeight, 50);
+ Assert.ok(!row.classList.contains("selected"));
+ Assert.ok(!row.classList.contains("current"));
+ }
+}
+
+/**
+ * Checks that resizing the widget automatically adds more rows if necessary.
+ */
+add_task(async function testResize() {
+ for (const variant of TEST_VARIANTS) {
+ info(`Running resize test for ${variant}`);
+ await runTestInSandbox(subtestResize, variant);
+ }
+});
+
+async function subtestResize() {
+ let doc = content.document;
+
+ let list = doc.getElementById("testTree");
+ Assert.ok(!!list, "the list exists");
+
+ async function doListActionAndWaitForRowBuffer(actionFn) {
+ // Filling the row buffer is fiddly timing, so provide an event to indicate
+ // that actions which may trigger changes in the row buffer have finished.
+ const eventName = "_treerowbufferfill";
+ list._rowBufferReadyEvent = new content.CustomEvent(eventName);
+
+ const promise = new Promise(resolve =>
+ list.addEventListener(eventName, resolve, { once: true })
+ );
+
+ await actionFn();
+ await promise;
+
+ list._rowBufferReadyEvent = null;
+ }
+
+ async function scrollVerticallyBy(scrollDistance) {
+ await doListActionAndWaitForRowBuffer(() => {
+ list.scrollBy(0, scrollDistance);
+ });
+ }
+
+ async function changeHeightTo(newHeight) {
+ await doListActionAndWaitForRowBuffer(() => {
+ list.style.height = `${newHeight}px`;
+ });
+ }
+
+ let rowCount = function () {
+ return list.querySelectorAll(`tr[is="test-row"]`).length;
+ };
+
+ let originalHeight = list.clientHeight;
+
+ // We should already be at the top, but this will force us to have finished
+ // loading before we trigger another scroll. Otherwise, we may get back a fill
+ // event for the initial fill when we expect an event in response to a scroll.
+ await doListActionAndWaitForRowBuffer(() => {
+ list.scrollTo(0, 0);
+ });
+
+ // Start by scrolling to somewhere in the middle of the list, so that we
+ // don't have to think about buffer rows that don't exist at the ends.
+ await scrollVerticallyBy(2650);
+
+ // The list has enough space for 13 visible rows, and 26 buffer rows should
+ // exist above and below.
+ Assert.equal(
+ rowCount(),
+ 13 + 26 + 26,
+ "the list should contain the right number of rows"
+ );
+
+ // Make the list shorter by 5 rows. This should not affect the number of rows,
+ // but this is a bit flaky, so check we have at least the minimum required.
+ await changeHeightTo(originalHeight - 250);
+ Assert.equal(list._toleranceSize, 16);
+ Assert.greaterOrEqual(
+ rowCount(),
+ 8 + 26 + 26,
+ "making the list shorter should not change the number of rows"
+ );
+
+ // Scrolling the list by any amount should remove excess rows.
+ await scrollVerticallyBy(50);
+ Assert.equal(
+ rowCount(),
+ 8 + 16 + 16,
+ "scrolling the list after resize should remove the excess rows"
+ );
+
+ // Return to the original height. More buffer rows should be added. We have
+ // to wait for the ResizeObserver to be triggered.
+ await changeHeightTo(originalHeight);
+ Assert.equal(list._toleranceSize, 26);
+ Assert.equal(
+ rowCount(),
+ 13 + 26 + 26,
+ "making the list taller should change the number of rows"
+ );
+
+ // Make the list taller by 5 rows. We have to wait for the ResizeObserver
+ // to be triggered.
+ await changeHeightTo(originalHeight + 250);
+ Assert.equal(list._toleranceSize, 36);
+ Assert.equal(
+ rowCount(),
+ 18 + 36 + 36,
+ "making the list taller should change the number of rows"
+ );
+
+ // Scrolling the list should not affect the number of rows.
+ await scrollVerticallyBy(50);
+ Assert.equal(
+ rowCount(),
+ 18 + 36 + 36,
+ "scrolling the list should not change the number of rows"
+ );
+}
diff --git a/comm/mail/base/test/browser/browser_viewMenu.js b/comm/mail/base/test/browser/browser_viewMenu.js
new file mode 100644
index 0000000000..d24e868595
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_viewMenu.js
@@ -0,0 +1,218 @@
+/* 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 { MessageGenerator } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MessageGenerator.jsm"
+);
+
+/** @type MenuData */
+const viewMenuData = {
+ menu_Toolbars: {},
+ view_toolbars_popup_quickFilterBar: { checked: true },
+ viewToolbarsPopupSpacesToolbar: { checked: true },
+ menu_showTaskbar: { checked: true },
+ customizeMailToolbars: {},
+ menu_MessagePaneLayout: {},
+ messagePaneClassic: {},
+ messagePaneWide: {},
+ messagePaneVertical: { checked: true },
+ menu_showFolderPane: { checked: true },
+ menu_toggleThreadPaneHeader: { disabled: true, checked: true },
+ menu_showMessage: {},
+ menu_FolderViews: {},
+ menu_toggleFolderHeader: { checked: true },
+ menu_allFolders: { disabled: true, checked: true },
+ menu_smartFolders: {},
+ menu_unreadFolders: {},
+ menu_favoriteFolders: {},
+ menu_recentFolders: {},
+ menu_tags: {},
+ menu_compactMode: { disabled: true },
+ menu_uiDensity: {},
+ uiDensityCompact: {},
+ uiDensityNormal: { checked: true },
+ uiDensityTouch: {},
+ viewFullZoomMenu: {},
+ menu_fullZoomEnlarge: { disabled: true },
+ menu_fullZoomReduce: { disabled: true },
+ menu_fullZoomReset: { disabled: true },
+ menu_fullZoomToggle: { disabled: true },
+ menu_uiFontSize: {},
+ menu_fontSizeEnlarge: {},
+ menu_fontSizeReduce: {},
+ menu_fontSizeReset: {},
+ calTodayPaneMenu: { hidden: true },
+ "calShowTodayPane-2": {},
+ calTodayPaneDisplayMiniday: {},
+ calTodayPaneDisplayMinimonth: {},
+ calTodayPaneDisplayNone: {},
+ calCalendarMenu: { hidden: true },
+ calChangeViewDay: {},
+ calChangeViewWeek: {},
+ calChangeViewMultiweek: {},
+ calChangeViewMonth: {},
+ calCalendarPaneMenu: {},
+ calViewCalendarPane: {},
+ calTasksViewMinimonth: {},
+ calTasksViewCalendarlist: {},
+ calCalendarCurrentViewMenu: {},
+ calWorkdaysOnlyMenuitem: {},
+ calTasksInViewMenuitem: {},
+ calShowCompletedInViewMenuItem: {},
+ calViewRotated: {},
+ calTasksMenu: { hidden: true },
+ calTasksViewFilterTasks: {},
+ calTasksViewFilterCurrent: {},
+ calTasksViewFilterToday: {},
+ calTasksViewFilterNext7days: {},
+ calTasksViewFilterNotstartedtasks: {},
+ calTasksViewFilterOverdue: {},
+ calTasksViewFilterCompleted: {},
+ calTasksViewFilterOpen: {},
+ calTasksViewFilterAll: {},
+ viewSortMenu: { disabled: true },
+ sortByDateMenuitem: {},
+ sortByReceivedMenuitem: {},
+ sortByFlagMenuitem: {},
+ sortByOrderReceivedMenuitem: {},
+ sortByPriorityMenuitem: {},
+ sortByFromMenuitem: {},
+ sortByRecipientMenuitem: {},
+ sortByCorrespondentMenuitem: {},
+ sortBySizeMenuitem: {},
+ sortByStatusMenuitem: {},
+ sortBySubjectMenuitem: {},
+ sortByUnreadMenuitem: {},
+ sortByTagsMenuitem: {},
+ sortByJunkStatusMenuitem: {},
+ sortByAttachmentsMenuitem: {},
+ sortAscending: {},
+ sortDescending: {},
+ sortThreaded: {},
+ sortUnthreaded: {},
+ groupBySort: {},
+ viewMessageViewMenu: { hidden: true },
+ viewMessageAll: {},
+ viewMessageUnread: {},
+ viewMessageNotDeleted: {},
+ viewMessageTags: {},
+ viewMessageCustomViews: {},
+ viewMessageVirtualFolder: {},
+ viewMessageCustomize: {},
+ viewMessagesMenu: { disabled: true },
+ viewAllMessagesMenuItem: { disabled: true, checked: true },
+ viewUnreadMessagesMenuItem: { disabled: true },
+ viewThreadsWithUnreadMenuItem: { disabled: true },
+ viewWatchedThreadsWithUnreadMenuItem: { disabled: true },
+ viewIgnoredThreadsMenuItem: { disabled: true },
+ menu_expandAllThreads: { disabled: true },
+ collapseAllThreads: { disabled: true },
+ viewheadersmenu: {},
+ viewallheaders: {},
+ viewnormalheaders: { checked: true },
+ viewBodyMenu: {},
+ bodyAllowHTML: { checked: true },
+ bodySanitized: {},
+ bodyAsPlaintext: {},
+ bodyAllParts: { hidden: true },
+ viewFeedSummary: { hidden: true },
+ bodyFeedGlobalWebPage: {},
+ bodyFeedGlobalSummary: {},
+ bodyFeedPerFolderPref: {},
+ bodyFeedSummaryAllowHTML: {},
+ bodyFeedSummarySanitized: {},
+ bodyFeedSummaryAsPlaintext: {},
+ viewAttachmentsInlineMenuitem: { checked: true },
+ pageSourceMenuItem: { disabled: true },
+};
+let helper = new MenuTestHelper("menu_View", viewMenuData);
+
+let tabmail = document.getElementById("tabmail");
+let inboxFolder, rootFolder, testMessages;
+
+add_setup(async function () {
+ document.getElementById("toolbar-menubar").removeAttribute("autohide");
+
+ let generator = new MessageGenerator();
+
+ MailServices.accounts.createLocalMailAccount();
+ let account = MailServices.accounts.accounts[0];
+ account.addIdentity(MailServices.accounts.createIdentity());
+ rootFolder = account.incomingServer.rootFolder;
+
+ rootFolder.createSubfolder("view menu", null);
+ inboxFolder = rootFolder
+ .getChildNamed("view menu")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+ inboxFolder.addMessageBatch(
+ generator.makeMessages({ count: 5 }).map(message => message.toMboxString())
+ );
+ testMessages = [...inboxFolder.messages];
+
+ registerCleanupFunction(() => {
+ tabmail.closeOtherTabs(0);
+ MailServices.accounts.removeAccount(account, false);
+ });
+});
+
+add_task(async function test3PaneTab() {
+ tabmail.currentAbout3Pane.restoreState({
+ folderPaneVisible: true,
+ messagePaneVisible: true,
+ folderURI: rootFolder,
+ });
+ await new Promise(resolve => setTimeout(resolve));
+ await helper.testAllItems("mail3PaneTab");
+
+ tabmail.currentAbout3Pane.displayFolder(inboxFolder);
+ await helper.testItems({
+ menu_Toolbars: {},
+ view_toolbars_popup_quickFilterBar: { checked: true },
+ menu_MessagePaneLayout: {},
+ menu_showFolderPane: { checked: true },
+ menu_toggleThreadPaneHeader: { checked: true },
+ menu_showMessage: { checked: true },
+ viewSortMenu: { disabled: false },
+ viewMessagesMenu: { disabled: false },
+ });
+
+ goDoCommand("cmd_toggleQuickFilterBar");
+ await helper.testItems({
+ menu_Toolbars: {},
+ view_toolbars_popup_quickFilterBar: { checked: false },
+ });
+
+ goDoCommand("cmd_toggleFolderPane");
+ await helper.testItems({
+ menu_MessagePaneLayout: {},
+ menu_showFolderPane: { checked: false },
+ menu_showMessage: { checked: true },
+ });
+
+ goDoCommand("cmd_toggleThreadPaneHeader");
+ await helper.testItems({
+ menu_MessagePaneLayout: {},
+ menu_toggleThreadPaneHeader: { checked: false },
+ });
+
+ goDoCommand("cmd_toggleMessagePane");
+ await helper.testItems({
+ menu_MessagePaneLayout: {},
+ menu_showFolderPane: { checked: false },
+ menu_showMessage: { checked: false },
+ });
+
+ goDoCommand("cmd_toggleQuickFilterBar");
+ goDoCommand("cmd_toggleFolderPane");
+ goDoCommand("cmd_toggleThreadPaneHeader");
+ goDoCommand("cmd_toggleMessagePane");
+ await helper.testItems({
+ menu_Toolbars: {},
+ view_toolbars_popup_quickFilterBar: { checked: true },
+ menu_MessagePaneLayout: {},
+ menu_showFolderPane: { checked: true },
+ menu_toggleThreadPaneHeader: { checked: true },
+ menu_showMessage: { checked: true },
+ });
+});
diff --git a/comm/mail/base/test/browser/browser_webSearchTelemetry.js b/comm/mail/base/test/browser/browser_webSearchTelemetry.js
new file mode 100644
index 0000000000..cd420e3b83
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_webSearchTelemetry.js
@@ -0,0 +1,50 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/* globals openWebSearch */
+
+/**
+ * Test telemetry related to web search usage.
+ */
+
+let { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+let { MockRegistrar } = ChromeUtils.importESModule(
+ "resource://testing-common/MockRegistrar.sys.mjs"
+);
+
+/** @implements {nsIExternalProtocolService} */
+let mockExternalProtocolService = {
+ loadURI(aURI, aWindowContext) {},
+ QueryInterface: ChromeUtils.generateQI(["nsIExternalProtocolService"]),
+};
+
+let mockExternalProtocolServiceCID = MockRegistrar.register(
+ "@mozilla.org/uriloader/external-protocol-service;1",
+ mockExternalProtocolService
+);
+
+registerCleanupFunction(() => {
+ MockRegistrar.unregister(mockExternalProtocolServiceCID);
+});
+
+/**
+ * Test that we're counting how many times search on web was used.
+ */
+add_task(async function test_web_search_usage() {
+ Services.telemetry.clearScalars();
+
+ const NUM_SEARCH = 5;
+ let engine = await Services.search.getDefault();
+ await Promise.all(
+ Array.from({ length: NUM_SEARCH }).map(() => openWebSearch("thunderbird"))
+ );
+
+ let scalars = TelemetryTestUtils.getProcessScalars("parent", true);
+ Assert.equal(
+ scalars["tb.websearch.usage"][engine.name.toLowerCase()],
+ NUM_SEARCH,
+ "Count of search on web times must be correct."
+ );
+});
diff --git a/comm/mail/base/test/browser/browser_zoom.js b/comm/mail/base/test/browser/browser_zoom.js
new file mode 100644
index 0000000000..0dbda439ca
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_zoom.js
@@ -0,0 +1,110 @@
+/* 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 { MessageGenerator } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MessageGenerator.jsm"
+);
+
+const tabmail = document.getElementById("tabmail");
+const about3Pane = tabmail.currentAbout3Pane;
+const { threadTree } = about3Pane;
+
+add_setup(async function () {
+ Services.prefs.setBoolPref("mailnews.scroll_to_new_message", false);
+ // Create an account for the test.
+ MailServices.accounts.createLocalMailAccount();
+ const account = MailServices.accounts.accounts[0];
+ account.addIdentity(MailServices.accounts.createIdentity());
+
+ // Create a folder for the account to store test messages.
+ const rootFolder = account.incomingServer.rootFolder;
+ rootFolder.createSubfolder("zoom", null);
+ const testFolder = rootFolder
+ .getChildNamed("zoom")
+ .QueryInterface(Ci.nsIMsgLocalMailFolder);
+
+ // Generate test messages.
+ const generator = new MessageGenerator();
+ testFolder.addMessageBatch(
+ generator
+ .makeMessages({ count: 5, msgsPerThread: 5 })
+ .map(message => message.toMboxString())
+ );
+
+ // Use the test folder.
+ about3Pane.displayFolder(testFolder.URI);
+ await ensure_cards_view();
+
+ // Remove test account on cleanup.
+ registerCleanupFunction(() => {
+ MailServices.accounts.removeAccount(account, false);
+ Services.prefs.setBoolPref("mailnews.scroll_to_new_message", true);
+ });
+});
+
+/**
+ * Tests zooming in and out of the multi-message view using keyboard shortcuts
+ * when viewing a thread.
+ */
+add_task(async function testMultiMessageZoom() {
+ // Threads need to be collapsed, otherwise the multi-message view
+ // won't be shown.
+ const row = threadTree.getRowAtIndex(0);
+ Assert.ok(
+ row.classList.contains("collapsed"),
+ "The thread row should be collapsed"
+ );
+
+ const subjectLine = row.querySelector(
+ ".thread-card-subject-container .subject"
+ );
+ // Simulate a click on the row's subject line to select the row.
+ const selectPromise = BrowserTestUtils.waitForEvent(threadTree, "select");
+ EventUtils.synthesizeMouseAtCenter(
+ subjectLine,
+ { clickCount: 1 },
+ about3Pane
+ );
+ await selectPromise;
+ // Make sure the correct thread is selected and that the multi-message view is
+ // visible.
+ Assert.ok(
+ row.classList.contains("selected"),
+ "The thread row should be selected"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_visible(about3Pane.multiMessageBrowser),
+ "The multi-message browser should be visible"
+ );
+
+ // Record the zoom value before the operation.
+ let previousZoom = top.ZoomManager.getZoomForBrowser(
+ about3Pane.multiMessageBrowser
+ );
+
+ // Emulate a zoom in.
+ EventUtils.synthesizeKey("+", { accelKey: true });
+
+ // Test that the zoom value increases.
+ await TestUtils.waitForCondition(
+ () =>
+ top.ZoomManager.getZoomForBrowser(about3Pane.multiMessageBrowser) >
+ previousZoom,
+ "zoom value should be greater than before keyboard event"
+ );
+
+ // Emulate a zoom out.
+ previousZoom = top.ZoomManager.getZoomForBrowser(
+ about3Pane.multiMessageBrowser
+ );
+ EventUtils.synthesizeKey("-", { accelKey: true });
+
+ // Test that the zoom value decreases.
+ await TestUtils.waitForCondition(
+ () =>
+ previousZoom >
+ top.ZoomManager.getZoomForBrowser(about3Pane.multiMessageBrowser),
+ "zoom value should be less than before keyboard event"
+ );
+});
diff --git a/comm/mail/base/test/browser/files/formContent.html b/comm/mail/base/test/browser/files/formContent.html
new file mode 100644
index 0000000000..6779051746
--- /dev/null
+++ b/comm/mail/base/test/browser/files/formContent.html
@@ -0,0 +1,36 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8" />
+ <title>Form Content</title>
+ </head>
+ <body>
+ <form>
+ <div>
+ <input type="date" />
+ </div>
+ <div>
+ <select>
+ <option value=""></option>
+ <option value="3.141592654">&pi;</option>
+ <option value="6.283185308">&tau;</option>
+ </select>
+ </div>
+ <div>
+ <input list="letters"/>
+ <datalist id="letters">
+ <option value="alpha"/>
+ <option value="beta"/>
+ <option value="gamma"/>
+ <option value="delta"/>
+ <option value="epsilon"/>
+ <option value="zeta"/>
+ <option value="eta"/>
+ <option value="theta"/>
+ <option value="iota"/>
+ <option value="kappa"/>
+ </datalist>
+ </div>
+ </form>
+ </body>
+</html>
diff --git a/comm/mail/base/test/browser/files/links.html b/comm/mail/base/test/browser/files/links.html
new file mode 100644
index 0000000000..f5703dc4ef
--- /dev/null
+++ b/comm/mail/base/test/browser/files/links.html
@@ -0,0 +1,38 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8"/>
+ <title>Links to other places</title>
+</head>
+<body>
+ <h1>Links to things</h1>
+ <p>This page is a test of what happens when you click on links. It should be loaded from http://example.org:80.</p>
+
+ <h2>This page:</h2>
+ <ul>
+ <li><a id="this-hash" href="#hash">Anchor on this page</a></li>
+ <li><a id="this-nohash" href="links.html">This page</a></li>
+ </ul>
+
+ <h2>Pages on this domain:</h2>
+ <ul>
+ <li><a id="local-here" href="sampleContent.html">A page in the same directory</a></li>
+ <li><a id="local-elsewhere" href="/browser/comm/mail/components/extensions/test/browser/data/content.html">A page elsewhere</a></li>
+ </ul>
+
+ <h2>Pages on other places on this TLD:</h2>
+ <ul>
+ <li><a id="other-https" href="https://example.org/browser/comm/mail/base/test/browser/files/links.html">This page, but over HTTPS</a></li>
+ <li><a id="other-port" href="http://example.org:8000/browser/comm/mail/base/test/browser/files/links.html">This page, but on example.com:8000</a></li>
+ <li><a id="other-subdomain" href="http://test1.example.org/browser/comm/mail/base/test/browser/files/links.html">This page, but on test1.example.com</a></li>
+ <li><a id="other-subsubdomain" href="http://sub1.test1.example.org/browser/comm/mail/base/test/browser/files/links.html">This page, but on sub1.test1.example.com</a></li>
+ </ul>
+
+ <h2>Pages on a completely different domain:</h2>
+ <ul style="margin-bottom: 100vh;">
+ <li><a id="other-domain" href="http://mochi.test:8888/browser/comm/mail/base/test/browser/files/links.html">This page, but on mochi.test</a></li>
+ </ul>
+
+ <h2 id="hash">This is the hash target!</h2>
+</body>
+</html>
diff --git a/comm/mail/base/test/browser/files/menulist.xhtml b/comm/mail/base/test/browser/files/menulist.xhtml
new file mode 100644
index 0000000000..cba2bbcf86
--- /dev/null
+++ b/comm/mail/base/test/browser/files/menulist.xhtml
@@ -0,0 +1,30 @@
+<?xml version="1.0"?>
+<?xml-stylesheet type="text/css" href="chrome://global/skin/global.css"?>
+<?xml-stylesheet type="text/css" href="chrome://messenger/skin/menulist.css"?>
+
+<window align="start" xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" xmlns:html="http://www.w3.org/1999/xhtml">
+ <button id="before" label="I'm just a button" onclick="alert('I\'m a button!')"/>
+
+ <menulist>
+ <menupopup>
+ <menuitem value="foo" label="foo"/>
+ <menuitem value="bar" label="bar"/>
+ </menupopup>
+ </menulist>
+
+ <menulist is="menulist-editable">
+ <menupopup>
+ <menuitem value="foo" label="foo"/>
+ <menuitem value="bar" label="bar"/>
+ </menupopup>
+ </menulist>
+
+ <menulist is="menulist-editable" editable="true" width="100">
+ <menupopup>
+ <menuitem value="foo" label="foo"/>
+ <menuitem value="bar" label="bar"/>
+ </menupopup>
+ </menulist>
+
+ <button id="after" label="I'm just a button"/>
+</window>
diff --git a/comm/mail/base/test/browser/files/orderableTreeListbox.xhtml b/comm/mail/base/test/browser/files/orderableTreeListbox.xhtml
new file mode 100644
index 0000000000..63154cbce9
--- /dev/null
+++ b/comm/mail/base/test/browser/files/orderableTreeListbox.xhtml
@@ -0,0 +1,171 @@
+<!DOCTYPE html>
+<html xmlns="http://www.w3.org/1999/xhtml">
+<head>
+ <meta charset="utf-8" />
+ <title>Test for the orderable-tree-listbox custom element</title>
+ <style>
+ :focus {
+ outline: 3px blue solid;
+ }
+ html {
+ height: 100%;
+ }
+ body {
+ height: 100%;
+ display: flex;
+ margin: 0;
+ }
+ #list {
+ overflow-y: auto;
+ white-space: nowrap;
+ margin: 1em;
+ border: 1px solid black;
+ width: 400px;
+ outline: none;
+ }
+ @media not (prefers-reduced-motion) {
+ #list {
+ scroll-behavior: smooth;
+ }
+ }
+ ol, ul {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ }
+ li > div {
+ display: flex;
+ align-items: center;
+ padding: 4px;
+ line-height: 24px;
+ }
+ li.selected > div {
+ color: white;
+ background-color: blue;
+ }
+ li > ul > li > div {
+ padding-inline-start: calc(1em + 8px);
+ }
+ li.collapsed > ul {
+ display: none;
+ }
+ div.twisty {
+ width: 1em;
+ height: 1em;
+ margin-inline-end: 4px;
+ }
+ li.children > div > div.twisty {
+ background-color: green;
+ }
+ li.children.collapsed > div > div.twisty {
+ background-color: red;
+ }
+
+ #list > li {
+ transition: opacity 250ms;
+ }
+ #list > li.dragging {
+ opacity: 0.75;
+ }
+ </style>
+ <!-- This script is used for the automated test. -->
+ <script defer="defer" src="chrome://messenger/content/tree-listbox.js"></script>
+ <!-- This script is used when this file is loaded in a browser. -->
+ <script defer="defer" src="../../../content/widgets/tree-listbox.js"></script>
+</head>
+<body>
+ <ol id="list" is="orderable-tree-listbox" role="tree">
+ <li id="row-1">
+ <div draggable="true">
+ <div class="twisty"></div>
+ Item 1
+ </div>
+ </li>
+ <li id="row-2">
+ <div draggable="true">
+ <div class="twisty"></div>
+ Item 2
+ </div>
+ <ul>
+ <li id="row-2-1">
+ <div>
+ <div class="twisty"></div>
+ First child
+ </div>
+ </li>
+ <li id="row-2-2">
+ <div>
+ <div class="twisty"></div>
+ Second child
+ </div>
+ </li>
+ </ul>
+ </li>
+ <li id="row-3">
+ <div draggable="true">
+ <div class="twisty"></div>
+ Item 3
+ </div>
+ <ul>
+ <li id="row-3-1">
+ <div>
+ <div class="twisty"></div>
+ First child
+ </div>
+ </li>
+ <li id="row-3-2">
+ <div>
+ <div class="twisty"></div>
+ Second child
+ </div>
+ </li>
+ <li id="row-3-3">
+ <div>
+ <div class="twisty"></div>
+ Third child
+ </div>
+ </li>
+ </ul>
+ </li>
+ <li id="row-4">
+ <div draggable="true">
+ <div class="twisty"></div>
+ Item 4
+ </div>
+ </li>
+ <li id="row-5">
+ <div draggable="true">
+ <div class="twisty"></div>
+ Item 5
+ </div>
+ <ul>
+ <li id="row-5-1">
+ <div>
+ <div class="twisty"></div>
+ First child
+ </div>
+ </li>
+ <li id="row-5-2">
+ <div>
+ <div class="twisty"></div>
+ Second child
+ </div>
+ </li>
+ </ul>
+ </li>
+ </ol>
+
+ <div id="marker" style="position: absolute; left: 500px; border-top: 1px red solid;"></div>
+ <script>
+ function moveMarker(event) {
+ let marker = document.getElementById("marker");
+ marker.style.top = `${event.clientY}px`;
+ marker.textContent = `${event.type} event here`;
+ }
+
+ document.addEventListener("dragstart", moveMarker);
+ document.addEventListener("dragover", moveMarker);
+ document.addEventListener("drop", moveMarker);
+ </script>
+</body>
+</html>
diff --git a/comm/mail/base/test/browser/files/paneSplitter.xhtml b/comm/mail/base/test/browser/files/paneSplitter.xhtml
new file mode 100644
index 0000000000..7d25e5596e
--- /dev/null
+++ b/comm/mail/base/test/browser/files/paneSplitter.xhtml
@@ -0,0 +1,122 @@
+<!DOCTYPE html>
+<html xmlns="http://www.w3.org/1999/xhtml">
+<head>
+ <meta charset="utf-8" />
+ <title>Test for the pane-splitter custom element</title>
+ <style>
+ hr[is="pane-splitter"] {
+ margin: 0 -3px;
+ border: none;
+ z-index: 1;
+ cursor: ew-resize;
+ opacity: .4;
+ background-color: red;
+ }
+
+ #splitter3,
+ #splitter4 {
+ margin: -3px 0;
+ cursor: ns-resize;
+ }
+
+ #horizontal-before {
+ display: grid;
+ grid-template-columns: minmax(auto, var(--splitter1-width)) 0 auto;
+ width: 500px;
+ height: 100px;
+ --splitter1-width: 200px;
+ margin: 1em;
+ }
+
+ #horizontal-after {
+ display: grid;
+ grid-template-columns: auto 0 minmax(auto, var(--splitter2-width));
+ width: 500px;
+ height: 100px;
+ --splitter2-width: 200px;
+ margin: 1em;
+ }
+
+ #vertical-before {
+ display: inline-grid;
+ grid-template-rows: minmax(auto, var(--splitter3-height)) 0 auto;
+ width: 100px;
+ height: 500px;
+ --splitter3-height: 200px;
+ margin: 1em;
+ }
+
+ #vertical-after {
+ display: inline-grid;
+ grid-template-rows: auto 0 minmax(auto, var(--splitter4-height));
+ width: 100px;
+ height: 500px;
+ --splitter4-height: 200px;
+ margin: 1em;
+ }
+
+ .resized {
+ background-color: lightblue;
+ }
+
+ .fill {
+ background-color: lightslategrey;
+ }
+ </style>
+ <!-- This path is used for the automated test. -->
+ <script src="chrome://messenger/content/pane-splitter.js"></script>
+ <!-- This path is used when this file is loaded in a browser. -->
+ <script src="../../../content/widgets/pane-splitter.js"></script>
+ <script>
+ function moveMarker(event) {
+ let markerX = document.getElementById("markerX");
+ markerX.style.left = `${event.clientX + window.scrollX}px`;
+ markerX.textContent = `${event.type} event here`;
+
+ let markerY = document.getElementById("markerY");
+ markerY.style.top = `${event.clientY + window.scrollY}px`;
+ markerY.textContent = `${event.type} event here`;
+ }
+
+ document.addEventListener("mousedown", moveMarker);
+ document.addEventListener("mousemove", moveMarker);
+ document.addEventListener("mouseup", moveMarker);
+
+ window.addEventListener("load", () => {
+ for (let splitter of document.querySelectorAll('hr[is="pane-splitter"]')) {
+ splitter.resizeElement = splitter.parentNode.querySelector(".resized");
+ }
+ });
+ </script>
+</head>
+<body>
+ <div id="horizontal-before">
+ <div id="splitter1-before" class="resized"></div>
+ <hr is="pane-splitter" id="splitter1" resize-direction="horizontal" />
+ <div id="splitter1-after" class="fill"></div>
+ </div>
+
+ <div id="horizontal-after">
+ <div id="splitter2-before" class="fill"></div>
+ <hr is="pane-splitter" id="splitter2" resize="next" resize-direction="horizontal" />
+ <div id="splitter2-after" class="resized"></div>
+ </div>
+
+ <div style="display: flex;">
+ <div id="vertical-before">
+ <div id="splitter3-before" class="resized"></div>
+ <hr is="pane-splitter" id="splitter3" />
+ <div id="splitter3-after" class="fill"></div>
+ </div>
+
+ <div id="vertical-after">
+ <div id="splitter4-before" class="fill"></div>
+ <hr is="pane-splitter" id="splitter4" resize="next" />
+ <div id="splitter4-after" class="resized"></div>
+ </div>
+ </div>
+
+ <div id="markerX" style="position: absolute; top: 0px; border-left: 1px red solid;"></div>
+ <div id="markerY" style="position: absolute; left: 550px; border-top: 1px red solid;"></div>
+</body>
+</html>
diff --git a/comm/mail/base/test/browser/files/rss.xml b/comm/mail/base/test/browser/files/rss.xml
new file mode 100644
index 0000000000..8ff0540a66
--- /dev/null
+++ b/comm/mail/base/test/browser/files/rss.xml
@@ -0,0 +1,16 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<rss version="2.0">
+ <channel>
+ <title>Test Feed</title>
+ <link>https://example.org/</link>
+ <description></description>
+ <lastBuildDate>Thu, 21 Jan 2021 17:57:54 +0000</lastBuildDate>
+ <language>en-US</language>
+
+ <item>
+ <title>Test Article</title>
+ <link>https://example.org/browser/comm/mail/base/test/browser/files/sampleContent.html</link>
+ <pubDate>Wed, 20 Jan 2021 17:00:39 +0000</pubDate>
+ </item>
+ </channel>
+</rss>
diff --git a/comm/mail/base/test/browser/files/sampleContent.eml b/comm/mail/base/test/browser/files/sampleContent.eml
new file mode 100644
index 0000000000..f0465ad2bd
--- /dev/null
+++ b/comm/mail/base/test/browser/files/sampleContent.eml
@@ -0,0 +1,160 @@
+From andy@anway.invalid
+Content-Type: multipart/related;
+ boundary="--------------CHOPCHOP0"
+Subject: Big Meeting Today
+From: "Andy Anway" <andy@anway.invalid>
+To: "Bob Bell" <bob@bell.invalid>
+Message-Id: <0@made.up.invalid>
+Date: Tue, 01 Feb 2000 00:00:00 +1300
+
+This is a multi-part message in MIME format.
+----------------CHOPCHOP0
+Content-Type: text/html; charset=ISO-8859-1; format=flowed
+Content-Transfer-Encoding: 7bit
+
+<!DOCTYPE html>
+<html>
+ <head>
+ <link rel="icon" href="http://mochi.test:8888/browser/comm/mail/base/test/browser/files/tb-logo.png" />
+ </head>
+ <body>
+ <p>This is a page of sample content for tests.</p>
+ <p><a href="https://www.thunderbird.net/">Link to a web page</a></p>
+ <form>
+ <input type="text" />
+ </form>
+ <p><img src="cid:logo" width="304" height="84" /></p>
+ </body>
+</html>
+
+----------------CHOPCHOP0
+Content-Type: image/png; charset=ISO-8859-1; format=flowed;
+ name="tb-logo.png"
+Content-Transfer-Encoding: base64
+Content-Disposition: attachment;
+ filename="tb-logo.png"
+Content-ID: <logo>
+
+iVBORw0KGgoAAAANSUhEUgAAATAAAABUCAMAAAAyN5s5AAAC91BMVEVMaXErLDVPU1wYFx1O
+T1RPT1RNT1MTExoFBwZNTlNOTlNNT1RNTlNMTFRNTlMGBgoDBgYFBQZNTlQGBgdOTlNNTlMA
+AwMEBQVNTlNOUFRMTFUAAABNTlMEBgdNT1QFBwoEBAZOTlNNTlMuOoNNTlQACghNTlRNTlNO
+T1RNTlMTDytNT1N/qs1NTlMxPolNTlSZydyfmI8pMHosPIYaFSFNTlMdEkRNUFVNTlMrM38U
+EBczT5hNTlMtNX8UDy8+XqIqN4AkGmCTiIEwN4JHaKgRCygnLHdzmsNYdq2Fd3N2nMRvlL8g
+G00RCi0TCzAsQYsqHGJMbq5pjLsWDj9HYqV/qdh6nsxpYFljhsL////7+PL38+z59u////z8
++/f8+vT28ufz7+UoG2D+/fnx7ODu6NvRyb3g2c7k39bSzcXr5NbIvq/Z0si9sqLJw7jDuKjb
+0cCJhYHOxbbn3s/n5eGjn5mtqKPY1M+0r6rl2MS9ubVwbW54dXZgXl5HREZnZWHHxcJWVlU/
+PjevpJXazbR/fX0cJGV/d29UaaFPTka5rZx0b2P/++T77c9UVEphW01BQFtXTl6QnavN3exg
+hcBIa65bgL0rS5dOcbGKt9NKZ6dPdLZdhbk8ZKlDcbNVd7RWe7l0pcszX6c5WpyKvN5EYaI9
+aK8yWaNJdrRff7OBsdc0RZUvUppukr8xN3knRZItLXkoUqFiisUoPIlsnMVpirxKfcdekL+Z
+yusoQY4EAQktOIQOBVVWicRBaqgqMmp3msRTgr40VJ1SiLhAbrFwmc1glc5DWaEqMoBRcKtp
+kMh0ntRMfrlHdronKVek1vYVCzd/ptg7aaEYGG83UZM6X6JDc6taea1cZXwkIUJDV5kLAyW3
+6P2PwOlhp+A4QIIcDg8+TpYoIXQiFVY0SYuClLAFABlZKzUdFWgyN4zU//82MTUrIGkpHWVO
+T1SUjoosIm0eK3oSCS89RYwpGjNNQHREVIaXdlSjqrsmJCHZ+P6hTAAuJnRz9RRfAAAAWXRS
+TlMAAw0H2DaeARv9u/JCJ39bSVawUY/kECNSFBsL6jdqQS2nyiBjFnOGWS6XSv75/cP+/Y1X
+gpf+INIxbcWMRrneatP7sn305HhA1FEovMfh4/W3xtjNzNPUx47MeaUAABWdSURBVHhe7JTH
+a1xXHIUleZ1lwPEygpDgFzEZEQUjhGQiYiEtjIwV8ofd13uv03vvvaj34t7Se1/kzigkJLGJ
+DN69+VZv8eM++DjnjL2YyVXv2PjYRRlxI/bRxOJFhY0YX4tdH781Oz13oesRE2sg61377jaj
+3phYWVpeXl5aWpl44fWISx93toN1FE/sVRAPIlpIkRHM/fnVxbnndnTEJ51UU+O2EomEkrQQ
+yycwTIvK75Yd56Z37j9JG7HacXC94osqIkkiluVjKgSlynggNWTKO/mP6xGLHXZvi0laikLC
+OopMpdJS5TIeCOAOBIcf3qW/r0d4sR4TNdYVMblORsWoub9PQV9lJxTSdUliWUmSQqH5xfNm
+jrh2vZEzTuF2iUpSSUR9574cR4eqqlzVMFiJzaMsy1KL8HqEV93p9RRFXEes5Knoi/3pK6Sz
+WrXKGWy1qlVZlCAIFKVuT47i9WHMPKrW1hWPYsG9h75ME+6XrEsatFXqsqymcRyNogSPDlhw
+ua8F2xJ/lOvJpMdDIkjRx5gMQ6hQFywj1zUMbhgzjqYJAUVpCDrr6pBNR0D0zgP2dOCLRIqw
+jibDq7I09LXXzUtcb1BL2hB4YQBN8xx+zcW+dHYnkw12PENfvv1Wi2eEPMyXBC11u908rGWP
+MwwaI2gBwwQAnWFayrXGptmw/HD7a8Qa+jIpimoxOUIuwxoaRrfUBiwH6Q36yA908TwvhAVM
+v3nJnb6W1E1Bnsr+fCJCX8VWQ4W+EiW1vCPvVnu5er1taL0Bw2wBrN0WiBYP2mE0MOtKXysq
+Im5NPb3zq4dUyFjjXqNBYfUcKpfxQr9feHhExOPxbreUK5XCAACBAAwtwIzVajyOu9HYBAWs
+van72UefkiRp2vcaRypTrxuyg+OpwuHMzMZG34nHSyWAUqBdqwECxkvAYDc7IBQKudDYLbZd
+iXzTzD46IUmfbTcaKl87K+06eAASKRQK/Y3jB1I8v8uiFAFqYBivMDRWq4UgC64rpEnndzLP
+vkjvnyBIy7bvb9udszo38JWCRDL+gr//+DgSh5tPU1SrxUNXcMEw3lfEoTB90m0BA5i8Y//+
+OH2wjhQbdvb7J5+fwYCd+4pAMk2/37+RLsTjtCAQBMFjAPaxBng+ltIlXXJZKZdj7YScCh6n
+7x6cirHy0x+e/PbTWT3/l68MpBn0Z2bS/XhOwHiC57EwgAPWxhrlgCNpEjv9EgLeuHz1VQn4
+4K2rr116iam+/PZzX7nyP4Ku/Os3iE/cCzS/vfvLZwebRdPOfnWYfu+s5wRwuPm4nYLCmpBg
+0H+YLtQxDNBQWLstYJs1UA6E8LKmafMXFfDO+2++++Xrr0rYH9Taa0xbV54A8AvEMCbgD8iW
+MEJonCIeojYiFXGiTbVVsttKuyuNZjRayQ9sLNtgk5TGwRhi3qQB0mTSxwJmTAgkLjEJJHXi
+MCkkdRpPaAykXcjYycRWCiZe1IiJzSOTCRIf9pxzL8fGNYnbrdLu/wO+HI7v43f/93+Oj0ny
++/3p+eyotGjM/K1+f4R9bPNzd7/kpOFhOLjhnSsXTo5cW55tbjpR9+XE/cG52dl/PPzTkOHS
+yFILiCXHYxQWMMFYHvV9BUpXV1/nBHgep6d7YQUznPn61tnwFMvhxH8/OMlEph8ePOYnAqP5
+YfCjAkuFXbnsyLuIfyFYMnor/kbt9Svjhs8eja7W1dY1XRgefzzS9hx4XRp5tDw7u7q6+q/L
+Ho9neWoKvoDN7smJiV4ANjlxe/ih0Qoq2NClU19/HZ5iHH+kyCVy8Wn/ZGD0tOgqAR3KJBOR
+TzT2pWBbcIKdvjL0mWN+daHpSE3T/ZPjg8Zvvz1rNrQsA62F5vqjJ04crV9YnR0dHZ33LC/P
+t0yOjXX2to9Nj5lun/eCkn/GbOjpvNMRlmLMSF7pCUQaHefEKwaLSY8IlkKCsaMH+8/Tw0Pm
+R7PN/vramtove1w+Y4f5jHVqFmo1NdU11Bwp0OkqA7V19IWny575+fHpyfbuzsnzY/2G/sEh
+Oyj5l8ydNwfCBsrCSGDbQN3dii/xFYMRWyKCZSCvnUTUYP9+4fVzZ02e1aMN9U0N6sZn5+qd
+Zw0tnlnIBbACldWNVVVVjfrqyoCmtqaufr5levryTePwwwnTkME+ZHfdOmu+1Nd+071xLpa9
+lQyUUFzqF+YvECwhH9WK6MHeGT7fe3NmtBnkUn2DrOGTTz68sTQ1u1DfVFdTq6nUV2lFEoFA
+UCTWNuoCtaBpwXP54cQX7rbPTxrMZrN9yHXq1JlLA5e/uL19A0BcLBnodAqpXxJ+SWA4slLi
+gVfUYL8bPj/e+c3s0boGTaVG+1//0/zg6WozyK1akFyNWokAR5FIpQ/UNNTN+/578gv3ymSH
+HXwVYrebu7p7Lt26fNn9dkSARAiWjRt+YWA4ogeLbbvw+rjxm9UTDUf00irp89WFj+uglkZT
+UF0lRlAw5HI5+KFoDNS0Pr3++aft3hX3bbDC32Oy27s6b5nPTlz2OXZtCsb8fw7GpwfB/s39
+5en71h0LTbXVWoHo+Ucfq6sRV6BSr5UgLcpLIpHKJTK9RlO/PP15r8874+4AAcEuD5jPjvU5
+rr0VPRgf1I48RnxhFg0P+knZqcE7mpqdixpTC9FrEuiaseFS01IZjJxkgr0BLCGJUcjZnRli
+QsvOQZ8HOClZFBifiMvZzWFmhRStBH5eNumYy8iE+wbdmXGkVA46TAyZYQmpmTTinZXXT590
+TDU3VJZKiwTPn6oE1Rrk1aiVYy7SSyiUSlU6TYNn+KHX613xgi+Pulwue9dE95lbY7eXrNuj
+BvMTBIPlR5GIHHJ2ckGlW68p8MqAXmYiwI0HrzvJroVBUA4XtTCT00PAsqhd+lNIDTYHNHBB
+Ox22kWBbiZz1A/Mpr3jYkATOLB6KgmET7TsGysWTXRkxLASWnJKYQfzL0pfnrz6eqj+iL5ZL
+5QJ1gVavI70kCAoF8pIKxWKxTF2te+AeHrzt9q4Yu3sBmLl7rPvMQJ91xLo3ajAWHzWj4Cav
+T0IY2Ji8AnS6KbFoorRhOp6JW1gsDAa748hBh0KbRB7VgsBYwb1tpbIb+YDtONSaQPOvV91U
+JIeSiwSjMQszid/+5dynf/ZOHQ1UgfwBOaYLVOl0gQK9VgqQMBf0AlwisVCkUvtb+m/3Q7C+
+zm4A1jc5cKt/xGqwvp0QLVg6UMOxE7yNEQoWT4HlUucaDFImyx8SCAx74UAYHKgZTx6Lj8Bw
+4FtFNdPWxanuycHbgtUIdkZGJvF7x8wfr3TsaNJpwSMHxQLVjdXVuiqxHPhJqEDpJRTCLaFW
+qWnp6O93r6wY29u7XT2u9ulTA4NWw9CI47VowVAkFlK5k7wJWEywawrJthsVGrKNw9i9bR0M
+ezFpuRksPHNnQDCUTP58KBN+4PSEDWBZOPfgDDuPerwZ2fkUGDx7gvhnX8u5T+9dbypXIDCJ
+QN2q1jeqAR/wowJtw3STCIsEcv3RG139RvfKTNvERJfLZhsbu9MBvADYrh8Als3Hz1ZSZDB8
+hUzQNQ5tJ0Iw8vzz0GCwZR2MfJDQsMFOR3RBgcS8WD4fg6XAvEomtzM2gOWQMPlk93REijI1
+NR1PKwCY96Jl7Nvv6g8qhGJIJBC0arSNWhGoV0IqxECrSFAkBz8E2oJjDR8+6zIa3d6ZtrGJ
+jh5b1/TEHednZgB27c2oweg0PNK/DIxFC9atbXh1ITNsWoEOwyEb89azJ8OPlSkZXAfTcNIE
+wVJxW8i9xIfBYG7f0tqVWzs+kEEwYZFQpW7Qi2RSEUkG3SRyAeACWkXlmmOt75a996zDaPR6
+L96fHrZ19PSe/8prNbvsAOyNqMGSqAbOy8GSMQ0EozhZeEZJgrFx4YKByl4uBcZN2wCGx0aq
+GoSD0UETzuOtFDU7FKzfNzjV/KelI8eBjVyiUMlkumMiiVBcLIIhJpNLAEJd0NpaUCoQFO9r
+s40bV1ZuDE939twxjX9qWzKccdntPwCMHkM1FL4UjBsbBpaOZxghGZaDLw/fhkwKDCcYlkGR
+SiVqOFh+KHpKhJn+P7X5Hj9ornv21wqRUCrTqiRFWm1BgUAqLJYVAy44vy+CWvqC1oAaVLDi
+Yll5W0ev22u5cX6y697NkfF2h9Xc02My/RCwNGzzYjDcFYMl4wsNBcsmrygGRQITFUkKjBMR
+jNoPMwwMG7HRlCIrEth9J+/B0dY9lY0isUolAmOjSCs/XC0AEwixRF4kh8lVVdC6p7oKuIkB
+l+y4vr9vfMW749nnw7ar/Vb74IjB1WFzmUasrwIsB9eWUDCUU1wWFahMxlNgKZHBYrmIJwwM
+J2QSHkTCwfaeM/IenIAzVVUpGBph3RJJ5AG1ADyMUgnQ0ur2aCrLpYIiIXhCi2UA7HnPSfft
+G/P3Hw5cHbcY7CbDkK2r467JynvzFYBl4WoVBGPDo4QH58VgcUg1MTIYvi8RwLbfP2l98GGl
+rlJXrkSVHg6LYDAEVQwkl6x6jyZQDthUYuRVLFMojn9y6r57Zn7qwvkvxv9mNfe7DPaOvns2
+p5W362cDy/8+WP5LMuzFYJmbgr3RdgWAlZfr9Kri4mIRRSYorxYIRPqAJqAXCQRSsVypkiIv
+mUL2h+E/G2fmPX8fG3Mvzg31dLgMpr6T92y+z3iv/bwZxmWFRHp8NGApPxzsNd6Y68GH1ftL
+lUoZJQbIJJLDlQWaPdUygUCO5hZSlVKMvJTH1Xd6LaOewLN+p2XO4eoCYN6rJwfcVgMv7hWA
+ZZBgkWpYPhETjLiEF4Dhr0TiI4LhQTQvAhjxdvtXO5ory2RKhQKJkWQCUeCwSgAKlxhyiUVi
+mUoEvRQK5bD1uqf+A92cw8eb493q7bjLW7ly1e0wOH9DvAKwPHwlEUbJsNgUDH/AyooMhqfH
+kcC28yYvLhSoK1RlZUqFDJiJUEiBlpgM2FAsBEMo5Dr+fOajuoKD79cuuUCGOW+es9nmVk6P
+rznszr0/EVjhi8DYfmwbBKMGNXa0YLhI0SKC4TGBGQnszaW28QXNvv2lpaUqFTSjyAAUjmIQ
+sv1KmVKpLKs4VFFW8sF7f12655ybM0303vN+NzPumOOZnG/9H8GyowEjdqKNsJl+DBclRBRg
+aaHnlU5EAsO6yAgvFLGoxZgE3trMR8d0Jfv374dkKiXKM1GYlkwmUu2XAS+Qh4cOvvfuu3+3
+Dvjm5oxX7tgscxaHw+Jz+Xb9eLCskEtLS38hGAOXYzz688M+MIF90DYDY4Q+kczNwTjUQgpO
+RwxGbB+8/s2xyn0lajVphvKMQitGIUPFq3h/qQJ4VVQcOLhv3+G/Dd6zzM2du3rXt7bm4F2z
+OF28hB8PlhP81JfMwkuAkcCoZ3IrEsvkrl9Wmh/hxFG6LP9mYH5OLpyXpuP1tUhguIhtycUL
+cMH/b/i1/bsdxyrf23cQkAGzKurZJNEwl0KpKCspUwKvUgD2fuC6q3/Rsma0OXlra9d8DovJ
+tJf40WDUDWfl8HOz6dSazmZgRPb6qhYHCKCg4VYWg8anZcLlrsLIYIg9hVpI201sCoa/Ec8v
+jEdFIPT/G/5jcEd96+EDFepy0gyhqVCmQTYUsHopSkuUFRUVhwDYoZrrvpk1yxrPabq2aOH5
+LJa7zjd+NBiqTGHBpW0GFsMK75uHLzAk+N8DC3sftNkcDK3Ahe+Ril95LZ75px8HDiorSsrL
+SwAayjQy1VSwasGAryUHQM0/dKDkwPsf/WUNeK05bb7FxYtO3+LFe78hIga6mYUhYPhZwJUi
+jyxcODKyqQqOZDAYLsLsfKySnhKsS4xQjHxaHFUZsQAfTbyCvTjYMXQBcRv2jQlZ/kebqZh+
+u3OHxzM//7RGdwCY7QNmJBrKNRAVIGD1Kqs4eAiCHfjD4etAa23RYru7trjIM/Ge+GzbiU0y
+jE5HuY8zjE6nc4MZBn5D5SgNyiKUHPit1k404Id0pYFtvPaSRd39ncmxsJm6H3kYcksGNUrS
+6dgFeIC+eUQGi6Qmlamj0FGGgVfoiyNzC6XPJ7jgTwwMFndxbcqzvOwZnV+o1ZUqSsuhGVYD
+UUpGRdmBgyRYDQJ74rsz82RxzWmyPLnrfC0yGDsNRGywISENRkzoX+Oo692dmJjITEUgJAE7
+pGsM3GZj9yQGk5kFUTc052YwU1JQO4pY/Ee8N3i1qdnM7FR8SsGjxOHuOPLAYRhJ1GHSgu1v
+zTwCYIBsfnS+WbOvDJmBpxOz/W/79fPithHHYXiEDjsEWkkRaKBikKDooltaYYggpk7ZwMLa
+LOyh1zKka1hXlfES6KY/LvYpkEP/ht5Nzu02PvRajHDcXHzYg0zYwgwMm6sO/c5Kzm5buuQ+
+fjHo/vAZaTxsOh0Nwet0Mn+lBiZf/ywk//XPtXix+Bxp1aMpTKzuzS+rHx8OvnrcHw0ATamN
+jjYNh6N8OJk8v3emvMTypZBSLhfTcrn4GGnV/ensshbr7sLOdp0P7z39+vj4KM8BDRptmmT5
+ZPT8+7MKpKq1FBWbvl4zAQPTLFrNVkqsiElRXBZdZND07pP+lyffZPngH43zYT5/JaGKcyn4
+y4Uo18sPkG65f83qiZlBD8TaBvGjINn79uRxf5Dl1w2y8akaWBNb/jEr+fknSL+st/M3amIJ
+CuPdorBIEISYuGDWPx7mz7KrcvUbzy9kU3n+23nJZAdp184Oss6VWNElYWTHvR4NbIgSaiV7
++enJUdaYZZMfzip+lWTV78uSCf4R0i7D9JHzdr5aFbuUKqbUsSEXCsIgvbuXwZUifwYNxuUF
+rxNysS4Z4w80HJhhehGyHsG3skgwSAUBYEEOZLkBtdM7e+N8MsmywU9ncgM2fQFe4jOkJxgm
+HolnrUuYWL0tR2XVOSBoJXceqr/djZYQHLSYeIB0TC2MhKHn7rdasZpYM64buXYYuukX/IKL
+dzEmP0VatmP6Hg5DinHabdl0s7CbYI7juiQ6lDe9uAgQpOeZ9CNCQkoxsVwaNBu7eSgBzMbt
+6lpLMNkhqE7T1z4GsjDEwEaD2gzQrgtwXCknwIK4YDFCGoPBxoAMEwjYrtE2OZTGFQcpwUvl
+xQ8DhAyNxWBjpu97UBTVcEotsOtcm7idSigp0SqFkKztI9PcQZqPDPIhUFNoNVlz5U8Oqnpe
+RYtxWSaG4ZmGAtN6ZHU1G6ApMiVGMd2vuOJirV4pJevaKIr8BkxzNOidmiIDMQLzkkJwznrt
+9kElDlPPJNF/B7YdG5CBGE4PKthX2Woncacq2ykx8JXXv8G2Y6u/nF7SacdxYlFi7e8ngWdi
+gj1/6/W/lw0Mh9NQ9zRqE9PwMI6A65bzuBWDIqyCB2gB123z2r7Mmm9mk19z3eK1FQMylQ/B
+w3gfrq3Zpvfm2qo1Ia37G8S93Ux/GSLZAAAAAElFTkSuQmCC
+
+----------------CHOPCHOP0--
+
diff --git a/comm/mail/base/test/browser/files/sampleContent.html b/comm/mail/base/test/browser/files/sampleContent.html
new file mode 100644
index 0000000000..05528ac9f1
--- /dev/null
+++ b/comm/mail/base/test/browser/files/sampleContent.html
@@ -0,0 +1,16 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8" />
+ <title>Sample Content</title>
+ <link rel="icon" href="tb-logo.png" />
+ </head>
+ <body>
+ <p>This is a page of sample content for tests.</p>
+ <p><a href="https://www.thunderbird.net/">Link to a web page</a></p>
+ <form>
+ <input type="text" />
+ </form>
+ <p><img src="tb-logo.png" width="304" height="84" /></p>
+ </body>
+</html>
diff --git a/comm/mail/base/test/browser/files/selectionWidget.js b/comm/mail/base/test/browser/files/selectionWidget.js
new file mode 100644
index 0000000000..b1e5f98e25
--- /dev/null
+++ b/comm/mail/base/test/browser/files/selectionWidget.js
@@ -0,0 +1,225 @@
+/* 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/. */
+
+var { SelectionWidgetController } = ChromeUtils.import(
+ "resource:///modules/SelectionWidgetController.jsm"
+);
+
+/**
+ * Data for a selectable item.
+ *
+ * @typedef {object} ItemData
+ * @property {HTMLElement} element - The DOM node for the item.
+ * @property {boolean} selected - Whether the item is selected.
+ */
+
+class TestSelectionWidget extends HTMLElement {
+ /**
+ * The selectable items for this widget, in DOM ordering.
+ *
+ * @type {ItemData[]}
+ */
+ items = [];
+ #focusItem = this;
+ #controller = null;
+
+ connectedCallback() {
+ let widget = this;
+
+ widget.tabIndex = 0;
+ widget.setAttribute("role", "listbox");
+ widget.setAttribute("aria-label", "Test selection widget");
+ widget.setAttribute(
+ "aria-orientation",
+ widget.getAttribute("layout-direction")
+ );
+ let model = widget.getAttribute("selection-model");
+ widget.setAttribute("aria-multiselectable", model == "browse-multi");
+
+ this.#controller = new SelectionWidgetController(widget, model, {
+ getLayoutDirection() {
+ return widget.getAttribute("layout-direction");
+ },
+ indexFromTarget(target) {
+ for (let i = 0; i < widget.items.length; i++) {
+ if (widget.items[i].element.contains(target)) {
+ return i;
+ }
+ }
+ return null;
+ },
+ getPageSizeDetails() {
+ if (widget.hasAttribute("no-pages")) {
+ return null;
+ }
+ let itemRect = widget.items[0]?.element.getBoundingClientRect();
+ if (widget.getAttribute("layout-direction") == "vertical") {
+ return {
+ itemSize: itemRect?.height ?? null,
+ viewSize: widget.clientHeight,
+ viewOffset: widget.scrollTop,
+ };
+ }
+ return {
+ itemSize: itemRect?.width ?? null,
+ viewSize: widget.clientWidth,
+ viewOffset: Math.abs(widget.scrollLeft),
+ };
+ },
+ setFocusableItem(index, focus) {
+ widget.#focusItem.tabIndex = -1;
+ widget.#focusItem =
+ index == null ? widget : widget.items[index].element;
+ widget.#focusItem.tabIndex = 0;
+ if (focus) {
+ widget.#focusItem.focus();
+ widget.#focusItem.scrollIntoView({
+ block: "nearest",
+ inline: "nearest",
+ });
+ }
+ },
+ setItemSelectionState(index, number, selected) {
+ for (let i = index; i < index + number; i++) {
+ widget.items[i].selected = selected;
+ widget.items[i].element.classList.toggle("selected", selected);
+ widget.items[i].element.setAttribute("aria-selected", selected);
+ }
+ },
+ });
+ }
+
+ #createItemElement(text) {
+ for (let { element } of this.items) {
+ if (element.textContent == text) {
+ throw new Error(`An item with the text "${text}" already exists`);
+ }
+ }
+ let element = this.ownerDocument.createElement("span");
+ element.textContent = text;
+ element.setAttribute("role", "option");
+ element.tabIndex = -1;
+ element.draggable = this.hasAttribute("items-draggable");
+ return element;
+ }
+
+ /**
+ * Create new items and add them to the widget.
+ *
+ * @param {number} index - The starting index at which to add the items.
+ * @param {string[]} textList - The textContent for the items to add. Each
+ * entry in the array will create one item in the same order.
+ */
+ addItems(index, textList) {
+ for (let [i, text] of textList.entries()) {
+ let element = this.#createItemElement(text);
+ this.insertBefore(element, this.items[index + i]?.element ?? null);
+ this.items.splice(index + i, 0, { element });
+ }
+ this.#controller.addedSelectableItems(index, textList.length);
+ // Force re-layout. This is needed for the items to be able to enter the
+ // focus cycle immediately.
+ this.getBoundingClientRect();
+ }
+
+ /**
+ * Remove items from the widget.
+ *
+ * @param {number} index - The starting index at which to remove items.
+ * @param {number} number - How many items to remove.
+ */
+ removeItems(index, number) {
+ this.#controller.removeSelectableItems(index, number, () => {
+ for (let { element } of this.items.splice(index, number)) {
+ element.remove();
+ }
+ });
+ }
+
+ /**
+ * Move items within the widget.
+ *
+ * @param {number} from - The index at which to move items from.
+ * @param {number} to - The index at which to move items to.
+ * @param {number} number - How many items to move.
+ * @param {boolean} reCreate - Whether to recreate the item when
+ * moving it. Otherwise the existing item is used.
+ */
+ moveItems(from, to, number, reCreate) {
+ if (reCreate == undefined) {
+ throw new Error("Missing reCreate argument");
+ }
+ this.#controller.moveSelectableItems(from, to, number, () => {
+ let moving = this.items.splice(from, number);
+ for (let [i, item] of moving.entries()) {
+ item.element.remove();
+ if (reCreate) {
+ let text = item.element.textContent;
+ item = { element: this.#createItemElement(text) };
+ }
+ this.insertBefore(item.element, this.items[to + i]?.element ?? null);
+ this.items.splice(to + i, 0, item);
+ }
+ });
+ }
+
+ /**
+ * Selects a single item via the SelectionWidgetController.selectSingleItem
+ * method.
+ *
+ * @param {number} index - The index of the item to select.
+ */
+ selectSingleItem(index) {
+ this.#controller.selectSingleItem(index);
+ }
+
+ /**
+ * Changes the selection state of an item via the
+ * SelectionWidgetController.setItemSelected method.
+ *
+ * @param {number} index - The index of the item to set the selection state
+ * of.
+ * @param {boolean} select - Whether to select the item.
+ */
+ setItemSelected(index, select) {
+ this.#controller.setItemSelected(index, select);
+ }
+
+ /**
+ * Get the list of selected item's indices.
+ *
+ * @returns {number[]} - The indices for selected items.
+ */
+ selectedIndices() {
+ let indices = [];
+ for (let i = 0; i < this.items.length; i++) {
+ // Assert that the item has a defined selection state set in
+ // setItemSelectionState.
+ if (typeof this.items[i].selected != "boolean") {
+ throw new Error(`Item ${i} has an undefined selection state`);
+ }
+ // Assert that our stored selection state matches that returned by the
+ // controller API.
+ let itemIsSelected = this.#controller.itemIsSelected(i);
+ if (this.items[i].selected != itemIsSelected) {
+ throw new Error(
+ `itemIsSelected(${i}): "${itemIsSelected}" does not match stored selection state "${this.items[i].selected}"`
+ );
+ }
+ if (itemIsSelected) {
+ indices.push(i);
+ }
+ }
+ return indices;
+ }
+
+ /**
+ * Get the return of SelectionWidgetController.getSelectionRanges
+ */
+ getSelectionRanges() {
+ return this.#controller.getSelectionRanges();
+ }
+}
+
+customElements.define("test-selection-widget", TestSelectionWidget);
diff --git a/comm/mail/base/test/browser/files/selectionWidget.xhtml b/comm/mail/base/test/browser/files/selectionWidget.xhtml
new file mode 100644
index 0000000000..e5f66fc30c
--- /dev/null
+++ b/comm/mail/base/test/browser/files/selectionWidget.xhtml
@@ -0,0 +1,57 @@
+<!DOCTYPE html>
+<html xmlns="http://www.w3.org/1999/xhtml">
+<head>
+ <meta charset="utf-8" />
+ <title>Test for SelectionWidgetController</title>
+ <style>
+ test-selection-widget {
+ display: flex;
+ align-items: start;
+ border: 1px solid black;
+ width: 600px;
+ height: 600px;
+ overflow: auto;
+ }
+
+ test-selection-widget[layout-direction="vertical"] {
+ flex-direction: column;
+ }
+
+ /* Fit 20 items in the view. */
+ test-selection-widget[layout-direction="vertical"] > * {
+ height: 30px;
+ }
+ test-selection-widget[layout-direction="horizontal"] > * {
+ width: 30px;
+ writing-mode: vertical-rl;
+ }
+
+ test-selection-widget > * {
+ padding-inline: 10px;
+ box-sizing: border-box;
+ border: 1px solid grey;
+ white-space: nowrap;
+ flex: 0 0 auto;
+ }
+
+ .selected {
+ background: pink;
+ }
+
+ :focus {
+ outline: 3px dashed black;
+ outline-offset: -3px;
+ }
+
+ :focus-visible {
+ outline-color: blue;
+ }
+ </style>
+ <!-- Load the SelectionWidgetController class inline if testing in a browser.
+ <script src="../../../../modules/SelectionWidgetController.jsm"></script>
+ -->
+ <script defer="defer" src="selectionWidget.js"></script>
+</head>
+<body>
+</body>
+</html>
diff --git a/comm/mail/base/test/browser/files/tb-logo.png b/comm/mail/base/test/browser/files/tb-logo.png
new file mode 100644
index 0000000000..aac56e2546
--- /dev/null
+++ b/comm/mail/base/test/browser/files/tb-logo.png
Binary files differ
diff --git a/comm/mail/base/test/browser/files/tree-element-test-common.js b/comm/mail/base/test/browser/files/tree-element-test-common.js
new file mode 100644
index 0000000000..6f22962aca
--- /dev/null
+++ b/comm/mail/base/test/browser/files/tree-element-test-common.js
@@ -0,0 +1,73 @@
+/* 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/. */
+
+// FIXME: Wrap the whole method around the document load listener to prevent the
+// undefined state of the "tree-view-table-row" element. This is due to the .mjs
+// nature of the class file.
+window.addEventListener("load", () => {
+ class AlternativeCardRow extends customElements.get("tree-view-table-row") {
+ static ROW_HEIGHT = 80;
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+
+ super.connectedCallback();
+
+ this.cell = this.appendChild(document.createElement("td"));
+ }
+
+ get index() {
+ return super.index;
+ }
+
+ set index(index) {
+ super.index = index;
+ this.cell.textContent = this.view.getCellText(index, {
+ id: "GeneratedName",
+ });
+ }
+ }
+ customElements.define("alternative-row", AlternativeCardRow, {
+ extends: "tr",
+ });
+
+ class TestView {
+ values = [];
+
+ constructor(start, count) {
+ for (let i = start; i < start + count; i++) {
+ this.values.push(i);
+ }
+ }
+
+ get rowCount() {
+ return this.values.length;
+ }
+
+ getCellText(index, column) {
+ return `${column.id} ${this.values[index]}`;
+ }
+
+ isContainer() {
+ return false;
+ }
+
+ isContainerOpen() {
+ return false;
+ }
+
+ selectionChanged() {}
+
+ setTree() {}
+ }
+
+ const tree = document.getElementById("testTree");
+ tree.table.setBodyID("testBody");
+ tree.addEventListener("select", () => {
+ console.log("select event, selected indices:", tree.selectedIndices);
+ });
+ tree.view = new TestView(0, 150);
+});
diff --git a/comm/mail/base/test/browser/files/tree-element-test-header.js b/comm/mail/base/test/browser/files/tree-element-test-header.js
new file mode 100644
index 0000000000..37d3b583e4
--- /dev/null
+++ b/comm/mail/base/test/browser/files/tree-element-test-header.js
@@ -0,0 +1,64 @@
+/* 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/. */
+
+// FIXME: Wrap the whole method around the document load listener to prevent the
+// undefined state of the "tree-view-table-row" element. This is due to the .mjs
+// nature of the class file.
+window.addEventListener("load", () => {
+ class TestCardRow extends customElements.get("tree-view-table-row") {
+ static ROW_HEIGHT = 50;
+
+ static COLUMNS = [
+ {
+ id: "testCol",
+ // Ensure that a table header is rendered in order to verify that the
+ // header's presence doesn't cause issues with scroll calculations.
+ l10n: {
+ header: "threadpane-column-header-subject",
+ menuitem: "threadpane-column-label-subject",
+ },
+ },
+ ];
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+
+ super.connectedCallback();
+
+ this.cell = this.appendChild(document.createElement("td"));
+ let container = this.cell.appendChild(document.createElement("div"));
+
+ this.d1 = container.appendChild(document.createElement("div"));
+ this.d1.classList.add("d1");
+
+ this.d2 = this.d1.appendChild(document.createElement("div"));
+ this.d2.classList.add("d2");
+
+ this.d3 = this.d1.appendChild(document.createElement("div"));
+ this.d3.classList.add("d3");
+ }
+
+ get index() {
+ return super.index;
+ }
+
+ set index(index) {
+ super.index = index;
+ this.d2.textContent = this.view.getCellText(index, {
+ id: "GeneratedName",
+ });
+ this.d3.textContent = this.view.getCellText(index, {
+ id: "PrimaryEmail",
+ });
+ this.dataset.value = this.view.values[index];
+ }
+ }
+ customElements.define("test-row", TestCardRow, { extends: "tr" });
+
+ const tree = document.getElementById("testTree");
+ tree.setAttribute("rows", "test-row");
+ tree.table.setColumns(TestCardRow.COLUMNS);
+});
diff --git a/comm/mail/base/test/browser/files/tree-element-test-header.xhtml b/comm/mail/base/test/browser/files/tree-element-test-header.xhtml
new file mode 100644
index 0000000000..522e3e5c60
--- /dev/null
+++ b/comm/mail/base/test/browser/files/tree-element-test-header.xhtml
@@ -0,0 +1,61 @@
+<?xml version="1.0"?>
+<!-- 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/. -->
+
+<!DOCTYPE html>
+<html xmlns="http://www.w3.org/1999/xhtml">
+<head>
+ <meta charset="utf-8" />
+ <title>Test for the tree-view custom element</title>
+ <link rel="stylesheet" href="chrome://messenger/skin/shared/tree-listbox.css" />
+ <!-- Localization is necessary for the table header to display text. -->
+ <link rel="localization" href="messenger/about3Pane.ftl" />
+ <style>
+ :root {
+ --color-gray-20: gray;
+ --selected-item-color: rebeccapurple;
+ --selected-item-text-color: white;
+ }
+
+ /* We want a total visible row area of 630px, but we need to account for the
+ * height of the header as well. */
+ #testTree {
+ height: calc(var(--tree-header-table-height) + 630px);
+ }
+
+ .tree-view-scrollable-container {
+ scroll-behavior: unset;
+ }
+
+ tr[is="test-row"] td > div {
+ display: flex;
+ align-items: center;
+ box-sizing: border-box;
+ }
+
+ tr[is="test-row"] td div.d1 {
+ flex: 1;
+ }
+
+ tr[is="test-row"] td div.d1 > div.d2 {
+ line-height: 1.2;
+ }
+
+ tr[is="test-row"] td div.d1 > div.d3 {
+ line-height: 1.2;
+ font-size: 13px;
+ }
+ </style>
+ <script type="module" src="chrome://messenger/content/tree-view.mjs"></script>
+ <script src="tree-element-test-header.js"></script>
+ <script src="tree-element-test-common.js"></script>
+</head>
+<!-- We force layout-table in order to ensure that table header rows are
+ displayed.-->
+<body class="layout-table">
+ <input id="before" placeholder="something to focus on" />
+ <tree-view id="testTree" data-select-delay="250"/>
+ <input id="after" placeholder="something to focus on" />
+</body>
+</html>
diff --git a/comm/mail/base/test/browser/files/tree-element-test-levels.js b/comm/mail/base/test/browser/files/tree-element-test-levels.js
new file mode 100644
index 0000000000..7ea7eb8232
--- /dev/null
+++ b/comm/mail/base/test/browser/files/tree-element-test-levels.js
@@ -0,0 +1,118 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals PROTO_TREE_VIEW */
+
+// FIXME: Wrap the whole method around the document load listener to prevent the
+// undefined state of the "tree-view-table-row" element. This is due to the .mjs
+// nature of the class file.
+window.addEventListener("load", () => {
+ class TestCardRow extends customElements.get("tree-view-table-row") {
+ static ROW_HEIGHT = 30;
+
+ static COLUMNS = [
+ {
+ id: "testCol",
+ },
+ ];
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+
+ super.connectedCallback();
+
+ this.cell = this.appendChild(document.createElement("td"));
+ let container = this.cell.appendChild(document.createElement("div"));
+
+ this.threader = container.appendChild(document.createElement("button"));
+ this.threader.textContent = "↳";
+ this.threader.classList.add("tree-button-thread");
+
+ this.twisty = container.appendChild(document.createElement("div"));
+ this.twisty.textContent = "v";
+ this.twisty.classList.add("twisty");
+
+ this.d2 = container.appendChild(document.createElement("div"));
+ this.d2.classList.add("d2");
+ }
+
+ get index() {
+ return super.index;
+ }
+
+ set index(index) {
+ super.index = index;
+ this.id = this.view.getRowProperties(index);
+ this.classList.remove("level0", "level1", "level2");
+ this.classList.add(`level${this.view.getLevel(index)}`);
+ this.d2.textContent = this.view.getCellText(index, { id: "text" });
+ }
+ }
+ customElements.define("test-row", TestCardRow, { extends: "tr" });
+
+ class TreeItem {
+ _children = [];
+
+ constructor(id, text, open = false, level = 0) {
+ this._id = id;
+ this._text = text;
+ this._open = open;
+ this._level = level;
+ }
+
+ getText() {
+ return this._text;
+ }
+
+ get open() {
+ return this._open;
+ }
+
+ get level() {
+ return this._level;
+ }
+
+ get children() {
+ return this._children;
+ }
+
+ getProperties() {
+ return this._id;
+ }
+
+ addChild(treeItem) {
+ treeItem._parent = this;
+ treeItem._level = this._level + 1;
+ this.children.push(treeItem);
+ }
+ }
+
+ let testView = new PROTO_TREE_VIEW();
+ testView._rowMap.push(new TreeItem("row-1", "Item with no children"));
+ testView._rowMap.push(new TreeItem("row-2", "Item with children"));
+ testView._rowMap.push(new TreeItem("row-3", "Item with grandchildren"));
+ testView._rowMap[1].addChild(new TreeItem("row-2-1", "First child"));
+ testView._rowMap[1].addChild(new TreeItem("row-2-2", "Second child"));
+ testView._rowMap[2].addChild(new TreeItem("row-3-1", "First child"));
+ testView._rowMap[2].children[0].addChild(
+ new TreeItem("row-3-1-1", "First grandchild")
+ );
+ testView._rowMap[2].children[0].addChild(
+ new TreeItem("row-3-1-2", "Second grandchild")
+ );
+ testView.toggleOpenState(1);
+ testView.toggleOpenState(4);
+ testView.toggleOpenState(5);
+
+ let tree = document.getElementById("testTree");
+ tree.table.setBodyID("testBody");
+ tree.setAttribute("rows", "test-row");
+ tree.table.setColumns(TestCardRow.COLUMNS);
+ tree.addEventListener("select", () => {
+ console.log("select event, selected indices:", tree.selectedIndices);
+ });
+ tree.view = testView;
+});
diff --git a/comm/mail/base/test/browser/files/tree-element-test-levels.xhtml b/comm/mail/base/test/browser/files/tree-element-test-levels.xhtml
new file mode 100644
index 0000000000..1175887e74
--- /dev/null
+++ b/comm/mail/base/test/browser/files/tree-element-test-levels.xhtml
@@ -0,0 +1,65 @@
+<!DOCTYPE html>
+<html xmlns="http://www.w3.org/1999/xhtml">
+<head>
+ <meta charset="utf-8" />
+ <title>Test for the tree-view custom element</title>
+ <link rel="stylesheet" href="chrome://messenger/skin/shared/tree-listbox.css" />
+ <style>
+ :root {
+ --color-gray-20: gray;
+ --selected-item-color: rebeccapurple;
+ --selected-item-text-color: white;
+ }
+
+ .tree-view-scrollable-container {
+ height: 630px;
+ scroll-behavior: unset;
+ }
+
+ tr[is="test-row"] td > div {
+ display: flex;
+ align-items: center;
+ }
+
+ button.threader {
+ width: 1em;
+ height: 1em;
+ }
+
+ div.twisty {
+ width: 1em;
+ height: 1em;
+ }
+
+ tr[is="test-row"].children button.threader {
+ display: inline-block;
+ }
+
+ tr[is="test-row"] button.threader {
+ display: hidden;
+ }
+
+ tr[is="test-row"].children div.twisty {
+ background-color: green;
+ }
+
+ tr[is="test-row"].children.collapsed div.twisty {
+ background-color: red;
+ }
+
+ tr[is="test-row"].level1 .d2 {
+ padding-inline-start: 1em;
+ }
+
+ tr[is="test-row"].level2 .d2 {
+ padding-inline-start: 2em;
+ }
+ </style>
+ <script type="module" defer="defer" src="chrome://messenger/content/tree-view.mjs"></script>
+ <script defer="defer" src="chrome://messenger/content/jsTreeView.js"></script>
+ <script defer="defer" src="tree-element-test-levels.js"></script>
+</head>
+<body>
+ <tree-view id="testTree" data-select-delay="250"/>
+</body>
+</html>
diff --git a/comm/mail/base/test/browser/files/tree-element-test-no-header.js b/comm/mail/base/test/browser/files/tree-element-test-no-header.js
new file mode 100644
index 0000000000..8a515be5e2
--- /dev/null
+++ b/comm/mail/base/test/browser/files/tree-element-test-no-header.js
@@ -0,0 +1,58 @@
+/* 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/. */
+
+// FIXME: Wrap the whole method around the document load listener to prevent the
+// undefined state of the "tree-view-table-row" element. This is due to the .mjs
+// nature of the class file.
+window.addEventListener("load", () => {
+ class TestCardRow extends customElements.get("tree-view-table-row") {
+ static ROW_HEIGHT = 50;
+
+ static COLUMNS = [
+ {
+ id: "testCol",
+ },
+ ];
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+
+ super.connectedCallback();
+
+ this.cell = this.appendChild(document.createElement("td"));
+ let container = this.cell.appendChild(document.createElement("div"));
+
+ this.d1 = container.appendChild(document.createElement("div"));
+ this.d1.classList.add("d1");
+
+ this.d2 = this.d1.appendChild(document.createElement("div"));
+ this.d2.classList.add("d2");
+
+ this.d3 = this.d1.appendChild(document.createElement("div"));
+ this.d3.classList.add("d3");
+ }
+
+ get index() {
+ return super.index;
+ }
+
+ set index(index) {
+ super.index = index;
+ this.d2.textContent = this.view.getCellText(index, {
+ id: "GeneratedName",
+ });
+ this.d3.textContent = this.view.getCellText(index, {
+ id: "PrimaryEmail",
+ });
+ this.dataset.value = this.view.values[index];
+ }
+ }
+ customElements.define("test-row", TestCardRow, { extends: "tr" });
+
+ const tree = document.getElementById("testTree");
+ tree.setAttribute("rows", "test-row");
+ tree.table.setColumns(TestCardRow.COLUMNS);
+});
diff --git a/comm/mail/base/test/browser/files/tree-element-test-no-header.xhtml b/comm/mail/base/test/browser/files/tree-element-test-no-header.xhtml
new file mode 100644
index 0000000000..7605279ba6
--- /dev/null
+++ b/comm/mail/base/test/browser/files/tree-element-test-no-header.xhtml
@@ -0,0 +1,54 @@
+<?xml version="1.0"?>
+<!-- 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/. -->
+
+<!DOCTYPE html>
+<html xmlns="http://www.w3.org/1999/xhtml">
+<head>
+ <meta charset="utf-8" />
+ <title>Test for the tree-view custom element</title>
+ <link rel="stylesheet" href="chrome://messenger/skin/shared/tree-listbox.css" />
+ <style>
+ :root {
+ --color-gray-20: gray;
+ --selected-item-color: rebeccapurple;
+ --selected-item-text-color: white;
+ }
+
+ /* We want a total visible row area of 630px. Intentionally avoid leaving
+ * room for a header. */
+ .tree-view-scrollable-container {
+ height: 630px;
+ scroll-behavior: unset;
+ }
+
+ tr[is="test-row"] td > div {
+ display: flex;
+ align-items: center;
+ box-sizing: border-box;
+ }
+
+ tr[is="test-row"] td div.d1 {
+ flex: 1;
+ }
+
+ tr[is="test-row"] td div.d1 > div.d2 {
+ line-height: 1.2;
+ }
+
+ tr[is="test-row"] td div.d1 > div.d3 {
+ line-height: 1.2;
+ font-size: 13px;
+ }
+ </style>
+ <script type="module" src="chrome://messenger/content/tree-view.mjs"></script>
+ <script src="tree-element-test-no-header.js"></script>
+ <script src="tree-element-test-common.js"></script>
+</head>
+<body>
+ <input id="before" placeholder="something to focus on" />
+ <tree-view id="testTree" data-select-delay="250"/>
+ <input id="after" placeholder="something to focus on" />
+</body>
+</html>
diff --git a/comm/mail/base/test/browser/files/treeListbox.xhtml b/comm/mail/base/test/browser/files/treeListbox.xhtml
new file mode 100644
index 0000000000..a760ca141d
--- /dev/null
+++ b/comm/mail/base/test/browser/files/treeListbox.xhtml
@@ -0,0 +1,390 @@
+<!DOCTYPE html>
+<html xmlns="http://www.w3.org/1999/xhtml">
+<head>
+ <meta charset="utf-8" />
+ <title>Test for the tree-listbox custom element</title>
+ <style>
+ :focus {
+ outline: 3px blue solid;
+ }
+ html {
+ height: 100%;
+ }
+ body {
+ height: 100%;
+ display: flex;
+ margin: 0;
+ }
+ ul[is="tree-listbox"] {
+ overflow-y: auto;
+ white-space: nowrap;
+ }
+ ul {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ }
+ li > div {
+ display: flex;
+ align-items: center;
+ }
+ li.selected > div {
+ color: white;
+ background-color: blue;
+ }
+ li > ul {
+ padding-inline-start: 1em;
+ }
+ li.collapsed > ul {
+ display: none;
+ }
+ div.twisty {
+ width: 1em;
+ height: 1em;
+ }
+ li.children > div > div.twisty {
+ background-color: green;
+ }
+ li.children.collapsed > div > div.twisty {
+ background-color: red;
+ }
+ li.unselectable > div {
+ background-color: red;
+ }
+ </style>
+ <script defer="defer" src="chrome://messenger/content/tree-listbox.js"></script>
+</head>
+<body>
+ <ul is="tree-listbox" role="tree">
+ <li id="row-1">
+ <div>
+ <div class="twisty"></div>
+ Item with no children
+ </div>
+ </li>
+ <li id="row-2">
+ <div>
+ <div class="twisty"></div>
+ Item with children
+ </div>
+ <ul>
+ <li id="row-2-1">
+ <div>
+ <div class="twisty"></div>
+ First child
+ </div>
+ </li>
+ <li id="row-2-2">
+ <div>
+ <div class="twisty"></div>
+ Second child
+ </div>
+ </li>
+ </ul>
+ </li>
+ <li id="row-3">
+ <div>
+ <div class="twisty"></div>
+ Item with grandchildren
+ </div>
+ <ul>
+ <li id="row-3-1">
+ <div>
+ <div class="twisty"></div>
+ First child
+ </div>
+ <ul>
+ <li id="row-3-1-1">
+ <div>
+ <div class="twisty"></div>
+ First grandchild
+ </div>
+ </li>
+ <li id="row-3-1-2">
+ <div>
+ <div class="twisty"></div>
+ Second grandchild
+ </div>
+ </li>
+ </ul>
+ </li>
+ </ul>
+ </li>
+ </ul>
+ <template id="rowToAdd">
+ <li id="new-row">
+ <div>
+ <div class="twisty"></div>
+ New row
+ </div>
+ </li>
+ </template>
+ <template id="rowsToAdd">
+ <li id="added-row">
+ <div>
+ <div class="twisty"></div>
+ Added row
+ </div>
+ <ul>
+ <li id="added-row-1">
+ <div>
+ <div class="twisty"></div>
+ Added child
+ </div>
+ <ul>
+ <li id="added-row-1-1">
+ <div>
+ <div class="twisty"></div>
+ Added grandchild
+ </div>
+ </li>
+ </ul>
+ </li>
+ <li id="added-row-2">
+ <div>
+ <div class="twisty"></div>
+ Added child
+ </div>
+ </li>
+ </ul>
+ </li>
+ </template>
+ <!-- Larger tree for deleting from -->
+ <ul>
+ <li>Before</li>
+ <li>
+ <!-- Place under a plain <li> an <ul> to make sure our selector logic
+ - doesn't break down. -->
+ <ul is="tree-listbox" id="deleteTree" role="tree">
+ <li id="dRow-1" class="collapsed">
+ <div>
+ <div class="twisty"></div>
+ Item with collapsed children
+ </div>
+ <ul>
+ <li id="dRow-1-1">
+ <div>
+ <div class="twisty"></div>
+ Hidden child
+ </div>
+ </li>
+ </ul>
+ </li>
+ <li id="dRow-2">
+ <div>
+ <div class="twisty"></div>
+ Item with children
+ </div>
+ <ul>
+ <li id="dRow-2-1">
+ <div>
+ <div class="twisty"></div>
+ First child
+ </div>
+ </li>
+ <li id="dRow-2-2">
+ <div>
+ <div class="twisty"></div>
+ Second child
+ </div>
+ </li>
+ </ul>
+ </li>
+ <li id="dRow-3">
+ <div>
+ <div class="twisty"></div>
+ Item with grandchildren
+ </div>
+ <ul>
+ <li id="dRow-3-1">
+ <div>
+ <div class="twisty"></div>
+ First child
+ </div>
+ <ul>
+ <li id="dRow-3-1-1" class="collapsed">
+ <div>
+ <div class="twisty"></div>
+ First grandchild
+ </div>
+ <ul>
+ <li id="dRow-3-1-1-1">
+ <div>
+ <div class="twisty"></div>
+ Hidden child
+ </div>
+ </li>
+ </ul>
+ </li>
+ <li id="dRow-3-1-2">
+ <div>
+ <div class="twisty"></div>
+ Second grandchild
+ </div>
+ </li>
+ <li id="dRow-3-1-3" class="collapsed">
+ <div>
+ <div class="twisty"></div>
+ Third grandchild
+ </div>
+ <ul>
+ <li id="dRow-3-1-3-1">
+ <div>
+ <div class="twisty"></div>
+ Hidden child
+ </div>
+ </li>
+ </ul>
+ </li>
+ </ul>
+ </li>
+ </ul>
+ </li>
+ <li id="dRow-4">
+ <div>
+ <div class="twisty"></div>
+ Fourth item
+ </div>
+ <ul>
+ <li id="dRow-4-1" class="collapsed">
+ <div>
+ <div class="twisty"></div>
+ First child
+ </div>
+ <ul>
+ <li id="dRow-4-1-1">
+ <div>
+ <div class="twisty"></div>
+ Hidden child 1
+ </div>
+ </li>
+ <li id="dRow-4-1-2">
+ <div>
+ <div class="twisty"></div>
+ Hidden child 2
+ </div>
+ </li>
+ </ul>
+ </li>
+ <li id="dRow-4-2">
+ <div>
+ <div class="twisty"></div>
+ Second child
+ </div>
+ </li>
+ <li id="dRow-4-3">
+ <div>
+ <div class="twisty"></div>
+ Third child
+ </div>
+ <ul>
+ <li id="dRow-4-3-1">
+ <div>
+ <div class="twisty"></div>
+ First Grand child
+ </div>
+ </li>
+ <li id="dRow-4-3-2">
+ <div>
+ <div class="twisty"></div>
+ Second Grand child
+ </div>
+ </li>
+ </ul>
+ </li>
+ <li id="dRow-4-4" class="collapsed">
+ <div>
+ <div class="twisty"></div>
+ Fourth child
+ </div>
+ <ul>
+ <li id="dRow-4-4-1">
+ <div>
+ <div class="twisty"></div>
+ Hidden child 1
+ </div>
+ </li>
+ <li id="dRow-4-4-2">
+ <div>
+ <div class="twisty"></div>
+ Hidden child 2
+ </div>
+ </li>
+ </ul>
+ </li>
+ </ul>
+ </li>
+ <li id="dRow-5">
+ <div>
+ <div class="twisty"></div>
+ Second last item
+ </div>
+ <ul>
+ <li id="dRow-5-1">
+ <div>
+ <div class="twisty"></div>
+ Last child
+ </div>
+ </li>
+ </ul>
+ </li>
+ <li id="dRow-6">
+ <div>
+ <div class="twisty"></div>
+ Last item
+ </div>
+ </li>
+ </ul>
+ </li>
+ <li>After</li>
+ </ul>
+ <!-- Tree with unselectable rows -->
+ <ul is="tree-listbox" id="unselectableTree" role="tree">
+ <li id="uRow-1" class="unselectable">
+ <div>Item with no children</div>
+ </li>
+ <li id="uRow-2" class="unselectable">
+ <div>Item with children</div>
+ <ul>
+ <li id="uRow-2-1">
+ <div>
+ <div class="twisty"></div>
+ First child
+ </div>
+ </li>
+ <li id="uRow-2-2">
+ <div>
+ <div class="twisty"></div>
+ Second child
+ </div>
+ </li>
+ </ul>
+ </li>
+ <li id="uRow-3" class="unselectable">
+ <div>Item with grandchildren</div>
+ <ul>
+ <li id="uRow-3-1">
+ <div>
+ <div class="twisty"></div>
+ First child
+ </div>
+ <ul>
+ <li id="uRow-3-1-1">
+ <div>
+ <div class="twisty"></div>
+ First grandchild
+ </div>
+ </li>
+ <li id="uRow-3-1-2">
+ <div>
+ <div class="twisty"></div>
+ Second grandchild
+ </div>
+ </li>
+ </ul>
+ </li>
+ </ul>
+ </li>
+ </ul>
+</body>
+</html>
diff --git a/comm/mail/base/test/browser/head.js b/comm/mail/base/test/browser/head.js
new file mode 100644
index 0000000000..4ee9845d89
--- /dev/null
+++ b/comm/mail/base/test/browser/head.js
@@ -0,0 +1,371 @@
+/* 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/. */
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+async function focusWindow(win) {
+ win.focus();
+ await TestUtils.waitForCondition(
+ () => Services.focus.focusedWindow?.browsingContext.topChromeWindow == win,
+ "waiting for window to be focused"
+ );
+}
+
+async function openExtensionPopup(win, buttonId) {
+ await focusWindow(win.top);
+
+ let actionButton = await TestUtils.waitForCondition(
+ () =>
+ win.document.querySelector(
+ `#${buttonId}, [item-id="${buttonId}"] button`
+ ),
+ "waiting for the action button to exist"
+ );
+ await TestUtils.waitForCondition(
+ () => BrowserTestUtils.is_visible(actionButton),
+ "waiting for action button to be visible"
+ );
+ EventUtils.synthesizeMouseAtCenter(actionButton, {}, win);
+
+ let panel = win.top.document.getElementById(
+ "webextension-remote-preload-panel"
+ );
+ let browser = panel.querySelector("browser");
+ await TestUtils.waitForCondition(
+ () => browser.clientWidth > 100,
+ "waiting for browser to resize"
+ );
+
+ return { actionButton, panel, browser };
+}
+
+function getSmartServer() {
+ return MailServices.accounts.findServer("nobody", "smart mailboxes", "none");
+}
+
+function resetSmartMailboxes() {
+ let oldServer = getSmartServer();
+ // Clean up any leftover server from an earlier test.
+ if (oldServer) {
+ let oldAccount = MailServices.accounts.FindAccountForServer(oldServer);
+ MailServices.accounts.removeAccount(oldAccount, false);
+ }
+}
+
+class MenuTestHelper {
+ /** @type {XULMenuElement} */
+ menu;
+
+ /**
+ * An object describing the state of a <menu> or <menuitem>.
+ *
+ * @typedef {Object} MenuItemData
+ * @property {boolean|string[]} [hidden] - true if the item should be hidden
+ * in all modes, or a list of modes in which it should be hidden.
+ * @property {boolean|string[]} [disabled] - true if the item should be
+ * disabled in all modes, or a list of modes in which it should be
+ * disabled. If the item should be hidden this property is ignored.
+ * @property {boolean|string[]} [checked] - true if the item should be
+ * checked in all modes, or a list of modes in which it should be
+ * checked. If the item should be hidden this property is ignored.
+ * @property {string} [l10nID] - the ID of the Fluent string this item
+ * should be displaying. If specified, `l10nArgs` will be checked.
+ * @property {object} [l10nArgs] - the arguments for the Fluent string this
+ * item should be displaying. If not specified, the string should not have
+ * arguments.
+ */
+ /**
+ * An object describing the possible states of a menu's items. Object keys
+ * are the item's ID, values describe the item's state.
+ *
+ * @typedef {Object.<string, MenuItemData>} MenuData
+ */
+
+ /** @type {MenuData} */
+ baseData;
+
+ constructor(menuID, baseData) {
+ this.menu = document.getElementById(menuID);
+ this.baseData = baseData;
+ }
+
+ /**
+ * Clicks on the menu and waits for it to open.
+ */
+ async openMenu() {
+ let shownPromise = BrowserTestUtils.waitForEvent(
+ this.menu.menupopup,
+ "popupshown"
+ );
+ EventUtils.synthesizeMouseAtCenter(this.menu, {});
+ await shownPromise;
+ }
+
+ /**
+ * Check that an item matches the expected state.
+ *
+ * @param {XULElement} actual - A <menu> or <menuitem>.
+ * @param {MenuItemData} expected
+ */
+ checkItem(actual, expected) {
+ Assert.equal(
+ BrowserTestUtils.is_hidden(actual),
+ !!expected.hidden,
+ `${actual.id} hidden`
+ );
+ if (!expected.hidden) {
+ Assert.equal(
+ actual.disabled,
+ !!expected.disabled,
+ `${actual.id} disabled`
+ );
+ }
+ if (expected.checked) {
+ Assert.equal(
+ actual.getAttribute("checked"),
+ "true",
+ `${actual.id} checked`
+ );
+ } else if (["checkbox", "radio"].includes(actual.getAttribute("type"))) {
+ Assert.ok(
+ !actual.hasAttribute("checked") ||
+ actual.getAttribute("checked") == "false",
+ `${actual.id} not checked`
+ );
+ }
+ if (expected.l10nID) {
+ let attributes = actual.ownerDocument.l10n.getAttributes(actual);
+ Assert.equal(attributes.id, expected.l10nID, `${actual.id} L10n string`);
+ Assert.deepEqual(
+ attributes.args,
+ expected.l10nArgs ?? null,
+ `${actual.id} L10n args`
+ );
+ }
+ }
+
+ /**
+ * Recurses through submenus performing checks on items.
+ *
+ * @param {XULPopupElement} popup - The current pop-up to check.
+ * @param {MenuData} data - The expected values to test against.
+ * @param {boolean} [itemsMustBeInData=false] - If true, all menu items and
+ * menus within `popup` must be specified in `data`. If false, items not
+ * in `data` will be ignored.
+ */
+ async iterate(popup, data, itemsMustBeInData = false) {
+ if (popup.state != "open") {
+ await BrowserTestUtils.waitForEvent(popup, "popupshown");
+ }
+
+ for (let item of popup.children) {
+ if (!item.id || item.localName == "menuseparator") {
+ continue;
+ }
+
+ if (!(item.id in data)) {
+ if (itemsMustBeInData) {
+ Assert.report(true, undefined, undefined, `${item.id} in data`);
+ }
+ continue;
+ }
+ let itemData = data[item.id];
+ this.checkItem(item, itemData);
+ delete data[item.id];
+
+ if (item.localName == "menu") {
+ if (BrowserTestUtils.is_visible(item) && !item.disabled) {
+ item.openMenu(true);
+ await this.iterate(item.menupopup, data, itemsMustBeInData);
+ } else {
+ for (let hiddenItem of item.querySelectorAll("menu, menuitem")) {
+ delete data[hiddenItem.id];
+ }
+ }
+ }
+ }
+
+ popup.hidePopup();
+ await new Promise(resolve => setTimeout(resolve));
+ }
+
+ /**
+ * Checks every item in the menu and submenus against the expected states.
+ *
+ * @param {string} mode - The current mode, used to select the right expected
+ * values from `baseData`.
+ */
+ async testAllItems(mode) {
+ // Get the data for just this mode.
+ let data = {};
+ for (let [id, itemData] of Object.entries(this.baseData)) {
+ data[id] = {
+ ...itemData,
+ hidden: itemData.hidden === true || itemData.hidden?.includes(mode),
+ disabled:
+ itemData.disabled === true || itemData.disabled?.includes(mode),
+ checked: itemData.checked === true || itemData.checked?.includes(mode),
+ };
+ }
+
+ // Open the menu and all submenus and check the items.
+ await this.openMenu();
+ await this.iterate(this.menu.menupopup, data, true);
+
+ // Report any unexpected items.
+ for (let id of Object.keys(data)) {
+ Assert.report(true, undefined, undefined, `extra item ${id} in data`);
+ }
+ }
+
+ /**
+ * Checks specific items in the menu.
+ *
+ * @param {MenuData} data - The expected values to test against.
+ */
+ async testItems(data) {
+ await this.openMenu();
+ await this.iterate(this.menu.menupopup, data);
+
+ for (let id of Object.keys(data)) {
+ Assert.report(true, undefined, undefined, `extra item ${id} in data`);
+ }
+
+ if (this.menu.menupopup.state != "closed") {
+ let hiddenPromise = BrowserTestUtils.waitForEvent(
+ this.menu.menupopup,
+ "popuphidden"
+ );
+ this.menu.menupopup.hidePopup();
+ await hiddenPromise;
+ }
+ await new Promise(resolve => setTimeout(resolve));
+ }
+
+ /**
+ * Activates the item in the menu.
+ *
+ * @note This currently only works on top-level items.
+ * @param {string} menuItemID - The item to activate.
+ * @param {MenuData} [data] - If given, the expected state of the menu item
+ * before activation.
+ */
+ async activateItem(menuItemID, data) {
+ await this.openMenu();
+ let hiddenPromise = BrowserTestUtils.waitForEvent(
+ this.menu.menupopup,
+ "popuphidden"
+ );
+ let item = document.getElementById(menuItemID);
+ if (data) {
+ this.checkItem(item, data);
+ }
+ this.menu.menupopup.activateItem(item);
+ await hiddenPromise;
+ await new Promise(resolve => setTimeout(resolve));
+ }
+}
+
+/**
+ * Helper method to switch to a cards view with vertical layout.
+ */
+async function ensure_cards_view() {
+ const { threadTree, threadPane } =
+ document.getElementById("tabmail").currentAbout3Pane;
+
+ Services.prefs.setIntPref("mail.pane_config.dynamic", 2);
+ Services.xulStore.setValue(
+ "chrome://messenger/content/messenger.xhtml",
+ "threadPane",
+ "view",
+ "cards"
+ );
+ threadPane.updateThreadView("cards");
+
+ await BrowserTestUtils.waitForCondition(
+ () => threadTree.getAttribute("rows") == "thread-card",
+ "The tree view switched to a cards layout"
+ );
+}
+
+/**
+ * Helper method to switch to a table view with classic layout.
+ */
+async function ensure_table_view() {
+ const { threadTree, threadPane } =
+ document.getElementById("tabmail").currentAbout3Pane;
+
+ Services.prefs.setIntPref("mail.pane_config.dynamic", 0);
+ Services.xulStore.setValue(
+ "chrome://messenger/content/messenger.xhtml",
+ "threadPane",
+ "view",
+ "table"
+ );
+ threadPane.updateThreadView("table");
+
+ await BrowserTestUtils.waitForCondition(
+ () => threadTree.getAttribute("rows") == "thread-row",
+ "The tree view switched to a table layout"
+ );
+}
+
+// Report and remove any remaining accounts/servers. If we register a cleanup
+// function here, it will run before any other cleanup function has had a
+// chance to run. Instead, when it runs register another cleanup function
+// which will run last.
+registerCleanupFunction(function () {
+ registerCleanupFunction(function () {
+ Services.prefs.clearUserPref("mail.pane_config.dynamic");
+ Services.xulStore.removeValue(
+ "chrome://messenger/content/messenger.xhtml",
+ "threadPane",
+ "view"
+ );
+
+ let tabmail = document.getElementById("tabmail");
+ if (tabmail.tabInfo.length > 1) {
+ Assert.report(
+ true,
+ undefined,
+ undefined,
+ "Unexpected tab(s) open at the end of the test run"
+ );
+ tabmail.closeOtherTabs(0);
+ }
+
+ for (let server of MailServices.accounts.allServers) {
+ Assert.report(
+ true,
+ undefined,
+ undefined,
+ `Found ${server} at the end of the test run`
+ );
+ MailServices.accounts.removeIncomingServer(server, false);
+ }
+ for (let account of MailServices.accounts.accounts) {
+ Assert.report(
+ true,
+ undefined,
+ undefined,
+ `Found ${account} at the end of the test run`
+ );
+ MailServices.accounts.removeAccount(account, false);
+ }
+
+ resetSmartMailboxes();
+ ensure_cards_view();
+
+ // Some tests that open new windows don't return focus to the main window
+ // in a way that satisfies mochitest, and the test times out.
+ Services.focus.focusedWindow = window;
+ // Focus an element in the main window, then blur it again to avoid it
+ // hijacking keypresses.
+ let mainWindowElement = document.getElementById("button-appmenu");
+ mainWindowElement.focus();
+ mainWindowElement.blur();
+ });
+});
diff --git a/comm/mail/base/test/browser/head_spacesToolbar.js b/comm/mail/base/test/browser/head_spacesToolbar.js
new file mode 100644
index 0000000000..f08f3deee5
--- /dev/null
+++ b/comm/mail/base/test/browser/head_spacesToolbar.js
@@ -0,0 +1,34 @@
+/* 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/. */
+
+async function sub_test_toolbar_alignment(drawInTitlebar, hideMenu) {
+ let menubar = document.getElementById("toolbar-menubar");
+ let tabsInTitlebar =
+ document.documentElement.getAttribute("tabsintitlebar") == "true";
+ Assert.equal(tabsInTitlebar, drawInTitlebar);
+
+ if (hideMenu) {
+ menubar.setAttribute("autohide", true);
+ menubar.setAttribute("inactive", true);
+ } else {
+ menubar.removeAttribute("autohide");
+ menubar.removeAttribute("inactive");
+ }
+ await new Promise(resolve => requestAnimationFrame(resolve));
+
+ let size = document
+ .getElementById("spacesToolbar")
+ .getBoundingClientRect().width;
+
+ Assert.equal(
+ document.getElementById("titlebar").getBoundingClientRect().left,
+ size,
+ "The correct style was applied to the #titlebar"
+ );
+ Assert.equal(
+ document.getElementById("toolbar-menubar").getBoundingClientRect().left,
+ size,
+ "The correct style was applied to the #toolbar-menubar"
+ );
+}
diff --git a/comm/mail/base/test/moz.build b/comm/mail/base/test/moz.build
new file mode 100644
index 0000000000..4299802bcd
--- /dev/null
+++ b/comm/mail/base/test/moz.build
@@ -0,0 +1,22 @@
+# vim: set filetype=python:
+# 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/.
+
+BROWSER_CHROME_MANIFESTS += [
+ "browser/browser-detachedWindows.ini",
+ "browser/browser-drawBelowTitlebar.ini",
+ "browser/browser-drawInTitlebar.ini",
+ "browser/browser.ini",
+ "performance/browser.ini",
+ "webextensions/browser.ini",
+]
+
+XPCSHELL_TESTS_MANIFESTS += [
+ "unit/xpcshell.ini",
+ "unit/xpcshell_maildir.ini",
+]
+
+TESTING_JS_MODULES += [
+ "../../../../browser/components/customizableui/test/CustomizableUITestUtils.sys.mjs",
+]
diff --git a/comm/mail/base/test/performance/browser.ini b/comm/mail/base/test/performance/browser.ini
new file mode 100644
index 0000000000..4682b3f482
--- /dev/null
+++ b/comm/mail/base/test/performance/browser.ini
@@ -0,0 +1,23 @@
+[DEFAULT]
+prefs =
+ mail.provider.suppress_dialog_on_startup=true
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+# To avoid overhead when running the browser normally, StartupRecorder.jsm will
+# do almost nothing unless browser.startup.record is true.
+# gfx.canvas.willReadFrequently.enable is just an optimization, but needs to be
+# set during early startup to have an impact as a canvas will be used by
+# StartupRecorder.jsm
+ browser.startup.record=true
+ gfx.canvas.willReadFrequently.enable=true
+ mail.ab_remote_content.migrated=true
+ # Skip migration work in MailMigrator for browser_startup.js since it isn't
+ # representative of common startup.
+ mail.ui-rdf.version=9999999
+subsuite = thunderbird
+
+[browser_preferences_usage.js]
+skip-if = !debug
+[browser_startup.js]
diff --git a/comm/mail/base/test/performance/browser_preferences_usage.js b/comm/mail/base/test/performance/browser_preferences_usage.js
new file mode 100644
index 0000000000..e770b12b46
--- /dev/null
+++ b/comm/mail/base/test/performance/browser_preferences_usage.js
@@ -0,0 +1,177 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+if (SpecialPowers.useRemoteSubframes) {
+ requestLongerTimeout(2);
+}
+
+const DEFAULT_PROCESS_COUNT = Services.prefs
+ .getDefaultBranch(null)
+ .getIntPref("dom.ipc.processCount");
+
+/**
+ * A test that checks whether any preference getter from the given list
+ * of stats was called more often than the max parameter.
+ *
+ * @param {Array} stats - an array of [prefName, accessCount] tuples
+ * @param {number} max - the maximum number of times any of the prefs should
+ * have been called.
+ * @param {object} knownProblematicPrefs (optional) - an object that defines
+ * prefs that should be exempt from checking the
+ * maximum access. It looks like the following:
+ *
+ * pref_name: {
+ * min: [Number] the minimum amount of times this should have
+ * been called (to avoid keeping around dead items)
+ * max: [Number] the maximum amount of times this should have
+ * been called (to avoid this creeping up further)
+ * }
+ */
+function checkPrefGetters(stats, max, knownProblematicPrefs = {}) {
+ let getterStats = Object.entries(stats).sort(
+ ([, val1], [, val2]) => val2 - val1
+ );
+
+ // Clone the list to be able to delete entries to check if we
+ // forgot any later on.
+ knownProblematicPrefs = Object.assign({}, knownProblematicPrefs);
+
+ for (let [pref, count] of getterStats) {
+ let prefLimits = knownProblematicPrefs[pref];
+ if (!prefLimits) {
+ Assert.lessOrEqual(
+ count,
+ max,
+ `${pref} should not be accessed more than ${max} times.`
+ );
+ } else {
+ // Still record how much this pref was accessed even if we don't do any real assertions.
+ if (!prefLimits.min && !prefLimits.max) {
+ info(
+ `${pref} should not be accessed more than ${max} times and was accessed ${count} times.`
+ );
+ }
+
+ if (prefLimits.min) {
+ Assert.lessOrEqual(
+ prefLimits.min,
+ count,
+ `${pref} should be accessed at least ${prefLimits.min} times.`
+ );
+ }
+ if (prefLimits.max) {
+ Assert.lessOrEqual(
+ count,
+ prefLimits.max,
+ `${pref} should be accessed at most ${prefLimits.max} times.`
+ );
+ }
+ delete knownProblematicPrefs[pref];
+ }
+ }
+
+ // This pref will be accessed by mozJSComponentLoader when loading modules,
+ // which fails TV runs since they run the test multiple times without restarting.
+ // We just ignore this pref, since it's for testing only anyway.
+ if (knownProblematicPrefs["browser.startup.record"]) {
+ delete knownProblematicPrefs["browser.startup.record"];
+ }
+
+ let unusedPrefs = Object.keys(knownProblematicPrefs);
+ is(
+ unusedPrefs.length,
+ 0,
+ `Should have accessed all known problematic prefs. Remaining: ${unusedPrefs}`
+ );
+}
+
+/**
+ * A helper function to read preference access data
+ * using the Services.prefs.readStats() function.
+ */
+function getPreferenceStats() {
+ let stats = {};
+ Services.prefs.readStats((key, value) => (stats[key] = value));
+ return stats;
+}
+
+add_task(async function debug_only() {
+ ok(AppConstants.DEBUG, "You need to run this test on a debug build.");
+});
+
+// Just checks how many prefs were accessed during startup.
+add_task(async function startup() {
+ let max = 40;
+
+ let knownProblematicPrefs = {
+ // These are all similar values to Firefox, check with the equivalent
+ // file in Firefox.
+ "browser.startup.record": {
+ // This pref is accessed in Nighly and debug builds only.
+ min: 200,
+ max: 400,
+ },
+ "network.loadinfo.skip_type_assertion": {
+ // This is accessed in debug only.
+ },
+ // Bug 944367: All gloda logs are controlled by one pref.
+ "gloda.loglevel": {
+ min: 10,
+ max: 70,
+ },
+ };
+
+ // These preferences are used in PresContext or layout areas and all have a
+ // similar number of errors - probably being loaded in the same component.
+ let prefsUsedInLayout = [
+ "browser.display.auto_quality_min_font_size",
+ "dom.send_after_paint_to_content",
+ "image.animation_mode",
+ "layout.reflow.dumpframebyframecounts",
+ "layout.reflow.dumpframecounts",
+ "layout.reflow.showframecounts",
+ "layout.scrollbar.side",
+ ];
+
+ for (let pref of prefsUsedInLayout) {
+ knownProblematicPrefs[pref] = {
+ min: 60,
+ max: 175,
+ };
+ }
+
+ if (AppConstants.platform == "macosx") {
+ for (let pref of [
+ "font.default.x-western",
+ "font.minimum-size.x-western",
+ "font.name.variable.x-western",
+ "font.size-adjust.cursive.x-western",
+ "font.size-adjust.fantasy.x-western",
+ "font.size-adjust.monospace.x-western",
+ "font.size-adjust.sans-serif.x-western",
+ "font.size-adjust.serif.x-western",
+ "font.size-adjust.system-ui.x-western",
+ "font.size-adjust.variable.x-western",
+ "font.size.cursive.x-western",
+ "font.size.fantasy.x-western",
+ "font.size.monospace.x-western",
+ "font.size.sans-serif.x-western",
+ "font.size.serif.x-western",
+ "font.size.system-ui.x-western",
+ "font.size.variable.x-western",
+ ]) {
+ knownProblematicPrefs[pref] = {
+ min: 0,
+ max: 45,
+ };
+ }
+ }
+
+ let startupRecorder =
+ Cc["@mozilla.org/test/startuprecorder;1"].getService().wrappedJSObject;
+ await startupRecorder.done;
+
+ ok(startupRecorder.data.prefStats, "startupRecorder has prefStats");
+
+ checkPrefGetters(startupRecorder.data.prefStats, max, knownProblematicPrefs);
+});
diff --git a/comm/mail/base/test/performance/browser_startup.js b/comm/mail/base/test/performance/browser_startup.js
new file mode 100644
index 0000000000..f0c6009543
--- /dev/null
+++ b/comm/mail/base/test/performance/browser_startup.js
@@ -0,0 +1,277 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/* This test records at which phase of startup the JS modules are first
+ * loaded.
+ * If you made changes that cause this test to fail, it's likely because you
+ * are loading more JS code during startup.
+ * Most code has no reason to run off of the app-startup notification
+ * (this is very early, before we have selected the user profile, so
+ * preferences aren't accessible yet).
+ * If your code isn't strictly required to show the first browser window,
+ * it shouldn't be loaded before we are done with first paint.
+ * Finally, if your code isn't really needed during startup, it should not be
+ * loaded before we have started handling user events.
+ */
+
+"use strict";
+
+/* Set this to true only for debugging purpose; it makes the output noisy. */
+const kDumpAllStacks = false;
+
+const startupPhases = {
+ // For app-startup, we have an allowlist of acceptable JS files.
+ // Anything loaded during app-startup must have a compelling reason
+ // to run before we have even selected the user profile.
+ // Consider loading your code after first paint instead,
+ // eg. from MailGlue.jsm' _onFirstWindowLoaded method).
+ "before profile selection": {
+ allowlist: {
+ modules: new Set([
+ "resource:///modules/MailGlue.jsm",
+ "resource:///modules/StartupRecorder.jsm",
+ "resource://gre/modules/ActorManagerParent.sys.mjs",
+ "resource://gre/modules/AppConstants.sys.mjs",
+ "resource://gre/modules/CustomElementsListener.sys.mjs",
+ "resource://gre/modules/MainProcessSingleton.sys.mjs",
+ "resource://gre/modules/XPCOMUtils.sys.mjs",
+ ]),
+ },
+ },
+
+ // For the following phases of startup we have only a list of files that
+ // are **not** allowed to load in this phase, as too many other scripts
+ // load during this time.
+
+ // We are at this phase after creating the first browser window (ie. after final-ui-startup).
+ "before opening first browser window": {
+ denylist: {
+ modules: new Set([
+ "chrome://openpgp/content/modules/constants.jsm",
+ "resource:///modules/IMServices.sys.mjs",
+ "resource:///modules/imXPCOMUtils.sys.mjs",
+ "resource:///modules/jsProtoHelper.sys.mjs",
+ "resource:///modules/logger.sys.mjs",
+ "resource:///modules/MailNotificationManager.jsm",
+ "resource:///modules/MailNotificationService.jsm",
+ "resource:///modules/MsgIncomingServer.jsm",
+ ]),
+ services: new Set([
+ "@mozilla.org/chat/logger;1",
+ "@mozilla.org/mail/notification-manager;1",
+ "@mozilla.org/newMailNotificationService;1",
+ ]),
+ },
+ },
+
+ // We reach this phase right after showing the first browser window.
+ // This means that anything already loaded at this point has been loaded
+ // before first paint and delayed it.
+ "before first paint": {
+ denylist: {
+ modules: new Set([
+ "chrome://openpgp/content/BondOpenPGP.jsm",
+ "chrome://openpgp/content/modules/core.jsm",
+ "resource:///modules/index_im.sys.mjs",
+ "resource:///modules/MsgDBCacheManager.jsm",
+ "resource:///modules/PeriodicFilterManager.jsm",
+ "resource://gre/modules/Blocklist.sys.mjs",
+ "resource://gre/modules/NewTabUtils.sys.mjs",
+ "resource://gre/modules/Sqlite.sys.mjs",
+ // Bug 1660907: These core modules shouldn't really be being loaded
+ // until sometime after first paint.
+ // "resource://gre/modules/PlacesUtils.sys.mjs",
+ // "resource://gre/modules/Preferences.jsm",
+ // These can probably be pushed back even further.
+ ]),
+ services: new Set([
+ "@mozilla.org/browser/search-service;1",
+ "@mozilla.org/msgDatabase/msgDBService;1",
+ ]),
+ },
+ },
+
+ // We are at this phase once we are ready to handle user events.
+ // Anything loaded at this phase or before gets in the way of the user
+ // interacting with the first mail window.
+ "before handling user events": {
+ denylist: {
+ modules: new Set([
+ "resource:///modules/gloda/Everybody.jsm",
+ "resource:///modules/gloda/Gloda.jsm",
+ "resource:///modules/gloda/GlodaContent.jsm",
+ "resource:///modules/gloda/GlodaDatabind.jsm",
+ "resource:///modules/gloda/GlodaDataModel.jsm",
+ "resource:///modules/gloda/GlodaDatastore.jsm",
+ "resource:///modules/gloda/GlodaExplicitAttr.jsm",
+ "resource:///modules/gloda/GlodaFundAttr.jsm",
+ "resource:///modules/gloda/GlodaMsgIndexer.jsm",
+ "resource:///modules/gloda/GlodaPublic.jsm",
+ "resource:///modules/gloda/GlodaQueryClassFactory.jsm",
+ "resource:///modules/gloda/GlodaUtils.jsm",
+ "resource:///modules/gloda/IndexMsg.jsm",
+ "resource:///modules/gloda/MimeMessage.jsm",
+ "resource:///modules/gloda/NounFreetag.jsm",
+ "resource:///modules/gloda/NounMimetype.jsm",
+ "resource:///modules/gloda/NounTag.jsm",
+ "resource:///modules/index_im.sys.mjs",
+ "resource:///modules/jsmime.jsm",
+ "resource:///modules/MimeJSComponents.jsm",
+ "resource:///modules/mimeParser.jsm",
+ "resource://gre/modules/BookmarkHTMLUtils.sys.mjs",
+ "resource://gre/modules/Bookmarks.sys.mjs",
+ "resource://gre/modules/ContextualIdentityService.sys.mjs",
+ "resource://gre/modules/CrashSubmit.sys.mjs",
+ "resource://gre/modules/FxAccounts.sys.mjs",
+ "resource://gre/modules/FxAccountsStorage.sys.mjs",
+ "resource://gre/modules/PlacesBackups.sys.mjs",
+ "resource://gre/modules/PlacesSyncUtils.sys.mjs",
+ "resource://gre/modules/PushComponents.jsm",
+ ]),
+ services: new Set([
+ "@mozilla.org/browser/annotation-service;1",
+ "@mozilla.org/browser/nav-bookmarks-service;1",
+ "@mozilla.org/messenger/filter-plugin;1?name=bayesianfilter",
+ "@mozilla.org/messenger/fts3tokenizer;1",
+ "@mozilla.org/messenger/headerparser;1",
+ ]),
+ },
+ },
+
+ // Things that are expected to be completely out of the startup path
+ // and loaded lazily when used for the first time by the user should
+ // be listed here.
+ "before becoming idle": {
+ denylist: {
+ modules: new Set([
+ "resource:///modules/AddrBookManager.jsm",
+ "resource:///modules/DisplayNameUtils.jsm",
+ "resource:///modules/gloda/Facet.jsm",
+ "resource:///modules/gloda/GlodaMsgSearcher.jsm",
+ "resource:///modules/gloda/SuffixTree.jsm",
+ "resource:///modules/GlodaAutoComplete.jsm",
+ "resource:///modules/ImapIncomingServer.jsm",
+ "resource:///modules/ImapMessageMessageService.jsm",
+ "resource:///modules/ImapMessageService.jsm",
+ // Skipped due to the way ImapModuleLoader and registerProtocolHandler
+ // works, uncomment once ImapModuleLoader is removed and imap-js becomes
+ // the only IMAP implemention.
+ // "resource:///modules/ImapProtocolHandler.jsm",
+ "resource:///modules/ImapService.jsm",
+ "resource:///modules/NntpIncomingServer.jsm",
+ "resource:///modules/NntpMessageService.jsm",
+ "resource:///modules/NntpProtocolHandler.jsm",
+ "resource:///modules/NntpProtocolInfo.jsm",
+ "resource:///modules/NntpService.jsm",
+ "resource:///modules/Pop3IncomingServer.jsm",
+ "resource:///modules/Pop3ProtocolHandler.jsm",
+ "resource:///modules/Pop3ProtocolInfo.jsm",
+ // "resource:///modules/Pop3Service.jsm",
+ "resource:///modules/SmtpClient.jsm",
+ "resource:///modules/SMTPProtocolHandler.jsm",
+ "resource:///modules/SmtpServer.jsm",
+ "resource:///modules/SmtpService.jsm",
+ "resource:///modules/TemplateUtils.jsm",
+ "resource://gre/modules/AsyncPrefs.sys.mjs",
+ "resource://gre/modules/LoginManagerContextMenu.jsm",
+ "resource://pdf.js/PdfStreamConverter.jsm",
+ ]),
+ services: new Set(["@mozilla.org/autocomplete/search;1?name=gloda"]),
+ },
+ },
+};
+
+add_task(async function () {
+ if (
+ !AppConstants.NIGHTLY_BUILD &&
+ !AppConstants.MOZ_DEV_EDITION &&
+ !AppConstants.DEBUG
+ ) {
+ ok(
+ !("@mozilla.org/test/startuprecorder;1" in Cc),
+ "the startup recorder component shouldn't exist in this non-nightly/non-devedition/" +
+ "non-debug build."
+ );
+ return;
+ }
+
+ let startupRecorder =
+ Cc["@mozilla.org/test/startuprecorder;1"].getService().wrappedJSObject;
+ await startupRecorder.done;
+
+ let data = Cu.cloneInto(startupRecorder.data.code, {});
+ function getStack(scriptType, name) {
+ if (scriptType == "modules") {
+ return Cu.getModuleImportStack(name);
+ }
+ return "";
+ }
+
+ // This block only adds debug output to help find the next bugs to file,
+ // it doesn't contribute to the actual test.
+ SimpleTest.requestCompleteLog();
+ let previous;
+ for (let phase in data) {
+ for (let scriptType in data[phase]) {
+ for (let f of data[phase][scriptType].sort()) {
+ // phases are ordered, so if a script wasn't loaded yet at the immediate
+ // previous phase, it wasn't loaded during any of the previous phases
+ // either, and is new in the current phase.
+ if (!previous || !data[previous][scriptType].includes(f)) {
+ info(`${scriptType} loaded ${phase}: ${f}`);
+ if (kDumpAllStacks) {
+ info(getStack(scriptType, f));
+ }
+ }
+ }
+ }
+ previous = phase;
+ }
+
+ for (let phase in startupPhases) {
+ let loadedList = data[phase];
+ let allowlist = startupPhases[phase].allowlist || null;
+ if (allowlist) {
+ for (let scriptType in allowlist) {
+ loadedList[scriptType] = loadedList[scriptType].filter(c => {
+ if (!allowlist[scriptType].has(c)) {
+ return true;
+ }
+ allowlist[scriptType].delete(c);
+ return false;
+ });
+ is(
+ loadedList[scriptType].length,
+ 0,
+ `should have no unexpected ${scriptType} loaded ${phase}`
+ );
+ for (let script of loadedList[scriptType]) {
+ let message = `unexpected ${scriptType}: ${script}`;
+ record(false, message, undefined, getStack(scriptType, script));
+ }
+ is(
+ allowlist[scriptType].size,
+ 0,
+ `all ${scriptType} allowlist entries should have been used`
+ );
+ for (let script of allowlist[scriptType]) {
+ ok(false, `unused ${scriptType} allowlist entry: ${script}`);
+ }
+ }
+ }
+ let denylist = startupPhases[phase].denylist || null;
+ if (denylist) {
+ for (let scriptType in denylist) {
+ for (let file of denylist[scriptType]) {
+ let loaded = loadedList[scriptType].includes(file);
+ let message = `${file} is not allowed ${phase}`;
+ if (!loaded) {
+ ok(true, message);
+ } else {
+ record(false, message, undefined, getStack(scriptType, file));
+ }
+ }
+ }
+ }
+ }
+});
diff --git a/comm/mail/base/test/unit/distribution.ini b/comm/mail/base/test/unit/distribution.ini
new file mode 100644
index 0000000000..ea07aa3c5e
--- /dev/null
+++ b/comm/mail/base/test/unit/distribution.ini
@@ -0,0 +1,56 @@
+# Partner Distribution Configuration File
+# Mozilla Thunderbird with Example Dist settings
+
+# NOTE! These three are required.
+# id: short string unique to this distribution
+# about: a short descriptive (ui-visible) string for this
+# distribution
+# version: version of the extra distribution pieces (not the version
+# of Thunderbird)
+
+[Global]
+id=ExampleDist
+version=1.0
+about=Example Distribution Edition
+about.en-US=Example Distribution Edition EN-US
+
+# This section contains the global js prefs. You do should not list
+# here the localized preferences (see below)
+
+# Boolean preferences should be 'true' or 'false', w/o quotes. e.g.:
+# my.bool.preference=true
+#
+# Integer preferences should be unquoted numbers. e.g.:
+# my.int.preference=123
+#
+# String preferences should be in quotes. e.g.:
+# my.string.preference="foo"
+
+[Preferences]
+app.distributor="exampledist"
+app.test.data="nospecialcharacterssetting"
+app.distributor.channel=""
+mail.phishing.detection.enabled=false
+mail.spam.manualMark=false
+test.setting.random="success"
+
+# This section is used as a template for locale-specific properties
+# files. They work similarly to the GlobalPrefs section, except that
+# the %LOCALE% string gets substituted with the language string.
+
+[LocalizablePreferences]
+app.releaseNotesURL="http://example.org/%LOCALE%/%LOCALE%/"
+mailnews.start_page.welcome_url="http://example.com/%APP%/firstrun?locale=%LOCALE%version=%VERSION%&os=%OS%&buildid=%APPBUILDID%"
+test.setting.locale="http://my.senecacacollege.on.example/%LOCALE%"
+
+# This section is an example of an override for a particular locale.
+# The override sections do not interpolate %LOCALE% into strings.
+# Preferences set in override sections are *merged* with the
+# localizable defaults. That is, if you want a pref in
+# [LocalizablePreferences] to not be set in a particular locale,
+# you'll need to unset it explicitly ("pref.name=" on a line of its
+# own).
+
+[LocalizablePreferences-en-US]
+app.releaseNotesURL="http://example.com/relnotes/"
+mailnews.start_page.welcome_url="http://example.com/firstrun/"
diff --git a/comm/mail/base/test/unit/head_mailbase.js b/comm/mail/base/test/unit/head_mailbase.js
new file mode 100644
index 0000000000..0c275d8abb
--- /dev/null
+++ b/comm/mail/base/test/unit/head_mailbase.js
@@ -0,0 +1,20 @@
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+var { mailTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MailTestUtils.jsm"
+);
+
+var CC = Components.Constructor;
+
+// Ensure the profile directory is set up
+do_get_profile();
+
+var gDEPTH = "../../../../";
+
+registerCleanupFunction(function () {
+ load(gDEPTH + "mailnews/resources/mailShutdown.js");
+});
diff --git a/comm/mail/base/test/unit/head_mailbase_maildir.js b/comm/mail/base/test/unit/head_mailbase_maildir.js
new file mode 100644
index 0000000000..921a2bd474
--- /dev/null
+++ b/comm/mail/base/test/unit/head_mailbase_maildir.js
@@ -0,0 +1,9 @@
+/* import-globals-from head_mailbase.js */
+
+// alternate head to set maildir as default
+load("head_mailbase.js");
+info("Running test with maildir");
+Services.prefs.setCharPref(
+ "mail.serverDefaultStoreContractID",
+ "@mozilla.org/msgstore/maildirstore;1"
+);
diff --git a/comm/mail/base/test/unit/resources/viewWrapperTestUtils.js b/comm/mail/base/test/unit/resources/viewWrapperTestUtils.js
new file mode 100644
index 0000000000..5ae301b016
--- /dev/null
+++ b/comm/mail/base/test/unit/resources/viewWrapperTestUtils.js
@@ -0,0 +1,534 @@
+/* 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/. */
+
+var { DBViewWrapper, IDBViewWrapperListener } = ChromeUtils.import(
+ "resource:///modules/DBViewWrapper.jsm"
+);
+var { MailViewManager, MailViewConstants } = ChromeUtils.import(
+ "resource:///modules/MailViewManager.jsm"
+);
+var { VirtualFolderHelper } = ChromeUtils.import(
+ "resource:///modules/VirtualFolderWrapper.jsm"
+);
+var { MessageGenerator, MessageScenarioFactory } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MessageGenerator.jsm"
+);
+var { MessageInjection } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MessageInjection.jsm"
+);
+var { dump_view_state } = ChromeUtils.import(
+ "resource://testing-common/mozmill/ViewHelpers.jsm"
+);
+
+var gMessageGenerator;
+var gMessageScenarioFactory;
+var messageInjection;
+var gMockViewWrapperListener;
+
+function initViewWrapperTestUtils(aInjectionConfig) {
+ if (!aInjectionConfig) {
+ throw new Error("Please provide an injection config for MessageInjection.");
+ }
+
+ gMessageGenerator = new MessageGenerator();
+ gMessageScenarioFactory = new MessageScenarioFactory(gMessageGenerator);
+
+ messageInjection = new MessageInjection(aInjectionConfig, gMessageGenerator);
+ messageInjection.registerMessageInjectionListener(VWTU_testHelper);
+ registerCleanupFunction(() => {
+ // Cleanup of VWTU_testHelper.
+ VWTU_testHelper.postTest();
+ });
+ gMockViewWrapperListener = new MockViewWrapperListener();
+}
+
+// Something less sucky than do_check_true.
+function assert_true(aBeTrue, aWhy, aDumpView) {
+ if (!aBeTrue) {
+ if (aDumpView) {
+ dump_view_state(VWTU_testHelper.active_view_wrappers[0]);
+ }
+ do_throw(aWhy);
+ }
+}
+
+function assert_false(aBeFalse, aWhy, aDumpView) {
+ if (aBeFalse) {
+ if (aDumpView) {
+ dump_view_state(VWTU_testHelper.active_view_wrappers[0]);
+ }
+ do_throw(aWhy);
+ }
+}
+
+function assert_equals(aA, aB, aWhy, aDumpView) {
+ if (aA != aB) {
+ if (aDumpView) {
+ dump_view_state(VWTU_testHelper.active_view_wrappers[0]);
+ }
+ do_throw(aWhy);
+ }
+}
+
+function assert_bit_set(aWhat, aBit, aWhy) {
+ if (!(aWhat & aBit)) {
+ do_throw(aWhy);
+ }
+}
+
+function assert_bit_not_set(aWhat, aBit, aWhy) {
+ if (aWhat & aBit) {
+ do_throw(aWhy);
+ }
+}
+
+var gFakeCommandUpdater = {
+ updateCommandStatus() {},
+
+ displayMessageChanged(aFolder, aSubject, aKeywords) {},
+
+ summarizeSelection() {},
+
+ updateNextMessageAfterDelete() {},
+};
+
+/**
+ * Track our resources used by each test. This is so we can keep our memory
+ * usage low by forcing things to be forgotten about (or even nuked) once
+ * a test completes, but also so we can provide useful information about the
+ * state of things if a test times out.
+ */
+var VWTU_testHelper = {
+ active_view_wrappers: [],
+ active_real_folders: [],
+ active_virtual_folders: [],
+
+ onVirtualFolderCreated(aVirtualFolder) {
+ this.active_virtual_folders.push(aVirtualFolder);
+ },
+
+ postTest() {
+ // Close all the views we opened.
+ this.active_view_wrappers.forEach(function (wrapper) {
+ wrapper.close();
+ });
+ // Verify that the notification helper has no outstanding listeners.
+ if (IDBViewWrapperListener.prototype._FNH.haveListeners()) {
+ let msg = "FolderNotificationHelper has listeners, but should not.";
+ dump("*** " + msg + "\n");
+ dump("Pending URIs:\n");
+ for (let folderURI in IDBViewWrapperListener.prototype._FNH
+ ._pendingFolderUriToViewWrapperLists) {
+ dump(" " + folderURI + "\n");
+ }
+ dump("Interested wrappers:\n");
+ for (let folderURI in IDBViewWrapperListener.prototype._FNH
+ ._interestedWrappers) {
+ dump(" " + folderURI + "\n");
+ }
+ dump("***\n");
+ do_throw(msg);
+ }
+ // Force the folder to forget about the message database.
+ this.active_virtual_folders.forEach(function (folder) {
+ folder.msgDatabase = null;
+ });
+ this.active_real_folders.forEach(function (folder) {
+ folder.msgDatabase = null;
+ });
+
+ this.active_view_wrappers.splice(0);
+ this.active_real_folders.splice(0);
+ this.active_virtual_folders.splice(0);
+
+ gMockViewWrapperListener.allMessagesLoadedEventCount = 0;
+ },
+ onTimeout() {
+ dump("-----------------------------------------------------------\n");
+ dump("Active things at time of timeout:\n");
+ for (let folder of this.active_real_folders) {
+ dump("Real folder: " + folder.prettyName + "\n");
+ }
+ for (let virtFolder of this.active_virtual_folders) {
+ dump("Virtual folder: " + virtFolder.prettyName + "\n");
+ }
+ for (let [i, viewWrapper] of this.active_view_wrappers.entries()) {
+ dump("-----------------------------------\n");
+ dump("Active view wrapper " + i + "\n");
+ dump_view_state(viewWrapper);
+ }
+ },
+};
+
+function make_view_wrapper() {
+ let wrapper = new DBViewWrapper(gMockViewWrapperListener);
+ VWTU_testHelper.active_view_wrappers.push(wrapper);
+ return wrapper;
+}
+
+/**
+ * Clone an open and valid view wrapper.
+ */
+function clone_view_wrapper(aViewWrapper) {
+ let wrapper = aViewWrapper.clone(gMockViewWrapperListener);
+ VWTU_testHelper.active_view_wrappers.push(wrapper);
+ return wrapper;
+}
+
+/**
+ * Open a folder for view display. This is an async operation, relying on the
+ * onMessagesLoaded(true) notification to get he test going again.
+ */
+async function view_open(aViewWrapper, aFolder) {
+ aViewWrapper.listener.pendingLoad = true;
+ aViewWrapper.open(aFolder);
+ await gMockViewWrapperListener.promise;
+ gMockViewWrapperListener.resetPromise();
+}
+
+async function view_set_mail_view(aViewWrapper, aMailViewIndex, aData) {
+ aViewWrapper.listener.pendingLoad = true;
+ aViewWrapper.setMailView(aMailViewIndex, aData);
+ await gMockViewWrapperListener.promise;
+ gMockViewWrapperListener.resetPromise();
+}
+
+async function view_refresh(aViewWrapper) {
+ aViewWrapper.listener.pendingLoad = true;
+ aViewWrapper.refresh();
+ await gMockViewWrapperListener.promise;
+ gMockViewWrapperListener.resetPromise();
+}
+
+async function view_group_by_sort(aViewWrapper, aGroupBySort) {
+ aViewWrapper.listener.pendingLoad = true;
+ aViewWrapper.showGroupedBySort = aGroupBySort;
+ await gMockViewWrapperListener.promise;
+ gMockViewWrapperListener.resetPromise();
+}
+
+/**
+ * Call endViewUpdate on your wrapper in the async idiom. This is essential if
+ * you are doing things to a cross-folder view which does its searching in a
+ * time-sliced fashion. In such a case, you would call beginViewUpdate
+ * manually, then poke at the view, then call us to end the view update.
+ */
+function async_view_end_update(aViewWrapper) {
+ aViewWrapper.listener.pendingLoad = true;
+ aViewWrapper.endViewUpdate();
+ return false;
+}
+
+/**
+ * The deletion is asynchronous from a view perspective because the view ends
+ * up re-creating itself which triggers a new search. This function is
+ * nominally asynchronous because we refresh XFVF views when one of their
+ * folders gets deleted. In that case, you must pass the view wrapper you
+ * expect to be affected so we can do our async thing.
+ * If, however, you are deleting the last folder that belongs to a view, you
+ * should not pass a view wrapper, because you should expect the view wrapper
+ * to close itself and destroy the view. (Well, the view might do something
+ * too, but we don't care what it does.) We provide a |delete_folder| alias
+ * so code can look clean.
+ *
+ * @param aViewWrapper Required when you want us to operate asynchronously.
+ * @param aDontEmptyTrash This function will empty the trash after deleting the
+ * folder, unless you set this parameter to true.
+ */
+async function delete_folder(aFolder, aViewWrapper, aDontEmptyTrash) {
+ VWTU_testHelper.active_real_folders.splice(
+ VWTU_testHelper.active_real_folders.indexOf(aFolder),
+ 1
+ );
+ // Deleting tries to be helpful and move the folder to the trash...
+ aFolder.deleteSelf(null);
+
+ // Ugh. So we have the problem where that move above just triggered a
+ // re-computation of the view... which is an asynchronous operation
+ // that we don't care about at all. We don't need to wait for it to
+ // complete, but if we don't, we have a race on enabling this next
+ // notification.
+ // So we interrupt the search ourselves. This problem is exclusively
+ // limited to unit testing and is not something we would need to do
+ // normally. (Because things are single-threaded we are also
+ // guaranteed that we can interrupt it without needing locks or anything.)
+ if (aViewWrapper) {
+ if (aViewWrapper.searching) {
+ aViewWrapper.search.session.interruptSearch();
+ }
+ aViewWrapper.listener.pendingLoad = true;
+ }
+
+ // ...so now the stupid folder is in the stupid trash.
+ // Let's empty the trash, then, shall we?
+ // (For local folders it doesn't matter who we call this on.)
+ if (!aDontEmptyTrash) {
+ aFolder.emptyTrash(null);
+ }
+
+ await gMockViewWrapperListener.promise;
+ gMockViewWrapperListener.resetPromise();
+}
+
+/**
+ * For assistance in debugging, dump information about a message header.
+ */
+function dump_message_header(aMsgHdr) {
+ dump(" Subject: " + aMsgHdr.mime2DecodedSubject + "\n");
+ dump(" Date: " + new Date(aMsgHdr.date / 1000) + "\n");
+ dump(" Author: " + aMsgHdr.mime2DecodedAuthor + "\n");
+ dump(" Recipients: " + aMsgHdr.mime2DecodedRecipients + "\n");
+ let junkScore = aMsgHdr.getStringProperty("junkscore");
+ dump(
+ " Read: " +
+ aMsgHdr.isRead +
+ " Flagged: " +
+ aMsgHdr.isFlagged +
+ " Killed: " +
+ aMsgHdr.isKilled +
+ " Junk: " +
+ (junkScore == "100") +
+ "\n"
+ );
+ dump(" Keywords: " + aMsgHdr.getStringProperty("Keywords") + "\n");
+ dump(
+ " Folder: " +
+ aMsgHdr.folder.prettyName +
+ " Key: " +
+ aMsgHdr.messageKey +
+ "\n"
+ );
+}
+
+/**
+ * Verify that the messages in the provided SyntheticMessageSets are the only
+ * visible messages in the provided DBViewWrapper. If dummy headers are present
+ * in the view for group-by-sort, the code will ensure that the dummy header's
+ * underlying header corresponds to a message in the synthetic sets. However,
+ * you should generally not rely on this code to test for anything involving
+ * dummy headers.
+ *
+ * In the event the view does not contain all of the messages from the provided
+ * sets or contains messages not in the provided sets, do_throw will be invoked
+ * with a human readable explanation of the problem.
+ *
+ * @param aSynSets A single SyntheticMessageSet or a list of
+ * SyntheticMessageSets.
+ * @param aViewWrapper The DBViewWrapper whose contents you want to validate.
+ */
+function verify_messages_in_view(aSynSets, aViewWrapper) {
+ if (!("length" in aSynSets)) {
+ aSynSets = [aSynSets];
+ }
+
+ // - Iterate over all the message sets, retrieving the message header. Use
+ // this to construct a URI to populate a dictionary mapping.
+ let synMessageURIs = {}; // map URI to message header
+ for (let messageSet of aSynSets) {
+ for (let msgHdr of messageSet.msgHdrs()) {
+ synMessageURIs[msgHdr.folder.getUriForMsg(msgHdr)] = msgHdr;
+ }
+ }
+
+ // - Iterate over the contents of the view, nulling out values in
+ // synMessageURIs for found messages, and exploding for missing ones.
+ let dbView = aViewWrapper.dbView;
+ let treeView = aViewWrapper.dbView.QueryInterface(Ci.nsITreeView);
+ let rowCount = treeView.rowCount;
+
+ for (let iViewIndex = 0; iViewIndex < rowCount; iViewIndex++) {
+ let msgHdr = dbView.getMsgHdrAt(iViewIndex);
+ let uri = msgHdr.folder.getUriForMsg(msgHdr);
+ // Expected hit, null it out. (in the dummy case, we will just null out
+ // twice, which is also why we do an 'in' test and not a value test.
+ if (uri in synMessageURIs) {
+ synMessageURIs[uri] = null;
+ } else {
+ // The view is showing a message that should not be shown, explode.
+ dump(
+ "The view is showing the following message header and should not" +
+ " be:\n"
+ );
+ dump_message_header(msgHdr);
+ dump("View State:\n");
+ dump_view_state(aViewWrapper);
+ throw new Error(
+ "view contains header that should not be present! " + msgHdr.messageKey
+ );
+ }
+ }
+
+ // - Iterate over our URI set and make sure every message got nulled out.
+ for (let uri in synMessageURIs) {
+ let msgHdr = synMessageURIs[uri];
+ if (msgHdr != null) {
+ dump("************************\n");
+ dump(
+ "The view should have included the following message header but" +
+ " did not:\n"
+ );
+ dump_message_header(msgHdr);
+ dump("View State:\n");
+ dump_view_state(aViewWrapper);
+ throw new Error(
+ "view does not contain a header that should be present! " +
+ msgHdr.messageKey
+ );
+ }
+ }
+}
+
+/**
+ * Assert if the view wrapper is displaying any messages.
+ */
+function verify_empty_view(aViewWrapper) {
+ verify_messages_in_view([], aViewWrapper);
+}
+
+/**
+ * Build a histogram of the treeview levels and verify it matches the expected
+ * histogram. Oddly enough, I find this to be a reasonable and concise way to
+ * verify that threading mode is enabled. Keep in mind that this file is
+ * currently not used to test the actual thread logic. If/when that day comes,
+ * something less eccentric is certainly the way that should be tested.
+ */
+function verify_view_level_histogram(aExpectedHisto, aViewWrapper) {
+ let treeView = aViewWrapper.dbView.QueryInterface(Ci.nsITreeView);
+ let rowCount = treeView.rowCount;
+
+ let actualHisto = {};
+ for (let iViewIndex = 0; iViewIndex < rowCount; iViewIndex++) {
+ let level = treeView.getLevel(iViewIndex);
+ actualHisto[level] = (actualHisto[level] || 0) + 1;
+ }
+
+ for (let [level, count] of Object.entries(aExpectedHisto)) {
+ if (actualHisto[level] != count) {
+ dump_view_state(aViewWrapper);
+ dump("*******************\n");
+ dump(
+ "Expected count for histogram level " +
+ level +
+ " was " +
+ count +
+ " but got " +
+ actualHisto[level] +
+ "\n"
+ );
+ do_throw("View histogram does not match!");
+ }
+ }
+}
+
+/**
+ * Given a view wrapper and one or more view indices, verify that the row
+ * returns true for isContainer.
+ *
+ * @param aViewWrapper The view wrapper in question
+ * @param ... View indices to check.
+ */
+function verify_view_row_at_index_is_container(aViewWrapper, ...aArgs) {
+ let treeView = aViewWrapper.dbView.QueryInterface(Ci.nsITreeView);
+ for (let viewIndex of aArgs) {
+ if (!treeView.isContainer(viewIndex)) {
+ dump_view_state(aViewWrapper);
+ do_throw("Expected isContainer to be true at view index " + viewIndex);
+ }
+ }
+}
+
+/**
+ * Given a view wrapper and one or more view indices, verify that there is a
+ * dummy header at each provided index.
+ *
+ * @param aViewWrapper The view wrapper in question
+ * @param ... View indices to check.
+ */
+function verify_view_row_at_index_is_dummy(aViewWrapper, ...aArgs) {
+ const MSG_VIEW_FLAG_DUMMY = 0x20000000;
+ for (let viewIndex of aArgs) {
+ let flags = aViewWrapper.dbView.getFlagsAt(viewIndex);
+ if (!(flags & MSG_VIEW_FLAG_DUMMY)) {
+ dump_view_state(aViewWrapper);
+ do_throw("Expected a dummy header at view index " + viewIndex);
+ }
+ }
+}
+
+/**
+ * Expand all nodes in the view wrapper. This is a debug helper function
+ * because there's no good reason to have it be on the view wrapper at this
+ * time. You must call async_view_refresh or async_view_end_update (if you are
+ * within a view update batch) after calling this!
+ */
+function view_expand_all(aViewWrapper) {
+ // We can't use the command because it has assertions about having a tree.
+ aViewWrapper._viewFlags |= Ci.nsMsgViewFlagsType.kExpandAll;
+}
+
+/**
+ * Create a name and address pair where the provided word is part of the name.
+ */
+function make_person_with_word_in_name(aWord) {
+ let dude = gMessageGenerator.makeNameAndAddress();
+ return [aWord, dude[1]];
+}
+
+/**
+ * Create a name and address pair where the provided word is part of the mail
+ * address.
+ */
+function make_person_with_word_in_address(aWord) {
+ let dude = gMessageGenerator.makeNameAndAddress();
+ return [dude[0], aWord + "@madeup.nul"];
+}
+
+class MockViewWrapperListener extends IDBViewWrapperListener {
+ shouldUseMailViews = true;
+ shouldDeferMessageDisplayUntilAfterServerConnect = false;
+ messenger = null;
+ // Use no message window!
+ msgWindow = null;
+ threadPaneCommandUpdater = gFakeCommandUpdater;
+ // Event handlers.
+ allMessagesLoadedEventCount = 0;
+ messagesRemovedEventCount = 0;
+
+ constructor() {
+ super();
+ this._promise = new Promise(resolve => {
+ this._resolve = resolve;
+ });
+ }
+
+ shouldMarkMessagesReadOnLeavingFolder(aMsgFolder) {
+ return Services.prefs.getBoolPref(
+ "mailnews.mark_message_read." + aMsgFolder.server.type
+ );
+ }
+
+ onMessagesLoaded(aAll) {
+ if (!aAll) {
+ return;
+ }
+ this.allMessagesLoadedEventCount++;
+ if (this.pendingLoad) {
+ this.pendingLoad = false;
+ this._resolve();
+ }
+ }
+
+ onMessagesRemoved() {
+ this.messagesRemovedEventCount++;
+ }
+
+ get promise() {
+ return this._promise;
+ }
+ resetPromise() {
+ this._promise = new Promise(resolve => {
+ this._resolve = resolve;
+ });
+ }
+}
diff --git a/comm/mail/base/test/unit/test_alertHook.js b/comm/mail/base/test/unit/test_alertHook.js
new file mode 100644
index 0000000000..0189930862
--- /dev/null
+++ b/comm/mail/base/test/unit/test_alertHook.js
@@ -0,0 +1,119 @@
+/* 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/. */
+
+/**
+ * Tests the replace of alerts service with our own. This will let us check if we're
+ * prompting or not.
+ */
+
+var { alertHook } = ChromeUtils.import(
+ "resource:///modules/activity/alertHook.jsm"
+);
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+var { MockRegistrar } = ChromeUtils.importESModule(
+ "resource://testing-common/MockRegistrar.sys.mjs"
+);
+var { PromiseTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mailnews/PromiseTestUtils.jsm"
+);
+var { PromiseUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/PromiseUtils.sys.mjs"
+);
+
+alertHook.init();
+
+// Wait time of 1s for slow debug builds.
+const TEST_WAITTIME = 1000;
+
+var gMsgWindow = Cc["@mozilla.org/messenger/msgwindow;1"].createInstance(
+ Ci.nsIMsgWindow
+);
+var mockAlertsService;
+var cid;
+var mailnewsURL;
+
+add_setup(function () {
+ // First register the mock alerts service.
+ mockAlertsService = new MockAlertsService();
+ cid = MockRegistrar.register(
+ "@mozilla.org/alerts-service;1",
+ mockAlertsService
+ );
+ // A random URL.
+ let uri = Services.io.newURI("news://localhost:80/1@regular.invalid");
+ mailnewsURL = uri.QueryInterface(Ci.nsIMsgMailNewsUrl);
+});
+
+add_task(async function test_not_shown_to_user_no_url_no_window() {
+ // Just text, no url or window => expect no error shown to user
+ MailServices.mailSession.alertUser("test error");
+ await Promise.race([
+ PromiseTestUtils.promiseDelay(TEST_WAITTIME).then(result => {
+ Assert.ok(true, "Alert is not shown with no window or no url present");
+ }),
+ mockAlertsService.promise.then(result => {
+ throw new Error(
+ "Alert is shown to the user although neither window nor url is present"
+ );
+ }),
+ ]);
+});
+
+add_task(async function test_shown_to_user() {
+ // Reset promise state.
+ mockAlertsService.deferPromise();
+ // Set a window for the URL.
+ mailnewsURL.msgWindow = gMsgWindow;
+
+ // Text, url and window => expect error shown to user
+ MailServices.mailSession.alertUser("test error 2", mailnewsURL);
+ let alertShown = await mockAlertsService.promise;
+ Assert.ok(alertShown);
+});
+
+add_task(async function test_not_shown_to_user_no_window() {
+ // Reset promise state.
+ mockAlertsService.deferPromise();
+ // No window for the URL.
+ mailnewsURL.msgWindow = null;
+
+ // Text, url and no window => export no error shown to user
+ MailServices.mailSession.alertUser("test error 3", mailnewsURL);
+ await Promise.race([
+ PromiseTestUtils.promiseDelay(TEST_WAITTIME).then(result => {
+ Assert.ok(true, "Alert is not shown with no window but a url present");
+ }),
+ mockAlertsService.promise.then(result => {
+ throw new Error(
+ "Alert is shown to the user although no window in the mailnewsURL present"
+ );
+ }),
+ ]);
+});
+
+add_task(function endTest() {
+ MockRegistrar.unregister(cid);
+});
+
+class MockAlertsService {
+ QueryInterface = ChromeUtils.generateQI(["nsIAlertsService"]);
+
+ constructor() {
+ this._deferredPromise = PromiseUtils.defer();
+ }
+
+ showAlert() {
+ this._deferredPromise.resolve(true);
+ }
+
+ deferPromise() {
+ this._deferredPromise = PromiseUtils.defer();
+ }
+
+ get promise() {
+ return this._deferredPromise.promise;
+ }
+}
diff --git a/comm/mail/base/test/unit/test_attachmentChecker.js b/comm/mail/base/test/unit/test_attachmentChecker.js
new file mode 100644
index 0000000000..b727819231
--- /dev/null
+++ b/comm/mail/base/test/unit/test_attachmentChecker.js
@@ -0,0 +1,121 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* 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/. */
+
+/*
+ * Test suite for the attachmentChecker class
+ *
+ * Currently tested:
+ * - getAttachmentKeywords function.
+ */
+
+// Globals
+
+var { AttachmentChecker } = ChromeUtils.import(
+ "resource:///modules/AttachmentChecker.jsm"
+);
+
+/*
+ * UTILITIES
+ */
+
+function assert(aBeTrue, aWhy) {
+ if (!aBeTrue) {
+ do_throw(aWhy);
+ }
+}
+
+function assert_equal(aA, aB, aWhy) {
+ assert(
+ aA == aB,
+ aWhy +
+ " (" +
+ unescape(encodeURIComponent(aA)) +
+ " != " +
+ unescape(encodeURIComponent(aB)) +
+ ")."
+ );
+}
+
+/*
+ * TESTS
+ */
+
+function test_getAttachmentKeywords(desc, mailData, keywords, expected) {
+ let result = AttachmentChecker.getAttachmentKeywords(mailData, keywords);
+ assert_equal(result, expected, desc + " not equal!");
+}
+
+var tests = [
+ // Desc, mail body Data, keywords to search for, expected keywords found.
+ ["Simple keyword", "latte.ca", "latte", "latte"],
+ ["Extension", "testing document.pdf", ".pdf", "document.pdf"],
+ [
+ "Two Extensions",
+ "testing document.pdf and test.pdf",
+ ".pdf",
+ "document.pdf,test.pdf",
+ ],
+ [
+ "Two+one Extensions",
+ "testing document.pdf and test.pdf and again document.pdf",
+ ".pdf",
+ "document.pdf,test.pdf",
+ ],
+ ["Url", "testing http://document.pdf", ".pdf", ""],
+ ["Both", "testing http://document.pdf test.pdf", ".pdf", "test.pdf"],
+ ["Greek", "This is a Θεωρία test", "Θεωρία,is", "Θεωρία,is"],
+ ["Greek missing", "This a Θεωρίαω test", "Θεωρία", ""],
+ ["Greek and punctuation", "This a:Θεωρία-test", "Θεωρία", "Θεωρία"],
+ ["Greek and Japanese", "This a 添Θεωρία付 test", "Θεωρία", "Θεωρία"],
+ ["Japanese", "This is 添付! test", "Θεωρία,添付", "添付"],
+ ["More Japanese", "添付mailを送る", "添付,cv", "添付"],
+ ["Japanese and English", "添付mailを送る", "添付,mail", "添付,mail"],
+ ["Japanese and English Mixed", "添付mailを送る", "添付mail", "添付mail"],
+ ["Japanese and English Mixed missing", "添付mailing", "添付mail", ""],
+ ["Japanese trailers", "This is 添添付付! test", "Θεωρία,添付", "添付"],
+ ["Multi-lang", "cv添付Θεωρία", "Θεωρία,添付,cv", "Θεωρία,添付,cv"],
+ [
+ "Should match",
+ "I've attached the http/test.pdf file",
+ ".pdf",
+ "http/test.pdf",
+ ],
+ ["Should still fail", "a https://test.pdf a", ".pdf", ""],
+ ["Should match Japanese", "a test.添付 a", ".添付", "test.添付"],
+ ["Should match Greek", "a test.Θεωρία a", ".Θεωρία", "test.Θεωρία"],
+ ["Should match once", "a test.pdf.doc a", ".pdf,.doc", "test.pdf.doc"],
+ [
+ "Should not match kw in url",
+ "see https://example.org/attachment.cgi?id=1 test",
+ "attachment",
+ "",
+ ],
+ [
+ "Should not match kw in url ending with kw",
+ "https://example.org/attachment",
+ "attachment",
+ "",
+ ],
+ [
+ "Should match CV and attachment",
+ "got my CV as attachment",
+ "CV,attachment",
+ "CV,attachment",
+ ],
+];
+
+function run_test() {
+ do_test_pending();
+
+ for (var i in tests) {
+ if (typeof tests[i] == "function") {
+ tests[i]();
+ } else {
+ test_getAttachmentKeywords.apply(null, tests[i]);
+ }
+ }
+
+ do_test_finished();
+}
diff --git a/comm/mail/base/test/unit/test_devtools_url.js b/comm/mail/base/test/unit/test_devtools_url.js
new file mode 100644
index 0000000000..d0c8baf21f
--- /dev/null
+++ b/comm/mail/base/test/unit/test_devtools_url.js
@@ -0,0 +1,22 @@
+/**
+ * This test checks for the URL of the developer tools toolbox. If it fails,
+ * then the code for opening the toolbox has likely changed, and the code in
+ * MailGlue that observes command-line-startup will not be working properly.
+ */
+
+Cu.importGlobalProperties(["fetch"]);
+var { MailGlue } = ChromeUtils.import("resource:///modules/MailGlue.jsm");
+
+add_task(async () => {
+ let expectedURL = `"${MailGlue.BROWSER_TOOLBOX_WINDOW_URL}"`;
+ let containingFile =
+ "resource://devtools/client/framework/browser-toolbox/Launcher.sys.mjs";
+
+ let response = await fetch(containingFile);
+ let text = await response.text();
+
+ Assert.ok(
+ text.includes(expectedURL),
+ `Expected to find ${expectedURL} in ${containingFile}.`
+ );
+});
diff --git a/comm/mail/base/test/unit/test_emptyTrash_dbViewWrapper.js b/comm/mail/base/test/unit/test_emptyTrash_dbViewWrapper.js
new file mode 100644
index 0000000000..d392a9ece2
--- /dev/null
+++ b/comm/mail/base/test/unit/test_emptyTrash_dbViewWrapper.js
@@ -0,0 +1,43 @@
+/* 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 resources/viewWrapperTestUtils.js */
+load("resources/viewWrapperTestUtils.js");
+initViewWrapperTestUtils({ mode: "imap", offline: false });
+
+add_task(async function test_real_folder_load_and_move_to_trash() {
+ let viewWrapper = make_view_wrapper();
+ let [[msgFolder], msgSet] = await messageInjection.makeFoldersWithSets(1, [
+ { count: 1 },
+ ]);
+
+ await view_open(
+ viewWrapper,
+ messageInjection.getRealInjectionFolder(msgFolder)
+ );
+ verify_messages_in_view(msgSet, viewWrapper);
+
+ await messageInjection.trashMessages(msgSet);
+ verify_empty_view(viewWrapper);
+});
+
+add_task(async function test_empty_trash() {
+ let viewWrapper = make_view_wrapper();
+ let trashHandle = await messageInjection.getTrashFolder();
+ let trashFolder = messageInjection.getRealInjectionFolder(trashHandle);
+
+ await view_open(viewWrapper, trashFolder);
+
+ await messageInjection.emptyTrash();
+ verify_empty_view(viewWrapper);
+
+ Assert.ok(viewWrapper.displayedFolder !== null);
+
+ let [msgSet] = await messageInjection.makeNewSetsInFolders(
+ [trashHandle],
+ [{ count: 1 }]
+ );
+
+ verify_messages_in_view(msgSet, viewWrapper);
+});
diff --git a/comm/mail/base/test/unit/test_mailGlue_distribution.js b/comm/mail/base/test/unit/test_mailGlue_distribution.js
new file mode 100644
index 0000000000..0eb1bc9574
--- /dev/null
+++ b/comm/mail/base/test/unit/test_mailGlue_distribution.js
@@ -0,0 +1,120 @@
+var { TBDistCustomizer } = ChromeUtils.import(
+ "resource:///modules/TBDistCustomizer.jsm"
+);
+
+function run_test() {
+ do_test_pending();
+
+ Services.locale.requestedLocales = ["en-US"];
+
+ // Create an instance of nsIFile out of the current process directory
+ let distroDir = Services.dirsvc.get("XCurProcD", Ci.nsIFile);
+
+ // Construct a descendant of the distroDir file
+ distroDir.append("distribution");
+
+ // Create a clone of distroDir
+ let iniFile = distroDir.clone();
+
+ // Create a descendant of iniFile
+ iniFile.append("distribution.ini");
+ // It's a bug if distribution.ini already exists
+ if (iniFile.exists()) {
+ do_throw(
+ "distribution.ini already exists in objdir/mozilla/dist/bin/distribution."
+ );
+ }
+
+ registerCleanupFunction(function () {
+ // Remove the distribution.ini file
+ if (iniFile.exists()) {
+ iniFile.remove(true);
+ }
+ });
+
+ let testDir = Services.dirsvc.get("CurWorkD", Ci.nsIFile);
+ let testDistributionFile = testDir.clone();
+
+ // Construct descendant file
+ testDistributionFile.append("distribution.ini");
+ // Copy to distroDir
+ testDistributionFile.copyTo(distroDir, "distribution.ini");
+ Assert.ok(testDistributionFile.exists());
+
+ // Set the prefs
+ TBDistCustomizer.applyPrefDefaults();
+
+ let testIni = Cc["@mozilla.org/xpcom/ini-parser-factory;1"]
+ .getService(Ci.nsIINIParserFactory)
+ .createINIParser(testDistributionFile);
+
+ // Now check that prefs were set - test the Global prefs against the
+ // Global section in the ini file
+ let iniValue = testIni.getString("Global", "id");
+ let pref = Services.prefs.getCharPref("distribution.id");
+ Assert.equal(iniValue, pref);
+
+ iniValue = testIni.getString("Global", "version");
+ pref = Services.prefs.getCharPref("distribution.version");
+ Assert.equal(iniValue, pref);
+
+ let aboutLocale;
+ try {
+ aboutLocale = testIni.getString("Global", "about.en-US");
+ } catch (e) {
+ console.error(e);
+ }
+
+ if (aboutLocale == undefined) {
+ aboutLocale = testIni.getString("Global", "about");
+ }
+
+ pref = Services.prefs.getCharPref("distribution.about");
+ Assert.equal(aboutLocale, pref);
+
+ // Test Preferences section
+ let s = "Preferences";
+ for (let key of testIni.getKeys(s)) {
+ let value = TBDistCustomizer.parseValue(testIni.getString(s, key));
+ switch (typeof value) {
+ case "boolean":
+ Assert.equal(value, Services.prefs.getBoolPref(key));
+ break;
+ case "number":
+ Assert.equal(value, Services.prefs.getIntPref(key));
+ break;
+ case "string":
+ Assert.equal(value, Services.prefs.getCharPref(key));
+ break;
+ default:
+ do_throw(
+ "The preference " + key + " is of unknown type: " + typeof value
+ );
+ }
+ }
+
+ // Test the LocalizablePreferences-[locale] section
+ // Add any prefs found in it to the overrides array
+ let overrides = [];
+ s = "LocalizablePreferences-en-US";
+ for (let key of testIni.getKeys(s)) {
+ let value = TBDistCustomizer.parseValue(testIni.getString(s, key));
+ value = "data:text/plain," + key + "=" + value;
+ Assert.equal(value, Services.prefs.getCharPref(key));
+ overrides.push(key);
+ }
+
+ // Test the LocalizablePreferences section
+ // Any prefs here that aren't found in overrides are not overridden
+ // by LocalizablePrefs-[locale] and should be tested
+ s = "LocalizablePreferences";
+ for (let key of testIni.getKeys(s)) {
+ if (!overrides.includes(key)) {
+ let value = TBDistCustomizer.parseValue(testIni.getString(s, key));
+ value = value.replace(/%LOCALE%/g, "en-US");
+ value = "data:text/plain," + key + "=" + value;
+ Assert.equal(value, Services.prefs.getCharPref(key));
+ }
+ }
+ do_test_finished();
+}
diff --git a/comm/mail/base/test/unit/test_oauth_migration.js b/comm/mail/base/test/unit/test_oauth_migration.js
new file mode 100644
index 0000000000..1130757a97
--- /dev/null
+++ b/comm/mail/base/test/unit/test_oauth_migration.js
@@ -0,0 +1,319 @@
+/* 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/. */
+
+/**
+ * Test migrating Yahoo/AOL users to OAuth2, since "normal password" is going away
+ * on October 20, 2020.
+ */
+
+var { MailMigrator } = ChromeUtils.import(
+ "resource:///modules/MailMigrator.jsm"
+);
+var { localAccountUtils } = ChromeUtils.import(
+ "resource://testing-common/mailnews/LocalAccountUtils.jsm"
+);
+
+var gAccountList = [
+ // POP Yahoo account + Yahoo Server.
+ {
+ type: "pop3",
+ port: 1234,
+ user: "pop3user",
+ password: "pop3password",
+ hostname: "pop3.mail.yahoo.com",
+ socketType: Ci.nsMsgSocketType.plain,
+ authMethod: Ci.nsMsgAuthMethod.passwordCleartext,
+ smtpServers: [
+ {
+ port: 3456,
+ user: "imapout",
+ password: "imapoutpassword",
+ isDefault: true,
+ hostname: "smtp.mail.yahoo.com",
+ socketType: Ci.nsMsgSocketType.alwaysSTARTTLS,
+ authMethod: Ci.nsMsgAuthMethod.passwordCleartext,
+ },
+ ],
+ },
+ // IMAP Yahoo account + Google Server.
+ {
+ type: "imap",
+ port: 2345,
+ user: "imapuser",
+ password: "imappassword",
+ hostname: "imap.mail.yahoo.com",
+ socketType: Ci.nsMsgSocketType.trySTARTTLS,
+ authMethod: Ci.nsMsgAuthMethod.passwordCleartext,
+ smtpServers: [
+ {
+ port: 3456,
+ user: "imapout",
+ password: "imapoutpassword",
+ isDefault: false,
+ hostname: "smtp.gmail.com",
+ socketType: Ci.nsMsgSocketType.alwaysSTARTTLS,
+ authMethod: Ci.nsMsgAuthMethod.passwordEncrypted,
+ },
+ ],
+ },
+ // IMAP Google account + Yahoo Server.
+ {
+ type: "imap",
+ port: 2345,
+ user: "imap2user",
+ password: "imap2password",
+ hostname: "imap.gmail.com",
+ socketType: Ci.nsMsgSocketType.trySTARTTLS,
+ authMethod: Ci.nsMsgAuthMethod.passwordCleartext,
+ smtpServers: [
+ {
+ port: 3456,
+ user: "imapout",
+ password: "imapoutpassword",
+ isDefault: false,
+ hostname: "smtp.mail.yahoo.com",
+ socketType: Ci.nsMsgSocketType.alwaysSTARTTLS,
+ authMethod: Ci.nsMsgAuthMethod.passwordEncrypted,
+ },
+ ],
+ },
+ // IMAP Invalid account + Invalid Server.
+ {
+ type: "imap",
+ port: 2345,
+ user: "imap2user",
+ password: "imap2password",
+ hostname: "imap.mail.foo.invalid",
+ socketType: Ci.nsMsgSocketType.trySTARTTLS,
+ authMethod: Ci.nsMsgAuthMethod.passwordCleartext,
+ smtpServers: [
+ {
+ port: 3456,
+ user: "imapout",
+ password: "imapoutpassword",
+ isDefault: false,
+ hostname: "smtp.mail.foo.invalid",
+ socketType: Ci.nsMsgSocketType.alwaysSTARTTLS,
+ authMethod: Ci.nsMsgAuthMethod.passwordEncrypted,
+ },
+ ],
+ },
+ // AOL IMAP account.
+ {
+ type: "imap",
+ port: 993,
+ user: "aolimap",
+ password: "imap2password",
+ hostname: "imap.aol.com",
+ socketType: Ci.nsMsgSocketType.SSL,
+ authMethod: Ci.nsMsgAuthMethod.passwordCleartext,
+ smtpServers: [
+ {
+ port: 465,
+ user: "imapout2",
+ password: "imapoutpassword2",
+ isDefault: false,
+ hostname: "smtp.aol.com",
+ socketType: Ci.nsMsgSocketType.SSL,
+ authMethod: Ci.nsMsgAuthMethod.passwordCleartext,
+ },
+ ],
+ },
+ // AOL POP3 account.
+ {
+ type: "pop3",
+ port: 995,
+ user: "aolpop3",
+ password: "abc",
+ hostname: "pop.aol.com",
+ socketType: Ci.nsMsgSocketType.SSL,
+ authMethod: Ci.nsMsgAuthMethod.passwordCleartext,
+ smtpServers: [
+ {
+ port: 465,
+ user: "popout",
+ password: "aaa",
+ isDefault: false,
+ hostname: "smtp.aol.com",
+ socketType: Ci.nsMsgSocketType.SSL,
+ authMethod: Ci.nsMsgAuthMethod.passwordCleartext,
+ },
+ ],
+ },
+ // Google POP3 account.
+ {
+ type: "pop3",
+ port: 995,
+ user: "gmailpop3",
+ password: "abc",
+ hostname: "pop.gmail.com",
+ socketType: Ci.nsMsgSocketType.trySTARTTLS,
+ authMethod: Ci.nsMsgAuthMethod.passwordEncrypted,
+ smtpServers: [
+ {
+ port: 465,
+ user: "gmailpopout",
+ password: "aaa",
+ isDefault: true,
+ hostname: "smtp.gmail.com",
+ socketType: Ci.nsMsgAuthMethod.alwaysSTARTTLS,
+ authMethod: Ci.nsMsgAuthMethod.passwordEncrypted,
+ },
+ ],
+ },
+ // Microsoft IMAP account
+ {
+ type: "imap",
+ port: 993,
+ user: "msimap",
+ password: "abc",
+ hostname: "outlook.office365.com",
+ socketType: Ci.nsMsgSocketType.SSL,
+ authMethod: Ci.nsMsgAuthMethod.passwordCleartext,
+ smtpServers: [
+ {
+ port: 587,
+ user: "msimap",
+ password: "abc",
+ isDefault: true,
+ hostname: "smtp.office365.com",
+ socketType: Ci.nsMsgAuthMethod.alwaysSTARTTLS,
+ authMethod: Ci.nsMsgAuthMethod.passwordCleartext,
+ },
+ ],
+ },
+];
+
+// An array of the incoming servers created from the setup_accounts() method.
+var gIncomingServers = [];
+
+// An array of the outgoing servers created from the setup_accounts() method.
+var gOutgoingServers = [];
+
+// An array of the accounts created from the setup_accounts() method.
+var gAccounts = [];
+
+/**
+ * Set up accounts based on the given data.
+ */
+function setup_accounts() {
+ for (let details of gAccountList) {
+ let server = localAccountUtils.create_incoming_server(
+ details.type,
+ details.port,
+ details.user,
+ details.password,
+ details.hostname
+ );
+ server.socketType = details.socketType;
+ server.authMethod = details.authMethod;
+
+ // Add the newly created server to the array for testing.
+ gIncomingServers.push(server);
+
+ let account = MailServices.accounts.FindAccountForServer(server);
+ for (let smtpDetails of details.smtpServers) {
+ let outgoing = localAccountUtils.create_outgoing_server(
+ smtpDetails.port,
+ smtpDetails.user,
+ smtpDetails.password,
+ smtpDetails.hostname
+ );
+ outgoing.socketType = smtpDetails.socketType;
+ outgoing.authMethod = smtpDetails.authMethod;
+ localAccountUtils.associate_servers(
+ account,
+ outgoing,
+ smtpDetails.isDefault
+ );
+
+ // Add the newly created server to the array for testing.
+ gOutgoingServers.push(outgoing);
+
+ // Add the newly created account to the array for cleanup.
+ gAccounts.push(account);
+ }
+ }
+}
+
+add_task(function test_oauth_migration() {
+ setup_accounts();
+
+ for (let server of gIncomingServers) {
+ // Confirm all the incoming servers are not using OAuth2 after the setup.
+ Assert.notEqual(
+ server.authMethod,
+ Ci.nsMsgAuthMethod.OAuth2,
+ "Incoming server should not use OAuth2"
+ );
+ }
+
+ for (let server of gOutgoingServers) {
+ // Confirm all the outgoing servers are not using OAuth2 after the setup.
+ Assert.notEqual(
+ server.authMethod,
+ Ci.nsMsgAuthMethod.OAuth2,
+ "Outgoing server should not use OAuth2"
+ );
+ }
+
+ // Run the migration.
+ Services.prefs.setIntPref("mail.ui-rdf.version", 21);
+ MailMigrator._migrateUI();
+
+ for (let server of gIncomingServers) {
+ // Confirm only the correct incoming servers are using OAuth2 after migration.
+ if (
+ !server.hostName.endsWith("mail.yahoo.com") &&
+ !server.hostName.endsWith("aol.com") &&
+ !server.hostName.endsWith("gmail.com") &&
+ !server.hostName.endsWith("office365.com")
+ ) {
+ Assert.notEqual(
+ server.authMethod,
+ Ci.nsMsgAuthMethod.OAuth2,
+ `Incoming server ${server.hostName} should not use OAuth2 after migration`
+ );
+ continue;
+ }
+
+ Assert.equal(
+ server.authMethod,
+ Ci.nsMsgAuthMethod.OAuth2,
+ `Incoming server ${server.hostName} should use OAuth2 after migration`
+ );
+ }
+
+ for (let server of gOutgoingServers) {
+ // Confirm only the correct outgoing servers are using OAuth2 after migration.
+ if (
+ !server.hostname.endsWith("mail.yahoo.com") &&
+ !server.hostname.endsWith("aol.com") &&
+ !server.hostname.endsWith("gmail.com") &&
+ !server.hostname.endsWith("office365.com")
+ ) {
+ Assert.notEqual(
+ server.authMethod,
+ Ci.nsMsgAuthMethod.OAuth2,
+ `Outgoing server ${server.hostname} should not use OAuth2 after migration`
+ );
+ continue;
+ }
+
+ Assert.equal(
+ server.authMethod,
+ Ci.nsMsgAuthMethod.OAuth2,
+ `Outgoing server ${server.hostname} should use OAuth2 after migration`
+ );
+ }
+
+ // Remove our test accounts and servers to leave the profile clean.
+ for (let account of gAccounts) {
+ MailServices.accounts.removeAccount(account);
+ }
+
+ for (let server of gOutgoingServers) {
+ MailServices.smtp.deleteServer(server);
+ }
+});
diff --git a/comm/mail/base/test/unit/test_treeSelection.js b/comm/mail/base/test/unit/test_treeSelection.js
new file mode 100644
index 0000000000..7e1b3193d1
--- /dev/null
+++ b/comm/mail/base/test/unit/test_treeSelection.js
@@ -0,0 +1,581 @@
+/* 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 { TreeSelection } = ChromeUtils.importESModule(
+ "chrome://messenger/content/tree-selection.mjs"
+);
+
+var fakeView = {
+ rowCount: 101,
+ selectionChanged() {},
+ QueryInterface: ChromeUtils.generateQI(["nsITreeView"]),
+};
+
+var sel = new TreeSelection(null);
+sel.view = fakeView;
+
+var tree = {
+ view: fakeView,
+
+ _invalidationCount: 0,
+ invalidate() {
+ this._invalidationCount++;
+ },
+ invalidateRange(startIndex, endIndex) {
+ for (let index = startIndex; index <= endIndex; index++) {
+ this.invalidateRow(index);
+ }
+ },
+ _invalidatedRows: [],
+ invalidateRow(index) {
+ this._invalidatedRows.push(index);
+ },
+
+ assertInvalidated() {
+ Assert.equal(this._invalidationCount, 1, "invalidated once");
+ this._invalidationCount = 0;
+ this.assertInvalidatedRows();
+ },
+ assertDidntInvalidate() {
+ Assert.equal(this._invalidationCount, 0, "didn't invalidate");
+ },
+ assertInvalidatedRows(expected) {
+ if (expected) {
+ this.assertDidntInvalidate();
+ } else {
+ expected = [];
+ }
+ let numericSort = (a, b) => a - b;
+ Assert.deepEqual(
+ this._invalidatedRows.sort(numericSort),
+ expected.sort(numericSort),
+ "invalidated rows"
+ );
+ this._invalidatedRows.length = 0;
+ },
+};
+sel.tree = tree;
+
+function createRangeArray(low, high) {
+ let array = [];
+ for (let i = low; i <= high; i++) {
+ array.push(i);
+ }
+ return array;
+}
+
+function assertSelectionRanges(expected) {
+ Assert.deepEqual(sel._ranges, expected, "selected ranges");
+}
+
+function assertCurrentIndex(index) {
+ Assert.equal(sel.currentIndex, index, `current index should be ${index}`);
+}
+
+function assertShiftPivot(index) {
+ Assert.equal(
+ sel.shiftSelectPivot,
+ index,
+ `shift select pivot should be ${index}`
+ );
+}
+
+function assertSelected(index) {
+ Assert.ok(sel.isSelected(index), `${index} should be selected`);
+}
+
+function assertNotSelected(index) {
+ Assert.ok(!sel.isSelected(index), `${index} should not be selected`);
+}
+
+function run_test() {
+ // -- select
+ sel.select(1);
+ tree.assertInvalidatedRows([1]);
+ assertSelected(1);
+ assertNotSelected(0);
+ assertNotSelected(2);
+ assertSelectionRanges([[1, 1]]);
+ assertCurrentIndex(1);
+
+ sel.select(2);
+ tree.assertInvalidatedRows([1, 2]);
+ assertSelected(2);
+ assertNotSelected(1);
+ assertNotSelected(3);
+ assertSelectionRanges([[2, 2]]);
+ assertCurrentIndex(2);
+
+ // -- clearSelection
+ sel.clearSelection();
+ tree.assertInvalidatedRows([2]);
+ assertSelectionRanges([]);
+ assertCurrentIndex(2); // should still be the same...
+
+ // -- toggleSelect
+ // start from nothing
+ sel.clearSelection();
+ tree.assertInvalidatedRows([]);
+ sel.toggleSelect(1);
+ tree.assertInvalidatedRows([1]);
+ assertSelectionRanges([[1, 1]]);
+ assertCurrentIndex(1);
+
+ // lower fusion
+ sel.select(2);
+ tree.assertInvalidatedRows([1, 2]);
+ sel.toggleSelect(1);
+ tree.assertInvalidatedRows([1]);
+ assertSelectionRanges([[1, 2]]);
+ assertCurrentIndex(1);
+
+ // upper fusion
+ sel.toggleSelect(3);
+ tree.assertInvalidatedRows([3]);
+ assertSelectionRanges([[1, 3]]);
+ assertCurrentIndex(3);
+
+ // splitting
+ sel.toggleSelect(2);
+ tree.assertInvalidatedRows([2]);
+ assertSelectionRanges([
+ [1, 1],
+ [3, 3],
+ ]);
+ assertSelected(1);
+ assertSelected(3);
+ assertNotSelected(0);
+ assertNotSelected(2);
+ assertNotSelected(4);
+ assertCurrentIndex(2);
+
+ // merge
+ sel.toggleSelect(2);
+ tree.assertInvalidatedRows([2]);
+ assertSelectionRanges([[1, 3]]);
+ assertCurrentIndex(2);
+
+ // lower shrinkage
+ sel.toggleSelect(1);
+ tree.assertInvalidatedRows([1]);
+ assertSelectionRanges([[2, 3]]);
+ assertCurrentIndex(1);
+
+ // upper shrinkage
+ sel.toggleSelect(3);
+ tree.assertInvalidatedRows([3]);
+ assertSelectionRanges([[2, 2]]);
+ assertCurrentIndex(3);
+
+ // nukage
+ sel.toggleSelect(2);
+ tree.assertInvalidatedRows([2]);
+ assertSelectionRanges([]);
+ assertCurrentIndex(2);
+
+ // -- rangedSelect
+ // simple non-augment
+ sel.rangedSelect(0, 0, false);
+ tree.assertInvalidatedRows([0]);
+ assertSelectionRanges([[0, 0]]);
+ assertShiftPivot(0);
+ assertCurrentIndex(0);
+
+ // slightly less simple non-augment
+ sel.rangedSelect(2, 4, false);
+ tree.assertInvalidatedRows([0, 2, 3, 4]);
+ assertSelectionRanges([[2, 4]]);
+ assertShiftPivot(2);
+ assertCurrentIndex(4);
+
+ // higher distinct range
+ sel.rangedSelect(7, 9, true);
+ tree.assertInvalidatedRows([7, 8, 9]);
+ assertSelectionRanges([
+ [2, 4],
+ [7, 9],
+ ]);
+ assertShiftPivot(7);
+ assertCurrentIndex(9);
+
+ // lower distinct range
+ sel.rangedSelect(0, 0, true);
+ tree.assertInvalidatedRows([0]);
+ assertSelectionRanges([
+ [0, 0],
+ [2, 4],
+ [7, 9],
+ ]);
+ assertShiftPivot(0);
+ assertCurrentIndex(0);
+
+ // lower fusion
+ sel.rangedSelect(6, 6, true);
+ tree.assertInvalidatedRows([6, 7, 8, 9]); // Ideally this would just be 6.
+ assertSelectionRanges([
+ [0, 0],
+ [2, 4],
+ [6, 9],
+ ]);
+ assertShiftPivot(6);
+ assertCurrentIndex(6);
+
+ // upper fusion
+ sel.rangedSelect(10, 11, true);
+ tree.assertInvalidatedRows([6, 7, 8, 9, 10, 11]); // 10, 11
+ assertSelectionRanges([
+ [0, 0],
+ [2, 4],
+ [6, 11],
+ ]);
+ assertShiftPivot(10);
+ assertCurrentIndex(11);
+
+ // notch merge
+ sel.rangedSelect(5, 5, true);
+ tree.assertInvalidatedRows([2, 3, 4, 5, 6, 7, 8, 9, 10, 11]); // 5
+ assertSelectionRanges([
+ [0, 0],
+ [2, 11],
+ ]);
+ assertShiftPivot(5);
+ assertCurrentIndex(5);
+
+ // ambiguous consume with merge
+ sel.rangedSelect(0, 5, true);
+ tree.assertInvalidatedRows(createRangeArray(0, 11)); // 1
+ assertSelectionRanges([[0, 11]]);
+ assertShiftPivot(0);
+ assertCurrentIndex(5);
+
+ // aligned consumption
+ sel.rangedSelect(0, 15, true);
+ tree.assertInvalidatedRows(createRangeArray(0, 15)); // 12, 13, 14, 15
+ assertSelectionRanges([[0, 15]]);
+ assertShiftPivot(0);
+ assertCurrentIndex(15);
+
+ // excessive consumption
+ sel.rangedSelect(5, 7, false);
+ tree.assertInvalidatedRows(createRangeArray(0, 15)); // 0 to 4, 8 to 15
+ sel.rangedSelect(3, 10, true);
+ tree.assertInvalidatedRows([3, 4, 5, 6, 7, 8, 9, 10]); // 3, 4, 8, 9, 10
+ assertSelectionRanges([[3, 10]]);
+ assertShiftPivot(3);
+ assertCurrentIndex(10);
+
+ // overlap merge
+ sel.rangedSelect(5, 10, false);
+ tree.assertInvalidatedRows([3, 4, 5, 6, 7, 8, 9, 10]); // 3, 4
+ sel.rangedSelect(15, 20, true);
+ tree.assertInvalidatedRows([15, 16, 17, 18, 19, 20]);
+ sel.rangedSelect(7, 17, true);
+ tree.assertInvalidatedRows(createRangeArray(5, 20)); // 11, 12, 13, 14
+ assertSelectionRanges([[5, 20]]);
+ assertShiftPivot(7);
+ assertCurrentIndex(17);
+
+ // big merge and consume
+ sel.rangedSelect(5, 10, false);
+ tree.assertInvalidatedRows(createRangeArray(5, 20)); // 11 to 20
+ sel.rangedSelect(15, 20, true);
+ tree.assertInvalidatedRows([15, 16, 17, 18, 19, 20]);
+ sel.rangedSelect(25, 30, true);
+ tree.assertInvalidatedRows([25, 26, 27, 28, 29, 30]);
+ sel.rangedSelect(35, 40, true);
+ tree.assertInvalidatedRows([35, 36, 37, 38, 39, 40]);
+ sel.rangedSelect(7, 37, true);
+ tree.assertInvalidatedRows(createRangeArray(5, 40)); // 11 to 14, 21 to 24, 31 to 34
+ assertSelectionRanges([[5, 40]]);
+ assertShiftPivot(7);
+ assertCurrentIndex(37);
+
+ // broad lower fusion
+ sel.rangedSelect(10, 20, false);
+ tree.assertInvalidatedRows(createRangeArray(5, 40)); // 5 to 9, 21 to 40
+ sel.rangedSelect(3, 15, true);
+ tree.assertInvalidatedRows(createRangeArray(3, 20)); // 3 to 9
+ assertSelectionRanges([[3, 20]]);
+ assertShiftPivot(3);
+ assertCurrentIndex(15);
+
+ // -- clearRange
+ sel.rangedSelect(10, 30, false);
+ tree.assertInvalidatedRows(createRangeArray(3, 30)); // 3 to 9, 21 to 30
+
+ // irrelevant low
+ sel.clearRange(0, 5);
+ tree.assertInvalidatedRows([]);
+ assertSelectionRanges([[10, 30]]);
+
+ // irrelevant high
+ sel.clearRange(40, 45);
+ tree.assertInvalidatedRows([]);
+ assertSelectionRanges([[10, 30]]);
+
+ // lower shrinkage tight
+ sel.clearRange(10, 10);
+ tree.assertInvalidatedRows([10]);
+ assertSelectionRanges([[11, 30]]);
+
+ // lower shrinkage broad
+ sel.clearRange(0, 13);
+ tree.assertInvalidatedRows(createRangeArray(0, 13)); // 11, 12, 13
+ assertSelectionRanges([[14, 30]]);
+
+ // upper shrinkage tight
+ sel.clearRange(30, 30);
+ tree.assertInvalidatedRows([30]);
+ assertSelectionRanges([[14, 29]]);
+
+ // upper shrinkage broad
+ sel.clearRange(27, 50);
+ tree.assertInvalidatedRows(createRangeArray(27, 50)); // 27, 28, 29
+ assertSelectionRanges([[14, 26]]);
+
+ // split tight
+ sel.clearRange(20, 20);
+ tree.assertInvalidatedRows([20]);
+ assertSelectionRanges([
+ [14, 19],
+ [21, 26],
+ ]);
+
+ // split broad
+ sel.toggleSelect(20);
+ tree.assertInvalidatedRows([20]);
+ sel.clearRange(19, 21);
+ tree.assertInvalidatedRows([19, 20, 21]);
+ assertSelectionRanges([
+ [14, 18],
+ [22, 26],
+ ]);
+
+ // hit two with tight shrinkage
+ sel.clearRange(18, 22);
+ tree.assertInvalidatedRows([18, 19, 20, 21, 22]); // 18, 22
+ assertSelectionRanges([
+ [14, 17],
+ [23, 26],
+ ]);
+
+ // hit two with broad shrinkage
+ sel.clearRange(15, 25);
+ tree.assertInvalidatedRows(createRangeArray(15, 25)); // 15, 16, 17, 23, 24, 25
+ assertSelectionRanges([
+ [14, 14],
+ [26, 26],
+ ]);
+
+ // obliterate
+ sel.clearRange(0, 100);
+ tree.assertInvalidatedRows(createRangeArray(0, 100)); // 14, 26
+ assertSelectionRanges([]);
+
+ // multi-obliterate
+ sel.rangedSelect(10, 20, true);
+ tree.assertInvalidatedRows(createRangeArray(10, 20));
+ sel.rangedSelect(30, 40, true);
+ tree.assertInvalidatedRows(createRangeArray(30, 40));
+ sel.clearRange(0, 100);
+ tree.assertInvalidatedRows(createRangeArray(0, 100)); // 10 to 20, 30 to 40
+ assertSelectionRanges([]);
+
+ // obliterate with shrinkage
+ sel.rangedSelect(5, 10, true);
+ tree.assertInvalidatedRows([5, 6, 7, 8, 9, 10]);
+ sel.rangedSelect(15, 20, true);
+ tree.assertInvalidatedRows([15, 16, 17, 18, 19, 20]);
+ sel.rangedSelect(25, 30, true);
+ tree.assertInvalidatedRows([25, 26, 27, 28, 29, 30]);
+ sel.rangedSelect(35, 40, true);
+ tree.assertInvalidatedRows([35, 36, 37, 38, 39, 40]);
+ sel.clearRange(7, 37);
+ tree.assertInvalidatedRows(createRangeArray(7, 37)); // 7 to 10, 15 to 20, 25 to 30, 35 to 37
+ assertSelectionRanges([
+ [5, 6],
+ [38, 40],
+ ]);
+
+ // -- selectAll
+ sel.selectAll();
+ tree.assertInvalidated();
+ assertSelectionRanges([[0, 100]]);
+
+ // -- adjustSelection
+ // bump due to addition on simple select
+ sel.select(5);
+ tree.assertInvalidatedRows(createRangeArray(0, 100));
+ sel.adjustSelection(5, 1);
+ tree.assertInvalidatedRows(createRangeArray(5, 100));
+ assertSelectionRanges([[6, 6]]);
+ assertCurrentIndex(6);
+
+ sel.select(5);
+ tree.assertInvalidatedRows([5, 6]);
+ sel.adjustSelection(0, 1);
+ tree.assertInvalidatedRows(createRangeArray(0, 100));
+ assertSelectionRanges([[6, 6]]);
+ assertCurrentIndex(6);
+
+ // bump due to addition on ranged simple select
+ sel.rangedSelect(5, 5, false);
+ tree.assertInvalidatedRows([5, 6]);
+ sel.adjustSelection(5, 1);
+ tree.assertInvalidatedRows(createRangeArray(5, 100));
+ assertSelectionRanges([[6, 6]]);
+ assertShiftPivot(6);
+ assertCurrentIndex(6);
+
+ sel.rangedSelect(5, 5, false);
+ tree.assertInvalidatedRows([5, 6]);
+ sel.adjustSelection(0, 1);
+ tree.assertInvalidatedRows(createRangeArray(0, 100));
+ assertSelectionRanges([[6, 6]]);
+ assertShiftPivot(6);
+ assertCurrentIndex(6);
+
+ // bump due to addition on ranged select
+ sel.rangedSelect(5, 7, false);
+ tree.assertInvalidatedRows([5, 6, 7]);
+ sel.adjustSelection(5, 1);
+ tree.assertInvalidatedRows(createRangeArray(5, 100));
+ assertSelectionRanges([[6, 8]]);
+ assertShiftPivot(6);
+ assertCurrentIndex(8);
+
+ // no-op with addition
+ sel.rangedSelect(0, 3, false);
+ tree.assertInvalidatedRows([0, 1, 2, 3, 6, 7, 8]);
+ sel.adjustSelection(10, 1);
+ tree.assertInvalidatedRows(createRangeArray(10, 100));
+ assertSelectionRanges([[0, 3]]);
+ assertShiftPivot(0);
+ assertCurrentIndex(3);
+
+ // split due to addition
+ sel.rangedSelect(5, 6, false);
+ tree.assertInvalidatedRows([0, 1, 2, 3, 5, 6]);
+ sel.adjustSelection(6, 1);
+ tree.assertInvalidatedRows(createRangeArray(6, 100));
+ assertSelectionRanges([
+ [5, 5],
+ [7, 7],
+ ]);
+ assertShiftPivot(5);
+ assertCurrentIndex(7);
+
+ // shift due to removal on simple select
+ sel.select(5);
+ tree.assertInvalidatedRows([5, 7]);
+ sel.adjustSelection(0, -1);
+ tree.assertInvalidatedRows(createRangeArray(0, 100));
+ assertSelectionRanges([[4, 4]]);
+ assertCurrentIndex(4);
+
+ // shift due to removal on ranged simple select
+ sel.rangedSelect(5, 5, false);
+ tree.assertInvalidatedRows([4, 5]);
+ sel.adjustSelection(0, -1);
+ tree.assertInvalidatedRows(createRangeArray(0, 100));
+ assertSelectionRanges([[4, 4]]);
+ assertShiftPivot(4);
+ assertCurrentIndex(4);
+
+ // nuked due to removal on simple select
+ sel.select(5);
+ tree.assertInvalidatedRows([4, 5]);
+ sel.adjustSelection(5, -1);
+ tree.assertInvalidatedRows(createRangeArray(5, 100));
+ assertSelectionRanges([]);
+ assertCurrentIndex(-1);
+
+ // upper tight shrinkage due to removal
+ sel.rangedSelect(5, 10, false);
+ tree.assertInvalidatedRows([5, 6, 7, 8, 9, 10]);
+ sel.adjustSelection(10, -1);
+ tree.assertInvalidatedRows(createRangeArray(10, 100));
+ assertSelectionRanges([[5, 9]]);
+ assertShiftPivot(5);
+ assertCurrentIndex(-1);
+
+ // upper broad shrinkage due to removal
+ sel.rangedSelect(5, 10, false);
+ tree.assertInvalidatedRows([5, 6, 7, 8, 9, 10]);
+ sel.adjustSelection(6, -10);
+ tree.assertInvalidatedRows(createRangeArray(6, 100));
+ assertSelectionRanges([[5, 5]]);
+ assertShiftPivot(5);
+ assertCurrentIndex(-1);
+
+ // lower tight shrinkage due to removal
+ sel.rangedSelect(5, 10, false);
+ tree.assertInvalidatedRows([5, 6, 7, 8, 9, 10]);
+ sel.adjustSelection(5, -1);
+ tree.assertInvalidatedRows(createRangeArray(5, 100));
+ assertSelectionRanges([[5, 9]]);
+ assertShiftPivot(-1);
+ assertCurrentIndex(9);
+
+ // lower broad shrinkage due to removal
+ sel.rangedSelect(5, 10, false);
+ tree.assertInvalidatedRows([5, 6, 7, 8, 9, 10]);
+ sel.adjustSelection(0, -10);
+ tree.assertInvalidatedRows(createRangeArray(0, 100));
+ assertSelectionRanges([[0, 0]]);
+ assertShiftPivot(-1);
+ assertCurrentIndex(0);
+
+ // tight nuke due to removal
+ sel.rangedSelect(5, 10, false);
+ tree.assertInvalidatedRows([0, 5, 6, 7, 8, 9, 10]);
+ sel.adjustSelection(5, -6);
+ tree.assertInvalidatedRows(createRangeArray(5, 100));
+ assertSelectionRanges([]);
+ assertShiftPivot(-1);
+ assertCurrentIndex(-1);
+
+ // broad nuke due to removal
+ sel.rangedSelect(5, 10, false);
+ tree.assertInvalidatedRows([5, 6, 7, 8, 9, 10]);
+ sel.adjustSelection(0, -20);
+ tree.assertInvalidatedRows(createRangeArray(0, 100));
+ assertSelectionRanges([]);
+ assertShiftPivot(-1);
+ assertCurrentIndex(-1);
+
+ // duplicateSelection (please keep this right at the end, as this modifies
+ // sel)
+ // no guarantees for the shift pivot yet, so don't test that
+ let oldSel = sel;
+ let newSel = new TreeSelection(null);
+ newSel.view = fakeView;
+ // multiple selections
+ oldSel.rangedSelect(1, 3, false);
+ oldSel.rangedSelect(5, 5, true);
+ oldSel.rangedSelect(10, 10, true);
+ oldSel.rangedSelect(6, 7, true);
+
+ oldSel.duplicateSelection(newSel);
+ // from now on we're only going to be checking newSel
+ sel = newSel;
+ assertSelectionRanges([
+ [1, 3],
+ [5, 7],
+ [10, 10],
+ ]);
+ assertCurrentIndex(7);
+
+ // single selection
+ oldSel.select(4);
+ oldSel.duplicateSelection(newSel);
+ assertSelectionRanges([[4, 4]]);
+ assertCurrentIndex(4);
+
+ // nothing selected
+ oldSel.clearSelection();
+ oldSel.duplicateSelection(newSel);
+ assertSelectionRanges([]);
+ assertCurrentIndex(4);
+}
diff --git a/comm/mail/base/test/unit/test_viewWrapper_imapFolder.js b/comm/mail/base/test/unit/test_viewWrapper_imapFolder.js
new file mode 100644
index 0000000000..5e91f587fc
--- /dev/null
+++ b/comm/mail/base/test/unit/test_viewWrapper_imapFolder.js
@@ -0,0 +1,55 @@
+/* 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/. */
+
+/**
+ * Test DBViewWrapper against a single imap folder. Try and test all the
+ * features we can without having a fake newsgroup. (Some features are
+ * newsgroup specific.)
+ */
+
+/* import-globals-from resources/viewWrapperTestUtils.js */
+load("resources/viewWrapperTestUtils.js");
+initViewWrapperTestUtils({ mode: "imap", offline: false });
+
+/**
+ * Create an empty folder, inject messages into it without triggering an
+ * updateFolder, sanity check that we believe there are no messages in the
+ * folder, then enter, making sure we immediately enter and that the view
+ * properly updates to reflect there being the right set of messages.
+ * (It will fail to update if the db change listener ended up detaching itself
+ * and not reattaching correctly when the updateFolder completes.)
+ */
+add_task(
+ async function test_enter_imap_folder_requiring_update_folder_immediately() {
+ // - create the folder and wait for the IMAP op to complete
+ let folderHandle = await messageInjection.makeEmptyFolder();
+ let msgFolder = messageInjection.getRealInjectionFolder(folderHandle);
+
+ // - add the messages
+ let [msgSet] = await messageInjection.makeNewSetsInFolders(
+ [folderHandle],
+ [{ count: 1 }],
+ true
+ );
+
+ let viewWrapper = make_view_wrapper();
+
+ // - make sure we don't know about the message!
+ Assert.equal(msgFolder.getTotalMessages(false), 0);
+
+ // - sync open the folder, verify we claim we entered, and make sure it has
+ // nothing in it!
+ viewWrapper.listener.pendingLoad = true;
+ viewWrapper.open(msgFolder);
+ Assert.ok(viewWrapper._enteredFolder);
+ verify_empty_view(viewWrapper);
+
+ // Wait for all the messages to load.
+ await gMockViewWrapperListener.promise;
+ gMockViewWrapperListener.resetPromise();
+
+ // - make sure the view sees the message though...
+ verify_messages_in_view(msgSet, viewWrapper);
+ }
+);
diff --git a/comm/mail/base/test/unit/test_viewWrapper_logic.js b/comm/mail/base/test/unit/test_viewWrapper_logic.js
new file mode 100644
index 0000000000..ef56cb3997
--- /dev/null
+++ b/comm/mail/base/test/unit/test_viewWrapper_logic.js
@@ -0,0 +1,359 @@
+/* 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/. */
+
+load("../../../../mailnews/resources/abSetup.js");
+
+/* import-globals-from resources/viewWrapperTestUtils.js */
+load("resources/viewWrapperTestUtils.js");
+initViewWrapperTestUtils({ mode: "local" });
+
+/**
+ * Verify that flipping between threading and grouped by sort settings properly
+ * clears the other flag. (Because they're mutually exclusive, you see.)
+ */
+add_task(async function test_threading_grouping_mutual_exclusion() {
+ let viewWrapper = make_view_wrapper();
+ let folder = await messageInjection.makeEmptyFolder();
+
+ await view_open(viewWrapper, folder);
+ // enter an update that will never conclude. this is fine.
+ viewWrapper.beginViewUpdate();
+ viewWrapper.showThreaded = true;
+ assert_true(viewWrapper.showThreaded, "view should be threaded");
+ assert_false(
+ viewWrapper.showGroupedBySort,
+ "view should not be grouped by sort"
+ );
+
+ viewWrapper.showGroupedBySort = true;
+ assert_false(viewWrapper.showThreaded, "view should not be threaded");
+ assert_true(viewWrapper.showGroupedBySort, "view should be grouped by sort");
+});
+
+/**
+ * Verify that flipping between the "View... Threads..." menu cases supported by
+ * |showUnreadOnly| / |specialViewThreadsWithUnread| /
+ * |specialViewThreadsWithUnread| has them all be properly mutually exclusive.
+ */
+add_task(async function test_threads_special_views_mutual_exclusion() {
+ let viewWrapper = make_view_wrapper();
+ let folder = await messageInjection.makeEmptyFolder();
+
+ await view_open(viewWrapper, folder);
+ // enter an update that will never conclude. this is fine.
+ viewWrapper.beginViewUpdate();
+
+ // turn on the special view, make sure we think it took
+ viewWrapper.specialViewThreadsWithUnread = true;
+ Assert.ok(viewWrapper.specialViewThreadsWithUnread);
+ Assert.ok(!viewWrapper.specialViewWatchedThreadsWithUnread);
+
+ // hit showUnreadOnly which should already be false, so this makes sure that
+ // just writing to it forces the special view off.
+ viewWrapper.showUnreadOnly = false;
+ Assert.ok(!viewWrapper.showUnreadOnly);
+ Assert.ok(!viewWrapper.specialViewThreadsWithUnread);
+ Assert.ok(!viewWrapper.specialViewWatchedThreadsWithUnread);
+
+ // turn on the other special view
+ viewWrapper.specialViewWatchedThreadsWithUnread = true;
+ Assert.ok(!viewWrapper.specialViewThreadsWithUnread);
+ Assert.ok(viewWrapper.specialViewWatchedThreadsWithUnread);
+
+ // turn on show unread only mode, make sure special view is cleared
+ viewWrapper.showUnreadOnly = true;
+ Assert.ok(viewWrapper.showUnreadOnly);
+ Assert.ok(!viewWrapper.specialViewThreadsWithUnread);
+ Assert.ok(!viewWrapper.specialViewWatchedThreadsWithUnread);
+
+ // turn off show unread only mode just to make sure the transition happens
+ viewWrapper.showUnreadOnly = false;
+ Assert.ok(!viewWrapper.showUnreadOnly);
+ Assert.ok(!viewWrapper.specialViewThreadsWithUnread);
+ Assert.ok(!viewWrapper.specialViewWatchedThreadsWithUnread);
+});
+
+/**
+ * Do a quick test of primary sorting to make sure we're actually changing the
+ * sort order. (However, we are not responsible for verifying correctness of
+ * the sort.)
+ */
+add_task(async function test_sort_primary() {
+ let viewWrapper = make_view_wrapper();
+ // we need to put messages in the folder or the sort logic doesn't actually
+ // save the sort state. (this is the C++ view's fault.)
+ let [[folder]] = await messageInjection.makeFoldersWithSets(1, [{}]);
+
+ await view_open(viewWrapper, folder);
+ viewWrapper.sort(
+ Ci.nsMsgViewSortType.byDate,
+ Ci.nsMsgViewSortOrder.ascending
+ );
+ assert_equals(
+ viewWrapper.dbView.sortType,
+ Ci.nsMsgViewSortType.byDate,
+ "sort should be by date",
+ true
+ );
+ assert_equals(
+ viewWrapper.dbView.sortOrder,
+ Ci.nsMsgViewSortOrder.ascending,
+ "sort order should be ascending",
+ true
+ );
+
+ viewWrapper.sort(
+ Ci.nsMsgViewSortType.byAuthor,
+ Ci.nsMsgViewSortOrder.descending
+ );
+ assert_equals(
+ viewWrapper.dbView.sortType,
+ Ci.nsMsgViewSortType.byAuthor,
+ "sort should be by author",
+ true
+ );
+ assert_equals(
+ viewWrapper.dbView.sortOrder,
+ Ci.nsMsgViewSortOrder.descending,
+ "sort order should be descending",
+ true
+ );
+});
+
+/**
+ * Verify that we handle explicit secondary sorts correctly.
+ */
+add_task(async function test_sort_secondary_explicit() {
+ let viewWrapper = make_view_wrapper();
+ // we need to put messages in the folder or the sort logic doesn't actually
+ // save the sort state. (this is the C++ view's fault.)
+ let [[folder]] = await messageInjection.makeFoldersWithSets(1, [{}]);
+
+ await view_open(viewWrapper, folder);
+ viewWrapper.sort(
+ Ci.nsMsgViewSortType.byAuthor,
+ Ci.nsMsgViewSortOrder.ascending,
+ Ci.nsMsgViewSortType.bySubject,
+ Ci.nsMsgViewSortOrder.descending
+ );
+ // check once for what we just did, then again after refreshing to make
+ // sure the sort order 'stuck'
+ for (let i = 0; i < 2; i++) {
+ assert_equals(
+ viewWrapper.dbView.sortType,
+ Ci.nsMsgViewSortType.byAuthor,
+ "sort should be by author"
+ );
+ assert_equals(
+ viewWrapper.dbView.sortOrder,
+ Ci.nsMsgViewSortOrder.ascending,
+ "sort order should be ascending"
+ );
+ assert_equals(
+ viewWrapper.dbView.secondarySortType,
+ Ci.nsMsgViewSortType.bySubject,
+ "secondary sort should be by subject"
+ );
+ assert_equals(
+ viewWrapper.dbView.secondarySortOrder,
+ Ci.nsMsgViewSortOrder.descending,
+ "secondary sort order should be descending"
+ );
+ await view_refresh(viewWrapper);
+ }
+});
+
+/**
+ * Verify that we handle implicit secondary sorts correctly.
+ * An implicit secondary sort is when we sort by Y, then we sort by X, and it's
+ * okay to have the effective sort of [X, Y]. The UI has/wants this, so, uh,
+ * let's make sure we obey its assumptions unless we have gone and made the UI
+ * be explicit about these things. We can't simply depend on the view to do
+ * this for us. Why? Because we re-create the view all the bloody time.
+ */
+add_task(async function test_sort_secondary_implicit() {
+ let viewWrapper = make_view_wrapper();
+ // we need to put messages in the folder or the sort logic doesn't actually
+ // save the sort state. (this is the C++ view's fault.)
+ let [[folder]] = await messageInjection.makeFoldersWithSets(1, [{}]);
+
+ await view_open(viewWrapper, folder);
+ viewWrapper.magicSort(
+ Ci.nsMsgViewSortType.bySubject,
+ Ci.nsMsgViewSortOrder.descending
+ );
+ viewWrapper.magicSort(
+ Ci.nsMsgViewSortType.byAuthor,
+ Ci.nsMsgViewSortOrder.ascending
+ );
+ // check once for what we just did, then again after refreshing to make
+ // sure the sort order 'stuck'
+ for (let i = 0; i < 2; i++) {
+ assert_equals(
+ viewWrapper.dbView.sortType,
+ Ci.nsMsgViewSortType.byAuthor,
+ "sort should be by author"
+ );
+ assert_equals(
+ viewWrapper.dbView.sortOrder,
+ Ci.nsMsgViewSortOrder.ascending,
+ "sort order should be ascending"
+ );
+ assert_equals(
+ viewWrapper.dbView.secondarySortType,
+ Ci.nsMsgViewSortType.bySubject,
+ "secondary sort should be by subject"
+ );
+ assert_equals(
+ viewWrapper.dbView.secondarySortOrder,
+ Ci.nsMsgViewSortOrder.descending,
+ "secondary sort order should be descending"
+ );
+ await view_refresh(viewWrapper);
+ }
+});
+
+/**
+ * Test that group-by-sort does not explode even if we try and get it to use
+ * sorts that are illegal for group-by-sort mode. It is important that we
+ * test both illegal primary sorts (fixed a while back) plus illegal
+ * secondary sorts (fixing now).
+ *
+ * Note: Sorting changes are synchronous, but toggling grouped by sort requires
+ * a view rebuild.
+ */
+add_task(async function test_sort_group_by_sort() {
+ let viewWrapper = make_view_wrapper();
+ // we need to put messages in the folder or the sort logic doesn't actually
+ // save the sort state. (this is the C++ view's fault.)
+ let [[folder]] = await messageInjection.makeFoldersWithSets(1, [{}]);
+ await view_open(viewWrapper, folder);
+
+ // - start out by being in an illegal (for group-by-sort) sort mode and
+ // switch to group-by-sort.
+ // (sorting changes are synchronous)
+ viewWrapper.sort(Ci.nsMsgViewSortType.byId, Ci.nsMsgViewSortOrder.descending);
+ await view_group_by_sort(viewWrapper, true);
+
+ // there should have been no explosion, and we should have changed to date
+ assert_equals(
+ viewWrapper.primarySortType,
+ Ci.nsMsgViewSortType.byDate,
+ "sort should have reset to date"
+ );
+
+ // - return to unthreaded, have an illegal secondary sort, go group-by-sort
+ await view_group_by_sort(viewWrapper, false);
+
+ viewWrapper.sort(
+ Ci.nsMsgViewSortType.byDate,
+ Ci.nsMsgViewSortOrder.descending,
+ Ci.nsMsgViewSortType.byId,
+ Ci.nsMsgViewSortOrder.descending
+ );
+
+ await view_group_by_sort(viewWrapper, true);
+ // we should now only have a single sort type and it should be date
+ assert_equals(
+ viewWrapper._sort.length,
+ 1,
+ "we should only have one sort type active"
+ );
+ assert_equals(
+ viewWrapper.primarySortType,
+ Ci.nsMsgViewSortType.byDate,
+ "remaining (primary) sort type should be date"
+ );
+
+ // - try and make group-by-sort sort by something illegal
+ // (we're still in group-by-sort mode)
+ viewWrapper.magicSort(
+ Ci.nsMsgViewSortType.byId,
+ Ci.nsMsgViewSortOrder.descending
+ );
+ assert_equals(
+ viewWrapper.primarySortType,
+ Ci.nsMsgViewSortType.byDate,
+ "remaining (primary) sort type should be date"
+ );
+});
+
+/**
+ * Verify that mailview changes are properly persisted but that we only use them
+ * when the listener indicates we should use them (because the widget is
+ * presumably visible).
+ */
+add_task(async function test_mailviews_persistence() {
+ let viewWrapper = make_view_wrapper();
+ let folder = await messageInjection.makeEmptyFolder();
+
+ // open the folder, ensure it is using the default mail view
+ await view_open(viewWrapper, folder);
+ Assert.equal(viewWrapper.mailViewIndex, MailViewConstants.kViewItemAll);
+
+ // set the view so as to be persisted
+ viewWrapper.setMailView(MailViewConstants.kViewItemUnread);
+ // ...but first make sure it took at all
+ Assert.equal(viewWrapper.mailViewIndex, MailViewConstants.kViewItemUnread);
+
+ // close, re-open and verify it took
+ viewWrapper.close();
+ await view_open(viewWrapper, folder);
+ Assert.equal(viewWrapper.mailViewIndex, MailViewConstants.kViewItemUnread);
+
+ // close, turn off the mailview usage indication by the listener...
+ viewWrapper.close();
+ gMockViewWrapperListener.shouldUseMailViews = false;
+ // ...open and verify that it did not take!
+ await view_open(viewWrapper, folder);
+ Assert.equal(viewWrapper.mailViewIndex, MailViewConstants.kViewItemAll);
+
+ // put the mailview setting back so other tests work
+ gMockViewWrapperListener.shouldUseMailViews = true;
+});
+
+/**
+ * Make sure:
+ * - View update depth increments / decrements as expected, and triggers a
+ * view rebuild when expected.
+ * - View update depth can't go below zero resulting in odd happenings.
+ * - That the view update depth is zeroed by a close so that we don't
+ * get into awkward states.
+ *
+ * @bug 498145
+ */
+add_task(function test_view_update_depth_logic() {
+ let viewWrapper = make_view_wrapper();
+
+ // create an instance-specific dummy method that counts calls t
+ // _applyViewChanges
+ let applyViewCount = 0;
+ viewWrapper._applyViewChanges = function () {
+ applyViewCount++;
+ };
+
+ // - view update depth basics
+ Assert.equal(viewWrapper._viewUpdateDepth, 0);
+ viewWrapper.beginViewUpdate();
+ Assert.equal(viewWrapper._viewUpdateDepth, 1);
+ viewWrapper.beginViewUpdate();
+ Assert.equal(viewWrapper._viewUpdateDepth, 2);
+ viewWrapper.endViewUpdate();
+ Assert.equal(applyViewCount, 0);
+ Assert.equal(viewWrapper._viewUpdateDepth, 1);
+ viewWrapper.endViewUpdate();
+ Assert.equal(applyViewCount, 1);
+ Assert.equal(viewWrapper._viewUpdateDepth, 0);
+
+ // - don't go below zero! (and don't trigger.)
+ applyViewCount = 0;
+ viewWrapper.endViewUpdate();
+ Assert.equal(applyViewCount, 0);
+ Assert.equal(viewWrapper._viewUpdateDepth, 0);
+
+ // - depth zeroed on clear
+ viewWrapper.beginViewUpdate();
+ viewWrapper.close(); // this does little else because there is nothing open
+ Assert.equal(viewWrapper._viewUpdateDepth, 0);
+});
diff --git a/comm/mail/base/test/unit/test_viewWrapper_realFolder.js b/comm/mail/base/test/unit/test_viewWrapper_realFolder.js
new file mode 100644
index 0000000000..fbcef1abe8
--- /dev/null
+++ b/comm/mail/base/test/unit/test_viewWrapper_realFolder.js
@@ -0,0 +1,666 @@
+/* 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/. */
+
+/**
+ * Test DBViewWrapper against a single local folder. Try and test all the
+ * features we can without having a fake newsgroup. (Some features are
+ * newsgroup specific.)
+ */
+
+/* import-globals-from resources/viewWrapperTestUtils.js */
+load("resources/viewWrapperTestUtils.js");
+initViewWrapperTestUtils({ mode: "local" });
+
+var { SyntheticMessageSet } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MessageGenerator.jsm"
+);
+var { setTimeout } = ChromeUtils.importESModule(
+ "resource://gre/modules/Timer.sys.mjs"
+);
+
+/* ===== Real Folder, no features ===== */
+
+/**
+ * Open a pre-populated real folder, make sure all the messages show up.
+ */
+add_task(async function test_real_folder_load() {
+ let viewWrapper = make_view_wrapper();
+ let [[msgFolder], msgSet] = await messageInjection.makeFoldersWithSets(1, [
+ { count: 1 },
+ ]);
+ viewWrapper.open(msgFolder);
+ verify_messages_in_view(msgSet, viewWrapper);
+ Assert.ok("test ran to completion");
+});
+
+/**
+ * Open a real folder, add some messages, make sure they show up, remove some
+ * messages, make sure they go away.
+ */
+add_task(async function test_real_folder_update() {
+ let viewWrapper = make_view_wrapper();
+
+ // start with an empty folder
+ let msgFolder = await messageInjection.makeEmptyFolder();
+ viewWrapper.open(msgFolder);
+ verify_empty_view(viewWrapper);
+
+ // add messages (none -> some)
+ let [setOne] = await messageInjection.makeNewSetsInFolders([msgFolder], [{}]);
+ verify_messages_in_view(setOne, viewWrapper);
+
+ // add more messages! (some -> more)
+ let [setTwo] = await messageInjection.makeNewSetsInFolders([msgFolder], [{}]);
+ verify_messages_in_view([setOne, setTwo], viewWrapper);
+
+ // remove the first set of messages (more -> some)
+ await messageInjection.trashMessages(setOne);
+ verify_messages_in_view(setTwo, viewWrapper);
+
+ // remove the second set of messages (some -> none)
+ await messageInjection.trashMessages(setTwo);
+ verify_empty_view(viewWrapper);
+});
+
+/**
+ * Open a real folder, verify, open another folder, verify. We are testing
+ * ability to change folders without exploding.
+ */
+add_task(async function test_real_folder_load_after_real_folder_load() {
+ let viewWrapper = make_view_wrapper();
+
+ let [[folderOne], setOne] = await messageInjection.makeFoldersWithSets(1, [
+ {},
+ ]);
+ viewWrapper.open(folderOne);
+ verify_messages_in_view(setOne, viewWrapper);
+
+ let [[folderTwo], setTwo] = await messageInjection.makeFoldersWithSets(1, [
+ {},
+ ]);
+ viewWrapper.open(folderTwo);
+ verify_messages_in_view(setTwo, viewWrapper);
+});
+
+/* ===== Real Folder, Threading Modes ==== */
+/*
+ * The first three tests that verify setting the threading flags has the
+ * expected outcome do this by creating the view from scratch with the view
+ * flags applied. The view threading persistence test handles making sure
+ * that changes in threading on-the-fly work from the perspective of the
+ * bits and what not. None of these are tests of the view implementation's
+ * threading/grouping logic, just sanity checking that we are doing the right
+ * thing.
+ */
+
+add_task(async function test_real_folder_threading_unthreaded() {
+ let viewWrapper = make_view_wrapper();
+ let folder = await messageInjection.makeEmptyFolder();
+
+ // create a single maximally nested thread.
+ const count = 10;
+ let messageSet = new SyntheticMessageSet(
+ gMessageScenarioFactory.directReply(count)
+ );
+ await messageInjection.addSetsToFolders([folder], [messageSet]);
+
+ // verify that we are not threaded (or grouped)
+ viewWrapper.open(folder);
+ viewWrapper.beginViewUpdate();
+ viewWrapper.showUnthreaded = true;
+ // whitebox test view flags (we've gotten them wrong before...)
+ assert_bit_not_set(
+ viewWrapper._viewFlags,
+ Ci.nsMsgViewFlagsType.kThreadedDisplay,
+ "View threaded bit should not be set."
+ );
+ assert_bit_not_set(
+ viewWrapper._viewFlags,
+ Ci.nsMsgViewFlagsType.kGroupBySort,
+ "View group-by-sort bit should not be set."
+ );
+ viewWrapper.endViewUpdate();
+ verify_view_level_histogram({ 0: count }, viewWrapper);
+});
+
+add_task(async function test_real_folder_threading_threaded() {
+ let viewWrapper = make_view_wrapper();
+ let folder = await messageInjection.makeEmptyFolder();
+
+ // create a single maximally nested thread.
+ const count = 10;
+ let messageSet = new SyntheticMessageSet(
+ gMessageScenarioFactory.directReply(count)
+ );
+ await messageInjection.addSetsToFolders([folder], [messageSet]);
+
+ // verify that we are threaded (in such a way that we can't be grouped)
+ viewWrapper.open(folder);
+ viewWrapper.beginViewUpdate();
+ viewWrapper.showThreaded = true;
+ // whitebox test view flags (we've gotten them wrong before...)
+ assert_bit_set(
+ viewWrapper._viewFlags,
+ Ci.nsMsgViewFlagsType.kThreadedDisplay,
+ "View threaded bit should be set."
+ );
+ assert_bit_not_set(
+ viewWrapper._viewFlags,
+ Ci.nsMsgViewFlagsType.kGroupBySort,
+ "View group-by-sort bit should not be set."
+ );
+ // expand everything so our logic below works.
+ view_expand_all(viewWrapper);
+ viewWrapper.endViewUpdate();
+ // blackbox test view flags: make sure IsContainer is true for the root
+ verify_view_row_at_index_is_container(viewWrapper, 0);
+ // do the histogram test to verify threading...
+ let expectedHisto = {};
+ for (let i = 0; i < count; i++) {
+ expectedHisto[i] = 1;
+ }
+ verify_view_level_histogram(expectedHisto, viewWrapper);
+});
+
+add_task(async function test_real_folder_threading_grouped_by_sort() {
+ let viewWrapper = make_view_wrapper();
+
+ // create some messages that belong to the 'in this week' bucket when sorting
+ // by date and grouping by date.
+ const count = 5;
+ let [[folder]] = await messageInjection.makeFoldersWithSets(1, [
+ { count, age: { days: 2 }, age_incr: { mins: 1 } },
+ ]);
+
+ // group-by-sort sorted by date
+ viewWrapper.open(folder);
+ viewWrapper.beginViewUpdate();
+ viewWrapper.showGroupedBySort = true;
+ // whitebox test view flags (we've gotten them wrong before...)
+ assert_bit_set(
+ viewWrapper._viewFlags,
+ Ci.nsMsgViewFlagsType.kThreadedDisplay,
+ "View threaded bit should be set."
+ );
+ assert_bit_set(
+ viewWrapper._viewFlags,
+ Ci.nsMsgViewFlagsType.kGroupBySort,
+ "View group-by-sort bit should be set."
+ );
+ viewWrapper.sort(
+ Ci.nsMsgViewSortType.byDate,
+ Ci.nsMsgViewSortOrder.ascending
+ );
+ // expand everyone
+ view_expand_all(viewWrapper);
+ viewWrapper.endViewUpdate();
+
+ // make sure the level depths are correct
+ verify_view_level_histogram({ 0: 1, 1: count }, viewWrapper);
+ // and make sure the first dude is a dummy
+ verify_view_row_at_index_is_dummy(viewWrapper, 0);
+});
+
+/**
+ * Verify that we the threading modes are persisted. We are only checking
+ * flags here; we trust the previous tests to have done their job.
+ */
+add_task(async function test_real_folder_threading_persistence() {
+ let viewWrapper = make_view_wrapper();
+ let folder = await messageInjection.makeEmptyFolder();
+
+ // create a single maximally nested thread.
+ const count = 10;
+ let messageSet = new SyntheticMessageSet(
+ gMessageScenarioFactory.directReply(count)
+ );
+ await messageInjection.addSetsToFolders([folder], [messageSet]);
+
+ // open the folder, set threaded mode, close it
+ viewWrapper.open(folder);
+ viewWrapper.showThreaded = true; // should be instantaneous
+ verify_view_row_at_index_is_container(viewWrapper, 0);
+ assert_bit_set(
+ viewWrapper._viewFlags,
+ Ci.nsMsgViewFlagsType.kThreadedDisplay,
+ "View threaded bit should be set."
+ );
+ assert_bit_not_set(
+ viewWrapper._viewFlags,
+ Ci.nsMsgViewFlagsType.kGroupBySort,
+ "View group-by-sort bit should not be set."
+ );
+ viewWrapper.close();
+
+ // open it again, make sure we're threaded, go unthreaded, close
+ viewWrapper.open(folder);
+ assert_true(viewWrapper.showThreaded, "view should be threaded");
+ assert_false(viewWrapper.showUnthreaded, "view is lying about threading");
+ assert_false(viewWrapper.showGroupedBySort, "view is lying about threading");
+ verify_view_row_at_index_is_container(viewWrapper, 0);
+ assert_bit_set(
+ viewWrapper._viewFlags,
+ Ci.nsMsgViewFlagsType.kThreadedDisplay,
+ "View threaded bit should be set."
+ );
+ assert_bit_not_set(
+ viewWrapper._viewFlags,
+ Ci.nsMsgViewFlagsType.kGroupBySort,
+ "View group-by-sort bit should not be set."
+ );
+
+ viewWrapper.showUnthreaded = true;
+ assert_bit_not_set(
+ viewWrapper._viewFlags,
+ Ci.nsMsgViewFlagsType.kThreadedDisplay,
+ "View threaded bit should not be set."
+ );
+ assert_bit_not_set(
+ viewWrapper._viewFlags,
+ Ci.nsMsgViewFlagsType.kGroupBySort,
+ "View group-by-sort bit should not be set."
+ );
+ viewWrapper.close();
+
+ // open it again, make sure we're unthreaded, go grouped, close
+ viewWrapper.open(folder);
+ assert_true(viewWrapper.showUnthreaded, "view should be unthreaded");
+ assert_false(viewWrapper.showThreaded, "view is lying about threading");
+ assert_false(viewWrapper.showGroupedBySort, "view is lying about threading");
+ assert_bit_not_set(
+ viewWrapper._viewFlags,
+ Ci.nsMsgViewFlagsType.kThreadedDisplay,
+ "View threaded bit should not be set."
+ );
+ assert_bit_not_set(
+ viewWrapper._viewFlags,
+ Ci.nsMsgViewFlagsType.kGroupBySort,
+ "View group-by-sort bit should not be set."
+ );
+
+ viewWrapper.showGroupedBySort = true;
+ assert_bit_set(
+ viewWrapper._viewFlags,
+ Ci.nsMsgViewFlagsType.kThreadedDisplay,
+ "View threaded bit should be set."
+ );
+ assert_bit_set(
+ viewWrapper._viewFlags,
+ Ci.nsMsgViewFlagsType.kGroupBySort,
+ "View group-by-sort bit should be set."
+ );
+ viewWrapper.close();
+
+ // open it again, make sure we're grouped.
+ viewWrapper.open(folder);
+ assert_true(viewWrapper.showGroupedBySort, "view should be grouped");
+ assert_false(viewWrapper.showThreaded, "view is lying about threading");
+ assert_false(viewWrapper.showUnthreaded, "view is lying about threading");
+ assert_bit_set(
+ viewWrapper._viewFlags,
+ Ci.nsMsgViewFlagsType.kThreadedDisplay,
+ "View threaded bit should be set."
+ );
+ assert_bit_set(
+ viewWrapper._viewFlags,
+ Ci.nsMsgViewFlagsType.kGroupBySort,
+ "View group-by-sort bit should be set."
+ );
+});
+
+/* ===== Real Folder, View Flags ===== */
+
+/*
+ * We cannot test the ignored flag for a local folder because we cannot ignore
+ * threads in a local folder. Only newsgroups can do that and that's not
+ * easily testable at this time.
+ * XXX ^^^ ignoring now works on mail as well.
+ */
+
+/**
+ * Test the kUnreadOnly flag usage. This functionality is equivalent to the
+ * mailview kViewItemUnread case, so it uses roughly the same test as
+ * test_real_folder_mail_views_unread.
+ */
+add_task(async function test_real_folder_flags_show_unread() {
+ let viewWrapper = make_view_wrapper();
+
+ let [[folder], setOne, setTwo] = await messageInjection.makeFoldersWithSets(
+ 1,
+ [{}, {}]
+ );
+
+ // everything is unread to start with! #1
+ viewWrapper.open(folder);
+ viewWrapper.beginViewUpdate();
+ viewWrapper.showUnreadOnly = true;
+ viewWrapper.endViewUpdate();
+ verify_messages_in_view([setOne, setTwo], viewWrapper);
+
+ // add some more things (unread!), make sure they appear. #2
+ let [setThree] = await messageInjection.makeNewSetsInFolders([folder], [{}]);
+ verify_messages_in_view([setOne, setTwo, setThree], viewWrapper);
+
+ // make some things read, make sure they disappear. #3 (after refresh)
+ setTwo.setRead(true);
+ viewWrapper.refresh(); // refresh to get the messages to disappear
+
+ verify_messages_in_view([setOne, setThree], viewWrapper);
+
+ // make those things un-read again. #2
+ setTwo.setRead(false);
+ viewWrapper.refresh(); // QUICKSEARCH-VIEW-LIMITATION-REMOVE or not?
+ verify_messages_in_view([setOne, setTwo, setThree], viewWrapper);
+});
+
+/* ===== Real Folder, Mail Views ===== */
+
+/*
+ * For these tests, we are testing the filtering logic, not grouping or sorting
+ * logic. The view tests are responsible for that stuff. We test that:
+ *
+ * 1) The view is populated correctly on open.
+ * 2) The view adds things that become relevant.
+ * 3) The view removes things that are no longer relevant. Because views like
+ * to be stable (read: messages don't disappear as you look at them), this
+ * requires refreshing the view (unless the message has been deleted).
+ */
+
+/**
+ * Test the kViewItemUnread mail-view case. This functionality is equivalent
+ * to the kUnreadOnly view flag case, so it uses roughly the same test as
+ * test_real_folder_flags_show_unread.
+ */
+add_task(async function test_real_folder_mail_views_unread() {
+ let viewWrapper = make_view_wrapper();
+
+ let [[folder], setOne, setTwo] = await messageInjection.makeFoldersWithSets(
+ 1,
+ [{}, {}]
+ );
+
+ // everything is unread to start with! #1
+ viewWrapper.open(folder);
+ await new Promise(resolve => setTimeout(resolve));
+ viewWrapper.setMailView(MailViewConstants.kViewItemUnread, null);
+ verify_messages_in_view([setOne, setTwo], viewWrapper);
+
+ // add some more things (unread!), make sure they appear. #2
+ let [setThree] = await messageInjection.makeNewSetsInFolders([folder], [{}]);
+ verify_messages_in_view([setOne, setTwo, setThree], viewWrapper);
+
+ // make some things read, make sure they disappear. #3 (after refresh)
+ setTwo.setRead(true);
+ viewWrapper.refresh(); // refresh to get the messages to disappear
+ verify_messages_in_view([setOne, setThree], viewWrapper);
+
+ // make those things un-read again. #2
+ setTwo.setRead(false);
+ viewWrapper.refresh(); // QUICKSEARCH-VIEW-LIMITATION-REMOVE
+ verify_messages_in_view([setOne, setTwo, setThree], viewWrapper);
+});
+
+add_task(async function test_real_folder_mail_views_tags() {
+ let viewWrapper = make_view_wrapper();
+
+ // setup the initial set with the tag
+ let [[folder], setOne, setTwo] = await messageInjection.makeFoldersWithSets(
+ 1,
+ [{}, {}]
+ );
+ setOne.addTag("$label1");
+
+ // open, apply mail view constraint, see those messages
+ viewWrapper.open(folder);
+ await new Promise(resolve => setTimeout(resolve));
+ viewWrapper.setMailView(MailViewConstants.kViewItemTags, "$label1");
+ verify_messages_in_view(setOne, viewWrapper);
+
+ // add some more with the tag
+ setTwo.addTag("$label1");
+
+ // make sure they showed up
+ viewWrapper.refresh(); // QUICKSEARCH-VIEW-LIMITATION-REMOVE
+ verify_messages_in_view([setOne, setTwo], viewWrapper);
+
+ // remove them all
+ setOne.removeTag("$label1");
+ setTwo.removeTag("$label1");
+
+ // make sure they all disappeared. #3
+ viewWrapper.refresh();
+ verify_empty_view(viewWrapper);
+});
+
+/*
+add_task(async function test_real_folder_mail_views_not_deleted() {
+ // not sure how to test this in the absence of an IMAP account with the IMAP
+ // deletion model...
+});
+
+add_task(async function test_real_folder_mail_views_custom_people_i_know() {
+ // blurg. address book.
+});
+*/
+
+// recent mail = less than 1 day
+add_task(async function test_real_folder_mail_views_custom_recent_mail() {
+ let viewWrapper = make_view_wrapper();
+
+ // create a set that meets the threshold and a set that does not
+ let [[folder], setRecent] = await messageInjection.makeFoldersWithSets(1, [
+ { age: { mins: 0 } },
+ { age: { days: 2 }, age_incr: { mins: 1 } },
+ ]);
+
+ // open the folder, ensure only the recent guys show. #1
+ viewWrapper.open(folder);
+ await new Promise(resolve => setTimeout(resolve));
+ viewWrapper.setMailView("Recent Mail", null);
+ verify_messages_in_view(setRecent, viewWrapper);
+
+ // add two more sets, one that meets, and one that doesn't. #2
+ let [setMoreRecent] = await messageInjection.makeNewSetsInFolders(
+ [folder],
+ [
+ { age: { mins: 0 } },
+ { age: { days: 2, hours: 1 }, age_incr: { mins: 1 } },
+ ]
+ );
+ // make sure that all we see is our previous recent set and our new recent set
+ verify_messages_in_view([setRecent, setMoreRecent], viewWrapper);
+
+ // we aren't going to mess with the system clock, so no #3.
+ // (we are assuming that the underlying code handles message deletion. also,
+ // we are taking the position that message timestamps should not change.)
+});
+
+add_task(async function test_real_folder_mail_views_custom_last_5_days() {
+ let viewWrapper = make_view_wrapper();
+
+ // create a set that meets the threshold and a set that does not
+ let [[folder], setRecent] = await messageInjection.makeFoldersWithSets(1, [
+ { age: { days: 2 }, age_incr: { mins: 1 } },
+ { age: { days: 6 }, age_incr: { mins: 1 } },
+ ]);
+
+ // open the folder, ensure only the recent guys show. #1
+ viewWrapper.open(folder);
+ await new Promise(resolve => setTimeout(resolve));
+ viewWrapper.setMailView("Last 5 Days", null);
+ verify_messages_in_view(setRecent, viewWrapper);
+
+ // add two more sets, one that meets, and one that doesn't. #2
+ let [setMoreRecent] = await messageInjection.makeNewSetsInFolders(
+ [folder],
+ [
+ { age: { mins: 0 } },
+ { age: { days: 5, hours: 1 }, age_incr: { mins: 1 } },
+ ]
+ );
+ // make sure that all we see is our previous recent set and our new recent set
+ verify_messages_in_view([setRecent, setMoreRecent], viewWrapper);
+
+ // we aren't going to mess with the system clock, so no #3.
+ // (we are assuming that the underlying code handles message deletion. also,
+ // we are taking the position that message timestamps should not change.)
+});
+
+add_task(async function test_real_folder_mail_views_custom_not_junk() {
+ let viewWrapper = make_view_wrapper();
+
+ let [[folder], setJunk, setNotJunk] =
+ await messageInjection.makeFoldersWithSets(1, [{}, {}]);
+ setJunk.setJunk(true);
+ setNotJunk.setJunk(false);
+
+ // open, see non-junk messages. #1
+ viewWrapper.open(folder);
+ await new Promise(resolve => setTimeout(resolve));
+ viewWrapper.setMailView("Not Junk", null);
+ verify_messages_in_view(setNotJunk, viewWrapper);
+
+ // add some more messages, have them be non-junk for now. #2
+ let [setFlippy] = await messageInjection.makeNewSetsInFolders([folder], [{}]);
+ setFlippy.setJunk(false);
+ viewWrapper.refresh(); // QUICKSEARCH-VIEW-LIMITATION-REMOVE
+ verify_messages_in_view([setNotJunk, setFlippy], viewWrapper);
+
+ // oops! they should be junk! #3
+ setFlippy.setJunk(true);
+ viewWrapper.refresh();
+ verify_messages_in_view(setNotJunk, viewWrapper);
+});
+
+add_task(async function test_real_folder_mail_views_custom_has_attachments() {
+ let viewWrapper = make_view_wrapper();
+
+ let attachSetDef = {
+ attachments: [
+ {
+ filename: "foo.png",
+ contentType: "image/png",
+ encoding: "base64",
+ charset: null,
+ body: "YWJj\n",
+ format: null,
+ },
+ ],
+ };
+ let noAttachSetDef = {};
+
+ let [[folder], , setAttach] = await messageInjection.makeFoldersWithSets(1, [
+ noAttachSetDef,
+ attachSetDef,
+ ]);
+ viewWrapper.open(folder);
+ await new Promise(resolve => setTimeout(resolve));
+ viewWrapper.setMailView("Has Attachments", null);
+ verify_messages_in_view(setAttach, viewWrapper);
+
+ let [setMoreAttach] = await messageInjection.makeNewSetsInFolders(
+ [folder],
+ [attachSetDef, noAttachSetDef]
+ );
+ verify_messages_in_view([setAttach, setMoreAttach], viewWrapper);
+});
+
+/* ===== Real Folder, Special Views ===== */
+
+add_task(async function test_real_folder_special_views_threads_with_unread() {
+ let viewWrapper = make_view_wrapper();
+ let folder = await messageInjection.makeEmptyFolder();
+
+ // create two maximally nested threads and add them to the folder.
+ const count = 10;
+ let setThreadOne = new SyntheticMessageSet(
+ gMessageScenarioFactory.directReply(count)
+ );
+ let setThreadTwo = new SyntheticMessageSet(
+ gMessageScenarioFactory.directReply(count)
+ );
+ await messageInjection.addSetsToFolders(
+ [folder],
+ [setThreadOne, setThreadTwo]
+ );
+
+ // open the view, set it to this special view
+ viewWrapper.open(folder);
+ viewWrapper.beginViewUpdate();
+ viewWrapper.specialViewThreadsWithUnread = true;
+ view_expand_all(viewWrapper);
+ viewWrapper.endViewUpdate();
+
+ // no one is read at this point, make sure both threads show up.
+ verify_messages_in_view([setThreadOne, setThreadTwo], viewWrapper);
+
+ // mark both threads read, make sure they disappear (after a refresh)
+ setThreadOne.setRead(true);
+ setThreadTwo.setRead(true);
+ viewWrapper.refresh();
+ verify_empty_view(viewWrapper);
+
+ // make the first thread visible by marking his last message unread
+ setThreadOne.slice(-1).setRead(false);
+
+ view_expand_all(viewWrapper);
+ viewWrapper.refresh();
+ verify_messages_in_view(setThreadOne, viewWrapper);
+
+ // make the second thread visible by marking some message in the middle
+ setThreadTwo.slice(5, 6).setRead(false);
+ view_expand_all(viewWrapper);
+ viewWrapper.refresh();
+ verify_messages_in_view([setThreadOne, setThreadTwo], viewWrapper);
+});
+
+/**
+ * Make sure that we restore special views from their persisted state when
+ * opening the view.
+ */
+add_task(async function test_real_folder_special_views_persist() {
+ let viewWrapper = make_view_wrapper();
+ let folder = await messageInjection.makeEmptyFolder();
+
+ viewWrapper.open(folder);
+ viewWrapper.beginViewUpdate();
+ viewWrapper.specialViewThreadsWithUnread = true;
+ viewWrapper.endViewUpdate();
+ viewWrapper.close();
+
+ viewWrapper.open(folder);
+ assert_true(
+ viewWrapper.specialViewThreadsWithUnread,
+ "We should be in threads-with-unread special view mode."
+ );
+});
+
+add_task(async function test_real_folder_mark_read_on_exit() {
+ // set a pref so that the local folders account will think we should
+ // mark messages read when leaving the folder.
+ Services.prefs.setBoolPref("mailnews.mark_message_read.none", true);
+
+ let viewWrapper = make_view_wrapper();
+ let folder = await messageInjection.makeEmptyFolder();
+ viewWrapper.open(folder);
+
+ // add some unread messages.
+ let [setOne] = await messageInjection.makeNewSetsInFolders([folder], [{}]);
+ setOne.setRead(false);
+ // verify that we have unread messages.
+ assert_equals(
+ folder.getNumUnread(false),
+ setOne.synMessages.length,
+ "all messages should have been added as unread"
+ );
+ viewWrapper.close(false);
+ // verify that closing the view does the expected marking of the messages
+ // as read.
+ assert_equals(
+ folder.getNumUnread(false),
+ 0,
+ "messages should have been marked read on view close"
+ );
+ Services.prefs.clearUserPref("mailnews.mark_message_read.none");
+});
diff --git a/comm/mail/base/test/unit/test_viewWrapper_virtualFolder.js b/comm/mail/base/test/unit/test_viewWrapper_virtualFolder.js
new file mode 100644
index 0000000000..f33124b9d4
--- /dev/null
+++ b/comm/mail/base/test/unit/test_viewWrapper_virtualFolder.js
@@ -0,0 +1,552 @@
+/* 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/. */
+
+/**
+ * Test DBViewWrapper against virtual folders.
+ *
+ * Things we do not test and our rationalizations:
+ * - threading stuff. This is not the view wrapper's problem. That is the db
+ * view's problem! (We test it in the real folder to make sure we are telling
+ * it to do things correctly.)
+ * - view flags. Again, it's a db view issue once we're sure we set the bits.
+ * - special view with threads. same deal.
+ *
+ * We could test all these things, but my patch is way behind schedule...
+ */
+
+/* import-globals-from resources/viewWrapperTestUtils.js */
+load("resources/viewWrapperTestUtils.js");
+initViewWrapperTestUtils({ mode: "local" });
+
+// -- single-folder backed virtual folder
+
+/**
+ * Make sure we open a virtual folder backed by a single underlying folder
+ * correctly; no constraints.
+ */
+add_task(async function test_virtual_folder_single_load_no_pred() {
+ let viewWrapper = make_view_wrapper();
+
+ let [[folderOne], setOne] = await messageInjection.makeFoldersWithSets(1, [
+ {},
+ ]);
+
+ let virtFolder = messageInjection.makeVirtualFolder([folderOne], {});
+ await view_open(viewWrapper, virtFolder);
+
+ Assert.ok(viewWrapper.isVirtual);
+
+ assert_equals(
+ gMockViewWrapperListener.allMessagesLoadedEventCount,
+ 1,
+ "Should only have received a single all messages loaded notification!"
+ );
+
+ verify_messages_in_view(setOne, viewWrapper);
+ virtFolder.parent.propagateDelete(virtFolder, true);
+});
+
+/**
+ * Make sure we open a virtual folder backed by a single underlying folder
+ * correctly; one constraint.
+ */
+add_task(async function test_virtual_folder_single_load_simple_pred() {
+ let viewWrapper = make_view_wrapper();
+
+ let [[folderOne], oneSubjFoo] = await messageInjection.makeFoldersWithSets(
+ 1,
+ [{ subject: "foo" }, {}]
+ );
+
+ let virtFolder = messageInjection.makeVirtualFolder([folderOne], {
+ subject: "foo",
+ });
+ await view_open(viewWrapper, virtFolder);
+
+ verify_messages_in_view(oneSubjFoo, viewWrapper);
+ virtFolder.parent.propagateDelete(virtFolder, true);
+});
+
+/**
+ * Make sure we open a virtual folder backed by a single underlying folder
+ * correctly; two constraints ANDed together.
+ */
+add_task(async function test_virtual_folder_single_load_complex_pred() {
+ let viewWrapper = make_view_wrapper();
+
+ let whoBar = make_person_with_word_in_name("bar");
+
+ let [[folderOne], , , oneBoth] = await messageInjection.makeFoldersWithSets(
+ 1,
+ [{ subject: "foo" }, { from: whoBar }, { subject: "foo", from: whoBar }, {}]
+ );
+
+ let virtFolder = messageInjection.makeVirtualFolder(
+ [folderOne],
+ { subject: "foo", from: "bar" },
+ /* and? */ true
+ );
+ await view_open(viewWrapper, virtFolder);
+
+ verify_messages_in_view(oneBoth, viewWrapper);
+ virtFolder.parent.propagateDelete(virtFolder, true);
+});
+
+/**
+ * Open a single-backed virtual folder, verify, open another single-backed
+ * virtual folder, verify. We are testing our ability to change folders
+ * without exploding.
+ */
+add_task(async function test_virtual_folder_single_load_after_load() {
+ let viewWrapper = make_view_wrapper();
+
+ let [[folderOne], oneSubjFoo] = await messageInjection.makeFoldersWithSets(
+ 1,
+ [{ subject: "foo" }, {}]
+ );
+ let virtOne = messageInjection.makeVirtualFolder([folderOne], {
+ subject: "foo",
+ });
+ await view_open(viewWrapper, virtOne);
+ verify_messages_in_view([oneSubjFoo], viewWrapper);
+
+ // use "bar" instead of "foo" to make sure constraints are properly changing
+ let [[folderTwo], twoSubjBar] = await messageInjection.makeFoldersWithSets(
+ 1,
+ [{ subject: "bar" }, {}]
+ );
+ let virtTwo = messageInjection.makeVirtualFolder([folderTwo], {
+ subject: "bar",
+ });
+ await view_open(viewWrapper, virtTwo);
+ verify_messages_in_view([twoSubjBar], viewWrapper);
+ virtOne.parent.propagateDelete(virtOne, true);
+ virtTwo.parent.propagateDelete(virtTwo, true);
+});
+
+// -- multi-folder backed virtual folder
+
+/**
+ * Make sure we open a virtual folder backed by multiple underlying folders
+ * correctly; no constraints.
+ */
+add_task(async function test_virtual_folder_multi_load_no_pred() {
+ let viewWrapper = make_view_wrapper();
+
+ let [[folderOne], setOne] = await messageInjection.makeFoldersWithSets(1, [
+ {},
+ ]);
+ let [[folderTwo], setTwo] = await messageInjection.makeFoldersWithSets(1, [
+ {},
+ ]);
+
+ let virtFolder = messageInjection.makeVirtualFolder(
+ [folderOne, folderTwo],
+ {}
+ );
+ await view_open(viewWrapper, virtFolder);
+
+ verify_messages_in_view([setOne, setTwo], viewWrapper);
+ virtFolder.parent.propagateDelete(virtFolder, true);
+});
+
+/**
+ * Make sure the sort order of a virtual folder backed by multiple underlying
+ * folders is persistent.
+ */
+add_task(async function test_virtual_folder_multi_sortorder_persistence() {
+ let viewWrapper = make_view_wrapper();
+
+ let [[folderOne], setOne] = await messageInjection.makeFoldersWithSets(1, [
+ {},
+ ]);
+ let [[folderTwo], setTwo] = await messageInjection.makeFoldersWithSets(1, [
+ {},
+ ]);
+
+ let virtFolder = messageInjection.makeVirtualFolder(
+ [folderOne, folderTwo],
+ {}
+ );
+ await view_open(viewWrapper, virtFolder);
+
+ verify_messages_in_view([setOne, setTwo], viewWrapper);
+ viewWrapper.showThreaded = true;
+ viewWrapper.sort(
+ Ci.nsMsgViewSortType.bySubject,
+ Ci.nsMsgViewSortOrder.ascending
+ );
+
+ viewWrapper.close();
+ await view_open(viewWrapper, virtFolder);
+ assert_equals(
+ viewWrapper.primarySortType,
+ Ci.nsMsgViewSortType.bySubject,
+ "should have remembered sort type."
+ );
+ assert_equals(
+ viewWrapper.primarySortOrder,
+ Ci.nsMsgViewSortOrder.ascending,
+ "should have remembered sort order."
+ );
+ virtFolder.parent.propagateDelete(virtFolder, true);
+});
+
+/**
+ * Make sure we open a virtual folder backed by multiple underlying folders
+ * correctly; one constraint.
+ */
+add_task(async function test_virtual_folder_multi_load_simple_pred() {
+ let viewWrapper = make_view_wrapper();
+
+ let [[folderOne], oneSubjFoo] = await messageInjection.makeFoldersWithSets(
+ 1,
+ [{ subject: "foo" }, {}]
+ );
+ let [[folderTwo], twoSubjFoo] = await messageInjection.makeFoldersWithSets(
+ 1,
+ [{ subject: "foo" }, {}]
+ );
+
+ let virtFolder = messageInjection.makeVirtualFolder([folderOne, folderTwo], {
+ subject: "foo",
+ });
+ await view_open(viewWrapper, virtFolder);
+
+ verify_messages_in_view([oneSubjFoo, twoSubjFoo], viewWrapper);
+ virtFolder.parent.propagateDelete(virtFolder, true);
+});
+
+/**
+ * Make sure we open a virtual folder backed by multiple underlying folders
+ * correctly; two constraints ANDed together.
+ */
+add_task(async function test_virtual_folder_multi_load_complex_pred() {
+ let viewWrapper = make_view_wrapper();
+
+ let whoBar = make_person_with_word_in_name("bar");
+
+ let [[folderOne], , , oneBoth] = await messageInjection.makeFoldersWithSets(
+ 1,
+ [{ subject: "foo" }, { from: whoBar }, { subject: "foo", from: whoBar }, {}]
+ );
+ let [[folderTwo], , , twoBoth] = await messageInjection.makeFoldersWithSets(
+ 1,
+ [{ subject: "foo" }, { from: whoBar }, { subject: "foo", from: whoBar }, {}]
+ );
+
+ let virtFolder = messageInjection.makeVirtualFolder(
+ [folderOne, folderTwo],
+ { subject: "foo", from: "bar" },
+ /* and? */ true
+ );
+ await view_open(viewWrapper, virtFolder);
+
+ verify_messages_in_view([oneBoth, twoBoth], viewWrapper);
+ virtFolder.parent.propagateDelete(virtFolder, true);
+});
+
+add_task(
+ async function test_virtual_folder_multi_load_alotta_folders_no_pred() {
+ let viewWrapper = make_view_wrapper();
+
+ const folderCount = 4;
+ const messageCount = 64;
+
+ let [folders, setOne] = await messageInjection.makeFoldersWithSets(
+ folderCount,
+ [{ count: messageCount }]
+ );
+
+ let virtFolder = messageInjection.makeVirtualFolder(folders, {});
+ await view_open(viewWrapper, virtFolder);
+
+ verify_messages_in_view([setOne], viewWrapper);
+ virtFolder.parent.propagateDelete(virtFolder, true);
+ }
+);
+
+add_task(
+ async function test_virtual_folder_multi_load_alotta_folders_simple_pred() {
+ let viewWrapper = make_view_wrapper();
+
+ const folderCount = 16;
+ const messageCount = 256;
+
+ let [folders, setOne] = await messageInjection.makeFoldersWithSets(
+ folderCount,
+ [{ subject: "foo", count: messageCount }]
+ );
+
+ let virtFolder = messageInjection.makeVirtualFolder(folders, {
+ subject: "foo",
+ });
+ await view_open(viewWrapper, virtFolder);
+
+ verify_messages_in_view([setOne], viewWrapper);
+ virtFolder.parent.propagateDelete(virtFolder, true);
+ }
+);
+
+/**
+ * Make sure that opening a virtual folder backed by multiple real folders, then
+ * opening another virtual folder of the same variety works without explosions.
+ */
+add_task(async function test_virtual_folder_multi_load_after_load() {
+ let viewWrapper = make_view_wrapper();
+
+ let [foldersOne, oneSubjFoo] = await messageInjection.makeFoldersWithSets(2, [
+ { subject: "foo" },
+ {},
+ ]);
+ let virtOne = messageInjection.makeVirtualFolder(foldersOne, {
+ subject: "foo",
+ });
+ await view_open(viewWrapper, virtOne);
+ verify_messages_in_view([oneSubjFoo], viewWrapper);
+
+ // use "bar" instead of "foo" to make sure constraints are properly changing
+ let [foldersTwo, twoSubjBar] = await messageInjection.makeFoldersWithSets(3, [
+ { subject: "bar" },
+ {},
+ ]);
+ let virtTwo = messageInjection.makeVirtualFolder(foldersTwo, {
+ subject: "bar",
+ });
+ await view_open(viewWrapper, virtTwo);
+ verify_messages_in_view([twoSubjBar], viewWrapper);
+
+ await view_open(viewWrapper, virtOne);
+ verify_messages_in_view([oneSubjFoo], viewWrapper);
+ virtOne.parent.propagateDelete(virtOne, true);
+ virtTwo.parent.propagateDelete(virtTwo, true);
+});
+
+// -- mixture of single-backed and multi-backed
+
+/**
+ * Make sure that opening a virtual folder backed by a single real folder, then
+ * a multi-backed one, then the single-backed one again doesn't explode.
+ *
+ * This is just test_virtual_folder_multi_load_after_load with foldersOne told
+ * to create just a single folder.
+ */
+add_task(async function test_virtual_folder_combo_load_after_load() {
+ let viewWrapper = make_view_wrapper();
+
+ let [foldersOne, oneSubjFoo] = await messageInjection.makeFoldersWithSets(1, [
+ { subject: "foo" },
+ {},
+ ]);
+ let virtOne = messageInjection.makeVirtualFolder(foldersOne, {
+ subject: "foo",
+ });
+ await view_open(viewWrapper, virtOne);
+ verify_messages_in_view([oneSubjFoo], viewWrapper);
+
+ // use "bar" instead of "foo" to make sure constraints are properly changing
+ let [foldersTwo, twoSubjBar] = await messageInjection.makeFoldersWithSets(3, [
+ { subject: "bar" },
+ {},
+ ]);
+ let virtTwo = messageInjection.makeVirtualFolder(foldersTwo, {
+ subject: "bar",
+ });
+ await view_open(viewWrapper, virtTwo);
+ verify_messages_in_view([twoSubjBar], viewWrapper);
+
+ await view_open(viewWrapper, virtOne);
+ verify_messages_in_view([oneSubjFoo], viewWrapper);
+ virtOne.parent.propagateDelete(virtOne, true);
+ virtTwo.parent.propagateDelete(virtTwo, true);
+});
+
+// -- ignore things we should ignore
+
+/**
+ * Make sure that if a server is listed in a virtual folder's search Uris that
+ * it does not get into our list of _underlyingFolders.
+ */
+add_task(async function test_virtual_folder_filters_out_servers() {
+ let viewWrapper = make_view_wrapper();
+
+ let [folders] = await messageInjection.makeFoldersWithSets(2, []);
+ folders.push(folders[0].rootFolder);
+ let virtFolder = messageInjection.makeVirtualFolder(folders, {});
+ await view_open(viewWrapper, virtFolder);
+
+ assert_equals(
+ viewWrapper._underlyingFolders.length,
+ 2,
+ "Server folder should have been filtered out."
+ );
+ virtFolder.parent.propagateDelete(virtFolder, true);
+});
+
+// -- rare/edge cases!
+
+/**
+ * Verify that if one of the folders backing our virtual folder is deleted that
+ * we do not explode. Then verify that if we remove the rest of them that the
+ * view wrapper closes itself.
+ */
+add_task(async function test_virtual_folder_underlying_folder_deleted() {
+ let viewWrapper = make_view_wrapper();
+
+ let [[folderOne]] = await messageInjection.makeFoldersWithSets(1, [
+ { subject: "foo" },
+ {},
+ ]);
+ let [[folderTwo], twoSubjFoo] = await messageInjection.makeFoldersWithSets(
+ 1,
+ [{ subject: "foo" }, {}]
+ );
+
+ let virtFolder = messageInjection.makeVirtualFolder([folderOne, folderTwo], {
+ subject: "foo",
+ });
+ await view_open(viewWrapper, virtFolder);
+
+ // this triggers the search (under the view's hood), so it's async
+ await delete_folder(folderOne, viewWrapper);
+
+ // only messages from the surviving folder should be present
+ verify_messages_in_view([twoSubjFoo], viewWrapper);
+
+ // this one is not async though, because we are expecting to close the wrapper
+ // and ignore the view entirely, no resolving action.
+ delete_folder(folderTwo);
+
+ // now the view wrapper should have closed itself.
+ Assert.equal(null, viewWrapper.displayedFolder);
+ // This fails because virtFolder.parent is null, not sure why
+ // virtFolder.parent.propagateDelete(virtFolder, true);
+});
+
+/* ===== Virtual Folder, Mail Views ===== */
+
+/*
+ * We do not need to test all of the mail view permutations, realFolder
+ * already did that. We just need to make sure it works at all.
+ */
+
+add_task(
+ async function test_virtual_folder_mail_views_unread_with_one_folder() {
+ let viewWrapper = make_view_wrapper();
+
+ let [folders, fooOne, fooTwo] = await messageInjection.makeFoldersWithSets(
+ 1,
+ [{ subject: "foo 1" }, { subject: "foo 2" }, {}, {}]
+ );
+ let virtFolder = messageInjection.makeVirtualFolder(folders, {
+ subject: "foo",
+ });
+
+ // everything is unread to start with!
+ await view_open(viewWrapper, virtFolder);
+ await view_set_mail_view(viewWrapper, MailViewConstants.kViewItemUnread);
+ verify_messages_in_view([fooOne, fooTwo], viewWrapper);
+
+ // add some more things (unread!), make sure they appear.
+ let [fooThree] = await messageInjection.makeNewSetsInFolders(folders, [
+ { subject: "foo 3" },
+ {},
+ ]);
+ verify_messages_in_view([fooOne, fooTwo, fooThree], viewWrapper);
+
+ // make some things read, make sure they disappear. (after a refresh)
+ fooTwo.setRead(true);
+ await view_refresh(viewWrapper);
+ verify_messages_in_view([fooOne, fooThree], viewWrapper);
+
+ // make those things un-read again.
+ fooTwo.setRead(false);
+ // I thought this was a quick search limitation, but XFVF needs it to, at
+ // least for the unread case.
+ await view_refresh(viewWrapper);
+ verify_messages_in_view([fooOne, fooTwo, fooThree], viewWrapper);
+ virtFolder.parent.propagateDelete(virtFolder, true);
+ }
+);
+
+// -- mail views
+
+add_task(
+ async function test_virtual_folder_mail_views_unread_with_four_folders() {
+ let viewWrapper = make_view_wrapper();
+
+ let [folders, fooOne, fooTwo] = await messageInjection.makeFoldersWithSets(
+ 4,
+ [{ subject: "foo 1" }, { subject: "foo 2" }, {}, {}]
+ );
+ let virtFolder = messageInjection.makeVirtualFolder(folders, {
+ subject: "foo",
+ });
+
+ // everything is unread to start with!
+ await view_open(viewWrapper, virtFolder);
+ await view_set_mail_view(viewWrapper, MailViewConstants.kViewItemUnread);
+ verify_messages_in_view([fooOne, fooTwo], viewWrapper);
+
+ // add some more things (unread!), make sure they appear.
+ let [fooThree] = await messageInjection.makeNewSetsInFolders(folders, [
+ { subject: "foo 3" },
+ {},
+ ]);
+ verify_messages_in_view([fooOne, fooTwo, fooThree], viewWrapper);
+
+ // make some things read, make sure they disappear. (after a refresh)
+ fooTwo.setRead(true);
+ await view_refresh(viewWrapper);
+ verify_messages_in_view([fooOne, fooThree], viewWrapper);
+
+ // make those things un-read again.
+ fooTwo.setRead(false);
+ // I thought this was a quick search limitation, but XFVF needs it to, at
+ // least for the unread case.
+ await view_refresh(viewWrapper);
+ verify_messages_in_view([fooOne, fooTwo, fooThree], viewWrapper);
+ virtFolder.parent.propagateDelete(virtFolder, true);
+ }
+);
+
+// This tests that clearing the new messages in a folder also clears the
+// new flag on saved search folders based on the real folder. This could be a
+// core view test, or a mozmill test, but I think the view wrapper stuff
+// is involved in some of the issues here, so this is a compromise.
+add_task(async function test_virtual_folder_mail_new_handling() {
+ let viewWrapper = make_view_wrapper();
+
+ let [folders] = await messageInjection.makeFoldersWithSets(1, [
+ { subject: "foo 1" },
+ { subject: "foo 2" },
+ ]);
+ let folder = folders[0];
+ let virtFolder = messageInjection.makeVirtualFolder(folders, {
+ subject: "foo",
+ });
+
+ await view_open(viewWrapper, folder);
+
+ await messageInjection.makeNewSetsInFolders(folders, [
+ { subject: "foo 3" },
+ {},
+ ]);
+
+ if (!virtFolder.hasNewMessages) {
+ do_throw("saved search should have new messages!");
+ }
+
+ if (!folder.hasNewMessages) {
+ do_throw("folder should have new messages!");
+ }
+
+ viewWrapper.close();
+ folder.msgDatabase = null;
+ folder.clearNewMessages();
+ if (virtFolder.hasNewMessages) {
+ do_throw("saved search should not have new messages!");
+ }
+ virtFolder.parent.propagateDelete(virtFolder, true);
+});
diff --git a/comm/mail/base/test/unit/test_viewWrapper_virtualFolderCustomTerm.js b/comm/mail/base/test/unit/test_viewWrapper_virtualFolderCustomTerm.js
new file mode 100644
index 0000000000..29890db68b
--- /dev/null
+++ b/comm/mail/base/test/unit/test_viewWrapper_virtualFolderCustomTerm.js
@@ -0,0 +1,65 @@
+/* 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/. */
+
+/**
+ * Test DBViewWrapper against a virtual folder with a custom search term.
+ *
+ * This test uses an imap message to specifically test the issues from
+ * bug 549336. The code is derived from test_viewWrapper_virtualFolder.js
+ *
+ * Original author: Kent James
+ */
+
+/* import-globals-from resources/viewWrapperTestUtils.js */
+load("resources/viewWrapperTestUtils.js");
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+initViewWrapperTestUtils({ mode: "imap", offline: false });
+
+/**
+ * A custom search term, that just does Subject Contains
+ */
+var gCustomSearchTermSubject = {
+ id: "mailnews@mozilla.org#test",
+ name: "Test-mailbase Subject",
+ getEnabled(scope, op) {
+ return true;
+ },
+ getAvailable(scope, op) {
+ return true;
+ },
+ getAvailableOperators(scope) {
+ return [Ci.nsMsgSearchOp.Contains];
+ },
+ match(aMsgHdr, aSearchValue, aSearchOp) {
+ return aMsgHdr.subject.includes(aSearchValue);
+ },
+ needsBody: false,
+};
+
+MailServices.filters.addCustomTerm(gCustomSearchTermSubject);
+
+/**
+ * Make sure we open a virtual folder backed by a single underlying folder
+ * correctly, with a custom search term.
+ */
+add_task(async function test_virtual_folder_single_load_custom_pred() {
+ let viewWrapper = make_view_wrapper();
+
+ let [[folderOne], oneSubjFoo] = await messageInjection.makeFoldersWithSets(
+ 1,
+ [{ subject: "foo" }, {}]
+ );
+
+ let virtFolder = messageInjection.makeVirtualFolder(folderOne, {
+ custom: "foo",
+ });
+
+ await view_open(viewWrapper, virtFolder);
+
+ verify_messages_in_view(oneSubjFoo, viewWrapper);
+});
diff --git a/comm/mail/base/test/unit/xpcshell.ini b/comm/mail/base/test/unit/xpcshell.ini
new file mode 100644
index 0000000000..ad48cee56f
--- /dev/null
+++ b/comm/mail/base/test/unit/xpcshell.ini
@@ -0,0 +1,25 @@
+[DEFAULT]
+head = head_mailbase.js
+dupe-manifest =
+support-files = distribution.ini resources/*
+
+[test_alertHook.js]
+[test_attachmentChecker.js]
+[test_devtools_url.js]
+[test_emptyTrash_dbViewWrapper.js]
+run-sequentially = Avoid bustage.
+[test_viewWrapper_imapFolder.js]
+run-sequentially = Avoid bustage.
+[test_viewWrapper_logic.js]
+[test_viewWrapper_realFolder.js]
+skip-if = os == "mac" && !debug
+reason = osx shippable perma failures
+[test_viewWrapper_virtualFolder.js]
+[test_viewWrapper_virtualFolderCustomTerm.js]
+run-sequentially = Avoid bustage.
+[test_oauth_migration.js]
+[test_mailGlue_distribution.js]
+skip-if = os == 'win' && msix # MSIX has a distribution.ini and it's unwritable. Tests fail.
+[test_treeSelection.js]
+
+[include:xpcshell_maildir.ini]
diff --git a/comm/mail/base/test/unit/xpcshell_maildir.ini b/comm/mail/base/test/unit/xpcshell_maildir.ini
new file mode 100644
index 0000000000..511295fcbe
--- /dev/null
+++ b/comm/mail/base/test/unit/xpcshell_maildir.ini
@@ -0,0 +1,6 @@
+[DEFAULT]
+head = head_mailbase_maildir.js
+dupe-manifest =
+run-sequentially =
+
+[test_viewWrapper_virtualFolder.js]
diff --git a/comm/mail/base/test/webextensions/.eslintrc.js b/comm/mail/base/test/webextensions/.eslintrc.js
new file mode 100644
index 0000000000..12effd2e27
--- /dev/null
+++ b/comm/mail/base/test/webextensions/.eslintrc.js
@@ -0,0 +1,13 @@
+"use strict";
+
+module.exports = {
+ extends: ["plugin:mozilla/browser-test"],
+
+ env: {
+ webextensions: true,
+ },
+
+ rules: {
+ "func-names": "off",
+ },
+};
diff --git a/comm/mail/base/test/webextensions/browser.ini b/comm/mail/base/test/webextensions/browser.ini
new file mode 100644
index 0000000000..5d08a06f8f
--- /dev/null
+++ b/comm/mail/base/test/webextensions/browser.ini
@@ -0,0 +1,41 @@
+[DEFAULT]
+prefs =
+ mail.provider.suppress_dialog_on_startup=true
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+ toolkit.telemetry.testing.overrideProductsCheck=true
+subsuite = thunderbird
+support-files =
+ head.js
+ file_install_extensions.html
+ browser_webext_experiment.xpi
+ browser_webext_experiment_permissions.xpi
+ browser_webext_experiment_update1.xpi
+ browser_webext_experiment_update2.xpi
+ browser_webext_permissions.xpi
+ browser_webext_nopermissions.xpi
+ browser_webext_unsigned.xpi
+ browser_webext_update1.xpi
+ browser_webext_update2.xpi
+ browser_webext_update_icon1.xpi
+ browser_webext_update_icon2.xpi
+ browser_webext_update_perms1.xpi
+ browser_webext_update_perms2.xpi
+ browser_webext_update_origins1.xpi
+ browser_webext_update_origins2.xpi
+ browser_webext_update.json
+
+[browser_extension_install_experiment.js]
+[browser_extension_sideloading.js]
+[browser_extension_update_background.js]
+[browser_extension_update_background_noprompt.js]
+[browser_permissions_installTrigger.js]
+[browser_permissions_local_file.js]
+[browser_permissions_mozAddonManager.js]
+[browser_permissions_optional.js]
+[browser_permissions_pointerevent.js]
+[browser_permissions_unsigned.js]
+[browser_update_checkForUpdates.js]
+[browser_update_interactive_noprompt.js]
diff --git a/comm/mail/base/test/webextensions/browser_extension_install_experiment.js b/comm/mail/base/test/webextensions/browser_extension_install_experiment.js
new file mode 100644
index 0000000000..d21d8bebce
--- /dev/null
+++ b/comm/mail/base/test/webextensions/browser_extension_install_experiment.js
@@ -0,0 +1,82 @@
+"use strict";
+
+async function installFile(filename) {
+ const ChromeRegistry = Cc["@mozilla.org/chrome/chrome-registry;1"].getService(
+ Ci.nsIChromeRegistry
+ );
+ let chromeUrl = Services.io.newURI(gTestPath);
+ let fileUrl = ChromeRegistry.convertChromeURL(chromeUrl);
+ let file = fileUrl.QueryInterface(Ci.nsIFileURL).file;
+ file.leafName = filename;
+
+ let MockFilePicker = SpecialPowers.MockFilePicker;
+ MockFilePicker.init(window);
+ MockFilePicker.setFiles([file]);
+ MockFilePicker.afterOpenCallback = MockFilePicker.cleanup;
+
+ let { document } = await openAddonsMgr("addons://list/extension");
+
+ // Do the install...
+ await waitAboutAddonsViewLoaded(document);
+ let installButton = document.querySelector('[action="install-from-file"]');
+ installButton.click();
+}
+
+async function testExperimentPrompt(filename) {
+ let installPromise = new Promise(resolve => {
+ let listener = {
+ onDownloadCancelled() {
+ AddonManager.removeInstallListener(listener);
+ resolve(false);
+ },
+
+ onDownloadFailed() {
+ AddonManager.removeInstallListener(listener);
+ resolve(false);
+ },
+
+ onInstallCancelled() {
+ AddonManager.removeInstallListener(listener);
+ resolve(false);
+ },
+
+ onInstallEnded() {
+ AddonManager.removeInstallListener(listener);
+ resolve(true);
+ },
+
+ onInstallFailed() {
+ AddonManager.removeInstallListener(listener);
+ resolve(false);
+ },
+ };
+ AddonManager.addInstallListener(listener);
+ });
+
+ await installFile(filename);
+
+ let panel = await promisePopupNotificationShown("addon-webext-permissions");
+ await checkNotification(
+ panel,
+ isDefaultIcon,
+ [["webext-perms-description-experiment"]],
+ false,
+ true
+ );
+ panel.secondaryButton.click();
+
+ let result = await installPromise;
+ ok(!result, "Installation was cancelled");
+ let addon = await AddonManager.getAddonByID(
+ "experiment_test@tests.mozilla.org"
+ );
+ is(addon, null, "Extension is not installed");
+
+ let tabmail = document.getElementById("tabmail");
+ tabmail.closeTab(tabmail.currentTabInfo);
+}
+
+add_task(async () => {
+ await testExperimentPrompt("browser_webext_experiment.xpi");
+ await testExperimentPrompt("browser_webext_experiment_permissions.xpi");
+});
diff --git a/comm/mail/base/test/webextensions/browser_extension_sideloading.js b/comm/mail/base/test/webextensions/browser_extension_sideloading.js
new file mode 100644
index 0000000000..eb0754a922
--- /dev/null
+++ b/comm/mail/base/test/webextensions/browser_extension_sideloading.js
@@ -0,0 +1,352 @@
+/* eslint-disable mozilla/no-arbitrary-setTimeout */
+const { AddonManagerPrivate } = ChromeUtils.importESModule(
+ "resource://gre/modules/AddonManager.sys.mjs"
+);
+
+var { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+
+AddonTestUtils.initMochitest(this);
+
+AddonTestUtils.hookAMTelemetryEvents();
+
+const kSideloaded = true;
+
+async function createWebExtension(details) {
+ let options = {
+ manifest: {
+ applications: { gecko: { id: details.id } },
+
+ name: details.name,
+
+ permissions: details.permissions,
+ },
+ };
+
+ if (details.iconURL) {
+ options.manifest.icons = { 64: details.iconURL };
+ }
+
+ let xpi = AddonTestUtils.createTempWebExtensionFile(options);
+
+ await AddonTestUtils.manuallyInstall(xpi);
+}
+
+function promiseEvent(eventEmitter, event) {
+ return new Promise(resolve => {
+ eventEmitter.once(event, resolve);
+ });
+}
+
+function getAddonElement(managerWindow, addonId) {
+ return BrowserTestUtils.waitForCondition(
+ () =>
+ managerWindow.document.querySelector(`addon-card[addon-id="${addonId}"]`),
+ `Found entry for sideload extension addon "${addonId}" in HTML about:addons`
+ );
+}
+
+function assertSideloadedAddonElementState(addonElement, pressed) {
+ const enableBtn = addonElement.querySelector('[action="toggle-disabled"]');
+ is(
+ enableBtn.pressed,
+ pressed,
+ `The enable button is ${!pressed ? " not " : ""} pressed`
+ );
+ is(enableBtn.localName, "moz-toggle", "The enable button is a toggle");
+}
+
+function clickEnableExtension(addonElement) {
+ addonElement.querySelector('[action="toggle-disabled"]').click();
+}
+
+add_task(async function test_sideloading() {
+ const DEFAULT_ICON_URL =
+ "chrome://mozapps/skin/extensions/extensionGeneric.svg";
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["xpinstall.signatures.required", false],
+ ["extensions.autoDisableScopes", 15],
+ ["extensions.ui.ignoreUnsigned", true],
+ ["extensions.allowPrivateBrowsingByDefault", false],
+ ],
+ });
+
+ const ID1 = "addon1@tests.mozilla.org";
+ await createWebExtension({
+ id: ID1,
+ name: "Test 1",
+ userDisabled: true,
+ permissions: ["accountsRead", "https://*/*"],
+ iconURL: "foo-icon.png",
+ });
+
+ const ID2 = "addon2@tests.mozilla.org";
+ await createWebExtension({
+ id: ID2,
+ name: "Test 2",
+ permissions: ["<all_urls>"],
+ });
+
+ const ID3 = "addon3@tests.mozilla.org";
+ await createWebExtension({
+ id: ID3,
+ name: "Test 3",
+ permissions: ["<all_urls>"],
+ });
+
+ testCleanup = async function () {
+ // clear out ExtensionsUI state about sideloaded extensions so
+ // subsequent tests don't get confused.
+ ExtensionsUI.sideloaded.clear();
+ ExtensionsUI.emit("change");
+ };
+
+ let changePromise = new Promise(resolve => {
+ ExtensionsUI.on("change", function listener() {
+ ExtensionsUI.off("change", listener);
+ resolve();
+ });
+ });
+ ExtensionsUI._checkForSideloaded();
+ await changePromise;
+
+ // Check for the addons badge on the hamburger menu
+ let menuButton = document.getElementById("button-appmenu");
+ is(
+ menuButton.getAttribute("badge-status"),
+ "addon-alert",
+ "Should have addon alert badge"
+ );
+
+ // Find the menu entries for sideloaded extensions
+ await gCUITestUtils.openMainMenu();
+
+ let addons = PanelUI.addonNotificationContainer;
+ is(
+ addons.children.length,
+ 3,
+ "Have 3 menu entries for sideloaded extensions"
+ );
+
+ info(
+ "Test disabling sideloaded addon 1 using the permission prompt secondary button"
+ );
+
+ // Click the first sideloaded extension
+ let popupPromise = promisePopupNotificationShown("addon-webext-permissions");
+ addons.children[0].click();
+
+ // The click should hide the main menu. This is currently synchronous.
+ ok(PanelUI.panel.state != "open", "Main menu is closed or closing.");
+
+ let panel = await popupPromise;
+
+ // Check the contents of the notification, then choose "Cancel"
+ await checkNotification(
+ panel,
+ /\/foo-icon\.png$/,
+ [
+ ["webext-perms-host-description-all-urls"],
+ ["webext-perms-description-accountsRead"],
+ ],
+ kSideloaded
+ );
+
+ panel.secondaryButton.click();
+
+ let [addon1, addon2, addon3] = await AddonManager.getAddonsByIDs([
+ ID1,
+ ID2,
+ ID3,
+ ]);
+ ok(addon1.seen, "Addon should be marked as seen");
+ is(addon1.userDisabled, true, "Addon 1 should still be disabled");
+ is(addon2.userDisabled, true, "Addon 2 should still be disabled");
+ is(addon3.userDisabled, true, "Addon 3 should still be disabled");
+
+ // Should still have 2 entries in the hamburger menu
+ await gCUITestUtils.openMainMenu();
+
+ addons = PanelUI.addonNotificationContainer;
+ is(
+ addons.children.length,
+ 2,
+ "Have 2 menu entries for sideloaded extensions"
+ );
+
+ // Close the hamburger menu and go directly to the addons manager
+ await gCUITestUtils.hideMainMenu();
+
+ const VIEW = "addons://list/extension";
+ let win = await openAddonsMgr(VIEW);
+
+ await waitAboutAddonsViewLoaded(win.document);
+
+ // about:addons addon entry element.
+ const addonElement = await getAddonElement(win, ID2);
+
+ assertSideloadedAddonElementState(addonElement, false);
+
+ info("Test enabling sideloaded addon 2 from about:addons enable button");
+
+ // When clicking enable we should see the permissions notification
+ popupPromise = promisePopupNotificationShown("addon-webext-permissions");
+ clickEnableExtension(addonElement);
+ panel = await popupPromise;
+ await checkNotification(
+ panel,
+ DEFAULT_ICON_URL,
+ [["webext-perms-host-description-all-urls"]],
+ kSideloaded
+ );
+
+ // Setup async test for post install notification on addon 2
+ popupPromise = promisePopupNotificationShown("addon-installed");
+
+ // Accept the permissions
+ panel.button.click();
+ await promiseEvent(ExtensionsUI, "change");
+
+ addon2 = await AddonManager.getAddonByID(ID2);
+ is(addon2.userDisabled, false, "Addon 2 should be enabled");
+ assertSideloadedAddonElementState(addonElement, true);
+
+ // Test post install notification on addon 2.
+ panel = await popupPromise;
+ panel.button.click();
+
+ let tabmail = document.getElementById("tabmail");
+ tabmail.closeTab(tabmail.currentTabInfo);
+
+ // Should still have 1 entry in the hamburger menu
+ await gCUITestUtils.openMainMenu();
+
+ addons = PanelUI.addonNotificationContainer;
+ is(addons.children.length, 1, "Have 1 menu entry for sideloaded extensions");
+
+ PanelUI.hide();
+
+ // Open the Add-Ons Manager
+ win = await openAddonsMgr(`addons://detail/${encodeURIComponent(ID3)}`);
+
+ info("Test enabling sideloaded addon 3 from app menu");
+ // Trigger addon 3 install as triggered from the app menu, to be able to cover the
+ // post install notification that should be triggered when the permission
+ // dialog is accepted from that flow.
+ popupPromise = promisePopupNotificationShown("addon-webext-permissions");
+ ExtensionsUI.showSideloaded(tabmail, addon3);
+
+ panel = await popupPromise;
+ await checkNotification(
+ panel,
+ DEFAULT_ICON_URL,
+ [["webext-perms-host-description-all-urls"]],
+ kSideloaded
+ );
+
+ // Setup async test for post install notification on addon 3
+ popupPromise = promisePopupNotificationShown("addon-installed");
+
+ // Accept the permissions
+ panel.button.click();
+ await promiseEvent(ExtensionsUI, "change");
+
+ addon3 = await AddonManager.getAddonByID(ID3);
+ is(addon3.userDisabled, false, "Addon 3 should be enabled");
+
+ // Test post install notification on addon 3.
+ panel = await popupPromise;
+ panel.button.click();
+
+ isnot(
+ menuButton.getAttribute("badge-status"),
+ "addon-alert",
+ "Should no longer have addon alert badge"
+ );
+
+ await new Promise(resolve => setTimeout(resolve, 100));
+
+ for (let addon of [addon1, addon2, addon3]) {
+ await addon.uninstall();
+ }
+
+ tabmail.closeTab(tabmail.currentTabInfo);
+
+ // Assert that the expected AddonManager telemetry are being recorded.
+ const expectedExtra = { source: "app-profile", method: "sideload" };
+
+ const baseEvent = { object: "extension", extra: expectedExtra };
+ const createBaseEventAddon = n => ({
+ ...baseEvent,
+ value: `addon${n}@tests.mozilla.org`,
+ });
+ const getEventsForAddonId = (events, addonId) =>
+ events.filter(ev => ev.value === addonId);
+
+ const amEvents = AddonTestUtils.getAMTelemetryEvents();
+
+ // Test telemetry events for addon1 (1 permission and 1 origin).
+ info("Test telemetry events collected for addon1");
+
+ const baseEventAddon1 = createBaseEventAddon(1);
+ const collectedEventsAddon1 = getEventsForAddonId(
+ amEvents,
+ baseEventAddon1.value
+ );
+ const expectedEventsAddon1 = [
+ {
+ ...baseEventAddon1,
+ method: "sideload_prompt",
+ extra: { ...expectedExtra, num_strings: "2" },
+ },
+ { ...baseEventAddon1, method: "uninstall" },
+ ];
+
+ let i = 0;
+ for (let event of collectedEventsAddon1) {
+ Assert.deepEqual(
+ event,
+ expectedEventsAddon1[i++],
+ "Got the expected telemetry event"
+ );
+ }
+
+ is(
+ collectedEventsAddon1.length,
+ expectedEventsAddon1.length,
+ "Got the expected number of telemetry events for addon1"
+ );
+
+ const baseEventAddon2 = createBaseEventAddon(2);
+ const collectedEventsAddon2 = getEventsForAddonId(
+ amEvents,
+ baseEventAddon2.value
+ );
+ const expectedEventsAddon2 = [
+ {
+ ...baseEventAddon2,
+ method: "sideload_prompt",
+ extra: { ...expectedExtra, num_strings: "1" },
+ },
+ { ...baseEventAddon2, method: "enable" },
+ { ...baseEventAddon2, method: "uninstall" },
+ ];
+
+ i = 0;
+ for (let event of collectedEventsAddon2) {
+ Assert.deepEqual(
+ event,
+ expectedEventsAddon2[i++],
+ "Got the expected telemetry event"
+ );
+ }
+
+ is(
+ collectedEventsAddon2.length,
+ expectedEventsAddon2.length,
+ "Got the expected number of telemetry events for addon2"
+ );
+});
diff --git a/comm/mail/base/test/webextensions/browser_extension_update_background.js b/comm/mail/base/test/webextensions/browser_extension_update_background.js
new file mode 100644
index 0000000000..5b5909711a
--- /dev/null
+++ b/comm/mail/base/test/webextensions/browser_extension_update_background.js
@@ -0,0 +1,263 @@
+const { AddonManagerPrivate } = ChromeUtils.importESModule(
+ "resource://gre/modules/AddonManager.sys.mjs"
+);
+
+var { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+
+AddonTestUtils.initMochitest(this);
+AddonTestUtils.hookAMTelemetryEvents();
+
+const ID = "update2@tests.mozilla.org";
+const ID_ICON = "update_icon2@tests.mozilla.org";
+const ID_PERMS = "update_perms@tests.mozilla.org";
+const ID_EXPERIMENT = "experiment_update@test.mozilla.org";
+const FAKE_INSTALL_TELEMETRY_SOURCE = "fake-install-source";
+
+requestLongerTimeout(2);
+
+function promiseViewLoaded(tab, viewid) {
+ let win = tab.linkedBrowser.contentWindow;
+ if (
+ win.gViewController &&
+ !win.gViewController.isLoading &&
+ win.gViewController.currentViewId == viewid
+ ) {
+ return Promise.resolve();
+ }
+
+ return waitAboutAddonsViewLoaded(win.document);
+}
+
+function getBadgeStatus() {
+ let menuButton = document.getElementById("button-appmenu");
+ return menuButton.getAttribute("badge-status");
+}
+
+function promiseBadgeChange() {
+ return new Promise(resolve => {
+ let menuButton = document.getElementById("button-appmenu");
+ new MutationObserver((mutationsList, observer) => {
+ for (let mutation of mutationsList) {
+ if (mutation.attributeName == "badge-status") {
+ observer.disconnect();
+ resolve();
+ return;
+ }
+ }
+ }).observe(menuButton, {
+ attributes: true,
+ });
+ });
+}
+
+// Set some prefs that apply to all the tests in this file
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ // We don't have pre-pinned certificates for the local mochitest server
+ ["extensions.install.requireBuiltInCerts", false],
+ ["extensions.update.requireBuiltInCerts", false],
+ ],
+ });
+});
+
+// Helper function to test background updates.
+async function backgroundUpdateTest(url, id, checkIconFn) {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ // Turn on background updates
+ ["extensions.update.enabled", true],
+
+ // Point updates to the local mochitest server
+ [
+ "extensions.update.background.url",
+ `${BASE}/browser_webext_update.json`,
+ ],
+ ],
+ });
+
+ // Install version 1.0 of the test extension
+ let addon = await promiseInstallAddon(url, {
+ source: FAKE_INSTALL_TELEMETRY_SOURCE,
+ });
+ let addonId = addon.id;
+
+ ok(addon, "Addon was installed");
+ is(getBadgeStatus(), "", "Should not start out with an addon alert badge");
+
+ // Trigger an update check and wait for the update for this addon
+ // to be downloaded.
+ let updatePromise = promiseInstallEvent(addon, "onDownloadEnded");
+ let badgePromise = promiseBadgeChange();
+
+ AddonManagerPrivate.backgroundUpdateCheck();
+ await Promise.all([updatePromise, badgePromise]);
+
+ is(getBadgeStatus(), "addon-alert", "Should have addon alert badge");
+
+ // Find the menu entry for the update
+ await gCUITestUtils.openMainMenu();
+
+ let addons = PanelUI.addonNotificationContainer;
+ is(addons.children.length, 1, "Have a menu entry for the update");
+
+ // Click the menu item
+ let popupPromise = promisePopupNotificationShown("addon-webext-permissions");
+ addons.children[0].click();
+
+ // The click should hide the main menu. This is currently synchronous.
+ ok(PanelUI.panel.state != "open", "Main menu is closed or closing.");
+
+ // Wait for the permission prompt, check the contents
+ let panel = await popupPromise;
+ checkIconFn(panel.getAttribute("icon"));
+
+ // The original extension has 1 promptable permission and the new one
+ // has 2 (history and <all_urls>) plus 1 non-promptable permission (cookies).
+ // So we should only see the 1 new promptable permission in the notification.
+ let singlePermissionEl = document.getElementById(
+ "addon-webext-perm-single-entry"
+ );
+ ok(!singlePermissionEl.hidden, "Single permission entry is not hidden");
+ ok(singlePermissionEl.textContent, "Single permission entry text is set");
+
+ // Cancel the update.
+ panel.secondaryButton.click();
+
+ addon = await AddonManager.getAddonByID(id);
+ is(addon.version, "1.0", "Should still be running the old version");
+
+ // Alert badge and hamburger menu items should be gone
+ is(getBadgeStatus(), "", "Addon alert badge should be gone");
+
+ await gCUITestUtils.openMainMenu();
+ addons = PanelUI.addonNotificationContainer;
+ is(addons.children.length, 0, "Update menu entries should be gone");
+ await gCUITestUtils.hideMainMenu();
+
+ // Re-check for an update
+ updatePromise = promiseInstallEvent(addon, "onDownloadEnded");
+ badgePromise = promiseBadgeChange();
+ await AddonManagerPrivate.backgroundUpdateCheck();
+ await Promise.all([updatePromise, badgePromise]);
+
+ is(getBadgeStatus(), "addon-alert", "Should have addon alert badge");
+
+ // Find the menu entry for the update
+ await gCUITestUtils.openMainMenu();
+
+ addons = PanelUI.addonNotificationContainer;
+ is(addons.children.length, 1, "Have a menu entry for the update");
+
+ // Click the menu item
+ popupPromise = promisePopupNotificationShown("addon-webext-permissions");
+ addons.children[0].click();
+
+ // Wait for the permission prompt and accept it this time
+ updatePromise = waitForUpdate(addon);
+ panel = await popupPromise;
+ panel.button.click();
+
+ addon = await updatePromise;
+ is(addon.version, "2.0", "Should have upgraded to the new version");
+
+ is(getBadgeStatus(), "", "Addon alert badge should be gone");
+
+ await addon.uninstall();
+ await SpecialPowers.popPrefEnv();
+
+ // Test that the expected telemetry events have been recorded (and that they include the
+ // permission_prompt event).
+ const amEvents = AddonTestUtils.getAMTelemetryEvents();
+ const updateEvents = amEvents
+ .filter(evt => evt.method === "update")
+ .map(evt => {
+ delete evt.value;
+ return evt;
+ });
+
+ Assert.deepEqual(
+ updateEvents.map(evt => evt.extra && evt.extra.step),
+ [
+ // First update (cancelled).
+ "started",
+ "download_started",
+ "download_completed",
+ "permissions_prompt",
+ "cancelled",
+ // Second update (completed).
+ "started",
+ "download_started",
+ "download_completed",
+ "permissions_prompt",
+ "completed",
+ ],
+ "Got the steps from the collected telemetry events"
+ );
+
+ const method = "update";
+ const object = "extension";
+ const baseExtra = {
+ addon_id: addonId,
+ source: FAKE_INSTALL_TELEMETRY_SOURCE,
+ step: "permissions_prompt",
+ updated_from: "app",
+ };
+
+ // Expect the telemetry events to have num_strings set to 1, as only the origin permissions is going
+ // to be listed in the permission prompt.
+ Assert.deepEqual(
+ updateEvents.filter(
+ evt => evt.extra && evt.extra.step === "permissions_prompt"
+ ),
+ [
+ { method, object, extra: { ...baseExtra, num_strings: "1" } },
+ { method, object, extra: { ...baseExtra, num_strings: "1" } },
+ ],
+ "Got the expected permission_prompts events"
+ );
+}
+
+function checkDefaultIcon(icon) {
+ is(
+ icon,
+ "chrome://mozapps/skin/extensions/extensionGeneric.svg",
+ "Popup has the default extension icon"
+ );
+}
+
+add_task(() =>
+ backgroundUpdateTest(
+ `${BASE}/browser_webext_update1.xpi`,
+ ID,
+ checkDefaultIcon
+ )
+);
+
+function checkNonDefaultIcon(icon) {
+ // The icon should come from the extension, don't bother with the precise
+ // path, just make sure we've got a jar url pointing to the right path
+ // inside the jar.
+ ok(icon.startsWith("jar:file://"), "Icon is a jar url");
+ ok(icon.endsWith("/icon.png"), "Icon is icon.png inside a jar");
+}
+
+add_task(() =>
+ backgroundUpdateTest(
+ `${BASE}/browser_webext_update_icon1.xpi`,
+ ID_ICON,
+ checkNonDefaultIcon
+ )
+);
+
+// Check bug 1710359 did not introduce a loophole and a simple WebExtension being
+// upgraded to an Experiment prompts for the permission update.
+add_task(() =>
+ backgroundUpdateTest(
+ `${BASE}/browser_webext_experiment_update1.xpi`,
+ ID_EXPERIMENT,
+ checkDefaultIcon
+ )
+);
diff --git a/comm/mail/base/test/webextensions/browser_extension_update_background_noprompt.js b/comm/mail/base/test/webextensions/browser_extension_update_background_noprompt.js
new file mode 100644
index 0000000000..d0cb135368
--- /dev/null
+++ b/comm/mail/base/test/webextensions/browser_extension_update_background_noprompt.js
@@ -0,0 +1,116 @@
+const { AddonManagerPrivate } = ChromeUtils.importESModule(
+ "resource://gre/modules/AddonManager.sys.mjs"
+);
+
+var { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+
+AddonTestUtils.initMochitest(this);
+AddonTestUtils.hookAMTelemetryEvents();
+
+const ID_PERMS = "update_perms@tests.mozilla.org";
+const ID_ORIGINS = "update_origins@tests.mozilla.org";
+const ID_EXPERIMENT = "experiment_test@tests.mozilla.org";
+
+function getBadgeStatus() {
+ let menuButton = document.getElementById("button-appmenu");
+ return menuButton.getAttribute("badge-status");
+}
+
+// Set some prefs that apply to all the tests in this file
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ // We don't have pre-pinned certificates for the local mochitest server
+ ["extensions.install.requireBuiltInCerts", false],
+ ["extensions.update.requireBuiltInCerts", false],
+ // Don't require the extensions to be signed
+ ["xpinstall.signatures.required", false],
+ ],
+ });
+});
+
+// Helper function to test an upgrade that should not show a prompt
+async function testNoPrompt(origUrl, id) {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ // Turn on background updates
+ ["extensions.update.enabled", true],
+
+ // Point updates to the local mochitest server
+ [
+ "extensions.update.background.url",
+ `${BASE}/browser_webext_update.json`,
+ ],
+ ],
+ });
+
+ // Install version 1.0 of the test extension
+ let addon = await promiseInstallAddon(origUrl);
+
+ ok(addon, "Addon was installed");
+
+ let sawPopup = false;
+ PopupNotifications.panel.addEventListener(
+ "popupshown",
+ () => (sawPopup = true),
+ { once: true }
+ );
+
+ // Trigger an update check and wait for the update to be applied.
+ let updatePromise = waitForUpdate(addon);
+ AddonManagerPrivate.backgroundUpdateCheck();
+ await updatePromise;
+
+ // There should be no notifications about the update
+ is(getBadgeStatus(), "", "Should not have addon alert badge");
+
+ await gCUITestUtils.openMainMenu();
+ let addons = PanelUI.addonNotificationContainer;
+ is(addons.children.length, 0, "Have 0 updates in the PanelUI menu");
+ await gCUITestUtils.hideMainMenu();
+
+ ok(!sawPopup, "Should not have seen permissions notification");
+
+ addon = await AddonManager.getAddonByID(id);
+ is(addon.version, "2.0", "Update should have applied");
+
+ await addon.uninstall();
+ await SpecialPowers.popPrefEnv();
+
+ // Test that the expected telemetry events have been recorded (and that they do not
+ // include the permission_prompt event).
+ const amEvents = AddonTestUtils.getAMTelemetryEvents();
+ const updateEventsSteps = amEvents
+ .filter(evt => {
+ return evt.method === "update" && evt.extra && evt.extra.addon_id == id;
+ })
+ .map(evt => {
+ return evt.extra.step;
+ });
+
+ // Expect telemetry events related to a completed update with no permissions_prompt event.
+ Assert.deepEqual(
+ updateEventsSteps,
+ ["started", "download_started", "download_completed", "completed"],
+ "Got the steps from the collected telemetry events"
+ );
+}
+
+// Test that an update that adds new non-promptable permissions is just
+// applied without showing a notification dialog.
+add_task(() =>
+ testNoPrompt(`${BASE}/browser_webext_update_perms1.xpi`, ID_PERMS)
+);
+
+// Test that an update that narrows origin permissions is just applied without
+// showing a notification prompt
+add_task(() =>
+ testNoPrompt(`${BASE}/browser_webext_update_origins1.xpi`, ID_ORIGINS)
+);
+
+// Test that an Experiment is not prompting for additional permissions.
+add_task(() =>
+ testNoPrompt(`${BASE}/browser_webext_experiment.xpi`, ID_EXPERIMENT)
+);
diff --git a/comm/mail/base/test/webextensions/browser_permissions_installTrigger.js b/comm/mail/base/test/webextensions/browser_permissions_installTrigger.js
new file mode 100644
index 0000000000..37f8117ab3
--- /dev/null
+++ b/comm/mail/base/test/webextensions/browser_permissions_installTrigger.js
@@ -0,0 +1,27 @@
+"use strict";
+
+const INSTALL_PAGE = `${BASE}/file_install_extensions.html`;
+
+async function installTrigger(filename) {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["extensions.InstallTrigger.enabled", true],
+ ["extensions.InstallTriggerImpl.enabled", true],
+ // Relax the user input requirements while running this test.
+ ["xpinstall.userActivation.required", false],
+ ],
+ });
+ let gBrowser = document.getElementById("tabmail");
+ BrowserTestUtils.loadURIString(gBrowser.selectedBrowser, INSTALL_PAGE);
+ await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+
+ SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [`${BASE}/${filename}`],
+ async function (url) {
+ content.wrappedJSObject.installTrigger(url);
+ }
+ );
+}
+
+add_task(() => testInstallMethod(installTrigger));
diff --git a/comm/mail/base/test/webextensions/browser_permissions_local_file.js b/comm/mail/base/test/webextensions/browser_permissions_local_file.js
new file mode 100644
index 0000000000..03cf35226c
--- /dev/null
+++ b/comm/mail/base/test/webextensions/browser_permissions_local_file.js
@@ -0,0 +1,46 @@
+"use strict";
+
+async function installFile(filename) {
+ const ChromeRegistry = Cc["@mozilla.org/chrome/chrome-registry;1"].getService(
+ Ci.nsIChromeRegistry
+ );
+ let chromeUrl = Services.io.newURI(gTestPath);
+ let fileUrl = ChromeRegistry.convertChromeURL(chromeUrl);
+ let file = fileUrl.QueryInterface(Ci.nsIFileURL).file;
+ file.leafName = filename;
+
+ let MockFilePicker = SpecialPowers.MockFilePicker;
+ MockFilePicker.init(window);
+ MockFilePicker.setFiles([file]);
+ MockFilePicker.afterOpenCallback = MockFilePicker.cleanup;
+
+ let { document } = await openAddonsMgr("addons://list/extension");
+
+ // Do the install...
+ await waitAboutAddonsViewLoaded(document);
+ let installButton = document.querySelector('[action="install-from-file"]');
+ installButton.click();
+}
+
+add_task(async function test_install_extension_from_local_file() {
+ // Listen for the first installId so we can check it later.
+ let firstInstallId = null;
+ AddonManager.addInstallListener({
+ onNewInstall(install) {
+ firstInstallId = install.installId;
+ AddonManager.removeInstallListener(this);
+ },
+ });
+
+ // Install the add-ons.
+ await testInstallMethod(installFile);
+
+ // Check we got an installId.
+ ok(
+ firstInstallId != null && !isNaN(firstInstallId),
+ "There was an installId found"
+ );
+
+ let tabmail = document.getElementById("tabmail");
+ tabmail.closeTab(tabmail.currentTabInfo);
+});
diff --git a/comm/mail/base/test/webextensions/browser_permissions_mozAddonManager.js b/comm/mail/base/test/webextensions/browser_permissions_mozAddonManager.js
new file mode 100644
index 0000000000..4f1a064760
--- /dev/null
+++ b/comm/mail/base/test/webextensions/browser_permissions_mozAddonManager.js
@@ -0,0 +1,19 @@
+"use strict";
+
+const INSTALL_PAGE = `${BASE}/file_install_extensions.html`;
+
+async function installMozAM(filename) {
+ let browser = document.getElementById("tabmail").selectedBrowser;
+ BrowserTestUtils.loadURIString(browser, INSTALL_PAGE);
+ await BrowserTestUtils.browserLoaded(browser);
+
+ await SpecialPowers.spawn(
+ browser,
+ [`${BASE}/${filename}`],
+ async function (url) {
+ await content.wrappedJSObject.installMozAM(url);
+ }
+ );
+}
+
+add_task(() => testInstallMethod(installMozAM));
diff --git a/comm/mail/base/test/webextensions/browser_permissions_optional.js b/comm/mail/base/test/webextensions/browser_permissions_optional.js
new file mode 100644
index 0000000000..750658a8fd
--- /dev/null
+++ b/comm/mail/base/test/webextensions/browser_permissions_optional.js
@@ -0,0 +1,53 @@
+"use strict";
+add_task(async function test_request_permissions_without_prompt() {
+ async function pageScript() {
+ const NO_PROMPT_PERM = "activeTab";
+ window.addEventListener(
+ "keypress",
+ async () => {
+ let permGranted = await browser.permissions.request({
+ permissions: [NO_PROMPT_PERM],
+ });
+ browser.test.assertTrue(
+ permGranted,
+ `${NO_PROMPT_PERM} permission was granted.`
+ );
+ let perms = await browser.permissions.getAll();
+ browser.test.assertTrue(
+ perms.permissions.includes(NO_PROMPT_PERM),
+ `${NO_PROMPT_PERM} permission exists.`
+ );
+ browser.test.sendMessage("permsGranted");
+ },
+ { once: true }
+ );
+ browser.test.sendMessage("pageReady");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background() {
+ browser.test.sendMessage("ready", browser.runtime.getURL("page.html"));
+ },
+ files: {
+ "page.html": `<html><head><script src="page.js"></script></head></html>`,
+ "page.js": pageScript,
+ },
+ manifest: {
+ optional_permissions: ["activeTab"],
+ },
+ });
+ await extension.startup();
+
+ let url = await extension.awaitMessage("ready");
+
+ let tab = openContentTab(url, undefined, null);
+ await extension.awaitMessage("pageReady");
+ await new Promise(resolve => requestAnimationFrame(resolve));
+ await BrowserTestUtils.synthesizeMouseAtCenter(tab.browser, {}, tab.browser);
+ await BrowserTestUtils.synthesizeKey("a", {}, tab.browser);
+ await extension.awaitMessage("permsGranted");
+ await extension.unload();
+
+ let tabmail = document.getElementById("tabmail");
+ tabmail.closeTab(tab);
+});
diff --git a/comm/mail/base/test/webextensions/browser_permissions_pointerevent.js b/comm/mail/base/test/webextensions/browser_permissions_pointerevent.js
new file mode 100644
index 0000000000..7f273dc79c
--- /dev/null
+++ b/comm/mail/base/test/webextensions/browser_permissions_pointerevent.js
@@ -0,0 +1,65 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+add_task(async function test_pointerevent() {
+ async function contentScript() {
+ document.addEventListener("pointerdown", async e => {
+ browser.test.assertTrue(true, "Should receive pointerdown");
+ e.preventDefault();
+ });
+
+ document.addEventListener("mousedown", e => {
+ browser.test.assertTrue(true, "Should receive mousedown");
+ });
+
+ document.addEventListener("mouseup", e => {
+ browser.test.assertTrue(true, "Should receive mouseup");
+ });
+
+ document.addEventListener("pointerup", e => {
+ browser.test.assertTrue(true, "Should receive pointerup");
+ browser.test.sendMessage("done");
+ });
+ browser.test.sendMessage("pageReady");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background() {
+ browser.test.sendMessage("ready", browser.runtime.getURL("page.html"));
+ },
+ files: {
+ "page.html": `<html><head><script src="page.js"></script></head></html>`,
+ "page.js": contentScript,
+ },
+ });
+ await extension.startup();
+ await new Promise(resolve => {
+ SpecialPowers.pushPrefEnv(
+ { set: [["dom.w3c_pointer_events.enabled", true]] },
+ resolve
+ );
+ });
+ let url = await extension.awaitMessage("ready");
+ let tab = openContentTab(url, undefined, null);
+
+ await extension.awaitMessage("pageReady");
+ await new Promise(resolve => requestAnimationFrame(resolve));
+ tab.linkedBrowser.focus();
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "html",
+ { type: "mousedown", button: 0 },
+ tab.linkedBrowser
+ );
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "html",
+ { type: "mouseup", button: 0 },
+ tab.linkedBrowser
+ );
+ await extension.awaitMessage("done");
+
+ await extension.unload();
+
+ let tabmail = document.getElementById("tabmail");
+ tabmail.closeTab(tab);
+});
diff --git a/comm/mail/base/test/webextensions/browser_permissions_unsigned.js b/comm/mail/base/test/webextensions/browser_permissions_unsigned.js
new file mode 100644
index 0000000000..06f0b2aa14
--- /dev/null
+++ b/comm/mail/base/test/webextensions/browser_permissions_unsigned.js
@@ -0,0 +1,49 @@
+"use strict";
+
+const ID = "permissions@test.mozilla.org";
+const WARNING_ICON = "chrome://browser/skin/warning.svg";
+
+add_task(async function test_unsigned() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["extensions.InstallTrigger.enabled", true],
+ ["extensions.InstallTriggerImpl.enabled", true],
+ ["extensions.webapi.testing", true],
+ ["extensions.install.requireBuiltInCerts", false],
+ // Relax the user input requirements while running this test.
+ ["xpinstall.userActivation.required", false],
+ ],
+ });
+
+ let testURI = makeURI("https://example.com/");
+ PermissionTestUtils.add(testURI, "install", Services.perms.ALLOW_ACTION);
+ registerCleanupFunction(() => PermissionTestUtils.remove(testURI, "install"));
+
+ let tab = openContentTab("about:blank");
+ BrowserTestUtils.loadURIString(
+ tab.linkedBrowser,
+ `${BASE}/file_install_extensions.html`
+ );
+ await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+
+ SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [`${BASE}/browser_webext_unsigned.xpi`],
+ async function (url) {
+ content.wrappedJSObject.installTrigger(url);
+ }
+ );
+
+ let panel = await promisePopupNotificationShown("addon-webext-permissions");
+
+ // cancel the install
+ let promise = promiseInstallEvent({ id: ID }, "onInstallCancelled");
+ panel.secondaryButton.click();
+ await promise;
+
+ let addon = await AddonManager.getAddonByID(ID);
+ is(addon, null, "Extension is not installed");
+
+ let tabmail = document.getElementById("tabmail");
+ tabmail.closeTab(tab);
+});
diff --git a/comm/mail/base/test/webextensions/browser_update_checkForUpdates.js b/comm/mail/base/test/webextensions/browser_update_checkForUpdates.js
new file mode 100644
index 0000000000..b902527cae
--- /dev/null
+++ b/comm/mail/base/test/webextensions/browser_update_checkForUpdates.js
@@ -0,0 +1,17 @@
+// Invoke the "Check for Updates" menu item
+function checkAll(win) {
+ triggerPageOptionsAction(win, "check-for-updates");
+ return new Promise(resolve => {
+ let observer = {
+ observe(subject, topic, data) {
+ Services.obs.removeObserver(observer, "EM-update-check-finished");
+ resolve();
+ },
+ };
+ Services.obs.addObserver(observer, "EM-update-check-finished");
+ });
+}
+
+// Test "Check for Updates" with both auto-update settings
+add_task(() => interactiveUpdateTest(true, checkAll));
+add_task(() => interactiveUpdateTest(false, checkAll));
diff --git a/comm/mail/base/test/webextensions/browser_update_interactive_noprompt.js b/comm/mail/base/test/webextensions/browser_update_interactive_noprompt.js
new file mode 100644
index 0000000000..5d391de662
--- /dev/null
+++ b/comm/mail/base/test/webextensions/browser_update_interactive_noprompt.js
@@ -0,0 +1,82 @@
+// Set some prefs that apply to all the tests in this file.
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ // We don't have pre-pinned certificates for the local mochitest server.
+ ["extensions.install.requireBuiltInCerts", false],
+ ["extensions.update.requireBuiltInCerts", false],
+
+ // Don't require the extensions to be signed.
+ ["xpinstall.signatures.required", false],
+
+ // Point updates to the local mochitest server.
+ ["extensions.update.url", `${BASE}/browser_webext_update.json`],
+ ],
+ });
+});
+
+// Helper to test that an update of a given extension does not
+// generate any permission prompts.
+async function testUpdateNoPrompt(
+ filename,
+ id,
+ initialVersion = "1.0",
+ updateVersion = "2.0"
+) {
+ // Install initial version of the test extension
+ let addon = await promiseInstallAddon(`${BASE}/${filename}`);
+ ok(addon, "Addon was installed");
+ is(addon.version, initialVersion, "Version 1 of the addon is installed");
+
+ // Go to Extensions in about:addons
+ let win = await openAddonsMgr("addons://list/extension");
+
+ await waitAboutAddonsViewLoaded(win.document);
+
+ let sawPopup = false;
+ function popupListener() {
+ sawPopup = true;
+ }
+ PopupNotifications.panel.addEventListener("popupshown", popupListener);
+
+ // Trigger an update check, we should see the update get applied
+ let updatePromise = waitForUpdate(addon);
+ triggerPageOptionsAction(win, "check-for-updates");
+ await updatePromise;
+
+ addon = await AddonManager.getAddonByID(id);
+ is(addon.version, updateVersion, "Should have upgraded");
+
+ ok(!sawPopup, "Should not have seen a permission notification");
+ PopupNotifications.panel.removeEventListener("popupshown", popupListener);
+
+ let tabmail = document.getElementById("tabmail");
+ tabmail.closeTab(tabmail.currentTabInfo);
+ await addon.uninstall();
+}
+
+// Test that we don't see a prompt when no new promptable permissions
+// are added.
+add_task(() =>
+ testUpdateNoPrompt(
+ "browser_webext_update_perms1.xpi",
+ "update_perms@tests.mozilla.org"
+ )
+);
+
+// Test that an update that narrows origin permissions is just applied without
+// showing a notification prompt.
+add_task(() =>
+ testUpdateNoPrompt(
+ "browser_webext_update_origins1.xpi",
+ "update_origins@tests.mozilla.org"
+ )
+);
+
+// Test that an Experiment is not prompting for additional permissions.
+add_task(() =>
+ testUpdateNoPrompt(
+ "browser_webext_experiment.xpi",
+ "experiment_test@tests.mozilla.org"
+ )
+);
diff --git a/comm/mail/base/test/webextensions/browser_webext_experiment.xpi b/comm/mail/base/test/webextensions/browser_webext_experiment.xpi
new file mode 100644
index 0000000000..982d5d6b25
--- /dev/null
+++ b/comm/mail/base/test/webextensions/browser_webext_experiment.xpi
Binary files differ
diff --git a/comm/mail/base/test/webextensions/browser_webext_experiment_permissions.xpi b/comm/mail/base/test/webextensions/browser_webext_experiment_permissions.xpi
new file mode 100644
index 0000000000..d659b876fe
--- /dev/null
+++ b/comm/mail/base/test/webextensions/browser_webext_experiment_permissions.xpi
Binary files differ
diff --git a/comm/mail/base/test/webextensions/browser_webext_experiment_update1.xpi b/comm/mail/base/test/webextensions/browser_webext_experiment_update1.xpi
new file mode 100644
index 0000000000..bb4a5fb008
--- /dev/null
+++ b/comm/mail/base/test/webextensions/browser_webext_experiment_update1.xpi
Binary files differ
diff --git a/comm/mail/base/test/webextensions/browser_webext_experiment_update2.xpi b/comm/mail/base/test/webextensions/browser_webext_experiment_update2.xpi
new file mode 100644
index 0000000000..5f57efe3c2
--- /dev/null
+++ b/comm/mail/base/test/webextensions/browser_webext_experiment_update2.xpi
Binary files differ
diff --git a/comm/mail/base/test/webextensions/browser_webext_nopermissions.xpi b/comm/mail/base/test/webextensions/browser_webext_nopermissions.xpi
new file mode 100644
index 0000000000..ab97d96a11
--- /dev/null
+++ b/comm/mail/base/test/webextensions/browser_webext_nopermissions.xpi
Binary files differ
diff --git a/comm/mail/base/test/webextensions/browser_webext_permissions.xpi b/comm/mail/base/test/webextensions/browser_webext_permissions.xpi
new file mode 100644
index 0000000000..307c25a839
--- /dev/null
+++ b/comm/mail/base/test/webextensions/browser_webext_permissions.xpi
Binary files differ
diff --git a/comm/mail/base/test/webextensions/browser_webext_unsigned.xpi b/comm/mail/base/test/webextensions/browser_webext_unsigned.xpi
new file mode 100644
index 0000000000..2ebc23b4fe
--- /dev/null
+++ b/comm/mail/base/test/webextensions/browser_webext_unsigned.xpi
Binary files differ
diff --git a/comm/mail/base/test/webextensions/browser_webext_update.json b/comm/mail/base/test/webextensions/browser_webext_update.json
new file mode 100644
index 0000000000..e44372d50c
--- /dev/null
+++ b/comm/mail/base/test/webextensions/browser_webext_update.json
@@ -0,0 +1,82 @@
+{
+ "addons": {
+ "update2@tests.mozilla.org": {
+ "updates": [
+ {
+ "version": "2.0",
+ "update_link": "https://example.com/browser/comm/mail/base/test/webextensions/browser_webext_update2.xpi",
+ "applications": {
+ "gecko": {
+ "strict_min_version": "1"
+ }
+ }
+ }
+ ]
+ },
+ "update_icon2@tests.mozilla.org": {
+ "updates": [
+ {
+ "version": "2.0",
+ "update_link": "https://example.com/browser/comm/mail/base/test/webextensions/browser_webext_update_icon2.xpi",
+ "applications": {
+ "gecko": {
+ "strict_min_version": "1"
+ }
+ }
+ }
+ ]
+ },
+ "update_perms@tests.mozilla.org": {
+ "updates": [
+ {
+ "version": "2.0",
+ "update_link": "https://example.com/browser/comm/mail/base/test/webextensions/browser_webext_update_perms2.xpi",
+ "applications": {
+ "gecko": {
+ "strict_min_version": "1"
+ }
+ }
+ }
+ ]
+ },
+ "update_origins@tests.mozilla.org": {
+ "updates": [
+ {
+ "version": "2.0",
+ "update_link": "https://example.com/browser/comm/mail/base/test/webextensions/browser_webext_update_origins2.xpi",
+ "applications": {
+ "gecko": {
+ "strict_min_version": "1"
+ }
+ }
+ }
+ ]
+ },
+ "experiment_test@tests.mozilla.org": {
+ "updates": [
+ {
+ "version": "2.0",
+ "update_link": "https://example.com/browser/comm/mail/base/test/webextensions/browser_webext_experiment_permissions.xpi",
+ "applications": {
+ "gecko": {
+ "strict_min_version": "1"
+ }
+ }
+ }
+ ]
+ },
+ "experiment_update@test.mozilla.org": {
+ "updates": [
+ {
+ "version": "2.0",
+ "update_link": "https://example.com/browser/comm/mail/base/test/webextensions/browser_webext_experiment_update2.xpi",
+ "applications": {
+ "gecko": {
+ "strict_min_version": "1"
+ }
+ }
+ }
+ ]
+ }
+ }
+}
diff --git a/comm/mail/base/test/webextensions/browser_webext_update1.xpi b/comm/mail/base/test/webextensions/browser_webext_update1.xpi
new file mode 100644
index 0000000000..79be90636c
--- /dev/null
+++ b/comm/mail/base/test/webextensions/browser_webext_update1.xpi
Binary files differ
diff --git a/comm/mail/base/test/webextensions/browser_webext_update2.xpi b/comm/mail/base/test/webextensions/browser_webext_update2.xpi
new file mode 100644
index 0000000000..d1a12cadca
--- /dev/null
+++ b/comm/mail/base/test/webextensions/browser_webext_update2.xpi
Binary files differ
diff --git a/comm/mail/base/test/webextensions/browser_webext_update_icon1.xpi b/comm/mail/base/test/webextensions/browser_webext_update_icon1.xpi
new file mode 100644
index 0000000000..d3dcb3235d
--- /dev/null
+++ b/comm/mail/base/test/webextensions/browser_webext_update_icon1.xpi
Binary files differ
diff --git a/comm/mail/base/test/webextensions/browser_webext_update_icon2.xpi b/comm/mail/base/test/webextensions/browser_webext_update_icon2.xpi
new file mode 100644
index 0000000000..5cd7a8cec4
--- /dev/null
+++ b/comm/mail/base/test/webextensions/browser_webext_update_icon2.xpi
Binary files differ
diff --git a/comm/mail/base/test/webextensions/browser_webext_update_origins1.xpi b/comm/mail/base/test/webextensions/browser_webext_update_origins1.xpi
new file mode 100644
index 0000000000..2909f8e8fd
--- /dev/null
+++ b/comm/mail/base/test/webextensions/browser_webext_update_origins1.xpi
Binary files differ
diff --git a/comm/mail/base/test/webextensions/browser_webext_update_origins2.xpi b/comm/mail/base/test/webextensions/browser_webext_update_origins2.xpi
new file mode 100644
index 0000000000..b1051affb1
--- /dev/null
+++ b/comm/mail/base/test/webextensions/browser_webext_update_origins2.xpi
Binary files differ
diff --git a/comm/mail/base/test/webextensions/browser_webext_update_perms1.xpi b/comm/mail/base/test/webextensions/browser_webext_update_perms1.xpi
new file mode 100644
index 0000000000..f4942f9082
--- /dev/null
+++ b/comm/mail/base/test/webextensions/browser_webext_update_perms1.xpi
Binary files differ
diff --git a/comm/mail/base/test/webextensions/browser_webext_update_perms2.xpi b/comm/mail/base/test/webextensions/browser_webext_update_perms2.xpi
new file mode 100644
index 0000000000..2c023edc9d
--- /dev/null
+++ b/comm/mail/base/test/webextensions/browser_webext_update_perms2.xpi
Binary files differ
diff --git a/comm/mail/base/test/webextensions/file_install_extensions.html b/comm/mail/base/test/webextensions/file_install_extensions.html
new file mode 100644
index 0000000000..9dd8ae830d
--- /dev/null
+++ b/comm/mail/base/test/webextensions/file_install_extensions.html
@@ -0,0 +1,19 @@
+<!DOCTYPE html>
+
+<html>
+<head>
+ <meta charset="utf-8">
+</head>
+<body>
+<script type="text/javascript">
+function installMozAM(url) {
+ return navigator.mozAddonManager.createInstall({url})
+ .then(install => install.install());
+}
+
+function installTrigger(url) {
+ InstallTrigger.install({extension: url});
+}
+</script>
+</body>
+</html>
diff --git a/comm/mail/base/test/webextensions/head.js b/comm/mail/base/test/webextensions/head.js
new file mode 100644
index 0000000000..9fc4e05cbc
--- /dev/null
+++ b/comm/mail/base/test/webextensions/head.js
@@ -0,0 +1,632 @@
+/* globals openAddonsMgr, openContentTab */
+
+ChromeUtils.defineESModuleGetters(this, {
+ AddonTestUtils: "resource://testing-common/AddonTestUtils.sys.mjs",
+ Management: "resource://gre/modules/Extension.sys.mjs",
+});
+
+const BASE = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content/",
+ "https://example.com/"
+);
+
+const l10n = new Localization([
+ "toolkit/global/extensions.ftl",
+ "toolkit/global/extensionPermissions.ftl",
+ "messenger/extensionsUI.ftl",
+ "messenger/extensionPermissions.ftl",
+ "messenger/addonNotifications.ftl",
+ "branding/brand.ftl",
+]);
+
+var { CustomizableUITestUtils } = ChromeUtils.import(
+ "resource://testing-common/CustomizableUITestUtils.jsm"
+);
+let gCUITestUtils = new CustomizableUITestUtils(window);
+
+const { PermissionTestUtils } = ChromeUtils.import(
+ "resource://testing-common/PermissionTestUtils.jsm"
+);
+
+/**
+ * Wait for the given PopupNotification to display
+ *
+ * @param {string} name
+ * The name of the notification to wait for.
+ *
+ * @returns {Promise}
+ * Resolves with the notification window.
+ */
+function promisePopupNotificationShown(name) {
+ return new Promise(resolve => {
+ function popupshown() {
+ let notification = PopupNotifications.getNotification(name);
+ if (!notification) {
+ return;
+ }
+
+ ok(notification, `${name} notification shown`);
+ ok(PopupNotifications.isPanelOpen, "notification panel open");
+
+ PopupNotifications.panel.removeEventListener("popupshown", popupshown);
+ resolve(PopupNotifications.panel.firstElementChild);
+ }
+
+ PopupNotifications.panel.addEventListener("popupshown", popupshown);
+ });
+}
+
+/**
+ * Wait for a specific install event to fire for a given addon
+ *
+ * @param {AddonWrapper} addon
+ * The addon to watch for an event on
+ * @param {string}
+ * The name of the event to watch for (e.g., onInstallEnded)
+ *
+ * @returns {Promise}
+ * Resolves when the event triggers with the first argument
+ * to the event handler as the resolution value.
+ */
+function promiseInstallEvent(addon, event) {
+ return new Promise(resolve => {
+ let listener = {};
+ listener[event] = (install, arg) => {
+ if (install.addon.id == addon.id) {
+ AddonManager.removeInstallListener(listener);
+ resolve(arg);
+ }
+ };
+ AddonManager.addInstallListener(listener);
+ });
+}
+
+/**
+ * Install an (xpi packaged) extension
+ *
+ * @param {string} url
+ * URL of the .xpi file to install
+ * @param {object?} installTelemetryInfo
+ * an optional object that contains additional details used by the telemetry events.
+ *
+ * @returns {Promise}
+ * Resolves when the extension has been installed with the Addon
+ * object as the resolution value.
+ */
+async function promiseInstallAddon(url, telemetryInfo) {
+ let install = await AddonManager.getInstallForURL(url, { telemetryInfo });
+ install.install();
+
+ let addon = await new Promise(resolve => {
+ install.addListener({
+ onInstallEnded(_install, _addon) {
+ resolve(_addon);
+ },
+ });
+ });
+
+ if (addon.isWebExtension) {
+ await new Promise(resolve => {
+ function listener(event, extension) {
+ if (extension.id == addon.id) {
+ Management.off("ready", listener);
+ resolve();
+ }
+ }
+ Management.on("ready", listener);
+ });
+ }
+
+ return addon;
+}
+
+/**
+ * Wait for an update to the given webextension to complete.
+ * (This does not actually perform an update, it just watches for
+ * the events that occur as a result of an update.)
+ *
+ * @param {AddonWrapper} addon
+ * The addon to be updated.
+ *
+ * @returns {Promise}
+ * Resolves when the extension has ben updated.
+ */
+async function waitForUpdate(addon) {
+ let installPromise = promiseInstallEvent(addon, "onInstallEnded");
+ let readyPromise = new Promise(resolve => {
+ function listener(event, extension) {
+ if (extension.id == addon.id) {
+ Management.off("ready", listener);
+ resolve();
+ }
+ }
+ Management.on("ready", listener);
+ });
+
+ let [newAddon] = await Promise.all([installPromise, readyPromise]);
+ return newAddon;
+}
+
+function waitAboutAddonsViewLoaded(doc) {
+ return BrowserTestUtils.waitForEvent(doc, "view-loaded");
+}
+
+/**
+ * Trigger an action from the page options menu.
+ */
+function triggerPageOptionsAction(win, action) {
+ win.document.querySelector(`#page-options [action="${action}"]`).click();
+}
+
+function isDefaultIcon(icon) {
+ // These are basically the same icon, but code within webextensions
+ // generates references to the former and generic add-ons manager code
+ // generates referces to the latter.
+ return (
+ icon == "chrome://browser/content/extension.svg" ||
+ icon == "chrome://mozapps/skin/extensions/extensionGeneric.svg"
+ );
+}
+
+/**
+ * Check the contents of a permission popup notification
+ *
+ * @param {Window} panel
+ * The popup window.
+ * @param {string | RegExp | Function} checkIcon
+ * The icon expected to appear in the notification. If this is a
+ * string, it must match the icon url exactly. If it is a
+ * regular expression it is tested against the icon url, and if
+ * it is a function, it is called with the icon url and returns
+ * true if the url is correct.
+ * @param {Object[]} permissions
+ * The expected entries in the permissions list. Each element
+ * in this array is itself a 2-element array with the string key
+ * for the item (e.g., "webext-perms-description-foo") for permission foo
+ * and an optional formatting parameter.
+ * @param {boolean} sideloaded
+ * Whether the notification is for a sideloaded extenion.
+ * @param {boolean} [warning]
+ * Whether the experiments warning should be visible.
+ */
+async function checkNotification(
+ panel,
+ checkIcon,
+ permissions,
+ sideloaded,
+ warning = false
+) {
+ let icon = panel.getAttribute("icon");
+ let ul = document.getElementById("addon-webext-perm-list");
+ let singleDataEl = document.getElementById("addon-webext-perm-single-entry");
+ let experimentWarning = document.getElementById(
+ "addon-webext-experiment-warning"
+ );
+ let learnMoreLink = document.getElementById("addon-webext-perm-info");
+
+ if (checkIcon instanceof RegExp) {
+ ok(
+ checkIcon.test(icon),
+ `Notification icon is correct ${JSON.stringify(icon)} ~= ${checkIcon}`
+ );
+ } else if (typeof checkIcon == "function") {
+ ok(checkIcon(icon), "Notification icon is correct");
+ } else {
+ is(icon, checkIcon, "Notification icon is correct");
+ }
+
+ is(
+ learnMoreLink.hidden,
+ !permissions.length,
+ "Permissions learn more is hidden if there are no permissions"
+ );
+
+ if (!permissions.length) {
+ ok(ul.hidden, "Permissions list is hidden");
+ ok(singleDataEl.hidden, "Single permission data entry is hidden");
+ ok(
+ !(ul.childElementCount || singleDataEl.textContent),
+ "Permission list and single permission element have no entries"
+ );
+ } else if (permissions.length === 1) {
+ ok(ul.hidden, "Permissions list is hidden");
+ ok(!ul.childElementCount, "Permission list has no entries");
+ ok(singleDataEl.textContent, "Single permission data label has been set");
+ } else {
+ ok(singleDataEl.hidden, "Single permission data entry is hidden");
+ ok(
+ !singleDataEl.textContent,
+ "Single permission data label has not been set"
+ );
+ }
+
+ if (warning) {
+ is(experimentWarning.hidden, false, "Experiments warning is visible");
+ } else {
+ is(experimentWarning.hidden, true, "Experiments warning is hidden");
+ }
+}
+
+/**
+ * Test that install-time permission prompts work for a given
+ * installation method.
+ *
+ * @param {Function} installFn
+ * Callable that takes the name of an xpi file to install and
+ * starts to install it. Should return a Promise that resolves
+ * when the install is finished or rejects if the install is canceled.
+ *
+ * @returns {Promise}
+ */
+async function testInstallMethod(installFn) {
+ const PERMS_XPI = "browser_webext_permissions.xpi";
+ const NO_PERMS_XPI = "browser_webext_nopermissions.xpi";
+ const ID = "permissions@test.mozilla.org";
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["extensions.webapi.testing", true],
+ ["extensions.install.requireBuiltInCerts", false],
+ ],
+ });
+
+ let testURI = makeURI("https://example.com/");
+ PermissionTestUtils.add(testURI, "install", Services.perms.ALLOW_ACTION);
+ registerCleanupFunction(() => PermissionTestUtils.remove(testURI, "install"));
+
+ async function runOnce(filename, cancel) {
+ let tab = openContentTab("about:blank");
+ if (tab.browser.webProgress.isLoadingDocument) {
+ await BrowserTestUtils.browserLoaded(tab.browser);
+ }
+
+ let installPromise = new Promise(resolve => {
+ let listener = {
+ onDownloadCancelled() {
+ AddonManager.removeInstallListener(listener);
+ resolve(false);
+ },
+
+ onDownloadFailed() {
+ AddonManager.removeInstallListener(listener);
+ resolve(false);
+ },
+
+ onInstallCancelled() {
+ AddonManager.removeInstallListener(listener);
+ resolve(false);
+ },
+
+ onInstallEnded() {
+ AddonManager.removeInstallListener(listener);
+ resolve(true);
+ },
+
+ onInstallFailed() {
+ AddonManager.removeInstallListener(listener);
+ resolve(false);
+ },
+ };
+ AddonManager.addInstallListener(listener);
+ });
+
+ let installMethodPromise = installFn(filename);
+
+ let panel = await promisePopupNotificationShown("addon-webext-permissions");
+ if (filename == PERMS_XPI) {
+ // The icon should come from the extension, don't bother with the precise
+ // path, just make sure we've got a jar url pointing to the right path
+ // inside the jar.
+ await checkNotification(panel, /^jar:file:\/\/.*\/icon\.png$/, [
+ ["webext-perms-host-description-wildcard", "domain"],
+ ["webext-perms-host-description-one-site", "domain"],
+ ["webext-perms-description-nativeMessaging"],
+ // The below permissions are deliberately in this order as permissions
+ // are sorted alphabetically by the permission string to match AMO.
+ ["webext-perms-description-accountsRead"],
+ ["webext-perms-description-tabs"],
+ ]);
+ } else if (filename == NO_PERMS_XPI) {
+ await checkNotification(panel, isDefaultIcon, []);
+ }
+
+ if (cancel) {
+ panel.secondaryButton.click();
+ try {
+ await installMethodPromise;
+ } catch (err) {}
+ } else {
+ // Look for post-install notification
+ let postInstallPromise = promisePopupNotificationShown("addon-installed");
+ panel.button.click();
+
+ // Press OK on the post-install notification
+ panel = await postInstallPromise;
+ panel.button.click();
+
+ await installMethodPromise;
+ }
+
+ let result = await installPromise;
+ let addon = await AddonManager.getAddonByID(ID);
+ if (cancel) {
+ ok(!result, "Installation was cancelled");
+ is(addon, null, "Extension is not installed");
+ } else {
+ ok(result, "Installation completed");
+ isnot(addon, null, "Extension is installed");
+ await addon.uninstall();
+ }
+
+ let tabmail = document.getElementById("tabmail");
+ tabmail.closeOtherTabs(tabmail.tabInfo[0]);
+ }
+
+ // A few different tests for each installation method:
+ // 1. Start installation of an extension that requests no permissions,
+ // verify the notification contents, then cancel the install
+ await runOnce(NO_PERMS_XPI, true);
+
+ // 2. Same as #1 but with an extension that requests some permissions.
+ await runOnce(PERMS_XPI, true);
+
+ // 3. Repeat with the same extension from step 2 but this time,
+ // accept the permissions to install the extension. (Then uninstall
+ // the extension to clean up.)
+ await runOnce(PERMS_XPI, false);
+
+ await SpecialPowers.popPrefEnv();
+}
+
+// Helper function to test a specific scenario for interactive updates.
+// `checkFn` is a callable that triggers a check for updates.
+// `autoUpdate` specifies whether the test should be run with
+// updates applied automatically or not.
+async function interactiveUpdateTest(autoUpdate, checkFn) {
+ AddonTestUtils.initMochitest(this);
+
+ const ID = "update2@tests.mozilla.org";
+ const FAKE_INSTALL_SOURCE = "fake-install-source";
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ // We don't have pre-pinned certificates for the local mochitest server
+ ["extensions.install.requireBuiltInCerts", false],
+ ["extensions.update.requireBuiltInCerts", false],
+
+ ["extensions.update.autoUpdateDefault", autoUpdate],
+
+ // Point updates to the local mochitest server
+ ["extensions.update.url", `${BASE}/browser_webext_update.json`],
+ ],
+ });
+
+ AddonTestUtils.hookAMTelemetryEvents();
+
+ // Trigger an update check, manually applying the update if we're testing
+ // without auto-update.
+ async function triggerUpdate(win, addon) {
+ let manualUpdatePromise;
+ if (!autoUpdate) {
+ manualUpdatePromise = new Promise(resolve => {
+ let listener = {
+ onNewInstall() {
+ AddonManager.removeInstallListener(listener);
+ resolve();
+ },
+ };
+ AddonManager.addInstallListener(listener);
+ });
+ }
+
+ let promise = checkFn(win, addon);
+
+ if (manualUpdatePromise) {
+ await manualUpdatePromise;
+
+ let doc = win.document;
+ if (win.gViewController.currentViewId !== "addons://updates/available") {
+ let showUpdatesBtn = doc.querySelector("addon-updates-message").button;
+ await TestUtils.waitForCondition(() => {
+ return !showUpdatesBtn.hidden;
+ }, "Wait for show updates button");
+ let viewChanged = waitAboutAddonsViewLoaded(doc);
+ showUpdatesBtn.click();
+ await viewChanged;
+ }
+ let card = await TestUtils.waitForCondition(() => {
+ return doc.querySelector(`addon-card[addon-id="${ID}"]`);
+ }, `Wait addon card for "${ID}"`);
+ let updateBtn = card.querySelector('panel-item[action="install-update"]');
+ ok(updateBtn, `Found update button for "${ID}"`);
+ updateBtn.click();
+ }
+
+ return { promise };
+ }
+
+ // Install version 1.0 of the test extension
+ let addon = await promiseInstallAddon(`${BASE}/browser_webext_update1.xpi`, {
+ source: FAKE_INSTALL_SOURCE,
+ });
+ ok(addon, "Addon was installed");
+ is(addon.version, "1.0", "Version 1 of the addon is installed");
+
+ let win = await openAddonsMgr("addons://list/extension");
+
+ await waitAboutAddonsViewLoaded(win.document);
+
+ // Trigger an update check
+ let popupPromise = promisePopupNotificationShown("addon-webext-permissions");
+ let { promise: checkPromise } = await triggerUpdate(win, addon);
+ let panel = await popupPromise;
+
+ // Click the cancel button, wait to see the cancel event
+ let cancelPromise = promiseInstallEvent(addon, "onInstallCancelled");
+ panel.secondaryButton.click();
+ await cancelPromise;
+
+ addon = await AddonManager.getAddonByID(ID);
+ is(addon.version, "1.0", "Should still be running the old version");
+
+ // Make sure the update check is completely finished.
+ await checkPromise;
+
+ // Trigger a new update check
+ popupPromise = promisePopupNotificationShown("addon-webext-permissions");
+ checkPromise = (await triggerUpdate(win, addon)).promise;
+
+ // This time, accept the upgrade
+ let updatePromise = waitForUpdate(addon);
+ panel = await popupPromise;
+ panel.button.click();
+
+ addon = await updatePromise;
+ is(addon.version, "2.0", "Should have upgraded");
+
+ await checkPromise;
+
+ let tabmail = document.getElementById("tabmail");
+ tabmail.closeTab(tabmail.currentTabInfo);
+ await addon.uninstall();
+ await SpecialPowers.popPrefEnv();
+
+ const collectedUpdateEvents = AddonTestUtils.getAMTelemetryEvents().filter(
+ evt => {
+ return evt.method === "update";
+ }
+ );
+
+ Assert.deepEqual(
+ collectedUpdateEvents.map(evt => evt.extra.step),
+ [
+ // First update is cancelled on the permission prompt.
+ "started",
+ "download_started",
+ "download_completed",
+ "permissions_prompt",
+ "cancelled",
+ // Second update is expected to be completed.
+ "started",
+ "download_started",
+ "download_completed",
+ "permissions_prompt",
+ "completed",
+ ],
+ "Got the expected sequence on update telemetry events"
+ );
+
+ ok(
+ collectedUpdateEvents.every(evt => evt.extra.addon_id === ID),
+ "Every update telemetry event should have the expected addon_id extra var"
+ );
+
+ ok(
+ collectedUpdateEvents.every(
+ evt => evt.extra.source === FAKE_INSTALL_SOURCE
+ ),
+ "Every update telemetry event should have the expected source extra var"
+ );
+
+ ok(
+ collectedUpdateEvents.every(evt => evt.extra.updated_from === "user"),
+ "Every update telemetry event should have the update_from extra var 'user'"
+ );
+
+ let hasPermissionsExtras = collectedUpdateEvents
+ .filter(evt => {
+ return evt.extra.step === "permissions_prompt";
+ })
+ .every(evt => {
+ return Number.isInteger(parseInt(evt.extra.num_strings, 10));
+ });
+
+ ok(
+ hasPermissionsExtras,
+ "Every 'permissions_prompt' update telemetry event should have the permissions extra vars"
+ );
+
+ let hasDownloadTimeExtras = collectedUpdateEvents
+ .filter(evt => {
+ return evt.extra.step === "download_completed";
+ })
+ .every(evt => {
+ const download_time = parseInt(evt.extra.download_time, 10);
+ return !isNaN(download_time) && download_time > 0;
+ });
+
+ ok(
+ hasDownloadTimeExtras,
+ "Every 'download_completed' update telemetry event should have a download_time extra vars"
+ );
+}
+
+// The tests in this directory install a bunch of extensions but they
+// need to uninstall them before exiting, as a stray leftover extension
+// after one test can foul up subsequent tests.
+// So, add a task to run before any tests that grabs a list of all the
+// add-ons that are pre-installed in the test environment and then checks
+// the list of installed add-ons at the end of the test to make sure no
+// new add-ons have been added.
+// Individual tests can store a cleanup function in the testCleanup global
+// to ensure it gets called before the final check is performed.
+let testCleanup;
+add_task(async function () {
+ let addons = await AddonManager.getAllAddons();
+ let existingAddons = new Set(addons.map(a => a.id));
+
+ registerCleanupFunction(async function () {
+ if (testCleanup) {
+ await testCleanup();
+ testCleanup = null;
+ }
+
+ for (let addon of await AddonManager.getAllAddons()) {
+ // Builtin search extensions may have been installed by SearchService
+ // during the test run, ignore those.
+ if (
+ !existingAddons.has(addon.id) &&
+ !(addon.isBuiltin && addon.id.endsWith("@search.mozilla.org"))
+ ) {
+ ok(
+ false,
+ `Addon ${addon.id} was left installed at the end of the test`
+ );
+ await addon.uninstall();
+ }
+ }
+ });
+});
+
+registerCleanupFunction(() => {
+ // The appmenu should be closed by the end of the test.
+ ok(PanelUI.panel.state == "closed", "Main menu is closed.");
+
+ // Any opened tabs should be closed by the end of the test.
+ let tabmail = document.getElementById("tabmail");
+ is(tabmail.tabInfo.length, 1, "All tabs are closed.");
+ tabmail.closeOtherTabs(0);
+});
+
+let collectedTelemetry = [];
+function hookExtensionsTelemetry() {
+ let originalHistogram = ExtensionsUI.histogram;
+ ExtensionsUI.histogram = {
+ add(value) {
+ collectedTelemetry.push(value);
+ },
+ };
+ registerCleanupFunction(() => {
+ is(
+ collectedTelemetry.length,
+ 0,
+ "No unexamined telemetry after test is finished"
+ );
+ ExtensionsUI.histogram = originalHistogram;
+ });
+}
+
+function expectTelemetry(values) {
+ Assert.deepEqual(values, collectedTelemetry);
+ collectedTelemetry = [];
+}