diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
commit | 6bf0a5cb5034a7e684dcc3500e841785237ce2dd (patch) | |
tree | a68f146d7fa01f0134297619fbe7e33db084e0aa /comm/mail/base/content/quickFilterBar.js | |
parent | Initial commit. (diff) | |
download | thunderbird-upstream.tar.xz thunderbird-upstream.zip |
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
-rw-r--r-- | comm/mail/base/content/quickFilterBar.js | 603 |
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") +); |