summaryrefslogtreecommitdiffstats
path: root/comm/mail/base/content/widgets
diff options
context:
space:
mode:
Diffstat (limited to 'comm/mail/base/content/widgets')
-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
17 files changed, 13245 insertions, 0 deletions
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",
+});