summaryrefslogtreecommitdiffstats
path: root/comm/mail/base/content/FilterListDialog.js
diff options
context:
space:
mode:
Diffstat (limited to 'comm/mail/base/content/FilterListDialog.js')
-rw-r--r--comm/mail/base/content/FilterListDialog.js1162
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]
+ );
+}