summaryrefslogtreecommitdiffstats
path: root/comm/mail/base/content/quickFilterBar.js
diff options
context:
space:
mode:
Diffstat (limited to 'comm/mail/base/content/quickFilterBar.js')
-rw-r--r--comm/mail/base/content/quickFilterBar.js603
1 files changed, 603 insertions, 0 deletions
diff --git a/comm/mail/base/content/quickFilterBar.js b/comm/mail/base/content/quickFilterBar.js
new file mode 100644
index 0000000000..e254b91416
--- /dev/null
+++ b/comm/mail/base/content/quickFilterBar.js
@@ -0,0 +1,603 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from about3Pane.js */
+
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+XPCOMUtils.defineLazyModuleGetters(this, {
+ MessageTextFilter: "resource:///modules/QuickFilterManager.jsm",
+ SearchSpec: "resource:///modules/SearchSpec.jsm",
+ QuickFilterManager: "resource:///modules/QuickFilterManager.jsm",
+ QuickFilterSearchListener: "resource:///modules/QuickFilterManager.jsm",
+ QuickFilterState: "resource:///modules/QuickFilterManager.jsm",
+});
+
+class ToggleButton extends HTMLButtonElement {
+ constructor() {
+ super();
+ this.addEventListener("click", () => {
+ this.pressed = !this.pressed;
+ });
+ }
+
+ connectedCallback() {
+ this.setAttribute("is", "toggle-button");
+ if (!this.hasAttribute("aria-pressed")) {
+ this.pressed = false;
+ }
+ }
+
+ get pressed() {
+ return this.getAttribute("aria-pressed") === "true";
+ }
+
+ set pressed(value) {
+ this.setAttribute("aria-pressed", value ? "true" : "false");
+ }
+}
+customElements.define("toggle-button", ToggleButton, { extends: "button" });
+
+var quickFilterBar = {
+ _filterer: null,
+ activeTopLevelFilters: new Set(),
+ topLevelFilters: ["unread", "starred", "addrBook", "attachment"],
+
+ /**
+ * The UI element that last triggered a search. This can be used to avoid
+ * updating the element when a search returns - in particular the text box,
+ * which the user may still be typing into.
+ *
+ * @type {Element}
+ */
+ activeElement: null,
+
+ init() {
+ this._bindUI();
+ this.updateRovingTab();
+
+ // Enable any filters set by the user.
+ // If keep filters applied/sticky setting is enabled, enable sticky.
+ let xulStickyVal = Services.xulStore.getValue(
+ XULSTORE_URL,
+ "quickFilterBarSticky",
+ "enabled"
+ );
+ if (xulStickyVal) {
+ this.filterer.setFilterValue("sticky", xulStickyVal == "true");
+
+ // If sticky is set, show saved filters.
+ // Otherwise do not display saved filters on load.
+ if (xulStickyVal == "true") {
+ // If any filter settings are enabled, retrieve the enabled filters.
+ let enabledTopFiltersVal = Services.xulStore.getValue(
+ XULSTORE_URL,
+ "quickFilter",
+ "enabledTopFilters"
+ );
+
+ // Set any enabled filters to enabled in the UI.
+ if (enabledTopFiltersVal) {
+ let enabledTopFilters = JSON.parse(enabledTopFiltersVal);
+ for (let filterName of enabledTopFilters) {
+ this.activeTopLevelFilters.add(filterName);
+ this.filterer.setFilterValue(filterName, true);
+ }
+ }
+ }
+ }
+
+ // Hide the toolbar, unless it has been previously shown.
+ if (
+ Services.xulStore.getValue(
+ XULSTORE_URL,
+ "quickFilterBar",
+ "collapsed"
+ ) === "false"
+ ) {
+ this._showFilterBar(true, true);
+ } else {
+ this._showFilterBar(false, true);
+ }
+
+ commandController.registerCallback("cmd_showQuickFilterBar", () => {
+ if (!this.filterer.visible) {
+ this._showFilterBar(true);
+ }
+ document.getElementById(QuickFilterManager.textBoxDomId).select();
+ });
+ commandController.registerCallback("cmd_toggleQuickFilterBar", () => {
+ let show = !this.filterer.visible;
+ this._showFilterBar(show);
+ if (show) {
+ document.getElementById(QuickFilterManager.textBoxDomId).select();
+ }
+ });
+ window.addEventListener("keypress", event => {
+ if (event.keyCode != KeyEvent.DOM_VK_ESCAPE || !this.filterer.visible) {
+ // The filter bar isn't visible, do nothing.
+ return;
+ }
+ if (this.filterer.userHitEscape()) {
+ // User hit the escape key; do our undo-ish thing.
+ this.updateSearch();
+ this.reflectFiltererState();
+ } else {
+ // Close the filter since there was nothing left to relax.
+ this._showFilterBar(false);
+ }
+ });
+
+ document.getElementById("qfd-dropdown").addEventListener("click", event => {
+ document
+ .getElementById("quickFilterButtonsContext")
+ .openPopup(event.target, { triggerEvent: event });
+ });
+
+ for (let buttonGroup of this.rovingGroups) {
+ buttonGroup.addEventListener("keypress", event => {
+ this.triggerQFTRovingTab(event);
+ });
+ }
+
+ document.getElementById("qfb-sticky").addEventListener("click", event => {
+ let stickyValue = event.target.pressed ? "true" : "false";
+ Services.xulStore.setValue(
+ XULSTORE_URL,
+ "quickFilterBarSticky",
+ "enabled",
+ stickyValue
+ );
+ });
+ },
+
+ /**
+ * Get all button groups with the roving-group class.
+ *
+ * @returns {Array} An array of buttons.
+ */
+ get rovingGroups() {
+ return document.querySelectorAll("#quick-filter-bar .roving-group");
+ },
+
+ /**
+ * Update the `tabindex` attribute of the buttons.
+ */
+ updateRovingTab() {
+ for (let buttonGroup of this.rovingGroups) {
+ for (let button of buttonGroup.querySelectorAll("button")) {
+ button.tabIndex = -1;
+ }
+ // Allow focus on the first available button.
+ buttonGroup.querySelector("button").tabIndex = 0;
+ }
+ },
+
+ /**
+ * Handles the keypress event on the button group.
+ *
+ * @param {Event} event - The keypress DOMEvent.
+ */
+ triggerQFTRovingTab(event) {
+ if (!["ArrowRight", "ArrowLeft"].includes(event.key)) {
+ return;
+ }
+
+ let buttonGroup = [
+ ...event.target
+ .closest(".roving-group")
+ .querySelectorAll(`[is="toggle-button"]`),
+ ];
+ let focusableButton = buttonGroup.find(b => b.tabIndex != -1);
+ let elementIndex = buttonGroup.indexOf(focusableButton);
+
+ // Find the adjacent focusable element based on the pressed key.
+ let isRTL = document.dir == "rtl";
+ if (
+ (isRTL && event.key == "ArrowLeft") ||
+ (!isRTL && event.key == "ArrowRight")
+ ) {
+ elementIndex++;
+ if (elementIndex > buttonGroup.length - 1) {
+ elementIndex = 0;
+ }
+ } else if (
+ (!isRTL && event.key == "ArrowLeft") ||
+ (isRTL && event.key == "ArrowRight")
+ ) {
+ elementIndex--;
+ if (elementIndex == -1) {
+ elementIndex = buttonGroup.length - 1;
+ }
+ }
+
+ // Move the focus to a button and update the tabindex attribute.
+ let newFocusableButton = buttonGroup[elementIndex];
+ if (newFocusableButton) {
+ focusableButton.tabIndex = -1;
+ newFocusableButton.tabIndex = 0;
+ newFocusableButton.focus();
+ }
+ },
+
+ get filterer() {
+ if (!this._filterer) {
+ this._filterer = new QuickFilterState();
+ this._filterer.visible = false;
+ }
+ return this._filterer;
+ },
+
+ set filterer(value) {
+ this._filterer = value;
+ },
+
+ // ---------------------
+ // UI State Manipulation
+
+ /**
+ * Add appropriate event handlers to the DOM elements. We do this rather
+ * than requiring lots of boilerplate "oncommand" junk on the nodes.
+ *
+ * We hook up the following:
+ * - "command" event listener.
+ * - reflect filter state
+ */
+ _bindUI() {
+ for (let filterDef of QuickFilterManager.filterDefs) {
+ let domNode = document.getElementById(filterDef.domId);
+ let menuItemNode = document.getElementById(filterDef.menuItemID);
+
+ let handlerDomId, handlerMenuItems;
+
+ if (!("onCommand" in filterDef)) {
+ handlerDomId = event => {
+ try {
+ let postValue = domNode.pressed ? true : null;
+ this.filterer.setFilterValue(filterDef.name, postValue);
+ this.updateFiltersSettings(filterDef.name, postValue);
+ this.deferredUpdateSearch(domNode);
+ } catch (ex) {
+ console.error(ex);
+ }
+ };
+ handlerMenuItems = event => {
+ try {
+ let postValue = menuItemNode.hasAttribute("checked") ? true : null;
+ this.filterer.setFilterValue(filterDef.name, postValue);
+ this.updateFiltersSettings(filterDef.name, postValue);
+ this.deferredUpdateSearch();
+ } catch (ex) {
+ console.error(ex);
+ }
+ };
+ } else {
+ handlerDomId = event => {
+ if (filterDef.name == "tags") {
+ filterDef.callID = "button";
+ }
+ let filterValues = this.filterer.filterValues;
+ let preValue =
+ filterDef.name in filterValues
+ ? filterValues[filterDef.name]
+ : null;
+ let [postValue, update] = filterDef.onCommand(
+ preValue,
+ domNode,
+ event,
+ document
+ );
+ this.filterer.setFilterValue(filterDef.name, postValue, !update);
+ this.updateFiltersSettings(filterDef.name, postValue);
+ if (update) {
+ this.deferredUpdateSearch(domNode);
+ }
+ };
+ handlerMenuItems = event => {
+ if (filterDef.name == "tags") {
+ filterDef.callID = "menuItem";
+ }
+ let filterValues = this.filterer.filterValues;
+ let preValue =
+ filterDef.name in filterValues
+ ? filterValues[filterDef.name]
+ : null;
+ let [postValue, update] = filterDef.onCommand(
+ preValue,
+ menuItemNode,
+ event,
+ document
+ );
+ this.filterer.setFilterValue(filterDef.name, postValue, !update);
+ this.updateFiltersSettings(filterDef.name, postValue);
+ if (update) {
+ this.deferredUpdateSearch();
+ }
+ };
+ }
+
+ if (domNode.namespaceURI == document.documentElement.namespaceURI) {
+ domNode.addEventListener("click", handlerDomId);
+ } else {
+ domNode.addEventListener("command", handlerDomId);
+ }
+ if (menuItemNode !== null) {
+ menuItemNode.addEventListener("command", handlerMenuItems);
+ }
+
+ if ("domBindExtra" in filterDef) {
+ filterDef.domBindExtra(document, this, domNode);
+ }
+ }
+ },
+
+ /**
+ * Update enabled filters in XULStore.
+ */
+ updateFiltersSettings(filterName, filterValue) {
+ if (this.topLevelFilters.includes(filterName)) {
+ this.updateTopLevelFilters(filterName, filterValue);
+ }
+ },
+
+ /**
+ * Update enabled top level filters in XULStore.
+ */
+ updateTopLevelFilters(filterName, filterValue) {
+ if (filterValue) {
+ this.activeTopLevelFilters.add(filterName);
+ } else {
+ this.activeTopLevelFilters.delete(filterName);
+ }
+
+ // Save enabled filter settings to XULStore.
+ Services.xulStore.setValue(
+ XULSTORE_URL,
+ "quickFilter",
+ "enabledTopFilters",
+ JSON.stringify(Array.from(this.activeTopLevelFilters))
+ );
+ },
+
+ /**
+ * Ensure all the quick filter menuitems in the quick filter dropdown menu are
+ * checked to reflect their current state.
+ */
+ updateCheckedStateQuickFilterButtons() {
+ for (let item of document.querySelectorAll(".quick-filter-menuitem")) {
+ if (Object.hasOwn(this.filterer.filterValues, `${item.value}`)) {
+ item.setAttribute("checked", true);
+ continue;
+ }
+ item.removeAttribute("checked");
+ }
+ },
+
+ /**
+ * Update the UI to reflect the state of the filterer constraints.
+ *
+ * @param [aFilterName] If only a single filter needs to be updated, name it.
+ */
+ reflectFiltererState(aFilterName) {
+ // If we aren't visible then there is no need to update the widgets.
+ if (this.filterer.visible) {
+ let filterValues = this.filterer.filterValues;
+ for (let filterDef of QuickFilterManager.filterDefs) {
+ // If we only need to update one state, check and skip as appropriate.
+ if (aFilterName && filterDef.name != aFilterName) {
+ continue;
+ }
+
+ let domNode = document.getElementById(filterDef.domId);
+
+ let value =
+ filterDef.name in filterValues ? filterValues[filterDef.name] : null;
+ if (!("reflectInDOM" in filterDef)) {
+ domNode.pressed = value;
+ } else {
+ filterDef.reflectInDOM(domNode, value, document, this);
+ }
+ }
+ }
+
+ this.reflectFiltererResults();
+
+ this.domNode.hidden = !this.filterer.visible;
+ },
+
+ /**
+ * Update the UI to reflect the state of the folderDisplay in terms of
+ * filtering. This is expected to be called by |reflectFiltererState| and
+ * when something happens event-wise in terms of search.
+ *
+ * We can have one of two states:
+ * - No filter is active; no attributes exposed for CSS to do anything.
+ * - A filter is active and we are still searching; filterActive=searching.
+ */
+ reflectFiltererResults() {
+ let threadPane = document.getElementById("threadTree");
+
+ // bail early if the view is in the process of being created
+ if (!gDBView) {
+ return;
+ }
+
+ // no filter active
+ if (!gViewWrapper.search || !gViewWrapper.search.userTerms) {
+ threadPane.removeAttribute("filterActive");
+ this.domNode.removeAttribute("filterActive");
+ } else if (gViewWrapper.searching) {
+ // filter active, still searching
+ // Do not set this immediately; wait a bit and then only set this if we
+ // still are in this same state (and we are still the active tab...)
+ setTimeout(() => {
+ threadPane.setAttribute("filterActive", "searching");
+ this.domNode.setAttribute("filterActive", "searching");
+ }, 500);
+ }
+ },
+
+ // ----------------------
+ // Event Handling Support
+
+ /**
+ * Retrieve the current filter state value (presumably an object) for mutation
+ * purposes. This causes the filter to be the last touched filter for escape
+ * undo-ish purposes.
+ */
+ getFilterValueForMutation(aName) {
+ return this.filterer.getFilterValue(aName);
+ },
+
+ /**
+ * Set the filter state for the given named filter to the given value. This
+ * causes the filter to be the last touched filter for escape undo-ish
+ * purposes.
+ *
+ * @param aName Filter name.
+ * @param aValue The new filter state.
+ */
+ setFilterValue(aName, aValue) {
+ this.filterer.setFilterValue(aName, aValue);
+ },
+
+ /**
+ * For UI responsiveness purposes, defer the actual initiation of the search
+ * until after the button click handling has completed and had the ability
+ * to paint such.
+ *
+ * @param {Element} activeElement - The element that triggered a call to
+ * this function, if any.
+ */
+ deferredUpdateSearch(activeElement) {
+ setTimeout(() => this.updateSearch(activeElement), 10);
+ },
+
+ /**
+ * Update the user terms part of the search definition to reflect the active
+ * filterer's current state.
+ *
+ * @param {Element?} activeElement - The element that triggered a call to
+ * this function, if any.
+ */
+ updateSearch(activeElement) {
+ if (!this._filterer || !gViewWrapper?.search) {
+ return;
+ }
+
+ this.activeElement = activeElement;
+ this.filterer.displayedFolder = gFolder;
+
+ let [terms, listeners] = this.filterer.createSearchTerms(
+ gViewWrapper.search.session
+ );
+
+ for (let [listener, filterDef] of listeners) {
+ // it registers itself with the search session.
+ new QuickFilterSearchListener(
+ gViewWrapper,
+ this.filterer,
+ filterDef,
+ listener,
+ quickFilterBar
+ );
+ }
+
+ gViewWrapper.search.userTerms = terms;
+ // Uncomment to know what the search state is when we (try and) update it.
+ // dump(tab.folderDisplay.view.search.prettyString());
+ },
+
+ /**
+ * Shows and hides quick filter bar, and sets the XUL Store value for the
+ * quick filter bar status.
+ *
+ * @param {boolean} show - Filter Status.
+ * @param {boolean} [init=false] - Initial Function Call.
+ */
+ _showFilterBar(show, init = false) {
+ this.filterer.visible = show;
+ if (!show) {
+ this.filterer.clear();
+ this.updateSearch();
+ // Cannot call the below function when threadTree hasn't been initialized yet.
+ if (!init) {
+ threadTree.table.body.focus();
+ }
+ }
+ this.reflectFiltererState();
+ Services.xulStore.setValue(
+ XULSTORE_URL,
+ "quickFilterBar",
+ "collapsed",
+ !show
+ );
+
+ window.dispatchEvent(new Event("qfbtoggle"));
+ },
+
+ /**
+ * Called by the view wrapper so we can update the results count.
+ */
+ onMessagesChanged() {
+ let filtering = gViewWrapper.search?.userTerms != null;
+ let newCount = filtering ? gDBView.numMsgsInView : null;
+ this.filterer.setFilterValue("results", newCount, true);
+
+ // - postFilterProcess everyone who cares
+ // This may need to be converted into an asynchronous process at some point.
+ for (let filterDef of QuickFilterManager.filterDefs) {
+ if ("postFilterProcess" in filterDef) {
+ let preState =
+ filterDef.name in this.filterer.filterValues
+ ? this.filterer.filterValues[filterDef.name]
+ : null;
+ let [newState, update, treatAsUserAction] = filterDef.postFilterProcess(
+ preState,
+ gViewWrapper,
+ filtering
+ );
+ this.filterer.setFilterValue(
+ filterDef.name,
+ newState,
+ !treatAsUserAction
+ );
+ if (update) {
+ let domNode = document.getElementById(filterDef.domId);
+ // We are passing update as a super-secret data propagation channel
+ // exclusively for one-off cases like the text filter gloda upsell.
+ filterDef.reflectInDOM(domNode, newState, document, this, update);
+ }
+ }
+ }
+
+ // - Update match status.
+ this.reflectFiltererState();
+ },
+
+ /**
+ * The displayed folder changed. Reset or reapply the filter, depending on
+ * the sticky state.
+ */
+ onFolderChanged() {
+ this.filterer = new QuickFilterState(this.filterer);
+ this.reflectFiltererState();
+ if (this._filterer?.filterValues.sticky) {
+ this.updateSearch();
+ }
+ },
+
+ _testHelperResetFilterState() {
+ if (!this._filterer) {
+ return;
+ }
+ this._filterer = new QuickFilterState();
+ this.updateSearch();
+ this.reflectFiltererState();
+ },
+};
+XPCOMUtils.defineLazyGetter(quickFilterBar, "domNode", () =>
+ document.getElementById("quick-filter-bar")
+);