diff options
Diffstat (limited to 'comm/mail/base/content/FilterListDialog.js')
-rw-r--r-- | comm/mail/base/content/FilterListDialog.js | 1162 |
1 files changed, 1162 insertions, 0 deletions
diff --git a/comm/mail/base/content/FilterListDialog.js b/comm/mail/base/content/FilterListDialog.js new file mode 100644 index 0000000000..a802da6d78 --- /dev/null +++ b/comm/mail/base/content/FilterListDialog.js @@ -0,0 +1,1162 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +var { PluralForm } = ChromeUtils.importESModule( + "resource://gre/modules/PluralForm.sys.mjs" +); +var { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" +); + +window.addEventListener("load", onLoad); +window.addEventListener("unload", onFilterUnload); +window.addEventListener("close", event => { + if (!onFilterClose()) { + event.preventDefault(); + } +}); + +var gFilterListMsgWindow = null; +var gCurrentFilterList; +var gServerMenu = null; +var gFilterListbox = null; +var gEditButton = null; +var gDeleteButton = null; +var gCopyToNewButton = null; +var gTopButton = null; +var gUpButton = null; +var gDownButton = null; +var gBottomButton = null; +var gSearchBox = null; +var gRunFiltersFolder = null; +var gRunFiltersButton = null; + +var gFilterBundle = null; + +var msgMoveMotion = { + Up: 0, + Down: 1, + Top: 2, + Bottom: 3, +}; + +var gStatusFeedback = { + progressMeterVisible: false, + + showStatusString(status) { + document.getElementById("statusText").setAttribute("value", status); + }, + startMeteors() { + // change run button to be a stop button + gRunFiltersButton.setAttribute( + "label", + gRunFiltersButton.getAttribute("stoplabel") + ); + gRunFiltersButton.setAttribute( + "accesskey", + gRunFiltersButton.getAttribute("stopaccesskey") + ); + + if (!this.progressMeterVisible) { + document + .getElementById("statusbar-progresspanel") + .removeAttribute("collapsed"); + this.progressMeterVisible = true; + } + + document.getElementById("statusbar-icon").removeAttribute("value"); + }, + stopMeteors() { + try { + // change run button to be a stop button + gRunFiltersButton.setAttribute( + "label", + gRunFiltersButton.getAttribute("runlabel") + ); + gRunFiltersButton.setAttribute( + "accesskey", + gRunFiltersButton.getAttribute("runaccesskey") + ); + + if (this.progressMeterVisible) { + document.getElementById("statusbar-progresspanel").collapsed = true; + this.progressMeterVisible = true; + } + } catch (ex) { + // can get here if closing window when running filters + } + }, + showProgress(percentage) {}, + closeWindow() {}, +}; + +var filterEditorQuitObserver = { + observe(aSubject, aTopic, aData) { + // Check whether or not we want to veto the quit request (unless another + // observer already did. + if ( + aTopic == "quit-application-requested" && + aSubject instanceof Ci.nsISupportsPRBool && + !aSubject.data + ) { + aSubject.data = !onFilterClose(); + } + }, +}; + +function onLoad() { + gFilterListMsgWindow = Cc[ + "@mozilla.org/messenger/msgwindow;1" + ].createInstance(Ci.nsIMsgWindow); + gFilterListMsgWindow.domWindow = window; + gFilterListMsgWindow.rootDocShell.appType = Ci.nsIDocShell.APP_TYPE_MAIL; + gFilterListMsgWindow.statusFeedback = gStatusFeedback; + + gServerMenu = document.getElementById("serverMenu"); + gFilterListbox = document.getElementById("filterList"); + gEditButton = document.getElementById("editButton"); + gDeleteButton = document.getElementById("deleteButton"); + gCopyToNewButton = document.getElementById("copyToNewButton"); + gTopButton = document.getElementById("reorderTopButton"); + gUpButton = document.getElementById("reorderUpButton"); + gDownButton = document.getElementById("reorderDownButton"); + gBottomButton = document.getElementById("reorderBottomButton"); + gSearchBox = document.getElementById("searchBox"); + gRunFiltersFolder = document.getElementById("runFiltersFolder"); + gRunFiltersButton = document.getElementById("runFiltersButton"); + gFilterBundle = document.getElementById("bundle_filter"); + + updateButtons(); + + initNewToolbarButtons(document.querySelector("#newButton toolbarbutton")); + initNewToolbarButtons(document.querySelector("#newButton dropmarker")); + document + .getElementById("filterActionButtons") + .addEventListener("keypress", event => onFilterActionButtonKeyPress(event)); + + processWindowArguments(window.arguments[0]); + + // Don't change width after initial layout, so buttons stay within the dialog. + gRunFiltersFolder.style.maxWidth = + gRunFiltersFolder.getBoundingClientRect().width + "px"; + + Services.obs.addObserver( + filterEditorQuitObserver, + "quit-application-requested" + ); +} +/** + * Set up the toolbarbutton to have an index and an EvenListener for proper + * keyboard navigation. + * + * @param {XULElement} newToolbarbutton - The toolbarbutton that needs to be + * initialized. + */ +function initNewToolbarButtons(newToolbarbutton) { + newToolbarbutton.setAttribute("tabindex", "0"); + newToolbarbutton.setAttribute( + "id", + newToolbarbutton.parentNode.id + newToolbarbutton.tagName + ); +} + +/** + * Processes arguments sent to this dialog when opened or refreshed. + * + * @param aArguments An object having members representing the arguments. + * { arg1: value1, arg2: value2, ... } + */ +function processWindowArguments(aArguments) { + // If a specific folder was requested, try to select it + // if we don't already show its server. + if ( + !gServerMenu._folder || + ("folder" in aArguments && + aArguments.folder != gServerMenu._folder && + aArguments.folder.rootFolder != gServerMenu._folder) + ) { + let wantedFolder; + if ("folder" in aArguments) { + wantedFolder = aArguments.folder; + } + + // Get the folder where filters should be defined, if that server + // can accept filters. + let firstItem = getFilterFolderForSelection(wantedFolder); + + // If the selected server cannot have filters, get the default server + // If the default server cannot have filters, check all accounts + // and get a server that can have filters. + if (!firstItem) { + firstItem = getServerThatCanHaveFilters().rootFolder; + } + + if (firstItem) { + setFilterFolder(firstItem); + } + + if (wantedFolder) { + setRunFolder(wantedFolder); + } + } else { + // If we didn't change folder still redraw the list + // to show potential new filters if we were called for refresh. + rebuildFilterList(); + } + + // If a specific filter was requested, try to select it. + if ("filter" in aArguments) { + selectFilter(aArguments.filter); + } +} + +/** + * This is called from OpenOrFocusWindow() if the dialog is already open. + * New filters could have been created by operations outside the dialog. + * + * @param aArguments An object of arguments having the same format + * as window.arguments[0]. + */ +function refresh(aArguments) { + // As we really don't know what has changed, clear the search box + // undonditionally so that the changed/added filters are surely visible. + resetSearchBox(); + + processWindowArguments(aArguments); +} + +function CanRunFiltersAfterTheFact(aServer) { + // filter after the fact is implement using search + // so if you can't search, you can't filter after the fact + return aServer.canSearchMessages; +} + +/** + * Change the root server for which we are managing filters. + * + * @param msgFolder The nsIMsgFolder server containing filters + * (or a folder for NNTP server). + */ +function setFilterFolder(msgFolder) { + if (!msgFolder || msgFolder == gServerMenu._folder) { + return; + } + + // Save the current filters to disk before switching because + // the dialog may be closed and we'll lose current filters. + if (gCurrentFilterList) { + gCurrentFilterList.saveToDefaultFile(); + } + + // Setting this attribute should go away in bug 473009. + gServerMenu._folder = msgFolder; + // Calling this should go away in bug 802609. + gServerMenu.menupopup.selectFolder(msgFolder); + + // Calling getEditableFilterList will detect any errors in msgFilterRules.dat, + // backup the file, and alert the user. + gCurrentFilterList = msgFolder.getEditableFilterList(gFilterListMsgWindow); + rebuildFilterList(); + + // Select the first item in the list, if there is one. + if (gFilterListbox.itemCount > 0) { + gFilterListbox.selectItem(gFilterListbox.getItemAtIndex(0)); + } + + // This will get the deferred to account root folder, if server is deferred. + // We intentionally do this after setting the current server, as we want + // that to refer to the rootFolder for the actual server, not the + // deferred-to server, as current server is really a proxy for the + // server whose filters we are editing. But below here we are managing + // where the filters will get applied, which is on the deferred-to server. + msgFolder = msgFolder.server.rootMsgFolder; + + // root the folder picker to this server + let runMenu = gRunFiltersFolder.menupopup; + runMenu._teardown(); + runMenu._parentFolder = msgFolder; + runMenu._ensureInitialized(); + + let canFilterAfterTheFact = CanRunFiltersAfterTheFact(msgFolder.server); + gRunFiltersFolder.disabled = !canFilterAfterTheFact; + gRunFiltersButton.disabled = !canFilterAfterTheFact; + document.getElementById("folderPickerPrefix").disabled = + !canFilterAfterTheFact; + + if (canFilterAfterTheFact) { + let wantedFolder = null; + // For a given server folder, get the default run target folder or show + // "Choose Folder". + if (!msgFolder.isServer) { + wantedFolder = msgFolder; + } else { + try { + switch (msgFolder.server.type) { + case "nntp": + // For NNTP select the subscribed newsgroup. + wantedFolder = gServerMenu._folder; + break; + case "rss": + // Show "Choose Folder" for feeds. + wantedFolder = null; + break; + case "imap": + case "pop3": + case "none": + // Find Inbox for IMAP and POP or Local Folders, + // show "Choose Folder" if not found. + wantedFolder = msgFolder.rootFolder.getFolderWithFlags( + Ci.nsMsgFolderFlags.Inbox + ); + break; + default: + // For other account types we don't know what's good to select, + // so show "Choose Folder". + wantedFolder = null; + } + } catch (e) { + console.error( + "Failed to select a suitable folder to run filters on: " + e + ); + wantedFolder = null; + } + } + + // Select a useful first folder for the server. + setRunFolder(wantedFolder); + } +} + +/** + * Select a folder on which filters are to be run. + * + * @param aFolder nsIMsgFolder folder to select. + */ +function setRunFolder(aFolder) { + // Setting this attribute should go away in bug 473009. + gRunFiltersFolder._folder = aFolder; + // Calling this should go away in bug 802609. + gRunFiltersFolder.menupopup.selectFolder(gRunFiltersFolder._folder); + updateButtons(); +} + +/** + * Toggle enabled state of a filter, in both the filter properties and the UI. + * + * @param aFilterItem an item (row) of the filter list to be toggled + */ +function toggleFilter(aFilterItem, aSetForEvent) { + let filter = aFilterItem._filter; + if (filter.unparseable && !filter.enabled) { + Services.prompt.alert( + window, + null, + gFilterBundle.getFormattedString("cannotEnableIncompatFilter", [ + document.getElementById("bundle_brand").getString("brandShortName"), + ]) + ); + return; + } + filter.enabled = aSetForEvent === undefined ? !filter.enabled : aSetForEvent; + + // Now update the checkbox + if (aSetForEvent === undefined) { + aFilterItem.firstElementChild.nextElementSibling.checked = filter.enabled; + } + // For accessibility set the checked state on listitem + aFilterItem.setAttribute("aria-checked", filter.enabled); +} + +/** + * Selects a specific filter in the filter list. + * The listbox view is scrolled to the corresponding item. + * + * @param aFilter The nsIMsgFilter to select. + * + * @returns true/false indicating whether the filter was found and selected. + */ +function selectFilter(aFilter) { + if (currentFilter() == aFilter) { + return true; + } + + resetSearchBox(aFilter); + + let filterCount = gCurrentFilterList.filterCount; + for (let i = 0; i < filterCount; i++) { + if (gCurrentFilterList.getFilterAt(i) == aFilter) { + gFilterListbox.ensureIndexIsVisible(i); + gFilterListbox.selectedIndex = i; + return true; + } + } + return false; +} + +/** + * Returns the currently selected filter. If multiple filters are selected, + * returns the first one. If none are selected, returns null. + */ +function currentFilter() { + let currentItem = gFilterListbox.selectedItem; + return currentItem ? currentItem._filter : null; +} + +function onEditFilter() { + if (gEditButton.disabled) { + return; + } + + let selectedFilter = currentFilter(); + if (!selectedFilter) { + return; + } + + let args = { filter: selectedFilter, filterList: gCurrentFilterList }; + + window.openDialog( + "chrome://messenger/content/FilterEditor.xhtml", + "FilterEditor", + "chrome,modal,titlebar,resizable,centerscreen", + args + ); + + if ("refresh" in args && args.refresh) { + // reset search if edit was okay (name change might lead to hidden entry!) + resetSearchBox(selectedFilter); + rebuildFilterList(); + } +} + +/** + * Handler function for the 'New...' buttons. + * Opens the filter dialog for creating a new filter. + */ +function onNewFilter() { + calculatePositionAndShowCreateFilterDialog({}); +} + +/** + * Handler function for the 'Copy...' button. + * Opens the filter dialog for copying the selected filter. + */ +function onCopyToNewFilter() { + if (gCopyToNewButton.disabled) { + return; + } + + let selectedFilter = currentFilter(); + if (!selectedFilter) { + return; + } + + let args = { copiedFilter: selectedFilter }; + + calculatePositionAndShowCreateFilterDialog(args); +} + +/** + * Calculates the position for inserting the new filter, + * and then displays the create dialog. + * + * @param args The object containing the arguments for the dialog, + * passed to the filterEditorOnLoad() function. + * It will be augmented with the insertion position + * and global filters list properties by this function. + */ +function calculatePositionAndShowCreateFilterDialog(args) { + let selectedFilter = currentFilter(); + // If no filter is selected use the first position. + let position = 0; + if (selectedFilter) { + // Get the position in the unfiltered list. + // - this is where the new filter should be inserted! + let filterCount = gCurrentFilterList.filterCount; + for (let i = 0; i < filterCount; i++) { + if (gCurrentFilterList.getFilterAt(i) == selectedFilter) { + position = i; + break; + } + } + } + args.filterPosition = position; + + args.filterList = gCurrentFilterList; + + window.openDialog( + "chrome://messenger/content/FilterEditor.xhtml", + "FilterEditor", + "chrome,modal,titlebar,resizable,centerscreen", + args + ); + + if ("refresh" in args && args.refresh) { + // On success: reset the search box if necessary! + resetSearchBox(args.newFilter); + rebuildFilterList(); + + // Select the new filter, it is at the position of previous selection. + gFilterListbox.selectItem(gFilterListbox.getItemAtIndex(position)); + if (currentFilter() != args.newFilter) { + console.error("Filter created at an unexpected position!"); + } + } +} + +/** + * Delete selected filters. + * 'Selected' is not to be confused with active (checkbox checked) + */ +function onDeleteFilter() { + if (gDeleteButton.disabled) { + return; + } + + let items = gFilterListbox.selectedItems; + if (!items.length) { + return; + } + + let checkValue = { value: false }; + if ( + Services.prefs.getBoolPref("mailnews.filters.confirm_delete") && + Services.prompt.confirmEx( + window, + null, + gFilterBundle.getString("deleteFilterConfirmation"), + Services.prompt.STD_YES_NO_BUTTONS, + "", + "", + "", + gFilterBundle.getString("dontWarnAboutDeleteCheckbox"), + checkValue + ) + ) { + return; + } + + if (checkValue.value) { + Services.prefs.setBoolPref("mailnews.filters.confirm_delete", false); + } + + // Save filter position before the first selected one. + let newSelectionIndex = gFilterListbox.selectedIndex - 1; + + // Must reverse the loop, as the items list shrinks when we delete. + for (let index = items.length - 1; index >= 0; --index) { + let item = items[index]; + gCurrentFilterList.removeFilter(item._filter); + item.remove(); + } + updateCountBox(); + + // Select filter above previously selected if one existed, otherwise the first one. + if (newSelectionIndex == -1 && gFilterListbox.itemCount > 0) { + newSelectionIndex = 0; + } + if (newSelectionIndex > -1) { + gFilterListbox.selectedIndex = newSelectionIndex; + updateViewPosition(-1); + } +} + +/** + * Move filter one step up in visible list. + */ +function onUp(event) { + moveFilter(msgMoveMotion.Up); +} + +/** + * Move filter one step down in visible list. + */ +function onDown(event) { + moveFilter(msgMoveMotion.Down); +} + +/** + * Move filter to bottom for long filter lists. + */ +function onTop(evt) { + moveFilter(msgMoveMotion.Top); +} + +/** + * Move filter to top for long filter lists. + */ +function onBottom(evt) { + moveFilter(msgMoveMotion.Bottom); +} + +/** + * Moves a singular selected filter up or down either 1 increment or to the + * top/bottom. This acts on the visible filter list only which means that: + * + * - when moving up or down "1" the filter may skip one or more other + * filters (which are currently not visible) - this will also lead + * to the "related" filters (e.g search filters containing 'moz') + * being grouped more closely together + * - moveTop / moveBottom + * this is currently moving to the top/bottom of the absolute list + * but it would be better if it moved "just as far as necessary" + * which would further "compact" related filters + * + * @param motion + * msgMoveMotion.Up, msgMoveMotion.Down, msgMoveMotion.Top, msgMoveMotion.Bottom + */ +function moveFilter(motion) { + // At the moment, do not allow moving groups of filters. + let selectedFilter = currentFilter(); + if (!selectedFilter) { + return; + } + + var relativeStep = 0; + var moveFilterNative = null; + + switch (motion) { + case msgMoveMotion.Top: + if (selectedFilter) { + gCurrentFilterList.removeFilter(selectedFilter); + gCurrentFilterList.insertFilterAt(0, selectedFilter); + rebuildFilterList(); + } + return; + case msgMoveMotion.Bottom: + if (selectedFilter) { + gCurrentFilterList.removeFilter(selectedFilter); + gCurrentFilterList.insertFilterAt( + gCurrentFilterList.filterCount, + selectedFilter + ); + rebuildFilterList(); + } + return; + case msgMoveMotion.Up: + relativeStep = -1; + moveFilterNative = Ci.nsMsgFilterMotion.up; + break; + case msgMoveMotion.Down: + relativeStep = +1; + moveFilterNative = Ci.nsMsgFilterMotion.down; + break; + } + + if (!gSearchBox.value) { + // use legacy move filter code: up, down; only if searchBox is empty + moveCurrentFilter(moveFilterNative); + return; + } + + let nextIndex = gFilterListbox.selectedIndex + relativeStep; + let nextFilter = gFilterListbox.getItemAtIndex(nextIndex)._filter; + + gCurrentFilterList.removeFilter(selectedFilter); + + // Find the index of the filter we want to insert at. + let newIndex = -1; + let filterCount = gCurrentFilterList.filterCount; + for (let i = 0; i < filterCount; i++) { + if (gCurrentFilterList.getFilterAt(i) == nextFilter) { + newIndex = i; + break; + } + } + + if (motion == msgMoveMotion.Down) { + newIndex += relativeStep; + } + + gCurrentFilterList.insertFilterAt(newIndex, selectedFilter); + + rebuildFilterList(); +} + +function viewLog() { + var args = { filterList: gCurrentFilterList }; + + window.openDialog( + "chrome://messenger/content/viewLog.xhtml", + "FilterLog", + "chrome,modal,titlebar,resizable,centerscreen", + args + ); +} + +function onFilterUnload() { + gCurrentFilterList.saveToDefaultFile(); + Services.obs.removeObserver( + filterEditorQuitObserver, + "quit-application-requested" + ); + + gFilterListMsgWindow.closeWindow(); +} + +function onFilterClose() { + if ( + gRunFiltersButton.getAttribute("label") == + gRunFiltersButton.getAttribute("stoplabel") + ) { + let promptTitle = gFilterBundle.getString("promptTitle"); + let promptMsg = gFilterBundle.getString("promptMsg"); + let stopButtonLabel = gFilterBundle.getString("stopButtonLabel"); + let continueButtonLabel = gFilterBundle.getString("continueButtonLabel"); + + let result = Services.prompt.confirmEx( + window, + promptTitle, + promptMsg, + Services.prompt.BUTTON_TITLE_IS_STRING * Services.prompt.BUTTON_POS_0 + + Services.prompt.BUTTON_TITLE_IS_STRING * Services.prompt.BUTTON_POS_1, + continueButtonLabel, + stopButtonLabel, + null, + null, + { value: 0 } + ); + + if (result) { + gFilterListMsgWindow.StopUrls(); + } else { + return false; + } + } + + return true; +} + +function runSelectedFilters() { + // if run button has "stop" label, do stop. + if ( + gRunFiltersButton.getAttribute("label") == + gRunFiltersButton.getAttribute("stoplabel") + ) { + gFilterListMsgWindow.StopUrls(); + return; + } + + let folder = + gRunFiltersFolder._folder || gRunFiltersFolder.selectedItem._folder; + if (!folder) { + return; + } + + let filterList = MailServices.filters.getTempFilterList(folder); + + // make sure the tmp filter list uses the real filter list log stream + filterList.loggingEnabled = gCurrentFilterList.loggingEnabled; + filterList.logStream = gCurrentFilterList.logStream; + + let index = 0; + for (let item of gFilterListbox.selectedItems) { + filterList.insertFilterAt(index++, item._filter); + } + + MailServices.filters.applyFiltersToFolders( + filterList, + [folder], + gFilterListMsgWindow + ); +} + +function moveCurrentFilter(motion) { + let filter = currentFilter(); + if (!filter) { + return; + } + + gCurrentFilterList.moveFilter(filter, motion); + rebuildFilterList(); +} + +/** + * Redraws the list of filters. Takes the search box value into account. + * + * This function should perform very fast even in case of high number of filters. + * Therefore there are some optimizations (e.g. listelement.itemChildren[] instead of + * list.getItemAtIndex()), that favour speed vs. semantical perfection. + */ +function rebuildFilterList() { + // Get filters that match the search box. + let aTempFilterList = onFindFilter(); + + let searchBoxFocus = false; + let activeElement = document.activeElement; + + // Find if the currently focused element is a child inside the search box + // (probably html:input). Traverse up the parents until the first element + // with an ID is found. If it is not searchBox, return false. + while (activeElement != null) { + if (activeElement == gSearchBox) { + searchBoxFocus = true; + break; + } else if (activeElement.id) { + searchBoxFocus = false; + break; + } + activeElement = activeElement.parentNode; + } + + // Make a note of which filters were previously selected + let selectedNames = []; + for (let i = 0; i < gFilterListbox.selectedItems.length; i++) { + selectedNames.push(gFilterListbox.selectedItems[i]._filter.filterName); + } + + // Save scroll position so we can try to restore it later. + // Doesn't work when the list is rebuilt after search box condition changed. + let firstVisibleRowIndex = gFilterListbox.getIndexOfFirstVisibleRow(); + + // listbox.xml seems to cache the value of the first selected item in a + // range at _selectionStart. The old value though is now obsolete, + // since we will recreate all of the elements. We need to clear this, + // and one way to do this is with a call to clearSelection. This might be + // ugly from an accessibility perspective, since it fires an onSelect event. + gFilterListbox.clearSelection(); + + let listitem, nameCell, enabledCell, filter; + let filterCount = gCurrentFilterList.filterCount; + let listitemCount = gFilterListbox.itemCount; + let listitemIndex = 0; + let tempFilterListLength = aTempFilterList ? aTempFilterList.length - 1 : 0; + for (let i = 0; i < filterCount; i++) { + if (aTempFilterList && listitemIndex > tempFilterListLength) { + break; + } + + filter = gCurrentFilterList.getFilterAt(i); + if (aTempFilterList && aTempFilterList[listitemIndex] != i) { + continue; + } + + if (listitemCount > listitemIndex) { + // If there is a free existing listitem, reuse it. + // Use .itemChildren[] instead of .getItemAtIndex() as it is much faster. + listitem = gFilterListbox.itemChildren[listitemIndex]; + nameCell = listitem.firstElementChild; + enabledCell = nameCell.nextElementSibling; + } else { + // If there are not enough listitems in the list, create a new one. + listitem = document.createXULElement("richlistitem"); + listitem.setAttribute("align", "center"); + listitem.setAttribute("role", "checkbox"); + nameCell = document.createXULElement("label"); + nameCell.setAttribute("flex", "1"); + nameCell.setAttribute("crop", "end"); + enabledCell = document.createXULElement("checkbox"); + enabledCell.setAttribute("style", "padding-inline-start: 25px;"); + enabledCell.addEventListener("CheckboxStateChange", onFilterClick, true); + listitem.appendChild(nameCell); + listitem.appendChild(enabledCell); + gFilterListbox.appendChild(listitem); + // We have to attach this listener to the listitem, even though we only care + // about clicks on the enabledCell. However, attaching to that item doesn't + // result in any events actually getting received. + listitem.addEventListener("dblclick", onFilterDoubleClick, true); + } + // For accessibility set the label on listitem. + listitem.setAttribute("label", filter.filterName); + // Set the listitem values to represent the current filter. + nameCell.setAttribute("value", filter.filterName); + if (filter.enabled) { + enabledCell.setAttribute("checked", "true"); + } else { + enabledCell.removeAttribute("checked"); + } + listitem.setAttribute("aria-checked", filter.enabled); + listitem._filter = filter; + + if (selectedNames.includes(filter.filterName)) { + gFilterListbox.addItemToSelection(listitem); + } + + listitemIndex++; + } + // Remove any superfluous listitems, if the number of filters shrunk. + for (let i = listitemCount - 1; i >= listitemIndex; i--) { + gFilterListbox.lastChild.remove(); + } + + updateViewPosition(firstVisibleRowIndex); + updateCountBox(); + + // If before rebuilding the list the searchbox was focused, focus it again. + // In any other case, focus the list. + if (searchBoxFocus) { + gSearchBox.focus(); + } else { + gFilterListbox.focus(); + } +} + +function updateViewPosition(firstVisibleRowIndex) { + if (firstVisibleRowIndex == -1) { + firstVisibleRowIndex = gFilterListbox.getIndexOfFirstVisibleRow(); + } + + // Restore to the extent possible the scroll position. + if (firstVisibleRowIndex && gFilterListbox.itemCount) { + gFilterListbox.ensureElementIsVisible( + gFilterListbox.getItemAtIndex( + Math.min(firstVisibleRowIndex, gFilterListbox.itemCount - 1) + ), + true + ); + } + + if (gFilterListbox.selectedCount) { + // Make sure that at least the first selected item is visible. + gFilterListbox.ensureElementIsVisible(gFilterListbox.selectedItems[0]); + + // The current item should be the first selected item, so that keyboard + // selection extension can work. + gFilterListbox.currentItem = gFilterListbox.selectedItems[0]; + } + + updateButtons(); +} + +/** + * Try to only enable buttons that make sense + * - moving filters is currently only enabled for single selection + * also movement is restricted by searchBox and current selection position + * - edit only for single filters + * - delete / run only for one or more selected filters + */ +function updateButtons() { + var numFiltersSelected = gFilterListbox.selectedItems.length; + var oneFilterSelected = numFiltersSelected == 1; + + // "edit" is disabled when not exactly one filter is selected + // or if we couldn't parse that filter + let disabled = !oneFilterSelected || currentFilter().unparseable; + gEditButton.disabled = disabled; + + // "copy" is the same as "edit" + gCopyToNewButton.disabled = disabled; + + // "delete" only disabled when no filters are selected + gDeleteButton.disabled = !numFiltersSelected; + + // we can run multiple filters on a folder + // so only disable this UI if no filters are selected + document.getElementById("folderPickerPrefix").disabled = !numFiltersSelected; + gRunFiltersFolder.disabled = !numFiltersSelected; + gRunFiltersButton.disabled = + !numFiltersSelected || !gRunFiltersFolder._folder; + // "up" and "top" enabled only if one filter is selected, and it's not the first + // don't use gFilterListbox.currentIndex here, it's buggy when we've just changed the + // children in the list (via rebuildFilterList) + disabled = !( + oneFilterSelected && + gFilterListbox.getSelectedItem(0) != gFilterListbox.getItemAtIndex(0) + ); + gUpButton.disabled = disabled; + gTopButton.disabled = disabled; + + // "down" and "bottom" enabled only if one filter is selected, + // and it's not the last one + disabled = !( + oneFilterSelected && + gFilterListbox.selectedIndex < gFilterListbox.itemCount - 1 + ); + gDownButton.disabled = disabled; + gBottomButton.disabled = disabled; +} + +/** + * Given a selected folder, returns the folder where filters should + * be defined (the root folder except for news) if the server can + * accept filters. + * + * @param nsIMsgFolder aFolder - selected folder, from window args + * @returns an nsIMsgFolder where the filter is defined + */ +function getFilterFolderForSelection(aFolder) { + let rootFolder = aFolder && aFolder.server ? aFolder.server.rootFolder : null; + if (rootFolder && rootFolder.isServer && rootFolder.server.canHaveFilters) { + return aFolder.server.type == "nntp" ? aFolder : rootFolder; + } + + return null; +} + +/** + * If the selected server cannot have filters, get the default server. + * If the default server cannot have filters, check all accounts + * and get a server that can have filters. + * + * @returns an nsIMsgIncomingServer + */ +function getServerThatCanHaveFilters() { + let defaultAccount = MailServices.accounts.defaultAccount; + if (defaultAccount) { + let defaultIncomingServer = defaultAccount.incomingServer; + // Check to see if default server can have filters. + if (defaultIncomingServer.canHaveFilters) { + return defaultIncomingServer; + } + } + + // If it cannot, check all accounts to find a server + // that can have filters. + return MailServices.accounts.allServers.find(server => server.canHaveFilters); +} + +function onFilterClick(event) { + // This is called after the clicked checkbox changed state + // so this.checked is the right state we want to toggle to. + toggleFilter(this.parentNode, this.checked); +} + +function onFilterDoubleClick(event) { + // we only care about button 0 (left click) events + if (event.button != 0) { + return; + } + + onEditFilter(); +} + +/** + * Handles the keypress event on the filter list dialog. + * + * @param {Event} event - The keypress DOMEvent. + */ +function onFilterActionButtonKeyPress(event) { + if ( + event.key == "Enter" || + (event.key == " " && event.target.hasAttribute("type")) + ) { + event.preventDefault(); + + if ( + event.target.classList.contains("toolbarbutton-menubutton-dropmarker") + ) { + document + .getElementById("newFilterMenupopup") + .openPopup(event.target.parentNode, "after_end", { + triggerEvent: event, + }); + return; + } + event.target.click(); + } +} + +function onFilterListKeyPress(aEvent) { + if (aEvent.keyCode) { + switch (aEvent.keyCode) { + case KeyEvent.DOM_VK_INSERT: + if (!document.getElementById("newButton").disabled) { + onNewFilter(); + } + break; + case KeyEvent.DOM_VK_DELETE: + if (!document.getElementById("deleteButton").disabled) { + onDeleteFilter(); + } + break; + case KeyEvent.DOM_VK_RETURN: + if (!document.getElementById("editButton").disabled) { + onEditFilter(); + } + break; + } + } else if (!aEvent.ctrlKey && !aEvent.altKey && !aEvent.metaKey) { + switch (aEvent.charCode) { + case KeyEvent.DOM_VK_SPACE: + for (let item of gFilterListbox.selectedItems) { + toggleFilter(item); + } + break; + default: + gSearchBox.focus(); + gSearchBox.value = String.fromCharCode(aEvent.charCode); + } + } +} + +/** + * Decides if the given filter matches the given keyword. + * + * @param aFilter nsIMsgFilter to check + * @param aKeyword the string to find in the filter name + * + * @returns True if the filter name contains the searched keyword. + Otherwise false. In the future this may be extended to match + other filter attributes. + */ +function filterSearchMatch(aFilter, aKeyword) { + return aFilter.filterName.toLocaleLowerCase().includes(aKeyword); +} + +/** + * Called from rebuildFilterList when the list needs to be redrawn. + * + * @returns Uses the search term in search box, to produce an array of + * row (filter) numbers (indexes) that match the search term. + */ +function onFindFilter() { + let keyWord = gSearchBox.value.toLocaleLowerCase(); + + // If searchbox is empty, just return and let rebuildFilterList + // create an unfiltered list. + if (!keyWord) { + return null; + } + + // Rematch everything in the list, remove what doesn't match the search box. + let rows = gCurrentFilterList.filterCount; + let matchingFilterList = []; + // Use the full gCurrentFilterList, not the filterList listbox, + // which may already be filtered. + for (let i = 0; i < rows; i++) { + if (filterSearchMatch(gCurrentFilterList.getFilterAt(i), keyWord)) { + matchingFilterList.push(i); + } + } + + return matchingFilterList; +} + +/** + * Clear the search term in the search box if needed. + * + * @param aFilter If this nsIMsgFilter matches the search term, + * do not reset the box. If this is null, + * reset unconditionally. + */ +function resetSearchBox(aFilter) { + let keyword = gSearchBox.value.toLocaleLowerCase(); + if (keyword && (!aFilter || !filterSearchMatch(aFilter, keyword))) { + gSearchBox.reset(); + } +} + +/** + * Display "1 item", "11 items" or "4 of 10" if list is filtered via search box. + */ +function updateCountBox() { + let countBox = document.getElementById("countBox"); + let sum = gCurrentFilterList.filterCount; + let len = gFilterListbox.itemCount; + + if (len == sum) { + // "N items" + countBox.value = PluralForm.get( + len, + gFilterBundle.getString("filterCountItems") + ).replace("#1", len); + return; + } + + // "N of M" + countBox.value = gFilterBundle.getFormattedString( + "filterCountVisibleOfTotal", + [len, sum] + ); +} |